Skip to content

TokenExchangeGrantRequest sends an OidcIdToken subject token as ...:access_token #19448

Description

@shin1488

Describe the bug

TokenExchangeGrantRequest.tokenType() derives subject_token_type (and actor_token_type) from the Java type of the token:

private static String tokenType(OAuth2Token token) {
    return (token instanceof Jwt) ? JWT_TOKEN_TYPE_VALUE : ACCESS_TOKEN_TYPE_VALUE;
}

OidcIdToken and Jwt are siblings, not parent and child — both extend AbstractOAuth2Token:

// oauth2-core
public class OidcIdToken extends AbstractOAuth2Token implements IdTokenClaimAccessor { ... }

// oauth2-jose  (depends on oauth2-core)
public class Jwt extends AbstractOAuth2Token implements JwtClaimAccessor { ... }

An OidcIdToken is therefore an OAuth2Token but not a Jwt, so it takes the ACCESS_TOKEN_TYPE_VALUE branch. An ID token is labelled an access token, and there is no scenario in which that is the correct identifier.

RFC 8693 section 3 defines urn:ietf:params:oauth:token-type:access_token as indicating "an OAuth 2.0 access token issued by the given authorization server", and defines urn:ietf:params:oauth:token-type:id_token separately for an ID token.

This is not an oversight that a subclass would fix. OidcIdToken lives in oauth2-core and Jwt lives in oauth2-jose, which depends on oauth2-core; the dependency direction means OidcIdToken cannot extend Jwt. So instanceof Jwt has never been — and can never be — true for an ID token, even though an ID token is by definition a JWT.

The behaviour has been present since the grant was introduced in 6.3. The logic moved from TokenExchangeGrantRequestEntityConverter to TokenExchangeGrantRequest.defaultParameters() in 6.4; the method itself is unchanged. Confirmed on main.

To Reproduce

Public API only — no external project or custom resolver needed:

ClientRegistration registration = ClientRegistration.withRegistrationId("exchange")
        .clientId("client")
        .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
        .tokenUri("https://example.com/oauth2/token")
        .build();

OidcIdToken idToken = OidcIdToken.withTokenValue("id-token").claim("sub", "user").build();

MultiValueMap<String, String> parameters =
        new DefaultOAuth2TokenRequestParametersConverter<TokenExchangeGrantRequest>()
                .convert(new TokenExchangeGrantRequest(registration, idToken, null));

parameters.getFirst("subject_token_type");
// -> "urn:ietf:params:oauth:token-type:access_token"

Expected behavior

subject_token_type should be urn:ietf:params:oauth:token-type:id_token.

How this is reached in practice

An OidcIdToken reaches the grant request through a custom subject token resolver — for example one returning oidcUser.getIdToken(). No Authentication shipped with Spring Security carries an OidcIdToken as its principal, so the default resolver never produces one.

That is the "Federated Identity" and "Backend-to-Backend with User Context" use cases raised in gh-19048 and acknowledged there as "solid use cases for id_token exchange" — in both, a Spring application is the client of the exchange and the subject token is an ID token. gh-19076 is implementing the server half now: an OidcIdTokenSubjectTokenResolver reading the IdP's JWKS URL from ClientSettings.idTokenJwkSetUrl(), whose first test scenario exchanges a valid ID token and expects an access token with the correct sub.

Relationship to #19436

#19436 asks that the RFC 8693 type identifiers be stated rather than inferred. That ask stands whatever happens here: no better mapping resolves any of its three defects — a mapping can only infer an identifier, and what is missing is the ability to state one.

This one is separable: gh-19436 stands without it, and a mapping change resolves this while leaving gh-19436's three defects untouched. And whatever resolver strategy eventually lands, tokenType() remains the default — an application that does not state an identifier still gets one from this method, and for an OidcIdToken it will still be wrong.

Mapping OidcIdToken to ...:id_token is not a behaviour change for anyone who is correct today, because labelling an ID token as an access token is never right.

I am happy to submit the PR.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions