Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
9 changes: 8 additions & 1 deletion docs/client/oauth-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ 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. Any refresh token goes with the
discarded record (it was issued to that `client_id` and no other client can redeem it), so the
stored tokens are rewritten without it; a still-live access token is kept and keeps working.

### 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 +102,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
193 changes: 148 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 check_registration_usable(client_info: OAuthClientInformationFull) -> None:
)


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 @@ -192,6 +208,25 @@ def can_refresh_token(self) -> bool:
"""Check if token can be refreshed."""
return bool(self.current_tokens and self.current_tokens.refresh_token and self.client_info)

def registration_secret_expired(self) -> bool:
"""Whether the loaded registration's minted secret has lapsed (RFC 7591)."""
return self.client_info is not None and stored_registration_expired(self.client_info)

async def discard_expired_registration(self) -> None:
"""Discard a registration whose minted secret lapsed so the flow re-registers.

The refresh token goes with it: RFC 6749 §6 binds a refresh token to the client
it was issued to, so once the flow re-registers under a fresh `client_id` the
orphaned token could only fail `invalid_grant`. The trimmed tokens are persisted
so an interrupted flow (or a restart) cannot resurrect the orphan. The live
access token is a bearer credential that keeps working without client
authentication, so it is kept.
"""
self.client_info = None
if self.current_tokens is not None and self.current_tokens.refresh_token is not None:
self.current_tokens.refresh_token = None
await self.storage.set_tokens(self.current_tokens)

def clear_tokens(self) -> None:
"""Clear current tokens."""
self.current_tokens = None
Expand Down Expand Up @@ -548,7 +583,16 @@ async def _handle_refresh_response(self, response: httpx2.Response) -> bool:
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 +621,63 @@ async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None
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 +687,14 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
# 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.registration_secret_expired()

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 +712,7 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
# 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 @@ -695,52 +804,28 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
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. The refresh token is
# dropped with the record it was issued to; the live access token
# is kept — it works without client authentication.
if self.context.registration_secret_expired():
logger.debug(
"Stored client registration secret has expired; discarding so this flow re-registers"
)
await self.context.discard_expired_registration()

# 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 +858,24 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
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.registration_secret_expired():
logger.debug(
"Stored client registration secret has expired; re-registering before the step-up"
)
await self.context.discard_expired_registration()
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