Skip to content

Add ID token support for token exchange#19076

Open
bkoragan wants to merge 2 commits intospring-projects:mainfrom
bkoragan:gh-19048
Open

Add ID token support for token exchange#19076
bkoragan wants to merge 2 commits intospring-projects:mainfrom
bkoragan:gh-19048

Conversation

@bkoragan
Copy link
Copy Markdown

I discussed this approach and received positive feedback. This PR implements the proposed design.

Summary

Add support for exchanging externally-issued OIDC ID tokens for access tokens via the OAuth 2.0 Token Exchange Grant per RFC 8693 - sec 3.

New classes:

  • OAuth2TokenExchangeSubjectTokenResolver — strategy interface for resolving external subject tokens
  • OAuth2TokenExchangeSubjectTokenContext — context object with resolved principal, claims, and scopes
  • OidcIdTokenSubjectTokenResolver — default implementation using JwtDecoderFactory<RegisteredClient>

Modified classes:

  • OAuth2TokenExchangeAuthenticationConverter — acceptid_token as supported token type
  • OAuth2TokenExchangeAuthenticationProvider — delegate to resolver before falling back to authorization service
  • OAuth2TokenEndpointConfigurer — auto-wire resolver bean

Design Decisions

  • Provider stays final; only token resolution is pluggable
  • Resolver returns null for unsupported types (chain-of-responsibility, same pattern as AuthenticationConverter)
  • JwtDecoderFactory<RegisteredClient> follows the same pattern as JwtClientAssertionDecoderFactory
  • Bean auto-wiring via getOptionalBean (same as OAuth2AuthorizationService, OAuth2TokenGenerator`)
  • No breaking changes; existing behavior untouched when no resolver is configured.

Configuration Example

@Bean
OidcIdTokenSubjectTokenResolver subjectTokenResolver() {
    return new OidcIdTokenSubjectTokenResolver(
        (registeredClient) -> {
            String jwkSetUri = registeredClient
                .getClientSettings()
                .getSetting("id-token-jwk-set-uri");
            return NimbusJwtDecoder
                .withJwkSetUri(jwkSetUri).build();
        }
    );
}

**Scenarios tested - as standard alone:**

| # | Scenario | Result |
|---|----------|--------|
| 1 | Exchange valid ID token (self-signed) | 200 + access token with correct `sub` |
| 2 | Tampered/invalid ID token | 400 `invalid_grant` |
| 3 | Expired ID token | 400 `invalid_grant` |
| 4 | ID token without `sub` claim | 400 `invalid_grant` |
| 5 | Unsupported token type | 400 `unsupported_token_type` |
| 6 | Resolver returns null, fallback to authorizationService | Works as before |
| 7 | Client credentials grant (backward compatabele) | 200 (no change) |
| 8 | Multi-IdP routing (GitHub, Google issuers) | Correct issuer-based decoder selection |

@bkoragan
Copy link
Copy Markdown
Author

@jgrandja Please review and share if any feedback. Note: reference to our proposal discussion :#19048 (comment)

@kpur-sbab
Copy link
Copy Markdown

Great PR. @bkoragan
My initial thoughts,

  1. We might need to add id-token-jwk-set-uri in ConfigurationSettingNames and use it in ClientSettings for RegisteredClient model
  2. Just curious how to map claims from id token to the real access token? For this use case

Correct me if my questions are invalid.
Thanks

@bkoragan
Copy link
Copy Markdown
Author

bkoragan commented Apr 16, 2026

@kpur-sbab Good Points! Thanks for the review/feedback! I ve pushed updates for both comments - below are the details.. Please take another look and let me know if you have any further observations. Thanks!

1# Added ID_TOKEN_JWK_SET_URL to ConfigurationSettingNames. Client with a typed getter and builder on ClientSettings, following the same pattern as the existing JWK_SET_URL.
The OidcIdTokenSubjectTokenResolver now provides a no-arg constructor that use ClientSettings.getIdTokenJwkSetUrl() automatically - avoiding manual factory wiring. This follows the same pattern as JwtClientAssertionDecoderFactory, which reads ClientSettings.getJwkSetUrl() internally.

here is sample configuration:

// Client registration - set the IdP's JWKS URL
RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString())
    .clientId("cicd-client")
    .clientSettings(ClientSettings.builder()
        .idTokenJwkSetUrl("https://gitlab.com/oauth/discovery/keys")
        .build())
    .build();

// Resolver - no factory required, reads from ClientSettings
@Bean
OidcIdTokenSubjectTokenResolver subjectTokenResolver() {
    return new OidcIdTokenSubjectTokenResolver();
}

The custom JwtDecoderFactory constructor is still available for advanced cases like multi-issuer routing or custom validation..

2# only the sub claim flows automatically into the access token. other ID token claims like email, groups, or name, etc are intentionally not copied automatically for two reasons/concerns:

a) security - blindly copying external IdP claims into access tokens could leak information the authorization server shouldn't be asserting. The external IdP's claim schema may not match the authorization server's resource model.
b) control - different deployments need different claims. A CI/CD pipeline might need project_path and ref from GitLab, while a federated login might need email and roles from external IDP(Google). There's no safe default.

Instead, the provider stores all ID token claims as a well-known authorization attribute (SUBJECT_TOKEN_CLAIMS_ATTRIBUTE), giving users/clients full control via the standard OAuth2TokenCustomizer - the same extension point already used for other grant types.

Here is example : mapping specific claims in your application

@Bean
OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
    return (context) -> {
        if (AuthorizationGrantType.TOKEN_EXCHANGE.equals(
                context.getAuthorizationGrantType())) {
            Map<String, Object> idTokenClaims = context.getAuthorization()
                .getAttribute(OAuth2TokenExchangeAuthenticationProvider
                    .SUBJECT_TOKEN_CLAIMS_ATTRIBUTE);
            if (idTokenClaims != null) {
                context.getClaims().claim("email", idTokenClaims.get("email"));
                context.getClaims().claim("groups", idTokenClaims.get("groups"));
            }
        }
    };
}

This approch follows the same principle as the existing token exchange flow so that the framework provides the data, the client/application decides what to expose..

++ @jgrandja

I'd appreciate feedback on the following on above changes:

  1. Is SUBJECT_TOKEN_CLAIMS_ATTRIBUTE the right way to expose ID token claims, or would a dedicated method on OAuth2TokenContext be preferred?
  2. Is the setting name settings.client.id-token-jwk-set-url aligned with your naming conventions, or would you prefer something like settings.client.token-exchange-jwk-set-url to be more generic for future non-OIDC token types?

Introduce support for exchanging externally-issued OIDC
ID tokens for access tokens via the OAuth 2.0 Token
Exchange Grant per RFC 8693 Section 3.

- Add urn:ietf:params:oauth:token-type:id_token as a
  supported token type in the converter
- Add OAuth2TokenExchangeSubjectTokenResolver strategy
  interface for resolving external subject tokens
- Add OidcIdTokenSubjectTokenResolver as the default
  implementation using JwtDecoderFactory
- Modify OAuth2TokenExchangeAuthenticationProvider to
  delegate to the resolver before falling back to the
  authorization service
- Auto-wire the resolver bean in the configurer

Closes spring-projectsgh-19048

Signed-off-by: Bapuji Koraganti <bapuk.2008@gmail.com>
- Add ID_TOKEN_JWK_SET_URL to ConfigurationSettingNames
  and typed getter/builder to ClientSettings
- Add default no-arg constructor to resolver that reads
  idTokenJwkSetUrl from ClientSettings automatically
- Store subject token claims as authorization attribute
  so OAuth2TokenCustomizer can map ID token claims to
  the generated access token

Closes spring-projectsgh-19048
Signed-off-by: Bapuji Koraganti <bapuk.2008@gmail.com>
@jgrandja jgrandja self-assigned this Apr 16, 2026
@jgrandja jgrandja added type: enhancement A general enhancement in: oauth2 An issue in OAuth2 modules (oauth2-core, oauth2-client, oauth2-resource-server, oauth2-jose) and removed status: waiting-for-triage An issue we've not yet triaged labels Apr 16, 2026
@jgrandja jgrandja added this to the 7.2.x milestone Apr 16, 2026
@kpur-sbab
Copy link
Copy Markdown

@bkoragan Perfect thanks for the detailed explanation

@jgrandja
Copy link
Copy Markdown
Contributor

@bkoragan Thanks for the PR.

Just a heads up that I have a few high priority items I need to complete before we release 7.1 (May 18).

Please give me a couple of weeks and then I can do an initial review. I've scheduled this for 7.2.x.

Thanks for your patience.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

in: oauth2 An issue in OAuth2 modules (oauth2-core, oauth2-client, oauth2-resource-server, oauth2-jose) type: enhancement A general enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants