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
43 changes: 31 additions & 12 deletions backend/internal/oauth/oauth2/granthandlers/refresh_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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",
}
}
Comment thread
thiva-k marked this conversation as resolved.

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
}
Expand Down
149 changes: 105 additions & 44 deletions backend/internal/oauth/oauth2/granthandlers/refresh_token_test.go

Large diffs are not rendered by default.

36 changes: 32 additions & 4 deletions backend/internal/oauth/oauth2/introspect/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if err != nil {
if errors.Is(err, revocation.ErrEnforcementUnavailable) {
logger.Error(ctx, "Token revocation status could not be verified", log.Error(err))
Expand All @@ -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{
Expand Down
134 changes: 116 additions & 18 deletions backend/internal/oauth/oauth2/introspect/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -111,23 +141,91 @@ 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)
assert.False(s.T(), response.Active)
}

// 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)
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// 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)
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/oauth/oauth2/tokenservice/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down
Loading
Loading