Skip to content

Commit 80be020

Browse files
committed
feat(signer): support EdDSA for the local token signer
Signed-off-by: somaz <genius5711@gmail.com>
1 parent 0969327 commit 80be020

10 files changed

Lines changed: 257 additions & 9 deletions

File tree

cmd/dex/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,7 @@ func normalizeLocalSignerConfig(c *signer.LocalConfig) error {
544544
c.Algorithm = jose.RS256
545545
return nil
546546
}
547-
if c.Algorithm == jose.RS256 || c.Algorithm == jose.ES256 {
547+
if c.Algorithm == jose.RS256 || c.Algorithm == jose.ES256 || c.Algorithm == jose.EdDSA {
548548
return nil
549549
}
550550
return fmt.Errorf("unsupported local signer algorithm %q", c.Algorithm)

server/introspectionhandler.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,12 @@ func (s *Server) guessTokenType(ctx context.Context, token string) (TokenTypeEnu
151151
SkipExpiryCheck: true,
152152
SkipIssuerCheck: true,
153153

154+
// go-oidc validates the token's alg against SupportedSigningAlgs before
155+
// honoring InsecureSkipSignatureCheck; without this it defaults to RS256
156+
// only and rejects ES256/EdDSA access tokens, mis-classifying them as
157+
// refresh tokens.
158+
SupportedSigningAlgs: supportedSigningAlgStrings(),
159+
154160
// We skip signature checks to avoid database calls;
155161
InsecureSkipSignatureCheck: true,
156162
}
@@ -245,7 +251,10 @@ func (s *Server) introspectRefreshToken(ctx context.Context, token string) (*Int
245251
}
246252

247253
func (s *Server) introspectAccessToken(ctx context.Context, token string) (*Introspection, error) {
248-
verifier := oidc.NewVerifier(s.issuerURL.String(), &signerKeySet{s.signer}, &oidc.Config{SkipClientIDCheck: true})
254+
verifier := oidc.NewVerifier(s.issuerURL.String(), &signerKeySet{s.signer}, &oidc.Config{
255+
SupportedSigningAlgs: supportedSigningAlgStrings(),
256+
SkipClientIDCheck: true,
257+
})
249258
idToken, err := verifier.Verify(ctx, token)
250259
if err != nil {
251260
return nil, newIntrospectInactiveTokenError()

server/introspectionhandler_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@ import (
1212
"testing"
1313
"time"
1414

15+
"github.com/go-jose/go-jose/v4"
1516
"github.com/stretchr/testify/require"
1617

1718
"github.com/dexidp/dex/server/internal"
19+
"github.com/dexidp/dex/server/signer"
1820
"github.com/dexidp/dex/storage"
21+
"github.com/dexidp/dex/storage/memory"
1922
)
2023

2124
func toJSON(a interface{}) string {
@@ -362,6 +365,75 @@ func TestHandleIntrospect(t *testing.T) {
362365
}
363366
}
364367

368+
// TestHandleIntrospectEdDSA drives the full introspection endpoint with an
369+
// EdDSA-signed access token. It is a regression test for guessTokenType: without
370+
// SupportedSigningAlgs on its verifier config, go-oidc defaults to RS256 only and
371+
// rejects the EdDSA token, so the access token is mis-classified as a refresh
372+
// token and introspection returns active: false.
373+
func TestHandleIntrospectEdDSA(t *testing.T) {
374+
t0 := time.Now()
375+
376+
ctx := t.Context()
377+
378+
now := func() time.Time { return t0 }
379+
380+
logger := newLogger(t)
381+
382+
refreshTokenPolicy, err := NewRefreshTokenPolicy(logger, false, "", "24h", "")
383+
if err != nil {
384+
t.Fatalf("failed to prepare rotation policy: %v", err)
385+
}
386+
refreshTokenPolicy.now = now
387+
388+
localConfig := signer.LocalConfig{
389+
KeysRotationPeriod: time.Hour.String(),
390+
Algorithm: jose.EdDSA,
391+
}
392+
eddsaSigner, err := localConfig.Open(ctx, memory.New(logger), time.Hour, now, logger)
393+
require.NoError(t, err)
394+
395+
httpServer, s := newTestServer(t, func(c *Config) {
396+
c.Issuer += "/non-root-path"
397+
c.RefreshTokenPolicy = refreshTokenPolicy
398+
c.Now = now
399+
c.Signer = eddsaSigner
400+
})
401+
defer httpServer.Close()
402+
403+
mockTestStorage(t, s.storage)
404+
405+
activeAccessToken, expiry, err := s.newIDToken(ctx, "test", storage.Claims{
406+
UserID: "1",
407+
Username: "jane",
408+
Email: "jane.doe@example.com",
409+
EmailVerified: true,
410+
Groups: []string{"a", "b"},
411+
}, []string{"openid", "email", "profile", "groups"}, "foo", "", "", "test", time.Time{})
412+
require.NoError(t, err)
413+
414+
// guessTokenType must recognize the EdDSA access token as an access token.
415+
tokenType, err := s.guessTokenType(ctx, activeAccessToken)
416+
require.NoError(t, err)
417+
require.Equal(t, AccessToken, tokenType)
418+
419+
// The full /token/introspect endpoint must report the EdDSA access token as active.
420+
data := url.Values{}
421+
data.Set("token", activeAccessToken)
422+
423+
u, err := url.Parse(s.issuerURL.String())
424+
require.NoError(t, err)
425+
u.Path = path.Join(u.Path, "token", "introspect")
426+
427+
req, _ := http.NewRequest("POST", u.String(), bytes.NewBufferString(data.Encode()))
428+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
429+
430+
rr := httptest.NewRecorder()
431+
s.ServeHTTP(rr, req)
432+
433+
require.Equal(t, 200, rr.Code)
434+
require.JSONEq(t, toJSON(getIntrospectionValue(s.issuerURL, t0, expiry, "access_token")), rr.Body.String())
435+
}
436+
365437
func TestIntrospectErrHelper(t *testing.T) {
366438
t0 := time.Now()
367439

server/oauth2.go

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,9 @@ var hashForSigAlg = map[jose.SignatureAlgorithm]func() hash.Hash{
229229
jose.ES256: sha256.New,
230230
jose.ES384: sha512.New384,
231231
jose.ES512: sha512.New,
232+
// EdDSA (Ed25519) uses SHA-512 internally, so the at_hash/c_hash is
233+
// computed over SHA-512 as well.
234+
jose.EdDSA: sha512.New,
232235
}
233236

234237
// Compute an at_hash from a raw access token and a signature algorithm
@@ -447,7 +450,8 @@ func (s *Server) newIDToken(ctx context.Context, clientID string, claims storage
447450
// Returns the verified token so callers can extract Subject, Audience, etc.
448451
func (s *Server) validateIDTokenHint(ctx context.Context, hint string) (*oidc.IDToken, error) {
449452
verifier := oidc.NewVerifier(s.issuerURL.String(), &signerKeySet{s.signer}, &oidc.Config{
450-
SkipExpiryCheck: true,
453+
SupportedSigningAlgs: supportedSigningAlgStrings(),
454+
SkipExpiryCheck: true,
451455
// SkipClientIDCheck is set because the hint may originate from any client that
452456
// Dex issued a token to — the caller does not know the expected audience in advance.
453457
// The signature verification via signerKeySet already guarantees the token was
@@ -763,13 +767,33 @@ func validateConnectorID(connectors []storage.Connector, connectorID string) boo
763767
return false
764768
}
765769

770+
// supportedSigningAlgs lists every JWS signing algorithm a Dex signer (local or
771+
// Vault) can produce. Verifiers for Dex-issued tokens must allow all of them:
772+
// otherwise go-oidc's IDTokenVerifier.Verify defaults to RS256 only and rejects
773+
// ES256/EdDSA tokens before signerKeySet.VerifySignature ever runs.
774+
var supportedSigningAlgs = []jose.SignatureAlgorithm{
775+
jose.RS256, jose.RS384, jose.RS512,
776+
jose.ES256, jose.ES384, jose.ES512,
777+
jose.EdDSA,
778+
}
779+
780+
// supportedSigningAlgStrings returns supportedSigningAlgs as strings for use in
781+
// oidc.Config.SupportedSigningAlgs.
782+
func supportedSigningAlgStrings() []string {
783+
algs := make([]string, len(supportedSigningAlgs))
784+
for i, alg := range supportedSigningAlgs {
785+
algs[i] = string(alg)
786+
}
787+
return algs
788+
}
789+
766790
// signerKeySet implements the oidc.KeySet interface backed by the Dex signer
767791
type signerKeySet struct {
768792
signer signer.Signer
769793
}
770794

771795
func (s *signerKeySet) VerifySignature(ctx context.Context, jwt string) (payload []byte, err error) {
772-
jws, err := jose.ParseSigned(jwt, []jose.SignatureAlgorithm{jose.RS256, jose.RS384, jose.RS512, jose.ES256, jose.ES384, jose.ES512})
796+
jws, err := jose.ParseSigned(jwt, supportedSigningAlgs)
773797
if err != nil {
774798
return nil, err
775799
}

server/oauth2_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"context"
55
"crypto/rand"
66
"crypto/rsa"
7+
"crypto/sha512"
8+
"encoding/base64"
79
"encoding/json"
810
"log/slog"
911
"net/http"
@@ -488,6 +490,16 @@ func TestAccessTokenHash(t *testing.T) {
488490
}
489491
}
490492

493+
func TestAccessTokenHashEdDSA(t *testing.T) {
494+
atHash, err := accessTokenHash(jose.EdDSA, googleAccessToken)
495+
require.NoError(t, err)
496+
497+
// EdDSA (Ed25519) uses SHA-512; at_hash is the base64url of the left-most half.
498+
sum := sha512.Sum512([]byte(googleAccessToken))
499+
want := base64.RawURLEncoding.EncodeToString(sum[:len(sum)/2])
500+
require.Equal(t, want, atHash)
501+
}
502+
491503
func TestValidRedirectURI(t *testing.T) {
492504
tests := []struct {
493505
client storage.Client
@@ -807,6 +819,31 @@ func TestSignerKeySetWithES256LocalSigner(t *testing.T) {
807819
require.Equal(t, []byte("payload"), payload)
808820
}
809821

822+
func TestSignerKeySetWithEdDSALocalSigner(t *testing.T) {
823+
ctx, cancel := context.WithCancel(context.Background())
824+
defer cancel()
825+
826+
logger := slog.New(slog.DiscardHandler)
827+
store := memory.New(logger)
828+
829+
localConfig := signer.LocalConfig{
830+
KeysRotationPeriod: time.Hour.String(),
831+
Algorithm: jose.EdDSA,
832+
}
833+
sig, err := localConfig.Open(ctx, store, time.Hour, time.Now, logger)
834+
require.NoError(t, err)
835+
836+
sig.Start(ctx)
837+
838+
jwt, err := sig.Sign(ctx, []byte("payload"))
839+
require.NoError(t, err)
840+
841+
keySet := &signerKeySet{signer: sig}
842+
payload, err := keySet.VerifySignature(ctx, jwt)
843+
require.NoError(t, err)
844+
require.Equal(t, []byte("payload"), payload)
845+
}
846+
810847
func TestRedirectedAuthErrHandler(t *testing.T) {
811848
tests := []struct {
812849
name string
@@ -1006,6 +1043,48 @@ func TestValidateIDTokenHint(t *testing.T) {
10061043
})
10071044
}
10081045

1046+
// TestValidateIDTokenHintEdDSA drives the real validateIDTokenHint verifier path
1047+
// with an EdDSA local signer. Without SupportedSigningAlgs on the oidc.Config,
1048+
// go-oidc defaults to RS256 only and rejects the EdDSA token as "malformed jwt".
1049+
func TestValidateIDTokenHintEdDSA(t *testing.T) {
1050+
ctx, cancel := context.WithCancel(context.Background())
1051+
defer cancel()
1052+
1053+
logger := slog.New(slog.DiscardHandler)
1054+
store := memory.New(logger)
1055+
1056+
localConfig := signer.LocalConfig{
1057+
KeysRotationPeriod: time.Hour.String(),
1058+
Algorithm: jose.EdDSA,
1059+
}
1060+
sig, err := localConfig.Open(ctx, store, time.Hour, time.Now, logger)
1061+
require.NoError(t, err)
1062+
sig.Start(ctx)
1063+
1064+
issuerURL, err := url.Parse("https://issuer.example.com")
1065+
require.NoError(t, err)
1066+
1067+
s := &Server{
1068+
signer: sig,
1069+
issuerURL: *issuerURL,
1070+
logger: slog.Default(),
1071+
}
1072+
1073+
payload, err := json.Marshal(idTokenClaims{
1074+
Issuer: "https://issuer.example.com",
1075+
Subject: "CgNmb28SA2Jhcg",
1076+
Expiry: time.Now().Add(time.Hour).Unix(),
1077+
})
1078+
require.NoError(t, err)
1079+
1080+
token, err := sig.Sign(ctx, payload)
1081+
require.NoError(t, err)
1082+
1083+
idToken, err := s.validateIDTokenHint(ctx, token)
1084+
require.NoError(t, err)
1085+
assert.Equal(t, "CgNmb28SA2Jhcg", idToken.Subject)
1086+
}
1087+
10091088
func TestNewIDTokenUsesStoredAlgorithmUntilNextRotation(t *testing.T) {
10101089
ctx, cancel := context.WithCancel(context.Background())
10111090
defer cancel()

server/signer/local.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ type LocalConfig struct {
1919
// Algorithm defines the signing algorithm used for newly generated local keys.
2020
// Changing it does not replace the current signing key immediately. The new
2121
// algorithm is applied when Dex generates the next signing key during rotation.
22-
// Supported values are RS256 and ES256.
22+
// Supported values are RS256, ES256, and EdDSA.
2323
Algorithm jose.SignatureAlgorithm `json:"algorithm"`
2424
}
2525

2626
func (c *LocalConfig) signingAlgorithm() (jose.SignatureAlgorithm, error) {
2727
if c.Algorithm == "" {
2828
return jose.RS256, nil
2929
}
30-
if c.Algorithm == jose.RS256 || c.Algorithm == jose.ES256 {
30+
if c.Algorithm == jose.RS256 || c.Algorithm == jose.ES256 || c.Algorithm == jose.EdDSA {
3131
return c.Algorithm, nil
3232
}
3333
return "", fmt.Errorf("unsupported local signer algorithm %q", c.Algorithm)

server/signer/local_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ func TestLocalSignerAlgorithm(t *testing.T) {
9797
cfg: LocalConfig{KeysRotationPeriod: time.Hour.String(), Algorithm: jose.ES256},
9898
want: jose.ES256,
9999
},
100+
{
101+
name: "EdDSA before first rotation",
102+
cfg: LocalConfig{KeysRotationPeriod: time.Hour.String(), Algorithm: jose.EdDSA},
103+
want: jose.EdDSA,
104+
},
100105
}
101106

102107
for _, tt := range tests {
@@ -126,6 +131,11 @@ func TestLocalSignerSignAndValidate(t *testing.T) {
126131
cfg: LocalConfig{KeysRotationPeriod: time.Hour.String(), Algorithm: jose.ES256},
127132
want: jose.ES256,
128133
},
134+
{
135+
name: "EdDSA",
136+
cfg: LocalConfig{KeysRotationPeriod: time.Hour.String(), Algorithm: jose.EdDSA},
137+
want: jose.EdDSA,
138+
},
129139
}
130140

131141
for _, tt := range tests {

server/signer/rotation.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"crypto"
66
"crypto/ecdsa"
7+
"crypto/ed25519"
78
"crypto/elliptic"
89
"crypto/rand"
910
"crypto/rsa"
@@ -34,7 +35,7 @@ type rotationStrategy struct {
3435
// Algorithm used for newly generated signing keys.
3536
algorithm jose.SignatureAlgorithm
3637

37-
// Local signer keys can be RSA or ECDSA depending on the configured algorithm.
38+
// Local signer keys can be RSA, ECDSA, or Ed25519 depending on the configured algorithm.
3839
key func() (crypto.Signer, error)
3940
}
4041

@@ -46,7 +47,7 @@ func rotationStrategyForAlgorithm(rotationFrequency, idTokenValidFor time.Durati
4647
idTokenValidFor: idTokenValidFor,
4748
algorithm: algorithm,
4849
}
49-
// Only RS256 and ES256 are supported for local key rotation; all other algorithms are handled by the default case.
50+
// Only RS256, ES256, and EdDSA are supported for local key rotation; all other algorithms are handled by the default case.
5051
switch algorithm { //nolint:exhaustive
5152
case jose.RS256:
5253
strategy.key = func() (crypto.Signer, error) {
@@ -56,6 +57,11 @@ func rotationStrategyForAlgorithm(rotationFrequency, idTokenValidFor time.Durati
5657
strategy.key = func() (crypto.Signer, error) {
5758
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
5859
}
60+
case jose.EdDSA:
61+
strategy.key = func() (crypto.Signer, error) {
62+
_, priv, err := ed25519.GenerateKey(rand.Reader)
63+
return priv, err
64+
}
5965
default:
6066
return rotationStrategy{}, fmt.Errorf("unsupported local signer algorithm %q", algorithm)
6167
}

0 commit comments

Comments
 (0)