From b631e2585518830222cb78739cb06df5a30e39ed Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Sat, 27 Jun 2026 23:28:16 +0800 Subject: [PATCH] feat(oauth): re-issue OIDC ID token on refresh grant (#275) - Re-issue a freshly signed OIDC id_token on the refresh_token grant when the effective scope still contains openid - Add a persisted AuthTime column to access and refresh tokens, inherited across rotation so auth_time stays the original login time - Source device-code auth_time from the user approval time instead of the redemption instant - Extract a shared buildIDToken helper used by both the authorization_code and refresh_token grants - Omit nonce and compute at_hash over the new access token on refresh per OIDC Core 1.0 section 12.2 - Add tests for re-issuance, scope narrowing, rotation auth_time inheritance, the legacy fallback, and JWKS signature verification - Document the refresh id_token behavior in README and the authorization code flow guide Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- docs/AUTHORIZATION_CODE_FLOW.md | 11 + internal/handlers/token.go | 7 +- internal/handlers/token_refresh_test.go | 189 +++++++++++++++ internal/models/token.go | 19 +- internal/services/token.go | 8 + internal/services/token_cache_test.go | 6 +- internal/services/token_domain_test.go | 2 +- internal/services/token_exchange.go | 177 +++++++++----- .../token_private_claim_prefix_test.go | 2 +- internal/services/token_refresh.go | 66 +++-- .../services/token_refresh_idtoken_test.go | 225 ++++++++++++++++++ internal/services/token_resource_test.go | 10 +- internal/services/token_single_use_test.go | 2 +- internal/services/token_test.go | 16 +- internal/services/token_uid_test.go | 2 +- 16 files changed, 641 insertions(+), 103 deletions(-) create mode 100644 internal/services/token_refresh_idtoken_test.go diff --git a/README.md b/README.md index 10cc8188..6627507e 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ AuthGate also serves as a lightweight **centralised identity gateway** for inter ## ✨ Key Features - **Three OAuth 2.0 Grant Types**: Device Authorization Grant ([RFC 8628][rfc8628]) for CLI/IoT, Authorization Code Flow with PKCE ([RFC 6749][rfc6749] + [RFC 7636][rfc7636]) for web/mobile apps, and Client Credentials Grant ([RFC 6749][rfc6749] §4.4) for machine-to-machine authentication -- **OIDC ID Token & UserInfo**: Issues a signed `id_token` (OIDC Core 1.0) alongside the access token when `openid` scope is granted. Supports `nonce`, `at_hash`, and scope-gated profile/email claims. Includes `/.well-known/openid-configuration` discovery, `/.well-known/jwks.json` (JWKS), and `/oauth/userinfo` endpoints. +- **OIDC ID Token & UserInfo**: Issues a signed `id_token` (OIDC Core 1.0) alongside the access token when `openid` scope is granted, and **re-issues it on `grant_type=refresh_token`** whenever the refresh still carries `openid` (OIDC Core 1.0 §12.2) — with a stable `auth_time` preserved across rotation, `at_hash` over the new access token, and no `nonce` — so clients that treat the `id_token` as their live credential (e.g. the Pinniped Kubernetes CLI) refresh without a browser re-login. Supports `nonce`, `at_hash`, and scope-gated profile/email claims. Includes `/.well-known/openid-configuration` discovery, `/.well-known/jwks.json` (JWKS), and `/oauth/userinfo` endpoints. - **Flexible JWT Signing**: Supports HS256 (symmetric), RS256 (RSA), and ES256 (ECDSA P-256) signing algorithms. Asymmetric keys enable resource servers to verify tokens via the JWKS endpoint without sharing secrets. - **User Consent Management**: Users can review and revoke per-app access at `/account/authorizations`; admins can force re-authentication for all users of any client - **Active Sessions & Remote Sign-Out**: Every browser/device login is recorded in a server-side registry (device, browser, OS, IP, first/last seen). Users see exactly where they're signed in at `/account/devices` and can sign out any device — or all other devices — remotely; a revoked device is bounced to `/login` on its next request. Gated by `LOGIN_SESSION_TRACKING_ENABLED` for an instant kill switch diff --git a/docs/AUTHORIZATION_CODE_FLOW.md b/docs/AUTHORIZATION_CODE_FLOW.md index 30b813ad..cb453c90 100644 --- a/docs/AUTHORIZATION_CODE_FLOW.md +++ b/docs/AUTHORIZATION_CODE_FLOW.md @@ -330,6 +330,17 @@ curl -X POST https://auth.example.com/oauth/token \ -d client_id=550e8400-... ``` +When the refresh still carries the `openid` scope, the response also re-issues a +freshly-signed `id_token` alongside the `access_token` (and rotated +`refresh_token`), matching OIDC Core 1.0 §12.2. The re-issued `id_token` keeps +the original login's `iss` / `sub` / `aud` and a stable `auth_time` (preserved +across refresh-token rotation), sets `iat` to the refresh time, computes +`at_hash` over the **new** access token, and omits `nonce`. A refresh that +narrows the scope so it no longer includes `openid`, and any non-OIDC grant, +returns no `id_token`. This lets OIDC clients that treat the `id_token` as their +live credential (e.g. the Pinniped Kubernetes CLI) refresh silently without a +full browser re-login. + --- ## Example CLI Clients diff --git a/internal/handlers/token.go b/internal/handlers/token.go index b8138d76..930edb4f 100644 --- a/internal/handlers/token.go +++ b/internal/handlers/token.go @@ -294,7 +294,7 @@ func (h *TokenHandler) handleRefreshTokenGrant(c *gin.Context) { } // 3. Call service to refresh token - newAccessToken, newRefreshToken, err := h.tokenService.RefreshAccessToken( + newAccessToken, newRefreshToken, idToken, err := h.tokenService.RefreshAccessToken( c.Request.Context(), refreshTokenString, clientID, @@ -348,8 +348,9 @@ func (h *TokenHandler) handleRefreshTokenGrant(c *gin.Context) { return } - // 5. Return new tokens (RFC 6749 format) - c.JSON(http.StatusOK, buildTokenResponse(newAccessToken, newRefreshToken, "")) + // 5. Return new tokens (RFC 6749 format). idToken is non-empty only when the + // effective scope still carries openid (OIDC Core 1.0 §12.2). + c.JSON(http.StatusOK, buildTokenResponse(newAccessToken, newRefreshToken, idToken)) } // TokenInfo godoc diff --git a/internal/handlers/token_refresh_test.go b/internal/handlers/token_refresh_test.go index e6040bf7..9c7630ec 100644 --- a/internal/handlers/token_refresh_test.go +++ b/internal/handlers/token_refresh_test.go @@ -252,3 +252,192 @@ func TestHandleRefreshTokenGrant_PublicClientNoSecret(t *testing.T) { // No narrowing requested → original grant scope is preserved. assert.Equal(t, "openid email profile", resp["scope"]) } + +// ─── #275: OIDC ID Token re-issuance on refresh (OIDC Core 1.0 §12.2) ───────── + +// seedOIDCRefreshToken mints a refresh token (signed with the env's JWT secret) +// carrying the given scopes and persists its row with an explicit AuthTime so +// auth_time inheritance can be asserted. It also seeds the authenticating user +// so the id_token profile/email claim path resolves. +func seedOIDCRefreshToken( + t *testing.T, + s *store.Store, + cfg *config.Config, + client *models.OAuthApplication, + userID, scopes string, + authTime time.Time, +) string { + t.Helper() + require.NoError(t, s.CreateUser(&models.User{ + ID: userID, + Username: "oidc-" + userID[:8], + Email: userID[:8] + "@example.com", + FullName: "OIDC User", + EmailVerified: true, + IsActive: true, + })) + provider, err := token.NewLocalTokenProvider(cfg) + require.NoError(t, err) + result, err := provider.GenerateRefreshToken( + context.Background(), userID, client.ClientID, scopes, 0, nil, nil, + ) + require.NoError(t, err) + require.NoError(t, s.CreateAccessToken(&models.AccessToken{ + ID: uuid.New().String(), + TokenHash: util.SHA256Hex(result.TokenString), + RawToken: result.TokenString, + TokenType: result.TokenType, + TokenCategory: models.TokenCategoryRefresh, + Status: models.TokenStatusActive, + UserID: userID, + ClientID: client.ClientID, + Scopes: scopes, + ExpiresAt: result.ExpiresAt, + AuthTime: authTime, + })) + return result.TokenString +} + +// Happy path: refreshing a grant that still carries openid re-issues an +// id_token whose iss/sub/aud match the original login, auth_time is preserved, +// iat is now, at_hash is over the NEW access token, and nonce is absent. +func TestHandleRefreshTokenGrant_OIDC_ReissuesIDToken(t *testing.T) { + cfg := refreshTestConfig() + r, s := newTokenTestEnv(t, cfg) + client, secret := createRefreshClient(t, s, core.ClientTypeConfidential, "openid email profile") + userID := uuid.New().String() + authTime := time.Now().Add(-2 * time.Hour).Truncate(time.Second) + rt := seedOIDCRefreshToken(t, s, cfg, client, userID, "openid email profile", authTime) + + w := postToken(t, r, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {rt}, + }, &[2]string{client.ClientID, secret}) + + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + var resp map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + + idToken, ok := resp["id_token"].(string) + require.True(t, ok && idToken != "", "refresh of an openid grant must return an id_token") + + // Verify the signature, not just decode the payload — the remaining + // assertions use decodeUnverifiedClaims, so without this a broken/unsigned + // id_token with valid-looking claims would pass. + verifier, err := token.NewLocalTokenProvider(cfg) + require.NoError(t, err) + _, err = verifier.ParseJWT(idToken) + require.NoError(t, err, "refreshed id_token must carry a valid signature") + + claims := decodeUnverifiedClaims(t, idToken) + assert.Equal(t, "http://localhost:8080", claims["iss"]) + assert.Equal(t, userID, claims["sub"]) + assert.Equal(t, client.ClientID, claims["aud"]) + // auth_time is the original login time, not the refresh instant. + assert.InDelta(t, float64(authTime.Unix()), claims["auth_time"], 0, + "auth_time must equal the original login time") + // iat is the new issuance time. + iat, ok := claims["iat"].(float64) + require.True(t, ok) + assert.InDelta(t, float64(time.Now().Unix()), iat, 30) + // nonce MUST be omitted on refresh (OIDC §12.2). + _, hasNonce := claims["nonce"] + assert.False(t, hasNonce, "refresh id_token must not carry a nonce") + // at_hash is computed over the NEW access token. + access, _ := resp["access_token"].(string) + require.NotEmpty(t, access) + assert.Equal(t, token.ComputeAtHash(access), claims["at_hash"], + "at_hash must be over the newly issued access token") +} + +// Negative — scope narrowed: dropping openid from the refresh request omits the +// id_token while still returning the access/refresh pair. +func TestHandleRefreshTokenGrant_OIDC_ScopeNarrowedDropsIDToken(t *testing.T) { + cfg := refreshTestConfig() + r, s := newTokenTestEnv(t, cfg) + client, secret := createRefreshClient(t, s, core.ClientTypeConfidential, "openid email profile") + userID := uuid.New().String() + rt := seedOIDCRefreshToken(t, s, cfg, client, userID, "openid email profile", + time.Now().Add(-time.Hour)) + + w := postToken(t, r, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {rt}, + "scope": {"email profile"}, // drop openid + }, &[2]string{client.ClientID, secret}) + + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + var resp map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + _, hasID := resp["id_token"] + assert.False(t, hasID, "narrowing openid away must omit id_token") + assert.NotEmpty(t, resp["access_token"]) + assert.NotEmpty(t, resp["refresh_token"]) + assert.Equal(t, "email profile", resp["scope"]) +} + +// Negative — never OIDC: a plain OAuth grant (no openid) returns no id_token. +func TestHandleRefreshTokenGrant_NonOIDC_NoIDToken(t *testing.T) { + cfg := refreshTestConfig() + r, s := newTokenTestEnv(t, cfg) + client, _ := createRefreshClient(t, s, core.ClientTypePublic, "read write") + userID := uuid.New().String() + rt := seedRefreshToken(t, s, cfg, client, userID, "read write") + + w := postToken(t, r, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {rt}, + "client_id": {client.ClientID}, + }, nil) + + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + var resp map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + _, hasID := resp["id_token"] + assert.False(t, hasID, "non-OIDC grant must never return id_token") + assert.NotEmpty(t, resp["access_token"]) +} + +// Rotation mode: two consecutive refreshes both emit id_tokens whose auth_time +// equals the original login time, proving the persisted AuthTime is inherited +// across rotation (the case Option B — re-deriving from the rotated row — would +// have failed). +func TestHandleRefreshTokenGrant_OIDC_RotationPreservesAuthTime(t *testing.T) { + cfg := refreshTestConfig() + cfg.EnableTokenRotation = true + r, s := newTokenTestEnv(t, cfg) + client, _ := createRefreshClient(t, s, core.ClientTypePublic, "openid email profile") + userID := uuid.New().String() + authTime := time.Now().Add(-3 * time.Hour).Truncate(time.Second) + rt := seedOIDCRefreshToken(t, s, cfg, client, userID, "openid email profile", authTime) + + w1 := postToken(t, r, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {rt}, + "client_id": {client.ClientID}, + }, nil) + require.Equal(t, http.StatusOK, w1.Code, "body=%s", w1.Body.String()) + var resp1 map[string]any + require.NoError(t, json.NewDecoder(w1.Body).Decode(&resp1)) + id1, ok := resp1["id_token"].(string) + require.True(t, ok && id1 != "") + rotated, ok := resp1["refresh_token"].(string) + require.True(t, ok && rotated != "", "rotation mode must return a new refresh token") + require.NotEqual(t, rt, rotated, "rotation must mint a NEW refresh token, not echo the input") + assert.InDelta(t, float64(authTime.Unix()), + decodeUnverifiedClaims(t, id1)["auth_time"], 0) + + w2 := postToken(t, r, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {rotated}, + "client_id": {client.ClientID}, + }, nil) + require.Equal(t, http.StatusOK, w2.Code, "body=%s", w2.Body.String()) + var resp2 map[string]any + require.NoError(t, json.NewDecoder(w2.Body).Decode(&resp2)) + id2, ok := resp2["id_token"].(string) + require.True(t, ok && id2 != "") + assert.InDelta(t, float64(authTime.Unix()), + decodeUnverifiedClaims(t, id2)["auth_time"], 0, + "auth_time must remain the original login time across consecutive rotations") +} diff --git a/internal/models/token.go b/internal/models/token.go index 60931d68..d0b77eb3 100644 --- a/internal/models/token.go +++ b/internal/models/token.go @@ -29,12 +29,19 @@ type AccessToken struct { // 'access' or 'refresh' TokenCategory string `gorm:"not null;default:'access';index:idx_token_cat_status_exp,priority:1"` // 'active', 'disabled', 'revoked' - Status string `gorm:"not null;default:'active';index:idx_token_client_status,priority:2;index:idx_token_family_status,priority:2;index:idx_token_auth_status,priority:2;index:idx_token_cat_status_exp,priority:2"` - UserID string `gorm:"not null;index"` - ClientID string `gorm:"not null;index:idx_token_client_status,priority:1"` - Scopes string `gorm:"not null"` // space-separated scopes - ExpiresAt time.Time `gorm:"index;index:idx_token_cat_status_exp,priority:3"` - CreatedAt time.Time + Status string `gorm:"not null;default:'active';index:idx_token_client_status,priority:2;index:idx_token_family_status,priority:2;index:idx_token_auth_status,priority:2;index:idx_token_cat_status_exp,priority:2"` + UserID string `gorm:"not null;index"` + ClientID string `gorm:"not null;index:idx_token_client_status,priority:1"` + Scopes string `gorm:"not null"` // space-separated scopes + ExpiresAt time.Time `gorm:"index;index:idx_token_cat_status_exp,priority:3"` + CreatedAt time.Time + // AuthTime is the end-user authentication time (OIDC Core 1.0 §2 auth_time), + // captured at the original grant and inherited across refresh-token rotation + // so a refreshed ID token reports the original login time, not the refresh + // time. Set at issuance for every grant. Legacy rows predating this column + // hold a zero value; refresh falls back to CreatedAt for an approximate but + // sane auth_time until they expire. + AuthTime time.Time LastUsedAt *time.Time `gorm:"index"` // Last time token was used (for refresh tokens) ParentTokenID string `gorm:"index"` // Links access tokens to their refresh token TokenFamilyID string `gorm:"index:idx_token_family_status,priority:1;default:'';not null"` // Stable root ID for rotation replay detection diff --git a/internal/services/token.go b/internal/services/token.go index 42f7d50b..0b9b73dd 100644 --- a/internal/services/token.go +++ b/internal/services/token.go @@ -175,6 +175,12 @@ type tokenPairParams struct { // (device flow, auth-code flow) load the client up front for other // validation, so this is always populated. Client *models.OAuthApplication + // AuthTime is the end-user authentication time persisted on both the access + // and refresh rows (OIDC Core 1.0 §2 auth_time). It is inherited across + // refresh-token rotation so a refreshed ID token reports the original login + // time. Auth-code flow passes the code's CreatedAt; device flow passes the + // issuance time. + AuthTime time.Time // ExtraClaims carries caller-supplied JWT claims parsed from the // extra_claims form parameter. Reserved keys are rejected by the parser // and overridden by generateJWT; system claims (project, service_account) @@ -537,6 +543,7 @@ func (s *TokenService) generateAndPersistTokenPair( ClientID: p.ClientID, Scopes: p.Scopes, ExpiresAt: accessResult.ExpiresAt, + AuthTime: p.AuthTime, AuthorizationID: p.AuthorizationID, Resource: models.StringArray(effectiveAudience(p.Resource, s.config.JWTAudience)), } @@ -573,6 +580,7 @@ func (s *TokenService) generateAndPersistTokenPair( ClientID: p.ClientID, Scopes: p.Scopes, ExpiresAt: refreshResult.ExpiresAt, + AuthTime: p.AuthTime, AuthorizationID: p.AuthorizationID, Resource: models.StringArray(refreshDBResource), } diff --git a/internal/services/token_cache_test.go b/internal/services/token_cache_test.go index 17c9b01b..8fa9297e 100644 --- a/internal/services/token_cache_test.go +++ b/internal/services/token_cache_test.go @@ -540,7 +540,7 @@ func TestRefreshAccessToken_RotationMode_CacheInvalidated(t *testing.T) { require.NoError(t, err) // Refresh with rotation — old refresh token should be revoked and cache invalidated - _, newRefresh, err := svc.RefreshAccessToken( + _, newRefresh, _, err := svc.RefreshAccessToken( ctx, initialRefresh.RawToken, client.ClientID, "", "read write", nil, nil) require.NoError(t, err) @@ -584,7 +584,7 @@ func TestRevokeTokenFamily_CacheInvalidated(t *testing.T) { require.NoError(t, err, "initial access token should be cached") // Rotate: first refresh succeeds, old refresh token gets revoked - newAccess, newRefresh, err := svc.RefreshAccessToken( + newAccess, newRefresh, _, err := svc.RefreshAccessToken( ctx, initialRefresh.RawToken, client.ClientID, "", "read write", nil, nil) require.NoError(t, err) @@ -598,7 +598,7 @@ func TestRevokeTokenFamily_CacheInvalidated(t *testing.T) { require.NoError(t, err, "new access token should be cached") // Replay attack: reuse old (revoked) refresh token → triggers family revocation - _, _, err = svc.RefreshAccessToken( + _, _, _, err = svc.RefreshAccessToken( ctx, initialRefresh.RawToken, client.ClientID, "", "read write", nil, nil) require.Error(t, err, "replay should fail") diff --git a/internal/services/token_domain_test.go b/internal/services/token_domain_test.go index 8be43c7a..e2a82372 100644 --- a/internal/services/token_domain_test.go +++ b/internal/services/token_domain_test.go @@ -131,7 +131,7 @@ func TestRefresh_ReResolvesJWTDomain(t *testing.T) { cfg.JWTDomain = "swrd" - newAccess, newRefresh, err := svc.RefreshAccessToken( + newAccess, newRefresh, _, err := svc.RefreshAccessToken( context.Background(), refresh.RawToken, client.ClientID, "", "read write", nil, nil, ) diff --git a/internal/services/token_exchange.go b/internal/services/token_exchange.go index 613f3b79..70890d0d 100644 --- a/internal/services/token_exchange.go +++ b/internal/services/token_exchange.go @@ -133,6 +133,16 @@ func (s *TokenService) ExchangeDeviceCode( // against the original /oauth/device/code grant rather than the (possibly // narrowed) access-token audience. start := time.Now() + // auth_time for any OIDC id_token minted from this grant (today only on a + // later refresh; see plan #275) is when the user actually approved the + // device at /device/verify — store.AuthorizeDeviceCode records that as + // dc.AuthorizedAt — NOT when the CLI polled and redeemed the code, which can + // be up to the 30-min code lifetime later. Fall back to the issuance instant + // for legacy codes authorized before authorized_at was recorded. + deviceAuthTime := dc.AuthorizedAt + if deviceAuthTime.IsZero() { + deviceAuthTime = start + } accessToken, refreshToken, err := s.generateAndPersistTokenPair(ctx, tokenPairParams{ UserID: dc.UserID, ClientID: dc.ClientID, @@ -142,6 +152,7 @@ func (s *TokenService) ExchangeDeviceCode( ExtraClaims: extraClaims, Resource: accessResource, RefreshResource: grantedResource, + AuthTime: deviceAuthTime, }) if err != nil { return nil, nil, err @@ -246,6 +257,7 @@ func (s *TokenService) ExchangeAuthorizationCode( ExtraClaims: extraClaims, Resource: accessResource, RefreshResource: refreshResource, + AuthTime: authCode.CreatedAt, }) if err != nil { return nil, nil, "", err @@ -254,63 +266,21 @@ func (s *TokenService) ExchangeAuthorizationCode( // Generate OIDC ID Token when openid scope was granted (OIDC Core 1.0 §3.1.3.3). // ID tokens are not stored in the database; they are short-lived and non-revocable. var idToken string - if scopeSet := util.ScopeSet(authCode.Scopes); scopeSet["openid"] { - params := token.IDTokenParams{ - Issuer: strings.TrimRight(s.config.BaseURL, "/"), - Subject: authCode.UserID, - Audience: authCode.ClientID, - AuthTime: authCode.CreatedAt, - Nonce: authCode.Nonce, - AtHash: token.ComputeAtHash(accessToken.RawToken), - } - - // Fetch user profile only when scope-gated claims are needed - if scopeSet["profile"] || scopeSet["email"] { - if user, err := s.store.GetUserByID(authCode.UserID); err == nil { - // Cache the user in context so the audit service's - // ActorUsername enrichment hits context (no extra DB call). - ctx = models.SetUserContext(ctx, user) - if scopeSet["profile"] { - params.Name = user.FullName - params.PreferredUsername = user.Username - params.Picture = user.AvatarURL - updatedAt := user.UpdatedAt - params.UpdatedAt = &updatedAt - } - if scopeSet["email"] { - params.Email = user.Email - params.EmailVerified = user.EmailVerified - } - } else { - log.Printf( - "[Token] ID token: failed to fetch user profile for user_id=%s, profile/email claims will be omitted: %v", - authCode.UserID, - err, - ) - } - } - - if generated, err := s.tokenProvider.GenerateIDToken(params); err == nil { - idToken = generated - s.auditService.Log(ctx, core.AuditLogEntry{ - EventType: models.EventIDTokenIssued, - Severity: models.SeverityInfo, - ActorUserID: authCode.UserID, - ResourceType: models.ResourceToken, - ResourceID: accessToken.ID, - Action: "ID token issued via authorization code exchange", - Details: models.AuditDetails{ - "client_id": authCode.ClientID, - "client_name": client.ClientName, - "scopes": authCode.Scopes, - "token_provider": providerName, - "access_token_id": accessToken.ID, - }, - Success: true, - }) - } else { - log.Printf("[Token] ID token generation failed: %v", err) - } + if util.ScopeSet(authCode.Scopes)["openid"] { + // Rebind ctx so the user buildIDToken caches (when profile/email is + // requested) is reused by the token-issued audit logs below, avoiding a + // redundant store lookup — see the audit comment further down. + idToken, ctx = s.buildIDToken(ctx, idTokenInput{ + UserID: authCode.UserID, + ClientID: authCode.ClientID, + ClientName: client.ClientName, + Scopes: authCode.Scopes, + AuthTime: authCode.CreatedAt, + Nonce: authCode.Nonce, + AccessRawToken: accessToken.RawToken, + GrantType: "authorization_code", + AccessTokenID: accessToken.ID, + }) } // Metrics @@ -366,3 +336,96 @@ func (s *TokenService) ExchangeAuthorizationCode( return accessToken, refreshToken, idToken, nil } + +// idTokenInput carries everything buildIDToken needs to mint an OIDC ID Token. +// Both ExchangeAuthorizationCode (authorization_code grant) and +// RefreshAccessToken (refresh_token grant) populate it so the two grants share +// one implementation and the claim logic cannot drift between them. +type idTokenInput struct { + UserID string + ClientID string + ClientName string // audit detail + Scopes string // OIDC scopes gating the profile/email claims + AuthTime time.Time // original end-user authentication time (auth_time) + Nonce string // "" on refresh (OIDC Core 1.0 §12.2) + AccessRawToken string // at_hash source: the NEW access token + GrantType string // audit detail: "authorization_code" | "refresh_token" + AccessTokenID string // audit ResourceID +} + +// buildIDToken generates and signs an OIDC ID Token (OIDC Core 1.0 §3.1.3.3) +// for the given input and, on success, emits an EventIDTokenIssued audit entry. +// ID tokens are not stored in the database; they are short-lived and +// non-revocable. Generation is always non-fatal: any failure logs and returns +// "" so the caller can still return the access/refresh token pair. +// +// It returns the (possibly user-enriched) context: when profile/email claims +// are loaded it caches the user via models.SetUserContext so a caller that +// rebinds the returned ctx lets subsequent audit logs resolve ActorUsername +// from context instead of re-querying the store. +func (s *TokenService) buildIDToken( + ctx context.Context, + in idTokenInput, +) (string, context.Context) { + params := token.IDTokenParams{ + Issuer: strings.TrimRight(s.config.BaseURL, "/"), + Subject: in.UserID, + Audience: in.ClientID, + AuthTime: in.AuthTime, + Nonce: in.Nonce, + AtHash: token.ComputeAtHash(in.AccessRawToken), + } + + // Fetch user profile only when scope-gated claims are needed. + scopeSet := util.ScopeSet(in.Scopes) + if scopeSet["profile"] || scopeSet["email"] { + if user, err := s.store.GetUserByID(in.UserID); err == nil { + // Cache the user in context so the audit service's ActorUsername + // enrichment hits context (no extra DB call). + ctx = models.SetUserContext(ctx, user) + if scopeSet["profile"] { + params.Name = user.FullName + params.PreferredUsername = user.Username + params.Picture = user.AvatarURL + updatedAt := user.UpdatedAt + params.UpdatedAt = &updatedAt + } + if scopeSet["email"] { + params.Email = user.Email + params.EmailVerified = user.EmailVerified + } + } else { + log.Printf( + "[Token] ID token: failed to fetch user profile for user_id=%s, profile/email claims will be omitted: %v", + in.UserID, + err, + ) + } + } + + generated, err := s.tokenProvider.GenerateIDToken(params) + if err != nil { + log.Printf("[Token] ID token generation failed: %v", err) + return "", ctx + } + + s.auditService.Log(ctx, core.AuditLogEntry{ + EventType: models.EventIDTokenIssued, + Severity: models.SeverityInfo, + ActorUserID: in.UserID, + ResourceType: models.ResourceToken, + ResourceID: in.AccessTokenID, + Action: "ID token issued", + Details: models.AuditDetails{ + "client_id": in.ClientID, + "client_name": in.ClientName, + "scopes": in.Scopes, + "token_provider": s.tokenProvider.Name(), + "access_token_id": in.AccessTokenID, + "grant_type": in.GrantType, + }, + Success: true, + }) + + return generated, ctx +} diff --git a/internal/services/token_private_claim_prefix_test.go b/internal/services/token_private_claim_prefix_test.go index 3ce1c295..d459f969 100644 --- a/internal/services/token_private_claim_prefix_test.go +++ b/internal/services/token_private_claim_prefix_test.go @@ -242,7 +242,7 @@ func TestPrivateClaimPrefix_RefreshContinuity(t *testing.T) { ) require.NoError(t, err) - newAccess, newRefresh, err := svc.RefreshAccessToken( + newAccess, newRefresh, _, err := svc.RefreshAccessToken( context.Background(), refresh.RawToken, client.ClientID, "", "read write", nil, nil, ) diff --git a/internal/services/token_refresh.go b/internal/services/token_refresh.go index 79c22e84..ed4d453d 100644 --- a/internal/services/token_refresh.go +++ b/internal/services/token_refresh.go @@ -112,18 +112,18 @@ func (s *TokenService) RefreshAccessToken( refreshTokenString, clientID, clientSecret, requestedScopes string, callerExtra map[string]any, requestedResource []string, -) (*models.AccessToken, *models.AccessToken, error) { +) (*models.AccessToken, *models.AccessToken, string, error) { // 1. Get refresh token from database refreshToken, err := s.store.GetAccessTokenByHash(util.SHA256Hex(refreshTokenString)) if err != nil { s.metrics.RecordTokenRefresh(false) - return nil, nil, token.ErrInvalidRefreshToken + return nil, nil, "", token.ErrInvalidRefreshToken } // 2. Verify token category and status if !refreshToken.IsRefreshToken() { s.metrics.RecordTokenRefresh(false) - return nil, nil, token.ErrInvalidRefreshToken + return nil, nil, "", token.ErrInvalidRefreshToken } if !refreshToken.IsActive() { // In rotation mode, a non-active refresh token being reused indicates @@ -132,25 +132,25 @@ func (s *TokenService) RefreshAccessToken( s.revokeTokenFamilyWithAudit(ctx, refreshToken) } s.metrics.RecordTokenRefresh(false) - return nil, nil, token.ErrInvalidRefreshToken + return nil, nil, "", token.ErrInvalidRefreshToken } // 3. Verify expiration if refreshToken.IsExpired() { s.metrics.RecordTokenRefresh(false) - return nil, nil, token.ErrExpiredRefreshToken + return nil, nil, "", token.ErrExpiredRefreshToken } // 4. Verify client_id if refreshToken.ClientID != clientID { s.metrics.RecordTokenRefresh(false) - return nil, nil, ErrAccessDenied + return nil, nil, "", ErrAccessDenied } // 5. Verify scope (cannot upgrade) if !util.IsScopeSubset(refreshToken.Scopes, requestedScopes) { s.metrics.RecordTokenRefresh(false) - return nil, nil, token.ErrInvalidScope + return nil, nil, "", token.ErrInvalidScope } // 5b. Resolve effective resource per RFC 8707 §2.2: the refresh request @@ -176,7 +176,7 @@ func (s *TokenService) RefreshAccessToken( effectiveResource, err := narrowResource(originalResource, requestedResource) if err != nil { s.metrics.RecordTokenRefresh(false) - return nil, nil, err + return nil, nil, "", err } // 6. Authenticate and authorize the client before issuing new tokens, then @@ -193,7 +193,7 @@ func (s *TokenService) RefreshAccessToken( // confidential clients can be authenticated. if s.clientService == nil { s.metrics.RecordTokenRefresh(false) - return nil, nil, ErrAccessDenied + return nil, nil, "", ErrAccessDenied } client, err := s.clientService.GetClientWithSecret(ctx, refreshToken.ClientID) if err != nil { @@ -202,13 +202,13 @@ func (s *TokenService) RefreshAccessToken( refreshToken.ClientID, err, ) s.metrics.RecordTokenRefresh(false) - return nil, nil, ErrAccessDenied + return nil, nil, "", ErrAccessDenied } // A disabled/rejected client must not be able to mint fresh tokens via an // already-issued refresh token (RFC 6749 §5.2 invalid_client). if !client.IsActive() { s.metrics.RecordTokenRefresh(false) - return nil, nil, ErrAccessDenied + return nil, nil, "", ErrAccessDenied } // Confidential clients MUST authenticate with their secret (RFC 6749 §6) — // otherwise a leaked refresh token is replayable using only the public @@ -217,7 +217,7 @@ func (s *TokenService) RefreshAccessToken( if core.ClientType(client.ClientType) == core.ClientTypeConfidential { if clientSecret == "" || !client.ValidateClientSecret([]byte(clientSecret)) { s.metrics.RecordTokenRefresh(false) - return nil, nil, ErrAccessDenied + return nil, nil, "", ErrAccessDenied } } accessTTL, refreshTTL := s.ttlForClient(client) @@ -238,7 +238,7 @@ func (s *TokenService) RefreshAccessToken( // requestedResource is empty. if err := validateClientResource(client, requestedResource); err != nil { s.metrics.RecordTokenRefresh(false) - return nil, nil, err + return nil, nil, "", err } // Effective access-token scope: honor the caller's narrowing (validated as a @@ -247,6 +247,16 @@ func (s *TokenService) RefreshAccessToken( // refresh can re-request up to the original grant. effectiveScope := util.ScopeOrDefault(requestedScopes, refreshToken.Scopes) + // AuthTime is inherited from the presented refresh row so a refreshed ID + // token reports the ORIGINAL login time (OIDC Core 1.0 §12.2), stable across + // consecutive rotations. Legacy rows predating the AuthTime column hold a + // zero value; fall back to the row's CreatedAt for an approximate but sane + // auth_time until they expire. + authTime := refreshToken.AuthTime + if authTime.IsZero() { + authTime = refreshToken.CreatedAt + } + extraClaims := s.composeIssuanceClaims(client, refreshToken.UserID, callerExtra) // Access token's `aud` = effectiveResource (possibly narrowed). // Refresh token's `aud` override = nil → provider falls back to the @@ -267,7 +277,7 @@ func (s *TokenService) RefreshAccessToken( if providerErr != nil { log.Printf("[Token] Refresh failed provider=%s: %v", s.tokenProvider.Name(), providerErr) s.metrics.RecordTokenRefresh(false) - return nil, nil, providerErr + return nil, nil, "", providerErr } // 7. Save new tokens in transaction @@ -289,6 +299,7 @@ func (s *TokenService) RefreshAccessToken( ClientID: refreshToken.ClientID, Scopes: effectiveScope, ExpiresAt: refreshResult.AccessToken.ExpiresAt, + AuthTime: authTime, ParentTokenID: refreshToken.ID, TokenFamilyID: refreshToken.TokenFamilyID, // Inherit family ID Resource: models.StringArray( @@ -316,6 +327,7 @@ func (s *TokenService) RefreshAccessToken( ClientID: refreshToken.ClientID, Scopes: refreshToken.Scopes, ExpiresAt: refreshResult.RefreshToken.ExpiresAt, + AuthTime: authTime, ParentTokenID: refreshToken.ID, TokenFamilyID: refreshToken.TokenFamilyID, // Inherit family ID Resource: models.StringArray(originalResource), @@ -366,7 +378,7 @@ func (s *TokenService) RefreshAccessToken( return nil }); err != nil { s.metrics.RecordTokenRefresh(false) - return nil, nil, err + return nil, nil, "", err } // Invalidate cache after transaction commits successfully @@ -410,7 +422,29 @@ func (s *TokenService) RefreshAccessToken( Success: true, }) - return newAccessToken, newRefreshToken, nil + // Re-issue the OIDC ID Token when the effective scope still carries openid + // (OIDC Core 1.0 §12.2). Gating on effectiveScope (not the original grant) + // honours scope narrowing: a refresh that dropped openid gets no id_token. + // nonce is omitted — a refresh is not bound to an authentication request + // that supplied one. at_hash is computed over the NEW access token. + var idToken string + if util.ScopeSet(effectiveScope)["openid"] { + // Discard the returned ctx: the EventTokenRefreshed audit above already + // ran, so there is no later consumer on this path. + idToken, _ = s.buildIDToken(ctx, idTokenInput{ + UserID: newAccessToken.UserID, + ClientID: newAccessToken.ClientID, + ClientName: client.ClientName, + Scopes: effectiveScope, + AuthTime: authTime, + Nonce: "", + AccessRawToken: newAccessToken.RawToken, + GrantType: "refresh_token", + AccessTokenID: newAccessToken.ID, + }) + } + + return newAccessToken, newRefreshToken, idToken, nil } // GetActiveRefreshTokens gets all active refresh tokens for a user diff --git a/internal/services/token_refresh_idtoken_test.go b/internal/services/token_refresh_idtoken_test.go new file mode 100644 index 00000000..00187b51 --- /dev/null +++ b/internal/services/token_refresh_idtoken_test.go @@ -0,0 +1,225 @@ +package services + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "fmt" + "testing" + "time" + + "github.com/go-authgate/authgate/internal/cache" + "github.com/go-authgate/authgate/internal/config" + "github.com/go-authgate/authgate/internal/core" + "github.com/go-authgate/authgate/internal/metrics" + "github.com/go-authgate/authgate/internal/models" + "github.com/go-authgate/authgate/internal/token" + "github.com/go-authgate/authgate/internal/util" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRefreshAccessToken_IDToken_VerifiesAgainstJWKS proves the RS256 path: a +// refreshed id_token's signature verifies against the provider's public key — +// exactly the key the /.well-known/jwks.json endpoint exposes (it is built from +// provider.PublicKey()) — and its `kid` header matches what JWKS advertises. +func TestRefreshAccessToken_IDToken_VerifiesAgainstJWKS(t *testing.T) { + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + cfg := &config.Config{ + JWTExpiration: 1 * time.Hour, + JWTSigningAlgorithm: "RS256", + BaseURL: "http://localhost:8080", + EnableRefreshTokens: true, + RefreshTokenExpiration: 30 * 24 * time.Hour, + JWTPrivateClaimPrefix: config.DefaultJWTPrivateClaimPrefix, + } + provider, err := token.NewLocalTokenProvider( + cfg, + token.WithSigningKey(rsaKey, &rsaKey.PublicKey), + token.WithKeyID("test-kid"), + ) + require.NoError(t, err) + + s := setupTestStore(t) + clientService := NewClientService(s, NewNoopAuditService(), nil, 0, nil, 0) + deviceService := NewDeviceService( + s, cfg, NewNoopAuditService(), metrics.NewNoopMetrics(), clientService, + ) + svc := NewTokenService( + s, cfg, deviceService, provider, NewNoopAuditService(), + metrics.NewNoopMetrics(), cache.NewNoopCache[models.AccessToken](), clientService, + ) + + // Active public client (PKCE) — no secret needed on refresh. + client := &models.OAuthApplication{ + ClientID: uuid.New().String(), + ClientName: "RS256 Client", + UserID: uuid.New().String(), + Scopes: "openid email profile", + GrantTypes: "authorization_code", + ClientType: core.ClientTypePublic.String(), + Status: models.ClientStatusActive, + } + require.NoError(t, s.CreateClient(client)) + + userID := uuid.New().String() + seedActiveUser(t, s, userID) + + authTime := time.Now().Add(-1 * time.Hour).Truncate(time.Second) + result, err := provider.GenerateRefreshToken( + context.Background(), userID, client.ClientID, "openid email profile", 0, nil, nil, + ) + require.NoError(t, err) + require.NoError(t, s.CreateAccessToken(&models.AccessToken{ + ID: uuid.New().String(), + TokenHash: util.SHA256Hex(result.TokenString), + RawToken: result.TokenString, + TokenType: result.TokenType, + TokenCategory: models.TokenCategoryRefresh, + Status: models.TokenStatusActive, + UserID: userID, + ClientID: client.ClientID, + Scopes: "openid email profile", + ExpiresAt: result.ExpiresAt, + AuthTime: authTime, + })) + + _, _, idToken, err := svc.RefreshAccessToken( + context.Background(), result.TokenString, client.ClientID, "", "", nil, nil, + ) + require.NoError(t, err) + require.NotEmpty(t, idToken, "RS256 refresh of an openid grant must return an id_token") + + // Verify the signature against the JWKS public key (provider.PublicKey()). + parsed, err := jwt.Parse(idToken, func(tok *jwt.Token) (any, error) { + if _, ok := tok.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", tok.Header["alg"]) + } + return provider.PublicKey(), nil + }) + require.NoError(t, err) + require.True(t, parsed.Valid) + assert.Equal(t, provider.KeyID(), parsed.Header["kid"], + "id_token kid must match the key advertised by JWKS") + + claims, ok := parsed.Claims.(jwt.MapClaims) + require.True(t, ok) + assert.InDelta(t, float64(authTime.Unix()), claims["auth_time"], 0) +} + +// TestExchangeAuthorizationCode_PersistsAuthTime proves AuthTime is set at +// issuance (= authCode.CreatedAt) on BOTH the access and refresh rows AND is +// actually persisted. The other OIDC tests hand-seed AuthTime onto the refresh +// row, so they would still pass if the issuance path stopped setting it; this +// test would fail, guarding the real source of auth_time. +func TestExchangeAuthorizationCode_PersistsAuthTime(t *testing.T) { + s := setupTestStore(t) + cfg := &config.Config{ + JWTExpiration: 1 * time.Hour, + JWTSecret: "test-secret", + BaseURL: "http://localhost:8080", + EnableRefreshTokens: true, + RefreshTokenExpiration: 30 * 24 * time.Hour, + } + svc := createTestTokenService(t, s, cfg) + client := createTestClient(t, s, true) + userID := uuid.New().String() + seedActiveUser(t, s, userID) + + now := time.Now() + authCode := &models.AuthorizationCode{ + UUID: "uuid-" + uuid.New().String(), + CodeHash: "hash-" + uuid.New().String(), + CodePrefix: "authtime", + ApplicationID: client.ID, + ClientID: client.ClientID, + UserID: userID, + RedirectURI: "https://app.example.com/callback", + Scopes: "openid", + ExpiresAt: now.Add(10 * time.Minute), + } + require.NoError(t, s.CreateAuthorizationCode(authCode)) + require.False(t, authCode.CreatedAt.IsZero(), "precondition: GORM sets CreatedAt on insert") + + access, refresh, _, err := svc.ExchangeAuthorizationCode( + context.Background(), authCode, nil, nil, nil, + ) + require.NoError(t, err) + + // In-memory structs carry AuthTime = the auth code's CreatedAt. + assert.WithinDuration(t, authCode.CreatedAt, access.AuthTime, time.Second) + assert.WithinDuration(t, authCode.CreatedAt, refresh.AuthTime, time.Second) + + // And it is actually persisted (reload the refresh row from the DB). + dbRefresh, err := s.GetAccessTokenByHash(util.SHA256Hex(refresh.RawToken)) + require.NoError(t, err) + assert.False(t, dbRefresh.AuthTime.IsZero(), + "AuthTime must be persisted on the refresh-token row, not left zero") + assert.WithinDuration(t, authCode.CreatedAt, dbRefresh.AuthTime, time.Second) +} + +// TestRefreshAccessToken_LegacyZeroAuthTime_FallsBackToCreatedAt exercises the +// `if authTime.IsZero() { authTime = refreshToken.CreatedAt }` branch for refresh +// rows minted before the AuthTime column existed (zero AuthTime). The refreshed +// id_token's auth_time must be the row's CreatedAt, never epoch zero. +func TestRefreshAccessToken_LegacyZeroAuthTime_FallsBackToCreatedAt(t *testing.T) { + s := setupTestStore(t) + cfg := &config.Config{ + JWTExpiration: 1 * time.Hour, + JWTSecret: "test-secret", + BaseURL: "http://localhost:8080", + EnableRefreshTokens: true, + RefreshTokenExpiration: 30 * 24 * time.Hour, + } + svc := createTestTokenService(t, s, cfg) + provider, err := token.NewLocalTokenProvider(cfg) + require.NoError(t, err) + + // createTestClient leaves ClientType empty → treated as non-confidential, + // so refresh needs no client_secret. + client := createTestClient(t, s, true) + userID := uuid.New().String() + seedActiveUser(t, s, userID) + + result, err := provider.GenerateRefreshToken( + context.Background(), userID, client.ClientID, "openid", 0, nil, nil, + ) + require.NoError(t, err) + // Legacy row: AuthTime deliberately left zero. + require.NoError(t, s.CreateAccessToken(&models.AccessToken{ + ID: uuid.New().String(), + TokenHash: util.SHA256Hex(result.TokenString), + RawToken: result.TokenString, + TokenType: result.TokenType, + TokenCategory: models.TokenCategoryRefresh, + Status: models.TokenStatusActive, + UserID: userID, + ClientID: client.ClientID, + Scopes: "openid", + ExpiresAt: result.ExpiresAt, + })) + + dbRow, err := s.GetAccessTokenByHash(util.SHA256Hex(result.TokenString)) + require.NoError(t, err) + require.True(t, dbRow.AuthTime.IsZero(), "precondition: legacy row has zero AuthTime") + require.False(t, dbRow.CreatedAt.IsZero(), "precondition: CreatedAt is set by GORM") + + _, _, idToken, err := svc.RefreshAccessToken( + context.Background(), result.TokenString, client.ClientID, "", "", nil, nil, + ) + require.NoError(t, err) + require.NotEmpty(t, idToken) + + parsed, err := provider.ParseJWT(idToken) + require.NoError(t, err) + authTimeClaim, ok := parsed.Claims["auth_time"].(float64) + require.True(t, ok, "auth_time must be present") + assert.Positive(t, authTimeClaim, "auth_time must not be epoch zero") + assert.InDelta(t, float64(dbRow.CreatedAt.Unix()), authTimeClaim, 0, + "legacy zero AuthTime must fall back to the refresh row's CreatedAt") +} diff --git a/internal/services/token_resource_test.go b/internal/services/token_resource_test.go index 76880160..85392164 100644 --- a/internal/services/token_resource_test.go +++ b/internal/services/token_resource_test.go @@ -125,7 +125,7 @@ func TestRefresh_RejectsResourceSupersetOfOriginal(t *testing.T) { require.NotNil(t, refreshToken) // Attempt to widen audience to a resource that was never granted. - _, _, err = tokenService.RefreshAccessToken( + _, _, _, err = tokenService.RefreshAccessToken( context.Background(), refreshToken.RawToken, client.ClientID, @@ -495,7 +495,7 @@ func TestRefresh_LegacyRefreshToken_AudienceFromJWT(t *testing.T) { // audience signed into the legacy refresh JWT. cfg.JWTAudience = []string{"rotated.example.com"} - newAccess, _, err := tokenService.RefreshAccessToken( + newAccess, _, _, err := tokenService.RefreshAccessToken( context.Background(), refreshResult.TokenString, client.ClientID, @@ -570,7 +570,7 @@ func TestRefresh_ClientLoadFails_RejectsRefresh(t *testing.T) { // requestedResource is a subset of the grant (passes narrowResource) but the // client cannot be loaded → fail closed with invalid_client. - _, _, err = tokenService.RefreshAccessToken( + _, _, _, err = tokenService.RefreshAccessToken( context.Background(), refreshResult.TokenString, missingClientID, @@ -652,7 +652,7 @@ func TestRefresh_NarrowsResource_Subset(t *testing.T) { require.NoError(t, err) // Narrow to a single resource from the original grant — must succeed. - newAccess, _, err := tokenService.RefreshAccessToken( + newAccess, _, _, err := tokenService.RefreshAccessToken( context.Background(), refreshToken.RawToken, client.ClientID, @@ -743,7 +743,7 @@ func TestRefresh_NoResource_AudienceFrozenAtIssuance(t *testing.T) { // Invariant 2: refresh without `resource` must produce an access token // whose `aud` is the ORIGINAL audience (read from the snapshot on the // refresh-token row), NOT the rotated config value. - newAccess, _, err := tokenService.RefreshAccessToken( + newAccess, _, _, err := tokenService.RefreshAccessToken( context.Background(), refreshToken.RawToken, client.ClientID, diff --git a/internal/services/token_single_use_test.go b/internal/services/token_single_use_test.go index 8aca8bcb..e2f95d40 100644 --- a/internal/services/token_single_use_test.go +++ b/internal/services/token_single_use_test.go @@ -169,7 +169,7 @@ func TestRefreshAccessToken_ConcurrentRotationSingleUse(t *testing.T) { require.NotNil(t, initialRefresh) results := runConcurrently(2, func() exchangeResult { - access, refresh, err := tokenService.RefreshAccessToken( + access, refresh, _, err := tokenService.RefreshAccessToken( context.Background(), initialRefresh.RawToken, client.ClientID, "", "read write", nil, nil, ) diff --git a/internal/services/token_test.go b/internal/services/token_test.go index 431f1e63..28359693 100644 --- a/internal/services/token_test.go +++ b/internal/services/token_test.go @@ -1504,7 +1504,7 @@ func TestRefreshAccessToken_RotationMode_ReplayDetection(t *testing.T) { require.NotNil(t, initialRefresh) // First refresh: should succeed and rotate (old refresh token gets revoked) - _, newRefresh, err := tokenService.RefreshAccessToken( + _, newRefresh, _, err := tokenService.RefreshAccessToken( context.Background(), initialRefresh.RawToken, client.ClientID, @@ -1519,7 +1519,7 @@ func TestRefreshAccessToken_RotationMode_ReplayDetection(t *testing.T) { assert.Equal(t, models.TokenStatusRevoked, oldToken.Status) // Replay attack: reuse the old (revoked) refresh token - _, _, err = tokenService.RefreshAccessToken( + _, _, _, err = tokenService.RefreshAccessToken( context.Background(), initialRefresh.RawToken, client.ClientID, @@ -1580,7 +1580,7 @@ func TestRefreshAccessToken_FixedMode_NoFamilyRevocation(t *testing.T) { require.NoError(t, s.DB().Create(childToken).Error) // Try to use the disabled token — should fail but NOT revoke family - _, _, err := tokenService.RefreshAccessToken( + _, _, _, err := tokenService.RefreshAccessToken( context.Background(), rawRefreshToken, client.ClientID, @@ -1658,7 +1658,7 @@ func TestRefreshAccessToken_RotationMode_DisabledToken_RevokesFamily(t *testing. require.NoError(t, s.DB().Create(childRefresh).Error) // Try to use the disabled token — should fail AND revoke the entire family - _, _, err := tokenService.RefreshAccessToken( + _, _, _, err := tokenService.RefreshAccessToken( context.Background(), rawRefreshToken, client.ClientID, @@ -1703,7 +1703,7 @@ func TestRefreshAccessToken_RotationMode_MultiRotation_ReplayDetection(t *testin require.NotNil(t, refresh0) // Rotation 1: refresh0 → refresh1 - _, refresh1, err := tokenService.RefreshAccessToken( + _, refresh1, _, err := tokenService.RefreshAccessToken( context.Background(), refresh0.RawToken, client.ClientID, @@ -1713,7 +1713,7 @@ func TestRefreshAccessToken_RotationMode_MultiRotation_ReplayDetection(t *testin require.NotNil(t, refresh1) // Rotation 2: refresh1 → refresh2 - _, refresh2, err := tokenService.RefreshAccessToken( + _, refresh2, _, err := tokenService.RefreshAccessToken( context.Background(), refresh1.RawToken, client.ClientID, @@ -1723,7 +1723,7 @@ func TestRefreshAccessToken_RotationMode_MultiRotation_ReplayDetection(t *testin require.NotNil(t, refresh2) // Rotation 3: refresh2 → refresh3 - _, refresh3, err := tokenService.RefreshAccessToken( + _, refresh3, _, err := tokenService.RefreshAccessToken( context.Background(), refresh2.RawToken, client.ClientID, @@ -1733,7 +1733,7 @@ func TestRefreshAccessToken_RotationMode_MultiRotation_ReplayDetection(t *testin require.NotNil(t, refresh3) // Replay attack: reuse refresh0 (3 rotations ago) - _, _, err = tokenService.RefreshAccessToken( + _, _, _, err = tokenService.RefreshAccessToken( context.Background(), refresh0.RawToken, client.ClientID, diff --git a/internal/services/token_uid_test.go b/internal/services/token_uid_test.go index 9aa34c08..8c1bbfda 100644 --- a/internal/services/token_uid_test.go +++ b/internal/services/token_uid_test.go @@ -129,7 +129,7 @@ func TestRefresh_ReResolvesUidAfterUsernameChange(t *testing.T) { user.Username = "alice2" require.NoError(t, s.UpdateUser(user)) - newAccess, newRefresh, err := svc.RefreshAccessToken( + newAccess, newRefresh, _, err := svc.RefreshAccessToken( context.Background(), refresh.RawToken, client.ClientID, "", "read write", nil, nil, )