Skip to content

Commit 55c2413

Browse files
committed
Add end-to-end interaction tests for token exchange (SEP-990)
Exercise the full stack - the real TokenExchangeOAuthProvider client against the real authorization-server token endpoint with InMemoryAuthorizationServerProvider implementing exchange_token - rather than shimming the grant as the M2M providers do. Covers: successful exchange + bearer authorizes the request, the subject_token_provider callback audience, server-disabled rejection (unsupported_grant_type), an unaccepted subject token, scope adoption from AS metadata, and metadata advertisement of the grant + public-client auth method. Adds the client-auth:token-exchange* requirement entries and threads token_exchange_enabled through the auth harness. Removes a now-covered pragma in OAuthClientProvider._handle_token_response: these tests are the first to drive a non-2xx token response through the full client flow.
1 parent c7cacf0 commit 55c2413

5 files changed

Lines changed: 358 additions & 5 deletions

File tree

src/mcp/client/auth/oauth2.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -417,9 +417,9 @@ async def _exchange_token_authorization_code(
417417
async def _handle_token_response(self, response: httpx.Response) -> None:
418418
"""Handle token exchange response."""
419419
if response.status_code not in {200, 201}:
420-
body = await response.aread() # pragma: no cover
421-
body_text = body.decode("utf-8") # pragma: no cover
422-
raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body_text}") # pragma: no cover
420+
body = await response.aread()
421+
body_text = body.decode("utf-8")
422+
raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body_text}")
423423

424424
# Parse and validate response with scope validation
425425
token_response = await handle_token_response_scopes(response)

tests/interaction/_requirements.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3676,6 +3676,53 @@ def __post_init__(self) -> None:
36763676
"elsewhere into the auth provider's state, so the absence cannot be observed end to end."
36773677
),
36783678
),
3679+
"client-auth:token-exchange": Requirement(
3680+
source="sdk",
3681+
behavior=(
3682+
"The token-exchange provider (SEP-990) exchanges an enterprise IdP-issued ID-JAG for an MCP "
3683+
"access token via the RFC 8693 token-exchange grant, with no authorize or registration step, "
3684+
"and the issued bearer token authorizes subsequent requests."
3685+
),
3686+
transports=("streamable-http",),
3687+
note="OAuth is HTTP-only.",
3688+
),
3689+
"client-auth:token-exchange:subject-token-callback": Requirement(
3690+
source="sdk",
3691+
behavior=(
3692+
"The token-exchange provider sources the subject token from its async subject_token_provider "
3693+
"callback, invoked with the authorization server's issuer as the audience, and sends it as "
3694+
"subject_token on the RFC 8693 request."
3695+
),
3696+
transports=("streamable-http",),
3697+
note="OAuth is HTTP-only.",
3698+
),
3699+
"client-auth:token-exchange:disabled-rejected": Requirement(
3700+
source="sdk",
3701+
behavior=(
3702+
"When the authorization server has token exchange disabled, the token endpoint rejects the "
3703+
"grant with unsupported_grant_type and the connection fails rather than issuing a token."
3704+
),
3705+
transports=("streamable-http",),
3706+
note="OAuth is HTTP-only.",
3707+
),
3708+
"client-auth:token-exchange:invalid-subject-token": Requirement(
3709+
source="sdk",
3710+
behavior=(
3711+
"A token-exchange request whose subject token the authorization server rejects surfaces as an "
3712+
"OAuth error and the connection fails rather than proceeding with a bearer token."
3713+
),
3714+
transports=("streamable-http",),
3715+
note="OAuth is HTTP-only.",
3716+
),
3717+
"client-auth:token-exchange:metadata-advertised": Requirement(
3718+
source="sdk",
3719+
behavior=(
3720+
"When token exchange is enabled, the authorization-server metadata advertises the "
3721+
"token-exchange grant type and the public-client `none` token-endpoint auth method."
3722+
),
3723+
transports=("streamable-http",),
3724+
note="OAuth is HTTP-only.",
3725+
),
36793726
# ═══════════════════════════════════════════════════════════════════════════
36803727
# stdio transport
36813728
# ═══════════════════════════════════════════════════════════════════════════

tests/interaction/auth/_harness.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,10 @@ async def callback_handler(self) -> AuthorizationCodeResult:
178178

179179

180180
def auth_settings(
181-
*, required_scopes: Sequence[str] = ("mcp",), valid_scopes: Sequence[str] | None = None
181+
*,
182+
required_scopes: Sequence[str] = ("mcp",),
183+
valid_scopes: Sequence[str] | None = None,
184+
token_exchange_enabled: bool = False,
182185
) -> AuthSettings:
183186
"""Build `AuthSettings` for the co-hosted authorization + resource server.
184187
@@ -188,6 +191,9 @@ def auth_settings(
188191
validation; tests pass a wider set when they need the protected-resource metadata's
189192
`scopes_supported` (which mirrors `required_scopes`) to differ from what the client may
190193
register or when AS metadata should advertise additional scopes such as `offline_access`.
194+
195+
`token_exchange_enabled` advertises and accepts the RFC 8693 token-exchange grant (SEP-990);
196+
the provider must implement `exchange_token` for the endpoint to issue tokens.
191197
"""
192198
required = list(required_scopes)
193199
valid = list(valid_scopes) if valid_scopes is not None else required
@@ -199,6 +205,7 @@ def auth_settings(
199205
enabled=True, valid_scopes=valid, default_scopes=required
200206
),
201207
revocation_options=RevocationOptions(enabled=False),
208+
token_exchange_enabled=token_exchange_enabled,
202209
)
203210

204211

tests/interaction/auth/_provider.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,18 @@
1818
OAuthAuthorizationServerProvider,
1919
RefreshToken,
2020
TokenError,
21+
TokenExchangeParams,
2122
construct_redirect_uri,
2223
)
23-
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
24+
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken, TokenExchangeToken
2425
from tests.interaction._connect import BASE_URL
2526

2627
_TOKEN_LIFETIME_SECONDS = 3600
2728

29+
# The only subject token the in-memory provider accepts as a valid ID-JAG; any other value is
30+
# rejected with invalid_grant, standing in for signature/policy validation a real AS performs.
31+
VALID_SUBJECT_TOKEN = "valid-id-jag"
32+
2833

2934
class InMemoryAuthorizationServerProvider(
3035
OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
@@ -71,6 +76,9 @@ def __init__(
7176
self.codes: dict[str, AuthorizationCode] = {}
7277
self.refresh_tokens: dict[str, RefreshToken] = {}
7378
self.access_tokens: dict[str, AccessToken] = {}
79+
# The most recent token-exchange request the SDK handler passed to exchange_token, for
80+
# tests to assert what the client sent (None until the first exchange).
81+
self.last_exchange_params: TokenExchangeParams | None = None
7482

7583
def _next_expires_in(self) -> int:
7684
self._tokens_issued += 1
@@ -187,6 +195,27 @@ async def exchange_refresh_token(
187195
refresh_token=new_refresh,
188196
)
189197

198+
async def exchange_token(self, client: OAuthClientInformationFull, params: TokenExchangeParams) -> OAuthToken:
199+
"""Validate the ID-JAG subject token and mint an MCP access token (RFC 8693 / SEP-990).
200+
201+
Records `params` for inspection and rejects any subject token other than
202+
`VALID_SUBJECT_TOKEN` with invalid_grant (standing in for signature/policy validation). The
203+
granted scopes are exactly those the client requested; a real provider would derive them
204+
from the validated ID-JAG and policy.
205+
"""
206+
self.last_exchange_params = params
207+
assert client.client_id is not None
208+
if params.subject_token != VALID_SUBJECT_TOKEN:
209+
raise TokenError(error="invalid_grant", error_description="subject token is not valid")
210+
scopes = params.scopes if params.scopes is not None else self._default_scopes
211+
access = self.mint_access_token(client_id=client.client_id, scopes=scopes, resource=params.resource)
212+
return TokenExchangeToken(
213+
access_token=access,
214+
token_type="Bearer",
215+
expires_in=self._next_expires_in(),
216+
scope=" ".join(scopes),
217+
)
218+
190219
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
191220
"""Not exercised by this suite; revocation is out of scope for the interaction tests."""
192221
raise NotImplementedError

0 commit comments

Comments
 (0)