1010import string
1111import time
1212from collections .abc import AsyncGenerator , Awaitable , Callable
13+ from contextlib import aclosing
1314from dataclasses import dataclass , field
1415from typing import Any , Protocol , get_args
1516from urllib .parse import quote , urlencode , urljoin , urlparse
@@ -160,6 +161,9 @@ class OAuthContext:
160161 oauth_metadata : OAuthMetadata | None = None
161162 auth_server_url : str | None = None
162163 protocol_version : str | None = None
164+ # Whether the eager (pre-401) refresh already ran its blind discovery probes, so a
165+ # server that publishes no metadata is not re-probed on every in-process refresh.
166+ eager_discovery_attempted : bool = False
163167
164168 # Client registration
165169 client_info : OAuthClientInformationFull | None = None
@@ -584,43 +588,63 @@ async def _refresh_with_discovery(self) -> AsyncGenerator[httpx2.Request, httpx2
584588 token reused before any 401) that metadata has not been discovered yet, so
585589 ``_refresh_token`` would fall back to ``{origin}/token`` — dropping any issuer
586590 path and 404ing on servers whose token endpoint lives elsewhere. Discovery runs
587- first, applying the same SEP-2352 issuer-binding checks as the 401 path so stored
588- credentials are never sent to an authorization server they are not bound to: on a
589- binding mismatch the credentials and tokens are dropped and the refresh is
590- skipped, letting the subsequent 401 flow re-register against the new server.
591- Yields the discovery and refresh requests so they run through the outer httpx
592- auth flow rather than a side-channel client.
591+ first so the refresh targets the discovered token endpoint.
592+
593+ Unlike the 401 path, this discovery is unanchored: there is no WWW-Authenticate
594+ ``resource_metadata`` hint, only blind well-known probes, so a co-hosted origin
595+ can legitimately serve some *other* resource's documents. Results are therefore
596+ treated as best-effort, never authoritative: a resource-mismatched PRM counts as
597+ a failed discovery rather than an error, and a SEP-2352 issuer-binding mismatch
598+ skips the eager refresh (so stored credentials are never presented to an
599+ unvalidated authorization server) while leaving the credentials themselves for
600+ the anchored 401 path to judge — that path re-discovers with the server's hint
601+ and drops/re-registers only on a confirmed change. Servers publishing no
602+ metadata at all keep the pre-existing ``{origin}/token`` fallback, and the
603+ probes run only once per context (``eager_discovery_attempted``). Yields the
604+ discovery and refresh requests so they run through the outer httpx auth flow
605+ rather than a side-channel client.
593606 """
594- if self .context .oauth_metadata is None :
607+ if self .context .oauth_metadata is None and not self .context .eager_discovery_attempted :
608+ self .context .eager_discovery_attempted = True
609+
595610 # Step 1: protected resource metadata -> authorization server URL (SEP-985).
596- # Best-effort: a legacy server without PRM falls through to the origin
597- # well-known fallback in the ASM step below. There is no 401 response at
598- # this point, so no WWW-Authenticate resource_metadata hint is available .
611+ # Best-effort: a PRM that fails resource validation is some other co-hosted
612+ # resource's document, not ours — skip it; a legacy server without PRM falls
613+ # through to the origin well-known fallback in the ASM step below .
599614 for url in build_protected_resource_metadata_discovery_urls (None , self .context .server_url ):
600615 prm = await handle_protected_resource_response ((yield create_oauth_metadata_request (url )))
601616 if prm :
602- # Validate PRM resource matches server URL (RFC 8707)
603- await self ._validate_resource_match (prm )
617+ try :
618+ # Validate PRM resource matches server URL (RFC 8707)
619+ await self ._validate_resource_match (prm )
620+ except OAuthFlowError :
621+ logger .debug (f"Ignoring protected resource metadata for a different resource: { url } " )
622+ continue
604623 self .context .protected_resource_metadata = prm
605624 self .context .auth_server_url = str (prm .authorization_servers [0 ])
606625 break
607626 else :
608627 logger .debug (f"Protected resource metadata discovery failed: { url } " )
609628
610629 # SEP-2352: stored credentials are bound to the issuer that registered them.
611- # If the authorization server changed, drop them (and the old tokens) and skip
612- # the refresh so the 401 flow re-registers instead of presenting another
613- # server's credentials to the newly discovered one.
630+ # A mismatch here may mean the AS changed — or merely that the blind probe
631+ # found a different co-hosted resource's PRM. Skip the eager refresh so the
632+ # credentials are never presented to an unvalidated AS, discard the
633+ # unanchored discovery results, and let the 401 path decide with the
634+ # server's own hint whether to drop the credentials and re-register.
614635 if (
615636 self .context .client_info is not None
616637 and self .context .auth_server_url is not None
617638 and not credentials_match_issuer (
618639 self .context .client_info , self .context .auth_server_url , self .context .client_metadata_url
619640 )
620641 ):
621- logger .debug ("Authorization server changed; discarding bound credentials and skipping refresh" )
622- self .context .client_info = None
623- self .context .clear_tokens ()
642+ logger .debug (
643+ "Eagerly discovered authorization server does not match stored credential binding; "
644+ "skipping refresh and deferring to 401 discovery"
645+ )
646+ self .context .protected_resource_metadata = None
647+ self .context .auth_server_url = None
624648 return
625649
626650 # Step 2: authorization server metadata -> the token endpoint (with fallback
@@ -641,8 +665,10 @@ async def _refresh_with_discovery(self) -> AsyncGenerator[httpx2.Request, httpx2
641665 logger .debug (f"OAuth metadata discovery failed: { url } " )
642666
643667 # SEP-2352: on the legacy no-PRM path the issuer is only known after ASM
644- # discovery, so re-evaluate the binding here using the discovered metadata
645- # issuer (mirroring the 401 path's post-ASM check).
668+ # discovery, so re-evaluate the binding here (mirroring the 401 path's
669+ # post-ASM check). As above, skip the refresh and discard the unanchored
670+ # metadata rather than acting on it — keeping it would let a rejected
671+ # issuer's endpoints leak into a later 401 flow's registration step.
646672 if (
647673 self .context .client_info is not None
648674 and self .context .auth_server_url is None
@@ -653,9 +679,11 @@ async def _refresh_with_discovery(self) -> AsyncGenerator[httpx2.Request, httpx2
653679 self .context .client_metadata_url ,
654680 )
655681 ):
656- logger .debug ("Authorization server changed; discarding bound credentials and skipping refresh" )
657- self .context .client_info = None
658- self .context .clear_tokens ()
682+ logger .debug (
683+ "Eagerly discovered authorization server does not match stored credential binding; "
684+ "skipping refresh and deferring to 401 discovery"
685+ )
686+ self .context .oauth_metadata = None
659687 return
660688
661689 refresh_response = yield await self ._refresh_token ()
@@ -675,15 +703,16 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
675703 if not self .context .is_token_valid () and self .context .can_refresh_token ():
676704 # Refresh the token, discovering authorization-server metadata first on a
677705 # cold start (see _refresh_with_discovery). Driven inline so its requests
678- # run through this httpx auth flow, not a side-channel client.
679- refresh_flow = self ._refresh_with_discovery ()
680- refresh_request = await refresh_flow .__anext__ ()
681- while True :
682- refresh_response = yield refresh_request
683- try :
684- refresh_request = await refresh_flow .asend (refresh_response )
685- except StopAsyncIteration :
686- break
706+ # run through this httpx auth flow, not a side-channel client; aclosing
707+ # finalizes the inner generator when httpx closes this flow mid-refresh.
708+ async with aclosing (self ._refresh_with_discovery ()) as refresh_flow :
709+ refresh_request = await refresh_flow .__anext__ ()
710+ while True :
711+ refresh_response = yield refresh_request
712+ try :
713+ refresh_request = await refresh_flow .asend (refresh_response )
714+ except StopAsyncIteration :
715+ break
687716
688717 if self .context .is_token_valid ():
689718 self ._add_auth_header (request )
0 commit comments