-
Notifications
You must be signed in to change notification settings - Fork 3.9k
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
Changes from 3 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,6 +547,7 @@ | |
| except ValidationError: # pragma: no cover | ||
| logger.exception("Invalid refresh response") | ||
| self.context.clear_tokens() | ||
| self._initialized = False | ||
| return False | ||
|
|
||
| async def _initialize(self) -> None: | ||
|
|
@@ -586,14 +589,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) | ||
|
|
@@ -648,6 +652,18 @@ | |
| # 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. | ||
| self.context.oauth_metadata = None | ||
| elif ( | ||
| self.context.client_info is not None | ||
| and self.context.client_info.client_id == self.context.client_metadata_url | ||
| and self.context.auth_server_url is not None | ||
| and self.context.client_info.issuer not in (None, self.context.auth_server_url) | ||
| ): | ||
| # A CIMD client_id is portable across authorization servers; the tokens issued | ||
| # under it and the cached metadata are not. Keep the record, re-stamped. | ||
| self.context.clear_tokens() | ||
| self.context.oauth_metadata = None | ||
| self.context.client_info.issuer = self.context.auth_server_url | ||
| await self.context.storage.set_client_info(self.context.client_info) | ||
|
|
||
| asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( | ||
| self.context.auth_server_url, self.context.server_url | ||
|
|
@@ -741,10 +757,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() | ||
|
Check warning on line 767 in src/mcp/client/auth/oauth2.py
|
||
|
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 768 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 | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3253,3 +3253,81 @@ 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 | ||||||||||
| 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 | ||||||||||
| ) -> 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 the old issuer, tokens minted there, and | ||||||||||
| the old server's cached metadata. As soon as PRM names a different issuer, the tokens and the | ||||||||||
| cached metadata are dropped and the record is re-stamped and persisted, so a failed | ||||||||||
| rediscovery cannot leave the old endpoints in play and no refresh reaches the new 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="https://old-as.example.com" | ||||||||||
| ) | ||||||||||
| 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() | ||||||||||
Uh oh!
There was an error while loading. Please reload this page.