Skip to content

Commit a7486b2

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

9 files changed

Lines changed: 179 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/handlers.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1541,7 +1541,10 @@ func (s *Server) handleUserInfo(w http.ResponseWriter, r *http.Request) {
15411541
}
15421542
rawIDToken := auth[len(prefix):]
15431543

1544-
verifier := oidc.NewVerifier(s.issuerURL.String(), &signerKeySet{s.signer}, &oidc.Config{SkipClientIDCheck: true})
1544+
verifier := oidc.NewVerifier(s.issuerURL.String(), &signerKeySet{s.signer}, &oidc.Config{
1545+
SupportedSigningAlgs: supportedSigningAlgStrings(),
1546+
SkipClientIDCheck: true,
1547+
})
15451548
idToken, err := verifier.Verify(ctx, rawIDToken)
15461549
if err != nil {
15471550
s.logger.ErrorContext(r.Context(), "failed to verify ID token", "err", err)

server/introspectionhandler.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,10 @@ func (s *Server) introspectRefreshToken(ctx context.Context, token string) (*Int
245245
}
246246

247247
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})
248+
verifier := oidc.NewVerifier(s.issuerURL.String(), &signerKeySet{s.signer}, &oidc.Config{
249+
SupportedSigningAlgs: supportedSigningAlgStrings(),
250+
SkipClientIDCheck: true,
251+
})
249252
idToken, err := verifier.Verify(ctx, token)
250253
if err != nil {
251254
return nil, newIntrospectInactiveTokenError()

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
@@ -764,13 +768,33 @@ func validateConnectorID(connectors []storage.Connector, connectorID string) boo
764768
return false
765769
}
766770

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

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

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
}

server/signer/rotation_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@ package signer
22

33
import (
44
"context"
5+
"crypto/ecdsa"
6+
"crypto/ed25519"
7+
"crypto/rsa"
58
"log/slog"
69
"sort"
710
"testing"
811
"time"
912

1013
"github.com/go-jose/go-jose/v4"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
1116

1217
"github.com/dexidp/dex/storage"
1318
"github.com/dexidp/dex/storage/memory"
@@ -101,3 +106,43 @@ func TestKeyRotator(t *testing.T) {
101106
}
102107
}
103108
}
109+
110+
func TestRotationStrategyForAlgorithm(t *testing.T) {
111+
frequency := time.Hour
112+
validFor := time.Hour
113+
114+
t.Run("RS256 generates an RSA key", func(t *testing.T) {
115+
strategy, err := rotationStrategyForAlgorithm(frequency, validFor, jose.RS256)
116+
require.NoError(t, err)
117+
assert.Equal(t, jose.RS256, strategy.algorithm)
118+
119+
key, err := strategy.key()
120+
require.NoError(t, err)
121+
assert.IsType(t, &rsa.PrivateKey{}, key)
122+
})
123+
124+
t.Run("ES256 generates an ECDSA key", func(t *testing.T) {
125+
strategy, err := rotationStrategyForAlgorithm(frequency, validFor, jose.ES256)
126+
require.NoError(t, err)
127+
assert.Equal(t, jose.ES256, strategy.algorithm)
128+
129+
key, err := strategy.key()
130+
require.NoError(t, err)
131+
assert.IsType(t, &ecdsa.PrivateKey{}, key)
132+
})
133+
134+
t.Run("EdDSA generates an Ed25519 key", func(t *testing.T) {
135+
strategy, err := rotationStrategyForAlgorithm(frequency, validFor, jose.EdDSA)
136+
require.NoError(t, err)
137+
assert.Equal(t, jose.EdDSA, strategy.algorithm)
138+
139+
key, err := strategy.key()
140+
require.NoError(t, err)
141+
assert.IsType(t, ed25519.PrivateKey{}, key)
142+
})
143+
144+
t.Run("unsupported algorithm errors", func(t *testing.T) {
145+
_, err := rotationStrategyForAlgorithm(frequency, validFor, jose.PS256)
146+
require.Error(t, err)
147+
})
148+
}

0 commit comments

Comments
 (0)