Skip to content

Token exchange: the RFC 8693 type identifiers cannot be stated by the application #19436

Description

@shin1488

Summary

RFC 8693 section 2.1 gives this grant three type parameters — subject_token_type, actor_token_type and requested_token_type — each specified as "an identifier, as described in Section 3". None of the three can be stated by the application. They are broken in three distinct ways.

subject_token_type: the inference is undecidable. It is derived from the Java type of the subject token by TokenExchangeGrantRequest.tokenType(). The same Jwt class arrives both when the token was issued by the authorization server being called — where ...:access_token is correct — and in the cross-domain case, where ...:jwt is. The correct identifier differs; the Java type does not. This is the framework's own documented path, and Ping (gh-16486) and Keycloak reject what it sends.

subject_token_type: one identifier has no type at all. tokenType() has two outputs. Section 3 defines five identifiers and leaves the set open; the type hierarchy is closed, and nothing in it denotes a SAML 2.0 assertion — which the Identity Assertion JWT Authorization Grant accepts as a subject token today. Consequence 1 can at least be worked around by misrepresenting the Java type; here there is no type to misrepresent into.

requested_token_type: it can be neither omitted nor changed. It is set to ...:access_token unconditionally. That value is right for most exchanges, so the constant is not a defect on its own — but section 2.1 makes it OPTIONAL and gives omission a meaning, and an application cannot omit it. Nor can it send ...:id-jag, which the Identity Assertion JWT Authorization Grant requires.

No better mapping resolves any of the three: a mapping can only infer an identifier, and what is missing is the ability to state one — requested_token_type is not derived from a token at all.

Overriding the request parameters works — it is the answer given in gh-16486, and it is what I do. That is not the ask. The ask is that these identifiers be expressible rather than inferred.

#19448 covers a separable defect in the same method: an OidcIdToken subject token is labelled ...:access_token. A mapping change resolves that one. It does not make any of the three above statable.

Details and sources below. I am happy to submit the PR.

Expected Behavior

The identifier sent for a token should reflect that token's role and the identity of its issuer relative to the authorization server being called. That is what the identifiers in RFC 8693 section 3 denote:

urn:ietf:params:oauth:token-type:access_token
Indicates that the token is an OAuth 2.0 access token issued by
the given authorization server.

Deriving the identifier from the Java type cannot do that. The type hierarchy mixes role types (OAuth2AccessToken, OidcIdToken) with a format type (Jwt), and section 3 states that role and format are orthogonal:

The distinction between an access token and a JWT is subtle. An access token represents a delegated authorization decision, whereas JWT is a token format. An access token can be formatted as a JWT but doesn't necessarily have to be. And a JWT might well be an access token, but not all JWTs are access tokens.

Above all, no Java type carries the issuer relationship, and the correct identifier depends on it. It should be possible to state the identifier explicitly.

Current Behavior

Notation: ...:access_token, ...:id_token, ...:id-jag, ...:jwt abbreviate the corresponding urn:ietf:params:oauth:token-type:* URNs. Jwt, OidcIdToken and OAuth2AccessToken are Java types.

TokenExchangeGrantRequest derives the type identifier from the Java type of the token:

private static final String ACCESS_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:access_token";
private static final String JWT_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:jwt";

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

These identifiers are private static final fields of TokenExchangeGrantRequest. They are not OAuth2ParameterNames-style constants, not a public enum, not resolvable — the only way an application reaches the wire value is by replacing the whole parameters map. OAuth2TokenExchangeAuthenticationConverter declares the same two constants again, under the same names, for the authorization server side. RFC 8693 treats these identifiers as a first-class, open-ended vocabulary; here they are literals duplicated across the classes that need them.

This has been the behaviour since the grant was introduced in 6.3 — the logic moved from TokenExchangeGrantRequestEntityConverter to TokenExchangeGrantRequest.defaultParameters() in 6.4, but the method itself is unchanged.

This method is the only source of subject_token_type in the default parameters for the grant — and of actor_token_type, which uses the same method. It is reached identically on the servlet and reactive stacks:

defaultParameters() also sets the grant's remaining type parameter, requested_token_type, to the same constant, unconditionally:

parameters.set(OAuth2ParameterNames.REQUESTED_TOKEN_TYPE, ACCESS_TOKEN_TYPE_VALUE);

The authorization server side treats that parameter as OPTIONAL and applies section 2.1's default when it is absent:

// requested_token_type (OPTIONAL)
String requestedTokenType = parameters.getFirst(OAuth2ParameterNames.REQUESTED_TOKEN_TYPE);
if (StringUtils.hasText(requestedTokenType)) {
    // ...
}
else {
    requestedTokenType = ACCESS_TOKEN_TYPE_VALUE;
}

A client in this repository cannot exercise the branch an authorization server in this repository implements.

A two-valued mapping over a class hierarchy cannot express the identifiers available to this grant. Section 3 defines five — ...:access_token, ...:refresh_token, ...:id_token, ...:saml1, ...:saml2 — and cites ...:jwt as defined in RFC 7519 section 9. Nor is the set closed:

Other URIs MAY be used to indicate other token types.

One profile that uses both the defined identifiers and that extension point is referenced twice below, so it is worth introducing once. draft-ietf-oauth-identity-assertion-authz-grant-04, adopted by the OAuth working group, profiles this grant to obtain an Identity Assertion JWT Authorization Grant (ID-JAG): an application that has completed SSO presents the identity assertion it already holds and receives a grant it can redeem for an access token at a third party's authorization server, with no interactive redirect and no per-user consent. It is aimed at integration between SaaS applications, which Okta ships as Cross App Access; the Enterprise-Managed Authorization extension to the Model Context Protocol is built on it and is published as a Stable extension. Section 4.3 of the draft specifies the token exchange request. I have not deployed the flow myself; what follows is a reading of the draft against the default parameters.

1. subject_token_type: the inference is undecidable

RFC 8693 section 3 elaborates on ...:access_token:

The intent of this specification is that "urn:ietf:params:oauth:token-type:access_token" be an
indicator that the token is a typical OAuth access token issued by the authorization server in
question, opaque to the client, and usable the same manner as any other access token obtained from
that authorization server. (It could well be a JWT, but the client isn't and needn't be aware of
that fact.) Whereas, "urn:ietf:params:oauth:token-type:jwt" is to indicate specifically that a JWT
is being requested or sent (perhaps in a cross-domain use case where the JWT is used as an
authorization grant to obtain an access token from a different authorization server as is
facilitated by [RFC7523]).

A resource server that exchanges the access token it has just validated, at the same authorization server that issued that token, is the first case. RFC 8693 section 1 names that role explicitly:

An OAuth resource server, for example, might assume the role of the client during token exchange in
order to trade an access token that it received in a protected resource request for a new token
that is appropriate to include in a call to a backend service.

Both cases arrive as a Jwt. The class does not record which authorization server issued the token, so no mapping over Java types can separate them.

Comparing the iss claim against the registration's issuer would work in the common case, but not in general. ClientRegistration.ProviderDetails.getIssuerUri() is @Nullable — "the issuer identifier uri ..., or null if not set" — and ClientRegistration.Builder.build() performs no grant-specific validation for token-exchange, so issuerUri is never required for such a registration. A registration configured with token-uri alone has nothing to compare against, and the inference would then silently pick an identifier, the failure surfacing only as a rejected exchange at runtime.

The mapping resolves this in favour of ...:jwt, and that is the wrong way round for the default path. The default subject token resolver returns context.getPrincipal().getPrincipal() when it is an OAuth2Token. Of the Authentication implementations shipped with Spring Security, only JwtAuthenticationToken satisfies that: its principal is the Jwt itself. OAuth2AuthenticationToken carries an OAuth2User, and BearerTokenAuthentication carries an OAuth2AuthenticatedPrincipal; neither is an OAuth2Token, so neither resolves at all and the grant does not run. The only subject token the framework resolves out of the box is a Jwt, and it is always labelled ...:jwt. The reference documentation's own token exchange example is exactly this path — its NOTE says "hence the use of JwtAuthenticationToken".

Authorization servers that hold the distinction reject the resulting request:

  • Ping, reported in gh-16486.
  • Keycloak, since Standard Token Exchange became generally available in 26.2. Its token exchange guide lists the allowed subject_token_type for the standard exchange as "Access token type only". Keycloak's legacy V1 exchange did accept ...:jwt for external-to-internal scenarios, but the same guide states that "Token Exchange Service is Preview and Deprecated. This feature is not fully supported, disabled by default, and will be removed in future versions".

Spring Authorization Server accepts both identifiers, which is presumably why this does not surface in Spring Security's own tests.

A type-level workaround is what a reader will reach for: a resource server can wrap the Jwt it holds in an OAuth2AccessToken inside the resolver and get ...:access_token that way. It works, and it is exactly the problem. A protocol parameter is being controlled by misrepresenting the Java type of the token, because the Java type is the only lever there is.

The accepted solution in gh-15408 confirms the default path from the other side: an OIDC-login client resolves no subject token at all by default, and the fix is a custom resolver — one that happens to return an OAuth2AccessToken, and therefore happens to send ...:access_token.

2. subject_token_type: one identifier has no type at all

tokenType() has two possible outputs, ...:jwt and ...:access_token. Every other identifier this grant needs can only be sent by overriding the request parameters — that is, by bypassing the framework's mapping rather than configuring it.

Section 4.3 of the ID-JAG draft names three:

subject_token_type

REQUIRED - An identifier, as described in Section 3 of [RFC8693], that indicates the type of the security token in the subject_token parameter. For an OpenID Connect ID Token: urn:ietf:params:oauth:token-type:id_token, for a SAML 2.0 Assertion: urn:ietf:params:oauth:token-type:saml2, and for a Refresh Token (when supported): urn:ietf:params:oauth:token-type:refresh_token.

Of the three, ...:saml2 is the one with no type at all. spring-security-saml2-service-provider declares no dependency on any oauth2 module — its only Spring Security dependency is spring-security-web — so OAuth2Token is not on its compile classpath, and nothing SAML login produces can be a subject token. On the other side, the eight subclasses of AbstractOAuth2TokenJwt, OAuth2AccessToken, OAuth2AuthorizationCode, OAuth2DeviceCode, OAuth2RefreshToken, OAuth2UserCode, OidcIdToken and OidcLogoutToken — contain no SAML assertion either.

An application on that path must therefore implement OAuth2Token itself to carry the assertion, and tokenType() labels it ...:access_token, because that is what everything which is not a Jwt is labelled. Consequence 1 at least has a lever: misrepresent the Java type and the right identifier comes out. Here there is no type to misrepresent into.

The other two are consequence 1 arriving through this parameter. #19448 would make ...:id_token reachable by mapping OidcIdToken to it — reachable, not statable: the application still gets whichever identifier the method infers. A refresh token arrives as OAuth2RefreshToken and takes the ...:access_token branch; ...:refresh_token is defined, like ...:access_token, as issued by the given authorization server, so mapping to it would be the same inference consequence 1 describes.

That is the shape. Section 3's vocabulary is open — "Other URIs MAY be used to indicate other token types" — and the type hierarchy is closed, so a mapping is a snapshot of the identifiers that happened to have a Java type when it was written. Widening it closes one identifier and leaves the application no more able to state one than before. ...:id-jag, which section 3 does not define at all, is where that runs out entirely — see consequence 3.

3. requested_token_type: it can be neither omitted nor changed

defaultParameters() sets this to ...:access_token unconditionally. That is the right value for most exchanges, so the constant is not a defect on its own. The defect is that it is the only value an application can send.

Section 2.1 specifies the parameter as OPTIONAL, and gives omission a meaning:

requested_token_type

OPTIONAL. An identifier, as described in Section 3, for the type of the requested security token. If the requested type is unspecified, the issued token type is at the discretion of the authorization server and may be dictated by knowledge of the requirements of the service or resource indicated by the resource or audience parameter.

An application that wants to defer to the authorization server cannot: the parameter is always sent. And an application that needs a different value cannot send one. Section 4.3 of the ID-JAG draft is a concrete case of the latter:

requested_token_type

REQUIRED - The value urn:ietf:params:oauth:token-type:id-jag indicates that an Identity Assertion JWT Authorization Grant is being requested.

That identifier is not among the five section 3 defines — it is a use of the extension point section 3 leaves open. An application cannot use that extension point here, because the parameter is a constant.

The draft also requires ...:id-jag when a Refresh Token is used as the subject token, which is how a client refreshes an ID-JAG without a new SSO round trip. So the value is not reachable on any path of that profile, not just the first one.

Taken with consequence 2, one real profile is blocked on two of this grant's three type parameters at once.

Proposal

Make the type identifiers expressible rather than inferred, smallest surface first.

  1. Resolve the identifier alongside the token. The providers already expose setSubjectTokenResolver(Function<OAuth2AuthorizationContext, OAuth2Token>); what is missing is the type. Either add a companion setSubjectTokenTypeResolver(Function<OAuth2AuthorizationContext, String>), or have a single resolver return the token together with its identifier — and add a TokenExchangeGrantRequest constructor taking subjectTokenType, actorTokenType and requestedTokenType, keeping the existing constructor and its current behaviour as the default. tokenType() is the single source of these parameters, so the API surface stays small. This is the client-side counterpart of the OAuth2TokenExchangeSubjectTokenResolver strategy proposed in Add ID token support for token exchange #19076.

    Whatever the shape, requested_token_type needs a way to express "unset" as distinct from access_token, so that section 2.1's default applies.

  2. Or a setting on the client registration. ClientRegistration.ClientSettings already exists (currently only requireProofKey), and Add ID token support for token exchange #19076 proposes adding idTokenJwkSetUrl to the authorization server's RegisteredClient.ClientSettings — the symmetry is exact. This is also close to what was raised in gh-16486: "I think adding a way to somehow associate additional parameters with the ClientRegistration could be beneficial for multiple use cases."

  3. Document the default parameters for this grant. "Customizing Request Parameters" explains setParametersConverter and setParametersCustomizer, and notes that "Default parameters are always provided", but nowhere states what the default parameters for this grant are — in particular that subject_token_type is derived from the Java type of the subject token. Without that, a user has no way to know the value needs correcting in the first place. In Token exchange with a jwt access_token send a subject token type 'jwt', my oidc provider only accept 'access_token' #16486 the reporter had to be told by a maintainer that it could be overridden at all: "I was looking on the default converter & co and face '.addAll' methods (DefaultTokenXxx class). Did not go down the rabbit hole to the documentation nor on the request." This item is independent of the rest and can land on its own. It is also the feedback invited when Token exchange with a jwt access_token send a subject token type 'jwt', my oidc provider only accept 'access_token' #16486 was closed: "if you have any additional thoughts or feedback on whether this customization meets your needs or the documentation around it, please add an additional comment."

Changing the default for a Jwt subject token is a behaviour change, so I am not asking for that up front.

I am happy to submit a PR for whichever direction you prefer, including the documentation.

Context

I hit this while building a Model Context Protocol (MCP) host that is an OAuth2 resource server sitting behind a gateway, and that calls MCP servers with the end user's identity. The MCP authorization specification requires audience validation and forbids token passthrough, so the incoming user token has to be exchanged for a token bound to the MCP server's audience. The host is therefore a resource server acting as the client of the exchange, which is the role RFC 8693 section 1 names. Against Keycloak, every exchange was rejected until subject_token_type was overridden. A sample and integration test of the flow are public; they run against Spring Authorization Server, which accepts both identifiers, so they demonstrate the flow rather than the rejection.

That topology exchanges directly at a single authorization server, so consequence 1 is the one I met in practice. Consequences 2 and 3 belong to the cross-domain case, where the same design is more visibly wrong.

I am opening this rather than commenting on gh-16486 because that issue was closed as answered, and the discussion there moved on to configuring request parameters in general. The mapping itself was never addressed.

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