Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/client/oauth-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ The first time `Client` sends a request, the server answers `401`. The provider
3. **Authorization.** It generates the PKCE pair and a `state`, builds the authorization URL, awaits your `redirect_handler`, then awaits your `callback_handler` for the code.
4. **Exchange.** It trades the code for an `OAuthToken`, stores it, and replays your original request with `Authorization: Bearer ...`.

After that it is quiet. Tokens come out of storage, an expired access token is refreshed with the refresh token, and only when none of that works does it run the flow again.
After that it is quiet. Tokens come out of storage, an expired access token is refreshed with the refresh token, and only when none of that works does it run the flow again. That holds across restarts: a new process that finds a refresh token in storage answers the first `401` by rediscovering the authorization server and refreshing, not by sending anyone back to the browser.

You wrote none of it. Two keyword arguments remain (`client_metadata_url` and `validate_resource_url`), and this file needs neither. `client_metadata_url` is the one worth knowing about; it gets its own section below.

Expand Down
96 changes: 55 additions & 41 deletions src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@
if self.context.client_metadata.redirect_uris is None:
raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover
if not self.context.redirect_handler:
raise OAuthFlowError("No redirect handler provided for authorization code grant") # pragma: no cover
raise OAuthFlowError("No redirect handler provided for authorization code grant")
if not self.context.callback_handler:
raise OAuthFlowError("No callback handler provided for authorization code grant") # pragma: no cover

Expand Down Expand Up @@ -521,6 +521,8 @@
if response.status_code != 200:
logger.warning(f"Token refresh failed: {response.status_code}")
self.context.clear_tokens()
# Re-read storage on the next request: the failure may have been transient.
self._initialized = False
return False

try:
Expand All @@ -545,8 +547,32 @@
except ValidationError: # pragma: no cover
logger.exception("Invalid refresh response")
self.context.clear_tokens()
self._initialized = False
return False

async def _apply_issuer_binding(self, issuer: str) -> bool:
"""Apply SEP-2352 to the held registration now that the authorization server's issuer is known.

Credentials bound to another issuer are discarded with their tokens so the flow re-registers.
A CIMD record is portable, so it is kept and re-stamped, but tokens it carried over from
another issuer (or of unknown origin, when the record is unstamped) are dropped. Returns
True when the held state was for a different issuer.
"""
client_info = self.context.client_info
if client_info is None:
return False
if not credentials_match_issuer(client_info, issuer, self.context.client_metadata_url):
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
self.context.client_info = None
self.context.clear_tokens()
return True
if client_info.client_id == self.context.client_metadata_url and client_info.issuer != issuer:
self.context.clear_tokens()
client_info.issuer = issuer
await self.context.storage.set_client_info(client_info)
return True
return False

async def _initialize(self) -> None:
"""Load stored tokens and client info."""
self.context.current_tokens = await self.context.storage.get_tokens()
Expand Down Expand Up @@ -586,14 +612,15 @@
# Capture protocol version from request headers
self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER)

if not self.context.is_token_valid() and self.context.can_refresh_token():
# Try to refresh token
refresh_request = await self._refresh_token()
refresh_response = yield refresh_request

if not await self._handle_refresh_response(refresh_response):
# Refresh failed, need full re-authentication
self._initialized = False
# Refresh ahead of the request only when the token endpoint is already known; on a cold
# start the request goes out and the 401 branch discovers, then refreshes.
if (
not self.context.is_token_valid()
and self.context.can_refresh_token()
and self.context.oauth_metadata is not None
):
refresh_response = yield await self._refresh_token()
await self._handle_refresh_response(refresh_response)

if self.context.is_token_valid():
self._add_auth_header(request)
Expand Down Expand Up @@ -632,21 +659,12 @@
else:
logger.debug(f"Protected resource metadata discovery failed: {url}")

# SEP-2352: stored credentials are bound to the issuer that registered them.
# If the authorization server changed, drop them (and the old tokens) so the
# flow re-registers instead of presenting another server's credentials.
if (
self.context.client_info is not None
and self.context.auth_server_url is not None
and not credentials_match_issuer(
self.context.client_info, self.context.auth_server_url, self.context.client_metadata_url
)
# SEP-2352: stored credentials and tokens belong to the issuer they came from.
if self.context.auth_server_url is not None and await self._apply_issuer_binding(
self.context.auth_server_url
):
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
self.context.client_info = None
self.context.clear_tokens()
# Any cached AS metadata is for the old server; drop it so a failed
# rediscovery cannot leak the old registration/token endpoints into Step 4.
# rediscovery cannot leak the old endpoints into Steps 4-5.
self.context.oauth_metadata = None

asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(
Expand All @@ -671,21 +689,9 @@
logger.debug(f"OAuth metadata discovery failed: {url}")

# SEP-2352: on the legacy no-PRM path the issuer is only known after ASM
# discovery, so re-evaluate the binding here using the discovered metadata
# issuer (mirroring the bound_issuer fallback in Step 4).
if (
self.context.client_info is not None
and self.context.auth_server_url is None
and self.context.oauth_metadata is not None
and not credentials_match_issuer(
self.context.client_info,
str(self.context.oauth_metadata.issuer),
self.context.client_metadata_url,
)
):
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
self.context.client_info = None
self.context.clear_tokens()
# discovery (mirroring the bound_issuer fallback in Step 4).
if self.context.auth_server_url is None and self.context.oauth_metadata is not None:
await self._apply_issuer_binding(str(self.context.oauth_metadata.issuer))

Check failure on line 694 in src/mcp/client/auth/oauth2.py

View check run for this annotation

Claude / Claude Code Review

Legacy no-PRM path trusts the resource-server-origin ASM's self-declared issuer without validation (validate_metadata_issuer at lines 684-685 is skipped when auth_server_url is None), so the SEP-2352 stamp check in _apply_issuer_binding is spoofable and t

Legacy no-PRM path trusts the resource-server-origin ASM's self-declared issuer without validation (validate_metadata_issuer at lines 684-685 is skipped when auth_server_url is None), so the SEP-2352 stamp check in _apply_issuer_binding is spoofable and the new Step-5 refresh (lines 756-758) silently POSTs the stored refresh token plus client secret to the forged metadata's token_endpoint — defeating issuer binding even for stamped, SDK-minted registrations, which the PRM path does protect (th
Comment on lines 691 to +694

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Legacy no-PRM path trusts the resource-server-origin ASM's self-declared issuer without validation (validate_metadata_issuer at lines 684-685 is skipped when auth_server_url is None), so the SEP-2352 stamp check in _apply_issuer_binding is spoofable and the new Step-5 refresh (lines 756-758) silently POSTs the stored refresh token plus client secret to the forged metadata's token_endpoint — defeating issuer binding even for stamped, SDK-minted registrations, which the PRM path does protect (there SEP-2468 forces asm.issuer to equal the discovery URL, so a stamped mismatch is discarded).

Extended reasoning...

A client previously authorized legitimately, so storage holds an SDK-minted registration stamped issuer=https://legit-as.example.com plus a refresh token. The resource server is later compromised. On the next 401 it serves no PRM (all PRM URLs 404), so auth_server_url stays None and discovery falls back to https://{rs-origin}/.well-known/oauth-authorization-server (utils.py:166-170), where the attacker serves ASM with issuer="https://legit-as.example.com" (the AS it formerly used, which it knows) and token_endpoint=https://attacker.example/token. The SEP-2468 check at oauth2.py:684-685 is skipped because auth_server_url is None; handle_auth_metadata_response accepts the document; _apply_issuer_binding(str(asm.issuer)) at lines 693-694 compares the forged issuer to the stamp, matches, and keeps credentials AND tokens. The new Step 5 (lines 756-758) then builds _refresh_token() with token_url = oauth_metadata.token_endpoint (line 498) and POSTs grant_type=refresh_token with the refresh token and, via prepare_token_auth, the client secret — to the attacker's endpoint, with no user-vis

Verification: normal — security gap newly reachable through this diff's Step-5 refresh. Chain in src/mcp/client/auth/oauth2.py at HEAD: (1) with all PRM URLs 404ing (attacker-controlled RS), auth_server_url stays None and ASM is fetched from the RS's own origin (utils.py:166-170 returns only "{rs-origin}/.well-known/oauth-authorization-server"); (2) lines 684-685 skip validate_metadata_issuer exactly when aut


# Step 3: Apply scope selection strategy
self.context.client_metadata.scope = get_client_metadata_scopes(
Expand Down Expand Up @@ -741,10 +747,18 @@
client_information.issuer = discovered_issuer
self.context.client_info = client_information
await self.context.storage.set_client_info(client_information)

# Step 5: Perform authorization and complete token exchange
token_response = yield await self._perform_authorization()
await self._handle_token_response(token_response)
# Held tokens belong to a previous client and cannot be refreshed by this one.
self.context.clear_tokens()

# Step 5: Refresh with the stored refresh token first (RFC 6749 §6); run the full
# authorization only when there is none or the server rejects it.
refreshed = False
if self.context.can_refresh_token():
refresh_response = yield await self._refresh_token()
Comment on lines +766 to +767

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit, pre-existing extended by this diff: the new 401-branch Step 5 refresh (and the pre-request refresh it complements) builds the refresh request via the base _refresh_token() -> prepare_token_auth(), which adds no client authentication for token_endpoint_auth_method="private_key_jwt" (oauth2.py:271-274 explicitly defers the assertion to "the provider that implements it"), but PrivateKeyJWTOAuthProvider only adds its client_assertion in _exchange_token_client_credentials (src/mcp/client/auth/extensions/client_credentials.py:303-315) and never overrides _refresh_token. Every refresh_token grant sent by a private_key_jwt client is therefore unauthenticated and is rejected by the AS with invalid_client, so refresh can never succeed for these clients; the diff adds a second…

Extended reasoning...

A PrivateKeyJWTOAuthProvider talks to an AS that issues refresh tokens on the client_credentials grant (e.g. Keycloak's legacy default). When the access token expires server-side, the next request 401s; discovery runs and Step 5 at oauth2.py:766-768 sees can_refresh_token() True and POSTs grant_type=refresh_token with only client_id in the body — no client_assertion — so the AS answers 400/401 invalid_client. _handle_refresh_response logs a warning, clears tokens and resets _initialized, and the flow falls back to a fresh client_credentials exchange, which succeeds. Net effect on every token expiry: one guaranteed-rejected token-endpoint round trip plus a spurious "Token refresh failed" warning, and the refresh token the SDK deliberately carries forward (lines 539-540) is dead weight that can never be used. Fix belongs in _refresh_token (add the RFC 7523 assertion for private_key_jwt, mirroring _add_client_authentication_jwt) or in can_refresh_token for that provider.

Verification: nit — the factual claim is verifiable in code, though the consequence is milder than a brick because the client_credentials fallback recovers headlessly. Chain, all in HEAD: (1) The new Step 5 at /home/claude/python-sdk/src/mcp/client/auth/oauth2.py:766-768 gates only on can_refresh_token() (line 191-193: tokens with a refresh_token plus client_info — no auth-method check), then yields `self._re

refreshed = await self._handle_refresh_response(refresh_response)

Check failure on line 758 in src/mcp/client/auth/oauth2.py

View check run for this annotation

Claude / Claude Code Review

Security: 401-branch Step 5 still POSTs a refresh token of unconfirmed issuer provenance to whatever AS the resource server's PRM currently names — for pre-registered/unstamped registrations always, and for CIMD across restarts because the SEP-2352 toke

Security: 401-branch Step 5 still POSTs a refresh token of unconfirmed issuer provenance to whatever AS the resource server's PRM currently names — for pre-registered/unstamped registrations always, and for CIMD across restarts because the SEP-2352 token drop in _apply_issuer_binding is memory-only (clear_tokens never touches storage). [also at: src/mcp/client/auth/oauth2.py:756 - Re-filing still-present security gap: for pre-registered/unstamped (non-CIMD) client_info, the new 401-branch Step
Comment thread
maxisbey marked this conversation as resolved.
Comment on lines +753 to +758

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit: 401-branch Step 5 re-refreshes a token the pre-request branch minted seconds earlier in the same flow — no flag records that a refresh already ran this request, so a 401 on the fresh token triggers an immediate second refresh_token POST instead of falling through to authorization.

Extended reasoning...

Concrete cost: doubled token-endpoint traffic and refresh-token rotation churn with no recovery path. When the pre-request refresh (lines 594-600) succeeds but the resource server still 401s the freshly minted access token (verifier/introspection lag, RS-side revocation, audience misconfig), can_refresh_token() is still True at line 769 (the carried-forward refresh_token from lines 539-540), so every request performs refresh POST -> 401 -> second refresh POST -> retry 401, and because the AS keeps answering 200 to refreshes, refreshed stays True and the interactive re-authorization at line 772 is never reached — the caller just sees repeating 401s at twice the token-endpoint cost. Tracking 'refreshed this flow' (skip Step 5's refresh when the pre-request one already succeeded) removes the duplicate POST.

Verification: nit — the claim is factually true. In /home/claude/python-sdk/src/mcp/client/auth/oauth2.py the pre-request branch (lines 594-600) refreshes an expired token when oauth_metadata is known; on success _handle_refresh_response stores the fresh token and carries the refresh token forward (lines 539-540: `if token_response.refresh_token is None and prior is not None: token_response.refresh_token =

Comment on lines +763 to +768

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The new 401-branch Step-5 refresh POSTs the refresh token (and client secret) to the origin-guessed {resource-server-origin}/token whenever AS metadata discovery fails, because _refresh_token (lines 497-501) still falls back to urljoin(get_authorization_base_url(self.context.server_url), "/token") and, unlike the newly gated pre-request site (lines 594-598), the Step-5 call site is not conditioned on oauth_metadata is not None. When PRM succeeded and named an AS that is not at the resource server's origin (different host, or a path-based AS), that guess targets the wrong server entirely — contradicting the PR's own claim that the client "never guesses {origin}/token"; the manifest note in tests/interaction/_requirements.py only blesses the fallback for the legacy…

Extended reasoning...

A restarted headless client holds a valid persisted refresh token for an AS at https://as.example.com/oauth2/v1 (RS at https://rs.example.com). The stale bearer draws a 401; PRM discovery succeeds and sets auth_server_url to the AS; the AS's metadata endpoint returns a transient 502, so handle_auth_metadata_response (utils.py:233-234) returns (False, None), the Step-2 loop breaks, and oauth_metadata stays None (OAuthMetadata.token_endpoint is required, so the fallback fires exactly when discovery failed). Step 5 then runs: can_refresh_token() is True, _refresh_token() builds token_url = "https://rs.example.com/token" — the resource server's origin, never the AS — and POSTs grant_type=refresh_token with the refresh token and the client secret (prepare_token_auth) to that host, disclosing long-lived credentials to a party that was only ever meant to see the access token. The guaranteed 404/non-200 makes _handle_refresh_response discard the tokens and fall through to _perform_authorization, which raises OAuthFlowError for the headless client — so one transient metadata 5xx both leaks

Verification: normal — the candidate is mechanically accurate and the failure is newly reachable through the diff-added Step-5 call site. Chain, all in /home/claude/python-sdk/src/mcp/client/auth/oauth2.py at HEAD: (1) the new 401-branch Step 5 (lines 765-768) is if self.context.can_refresh_token(): refresh_response = yield await self._refresh_token() — unlike the pre-request site this PR gated (lines 594-598

Comment on lines +756 to +758

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Security: 401-branch Step 5 still POSTs a refresh token of unconfirmed issuer provenance to whatever AS the resource server's PRM currently names — for pre-registered/unstamped registrations always, and for CIMD across restarts because the SEP-2352 token drop in _apply_issuer_binding is memory-only (clear_tokens never touches storage). [also at: src/mcp/client/auth/oauth2.py:756 - Re-filing still-present security gap: for pre-registered/unstamped (non-CIMD) client_info, the new 401-branch Step 5…; src/mcp/client/auth/oauth2.py:723 - Fresh-registration token clearing is applied only in the DCR branch of Step 4 (line 751) — the CIMD branch (lines…; +1 more]

Extended reasoning...

A client with pre-registered credentials (client_info.issuer is None) holds a persisted refresh token. A compromised or malicious resource server changes its PRM to name an attacker-controlled AS; credentials_match_issuer (src/mcp/client/auth/utils.py:352-353) returns True for the unstamped record, _apply_issuer_binding's token-drop branch (oauth2.py:569-573) applies only when client_id == client_metadata_url, so tokens survive, and Step 5 (oauth2.py:756-758) silently POSTs the long-lived refresh token (plus client secret via prepare_token_auth) to the attacker's advertised token_endpoint with no user-visible signal. The CIMD case is only fixed in-process: clear_tokens (oauth2.py:195-198) does not delete tokens from storage while the re-stamped record IS persisted (line 572), so the next restarted process reloads the old-issuer refresh token under a record now stamped with the new issuer, _apply_issuer_binding finds issuer == issuer and keeps it, and Step 5 presents the previous issuer's refresh token to the new AS anyway. Prior to this PR the 401 branch never refreshed, so this harv

Verification: normal — both prongs are mechanically real at HEAD. (1) Pre-registered/unstamped: utils.py:352-353 (if client_info.issuer is None: return True) makes credentials_match_issuer pass, and the token-drop branch in _apply_issuer_binding (oauth2.py:569, if client_info.client_id == self.context.client_metadata_url ...) is CIMD-only, so tokens survive; the RS's PRM alone sets the AS (oauth2.py:657

if not refreshed:
Comment thread
maxisbey marked this conversation as resolved.
token_response = yield await self._perform_authorization()
await self._handle_token_response(token_response)
except Exception:
logger.exception("OAuth flow error")
raise
Expand Down
138 changes: 137 additions & 1 deletion tests/client/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import json
import time
from unittest import mock
from urllib.parse import parse_qs, quote, unquote, urlparse
from urllib.parse import parse_qs, parse_qsl, quote, unquote, urlparse

import httpx2
import pytest
Expand Down Expand Up @@ -3253,3 +3253,139 @@ async def echo_callback() -> AuthorizationCodeResult:
await auth_flow.asend(httpx2.Response(200, request=final_req))
except StopAsyncIteration:
pass


@pytest.mark.anyio
async def test_expired_token_is_not_refreshed_ahead_of_the_request_before_metadata_is_discovered(
oauth_provider: OAuthClientProvider, valid_tokens: OAuthToken
) -> None:
"""With no authorization-server metadata yet, an expired token is not refreshed at a guessed endpoint.

The request goes out unauthenticated instead, so the 401 branch discovers the real token
endpoint before the refresh token is presented anywhere (#3240).
"""
oauth_provider.context.current_tokens = valid_tokens
oauth_provider.context.token_expiry_time = time.time() - 60
oauth_provider.context.client_info = OAuthClientInformationFull(client_id="c", redirect_uris=None)
oauth_provider.context.oauth_metadata = None
oauth_provider._initialized = True

request = httpx2.Request("POST", "https://api.example.com/v1/mcp")
auth_flow = oauth_provider.async_auth_flow(request)
first = await auth_flow.__anext__()

assert first is request
assert "Authorization" not in first.headers

with pytest.raises(StopAsyncIteration):
await auth_flow.asend(httpx2.Response(200, request=request))


@pytest.mark.anyio
@pytest.mark.parametrize("stamped_issuer", ["https://old-as.example.com", None], ids=["stamped-elsewhere", "unstamped"])
async def test_cimd_record_is_restamped_and_its_tokens_and_cached_metadata_dropped_when_prm_names_a_new_issuer(
client_metadata: OAuthClientMetadata,
mock_storage: MockTokenStorage,
valid_tokens: OAuthToken,
stamped_issuer: str | None,
) -> None:
"""SEP-2352 for CIMD: the URL client_id survives an authorization-server change, nothing else does.

A long-lived provider holds a CIMD record stamped with another issuer (or, from an older store,
not stamped at all), tokens of matching provenance, and cached metadata. As soon as PRM names
the issuer in use, the tokens and the cached metadata are dropped and the record is re-stamped
and persisted, so a failed rediscovery cannot leave old endpoints in play and no refresh token
of unconfirmed origin reaches the named server.
"""
cimd_url = "https://client.example.com/.well-known/mcp-client"
provider = OAuthClientProvider(
server_url="https://api.example.com/v1/mcp",
client_metadata=client_metadata,
storage=mock_storage,
client_metadata_url=cimd_url,
)
provider.context.client_info = OAuthClientInformationFull(
client_id=cimd_url, token_endpoint_auth_method="none", issuer=stamped_issuer
)
provider.context.current_tokens = valid_tokens
provider.context.token_expiry_time = time.time() + 1800
provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://old-as.example.com"),
authorization_endpoint=AnyHttpUrl("https://old-as.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://old-as.example.com/token"),
)
provider._initialized = True

auth_flow = provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
request = await auth_flow.__anext__()
prm_req = await auth_flow.asend(httpx2.Response(401, request=request))
prm_response = httpx2.Response(
200,
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://new-as.example.com"]}',
request=prm_req,
)
asm_req = await auth_flow.asend(prm_response)

assert str(asm_req.url) == "https://new-as.example.com/.well-known/oauth-authorization-server"
assert provider.context.current_tokens is None
assert provider.context.oauth_metadata is None
assert provider.context.client_info is not None
assert (provider.context.client_info.client_id, provider.context.client_info.issuer) == (
cimd_url,
"https://new-as.example.com",
)
assert mock_storage._client_info is provider.context.client_info

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Asserting mock_storage._client_info is provider.context.client_info reaches into the mock's private attribute and checks object identity, which only passes because set_client_info stores the exact mutated object. Use the public API instead so the test verifies the re-stamped issuer is actually persisted rather than that the same object was assigned: stored = await mock_storage.get_client_info(); assert stored is not None; assert stored.client_id == cimd_url; assert stored.issuer == "https://new-as.example.com".

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/client/test_auth.py, line 3332:

<comment>Asserting `mock_storage._client_info is provider.context.client_info` reaches into the mock's private attribute and checks object identity, which only passes because `set_client_info` stores the exact mutated object. Use the public API instead so the test verifies the re-stamped issuer is actually persisted rather than that the same object was assigned: `stored = await mock_storage.get_client_info(); assert stored is not None; assert stored.client_id == cimd_url; assert stored.issuer == "https://new-as.example.com"`.</comment>

<file context>
@@ -3279,3 +3279,55 @@ async def test_expired_token_is_not_refreshed_ahead_of_the_request_before_metada
+        cimd_url,
+        "https://new-as.example.com",
+    )
+    assert mock_storage._client_info is provider.context.client_info
+    await auth_flow.aclose()
</file context>
Suggested change
assert mock_storage._client_info is provider.context.client_info
stored = await mock_storage.get_client_info()
assert stored is not None
assert (stored.client_id, stored.issuer) == (cimd_url, "https://new-as.example.com")

await auth_flow.aclose()


@pytest.mark.anyio
async def test_cimd_record_is_restamped_and_its_tokens_dropped_when_only_asm_reveals_a_new_issuer(
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage, valid_tokens: OAuthToken
) -> None:
"""The CIMD rebinding also applies on the legacy no-PRM path, where the issuer is learned from AS metadata.

PRM discovery 404s, so the issuer only becomes known from the root well-known metadata; it
differs from the record's stamp, so the tokens are dropped and the record re-stamped before
any refresh could be attempted, and the flow proceeds to authorize rather than refresh.
"""
cimd_url = "https://client.example.com/.well-known/mcp-client"
provider = OAuthClientProvider(
server_url="https://api.example.com/v1/mcp",
client_metadata=client_metadata,
storage=mock_storage,
client_metadata_url=cimd_url,
)
provider.context.client_info = OAuthClientInformationFull(
client_id=cimd_url, token_endpoint_auth_method="none", issuer="https://old-as.example.com"
)
provider.context.current_tokens = valid_tokens
provider.context.token_expiry_time = time.time() + 1800
provider._initialized = True
provider._perform_authorization_code_grant = mock.AsyncMock(return_value=("auth-code", "verifier"))

auth_flow = provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
request = await auth_flow.__anext__()
prm_req = await auth_flow.asend(httpx2.Response(401, request=request))
prm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
asm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
assert str(asm_req.url) == "https://api.example.com/.well-known/oauth-authorization-server"
asm_response = httpx2.Response(
200,
content=(
b'{"issuer": "https://api.example.com", '
b'"authorization_endpoint": "https://api.example.com/authorize", '
b'"token_endpoint": "https://api.example.com/token", '
b'"client_id_metadata_document_supported": true}'
),
request=asm_req,
)
next_req = await auth_flow.asend(asm_response)

assert dict(parse_qsl(next_req.content.decode()))["grant_type"] == "authorization_code"
assert provider.context.client_info is not None
assert (provider.context.client_info.client_id, provider.context.client_info.issuer) == (
cimd_url,
"https://api.example.com",
)
assert mock_storage._client_info is provider.context.client_info
await auth_flow.aclose()
23 changes: 23 additions & 0 deletions tests/interaction/_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -3883,6 +3883,29 @@ def __post_init__(self) -> None:
transports=("streamable-http",),
note="OAuth is HTTP-only.",
),
"client-auth:refresh:on-401": Requirement(
source="issue:#3250",
behavior=(
"A 401 received while a refresh token is held is answered, after rediscovery, with a "
"refresh_token grant before any interactive authorization, so a client constructed over "
"persisted tokens and client registration recovers from an expired access token headlessly."
),
transports=("streamable-http",),
note="OAuth is HTTP-only. RFC 6749 §1.5 (E)-(H); matches the TypeScript, C# and Rust SDKs.",
),
"client-auth:refresh:discovered-endpoint": Requirement(
source="issue:#3240",
behavior=(
"A refresh in a process that has not yet discovered the authorization server happens only after "
"protected-resource and authorization-server metadata discovery and posts to the token endpoint "
"that metadata advertises."
),
transports=("streamable-http",),
note=(
"OAuth is HTTP-only. When discovery yields no AS metadata at all, the 2025-03-26 origin-derived "
"fallback endpoint is still used, as it is for the authorization itself."
),
),
"client-auth:resource-parameter": Requirement(
source=f"{SPEC_BASE_URL}/basic/authorization#resource-parameter-implementation",
behavior=(
Expand Down
Loading
Loading