From 31cf652930ce0ca819fe5baba369990c248f3fea Mon Sep 17 00:00:00 2001 From: Roger Meier Date: Sun, 29 Mar 2026 00:07:11 +0100 Subject: [PATCH] feat: add a jti per default (#4695) Signed-off-by: Roger Meier --- server/oauth2.go | 3 ++ server/oauth2_test.go | 78 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/server/oauth2.go b/server/oauth2.go index 7e102302..40f8063b 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -21,6 +21,7 @@ import ( "github.com/coreos/go-oidc/v3/oidc" "github.com/go-jose/go-jose/v4" + "github.com/google/uuid" "github.com/dexidp/dex/connector" "github.com/dexidp/dex/server/internal" @@ -271,6 +272,7 @@ type idTokenClaims struct { Audience audience `json:"aud"` Expiry int64 `json:"exp"` IssuedAt int64 `json:"iat"` + JWTID string `json:"jti,omitempty"` AuthorizingParty string `json:"azp,omitempty"` Nonce string `json:"nonce,omitempty"` AuthTime int64 `json:"auth_time,omitempty"` @@ -357,6 +359,7 @@ func (s *Server) newIDToken(ctx context.Context, clientID string, claims storage Nonce: nonce, Expiry: expiry.Unix(), IssuedAt: issuedAt.Unix(), + JWTID: uuid.New().String(), } // Include auth_time when sessions are enabled and the value is available. diff --git a/server/oauth2_test.go b/server/oauth2_test.go index 6df028e0..2cdc938a 100644 --- a/server/oauth2_test.go +++ b/server/oauth2_test.go @@ -1099,6 +1099,84 @@ func TestNewIDTokenUsesStoredAlgorithmUntilNextRotation(t *testing.T) { require.Equal(t, wantCodeHash, claims.CodeHash) } +func TestNewIDTokenContainsJTI(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := slog.New(slog.DiscardHandler) + store := memory.New(logger) + + now := time.Now().UTC() + err := store.UpdateKeys(ctx, func(keys storage.Keys) (storage.Keys, error) { + keys.SigningKey = &jose.JSONWebKey{ + Key: testKey, + KeyID: "test-rs256", + Algorithm: string(jose.RS256), + Use: "sig", + } + keys.SigningKeyPub = &jose.JSONWebKey{ + Key: testKey.Public(), + KeyID: "test-rs256", + Algorithm: string(jose.RS256), + Use: "sig", + } + keys.NextRotation = now.Add(time.Hour) + return keys, nil + }) + require.NoError(t, err) + + localConfig := signer.LocalConfig{ + KeysRotationPeriod: time.Hour.String(), + Algorithm: jose.RS256, + } + sig, err := localConfig.Open(ctx, store, time.Hour, func() time.Time { return now }, logger) + require.NoError(t, err) + + sig.Start(ctx) + + issuerURL, err := url.Parse("https://issuer.example.com") + require.NoError(t, err) + + s := &Server{ + signer: sig, + issuerURL: *issuerURL, + logger: logger, + now: func() time.Time { return now }, + idTokensValidFor: time.Hour, + } + + keys, err := sig.ValidationKeys(ctx) + require.NoError(t, err) + require.NotEmpty(t, keys) + + extractJTI := func(t *testing.T, idToken string) string { + t.Helper() + jws, err := jose.ParseSigned(idToken, []jose.SignatureAlgorithm{jose.RS256}) + require.NoError(t, err) + payload, err := jws.Verify(keys[0]) + require.NoError(t, err) + var claims struct { + JTI string `json:"jti"` + } + err = json.Unmarshal(payload, &claims) + require.NoError(t, err) + return claims.JTI + } + + token1, _, err := s.newIDToken(ctx, "client", storage.Claims{UserID: "1", Username: "alice"}, []string{"openid"}, "n1", "", "", "mock", time.Time{}) + require.NoError(t, err) + + token2, _, err := s.newIDToken(ctx, "client", storage.Claims{UserID: "1", Username: "alice"}, []string{"openid"}, "n2", "", "", "mock", time.Time{}) + require.NoError(t, err) + + jti1 := extractJTI(t, token1) + jti2 := extractJTI(t, token2) + + assert.NotEmpty(t, jti1, "jti claim must be present and non-empty") + assert.NotEmpty(t, jti2, "jti claim must be present and non-empty") + assert.NotEqual(t, jti1, jti2, "each token must have a unique jti") +} + func TestSessionMatchesHint(t *testing.T) { // genSubject("foo", "bar") == "CgNmb28SA2Jhcg" (from TestGetSubject) assert.True(t, sessionMatchesHint(&storage.AuthSession{UserID: "foo", ConnectorID: "bar"}, "CgNmb28SA2Jhcg"))