Skip to content

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

Open
maxisbey wants to merge 1 commit into
mainfrom
oauth-refresh-on-401
Open

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

Conversation

@maxisbey

Copy link
Copy Markdown
Contributor

OAuthClientProvider now recovers from an expired access token the way the other SDKs do: on a 401 it rediscovers, tries the stored refresh token, and only falls back to interactive authorization if that fails. A refresh attempted before the first request of a process discovers metadata first instead of guessing {origin}/token. A dynamically registered client whose secret has expired (or that the token endpoint rejects with invalid_client) is discarded and re-registered once instead of being reused forever.

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

Motivation and Context

The provider had two disconnected ways to obtain a token. The pre-request branch could refresh but never ran discovery; the 401 branch ran discovery but never tried the refresh token. After a restart the first branch is unreachable (_initialize restores no expiry, and is_token_valid() reads "no expiry" as valid), so the stale bearer goes out, the server 401s, and the flow goes straight to the browser with a perfectly good 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 was posted to a path derived from the server origin, which 404s for any authorization server under a path, and the refresh token was then thrown away. #1318 reported this a year ago; #3240 and #3250 are the same defect from different angles.

The 401 branch did try the refresh token in 1.10/1.11; #1071 moved that block ahead of the request rather than duplicating it, and nothing tested the restart path, so it went unnoticed.

#3256 is adjacent: client_secret_expires_at was persisted but never read, and neither token-response handler looked at the RFC 6749 error member, so once a DCR secret lapsed every attempt spent a consent at /authorize and died at the code exchange with invalid_client.

What changed

  • The 401 body moves into _reacquire_tokens(challenge), an async generator both entry points drive. Order: PRM/ASM discovery (always on a 401; before a cold-start refresh only when nothing is cached) → SEP-2352 issuer-binding checks (unchanged) → drop an SDK-minted registration whose secret has lapsed → scope selection (401 only) → register/CIMD if needed → refresh_token grant if one is held → the provider's full grant. With challenge=None (cold start) it never registers or authorizes unprompted; it refreshes if it can and otherwise lets the request go out so a real 401, with its WWW-Authenticate hints, drives the rest.
  • invalid_client from the token endpoint (refresh or code exchange, 400 or 401) discards the registration and its tokens and runs registration once more. Only registrations the SDK minted are treated this way — they carry the SEP-2352 issuer stamp, the same provenance test credentials_match_issuer uses — so pre-registered credentials and the client-credentials providers' fixed client_info surface OAuthTokenError rather than being swapped for a dynamic client.
  • _initialize derives token_expiry_time from the loaded token unless the application already set it. For a storage that persists the token verbatim that window is stale, which now costs one 401 round trip rather than a re-authorization.
  • A fresh DCR registration clears held tokens (they're bound to the previous client and can't be refreshed by the new one).
  • New helpers client_secret_lapsed() and token_error_code(); two duplicated "discard bound credentials" blocks fold into _discard_registration().
  • The 2025-03-26 {origin}/token|/authorize|/register fallbacks are untouched; they're just no longer reached because state wasn't loaded.

No public API changes; TokenStorage is unchanged. Most of the oauth2.py line count is the existing 401 body re-indented into the new method (git diff -w is +150/−37).

How Has This Been Tested?

Six interaction tests against the SDK's own AS+RS in process (a first "process" logs in, a fresh provider over the same storage is the restart): refresh on 401 with no handlers wired; cold-start refresh against an AS under /oauth2/v1 hitting the advertised endpoint; lapsed client_secret_expires_at replaced before any consent; invalid_client on refresh → re-register; invalid_client at the code exchange → re-register and authorize once more; pre-registered credentials → error surfaces, no /register. Unit tests for the two helpers and the _initialize guard. Manifest gains client-auth:refresh:on-401, client-auth:refresh:discovered-endpoint, client-auth:registration:secret-expiry, and client-auth:invalid-client-clears-all loses its deferral. The harness grows a path-prefixed-AS shim, a report_expired_on_load storage knob and client_secret_expiry_seconds on auth_settings(); the server-side "Client secret has expired" branch is now executed, so its pragma goes.

Also driven over real sockets: uvicorn serving the SDK AS (3 s access tokens, expiring DCR secrets, then the AS under a prefix), and a separate application process with file-backed storage run repeatedly against it. Headless runs after expiry show 401 → PRM → ASM → POST …/token grant_type=refresh_token → 200; after the secret window, one consent with a new client id and headless refreshes thereafter; with the AS restarted (all registrations forgotten) the headless run re-registers and stops cleanly asking for a browser.

The restart path had no coverage before this (no test loaded tokens through _initialize, none entered the 401 branch holding a refresh token), which is why nothing existing changes.

Breaking Changes

None intended. Behavioural notes: a 401 with a refresh token in storage now produces a token-endpoint request before any redirect; applications that pre-seed context.token_expiry_time or context.oauth_metadata keep working (both are respected); _initialized = False after a failed refresh is preserved.

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

AI Disclaimer

…eshing

OAuthClientProvider had two disconnected ways to obtain a token: a
pre-request branch that could refresh but never discovered metadata, and
a 401 branch that discovered but never tried the refresh token. After a
process restart the first is unreachable (no expiry is restored, so the
loaded token always reads as valid) and the second goes straight to
interactive authorization, so every restarted client re-opened the
browser once its access token lapsed and headless clients failed
outright. When an application forced the pre-request path, the refresh
was posted to a path guessed from the server origin and 404ed against
any authorization server mounted under a path. Separately, a dynamically
registered client whose secret had expired was reused forever: the
stored client_secret_expires_at was never read and invalid_client from
the token endpoint was not recognised, so each attempt spent a consent
and failed at the code exchange.

Both entry points now drive one sequence, _reacquire_tokens: discover
(always on a 401, and before a cold-start refresh when nothing is
cached), drop a registration the SDK minted whose secret has lapsed,
try the refresh_token grant when one is held, and only then run the
provider's full grant. invalid_client from the token endpoint discards
an SDK-minted registration and its tokens and runs registration once
more; pre-registered credentials surface the error instead. _initialize
derives an expiry from the loaded token unless the application already
set one. The 2025-03-26 origin-path fallbacks remain for servers with no
metadata but are no longer reached merely because state was not loaded.

Closes #3240, #3250, #3256, #1318.
@github-actions

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3328.mcp-python-docs.pages.dev
Deployment https://12ed6c32.mcp-python-docs.pages.dev
Commit 07d639a
Triggered by @maxisbey
Updated 2026-08-17 23:32:10 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 on lines +132 to +133
if self.tokens is not None and self.report_expired_on_load:
return self.tokens.model_copy(update={"expires_in": 0})

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.

🔴 report_expired_on_load stands in for "already expired" with expires_in=0, but that makes _initialize set token_expiry_time = time.time() and is_token_valid() uses the inclusive test time.time() <= token_expiry_time microseconds later - on Windows CPython 3.10-3.12, where time.time() is backed by GetSystemTimeAsFileTime with ~15.6 ms granularity (the precise clock was only adopted in 3.13), both calls return the same value, so the token is judged still valid and the cold-start refresh path never runs.

Extended reasoning...

On the windows-latest x {3.10, 3.11, 3.12} CI entries (matrix at .github/workflows/shared.yml line 75), test_a_refresh_before_the_first_request_discovers_metadata_and_posts_to_the_advertised_token_endpoint and test_a_stored_registration_with_a_lapsed_secret_is_replaced_before_authorizing enter async_auth_flow, _initialize() derives expiry A from the copied token's expires_in=0, and the not is_token_valid() gate evaluates time.time() (== A within the same 15.6 ms clock tick) <= A as True. The cold-start _reacquire_tokens(challenge=None) pass is skipped, the stale bearer (still valid server-side in both tests - neither calls expire_access_token) is sent to /mcp and succeeds, so recorded[0] is ("POST", "/mcp") instead of the discovery GET and the snapshot assertions fail - a near-deterministic failure on those matrix entries, not just a flake. Fix: return the copy with a strictly negative expires_in (e.g. -1), which OAuthToken permits (expires_in: int | None has no ge=0 constraint).

Verification: normal. The chain is real and each link checks out. (1) /home/claude/python-sdk/tests/interaction/auth/_harness.py:131-134: get_tokens returns self.tokens.model_copy(update={"expires_in": 0}). (2) /home/claude/python-sdk/src/mcp/client/auth/oauth2.py:584-585 (new in this PR): _initialize derives expiry via update_token_expiry -> calculate_token_expiry(0) which is time.time() + int(0)

Comment on lines +803 to +810
if (
may_reregister
and self._sdk_minted_registration()
and await token_error_code(refresh_response) == "invalid_client"
):
self._discard_registration("Token endpoint rejected the client credentials")
may_reregister = False
continue

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.

🟡 Pre-existing, extended by this diff: when the token endpoint answers invalid_client to the Step 5a refresh but the registration is NOT SDK-minted (pre-registered credentials, or the client-credentials providers' fixed client_info which carries no issuer stamp), the new guard at lines 803-810 skips the discard and the flow falls through to Step 5b _perform_authorization(), running the full grant with the exact same client authentication the server just rejected. For the interactive provider this launches a browser consent that is guaranteed to fail at the code exchange with the same invalid_client (the exchange uses the same prepare_token_auth secret/client_id), only then raising OAuthTokenError. The PR and the updated client-auth:invalid-client-clears-all manifest entry claim

Extended reasoning...

An operator pre-registers a confidential client (no issuer stamp) and its secret is rotated or revoked on the AS. The stored access token expires; the next request 401s; _reacquire_tokens(challenge=response) tries the held refresh token, the token endpoint answers 401 {"error":"invalid_client"}, the guard at line 803-810 does nothing (not SDK-minted), and execution reaches Step 5b at line 817: the user's browser is opened, they complete a consent at /authorize, and the subsequent code exchange fails with the same invalid_client, surfacing OAuthTokenError only after the pointless interactive round trip. A headless deployment with pre-registered credentials instead crashes at the missing redirect handler rather than getting the documented OAuthTokenError.

Verification: nit — the mechanics check out but the end state is the designed one, just after one doomed consent. At src/mcp/client/auth/oauth2.py:803-810 the invalid_client re-register guard requires self._sdk_minted_registration() (line 621: client_info.issuer is not None), so a pre-registered client whose secret was rotated gets no discard and no early error; _handle_refresh_response (548-551) merely c

Comment on lines +727 to +728
client_info = self.context.client_info
if client_info is not None and client_info.issuer is not None and client_secret_lapsed(client_info):

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.

🟡 [quality] Secret-lapse check inlines the SDK-minted provenance test (client_info.issuer is not None) instead of calling the _sdk_minted_registration() helper defined ~100 lines above for exactly this purpose

Extended reasoning...

Duplicated provenance logic: the same "is this registration ours to replace" rule now lives in two places (the helper used by the invalid_client paths at lines 805/820, and the inline copy at line 728). If the provenance test ever changes (e.g. an additional stamp or a CIMD carve-out), the secret-lapse site can silently diverge from the invalid_client sites. if self._sdk_minted_registration() and client_secret_lapsed(client_info) (with the existing client_info local for narrowing) does the same job with one source of truth.

Verification: nit — the claim is factually accurate. /home/claude/python-sdk/src/mcp/client/auth/oauth2.py:613-621 defines _sdk_minted_registration() whose body is exactly return self.context.client_info is not None and self.context.client_info.issuer is not None, documented as the SEP-2352 provenance test for registrations the SDK may replace. Line 728 re-states that same test inline: `if client_info is no

"""
# Step 1-2: discovery. Always on a 401 (the AS may have changed); before a cold-start
# refresh only when nothing has been discovered yet in this process.
if challenge is not None or self.context.oauth_metadata is None:

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.

🟡 [quality] Cold-start discovery gate challenge is not None or self.context.oauth_metadata is None only remembers successful discovery, so against a legacy server that publishes no metadata (both PRM and ASM discovery 404), every pre-request refresh in the process re-runs the entire failing PRM+ASM discovery URL chain before posting the refresh.

Extended reasoning...

Concrete cost: wasted repeated I/O. For a 2025-03-26-style server with no /.well-known metadata, oauth_metadata stays None forever, so each time the access token expires in a long-lived process the pre-request branch in async_auth_flow (line 838) issues several guaranteed-404 GETs (all PRM discovery URLs plus all ASM fallback URLs) before the refresh POST to {origin}/token — every token lifetime, for the life of the process. Before this change the cold refresh posted directly. Tracking 'discovery attempted this process' (a flag set after the discovery block runs) instead of gating on oauth_metadata is None gives the intended 'only when nothing has been discovered yet in this process' behavior without the repeated failing round-trips.

Verification: nit — the claim is factually accurate. The cold-start gate at src/mcp/client/auth/oauth2.py:642 is if challenge is not None or self.context.oauth_metadata is None:, and the only thing that ever suppresses re-discovery on the pre-request path is a successful ASM discovery (self.context.oauth_metadata = asm inside the Step-2 loop). Against a legacy 2025-03-26 server that publishes no metadata,

Comment on lines +580 to +585
# Derive an expiry from the loaded token unless the application already set one (some
# storages assign `context.token_expiry_time` before or during `get_tokens`). A storage
# that persists tokens verbatim yields a stale window here; that costs one 401 round-trip,
# after which `_reacquire_tokens` refreshes.
if self.context.current_tokens is not None and self.context.token_expiry_time is None:
self.context.update_token_expiry(self.context.current_tokens)

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.

🟡 [quality] The #3250 fix (derive token_expiry_time from the loaded token) is applied only in OAuthClientProvider._initialize; ClientCredentialsOAuthProvider._initialize and PrivateKeyJWTOAuthProvider._initialize (src/mcp/client/auth/extensions/client_credentials.py lines 79-81 and 291-295) override it with duplicated token-loading code that still leaves token_expiry_time None, so the exact defect this PR fixes for the base class - a persisted token treated as valid forever - persists for the M2M providers. Fix at shared depth: put load-plus-derivation in one helper (or in OAuthContext) that all three _initialize implementations call.

Extended reasoning...

Concrete cost: divergent behavior plus duplicated logic. A restarted ClientCredentialsOAuthProvider/PrivateKeyJWTOAuthProvider whose storage reports the token's remaining lifetime via expires_in (the same storage shape the new report_expired_on_load models) still gets token_expiry_time=None, is_token_valid() reads that as valid, and the stale bearer goes out and draws a 401 on every restart instead of the documented cold-start refresh (docs/client/oauth-clients.md now promises 'an expired access token is refreshed... before the next request'). Meanwhile the three near-identical _initialize bodies mean the next change to token loading must be made in three places - the identity_assertion extension already derives expiry on load, showing a fourth divergent copy of the same logic.

Verification: nit — the factual claim checks out. The new derivation lives only in the base class: src/mcp/client/auth/oauth2.py:584-585 (if self.context.current_tokens is not None and self.context.token_expiry_time is None: self.context.update_token_expiry(...)). Both M2M subclasses override _initialize with duplicated token-loading that skips it: src/mcp/client/auth/extensions/client_credentials.py:77-81

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