Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions backend/internal/oauth/oauth2/granthandlers/jwt_bearer.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package granthandlers

import (
"context"
"errors"
"slices"

"github.com/thunder-id/thunderid/internal/oauth/oauth2/constants"
Expand Down Expand Up @@ -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",
}
}
}

Expand Down
53 changes: 53 additions & 0 deletions backend/internal/oauth/oauth2/granthandlers/jwt_bearer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package granthandlers
import (
"context"
"errors"
"fmt"
"testing"
"time"

Expand Down Expand Up @@ -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() {
Expand Down
57 changes: 47 additions & 10 deletions backend/internal/oauth/oauth2/granthandlers/token_exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
}
}

Expand All @@ -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",
}
}
}
}
Expand Down
131 changes: 131 additions & 0 deletions backend/internal/oauth/oauth2/granthandlers/token_exchange_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}{
Expand Down Expand Up @@ -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{}{
Expand Down
35 changes: 35 additions & 0 deletions backend/internal/oauth/oauth2/tokenservice/error_constants.go
Original file line number Diff line number Diff line change
@@ -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")
)
Loading
Loading