MCP Client: Add token exchange (RFC 8693) sample - #89
Conversation
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>
|
Reworked per the discussion in #88: token exchange now ships as a sample module instead of being integrated into What's included
Verification
|
519321e to
c57b583
Compare
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>
c57b583 to
06e6f3e
Compare
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>
|
Two updates to the sample, both about how Mechanism. It is now set explicitly on the token response client, via Framing. I had described 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>
|
Two follow-ups, both about Test. The integration test runs against Spring Authorization Server, which accepts both Docs. The README and javadoc said the subject token resolver derives Sample tests 8/8, integration test 1/1. I have also updated the PR description, which still described |
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>
a0d5efd to
09d5747
Compare
|
Two changes from a review pass on the audience side.
The Sending |
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>
|
Updated the MCP authorization specification links from the Three references: two in the README, one in the customizer javadoc. |
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.
22e282c to
21bc63a
Compare
…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>
Adds token exchange as a sample, per the discussion in #88.
What's included
samples/sample-mcp-client-token-exchange—OAuth2TokenExchangeSyncHttpRequestCustomizer, a single-grant request customizer for MCP hosts that are OAuth2 resource servers (a service behind a gateway holding a validated userJwt). It reads the userAuthenticationfrom theMcpTransportContext— same as the authorization-code customizer — and uses it as the principal of theOAuth2AuthorizeRequest, so the incoming user token is sent as thesubject_tokenand exchanged for a token minted for the MCP call.tokenExchangeAuthorizedClientManager(...)builds theOAuth2AuthorizedClientManagerwith aTokenExchangeOAuth2AuthorizedClientProvider, and setssubject_token_typeexplicitly tourn:ietf:params:oauth:token-type:access_tokenandresourceto the MCP server's canonical URI on the token response client.failOnMissingAuthentication(boolean). The default skips theAuthorizationheader when no userAuthenticationis present, in line with the authorization-code customizer.resourceparameter, Keycloak setup and initialization guidance.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 theJwtprincipal as anOAuth2AccessTokento 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_tokenfor an access token issued by the authorization server being called — which is what the host holds in this topology — while...:jwtis 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 (TokenExchangeGrantRequestmaps aJwtto...:jwtand any otherOAuth2Tokento...:access_token), so a resource server'sJwtwould 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
resourceparameter 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 alongsidesubject_token_type.Sending it is the client's part. What happens to it is not:
resourcenaming the MCP serverresourceintoaudResourceIdentifierAudienceTokenCustomizerdoes this forauthorization_code,client_credentialsandrefresh_token, but not for token exchange — see belowaudvalidateAudienceClaimdefaults tofalse, so the token is accepted on the shared issuerThe integration test still observes
aud: token-exchange-client, and it will keep doing so.ResourceIdentifierAudienceTokenCustomizerreads theresourcefromOAuth2AuthorizationGrantAuthenticationToken.getAdditionalParameters(), but Spring Authorization Server'sOAuth2TokenExchangeAuthenticationConverterexplicitly excludesresource(andaudience) from the additional parameters and routes them intoOAuth2TokenExchangeAuthenticationToken.getResources()instead. The converters for the other grants exclude only their own parameters —grant_type,code,redirect_uri,client_idforauthorization_code;grant_typeandscopeforclient_credentials;grant_type,refresh_tokenandscopeforrefresh_token— soresourcefalls through into the additional parameters, which is why the customizer works there. Token exchange is the one grant where RFC 8693 makesresourcea 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
AuthorizedClientServiceOAuth2AuthorizedClientManagerloads and saves authorized clients through theOAuth2AuthorizedClientService, which keys them by(clientRegistrationId, principalName)— theresourceis 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:
McpClientRegistrationRepositoryresolves theresourceof a token request from the registration id. That mapping is populated by dynamic client registration and by client ID metadata documents, though — registrations declared underspring.security.oauth2.client.registration, as a resource-server host does, are stored with anullresource identifier — so the manager takes theresourcedirectly.Tests
OAuth2TokenExchangeSyncHttpRequestCustomizerTests(9) — Bearer attachment, the userAuthenticationused as the authorize-request principal, header skipped on missing authentication, opt-infailOnMissingAuthentication, the null-authorized-client guard, theresourcerequirement, and the token request on the wire: given aJwtsubject token, the form body carriessubject_token_type=urn:ietf:params:oauth:token-type:access_tokenandresource=<the MCP server>.StreamableHttpTokenExchangeTests(samples/integration-tests) — a user token obtained through theauthorization_codeflow by one client is exchanged by a different client registered with the token-exchange grant; thegreetertool responds with the original user's identity ("Hello test-user"); the exchanged token differs from the subject token, withsub=test-userpreserved andaudcontainingtoken-exchange-client. It runs against the Spring Authorization Server based test authorization server, which accepts both...:access_tokenand...:jwt— hence the wire assertion in the unit test.Not included (follow-ups)
McpOAuth2TokenExchangeExchangeFilterFunction).subject_token_type=urn:ietf:params:oauth:token-type:access_token, which is what Keycloak's standard token exchange requires, but it runs againstsample-authorization-server; the Keycloak specifics (26.2 floor,standard.token.exchange.enabled, audience mappers) stay as README notes for now.Verification