Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/client/oauth-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ The in-memory version above works. It also forgets everything when the process e
Store `client_info`, not only the tokens. The provider registers dynamically the first time it
finds no stored `client_info`. Throw it away and you mint a fresh registration on every run.

One exception: a stored registration whose dynamically issued secret has expired (a non-zero
`client_secret_expires_at` in the past) is treated as absent — the expired secret could never
authenticate again, so the provider discards the record and re-registers on the next flow,
overwriting it in your storage with the fresh registration.

### The two handlers

The authorization code flow needs a human exactly once: someone has to sign in and click "allow".
Expand Down Expand Up @@ -95,7 +100,7 @@ The repository ships the live version. `examples/servers/simple-auth/` runs a st

The 2026-07-28 revision of the spec deprecates dynamic client registration in favor of **Client ID Metadata Documents** (CIMD). Instead of POSTing a fresh registration to every authorization server it meets, your client publishes one JSON document about itself at a stable HTTPS URL, and that URL *is* its `client_id`. The authorization server fetches the document; the provider never touches it.

The SDK already speaks it: pass the URL as `client_metadata_url=` when you construct the provider. When the authorization server's metadata advertises `client_id_metadata_document_supported: true`, the provider skips the `/register` request entirely: the URL goes into the flow as the `client_id`, and there is no `client_secret`. When the server doesn't advertise it (most don't yet), or you never pass a URL, the provider falls back to dynamic registration **silently**, and everything above works exactly as described. Stored `client_info` still wins over both.
The SDK already speaks it: pass the URL as `client_metadata_url=` when you construct the provider. When the authorization server's metadata advertises `client_id_metadata_document_supported: true`, the provider skips the `/register` request entirely: the URL goes into the flow as the `client_id`, and there is no `client_secret`. When the server doesn't advertise it (most don't yet), or you never pass a URL, the provider falls back to dynamic registration **silently**, and everything above works exactly as described. Stored `client_info` still wins over both, as long as its registration is usable — a record whose dynamically issued secret has expired is discarded and the provider registers (or resolves the CIMD URL) afresh.

The URL must be HTTPS with a non-root path; anything else is a `ValueError` at construction, before any network happens. The shipped `examples/clients/simple-auth-client/` takes it as the `MCP_CLIENT_METADATA_URL` environment variable.

Expand Down
177 changes: 132 additions & 45 deletions src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@
)


def stored_registration_expired(client_info: OAuthClientInformationFull) -> bool:
"""Whether a stored registration's minted secret has lapsed and can no longer authenticate.

RFC 7591 requires `client_secret_expires_at` whenever a secret is issued, with ``0``
meaning the secret never expires. Once a non-zero expiry passes, every token-endpoint
interaction authenticating with that secret fails with ``invalid_client`` — and with no
RFC 7592 rotation endpoint, re-registration is the only standard recovery. The lapse
only matters for registrations that authenticate with the minted secret: ``none`` (or
an absent method) sends no secret, and `private_key_jwt` signs an assertion instead.
"""
if client_info.token_endpoint_auth_method not in _SECRET_TOKEN_ENDPOINT_AUTH_METHODS:
return False
expires_at = client_info.client_secret_expires_at
return expires_at is not None and expires_at != 0 and expires_at < int(time.time())


class PKCEParameters(BaseModel):
"""PKCE (Proof Key for Code Exchange) parameters."""

Expand Down Expand Up @@ -548,7 +564,16 @@
return False

async def _initialize(self) -> None:
"""Load stored tokens and client info."""
"""Load stored tokens and client info.

A stored registration whose minted secret has expired (RFC 7591
`client_secret_expires_at`) is loaded as-is rather than discarded here: the auth
flow discards it right before re-registering, *after* the SEP-2352 issuer checks,
which need the record's issuer stamp — an expired record that is also bound to a
different issuer must still get its cross-issuer cleanup (dropping the old
issuer's tokens and cached metadata). Until then the dead secret is never
presented: the refresh branch and the 403 step-up skip it explicitly.
"""
self.context.current_tokens = await self.context.storage.get_tokens()
self.context.client_info = await self.context.storage.get_client_info()
self._initialized = True
Expand Down Expand Up @@ -577,6 +602,63 @@
if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource):
raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}")

def _registration_issuer(self) -> str | None:
"""SEP-2352: the issuer to bind newly minted credentials to, when known."""
if self.context.oauth_metadata is not None:
return self.context.auth_server_url or str(self.context.oauth_metadata.issuer)
return None

async def _prepare_client_registration(self) -> httpx2.Request | None:
"""Resolve a URL-based client ID (CIMD) or build a Dynamic Client Registration request.

When the server supports CIMD the client information is created (and persisted)
immediately and ``None`` is returned — no network round trip is needed. Otherwise
the returned registration request must be sent and its response passed to
`_complete_client_registration`.
"""
if should_use_client_metadata_url(self.context.oauth_metadata, self.context.client_metadata_url):
# Use URL-based client ID (CIMD). CIMD records are portable across
# authorization servers, so the issuer stamp is informational.
logger.debug(f"Using URL-based client ID (CIMD): {self.context.client_metadata_url}")
client_information = create_client_info_from_metadata_url(
self.context.client_metadata_url, # type: ignore[arg-type]
redirect_uris=self.context.client_metadata.redirect_uris,
)
client_information.issuer = self._registration_issuer()
self.context.client_info = client_information
await self.context.storage.set_client_info(client_information)
return None

# Fallback to Dynamic Client Registration
fallback_base = self.context.get_authorization_base_url(self.context.server_url)
return create_client_registration_request(
self.context.oauth_metadata, self.context.client_metadata, fallback_base
)

async def _complete_client_registration(self, response: httpx2.Response) -> None:
"""Handle a Dynamic Client Registration response and persist the minted record."""
client_information = await handle_registration_response(response)
check_registration_usable(client_information)
discovered_issuer = self._registration_issuer()
fallback_base = self.context.get_authorization_base_url(self.context.server_url)
# Only record the issuer when the registration actually targeted the discovered
# AS — either via its published registration_endpoint, or because the
# resource-origin /register fallback is on the issuer's own host (legacy
# same-origin embedded AS). Otherwise the fallback hit a different server and
# recording a binding to the PRM-advertised AS would persist a binding that was
# never established.
if (
self.context.oauth_metadata is not None
and discovered_issuer is not None
and (
self.context.oauth_metadata.registration_endpoint is not None
or self.context.get_authorization_base_url(discovered_issuer) == fallback_base
)
):
client_information.issuer = discovered_issuer
self.context.client_info = client_information
await self.context.storage.set_client_info(client_information)

async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
"""httpx2 auth flow integration."""
async with self.context.lock:
Expand All @@ -586,7 +668,16 @@
# 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():
# A refresh request authenticates with the minted secret, so a registration
# whose secret has lapsed (RFC 7591 `client_secret_expires_at`) can only fail
# `invalid_client`. Skip the doomed refresh and fall through to the 401 flow,
# which re-registers; the record itself is kept for now so the flow's SEP-2352
# issuer checks can still read its issuer stamp before the expiry discard runs.
registration_expired = self.context.client_info is not None and stored_registration_expired(
self.context.client_info
)

Check warning on line 678 in src/mcp/client/auth/oauth2.py

View check run for this annotation

Claude / Claude Code Review

[quality] Expiry guard repeated verbatim at three flow sites

The compound guard `self.context.client_info is not None and stored_registration_expired(self.context.client_info)` is written out verbatim at three sites in `async_auth_flow` (the refresh gate, the 401 flow's pre-Step-4 discard, and the 403 step-up). Since `OAuthContext` already hosts the sibling flow-gating predicates `is_token_valid()` and `can_refresh_token()`, a small `OAuthContext.registration_secret_expired()` method (or widening `stored_registration_expired` to accept `OAuthClientInforma
Comment thread
claude[bot] marked this conversation as resolved.
Outdated

if not self.context.is_token_valid() and self.context.can_refresh_token() and not registration_expired:
# Try to refresh token
refresh_request = await self._refresh_token()
refresh_response = yield refresh_request
Expand All @@ -604,6 +695,7 @@
# Perform full OAuth flow
try:
# OAuth flow must be inline due to generator constraints

www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response)

# Step 1: Discover protected resource metadata (SEP-985 with fallback support)
Expand Down Expand Up @@ -694,53 +786,28 @@
self.context.oauth_metadata,
self.context.client_metadata.grant_types,
)

# A registration whose minted secret lapsed (RFC 7591
# `client_secret_expires_at`) — whether loaded from storage or expired
# mid-session — can no longer authenticate: reusing it would burn an
# interactive authorization doomed to fail `invalid_client` at the
# token endpoint. Discard it only now, after the SEP-2352 issuer
# checks above, so an expired record bound to a different issuer
# still got its cross-issuer cleanup; Step 4 then re-registers,
# overwriting the dead record in storage. Any stored tokens are kept:
# a live access token keeps working without client authentication.
if self.context.client_info is not None and stored_registration_expired(self.context.client_info):
logger.debug(
"Stored client registration secret has expired; discarding so this flow re-registers"
)
self.context.client_info = None

Check warning on line 803 in src/mcp/client/auth/oauth2.py

View check run for this annotation

Claude / Claude Code Review

Expiry discard orphans a refresh token bound to the discarded client_id

The expiry discard nulls `client_info` but keeps `current_tokens` including a `refresh_token` that was issued to the discarded `client_id` — if the flow then fails between `_complete_client_registration`'s `set_client_info` and the token exchange (abandoned consent, state mismatch, token-endpoint 5xx, crash/restart), the next request pairs fresh `client_info` with the old refresh token, `can_refresh_token()` passes, and `_refresh_token()` presents the old client's refresh token authenticated as
Comment thread
claude[bot] marked this conversation as resolved.
Outdated

# Step 4: Register client or use URL-based client ID (CIMD)
if not self.context.client_info:
# SEP-2352: the issuer to bind these credentials to, when known.
discovered_issuer: str | None = None
if self.context.oauth_metadata is not None:
discovered_issuer = self.context.auth_server_url or str(self.context.oauth_metadata.issuer)

if should_use_client_metadata_url(
self.context.oauth_metadata, self.context.client_metadata_url
):
# Use URL-based client ID (CIMD). CIMD records are portable across
# authorization servers, so the issuer stamp is informational.
logger.debug(f"Using URL-based client ID (CIMD): {self.context.client_metadata_url}")
client_information = create_client_info_from_metadata_url(
self.context.client_metadata_url, # type: ignore[arg-type]
redirect_uris=self.context.client_metadata.redirect_uris,
)
client_information.issuer = discovered_issuer
self.context.client_info = client_information
await self.context.storage.set_client_info(client_information)
else:
# Fallback to Dynamic Client Registration
fallback_base = self.context.get_authorization_base_url(self.context.server_url)
registration_request = create_client_registration_request(
self.context.oauth_metadata, self.context.client_metadata, fallback_base
)
registration_request = await self._prepare_client_registration()
if registration_request is not None:
registration_response = yield registration_request
client_information = await handle_registration_response(registration_response)
check_registration_usable(client_information)
# Only record the issuer when the registration above actually targeted
# the discovered AS — either via its published registration_endpoint,
# or because the resource-origin /register fallback is on the issuer's
# own host (legacy same-origin embedded AS). Otherwise the fallback hit
# a different server and recording a binding to the PRM-advertised AS
# would persist a binding that was never established.
if (
self.context.oauth_metadata is not None
and discovered_issuer is not None
and (
self.context.oauth_metadata.registration_endpoint is not None
or self.context.get_authorization_base_url(discovered_issuer) == fallback_base
)
):
client_information.issuer = discovered_issuer
self.context.client_info = client_information
await self.context.storage.set_client_info(client_information)
await self._complete_client_registration(registration_response)

# Step 5: Perform authorization and complete token exchange
token_response = yield await self._perform_authorization()
Expand Down Expand Up @@ -773,6 +840,26 @@
prior_scope = union_scopes(self.context.client_metadata.scope, granted_scope)
self.context.client_metadata.scope = union_scopes(prior_scope, challenged_scope)

# A registration whose minted secret lapsed (RFC 7591
# `client_secret_expires_at`) cannot complete the step-up: the
# token exchange would fail `invalid_client` after burning a full
# interactive consent — and the still-live access token keeps the
# 401 flow's discard from ever running. Discard it and mint fresh
# credentials first (mirroring the 401 flow's Step 4, reusing any
# AS metadata already discovered).
if self.context.client_info is not None and stored_registration_expired(
self.context.client_info
):
logger.debug(
"Stored client registration secret has expired; re-registering before the step-up"
)
self.context.client_info = None
if not self.context.client_info:
registration_request = await self._prepare_client_registration()
if registration_request is not None:
registration_response = yield registration_request
await self._complete_client_registration(registration_response)

# Step 2b: Perform (re-)authorization and token exchange
token_response = yield await self._perform_authorization()
await self._handle_token_response(token_response)
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down
Loading
Loading