Skip to content

MCP Client: Add token exchange (RFC 8693) sample - #89

Open
shin1488 wants to merge 11 commits into
spring-ai-community:mainfrom
shin1488:gh-88-token-exchange-customizer
Open

MCP Client: Add token exchange (RFC 8693) sample#89
shin1488 wants to merge 11 commits into
spring-ai-community:mainfrom
shin1488:gh-88-token-exchange-customizer

Conversation

@shin1488

@shin1488 shin1488 commented Jul 9, 2026

Copy link
Copy Markdown

Adds token exchange as a sample, per the discussion in #88.

What's included

  • samples/sample-mcp-client-token-exchangeOAuth2TokenExchangeSyncHttpRequestCustomizer, a single-grant request customizer for MCP hosts that are OAuth2 resource servers (a service behind a gateway holding a validated user Jwt). It reads the user Authentication from the McpTransportContext — same as the authorization-code customizer — and uses it as the principal of the OAuth2AuthorizeRequest, so the incoming user token is sent as the subject_token and exchanged for a token minted for the MCP call.
  • tokenExchangeAuthorizedClientManager(...) builds the OAuth2AuthorizedClientManager with a TokenExchangeOAuth2AuthorizedClientProvider, and sets subject_token_type explicitly to urn:ietf:params:oauth:token-type:access_token and resource to the MCP server's canonical URI on the token response client.
  • Opt-in failOnMissingAuthentication(boolean). The default skips the Authorization header when no user Authentication is present, in line with the authorization-code customizer.
  • A README covering the topology, the exchange on the wire, the subject token type, the resource parameter, Keycloak setup and initialization guidance.
  • Unit tests, and an integration test in samples/integration-tests.

Design choices (per the questions in #88)

Scope. Ships as a sample rather than inside mcp-client-security, as requested.

Subject token type. Set explicitly on the response client via setParametersCustomizer, rather than re-wrapping the Jwt principal as an OAuth2AccessToken to steer the default subject-token resolver. Same request on the wire, but the intent lives in the request rather than in the shape of the principal. RFC 8693 §3 defines ...:access_token for an access token issued by the authorization server being called — which is what the host holds in this topology — while ...:jwt is defined for sending a JWT as an authorization grant to a different authorization server (RFC 7523). Spring Security derives the identifier from the Java type of the subject token (TokenExchangeGrantRequest maps a Jwt to ...:jwt and any other OAuth2Token to ...:access_token), so a resource server's Jwt would go out as ...:jwt. Spring Authorization Server accepts both, so this only surfaces against servers that hold the distinction, such as Keycloak.

Missing user context. Default is to skip the header; failOnMissingAuthentication(true) throws instead, for multi-tenant setups where a user-scoped request must never go out anonymously.

The resource parameter

The MCP authorization specification requires MCP clients to implement RFC 8707 resource indicators: the resource parameter MUST be included in token requests, MUST identify the MCP server the token is intended for, and MUST be sent regardless of whether the authorization server supports it. The sample now does, and the unit test asserts it on the wire alongside subject_token_type.

Sending it is the client's part. What happens to it is not:

Layer Requirement In this sample
MCP client send resource naming the MCP server done, asserted on the wire
Authorization server reflect resource into aud ResourceIdentifierAudienceTokenCustomizer does this for authorization_code, client_credentials and refresh_token, but not for token exchange — see below
MCP server validate aud validateAudienceClaim defaults to false, so the token is accepted on the shared issuer

The integration test still observes aud: token-exchange-client, and it will keep doing so. ResourceIdentifierAudienceTokenCustomizer reads the resource from OAuth2AuthorizationGrantAuthenticationToken.getAdditionalParameters(), but Spring Authorization Server's OAuth2TokenExchangeAuthenticationConverter explicitly excludes resource (and audience) from the additional parameters and routes them into OAuth2TokenExchangeAuthenticationToken.getResources() instead. The converters for the other grants exclude only their own parameters — grant_type, code, redirect_uri, client_id for authorization_code; grant_type and scope for client_credentials; grant_type, refresh_token and scope for refresh_token — so resource falls through into the additional parameters, which is why the customizer works there. Token exchange is the one grant where RFC 8693 makes resource a first-class, multi-valued parameter, so Spring Authorization Server excludes it by name and passes it as a typed value instead.

So the audience is not bound for token exchange, and that is a library-side gap rather than something the sample can set right. I will report it separately.

Why the resource is bound to the registration

AuthorizedClientServiceOAuth2AuthorizedClientManager loads and saves authorized clients through the OAuth2AuthorizedClientService, which keys them by (clientRegistrationId, principalName) — the resource is not part of that key. A registration shared across several MCP servers would therefore serve a token obtained for one server out of the store when calling another, once audience binding is enabled.

This is the model the library already assumes: McpClientRegistrationRepository resolves the resource of a token request from the registration id. That mapping is populated by dynamic client registration and by client ID metadata documents, though — registrations declared under spring.security.oauth2.client.registration, as a resource-server host does, are stored with a null resource identifier — so the manager takes the resource directly.

Tests

OAuth2TokenExchangeSyncHttpRequestCustomizerTests (9) — Bearer attachment, the user Authentication used as the authorize-request principal, header skipped on missing authentication, opt-in failOnMissingAuthentication, the null-authorized-client guard, the resource requirement, and the token request on the wire: given a Jwt subject token, the form body carries subject_token_type=urn:ietf:params:oauth:token-type:access_token and resource=<the MCP server>.

StreamableHttpTokenExchangeTests (samples/integration-tests) — a user token obtained through the authorization_code flow by one client is exchanged by a different client registered with the token-exchange grant; the greeter tool responds with the original user's identity ("Hello test-user"); the exchanged token differs from the subject token, with sub=test-user preserved and aud containing token-exchange-client. It runs against the Spring Authorization Server based test authorization server, which accepts both ...:access_token and ...:jwt — hence the wire assertion in the unit test.

Not included (follow-ups)

  • The WebClient counterpart (McpOAuth2TokenExchangeExchangeFilterFunction).
  • A Keycloak-based variant of the sample (relates to Add examples for connecting with Auth0 / Keycloak and adding it to an MCP client such as Claude Desktop #42). The sample already sends subject_token_type=urn:ietf:params:oauth:token-type:access_token, which is what Keycloak's standard token exchange requires, but it runs against sample-authorization-server; the Keycloak specifics (26.2 floor, standard.token.exchange.enabled, audience mappers) stay as README notes for now.

Verification

./mvnw spring-javaformat:apply
./mvnw clean install -DskipTests
./mvnw -pl samples/sample-mcp-client-token-exchange test
./mvnw -f samples/integration-tests/pom.xml test -Dtest=StreamableHttpTokenExchangeTests

shin1488 added 3 commits July 10, 2026 01:57
Add OAuth2TokenExchangeSyncHttpRequestCustomizer, for MCP hosts that
are OAuth2 resource servers rather than the application the user logs
into (e.g. a service behind a gateway). The customizer reads the user
Authentication from the McpTransportContext and uses it as the
principal of the OAuth2AuthorizeRequest, so the incoming user token is
sent as the subject_token and exchanged for a token whose audience is
the MCP server.

When no user authentication is present, no Authorization header is
added, in line with the authorization_code customizer. An opt-in
failOnMissingAuthentication flag throws instead, for multi-tenant
setups where user-scoped requests must not be sent anonymously.

See spring-ai-community#88

Signed-off-by: Yeongchan Shin <syc1488@naver.com>
Per the discussion in spring-ai-community#88, hold off on integrating token exchange into
mcp-client-security, and ship it as a sample instead. The customizer
moves to a new samples/sample-mcp-client-token-exchange module, with
the Keycloak-compatible subject token resolver (sending the subject
token as an access token rather than a JWT) bundled via a factory
method, and a README covering the resource-server topology, Keycloak
notes, and initialization guidance.

See spring-ai-community#88

Signed-off-by: Yeongchan Shin <syc1488@naver.com>
Verify the token exchange flow end-to-end: a user token obtained
through the authorization_code flow by one client is exchanged by a
different client registered with the token-exchange grant, and the
exchanged token is used against the streamable-HTTP MCP server. The
greeter tool responds with the original user's identity, and the
outgoing token is newly issued with sub preserved and aud set to the
exchanging client.

See spring-ai-community#88

Signed-off-by: Yeongchan Shin <syc1488@naver.com>
@shin1488 shin1488 changed the title MCP Client: Add token exchange (RFC 8693) request customizer MCP Client: Add token exchange (RFC 8693) sample Jul 10, 2026
@shin1488

Copy link
Copy Markdown
Author

Reworked per the discussion in #88: token exchange now ships as a sample module instead of being integrated into mcp-client-security.

What's included

  • samples/sample-mcp-client-token-exchange: the token-exchange request customizer for MCP hosts that are OAuth2 resource servers, with the subject-token resolver inlined via a factory method (sends ...:access_token, accepted by both Keycloak and Spring Authorization Server), unit tests, and a README covering the topology, Keycloak notes, and initialization guidance.
  • An integration test in samples/integration-tests exercising the full flow against the test authorization server and the streamable-HTTP MCP server.

Verification

./mvnw spring-javaformat:apply
./mvnw clean install -DskipTests
./mvnw -pl samples/sample-mcp-client-token-exchange test
./mvnw -f samples/integration-tests/pom.xml test -Dtest=StreamableHttpTokenExchangeTests
  • Sample unit tests: 7/7 pass (OAuth2TokenExchangeSyncHttpRequestCustomizerTests) — Bearer attachment, the user Authentication used as the authorize-request principal, header skipped on missing authentication, opt-in failOnMissingAuthentication, and the null-authorized-client guard.
  • Integration test: 1/1 pass (StreamableHttpTokenExchangeTests) — a user token obtained through the authorization_code flow by one client is exchanged by a different client registered with the token-exchange grant; the greeter tool responds with the original user's identity ("Hello test-user"); the outgoing token differs from the subject token, with sub=test-user preserved and aud containing token-exchange-client. The subject token is carried as a Jwt principal, so the access-token-wrapping resolver is exercised as well.

@shin1488
shin1488 marked this pull request as ready for review July 10, 2026 15:38
@shin1488
shin1488 force-pushed the gh-88-token-exchange-customizer branch 2 times, most recently from 519321e to c57b583 Compare July 10, 2026 21:12
Label the token audiences in the topology diagram, show the exchange
request and the resulting claims as observed in the integration test,
split the initialization guidance into focused paragraphs, and tighten
the wording around resource-server hosts and client_credentials.

See spring-ai-community#88

Signed-off-by: Yeongchan Shin <syc1488@naver.com>
Set subject_token_type on the token response client with
setParametersCustomizer, instead of re-wrapping the Jwt principal as an
OAuth2AccessToken to steer the default subject token resolver. The
request on the wire is unchanged, but the intent now lives in the
request rather than in the shape of the principal.

Also reframe the documentation: RFC 8693 section 3 defines
...:access_token for an access token issued by the authorization server
being called, which is what a resource-server host holds, while
...:jwt is defined for sending a JWT as an authorization grant to a
different authorization server. Spring Authorization Server accepts both
types, so the distinction only surfaces against servers that hold it,
such as Keycloak.

See spring-ai-community#88

Signed-off-by: Yeongchan Shin <syc1488@naver.com>
@shin1488

Copy link
Copy Markdown
Author

Two updates to the sample, both about how subject_token_type is set.

Mechanism. It is now set explicitly on the token response client, via RestClientTokenExchangeTokenResponseClient.setParametersCustomizer(...), instead of re-wrapping the Jwt principal as an OAuth2AccessToken to steer the default subject-token resolver. Same request on the wire, but the intent lives in the request rather than in the shape of the principal.

Framing. I had described ...:access_token as a Keycloak accommodation, which undersells it. RFC 8693 §3 defines that type for an access token issued by the authorization server being called — which is exactly what a resource-server host holds — while ...:jwt is defined for sending a JWT as an authorization grant to a different authorization server (RFC 7523). Spring Security derives the type from the Java type of the subject token, so a Jwt principal goes out as ...:jwt; Spring Authorization Server accepts both, so this only surfaces against servers that hold the distinction, such as Keycloak. The docs now say that.

Worth noting that RFC 8693 §1 names this topology directly: "An OAuth resource server, for example, might assume the role of the client during token exchange", and the first example in the spec walks through exactly that.

Integration test still passes.

…ample

The integration test runs against Spring Authorization Server, which accepts
both urn:ietf:params:oauth:token-type:access_token and ...:jwt, so it would
still pass with the subject_token_type customization removed. Add a unit test
that asserts the token request itself: given a Jwt subject token, the form
body must carry subject_token_type=...:access_token. Extract the token
response client into a package-private factory method so the request can be
exercised against a MockRestServiceServer.

Also correct the README and javadoc, which attributed the type identifier to
the subject token resolver. The resolver only chooses which token to send;
the identifier comes from TokenExchangeGrantRequest, which maps a Jwt to
...:jwt and any other OAuth2Token to ...:access_token.

See spring-ai-community#88

Signed-off-by: Yeongchan Shin <syc1488@naver.com>
@shin1488

Copy link
Copy Markdown
Author

Two follow-ups, both about subject_token_type.

Test. The integration test runs against Spring Authorization Server, which accepts both
...:access_token and ...:jwt, so it would still pass if the setParametersCustomizer(...) line
were removed — the behaviour the previous commit added was unguarded. Added a unit test that asserts
the token request itself: given a Jwt subject token, the form body must carry
subject_token_type=urn:ietf:params:oauth:token-type:access_token. The token response client is now
built by a package-private factory method so it can be exercised against a MockRestServiceServer.

Docs. The README and javadoc said the subject token resolver derives subject_token_type. It
does not: the resolver only chooses which token to send, and the identifier comes from
TokenExchangeGrantRequest, which maps a Jwt to ...:jwt and any other OAuth2Token to
...:access_token. Corrected both.

Sample tests 8/8, integration test 1/1. I have also updated the PR description, which still described
the original draft.

The MCP authorization specification requires MCP clients to implement RFC
8707 resource indicators: the resource parameter must be included in token
requests, must identify the MCP server the token is intended for, and must
be sent regardless of whether the authorization server supports it. The
sample did not send it. tokenExchangeAuthorizedClientManager now takes the
MCP server's canonical URI and sets it on every exchange, asserted on the
wire next to subject_token_type.

The resource is bound to the ClientRegistration rather than resolved per
request. AuthorizedClientServiceOAuth2AuthorizedClientManager loads and
saves authorized clients through the OAuth2AuthorizedClientService, which
keys them by (clientRegistrationId, principalName), and the resource is not
part of that key. This is the model the library already assumes, as
McpClientRegistrationRepository resolves the resource of a token request
from the registration id, but that mapping is only populated by dynamic
client registration, so a resource-server host with statically declared
registrations passes the resource in.

Also correct the javadoc, which claimed the exchanged token's audience is
the MCP server. It is a newly issued token that preserves the user's
identity; whether the resource reaches the aud claim is up to the
authorization server, and Spring Authorization Server issues aud as the
client the token was issued to.

See spring-ai-community#88

Signed-off-by: Yeongchan Shin <syc1488@naver.com>
@shin1488
shin1488 force-pushed the gh-88-token-exchange-customizer branch from a0d5efd to 09d5747 Compare July 13, 2026 04:13
@shin1488

shin1488 commented Jul 13, 2026

Copy link
Copy Markdown
Author

Two changes from a review pass on the audience side.

resource is now sent. The MCP authorization specification requires MCP clients to implement
RFC 8707: the resource parameter MUST be included in token requests, MUST identify the MCP server
the token is intended for, and MUST be sent regardless of whether the authorization server supports
it. The sample was not sending it. It now does on every exchange, asserted on the wire next to
subject_token_type. I also corrected the class javadoc, which claimed the exchanged token's audience is the MCP server — it is not. Binding it requires the authorization server to reflect resource into aud; sample-authorization-server does not do so for this grant. I will follow up on that separately.

The resource is bound to the ClientRegistration rather than resolved per request.
AuthorizedClientServiceOAuth2AuthorizedClientManager loads and saves authorized clients through
the OAuth2AuthorizedClientService, which keys them by (clientRegistrationId, principalName), and
the resource is not part of that key — so a registration shared across several MCP servers would
serve a token obtained for one out of the store when calling another, once audience binding is on.
That is the model the library already assumes: McpClientRegistrationRepository resolves the
resource of a token request from the registration id. That mapping is only populated by DCR,
though, and a resource-server host declares its registrations statically, so the manager takes the
resource directly.

Sending resource is the client's part; whether it reaches the token's aud, and whether aud is
checked, are authorization server and MCP server concerns. On that: the audience is not bound for
token exchange, and the sample cannot set that right on its own —
ResourceIdentifierAudienceTokenCustomizer reads resource from the grant's additional parameters,
and Spring Authorization Server's token exchange converter routes it into
OAuth2TokenExchangeAuthenticationToken.getResources() instead. Raised in #88.

@shin1488
shin1488 marked this pull request as ready for review July 13, 2026 04:23
The sample cited the 2025-06-18 revision; 2025-11-25 is the current one
in force. The resource-parameter-implementation anchor and the resource
MUST clause are unchanged in that revision.

Signed-off-by: Yeongchan Shin <syc1488@naver.com>
@shin1488

shin1488 commented Jul 15, 2026

Copy link
Copy Markdown
Author

Updated the MCP authorization specification links from the 2025-06-18 revision to
2025-11-25, the current one in force. The #resource-parameter-implementation anchor and
the resource MUST clause are unchanged there, so nothing in the sample's reasoning shifts —
the citations just point at the revision the reader will land on.

Three references: two in the README, one in the customizer javadoc.

shin1488 added 2 commits July 15, 2026 21:27
RFC 8707 requires the `resource` parameter to identify the MCP server the token is for.
The MCP server derives its own identifier from the request as
`<scheme>://<host>:<port><contextPath><resourcePath>` (default `/mcp`), so a client that
sends only the base URL names a resource no server can match.

Send the canonical `/mcp` URI in the integration test, and document what the value must be,
including the reverse-proxy case where the server needs forwarded-header handling to
reconstruct the external URL — otherwise audience validation rejects the token with a 401
that is hard to diagnose from the client side.
…xchange sample

The integration test comment described a subject-token resolver re-wrapping the Jwt as an
access token. The sample configures no resolver: it sends the Jwt as the subject token and
sets subject_token_type explicitly on the token response client. Describe that instead, so
the sample is not read as controlling the type through the Java class of the token.

The same comment named a "default-client" registration; the registration is "authserver",
and "default-client" is its client id. The audience sentence now says the resource is what
an MCP server checks aud against once audience validation is enabled, since this test leaves
validateAudienceClaim at its default of false.

Fix three stale references to the canonical resource URI as well: the README pointed at a
nested test class SubjectTokenType that was renamed to TokenRequestParameters, its usage
snippet passed a base URL where the resource must be the canonical URI, and the unit test
fixture did the same.
@shin1488
shin1488 force-pushed the gh-88-token-exchange-customizer branch from 22e282c to 21bc63a Compare July 26, 2026 14:02
…n exchange sample

The README and the customizer javadoc said McpClientRegistrationRepository's resource
mapping is populated only by dynamic client registration. DefaultMcpOAuth2CimdClientManager
populates it as well, from the resource in the protected resource metadata.

The point the passage makes is unchanged: registrations declared under
spring.security.oauth2.client.registration are stored with a null resource identifier, which
is why the sample passes the resource to the manager.

Signed-off-by: Yeongchan Shin <syc1488@naver.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant