-
Notifications
You must be signed in to change notification settings - Fork 3.8k
OAuth client: refresh before re-authorizing, and discover before refreshing #3328
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2bc618a
ab40324
76542f8
7f01cd0
caa022f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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: | ||
|
|
@@ -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() | ||
|
|
@@ -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) | ||
|
|
@@ -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( | ||
|
|
@@ -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
|
||
|
|
||
| # Step 3: Apply scope selection strategy | ||
| self.context.client_metadata.scope = get_client_metadata_scopes( | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Extended reasoning...A 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 |
||
| refreshed = await self._handle_refresh_response(refresh_response) | ||
|
Check failure on line 758 in src/mcp/client/auth/oauth2.py
|
||
|
maxisbey marked this conversation as resolved.
Comment on lines
+753
to
+758
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, 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
Comment on lines
+763
to
+768
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Comment on lines
+756
to
+758
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 not refreshed: | ||
|
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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||
|
|
@@ -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 | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Asserting Prompt for AI agents
Suggested change
|
||||||||||
| 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() | ||||||||||
There was a problem hiding this comment.
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