@@ -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