Skip to content

Add ID token support for token exchange#19076

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

Add ID token support for token exchange#19076
bkoragan wants to merge 2 commits into
spring-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

bkoragan commented Apr 16, 2026

Copy link
Copy Markdown
Author

@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
Collaborator

@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.

@shin1488

shin1488 commented Jul 11, 2026

Copy link
Copy Markdown

Hope it's fine to chime in here — this isn't a review comment on the change itself, but a
client-side observation that might be worth knowing while the API is being shaped.

Once this lands, Spring Authorization Server will accept
subject_token_type=urn:ietf:params:oauth:token-type:id_token. On the client side, Spring Security
cannot produce that request through its default parameters: TokenExchangeGrantRequest.tokenType()
derives the identifier with (token instanceof Jwt) ? jwt : access_token, so ...:id_token is not
one of the two values it can emit — and an OidcIdToken subject token is in fact sent as
...:access_token today.

I filed that separately as gh-19436. Raising it here only because the
OAuth2TokenExchangeSubjectTokenResolver strategy in this PR looks like the natural shape for a
client-side counterpart, so it may be cheaper to consider the two together while this is under
review. Happy to be told it's out of scope for this PR.

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.

5 participants