From abe9097b84a4e09454837f27426fcf3f84c5398f Mon Sep 17 00:00:00 2001 From: thiva-k Date: Sat, 15 Aug 2026 21:31:30 +0530 Subject: [PATCH 1/3] Restrict introspect to access and refresh tokens (cherry picked from commit 739b7bcc1f5d89f8a12b5c97c4bf78fe5003c68b) --- .../oauth2/granthandlers/refresh_token.go | 43 +++-- .../granthandlers/refresh_token_test.go | 149 ++++++++++++------ .../oauth/oauth2/introspect/service.go | 36 ++++- .../oauth/oauth2/introspect/service_test.go | 134 +++++++++++++--- .../oauth/oauth2/tokenservice/model.go | 2 + .../oauth/oauth2/tokenservice/validator.go | 56 ++----- .../oauth2/tokenservice/validator_test.go | 132 ++++++---------- .../TokenValidatorInterface_mock.go | 98 ++---------- 8 files changed, 360 insertions(+), 290 deletions(-) diff --git a/backend/internal/oauth/oauth2/granthandlers/refresh_token.go b/backend/internal/oauth/oauth2/granthandlers/refresh_token.go index ca9a45a792..b5d2f67ae2 100644 --- a/backend/internal/oauth/oauth2/granthandlers/refresh_token.go +++ b/backend/internal/oauth/oauth2/granthandlers/refresh_token.go @@ -98,18 +98,14 @@ func (h *refreshTokenGrantHandler) ValidateGrant(ctx context.Context, tokenReque return nil } -// HandleGrant processes the refresh token grant request and generates a new token response. -func (h *refreshTokenGrantHandler) HandleGrant(ctx context.Context, tokenRequest *model.TokenRequest, - oauthApp *providers.OAuthClient) ( - *model.TokenResponseDTO, *model.ErrorResponse) { - logger := log.GetLogger().With(log.String(log.LoggerKeyComponentName, "RefreshTokenGrantHandler")) - - // Validate refresh token using token validator - // ValidateRefreshToken verifies the token and enforces the RFC 7009 deny list. A revoked token is - // rejected as invalid_grant like any other invalid token; an unavailable deny list fails closed - // with a server_error. - refreshTokenClaims, err := h.tokenValidator.ValidateRefreshToken( - ctx, tokenRequest.RefreshToken, tokenRequest.ClientID) +// resolveRefreshToken validates the presented refresh token and confirms it was issued to the +// requesting client. ValidateRefreshToken enforces the RFC 7009 deny list, so a revoked token is +// rejected as invalid_grant like any other invalid token and an unavailable deny list fails closed +// with a server_error. +func (h *refreshTokenGrantHandler) resolveRefreshToken(ctx context.Context, + tokenRequest *model.TokenRequest, logger *log.Logger) ( + *tokenservice.RefreshTokenClaims, *model.ErrorResponse) { + refreshTokenClaims, err := h.tokenValidator.ValidateRefreshToken(ctx, tokenRequest.RefreshToken) if err != nil { logger.Debug(ctx, "Failed to validate refresh token", log.Error(err)) if errors.Is(err, revocation.ErrEnforcementUnavailable) { @@ -129,6 +125,29 @@ func (h *refreshTokenGrantHandler) HandleGrant(ctx context.Context, tokenRequest } } + // A client may only redeem refresh tokens issued to it. + if refreshTokenClaims.ClientID != tokenRequest.ClientID { + logger.Debug(ctx, "Refresh token does not belong to the requesting client") + return nil, &model.ErrorResponse{ + Error: constants.ErrorInvalidGrant, + ErrorDescription: "Invalid refresh token", + } + } + + return refreshTokenClaims, nil +} + +// HandleGrant processes the refresh token grant request and generates a new token response. +func (h *refreshTokenGrantHandler) HandleGrant(ctx context.Context, tokenRequest *model.TokenRequest, + oauthApp *providers.OAuthClient) ( + *model.TokenResponseDTO, *model.ErrorResponse) { + logger := log.GetLogger().With(log.String(log.LoggerKeyComponentName, "RefreshTokenGrantHandler")) + + refreshTokenClaims, errResp := h.resolveRefreshToken(ctx, tokenRequest, logger) + if errResp != nil { + return nil, errResp + } + if errResp := dpop.VerifyProofBinding(ctx, refreshTokenClaims.DPoPJkt, "refresh token"); errResp != nil { return nil, errResp } diff --git a/backend/internal/oauth/oauth2/granthandlers/refresh_token_test.go b/backend/internal/oauth/oauth2/granthandlers/refresh_token_test.go index 6e58ee5130..831f9c0b69 100644 --- a/backend/internal/oauth/oauth2/granthandlers/refresh_token_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/refresh_token_test.go @@ -223,7 +223,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_ReplayRevokesTok RefreshToken: reusedToken, } - suite.mockTokenValidator.On("ValidateRefreshToken", mock.Anything, reusedToken, testClientID). + suite.mockTokenValidator.On("ValidateRefreshToken", mock.Anything, reusedToken). Return(nil, revocation.ErrTokenRevoked) suite.mockCriteriaRevoker.On("RevokeTokenFamily", mock.Anything, "tfid-reuse", revocation.RevocationReasonRefreshReplay).Return(nil) @@ -236,6 +236,28 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_ReplayRevokesTok suite.mockCriteriaRevoker.AssertExpectations(suite.T()) } +// Validation is client-agnostic so introspection can reuse it, which puts the ownership check on the +// grant handler: a refresh token issued to another client is rejected as invalid_grant and no token +// is minted. +func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_TokenIssuedToAnotherClient_IsRejected() { + suite.mockTokenValidator. + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). + Return(&tokenservice.RefreshTokenClaims{ + ClientID: "another-client", + Sub: testRefreshTokenUserID, + Audiences: []string{testRefreshTokenAudience}, + Scopes: []string{"read"}, + GrantType: "authorization_code", + }, nil) + + resp, errResp := suite.handler.HandleGrant(context.Background(), suite.testTokenReq, suite.oauthApp) + + assert.Nil(suite.T(), resp) + suite.Require().NotNil(errResp) + assert.Equal(suite.T(), constants.ErrorInvalidGrant, errResp.Error) + suite.mockTokenBuilder.AssertNotCalled(suite.T(), "BuildAccessToken", mock.Anything, mock.Anything) +} + func (suite *RefreshTokenGrantHandlerTestSuite) TestValidateGrant_Success() { err := suite.handler.ValidateGrant(context.Background(), suite.testTokenReq, suite.oauthApp) assert.Nil(suite.T(), err) @@ -281,7 +303,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestValidateGrant_MissingClientI func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_InvalidSignature() { // Mock token validator to return error (simulating signature verification failure) suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(nil, errors.New("public key not available")) response, err := suite.handler.HandleGrant(context.Background(), suite.testTokenReq, suite.oauthApp) @@ -296,7 +318,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_InvalidSignature // list and surfaces ErrTokenRevoked, which the grant handler maps to invalid_grant. func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_RevokedRefreshToken() { suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(nil, revocation.ErrTokenRevoked) response, err := suite.handler.HandleGrant(context.Background(), suite.testTokenReq, suite.oauthApp) @@ -310,7 +332,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_RevokedRefreshTo // refresh grant fails closed with server_error. func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_EnforcementUnavailableFailsClosed() { suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(nil, revocation.ErrEnforcementUnavailable) response, err := suite.handler.HandleGrant(context.Background(), suite.testTokenReq, suite.oauthApp) @@ -475,8 +497,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestIssueRefreshToken_AppClientW func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_ReplaysActorSubFromStoredMarker() { const actAppID = "act-entity-id" suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read", "write"}, @@ -519,8 +542,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_NoActorSubMarker } suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read", "write"}, @@ -554,8 +578,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_NoActorSubMarker func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_Success_WithRenewOnGrantDisabled() { // Mock successful refresh token validation suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read", "write"}, @@ -594,8 +619,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_RenewRevokesCons consumedJTI := "consumed-rt-jti" exp := int64(suite.validClaims["exp"].(float64)) suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read", "write"}, @@ -630,8 +656,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_RenewRevokeFailu suite.rebuildHandlerWithConfig() suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read", "write"}, @@ -678,8 +705,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_RevokePreviousOn ).(*refreshTokenGrantHandler) suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read", "write"}, @@ -709,8 +737,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_Success_WithRene // Mock successful refresh token validation suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read", "write"}, @@ -750,8 +779,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_Success_WithRene func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_GetAttributeCacheError() { suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read", "write"}, @@ -782,8 +812,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_GetAttributeCach func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_BuildAccessTokenError() { // Mock successful refresh token validation suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read"}, @@ -811,8 +842,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_IssueRefreshToke // Mock successful refresh token validation suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read"}, @@ -847,7 +879,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_ExtractIatClaimE // Mock validator to return error when iat is missing (validation fails) suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(nil, errors.New("missing or invalid 'iat' claim")) response, err := suite.handler.HandleGrant(context.Background(), suite.testTokenReq, suite.oauthApp) @@ -919,8 +951,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestValidateAndApplyScopes_NoMat func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_IDTokenGenerated_WhenOpenIDScopePresent() { // Mock successful refresh token validation with openid scope suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"openid", "read"}, @@ -973,8 +1006,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_IDTokenGenerated func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_NoIDToken_WhenOpenIDScopeAbsent() { // Mock successful refresh token validation without openid scope suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read", "write"}, @@ -1008,8 +1042,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_NoIDToken_WhenOp func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_IDTokenGenerationError() { // Mock successful refresh token validation with openid scope suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"openid", "read"}, @@ -1052,8 +1087,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_NoRenewOnGrant_R // token's remaining lifetime (~82800 s). suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read", "write"}, @@ -1091,8 +1127,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_ExtendsCache_Whe now := time.Now().Unix() suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read", "write"}, @@ -1130,8 +1167,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_NoRenewOnGrant_E now := time.Now().Unix() suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read", "write"}, @@ -1175,8 +1213,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_RenewOnGrant_Ext suite.rebuildHandlerWithConfig() suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read", "write"}, @@ -1226,8 +1265,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_RenewOnGrant_Ext suite.rebuildHandlerWithConfig() suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read", "write"}, @@ -1281,8 +1321,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_ExtendsCache_Eve // is called unconditionally regardless of the current TTL (100000). suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read", "write"}, @@ -1429,8 +1470,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_IDTokenWithRenew // Mock successful refresh token validation with openid scope suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"openid", "read"}, @@ -1507,8 +1549,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestValidateGrant_MalformedResou func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_MatchingResource_ReusesBoundAudience() { // Refresh token is bound to a single audience (rs01); request resource=[rs01] matches → issued aud=[rs01]. suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRS01URI}, Scopes: []string{"read"}, @@ -1545,8 +1588,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_MatchingResource func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_DifferentResource_InvalidTarget() { // Refresh token is bound to rs01; request resource=[rs02] does not match → invalid_target. suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRS01URI}, Scopes: []string{"read"}, @@ -1574,8 +1618,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_DifferentResourc func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_MultipleResources_InvalidTarget() { // More than one resource parameter is not supported → invalid_target. suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRS01URI}, Scopes: []string{"read"}, @@ -1603,8 +1648,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_MultipleResource func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_NoResourceParam_ReusesBoundAudience() { // No resource param → issued aud equals the single bound audience (rs01). suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRS01URI}, Scopes: []string{"read"}, @@ -1639,8 +1685,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_NoResourceParam_ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_NonSingleAudience_InvalidGrant() { // A refresh token that is not bound to exactly one audience is rejected as invalid_grant. suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRS01URI, testRS02URI}, Scopes: []string{"read"}, @@ -1671,8 +1718,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_BoundResourceSer }) suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRS01URI}, Scopes: []string{"read"}, @@ -1695,8 +1743,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_RenewOnGrant_Pre suite.rebuildHandlerWithConfig() suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRS01URI}, Scopes: []string{"read"}, @@ -1751,8 +1800,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_ScopeDownscopedT Return([]string{"write"}, nil) suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRS01URI}, Scopes: []string{"read", "write"}, @@ -1791,8 +1841,9 @@ const testRefreshTokenJkt = "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" // #no func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_DPoPBoundRT_MissingProof_Rejected() { suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read"}, @@ -1811,8 +1862,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_DPoPBoundRT_Miss func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_DPoPBoundRT_WrongKey_Rejected() { suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read"}, @@ -1832,8 +1884,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_DPoPBoundRT_Wron func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_DPoPBoundRT_ValidProof_AccessTokenBound() { suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read", "write"}, @@ -1863,8 +1916,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_DPoPBoundRT_Vali func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_UnboundRT_NoProof_Succeeds() { suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read"}, @@ -1891,8 +1945,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_UnboundRT_NoProo func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_UnboundRT_VoluntaryProof_AccessTokenBound() { suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read"}, @@ -1925,8 +1980,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_DPoPBoundRT_Rene suite.oauthApp.PublicClient = true suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read"}, @@ -1967,8 +2023,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_RenewOnGrant_Con suite.oauthApp.PublicClient = false suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read"}, @@ -2046,8 +2103,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestIssueRefreshToken_Confidenti func (suite *RefreshTokenGrantHandlerTestSuite) refreshClaimsValid() { suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read", "write"}, @@ -2206,8 +2264,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_EvaluatesWithSub func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_OIDCScopesSurviveDeauthorization() { suite.resetAuthzMocks() suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"openid", "read"}, @@ -2250,8 +2309,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_OIDCScopesSurviv func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_NoReauthorizationWithoutResourceServer() { suite.resetAuthzMocks() suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: testRefreshTokenUserID, Audiences: []string{testRefreshTokenClientID}, Scopes: []string{"openid"}, @@ -2539,8 +2599,9 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_NoActorProviderS func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_EmptySubjectSkipsChecks() { suite.resetActorMocks() suite.mockTokenValidator. - On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken, testRefreshTokenClientID). + On("ValidateRefreshToken", mock.Anything, suite.validRefreshToken). Return(&tokenservice.RefreshTokenClaims{ + ClientID: testRefreshTokenClientID, Sub: "", Audiences: []string{testRefreshTokenAudience}, Scopes: []string{"read"}, diff --git a/backend/internal/oauth/oauth2/introspect/service.go b/backend/internal/oauth/oauth2/introspect/service.go index f55b6e90f7..00919fb75f 100644 --- a/backend/internal/oauth/oauth2/introspect/service.go +++ b/backend/internal/oauth/oauth2/introspect/service.go @@ -7,11 +7,13 @@ package introspect import ( "context" "errors" + "fmt" "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" "github.com/thunder-id/thunderid/internal/oauth/oauth2/dpop" "github.com/thunder-id/thunderid/internal/oauth/oauth2/revocation" "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" + "github.com/thunder-id/thunderid/internal/system/jose/jwt" "github.com/thunder-id/thunderid/internal/system/log" ) @@ -45,10 +47,9 @@ func (s *tokenIntrospectionService) IntrospectToken( return nil, errors.New("token is required") } - // ValidateToken verifies the signature and enforces the RFC 7009 deny list. A revoked or otherwise - // invalid token is inactive per RFC 7662; if the deny list cannot be consulted we fail closed - // (surface a server error) rather than asserting the token is active. - payload, err := s.tokenValidator.ValidateToken(ctx, token) + // RFC 7662 Section 2.1 scopes introspection to access and refresh tokens, so anything else this + // server signs (ID tokens, flow assertions) is reported inactive. + payload, err := s.validateByType(ctx, token) if err != nil { if errors.Is(err, revocation.ErrEnforcementUnavailable) { logger.Error(ctx, "Token revocation status could not be verified", log.Error(err)) @@ -63,6 +64,33 @@ func (s *tokenIntrospectionService) IntrospectToken( return s.prepareValidResponse(payload), nil } +// validateByType validates the token with the validator for its typ header and returns its claims. +func (s *tokenIntrospectionService) validateByType( + ctx context.Context, token string, +) (map[string]interface{}, error) { + header, err := jwt.DecodeJWTHeader(token) + if err != nil { + return nil, fmt.Errorf("failed to decode token header: %w", err) + } + + switch typ, _ := header["typ"].(string); typ { + case jwt.TokenTypeAccessToken: + claims, validateErr := s.tokenValidator.ValidateAccessToken(ctx, token) + if validateErr != nil { + return nil, validateErr + } + return claims.Claims, nil + case jwt.TokenTypeJWT: + claims, validateErr := s.tokenValidator.ValidateRefreshToken(ctx, token) + if validateErr != nil { + return nil, validateErr + } + return claims.Claims, nil + default: + return nil, fmt.Errorf("token type %q is not introspectable", typ) + } +} + // prepareValidResponse prepares the response for a valid token introspection. func (s *tokenIntrospectionService) prepareValidResponse(payload map[string]interface{}) *IntrospectResponse { response := &IntrospectResponse{ diff --git a/backend/internal/oauth/oauth2/introspect/service_test.go b/backend/internal/oauth/oauth2/introspect/service_test.go index e843b288fd..6e1f277279 100644 --- a/backend/internal/oauth/oauth2/introspect/service_test.go +++ b/backend/internal/oauth/oauth2/introspect/service_test.go @@ -5,11 +5,14 @@ package introspect import ( "context" + "encoding/base64" "errors" "testing" "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" "github.com/thunder-id/thunderid/internal/oauth/oauth2/revocation" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" + "github.com/thunder-id/thunderid/internal/system/jose/jwt" "github.com/thunder-id/thunderid/tests/mocks/oauth/oauth2/tokenservicemock" "github.com/stretchr/testify/assert" @@ -32,6 +35,35 @@ func (s *TokenIntrospectionServiceTestSuite) SetupTest() { s.introspectService = newTokenIntrospectionService(s.tokenValidatorMock) } +// tokenWithTyp builds a syntactically valid JWT whose typ header selects the validator the service +// routes to. The payload and signature are never inspected here because the validator is mocked; +// only the header matters for routing. The id keeps each token string distinct so mock expectations +// on different tokens do not collide. +func tokenWithTyp(typ, id string) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"` + typ + `"}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"id":"` + id + `"}`)) + return header + "." + payload + ".signature" +} + +// accessTokenFor returns a token routed to the access-token validator (RFC 9068 at+jwt). +func accessTokenFor(id string) string { return tokenWithTyp(jwt.TokenTypeAccessToken, id) } + +// genericTokenFor returns a token carrying the generic JWT typ, which refresh tokens share with ID +// tokens and flow assertions; the refresh validator's claim checks separate them. +func genericTokenFor(id string) string { return tokenWithTyp(jwt.TokenTypeJWT, id) } + +// stubAccessToken makes the token resolve as a valid access token carrying the given raw claims. +func (s *TokenIntrospectionServiceTestSuite) stubAccessToken(token string, claims map[string]interface{}) { + s.tokenValidatorMock.On("ValidateAccessToken", mock.Anything, token). + Return(&tokenservice.AccessTokenClaims{Claims: claims}, nil) +} + +// stubAccessTokenError makes an at+jwt fixture fail validation. Only the access-token validator is +// stubbed because the typ header routes the token there and no fallback runs. +func (s *TokenIntrospectionServiceTestSuite) stubAccessTokenError(token string, err error) { + s.tokenValidatorMock.On("ValidateAccessToken", mock.Anything, token).Return(nil, err) +} + func (s *TokenIntrospectionServiceTestSuite) TestIntrospectToken_EmptyToken() { response, err := s.introspectService.IntrospectToken(context.Background(), "", "") assert.Error(s.T(), err) @@ -50,9 +82,9 @@ func (s *TokenIntrospectionServiceTestSuite) TestIntrospectToken_ValidToken_Acti "aud": "api.example.com", "iss": "https://example.com", } - s.tokenValidatorMock.On("ValidateToken", mock.Anything, "valid-token").Return(claims, nil) + s.stubAccessToken(accessTokenFor("valid-token"), claims) - response, err := s.introspectService.IntrospectToken(context.Background(), "valid-token", "") + response, err := s.introspectService.IntrospectToken(context.Background(), accessTokenFor("valid-token"), "") assert.NoError(s.T(), err) assert.NotNil(s.T(), response) @@ -72,9 +104,9 @@ func (s *TokenIntrospectionServiceTestSuite) TestIntrospectToken_ArrayAudience() claims := map[string]interface{}{ "aud": []interface{}{"api.example.com", "api2.example.com"}, } - s.tokenValidatorMock.On("ValidateToken", mock.Anything, "array-aud-token").Return(claims, nil) + s.stubAccessToken(accessTokenFor("array-aud-token"), claims) - response, err := s.introspectService.IntrospectToken(context.Background(), "array-aud-token", "") + response, err := s.introspectService.IntrospectToken(context.Background(), accessTokenFor("array-aud-token"), "") assert.NoError(s.T(), err) assert.True(s.T(), response.Active) @@ -83,10 +115,9 @@ func (s *TokenIntrospectionServiceTestSuite) TestIntrospectToken_ArrayAudience() // A valid token missing optional claims is still active, with empty optional fields. func (s *TokenIntrospectionServiceTestSuite) TestIntrospectToken_MissingOptionalClaims_Active() { - s.tokenValidatorMock.On("ValidateToken", mock.Anything, "sparse-token"). - Return(map[string]interface{}{}, nil) + s.stubAccessToken(accessTokenFor("sparse-token"), map[string]interface{}{}) - response, err := s.introspectService.IntrospectToken(context.Background(), "sparse-token", "") + response, err := s.introspectService.IntrospectToken(context.Background(), accessTokenFor("sparse-token"), "") assert.NoError(s.T(), err) assert.True(s.T(), response.Active) @@ -99,10 +130,9 @@ func (s *TokenIntrospectionServiceTestSuite) TestIntrospectToken_MissingOptional // An invalid token (bad signature, expired, malformed, …) is reported inactive per RFC 7662. func (s *TokenIntrospectionServiceTestSuite) TestIntrospectToken_InvalidToken_IsInactive() { - s.tokenValidatorMock.On("ValidateToken", mock.Anything, "invalid-token"). - Return(nil, errors.New("token verification failed")) + s.stubAccessTokenError(accessTokenFor("invalid-token"), errors.New("token verification failed")) - response, err := s.introspectService.IntrospectToken(context.Background(), "invalid-token", "") + response, err := s.introspectService.IntrospectToken(context.Background(), accessTokenFor("invalid-token"), "") assert.NoError(s.T(), err) assert.NotNil(s.T(), response) @@ -111,10 +141,9 @@ func (s *TokenIntrospectionServiceTestSuite) TestIntrospectToken_InvalidToken_Is // A revoked but otherwise valid token is reported inactive (RFC 7009 deny-list enforcement). func (s *TokenIntrospectionServiceTestSuite) TestIntrospectToken_RevokedToken_IsInactive() { - s.tokenValidatorMock.On("ValidateToken", mock.Anything, "revoked-token"). - Return(nil, revocation.ErrTokenRevoked) + s.stubAccessTokenError(accessTokenFor("revoked-token"), revocation.ErrTokenRevoked) - response, err := s.introspectService.IntrospectToken(context.Background(), "revoked-token", "") + response, err := s.introspectService.IntrospectToken(context.Background(), accessTokenFor("revoked-token"), "") assert.NoError(s.T(), err) assert.NotNil(s.T(), response) @@ -122,12 +151,81 @@ func (s *TokenIntrospectionServiceTestSuite) TestIntrospectToken_RevokedToken_Is } // When the deny list cannot be consulted, introspection fails closed with a server error rather -// than asserting the token is active. +// than asserting the token is active. The refresh path is never reached, so a revocation outage +// cannot be masked by falling through to the next validator. func (s *TokenIntrospectionServiceTestSuite) TestIntrospectToken_EnforcementUnavailable_FailsClosed() { - s.tokenValidatorMock.On("ValidateToken", mock.Anything, "some-token"). + s.tokenValidatorMock.On("ValidateAccessToken", mock.Anything, accessTokenFor("some-token")). + Return(nil, revocation.ErrEnforcementUnavailable) + + response, err := s.introspectService.IntrospectToken(context.Background(), accessTokenFor("some-token"), "") + + assert.Error(s.T(), err) + assert.Nil(s.T(), response) +} + +// RFC 7662 Section 2.1 covers refresh tokens as well as access tokens, so a refresh token is +// reported active with its claims surfaced. +func (s *TokenIntrospectionServiceTestSuite) TestIntrospectToken_RefreshToken_Active() { + claims := map[string]interface{}{ + "sub": "client123", + "access_token_sub": "user123", + "scope": "openid profile", + "jti": "refresh-jti", + } + s.tokenValidatorMock.On("ValidateRefreshToken", mock.Anything, genericTokenFor("refresh-token")). + Return(&tokenservice.RefreshTokenClaims{Claims: claims}, nil) + + response, err := s.introspectService.IntrospectToken(context.Background(), genericTokenFor("refresh-token"), "") + + assert.NoError(s.T(), err) + assert.True(s.T(), response.Active) + assert.Equal(s.T(), "refresh-jti", response.Jti) + assert.Equal(s.T(), "openid profile", response.Scope) +} + +// Anything this server signs that is not an access or refresh token is outside the scope of +// RFC 7662. ID tokens and flow assertions carry the generic JWT typ but none of the refresh claims, +// so the refresh validator rejects them and they are reported inactive. +func (s *TokenIntrospectionServiceTestSuite) TestIntrospectToken_NonOAuthToken_IsInactive() { + for _, id := range []string{"id-token", "flow-assertion"} { + s.Run(id, func() { + token := genericTokenFor(id) + s.tokenValidatorMock.On("ValidateRefreshToken", mock.Anything, token). + Return(nil, errors.New("missing or invalid 'access_token_sub' claim")) + + response, err := s.introspectService.IntrospectToken(context.Background(), token, "") + + assert.NoError(s.T(), err) + assert.NotNil(s.T(), response) + assert.False(s.T(), response.Active) + }) + } +} + +// A typ this server does not introspect is rejected on the header alone, without reaching either +// validator, so an unrecognized token type can never fall through to a claim-shape check. +func (s *TokenIntrospectionServiceTestSuite) TestIntrospectToken_UnsupportedTokenType_IsInactive() { + for _, typ := range []string{jwt.TokenTypeIDJAG, "id+jwt"} { + s.Run(typ, func() { + token := tokenWithTyp(typ, "unsupported") + + response, err := s.introspectService.IntrospectToken(context.Background(), token, "") + + assert.NoError(s.T(), err) + assert.NotNil(s.T(), response) + assert.False(s.T(), response.Active) + s.tokenValidatorMock.AssertNotCalled(s.T(), "ValidateAccessToken", mock.Anything, token) + s.tokenValidatorMock.AssertNotCalled(s.T(), "ValidateRefreshToken", mock.Anything, token) + }) + } +} + +// The deny list must fail closed on the refresh path too, not just the access-token path. +func (s *TokenIntrospectionServiceTestSuite) TestIntrospectToken_EnforcementUnavailableOnRefreshPath_FailsClosed() { + s.tokenValidatorMock.On("ValidateRefreshToken", mock.Anything, genericTokenFor("refresh-token")). Return(nil, revocation.ErrEnforcementUnavailable) - response, err := s.introspectService.IntrospectToken(context.Background(), "some-token", "") + response, err := s.introspectService.IntrospectToken(context.Background(), genericTokenFor("refresh-token"), "") assert.Error(s.T(), err) assert.Nil(s.T(), response) @@ -140,9 +238,9 @@ func (s *TokenIntrospectionServiceTestSuite) TestIntrospectToken_DPoPBoundToken_ "client_id": "client123", "cnf": map[string]interface{}{"jkt": "thumbprint-abc"}, } - s.tokenValidatorMock.On("ValidateToken", mock.Anything, "dpop-token").Return(claims, nil) + s.stubAccessToken(accessTokenFor("dpop-token"), claims) - response, err := s.introspectService.IntrospectToken(context.Background(), "dpop-token", "") + response, err := s.introspectService.IntrospectToken(context.Background(), accessTokenFor("dpop-token"), "") assert.NoError(s.T(), err) assert.NotNil(s.T(), response) diff --git a/backend/internal/oauth/oauth2/tokenservice/model.go b/backend/internal/oauth/oauth2/tokenservice/model.go index de802f72de..6811b36ad6 100644 --- a/backend/internal/oauth/oauth2/tokenservice/model.go +++ b/backend/internal/oauth/oauth2/tokenservice/model.go @@ -112,6 +112,7 @@ type IDTokenBuildContext struct { // RefreshTokenClaims represents the validated claims from a refresh token. type RefreshTokenClaims struct { Sub string + ClientID string Audiences []string GrantType string Scopes []string @@ -130,6 +131,7 @@ type RefreshTokenClaims struct { // tokens minted during rotation so the family stays intact, and used to revoke the whole family on // reuse. Empty for pre-rollout tokens. TokenFamilyID string + Claims map[string]interface{} } // SubjectTokenClaims represents the validated claims from a subject token (for token exchange). diff --git a/backend/internal/oauth/oauth2/tokenservice/validator.go b/backend/internal/oauth/oauth2/tokenservice/validator.go index 4f9d32a042..8d510eb8b9 100644 --- a/backend/internal/oauth/oauth2/tokenservice/validator.go +++ b/backend/internal/oauth/oauth2/tokenservice/validator.go @@ -33,7 +33,7 @@ const maxIDJAGJTILength = 256 // yields revocation.ErrEnforcementUnavailable (fail-closed); callers discriminate via errors.Is. type TokenValidatorInterface interface { ValidateAccessToken(ctx context.Context, token string) (*AccessTokenClaims, error) - ValidateRefreshToken(ctx context.Context, token string, clientID string) (*RefreshTokenClaims, error) + ValidateRefreshToken(ctx context.Context, token string) (*RefreshTokenClaims, error) ValidateSubjectToken(ctx context.Context, token string, oauthApp *providers.OAuthClient) ( *SubjectTokenClaims, error) // ValidateIDJAGSubjectToken validates a subject token for the ID-JAG issuance leg of token @@ -44,9 +44,6 @@ type TokenValidatorInterface interface { // refresh token into an ID-JAG. ValidateIDJAGSubjectToken(ctx context.Context, token string, oauthApp *providers.OAuthClient) ( *SubjectTokenClaims, error) - // ValidateToken verifies a self-issued token's signature and enforces revocation without pinning - // its type, returning the raw claims. Used by token introspection, which is token-type agnostic. - ValidateToken(ctx context.Context, token string) (map[string]interface{}, error) // ValidateIDJAGAssertion validates an ID-JAG assertion presented on the jwt-bearer grant, // binding it to the authenticated client via its client_id claim. ValidateIDJAGAssertion(ctx context.Context, assertion, clientID string) (*IDJAGAssertionClaims, error) @@ -144,9 +141,9 @@ func (tv *tokenValidator) ValidateAccessToken(ctx context.Context, token string) // ValidateRefreshToken validates a refresh token and extracts the claims. func (tv *tokenValidator) ValidateRefreshToken( - ctx context.Context, token string, clientID string, + ctx context.Context, token string, ) (*RefreshTokenClaims, error) { - if err := tv.jwtService.VerifyJWT(ctx, token, "", ""); err != nil { + if err := tv.jwtService.VerifyJWT(ctx, token, "", tv.cfg.JWT.Issuer); err != nil { return nil, fmt.Errorf("invalid refresh token: %v", err.Error) } @@ -155,7 +152,8 @@ func (tv *tokenValidator) ValidateRefreshToken( return nil, fmt.Errorf("failed to decode refresh token: %w", err) } - if err := tv.validateOAuth2RefreshClaims(claims, clientID); err != nil { + clientID, err := tv.validateOAuth2RefreshClaims(claims) + if err != nil { return nil, err } @@ -201,6 +199,8 @@ func (tv *tokenValidator) ValidateRefreshToken( // Extract user type and organizational unit details if present return &RefreshTokenClaims{ Sub: sub, + ClientID: clientID, + Claims: claims, Audiences: audiences, GrantType: grantType, Scopes: scopes, @@ -323,28 +323,6 @@ func (tv *tokenValidator) ValidateIDJAGSubjectToken( return subjectClaims, nil } -// ValidateToken verifies a self-issued token's signature (type-agnostic) and enforces the revocation -// deny list, returning the raw claims. Token introspection uses this because it accepts both access -// and refresh tokens and must not pin a token type. -func (tv *tokenValidator) ValidateToken(ctx context.Context, token string) (map[string]interface{}, error) { - if err := tv.jwtService.VerifyJWT(ctx, token, "", ""); err != nil { - return nil, fmt.Errorf("token verification failed: %v", err.Error) - } - - claims, err := jwt.DecodeJWTPayload(token) - if err != nil { - return nil, fmt.Errorf("failed to decode token payload: %w", err) - } - - jti, _ := extractStringClaim(claims, constants.ClaimJTI) - tokenFamilyID, _ := extractStringClaim(claims, constants.ClaimTokenFamilyID) - if err := tv.ensureNotRevoked(ctx, revocationIdentity(claims, jti, tokenFamilyID)); err != nil { - return nil, err - } - - return claims, nil -} - // ValidateIDJAGAssertion validates an ID-JAG assertion presented on the jwt-bearer grant // (draft-ietf-oauth-identity-assertion-authz-grant). It requires the oauth-id-jag+jwt typ header, // resolves the assertion's issuer to a trusted external IdP with ID-JAG enabled, verifies the @@ -665,30 +643,28 @@ func (tv *tokenValidator) validateTimeClaims(claims map[string]interface{}) erro } // validateOAuth2RefreshClaims validates OAuth2-specific refresh token claims. -func (tv *tokenValidator) validateOAuth2RefreshClaims(claims map[string]interface{}, clientID string) error { - sub, err := extractStringClaim(claims, "sub") +// validateOAuth2RefreshClaims asserts the claim shape unique to a refresh token and returns the client +// the token was issued to. +func (tv *tokenValidator) validateOAuth2RefreshClaims(claims map[string]interface{}) (string, error) { + clientID, err := extractStringClaim(claims, "sub") if err != nil { - return fmt.Errorf("missing or invalid 'sub' claim: %w", err) - } - - if sub != clientID { - return fmt.Errorf("refresh token does not belong to the requesting client") + return "", fmt.Errorf("missing or invalid 'sub' claim: %w", err) } // Validate required refresh token claims if _, err := extractStringClaim(claims, "access_token_sub"); err != nil { - return fmt.Errorf("missing or invalid 'access_token_sub' claim: %w", err) + return "", fmt.Errorf("missing or invalid 'access_token_sub' claim: %w", err) } if auds := extractStringSliceClaim(claims, "access_token_aud"); len(auds) == 0 { - return fmt.Errorf("missing or invalid 'access_token_aud' claim") + return "", fmt.Errorf("missing or invalid 'access_token_aud' claim") } if _, err := extractStringClaim(claims, "grant_type"); err != nil { - return fmt.Errorf("missing or invalid 'grant_type' claim: %w", err) + return "", fmt.Errorf("missing or invalid 'grant_type' claim: %w", err) } - return nil + return clientID, nil } // isAuthAssertion determines if a JWT token is an auth assertion. diff --git a/backend/internal/oauth/oauth2/tokenservice/validator_test.go b/backend/internal/oauth/oauth2/tokenservice/validator_test.go index d7be3d4e92..ff74ff0919 100644 --- a/backend/internal/oauth/oauth2/tokenservice/validator_test.go +++ b/backend/internal/oauth/oauth2/tokenservice/validator_test.go @@ -859,9 +859,9 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Success_Basic() { } token := suite.createTestJWT(claims) - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "").Return(nil) + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com").Return(nil) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) assert.NoError(suite.T(), err) assert.NotNil(suite.T(), result) @@ -889,9 +889,9 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Success_WithActor } token := suite.createTestJWT(claims) - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "").Return(nil) + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com").Return(nil) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) assert.NoError(suite.T(), err) assert.NotNil(suite.T(), result) @@ -914,9 +914,9 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Success_WithoutUs } token := suite.createTestJWT(claims) - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "").Return(nil) + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com").Return(nil) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) assert.NoError(suite.T(), err) assert.NotNil(suite.T(), result) @@ -938,9 +938,9 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Success_EmptyScop } token := suite.createTestJWT(claims) - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "").Return(nil) + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com").Return(nil) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) assert.NoError(suite.T(), err) assert.NotNil(suite.T(), result) @@ -951,7 +951,7 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Success_EmptyScop func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Error_InvalidSignature() { token := "invalid.token.signature" - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", ""). + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com"). Return(&tidcommon.ServiceError{ Type: tidcommon.ServerErrorType, Code: "SIGNATURE_VERIFICATION_FAILED", @@ -964,7 +964,7 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Error_InvalidSign }, }) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) assert.Error(suite.T(), err) assert.Nil(suite.T(), result) @@ -976,7 +976,7 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Error_InvalidJWTF token := invalidJWTFormat // VerifyJWT is called first and should fail for invalid format - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", ""). + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com"). Return(&tidcommon.ServiceError{ Type: tidcommon.ClientErrorType, Code: "INVALID_JWT_FORMAT", @@ -988,7 +988,7 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Error_InvalidJWTF }, }) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) assert.Error(suite.T(), err) assert.Nil(suite.T(), result) @@ -1002,7 +1002,7 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Error_DecodeFailu token := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.invalid-base64.signature" // VerifyJWT is called first and should fail for invalid base64 - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", ""). + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com"). Return(&tidcommon.ServiceError{ Type: tidcommon.ServerErrorType, Code: "INVALID_JWT_SIGNATURE", @@ -1014,7 +1014,7 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Error_DecodeFailu }, }) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) assert.Error(suite.T(), err) assert.Nil(suite.T(), result) @@ -1037,9 +1037,9 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Success_MissingIa } token := suite.createTestJWT(claims) - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "").Return(nil) + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com").Return(nil) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) assert.NoError(suite.T(), err) assert.NotNil(suite.T(), result) @@ -1065,7 +1065,7 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Error_ExpiredToke token := suite.createTestJWT(claims) // VerifyJWT should catch expired tokens - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", ""). + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com"). Return(&tidcommon.ServiceError{ Type: tidcommon.ClientErrorType, Code: "TOKEN_EXPIRED", @@ -1075,7 +1075,7 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Error_ExpiredToke }, }) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) assert.Error(suite.T(), err) assert.Nil(suite.T(), result) @@ -1100,7 +1100,7 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Error_NotYetValid token := suite.createTestJWT(claims) // VerifyJWT should catch not yet valid tokens - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", ""). + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com"). Return(&tidcommon.ServiceError{ Type: tidcommon.ClientErrorType, Code: "TOKEN_NOT_VALID_YET", @@ -1112,7 +1112,7 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Error_NotYetValid }, }) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) assert.Error(suite.T(), err) assert.Nil(suite.T(), result) @@ -1134,9 +1134,9 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Error_MissingSub( } token := suite.createTestJWT(claims) - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "").Return(nil) + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com").Return(nil) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) assert.Error(suite.T(), err) assert.Nil(suite.T(), result) @@ -1144,12 +1144,14 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Error_MissingSub( suite.mockJWTService.AssertExpectations(suite.T()) } -func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Error_WrongClientID() { +// Validation is client-agnostic so introspection can reuse it: a refresh token issued to another +// client validates here and reports its owner, leaving the binding to the redeeming caller. +func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_ReportsClientID() { now := time.Now().Unix() claims := map[string]interface{}{ - "sub": "wrong-client", + "sub": "other-client", "iss": "https://example.com", - "aud": "wrong-client", + "aud": "other-client", "exp": float64(now + 3600), "iat": float64(now), "access_token_sub": "user123", @@ -1158,13 +1160,13 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Error_WrongClient } token := suite.createTestJWT(claims) - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "").Return(nil) + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com").Return(nil) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) - assert.Error(suite.T(), err) - assert.Nil(suite.T(), result) - assert.Contains(suite.T(), err.Error(), "refresh token does not belong to the requesting client") + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), "other-client", result.ClientID) + assert.Equal(suite.T(), "user123", result.Sub) suite.mockJWTService.AssertExpectations(suite.T()) } @@ -1182,9 +1184,9 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Error_MissingAcce } token := suite.createTestJWT(claims) - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "").Return(nil) + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com").Return(nil) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) assert.Error(suite.T(), err) assert.Nil(suite.T(), result) @@ -1206,9 +1208,9 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Error_MissingAcce } token := suite.createTestJWT(claims) - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "").Return(nil) + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com").Return(nil) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) assert.Error(suite.T(), err) assert.Nil(suite.T(), result) @@ -1230,9 +1232,9 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Error_MissingGran } token := suite.createTestJWT(claims) - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "").Return(nil) + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com").Return(nil) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) assert.Error(suite.T(), err) assert.Nil(suite.T(), result) @@ -1257,9 +1259,9 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Success_WithClaim } token := suite.createTestJWT(claims) - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "").Return(nil) + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com").Return(nil) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) assert.NoError(suite.T(), err) assert.NotNil(suite.T(), result) @@ -1289,9 +1291,9 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Success_WithDPoPJ } token := suite.createTestJWT(claims) - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "").Return(nil) + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com").Return(nil) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) assert.NoError(suite.T(), err) assert.NotNil(suite.T(), result) @@ -1313,9 +1315,9 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_Success_WithoutDP } token := suite.createTestJWT(claims) - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "").Return(nil) + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com").Return(nil) - result, err := suite.validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := suite.validator.ValidateRefreshToken(context.Background(), token) assert.NoError(suite.T(), err) assert.NotNil(suite.T(), result) @@ -2136,10 +2138,10 @@ func (suite *TokenValidatorTestSuite) TestValidateRefreshToken_RevocationEnforce "jti": tc.jti, } token := suite.createTestJWT(claims) - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "").Return(nil) + suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com").Return(nil) validator := suite.validatorWithEnforcement(tc.jti, tc.returnedErr) - result, err := validator.ValidateRefreshToken(context.Background(), token, "test-client") + result, err := validator.ValidateRefreshToken(context.Background(), token) assert.Nil(suite.T(), result) assert.ErrorIs(suite.T(), err, tc.returnedErr) @@ -2176,48 +2178,6 @@ func (suite *TokenValidatorTestSuite) TestValidateSubjectToken_SelfIssued_Revoca } } -// ValidateToken (used by introspection) verifies the signature, enforces the deny list, and returns -// the raw claims for a valid, non-revoked token. -func (suite *TokenValidatorTestSuite) TestValidateToken_Success() { - claims := map[string]interface{}{ - "sub": "user123", - "iss": "https://example.com", - "jti": "vt-jti-active", - } - token := suite.createTestJWT(claims) - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "").Return(nil) - - result, err := suite.validator.ValidateToken(context.Background(), token) - - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), "user123", result["sub"]) - assert.Equal(suite.T(), "vt-jti-active", result["jti"]) - suite.mockJWTService.AssertExpectations(suite.T()) -} - -// ValidateToken enforces the deny list after signature verification: a revoked token surfaces -// revocation.ErrTokenRevoked (so introspection reports it inactive) and an unavailable deny list -// fails closed with revocation.ErrEnforcementUnavailable. -func (suite *TokenValidatorTestSuite) TestValidateToken_RevocationEnforced() { - for _, tc := range revocationEnforcementCases("vt") { - suite.Run(tc.name, func() { - claims := map[string]interface{}{ - "sub": "user123", - "iss": "https://example.com", - "jti": tc.jti, - } - token := suite.createTestJWT(claims) - suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "").Return(nil) - - validator := suite.validatorWithEnforcement(tc.jti, tc.returnedErr) - result, err := validator.ValidateToken(context.Background(), token) - - assert.Nil(suite.T(), result) - assert.ErrorIs(suite.T(), err, tc.returnedErr) - }) - } -} - func (suite *TokenValidatorTestSuite) TestValidateAccessToken_Error_VerifyFails() { token := "invalid.token.signature" diff --git a/backend/tests/mocks/oauth/oauth2/tokenservicemock/TokenValidatorInterface_mock.go b/backend/tests/mocks/oauth/oauth2/tokenservicemock/TokenValidatorInterface_mock.go index 0db3751233..d9f72ddda5 100644 --- a/backend/tests/mocks/oauth/oauth2/tokenservicemock/TokenValidatorInterface_mock.go +++ b/backend/tests/mocks/oauth/oauth2/tokenservicemock/TokenValidatorInterface_mock.go @@ -256,8 +256,8 @@ func (_c *TokenValidatorInterfaceMock_ValidateIDJAGSubjectToken_Call) RunAndRetu } // ValidateRefreshToken provides a mock function for the type TokenValidatorInterfaceMock -func (_mock *TokenValidatorInterfaceMock) ValidateRefreshToken(ctx context.Context, token string, clientID string) (*tokenservice.RefreshTokenClaims, error) { - ret := _mock.Called(ctx, token, clientID) +func (_mock *TokenValidatorInterfaceMock) ValidateRefreshToken(ctx context.Context, token string) (*tokenservice.RefreshTokenClaims, error) { + ret := _mock.Called(ctx, token) if len(ret) == 0 { panic("no return value specified for ValidateRefreshToken") @@ -265,18 +265,18 @@ func (_mock *TokenValidatorInterfaceMock) ValidateRefreshToken(ctx context.Conte var r0 *tokenservice.RefreshTokenClaims var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*tokenservice.RefreshTokenClaims, error)); ok { - return returnFunc(ctx, token, clientID) + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*tokenservice.RefreshTokenClaims, error)); ok { + return returnFunc(ctx, token) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *tokenservice.RefreshTokenClaims); ok { - r0 = returnFunc(ctx, token, clientID) + if returnFunc, ok := ret.Get(0).(func(context.Context, string) *tokenservice.RefreshTokenClaims); ok { + r0 = returnFunc(ctx, token) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*tokenservice.RefreshTokenClaims) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { - r1 = returnFunc(ctx, token, clientID) + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, token) } else { r1 = ret.Error(1) } @@ -291,12 +291,11 @@ type TokenValidatorInterfaceMock_ValidateRefreshToken_Call struct { // ValidateRefreshToken is a helper method to define mock.On call // - ctx context.Context // - token string -// - clientID string -func (_e *TokenValidatorInterfaceMock_Expecter) ValidateRefreshToken(ctx interface{}, token interface{}, clientID interface{}) *TokenValidatorInterfaceMock_ValidateRefreshToken_Call { - return &TokenValidatorInterfaceMock_ValidateRefreshToken_Call{Call: _e.mock.On("ValidateRefreshToken", ctx, token, clientID)} +func (_e *TokenValidatorInterfaceMock_Expecter) ValidateRefreshToken(ctx interface{}, token interface{}) *TokenValidatorInterfaceMock_ValidateRefreshToken_Call { + return &TokenValidatorInterfaceMock_ValidateRefreshToken_Call{Call: _e.mock.On("ValidateRefreshToken", ctx, token)} } -func (_c *TokenValidatorInterfaceMock_ValidateRefreshToken_Call) Run(run func(ctx context.Context, token string, clientID string)) *TokenValidatorInterfaceMock_ValidateRefreshToken_Call { +func (_c *TokenValidatorInterfaceMock_ValidateRefreshToken_Call) Run(run func(ctx context.Context, token string)) *TokenValidatorInterfaceMock_ValidateRefreshToken_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -306,14 +305,9 @@ func (_c *TokenValidatorInterfaceMock_ValidateRefreshToken_Call) Run(run func(ct if args[1] != nil { arg1 = args[1].(string) } - var arg2 string - if args[2] != nil { - arg2 = args[2].(string) - } run( arg0, arg1, - arg2, ) }) return _c @@ -324,7 +318,7 @@ func (_c *TokenValidatorInterfaceMock_ValidateRefreshToken_Call) Return(refreshT return _c } -func (_c *TokenValidatorInterfaceMock_ValidateRefreshToken_Call) RunAndReturn(run func(ctx context.Context, token string, clientID string) (*tokenservice.RefreshTokenClaims, error)) *TokenValidatorInterfaceMock_ValidateRefreshToken_Call { +func (_c *TokenValidatorInterfaceMock_ValidateRefreshToken_Call) RunAndReturn(run func(ctx context.Context, token string) (*tokenservice.RefreshTokenClaims, error)) *TokenValidatorInterfaceMock_ValidateRefreshToken_Call { _c.Call.Return(run) return _c } @@ -402,71 +396,3 @@ func (_c *TokenValidatorInterfaceMock_ValidateSubjectToken_Call) RunAndReturn(ru _c.Call.Return(run) return _c } - -// ValidateToken provides a mock function for the type TokenValidatorInterfaceMock -func (_mock *TokenValidatorInterfaceMock) ValidateToken(ctx context.Context, token string) (map[string]interface{}, error) { - ret := _mock.Called(ctx, token) - - if len(ret) == 0 { - panic("no return value specified for ValidateToken") - } - - var r0 map[string]interface{} - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) (map[string]interface{}, error)); ok { - return returnFunc(ctx, token) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string) map[string]interface{}); ok { - r0 = returnFunc(ctx, token) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(map[string]interface{}) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = returnFunc(ctx, token) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// TokenValidatorInterfaceMock_ValidateToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ValidateToken' -type TokenValidatorInterfaceMock_ValidateToken_Call struct { - *mock.Call -} - -// ValidateToken is a helper method to define mock.On call -// - ctx context.Context -// - token string -func (_e *TokenValidatorInterfaceMock_Expecter) ValidateToken(ctx interface{}, token interface{}) *TokenValidatorInterfaceMock_ValidateToken_Call { - return &TokenValidatorInterfaceMock_ValidateToken_Call{Call: _e.mock.On("ValidateToken", ctx, token)} -} - -func (_c *TokenValidatorInterfaceMock_ValidateToken_Call) Run(run func(ctx context.Context, token string)) *TokenValidatorInterfaceMock_ValidateToken_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *TokenValidatorInterfaceMock_ValidateToken_Call) Return(stringToIfaceVal map[string]interface{}, err error) *TokenValidatorInterfaceMock_ValidateToken_Call { - _c.Call.Return(stringToIfaceVal, err) - return _c -} - -func (_c *TokenValidatorInterfaceMock_ValidateToken_Call) RunAndReturn(run func(ctx context.Context, token string) (map[string]interface{}, error)) *TokenValidatorInterfaceMock_ValidateToken_Call { - _c.Call.Return(run) - return _c -} From 6e54c09eab3979ac197b35a0cefe5b230fe56dd2 Mon Sep 17 00:00:00 2001 From: thiva-k Date: Sat, 15 Aug 2026 20:23:23 +0530 Subject: [PATCH 2/3] Bind CIBA-issued access tokens to the DPoP proof key (cherry picked from commit 4c4323cb2d72e6a95b343d65f91c403e1cf22f7e) --- .../oauth/oauth2/granthandlers/ciba.go | 2 + .../oauth/oauth2/granthandlers/ciba_test.go | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/backend/internal/oauth/oauth2/granthandlers/ciba.go b/backend/internal/oauth/oauth2/granthandlers/ciba.go index 188258ad57..a4c9f9da4b 100644 --- a/backend/internal/oauth/oauth2/granthandlers/ciba.go +++ b/backend/internal/oauth/oauth2/granthandlers/ciba.go @@ -12,6 +12,7 @@ import ( "github.com/thunder-id/thunderid/internal/attributecache" "github.com/thunder-id/thunderid/internal/oauth/oauth2/ciba" "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/dpop" "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" "github.com/thunder-id/thunderid/internal/oauth/oauth2/resourceindicators" "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" @@ -230,6 +231,7 @@ func (h *cibaGrantHandler) issueTokens(ctx context.Context, record *ciba.CIBAAut GrantType: string(providers.GrantTypeCIBA), OAuthApp: oauthApp, ValidityPeriod: userSubConfig.ValidityPeriodOrZero(), + DPoPJkt: dpop.GetJkt(ctx), }) if err != nil { h.logger.Error(ctx, "Failed to generate access token", log.Error(err)) diff --git a/backend/internal/oauth/oauth2/granthandlers/ciba_test.go b/backend/internal/oauth/oauth2/granthandlers/ciba_test.go index 00b4e38818..e6e580219b 100644 --- a/backend/internal/oauth/oauth2/granthandlers/ciba_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/ciba_test.go @@ -18,6 +18,7 @@ import ( "github.com/thunder-id/thunderid/internal/attributecache" "github.com/thunder-id/thunderid/internal/oauth/oauth2/ciba" "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/dpop" "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" "github.com/thunder-id/thunderid/tests/mocks/attributecachemock" @@ -252,6 +253,42 @@ func (suite *CIBAGrantHandlerTestSuite) TestHandleGrant_Authenticated_IssuesToke suite.Equal("id-token", resp.IDToken.Token) } +// A CIBA access token is sender-constrained to the key the client proved possession of when polling +// the token endpoint, so a stolen token cannot be replayed without the matching DPoP proof. +func (suite *CIBAGrantHandlerTestSuite) TestHandleGrant_Authenticated_BindsDPoPJkt() { + record := suite.boundAuthenticatedRecord(testScopeRead) + suite.mockCIBAService.EXPECT().GetByAuthReqID(mock.Anything, "auth-req-1").Return(record, nil) + suite.expectResourceServer() + suite.mockTokenBuilder.EXPECT().BuildAccessToken(mock.Anything, mock.MatchedBy( + func(ctx *tokenservice.AccessTokenBuildContext) bool { + return ctx.DPoPJkt == "test-jkt" + })).Return(&model.TokenDTO{Token: "access-token", TokenType: "DPoP"}, nil) + suite.mockCIBAService.EXPECT().MarkConsumed(mock.Anything, "auth-req-1").Return(true, nil) + + ctx := dpop.WithJkt(context.Background(), "test-jkt") + resp, errResp := suite.handler.HandleGrant(ctx, suite.tokenReq, suite.oauthApp) + suite.Nil(errResp) + suite.NotNil(resp) + suite.Equal("access-token", resp.AccessToken.Token) +} + +// Without a verified DPoP proof the access token stays unbound, so non-DPoP clients are unaffected. +func (suite *CIBAGrantHandlerTestSuite) TestHandleGrant_Authenticated_NoProofLeavesTokenUnbound() { + record := suite.boundAuthenticatedRecord(testScopeRead) + suite.mockCIBAService.EXPECT().GetByAuthReqID(mock.Anything, "auth-req-1").Return(record, nil) + suite.expectResourceServer() + suite.mockTokenBuilder.EXPECT().BuildAccessToken(mock.Anything, mock.MatchedBy( + func(ctx *tokenservice.AccessTokenBuildContext) bool { + return ctx.DPoPJkt == "" + })).Return(&model.TokenDTO{Token: "access-token", TokenType: "Bearer"}, nil) + suite.mockCIBAService.EXPECT().MarkConsumed(mock.Anything, "auth-req-1").Return(true, nil) + + resp, errResp := suite.handler.HandleGrant(context.Background(), suite.tokenReq, suite.oauthApp) + suite.Nil(errResp) + suite.NotNil(resp) + suite.Equal("access-token", resp.AccessToken.Token) +} + func (suite *CIBAGrantHandlerTestSuite) TestHandleGrant_Authenticated_NoOpenIDSkipsIDToken() { record := suite.boundAuthenticatedRecord(testScopeRead) suite.mockCIBAService.EXPECT().GetByAuthReqID(mock.Anything, "auth-req-1").Return(record, nil) From 8cfd80ed82ed4f85dcc9f4f6d01381cd3b5c8580 Mon Sep 17 00:00:00 2001 From: thiva-k Date: Sun, 16 Aug 2026 01:36:15 +0530 Subject: [PATCH 3/3] Require id_token_hint to be an ID token (cherry picked from commit 721e49afb035cbb3805d8add7e09d1d3b25295c3) --- .../internal/oauth/oauth2/logout/service.go | 14 +++++++ .../oauth/oauth2/logout/service_test.go | 40 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/backend/internal/oauth/oauth2/logout/service.go b/backend/internal/oauth/oauth2/logout/service.go index 7dab305830..1d56c2868e 100644 --- a/backend/internal/oauth/oauth2/logout/service.go +++ b/backend/internal/oauth/oauth2/logout/service.go @@ -228,10 +228,24 @@ func (s *logoutService) clientIDFromIDTokenHint(ctx context.Context, idTokenHint if svcErr := s.jwtService.VerifyJWTSignature(ctx, idTokenHint); svcErr != nil { return "", errInvalidIDTokenHint } + header, err := jwt.DecodeJWTHeader(idTokenHint) + if err != nil { + return "", errInvalidIDTokenHint + } + // The hint must be an ID token, not any other JWT this server signs. An access token issued to an + // application with no configured default audience carries aud=client_id, so without this check it + // would resolve to a client and be accepted as a hint, which suppresses the End-User sign-out + // confirmation. + if typ, _ := header["typ"].(string); typ != jwt.TokenTypeJWT { + return "", errInvalidIDTokenHint + } payload, err := jwt.DecodeJWTPayload(idTokenHint) if err != nil { return "", errInvalidIDTokenHint } + if _, isRefreshToken := payload[constants.ClaimAccessTokenSubject]; isRefreshToken { + return "", errInvalidIDTokenHint + } if iss, _ := payload[constants.ClaimIss].(string); iss != s.issuer { return "", errInvalidIDTokenHint } diff --git a/backend/internal/oauth/oauth2/logout/service_test.go b/backend/internal/oauth/oauth2/logout/service_test.go index 8a5f35eac8..adcb91b5e4 100644 --- a/backend/internal/oauth/oauth2/logout/service_test.go +++ b/backend/internal/oauth/oauth2/logout/service_test.go @@ -300,6 +300,20 @@ func makeIDToken(iss, aud string) string { enc(map[string]interface{}{"iss": iss, "aud": aud}) + ".sig" } +// makeTypedToken builds a token with an arbitrary typ header and extra claims, for asserting that +// only ID tokens are accepted as an id_token_hint. +func makeTypedToken(typ, iss, aud string, extra map[string]interface{}) string { + enc := func(v interface{}) string { + b, _ := json.Marshal(v) + return base64.RawURLEncoding.EncodeToString(b) + } + claims := map[string]interface{}{"iss": iss, "aud": aud} + for k, v := range extra { + claims[k] = v + } + return enc(map[string]string{"alg": "RS256", "typ": typ}) + "." + enc(claims) + ".sig" +} + func makeIDTokenMultiAud(iss string, aud []string, azp string) string { enc := func(v interface{}) string { b, _ := json.Marshal(v) @@ -438,6 +452,32 @@ func (suite *LogoutServiceTestSuite) TestResolve_IDTokenHintBadSignature() { suite.Require().ErrorIs(err, errInvalidIDTokenHint) } +// An access token issued to an application with no configured default audience carries +// aud=client_id, so it resolves to a valid client. It must still be rejected as an id_token_hint: +// accepting it would let anyone holding such a token suppress the End-User sign-out confirmation. +func (suite *LogoutServiceTestSuite) TestResolve_AccessTokenAsIDTokenHintRejected() { + svc, jwtSvc, _ := suite.newService() + token := makeTypedToken("at+jwt", testIssuer, "client-x", nil) + jwtSvc.EXPECT().VerifyJWTSignature(mock.Anything, token).Return(nil) + + _, err := svc.Resolve(context.Background(), LogoutRequest{IDTokenHint: token}) + + suite.Require().ErrorIs(err, errInvalidIDTokenHint) +} + +// A refresh token shares the generic JWT typ with ID tokens, so it is separated by its +// access_token_sub claim, the same way the ID-JAG subject token check does it. +func (suite *LogoutServiceTestSuite) TestResolve_RefreshTokenAsIDTokenHintRejected() { + svc, jwtSvc, _ := suite.newService() + token := makeTypedToken("JWT", testIssuer, "client-x", + map[string]interface{}{"access_token_sub": "user-1"}) + jwtSvc.EXPECT().VerifyJWTSignature(mock.Anything, token).Return(nil) + + _, err := svc.Resolve(context.Background(), LogoutRequest{IDTokenHint: token}) + + suite.Require().ErrorIs(err, errInvalidIDTokenHint) +} + func (suite *LogoutServiceTestSuite) TestResolve_IDTokenHintWrongIssuer() { svc, jwtSvc, _ := suite.newService() token := makeIDToken("https://other.issuer", "client-x")