Skip to content

OAuth client: refresh before re-authorizing, and discover before refreshing - #3328

Open
maxisbey wants to merge 5 commits into
mainfrom
oauth-refresh-on-401
Open

OAuth client: refresh before re-authorizing, and discover before refreshing#3328
maxisbey wants to merge 5 commits into
mainfrom
oauth-refresh-on-401

Conversation

@maxisbey

@maxisbey maxisbey commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

On a 401, OAuthClientProvider now tries the stored refresh token (after rediscovery) before falling back to interactive authorization, and the pre-request refresh only runs once authorization-server metadata is known, so it never guesses {origin}/token. A restarted or headless client with a persisted refresh token recovers from an expired access token without a browser.

Closes #3240, closes #3250, closes #1318.

Motivation and Context

The provider had two ways to obtain a token that each lacked half of what they needed: the pre-request branch could refresh but never ran discovery, and the 401 branch ran discovery but never tried the refresh token. After a restart nothing restores an expiry, so the pre-request branch is skipped, the stale bearer draws a 401, and the flow goes to the browser with a usable refresh token in hand — headless clients just fail. If an application forced the pre-request path by reporting the loaded token as expired, the refresh went to a path derived from the server origin, which 404s for any authorization server under a path, and the refresh token was then discarded. #1318 reported this a year ago; #3240 and #3250 are the same defect from two angles. The 401 branch did refresh in 1.10/1.11; #1071 moved that block ahead of the request instead of duplicating it and nothing tested the restart path.

What changed

  • 401 branch, Step 5: if can_refresh_token(): refresh → on success retry the request; otherwise (no refresh token, or the server rejected it) run the full grant as before.
  • Pre-request refresh: additionally gated on oauth_metadata is not None. In-process behaviour is unchanged (metadata is cached after the first flow); on a cold start the request goes out and the 401 branch discovers, then refreshes.
  • A failed refresh (either site) resets _initialized inside _handle_refresh_response, so the next request re-reads storage; a long-lived headless provider whose one refresh hit a transient error retries it rather than staying tokenless for the life of the process. Classifying failures (keep the refresh token on 5xx, clear only on invalid_grant) is left for the follow-up below.
  • A fresh dynamic registration clears any held tokens (they were issued to a previous client and can't be refreshed by the new one).
  • A CIMD client_id is portable across authorization servers, but tokens issued under it are not: when the issuer that becomes known (from PRM, or from AS metadata on the legacy no-PRM path) differs from the record's SEP-2352 stamp — or the record carries no stamp, so its tokens have no confirmed origin — the record is kept and re-stamped and its tokens (and any cached metadata from the previous server) are dropped before anything is refreshed (the TypeScript SDK's discardIfIssuerMismatch). The existing bound-credential discard and this rule now live in one _apply_issuer_binding method called at both points the issuer becomes known, replacing the two inline copies.

src/ is +55/−41. No public API change, TokenStorage untouched. This is the TypeScript SDK's ordering (auth() refreshes before startAuthorization) and matches C#/Rust.

How Has This Been Tested?

Interaction tests against the SDK's own AS+RS in process, each logging in as a "first process" and then connecting a fresh provider over the same storage: refresh on 401 after restart (no handlers wired); the same against an AS mounted under /oauth2/v1 (bare /token 404s) proving the refresh goes to the advertised endpoint; a rejected 401-path refresh falling back to authorization; a headless provider retrying the refresh on its next connection after a failed one; a fresh registration not presenting tokens left from a previous client; and a CIMD client keeping its id but dropping its tokens on an issuer change. Each was mutation-checked against the line it pins. Unit tests pin that an expired token is not refreshed ahead of the request while metadata is unknown, and the CIMD rebinding on both discovery paths (stamped elsewhere, and unstamped). Manifest gains client-auth:refresh:on-401 and client-auth:refresh:discovered-endpoint; the harness gains a path-prefixed-AS shim and expire_access_token.

Also driven over real sockets (uvicorn serving the SDK AS with 3 s access tokens, then under a prefix; a separate application process with file-backed storage run repeatedly): headless runs after expiry show 401 → PRM → ASM → POST …/token grant_type=refresh_token → 200; with tokens.json rewritten to expires_in=-1 and an app that derives expiry on load (the #3240 setup) there is no guessed /token request and the refresh lands on /oauth2/v1/token.

Breaking Changes

None. Behavioural notes: a 401 with a refresh token in storage now produces one token-endpoint request before any redirect (a scope= challenge wider than the grant is then met by the 403 step-up on the retry, as in the TypeScript SDK). A cold start with an access token the server no longer accepts costs one 401 round trip before the refresh, unless the application restores both the expiry and the AS metadata itself. Against 2025-03-26-era servers that publish no metadata at all, in-process expiry now also goes through that 401 (and the failing well-known probes) instead of a single pre-request refresh; everywhere else in-process behaviour is unchanged.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

An earlier revision of this branch also folded in the #3256 recovery (expired DCR secret / invalid_client → re-register once) and a cold-start pre-request discovery mode. Review surfaced enough edge cases in both (403 step-up path, pre-registered credentials, tokens outliving their registration, a coarse-clock test knob) that they're better handled separately, so this PR is cut back to the refresh fix and the rest follows:

AI Disclaimer

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3328.mcp-python-docs.pages.dev
Deployment https://1929bcf6.mcp-python-docs.pages.dev
Commit caa022f
Triggered by @maxisbey
Updated 2026-08-18 13:55:26 UTC

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No issues found across 8 files

Re-trigger cubic

@claude claude Bot left a comment

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.

Beyond the inline findings, two adjacent concerns were examined and ruled out: the invalid_client discard-and-reregister path also replacing CIMD registrations (harmless — Step 4 deterministically re-derives the same registration from the client metadata URL, so no extra consent or identity change), and the post-401 pass re-running PRM/ASM discovery when a cold-start pass already ran it within the same request (matches the stated design — a 401's WWW-Authenticate hints may point at a changed AS, so rediscovery there is intentional).

Extended reasoning...

Findings were confirmed and will be posted inline, so the body is limited to recording what else was investigated and ruled out. The CIMD variant of the invalid_client recovery concern (src/mcp/client/auth/oauth2.py around lines 744-765) was ruled out because create_client_info_from_metadata_url rebuilds the identical URL-based registration non-interactively, unlike the pre-registered-credentials case that did survive as a finding. The duplicate-discovery concern (lines 640-642 and the 855 entry point) was ruled out as intended behavior per the code's own Step 1-2 comment ("Always on a 401 — the AS may have changed"). The bug hunt exited on max_rounds and several verified findings were pruned from posting, so this change should not be approved without a human pass over the auth flow; the inline comments already signal that.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟣 src/mcp/client/auth/oauth2.py — The new lapsed-secret discard and invalid_client re-registration recovery apply only inside _reacquire_tokens (401/cold-start paths); the 403 insufficient_scope step-up branch still calls _perform_authorization() and _handle_token_response() directly with no client_secret_lapsed() check and no token_error_code() handling, so an SDK-minted registration whose secret has lapsed spends a user consent at /authorize and then dies with OAuthTokenError at the code exchange - the exact #3256 failure this PR fixes elsewhere, and a direct contradiction of the new docs/client/oauth-clients.md claim that a registration whose secret expired or that the token endpoint rejects with invalid_client is always discarded and re-registered. (The 403 branch itself predates this PR, but the recovery mechanism it

    Extended reasoning...

    A client registered against an AS that issues expiring DCR secrets (the PR's own auth_settings(client_secret_expiry_seconds=...) scenario) holds a still-valid access token when the secret window passes. The server answers a request with 403 insufficient_scope (SEP-2350 step-up). async_auth_flow's 403 branch sends the user through redirect_handler/callback_handler - the AS's /authorize does not check the secret, so consent is granted - then the code exchange authenticates with the dead secret, the token endpoint answers 400/401 invalid_client, and _handle_token_response raises OAuthTokenError (lines 891-892). The consent is wasted and the operation fails; every subsequent step-up repeats the consent-then-error cycle until the access token itself expires and a 401 finally routes through _reacquire_tokens, which discards and re-registers. Routing the 403 exchange through the same lapsed-secret check and token_error_code recovery (or through _reacquire_tokens) would make the step-up recover headlessly like the 401 path does.

    Verification: pre-existing — the candidate is factually correct, though the defective branch itself is untouched by this diff. The 403 insufficient_scope step-up branch at src/mcp/client/auth/oauth2.py:869-899 calls token_response = yield await self._perform_authorization() then await self._handle_token_response(token_response) (lines 891-892) directly, with no client_secret_lapsed() discard (that check l

Comment thread tests/interaction/auth/_harness.py Outdated
Comment thread src/mcp/client/auth/oauth2.py Outdated
Comment thread src/mcp/client/auth/oauth2.py Outdated
Comment thread src/mcp/client/auth/oauth2.py Outdated
Comment thread src/mcp/client/auth/oauth2.py Outdated
…eshing

The 401 branch of OAuthClientProvider discovered metadata but never tried
the stored refresh token, and the pre-request refresh tried the refresh
token but never discovered metadata. After a restart nothing restores an
expiry, so the pre-request path is skipped, the stale bearer draws a 401,
and the flow went straight to interactive authorization with a usable
refresh token in hand; headless clients failed outright. When an
application forced the pre-request path, the refresh was posted to a
path guessed from the server origin, which 404s against an
authorization server under a path, and the refresh token was dropped.

The 401 branch now tries the refresh_token grant after discovery and
registration and runs the full authorization only when there is no
refresh token or the server rejects it. The pre-request refresh runs
only when authorization server metadata is already known, so it never
guesses an endpoint; a cold start takes the 401 and refreshes there. A
fresh dynamic registration clears any held tokens, which belonged to a
previous client.

Closes #3240, #3250, #1318.
@maxisbey
maxisbey force-pushed the oauth-refresh-on-401 branch from 07d639a to 2bc618a Compare August 18, 2026 12:41

@claude claude Bot left a comment

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.

Beyond the inline findings, this run also examined and ruled out: clear_tokens() after re-registration leaving the old tokens in TokenStorage (in-memory state governs the rest of the flow, so it is a persistence nit, not a correctness break); the Step 5 refresh running without a metadata guard when every discovery URL fails (it falls back to the same guessed-endpoint behaviour as before, not worse); and the refresh request going out unauthenticated for PrivateKeyJWTOAuthProvider (pre-existing — _refresh_token() was never overridden there, this PR only widens exposure).

Extended reasoning...

This run confirmed two findings on the new 401-branch refresh in src/mcp/client/auth/oauth2.py (posted inline at lines 757–758), so approval is not on the table — additionally, verified findings exist that will not be posted, which independently blocks approval. The body is limited to the informational ruled-out note covering candidates newly investigated this run that do not overlap the posted inline comments or the prior review's comments: the storage-vs-memory scope of the new clear_tokens() call after dynamic re-registration, the absence of a discovered-metadata guard on the Step 5 refresh when discovery fails entirely, and the unauthenticated refresh for the private-key-JWT provider, which predates this PR. The remaining ruled-out candidate (refresh token presented to a newly discovered AS for CIMD/unstamped registrations) substantially matches the confirmed finding at line 757 and is therefore not listed as ruled out.

Comment thread src/mcp/client/auth/oauth2.py
Comment thread src/mcp/client/auth/oauth2.py
…ests

- A failed refresh resets _initialized inside _handle_refresh_response, so
  the 401-path refresh site behaves like the pre-request one: the next
  request re-reads storage and can retry a refresh token that is still good
  instead of leaving a long-lived headless provider with no tokens.
- A CIMD client_id is portable across authorization servers but its tokens
  are not: when the discovered issuer differs from the record's stamp, keep
  the record, drop the tokens, and re-stamp before deciding to refresh.
- Tests pinning: rejected 401-path refresh falls back to authorization; a
  headless provider retries the refresh on its next connection; a fresh
  registration does not present tokens left from a previous client; the
  CIMD issuer-change case. Manifest wording for the discovered-endpoint
  entry no longer overclaims about the no-metadata fallback.

@claude claude Bot left a comment

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.

Beyond the inline findings, I also checked two things new to this revision and ruled them out: (1) the new clear_tokens() calls (fresh DCR registration, CIMD issuer change) drop tokens only in memory while the client record is persisted — but the TokenStorage protocol has no token-removal API, so an in-process clear is the strongest drop available today, and the PR already flags a storage "forget tokens" capability as follow-up work; (2) _handle_refresh_response leaving _initialized = False after a failure that the same flow then recovers from via full authorization is harmless, since the next request's re-_initialize re-reads the tokens _handle_token_response just persisted.

Extended reasoning...

Two confirmed findings are posted inline (the CIMD issuer-change block persisting a new issuer stamp without dropping stale old-AS metadata, and the double-refresh when the pre-request refresh succeeds but the RS still 401s), so the inline comments already signal that a human should look. The hunt exited on max_rounds, so approval is off the table regardless. This note only records two concrete candidates investigated and ruled out in this run that were not covered in my two earlier reviews: the storage-persistence gap of the new token drops (bounded by TokenStorage having only get_tokens/set_tokens — verified in src/mcp/client/auth/oauth2.py:127-134 — and explicitly deferred by the PR to the stored-token-contract follow-up) and the non-restored _initialized flag after an in-flow recovery (benign because _initialize re-reads the storage that _handle_token_response just updated). Neither is a repeat of prior feedback, and naming them saves a human reviewer from re-chasing the same questions about the new clear_tokens() call sites.

Comment thread src/mcp/client/auth/oauth2.py Outdated
Comment on lines +766 to +771
# 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)

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 =

…data is dropped too

The CIMD token drop ran after authorization-server metadata rediscovery,
so when that rediscovery failed the previous server's cached metadata
survived under a record already re-stamped with the new issuer. Doing it
at the same point as the existing bound-credentials guard, right after PRM
names the issuer, drops the cached metadata as that guard does. A unit
test pins all three effects.
…okens, then stamp it

Records this SDK creates for a client ID metadata document always carry
the issuer stamp, so an unstamped one comes from an older store and its
tokens have no confirmed origin. Dropping them once and stamping the record
converges after a single re-authorization instead of presenting a refresh
token of unknown provenance to whichever server PRM names.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tests/client/test_auth.py">

<violation number="1" location="tests/client/test_auth.py:3332">
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"`.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/mcp/client/auth/oauth2.py Outdated
Comment thread tests/client/test_auth.py
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")

…iscovery points

The bound-credential discard and the CIMD keep-but-rebind case were spelled
out inline where PRM names the issuer, and only the former where the issuer
is first learned from AS metadata on the legacy no-PRM path. One method now
applies both rules wherever the issuer becomes known, so a CIMD record's
carried-over tokens are dropped on that path too. A unit test covers it.

@claude claude Bot left a comment

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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/mcp/client/auth/oauth2.py — The new CIMD issuer-change guard (elif at lines 655-666: drop tokens, drop cached metadata, re-stamp the record) is gated on auth_server_url is not None, so it only runs on the PRM discovery path. The legacy no-PRM path's mirrored SEP-2352 re-evaluation block (lines 692-704) re-checks credentials_match_issuer against the discovered ASM issuer, but that helper short-circuits True for CIMD records (utils.py:350-351) and no CIMD branch was mirrored there — so a stamped CIMD record whose issuer changed is neither re-stamped nor has its tokens dropped on the legacy path, and the PR's new Step 5 refresh (lines 766-768) then presents the old AS's refresh token to the new AS. Distinct from the prior finding about unstamped/pre-registered records: here the issuer stamp exists and the mismatch…

    Extended reasoning...

    A client uses client_metadata_url against a 2025-03-26-era server that publishes no protected-resource metadata but serves origin AS metadata advertising client_id_metadata_document_supported; Step 4's legacy stamp records issuer=OLD (line 719 uses str(oauth_metadata.issuer) when auth_server_url is None) and storage holds a refresh token minted by OLD. The deployment later migrates to a different authorization server: PRM probes still 404 (auth_server_url stays None) and the origin ASM now returns metadata with issuer=NEW and NEW's endpoints. On the next 401, the elif at 655-666 is skipped (auth_server_url is None), the legacy re-evaluation at 692-704 returns without clearing anything because credentials_match_issuer short-circuits True for the CIMD client_id, Step 4 is skipped (client_info present), and the new Step 5 sees can_refresh_token() True and POSTs OLD's long-lived refresh token to NEW's token_endpoint — disclosing the credential to a server it was never issued by (exactly what the elif was added to prevent; before this PR the 401 branch never refreshed, so no disclosur

    Verification: nit — the asymmetry is real and mechanically verifiable at HEAD in /home/claude/python-sdk/src/mcp/client/auth/oauth2.py. (1) The new CIMD issuer-change branch is gated to the PRM path: the elif at lines 655-660 requires self.context.auth_server_url is not None (line 658) before it clears tokens, drops cached metadata, and re-stamps the record (lines 663-666). (2) The legacy no-PRM re-evaluation

  • 🟡 src/mcp/client/auth/oauth2.py — Pre-existing, extended by this PR: _refresh_token (lines 497-501) re-implements _get_token_endpoint() (lines 433-439) verbatim — same metadata check, same origin fallback — so the token-endpoint selection rule this PR is correcting (#3240) lives in two places, and the fix was applied as a gate at one call site (line 597) instead of once in the shared helper.

    Extended reasoning...

    Concrete cost: duplicated logic plus a fix at the wrong depth. The PR's whole point is "post the refresh to the discovered token endpoint", yet the endpoint choice for refresh remains a private copy of _get_token_endpoint's body; any future change to endpoint selection (e.g. deriving the fallback from auth_server_url, or dropping the origin guess) must be made twice or the refresh path silently diverges from the code-exchange path — exactly the divergence that produced #3240. Replacing lines 497-501 with token_url = self._get_token_endpoint() removes the copy and gives one place to fix the fallback.

    Verification: nit — the factual claim is verbatim true. _get_token_endpoint() at src/mcp/client/auth/oauth2.py:433-439 is: if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint: token_url = str(...token_endpoint) else auth_base_url = self.context.get_authorization_base_url(self.context.server_url); token_url = urljoin(auth_base_url, "/token"). _refresh_token at lines 497-501 re

Comment on lines +763 to +768
# 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)

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 +766 to +767
if self.context.can_refresh_token():
refresh_response = yield await self._refresh_token()

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

@claude claude Bot left a comment

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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/mcp/client/auth/oauth2.py — Step 4's CIMD comment "the issuer stamp is informational" is made false by this PR: the new _apply_issuer_binding (lines 569-573) reads that exact stamp to decide whether to drop held tokens and re-persist the record, so the stamp is now load-bearing SEP-2352 state, not informational.

    Extended reasoning...

    Concrete cost: a maintainer misled by the comment. The comment at src/mcp/client/auth/oauth2.py:714-715 predates this PR, but the PR adds the first consumer of the CIMD issuer stamp: _apply_issuer_binding line 569 compares client_info.client_id == self.context.client_metadata_url and client_info.issuer != issuer and on mismatch clears tokens and re-stamps. A future change to the CIMD creation path (line 721 client_information.issuer = discovered_issuer) that skips or mis-sets the stamp — plausible exactly because the adjacent comment says it is informational — would silently disable the token-drop guard, so a refresh token minted at one authorization server would be presented to a different one on the next 401. Fix is one line: reword the comment to say the stamp records which issuer the record's tokens came from and drives _apply_issuer_binding's token drop.

    Verification: nit — the claim is factually accurate. The comment at /home/claude/python-sdk/src/mcp/client/auth/oauth2.py:714-715 ("CIMD records are portable across authorization servers, so the issuer stamp is informational") predates the PR (present at base line 709, untouched by the diff — git diff 0d92192..HEAD contains no hunk touching it), and at base it was true: the only reader of the stamp was `cre

  • 🟡 tests/interaction/auth/_harness.py — connect_with_oauth's docstring ("in that case ... headless [is] unused (the yielded HeadlessOAuth is never invoked)") is made stale by this PR: the new test passes both auth= and headless= and depends on headless.bind(http_client) firing so the provider's redirect/callback handlers work.

    Extended reasoning...

    Concrete cost: the harness contract now contradicts its own usage. tests/interaction/auth/test_lifecycle.py's new test_a_refresh_the_server_rejects_on_the_401_path_falls_back_to_authorization builds auth=restarted_interactive_provider(storage, headless) AND passes headless=headless to connect_with_oauth; the flow only completes because connect_with_oauth still calls headless.bind(http_client) and the supplied provider invokes that same HeadlessOAuth's handlers. A test author following the docstring at tests/interaction/auth/_harness.py:485-488 ("headless [is] unused" when auth is given) would omit headless= in the next such restart-with-browser test, get an unbound HeadlessOAuth whose redirect_handler asserts self._http is not None, and burn time on a confusing in-harness AssertionError. One-sentence docstring update (headless is still bound and is used when the supplied auth references its handlers) removes the trap.

    Verification: nit. The claim is factually true. The pre-existing docstring at tests/interaction/auth/_harness.py:485-488 states: "auth: supply a pre-built httpx2.Auth ... in that case storage, client_metadata, client_metadata_url, and headless are unused (the yielded HeadlessOAuth is never invoked and its authorize_url stays None)." This PR adds two tests that violate that contract: tests/inte

Comment on lines +756 to +758
if self.context.can_refresh_token():
refresh_response = yield await self._refresh_token()
refreshed = await self._handle_refresh_response(refresh_response)

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

Comment on lines 691 to +694
# 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))

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant