Skip to content

Commit 5a246e6

Browse files
committed
fix(client/auth): run the expiry discard after the SEP-2352 issuer checks; cover the refresh and 403 step-up paths
The expired-registration discard previously nulled client_info before the SEP-2352 issuer-binding checks, so a record that was both expired and issuer-mismatched skipped the cross-issuer cleanup (old tokens and cached AS metadata were kept). The discard now runs just before Step 4, after both issuer checks; _initialize loads the record as-is and defers the discard to the flow. The two other paths that present the minted secret are covered as well: the refresh branch skips a refresh whose registration secret has lapsed (falling through to the 401 flow's re-registration instead of failing invalid_client), and the 403 insufficient_scope step-up re-registers — mirroring Step 4, reusing any discovered AS metadata, extracted into _prepare_client_registration/_complete_client_registration — before running the interactive authorization, instead of burning a consent doomed to fail invalid_client while the live access token keeps the 401 discard from running.
1 parent 6b7ab0d commit 5a246e6

2 files changed

Lines changed: 404 additions & 75 deletions

File tree

src/mcp/client/auth/oauth2.py

Lines changed: 113 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -566,21 +566,16 @@ async def _handle_refresh_response(self, response: httpx2.Response) -> bool:
566566
async def _initialize(self) -> None:
567567
"""Load stored tokens and client info.
568568
569-
Stored client information whose minted secret has expired (RFC 7591
570-
`client_secret_expires_at`) is treated as absent: reusing it can only produce
571-
`invalid_client` at the token endpoint — even interactive re-authorization ends in
572-
the same failure, permanently — so it is discarded here and the next 401 flow
573-
re-registers (or resolves CIMD), overwriting the dead record in storage. Any still
574-
stored tokens are kept: a live access token keeps working without client
575-
authentication, and with no client info the refresh path (which would present the
576-
lapsed secret) is skipped.
569+
A stored registration whose minted secret has expired (RFC 7591
570+
`client_secret_expires_at`) is loaded as-is rather than discarded here: the auth
571+
flow discards it right before re-registering, *after* the SEP-2352 issuer checks,
572+
which need the record's issuer stamp — an expired record that is also bound to a
573+
different issuer must still get its cross-issuer cleanup (dropping the old
574+
issuer's tokens and cached metadata). Until then the dead secret is never
575+
presented: the refresh branch and the 403 step-up skip it explicitly.
577576
"""
578577
self.context.current_tokens = await self.context.storage.get_tokens()
579-
client_info = await self.context.storage.get_client_info()
580-
if client_info is not None and stored_registration_expired(client_info):
581-
logger.debug("Stored client registration secret has expired; discarding so the next flow re-registers")
582-
client_info = None
583-
self.context.client_info = client_info
578+
self.context.client_info = await self.context.storage.get_client_info()
584579
self._initialized = True
585580

586581
def _add_auth_header(self, request: httpx2.Request) -> None:
@@ -607,6 +602,63 @@ async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None
607602
if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource):
608603
raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}")
609604

605+
def _registration_issuer(self) -> str | None:
606+
"""SEP-2352: the issuer to bind newly minted credentials to, when known."""
607+
if self.context.oauth_metadata is not None:
608+
return self.context.auth_server_url or str(self.context.oauth_metadata.issuer)
609+
return None
610+
611+
async def _prepare_client_registration(self) -> httpx2.Request | None:
612+
"""Resolve a URL-based client ID (CIMD) or build a Dynamic Client Registration request.
613+
614+
When the server supports CIMD the client information is created (and persisted)
615+
immediately and ``None`` is returned — no network round trip is needed. Otherwise
616+
the returned registration request must be sent and its response passed to
617+
`_complete_client_registration`.
618+
"""
619+
if should_use_client_metadata_url(self.context.oauth_metadata, self.context.client_metadata_url):
620+
# Use URL-based client ID (CIMD). CIMD records are portable across
621+
# authorization servers, so the issuer stamp is informational.
622+
logger.debug(f"Using URL-based client ID (CIMD): {self.context.client_metadata_url}")
623+
client_information = create_client_info_from_metadata_url(
624+
self.context.client_metadata_url, # type: ignore[arg-type]
625+
redirect_uris=self.context.client_metadata.redirect_uris,
626+
)
627+
client_information.issuer = self._registration_issuer()
628+
self.context.client_info = client_information
629+
await self.context.storage.set_client_info(client_information)
630+
return None
631+
632+
# Fallback to Dynamic Client Registration
633+
fallback_base = self.context.get_authorization_base_url(self.context.server_url)
634+
return create_client_registration_request(
635+
self.context.oauth_metadata, self.context.client_metadata, fallback_base
636+
)
637+
638+
async def _complete_client_registration(self, response: httpx2.Response) -> None:
639+
"""Handle a Dynamic Client Registration response and persist the minted record."""
640+
client_information = await handle_registration_response(response)
641+
check_registration_usable(client_information)
642+
discovered_issuer = self._registration_issuer()
643+
fallback_base = self.context.get_authorization_base_url(self.context.server_url)
644+
# Only record the issuer when the registration actually targeted the discovered
645+
# AS — either via its published registration_endpoint, or because the
646+
# resource-origin /register fallback is on the issuer's own host (legacy
647+
# same-origin embedded AS). Otherwise the fallback hit a different server and
648+
# recording a binding to the PRM-advertised AS would persist a binding that was
649+
# never established.
650+
if (
651+
self.context.oauth_metadata is not None
652+
and discovered_issuer is not None
653+
and (
654+
self.context.oauth_metadata.registration_endpoint is not None
655+
or self.context.get_authorization_base_url(discovered_issuer) == fallback_base
656+
)
657+
):
658+
client_information.issuer = discovered_issuer
659+
self.context.client_info = client_information
660+
await self.context.storage.set_client_info(client_information)
661+
610662
async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
611663
"""httpx2 auth flow integration."""
612664
async with self.context.lock:
@@ -616,7 +668,16 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
616668
# Capture protocol version from request headers
617669
self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER)
618670

619-
if not self.context.is_token_valid() and self.context.can_refresh_token():
671+
# A refresh request authenticates with the minted secret, so a registration
672+
# whose secret has lapsed (RFC 7591 `client_secret_expires_at`) can only fail
673+
# `invalid_client`. Skip the doomed refresh and fall through to the 401 flow,
674+
# which re-registers; the record itself is kept for now so the flow's SEP-2352
675+
# issuer checks can still read its issuer stamp before the expiry discard runs.
676+
registration_expired = self.context.client_info is not None and stored_registration_expired(
677+
self.context.client_info
678+
)
679+
680+
if not self.context.is_token_valid() and self.context.can_refresh_token() and not registration_expired:
620681
# Try to refresh token
621682
refresh_request = await self._refresh_token()
622683
refresh_response = yield refresh_request
@@ -635,17 +696,6 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
635696
try:
636697
# OAuth flow must be inline due to generator constraints
637698

638-
# A registration whose minted secret lapsed mid-session (after
639-
# _initialize already loaded it) can no longer authenticate either —
640-
# discard it here too, so Step 4 re-registers instead of running an
641-
# interactive authorization doomed to fail `invalid_client` at the
642-
# token endpoint.
643-
if self.context.client_info is not None and stored_registration_expired(self.context.client_info):
644-
logger.debug(
645-
"Stored client registration secret has expired; discarding so this flow re-registers"
646-
)
647-
self.context.client_info = None
648-
649699
www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response)
650700

651701
# Step 1: Discover protected resource metadata (SEP-985 with fallback support)
@@ -737,52 +787,27 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
737787
self.context.client_metadata.grant_types,
738788
)
739789

790+
# A registration whose minted secret lapsed (RFC 7591
791+
# `client_secret_expires_at`) — whether loaded from storage or expired
792+
# mid-session — can no longer authenticate: reusing it would burn an
793+
# interactive authorization doomed to fail `invalid_client` at the
794+
# token endpoint. Discard it only now, after the SEP-2352 issuer
795+
# checks above, so an expired record bound to a different issuer
796+
# still got its cross-issuer cleanup; Step 4 then re-registers,
797+
# overwriting the dead record in storage. Any stored tokens are kept:
798+
# a live access token keeps working without client authentication.
799+
if self.context.client_info is not None and stored_registration_expired(self.context.client_info):
800+
logger.debug(
801+
"Stored client registration secret has expired; discarding so this flow re-registers"
802+
)
803+
self.context.client_info = None
804+
740805
# Step 4: Register client or use URL-based client ID (CIMD)
741806
if not self.context.client_info:
742-
# SEP-2352: the issuer to bind these credentials to, when known.
743-
discovered_issuer: str | None = None
744-
if self.context.oauth_metadata is not None:
745-
discovered_issuer = self.context.auth_server_url or str(self.context.oauth_metadata.issuer)
746-
747-
if should_use_client_metadata_url(
748-
self.context.oauth_metadata, self.context.client_metadata_url
749-
):
750-
# Use URL-based client ID (CIMD). CIMD records are portable across
751-
# authorization servers, so the issuer stamp is informational.
752-
logger.debug(f"Using URL-based client ID (CIMD): {self.context.client_metadata_url}")
753-
client_information = create_client_info_from_metadata_url(
754-
self.context.client_metadata_url, # type: ignore[arg-type]
755-
redirect_uris=self.context.client_metadata.redirect_uris,
756-
)
757-
client_information.issuer = discovered_issuer
758-
self.context.client_info = client_information
759-
await self.context.storage.set_client_info(client_information)
760-
else:
761-
# Fallback to Dynamic Client Registration
762-
fallback_base = self.context.get_authorization_base_url(self.context.server_url)
763-
registration_request = create_client_registration_request(
764-
self.context.oauth_metadata, self.context.client_metadata, fallback_base
765-
)
807+
registration_request = await self._prepare_client_registration()
808+
if registration_request is not None:
766809
registration_response = yield registration_request
767-
client_information = await handle_registration_response(registration_response)
768-
check_registration_usable(client_information)
769-
# Only record the issuer when the registration above actually targeted
770-
# the discovered AS — either via its published registration_endpoint,
771-
# or because the resource-origin /register fallback is on the issuer's
772-
# own host (legacy same-origin embedded AS). Otherwise the fallback hit
773-
# a different server and recording a binding to the PRM-advertised AS
774-
# would persist a binding that was never established.
775-
if (
776-
self.context.oauth_metadata is not None
777-
and discovered_issuer is not None
778-
and (
779-
self.context.oauth_metadata.registration_endpoint is not None
780-
or self.context.get_authorization_base_url(discovered_issuer) == fallback_base
781-
)
782-
):
783-
client_information.issuer = discovered_issuer
784-
self.context.client_info = client_information
785-
await self.context.storage.set_client_info(client_information)
810+
await self._complete_client_registration(registration_response)
786811

787812
# Step 5: Perform authorization and complete token exchange
788813
token_response = yield await self._perform_authorization()
@@ -815,6 +840,26 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
815840
prior_scope = union_scopes(self.context.client_metadata.scope, granted_scope)
816841
self.context.client_metadata.scope = union_scopes(prior_scope, challenged_scope)
817842

843+
# A registration whose minted secret lapsed (RFC 7591
844+
# `client_secret_expires_at`) cannot complete the step-up: the
845+
# token exchange would fail `invalid_client` after burning a full
846+
# interactive consent — and the still-live access token keeps the
847+
# 401 flow's discard from ever running. Discard it and mint fresh
848+
# credentials first (mirroring the 401 flow's Step 4, reusing any
849+
# AS metadata already discovered).
850+
if self.context.client_info is not None and stored_registration_expired(
851+
self.context.client_info
852+
):
853+
logger.debug(
854+
"Stored client registration secret has expired; re-registering before the step-up"
855+
)
856+
self.context.client_info = None
857+
if not self.context.client_info:
858+
registration_request = await self._prepare_client_registration()
859+
if registration_request is not None:
860+
registration_response = yield registration_request
861+
await self._complete_client_registration(registration_response)
862+
818863
# Step 2b: Perform (re-)authorization and token exchange
819864
token_response = yield await self._perform_authorization()
820865
await self._handle_token_response(token_response)

0 commit comments

Comments
 (0)