Repository navigation
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 1 commit
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 |
|---|---|---|
|
|
@@ -586,8 +586,13 @@ | |
| # 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 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_request = await self._refresh_token() | ||
| refresh_response = yield refresh_request | ||
|
|
||
|
|
@@ -741,10 +746,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() | ||
| refreshed = await self._handle_refresh_response(refresh_response) | ||
|
Check failure on line 757 in src/mcp/client/auth/oauth2.py
|
||
|
maxisbey marked this conversation as resolved.
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: | ||
|
Check failure on line 758 in src/mcp/client/auth/oauth2.py
|
||
|
maxisbey marked this conversation as resolved.
Resolved
|
||
| token_response = yield await self._perform_authorization() | ||
| await self._handle_token_response(token_response) | ||
| except Exception: | ||
| logger.exception("OAuth flow error") | ||
| raise | ||
|
|
||
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.
🟡 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 fortoken_endpoint_auth_method="private_key_jwt"(oauth2.py:271-274 explicitly defers the assertion to "the provider that implements it"), butPrivateKeyJWTOAuthProvideronly adds itsclient_assertionin_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
PrivateKeyJWTOAuthProvidertalks 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 seescan_refresh_token()True and POSTsgrant_type=refresh_tokenwith onlyclient_idin the body — noclient_assertion— so the AS answers 400/401 invalid_client._handle_refresh_responselogs 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 incan_refresh_tokenfor 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