diff --git a/backend/internal/oauth/oauth2/granthandlers/jwt_bearer.go b/backend/internal/oauth/oauth2/granthandlers/jwt_bearer.go index b2552c8ba4..cc37902967 100644 --- a/backend/internal/oauth/oauth2/granthandlers/jwt_bearer.go +++ b/backend/internal/oauth/oauth2/granthandlers/jwt_bearer.go @@ -20,6 +20,7 @@ package granthandlers import ( "context" + "errors" "slices" "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" @@ -100,9 +101,27 @@ func (h *jwtBearerGrantHandler) HandleGrant(ctx context.Context, tokenRequest *m ctx, tokenRequest.Assertion, tokenRequest.ClientID) if err != nil { logger.Debug(ctx, "Failed to validate ID-JAG assertion", log.Error(err)) - return nil, &model.ErrorResponse{ - Error: constants.ErrorInvalidGrant, - ErrorDescription: "Invalid assertion", + switch { + case errors.Is(err, tokenservice.ErrTokenExpired): + return nil, &model.ErrorResponse{ + Error: constants.ErrorInvalidGrant, + ErrorDescription: "The assertion has expired", + } + case errors.Is(err, tokenservice.ErrIssuerNotTrusted): + return nil, &model.ErrorResponse{ + Error: constants.ErrorInvalidGrant, + ErrorDescription: "The assertion issuer is not registered as a trusted ID-JAG issuer", + } + case errors.Is(err, tokenservice.ErrAudienceNotAccepted): + return nil, &model.ErrorResponse{ + Error: constants.ErrorInvalidGrant, + ErrorDescription: "The assertion audience must be exactly this server's issuer", + } + default: + return nil, &model.ErrorResponse{ + Error: constants.ErrorInvalidGrant, + ErrorDescription: "Invalid assertion", + } } } diff --git a/backend/internal/oauth/oauth2/granthandlers/jwt_bearer_test.go b/backend/internal/oauth/oauth2/granthandlers/jwt_bearer_test.go index 34b2b11235..f6260f1af1 100644 --- a/backend/internal/oauth/oauth2/granthandlers/jwt_bearer_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/jwt_bearer_test.go @@ -21,6 +21,7 @@ package granthandlers import ( "context" "errors" + "fmt" "testing" "time" @@ -256,6 +257,58 @@ func (suite *JWTBearerGrantHandlerTestSuite) TestHandleGrant_InvalidAssertion() assert.Equal(suite.T(), "Invalid assertion", errResp.ErrorDescription) } +// The reasons the validator attributes are reported so a client can tell an expired assertion from +// an IdP that was never registered for ID-JAG. +func (suite *JWTBearerGrantHandlerTestSuite) TestHandleGrant_AssertionRejectionReasonIsReported() { + testCases := []struct { + name string + validationErr error + expectedDescription string + }{ + { + name: "Expired", + validationErr: tokenservice.ErrTokenExpired, + expectedDescription: "The assertion has expired", + }, + { + name: "UntrustedIssuer", + validationErr: fmt.Errorf("%w: unknown issuer", tokenservice.ErrIssuerNotTrusted), + expectedDescription: "The assertion issuer is not registered as a trusted ID-JAG issuer", + }, + { + name: "AudienceNotAccepted", + validationErr: fmt.Errorf("%w: audience mismatch", tokenservice.ErrAudienceNotAccepted), + expectedDescription: "The assertion audience must be exactly this server's issuer", + }, + { + name: "UnattributedFailureKeepsGenericDescription", + validationErr: errors.New("something else went wrong"), + expectedDescription: "Invalid assertion", + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + + tokenRequest := &model.TokenRequest{ + GrantType: string(providers.GrantTypeJWTBearer), + ClientID: testClientID, + Assertion: testAssertion, + } + suite.mockTokenValidator.On("ValidateIDJAGAssertion", mock.Anything, testAssertion, testClientID). + Return(nil, tc.validationErr) + + result, errResp := suite.handler.HandleGrant(context.Background(), tokenRequest, suite.oauthApp) + + assert.Nil(suite.T(), result) + assert.NotNil(suite.T(), errResp) + assert.Equal(suite.T(), constants.ErrorInvalidGrant, errResp.Error) + assert.Equal(suite.T(), tc.expectedDescription, errResp.ErrorDescription) + }) + } +} + // Granted scopes are the intersection of the assertion scopes and the request scope parameter. The // app's registered scopes are not consulted. func (suite *JWTBearerGrantHandlerTestSuite) TestHandleGrant_ScopeIntersection() { diff --git a/backend/internal/oauth/oauth2/granthandlers/token_exchange.go b/backend/internal/oauth/oauth2/granthandlers/token_exchange.go index 21dd0ade62..315371a2bb 100644 --- a/backend/internal/oauth/oauth2/granthandlers/token_exchange.go +++ b/backend/internal/oauth/oauth2/granthandlers/token_exchange.go @@ -174,15 +174,33 @@ func (h *tokenExchangeGrantHandler) HandleGrant(ctx context.Context, tokenReques subjectClaims, err := h.tokenValidator.ValidateSubjectToken(ctx, tokenRequest.SubjectToken, oauthApp) if err != nil { logger.Debug(ctx, "Failed to validate subject token", log.Error(err)) - if errors.Is(err, revocation.ErrEnforcementUnavailable) { + switch { + case errors.Is(err, revocation.ErrEnforcementUnavailable): return nil, &model.ErrorResponse{ Error: constants.ErrorServerError, ErrorDescription: "Token revocation status could not be verified", } - } - return nil, &model.ErrorResponse{ - Error: constants.ErrorInvalidRequest, - ErrorDescription: "Invalid subject_token", + case errors.Is(err, tokenservice.ErrTokenExpired): + return nil, &model.ErrorResponse{ + Error: constants.ErrorInvalidRequest, + ErrorDescription: "The subject_token has expired", + } + case errors.Is(err, tokenservice.ErrIssuerNotTrusted): + return nil, &model.ErrorResponse{ + Error: constants.ErrorInvalidRequest, + ErrorDescription: "The subject_token issuer is not registered as a trusted token exchange issuer", + } + case errors.Is(err, tokenservice.ErrAudienceNotAccepted): + return nil, &model.ErrorResponse{ + Error: constants.ErrorInvalidRequest, + ErrorDescription: "The subject_token audience does not contain this server's issuer or the " + + "trusted token audience configured for its issuer", + } + default: + return nil, &model.ErrorResponse{ + Error: constants.ErrorInvalidRequest, + ErrorDescription: "Invalid subject_token", + } } } @@ -198,15 +216,34 @@ func (h *tokenExchangeGrantHandler) HandleGrant(ctx context.Context, tokenReques actorClaims, err = h.tokenValidator.ValidateSubjectToken(ctx, tokenRequest.ActorToken, oauthApp) if err != nil { logger.Debug(ctx, "Failed to validate actor token", log.Error(err)) - if errors.Is(err, revocation.ErrEnforcementUnavailable) { + // Attribute the actor_token rejection the same way as the subject_token above. + switch { + case errors.Is(err, revocation.ErrEnforcementUnavailable): return nil, &model.ErrorResponse{ Error: constants.ErrorServerError, ErrorDescription: "Token revocation status could not be verified", } - } - return nil, &model.ErrorResponse{ - Error: constants.ErrorInvalidRequest, - ErrorDescription: "Invalid actor_token", + case errors.Is(err, tokenservice.ErrTokenExpired): + return nil, &model.ErrorResponse{ + Error: constants.ErrorInvalidRequest, + ErrorDescription: "The actor_token has expired", + } + case errors.Is(err, tokenservice.ErrIssuerNotTrusted): + return nil, &model.ErrorResponse{ + Error: constants.ErrorInvalidRequest, + ErrorDescription: "The actor_token issuer is not registered as a trusted token exchange issuer", + } + case errors.Is(err, tokenservice.ErrAudienceNotAccepted): + return nil, &model.ErrorResponse{ + Error: constants.ErrorInvalidRequest, + ErrorDescription: "The actor_token audience does not contain this server's issuer or the " + + "trusted token audience configured for its issuer", + } + default: + return nil, &model.ErrorResponse{ + Error: constants.ErrorInvalidRequest, + ErrorDescription: "Invalid actor_token", + } } } } diff --git a/backend/internal/oauth/oauth2/granthandlers/token_exchange_test.go b/backend/internal/oauth/oauth2/granthandlers/token_exchange_test.go index 582f24c6cf..c9f63abb6b 100644 --- a/backend/internal/oauth/oauth2/granthandlers/token_exchange_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/token_exchange_test.go @@ -982,6 +982,65 @@ func (suite *TokenExchangeGrantHandlerTestSuite) TestHandleGrant_InvalidSubjectT assert.Equal(suite.T(), "Invalid subject_token", errResp.ErrorDescription) } +// A caller cannot tell an expired token from an untrusted issuer or an audience mismatch when every +// rejection collapses into one description, so each attributable reason gets its own. +func (suite *TokenExchangeGrantHandlerTestSuite) TestHandleGrant_SubjectTokenRejectionReasonIsReported() { + testCases := []struct { + name string + validationErr error + expectedDescription string + }{ + { + name: "Expired", + validationErr: tokenservice.ErrTokenExpired, + expectedDescription: "The subject_token has expired", + }, + { + name: "UntrustedIssuer", + validationErr: fmt.Errorf("%w: unknown issuer", tokenservice.ErrIssuerNotTrusted), + expectedDescription: "The subject_token issuer is not registered as a trusted token exchange issuer", + }, + { + name: "AudienceNotAccepted", + validationErr: fmt.Errorf("%w: audience mismatch", tokenservice.ErrAudienceNotAccepted), + expectedDescription: "The subject_token audience does not contain this server's issuer or the " + + "trusted token audience configured for its issuer", + }, + { + name: "UnattributedFailureKeepsGenericDescription", + validationErr: errors.New("something else went wrong"), + expectedDescription: "Invalid subject_token", + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + + subjectToken := suite.createTestJWT(map[string]interface{}{ + "sub": "user123", + "iss": testCustomIssuer, + "exp": float64(time.Now().Unix() + 3600), + }) + tokenRequest := &model.TokenRequest{ + GrantType: string(providers.GrantTypeTokenExchange), + ClientID: testClientID, + SubjectToken: subjectToken, + SubjectTokenType: string(constants.TokenTypeIdentifierAccessToken), + } + suite.mockTokenValidator.On("ValidateSubjectToken", mock.Anything, subjectToken, suite.oauthApp). + Return(nil, tc.validationErr) + + result, errResp := suite.handler.HandleGrant(context.Background(), tokenRequest, suite.oauthApp) + + assert.Nil(suite.T(), result) + assert.NotNil(suite.T(), errResp) + assert.Equal(suite.T(), constants.ErrorInvalidRequest, errResp.Error) + assert.Equal(suite.T(), tc.expectedDescription, errResp.ErrorDescription) + }) + } +} + func (suite *TokenExchangeGrantHandlerTestSuite) TestHandleGrant_InvalidSubjectToken_MissingSubClaim() { now := time.Now().Unix() subjectToken := suite.createTestJWT(map[string]interface{}{ @@ -1114,6 +1173,78 @@ func (suite *TokenExchangeGrantHandlerTestSuite) TestHandleGrant_InvalidActorTok assert.Equal(suite.T(), "Invalid actor_token", errResp.ErrorDescription) } +// The actor_token rejection reason is reported the same way as the subject_token above. +func (suite *TokenExchangeGrantHandlerTestSuite) TestHandleGrant_ActorTokenRejectionReasonIsReported() { + testCases := []struct { + name string + validationErr error + expectedDescription string + }{ + { + name: "Expired", + validationErr: tokenservice.ErrTokenExpired, + expectedDescription: "The actor_token has expired", + }, + { + name: "UntrustedIssuer", + validationErr: fmt.Errorf("%w: unknown issuer", tokenservice.ErrIssuerNotTrusted), + expectedDescription: "The actor_token issuer is not registered as a trusted token exchange issuer", + }, + { + name: "AudienceNotAccepted", + validationErr: fmt.Errorf("%w: audience mismatch", tokenservice.ErrAudienceNotAccepted), + expectedDescription: "The actor_token audience does not contain this server's issuer or the " + + "trusted token audience configured for its issuer", + }, + { + name: "UnattributedFailureKeepsGenericDescription", + validationErr: errors.New("something else went wrong"), + expectedDescription: "Invalid actor_token", + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + + now := time.Now().Unix() + subjectToken := suite.createTestJWT(map[string]interface{}{ + "sub": "user123", + "iss": testCustomIssuer, + "exp": float64(now + 3600), + }) + actorToken := suite.createTestJWT(map[string]interface{}{ + "sub": "service456", + "iss": testCustomIssuer, + "exp": float64(now + 3600), + }) + tokenRequest := &model.TokenRequest{ + GrantType: string(providers.GrantTypeTokenExchange), + ClientID: testClientID, + SubjectToken: subjectToken, + SubjectTokenType: string(constants.TokenTypeIdentifierAccessToken), + ActorToken: actorToken, + ActorTokenType: string(constants.TokenTypeIdentifierAccessToken), + } + suite.mockTokenValidator.On("ValidateSubjectToken", mock.Anything, subjectToken, suite.oauthApp). + Return(&tokenservice.SubjectTokenClaims{ + Sub: testUserID, + Iss: testCustomIssuer, + UserAttributes: map[string]interface{}{}, + }, nil) + suite.mockTokenValidator.On("ValidateSubjectToken", mock.Anything, actorToken, suite.oauthApp). + Return(nil, tc.validationErr) + + result, errResp := suite.handler.HandleGrant(context.Background(), tokenRequest, suite.oauthApp) + + assert.Nil(suite.T(), result) + assert.NotNil(suite.T(), errResp) + assert.Equal(suite.T(), constants.ErrorInvalidRequest, errResp.Error) + assert.Equal(suite.T(), tc.expectedDescription, errResp.ErrorDescription) + }) + } +} + func (suite *TokenExchangeGrantHandlerTestSuite) TestHandleGrant_InvalidScope() { now := time.Now().Unix() subjectToken := suite.createTestJWT(map[string]interface{}{ diff --git a/backend/internal/oauth/oauth2/tokenservice/error_constants.go b/backend/internal/oauth/oauth2/tokenservice/error_constants.go new file mode 100644 index 0000000000..647ce0083e --- /dev/null +++ b/backend/internal/oauth/oauth2/tokenservice/error_constants.go @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package tokenservice + +import "errors" + +// Reasons a token failed validation, discriminated by callers with errors.Is to pick a specific error_description. +var ( + // ErrTokenExpired indicates the token's exp claim is in the past. + ErrTokenExpired = errors.New("token has expired") + + // ErrIssuerNotTrusted indicates the token's iss claim resolves to neither this server nor an + // identity provider registered as a trusted issuer for the grant being used. + ErrIssuerNotTrusted = errors.New("token issuer is not trusted") + + // ErrAudienceNotAccepted indicates the token's aud claim names none of the audiences the grant + // accepts, such as this server's issuer or an identity provider's trusted token audience. + ErrAudienceNotAccepted = errors.New("token audience is not accepted") +) diff --git a/backend/internal/oauth/oauth2/tokenservice/validator.go b/backend/internal/oauth/oauth2/tokenservice/validator.go index 34a87fd673..3e4b65b7fc 100644 --- a/backend/internal/oauth/oauth2/tokenservice/validator.go +++ b/backend/internal/oauth/oauth2/tokenservice/validator.go @@ -262,7 +262,8 @@ func (tv *tokenValidator) ValidateSubjectToken( // Not a server-issued token — try external IDP issuers. issuerInfo, resolveErr := tv.resolveExternalIssuer(ctx, iss, claims) if resolveErr != nil { - return nil, fmt.Errorf("failed to exchange token for issuer %q: %w", iss, resolveErr) + return nil, fmt.Errorf("%w: failed to exchange token for issuer %q: %w", + ErrIssuerNotTrusted, iss, resolveErr) } svcErr := tv.jwtService.VerifyJWTSignatureWithJWKS(ctx, token, issuerInfo.JWKSURL) @@ -374,7 +375,7 @@ func (tv *tokenValidator) ValidateIDJAGAssertion( issuerInfo, resolveErr := tv.resolveIDJAGIssuer(ctx, iss) if resolveErr != nil { - return nil, fmt.Errorf("untrusted assertion issuer %q: %w", iss, resolveErr) + return nil, fmt.Errorf("%w: untrusted assertion issuer %q: %w", ErrIssuerNotTrusted, iss, resolveErr) } if svcErr := tv.jwtService.VerifyJWTSignatureWithJWKS(ctx, assertion, issuerInfo.JWKSURL); svcErr != nil { @@ -400,14 +401,15 @@ func (tv *tokenValidator) ValidateIDJAGAssertion( serverIssuer := tv.cfg.JWT.Issuer auds, audErr := extractAudiences(claims) if audErr != nil { - return nil, fmt.Errorf("assertion is missing 'aud' claim: %w", audErr) + return nil, fmt.Errorf("%w: assertion is missing 'aud' claim: %w", ErrAudienceNotAccepted, audErr) } // The draft permits aud to be a string or an array, but an array MUST contain exactly one element. if len(auds) != 1 { - return nil, fmt.Errorf("assertion must have exactly one audience") + return nil, fmt.Errorf("%w: assertion must have exactly one audience", ErrAudienceNotAccepted) } if auds[0] != serverIssuer { - return nil, fmt.Errorf("assertion audience does not match server issuer %q", serverIssuer) + return nil, fmt.Errorf("%w: assertion audience does not match server issuer %q", + ErrAudienceNotAccepted, serverIssuer) } assertionClientID, err := extractStringClaim(claims, "client_id") @@ -515,11 +517,12 @@ func (tv *tokenValidator) validateExternalTokenAudience(auds []string, issuerInf } if issuerInfo.TrustedTokenAudience != "" { - return fmt.Errorf("external token audience does not contain expected server issuer %q or configured "+ - "trusted token audience", serverIssuer) + return fmt.Errorf("%w: external token audience does not contain expected server issuer %q or configured "+ + "trusted token audience", ErrAudienceNotAccepted, serverIssuer) } - return fmt.Errorf("external token audience does not contain expected server issuer %q", serverIssuer) + return fmt.Errorf("%w: external token audience does not contain expected server issuer %q", + ErrAudienceNotAccepted, serverIssuer) } // extractSubjectTokenClaims extracts and validates claims from a decoded subject token. @@ -637,7 +640,7 @@ func (tv *tokenValidator) validateTimeClaims(claims map[string]interface{}) erro return fmt.Errorf("missing or invalid 'exp' claim: %w", err) } if now >= exp+leeway { - return fmt.Errorf("token has expired") + return ErrTokenExpired } nbf, err := extractInt64Claim(claims, "nbf") diff --git a/tests/integration/oauth/token/tokenexchange_test.go b/tests/integration/oauth/token/tokenexchange_test.go index c75b8da868..e5bbcddfc2 100644 --- a/tests/integration/oauth/token/tokenexchange_test.go +++ b/tests/integration/oauth/token/tokenexchange_test.go @@ -881,5 +881,5 @@ func (ts *TokenExchangeTestSuite) TestTokenExchange_SubjectTokenUnsupportedIssue ts.Require().NoError(err) ts.Equal(http.StatusBadRequest, statusCode) ts.Equal("invalid_request", resp.Error) - ts.Contains(resp.ErrorDescription, "Invalid subject_token") + ts.Contains(resp.ErrorDescription, "issuer is not registered as a trusted token exchange issuer") }