Skip to content

Commit 7e70eae

Browse files
committed
fix(client/auth): make hint-less eager discovery best-effort, never destructive
Address review findings: without a WWW-Authenticate resource_metadata hint the eager probes are unanchored, so a co-hosted origin can serve another resource's documents. Treat a resource-mismatched PRM as failed discovery instead of raising out of the auth flow; on a SEP-2352 binding mismatch skip the refresh and discard the unanchored discovery results (including rejected ASM metadata) but keep the credentials for the anchored 401 path to judge. Run the probes only once per context so servers publishing no metadata are not re-probed on every in-process refresh, and finalize the inner refresh generator with aclosing(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjbXueCDdFNJK6imejCXgM
1 parent a80aae2 commit 7e70eae

2 files changed

Lines changed: 170 additions & 44 deletions

File tree

src/mcp/client/auth/oauth2.py

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import string
1111
import time
1212
from collections.abc import AsyncGenerator, Awaitable, Callable
13+
from contextlib import aclosing
1314
from dataclasses import dataclass, field
1415
from typing import Any, Protocol, get_args
1516
from 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)

tests/client/test_auth.py

Lines changed: 109 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3417,10 +3417,11 @@ async def test_eager_refresh_skips_refresh_when_credentials_bound_to_different_i
34173417
):
34183418
"""SEP-2352: a cold-start refresh never sends credentials bound to another issuer.
34193419
3420-
When PRM discovery reveals an authorization server different from the one the stored
3421-
client credentials are bound to, the credentials and tokens are dropped and the
3422-
refresh is skipped, so the subsequent 401 flow re-registers against the new server
3423-
— mirroring the issuer-binding check on the 401 discovery path.
3420+
When blind PRM discovery reveals an authorization server different from the one the
3421+
stored client credentials are bound to, the eager refresh is skipped and the
3422+
unanchored discovery results are discarded — but the credentials themselves are
3423+
kept: without a WWW-Authenticate hint the probe may have found a different
3424+
co-hosted resource's PRM, so dropping is deferred to the anchored 401 path.
34243425
"""
34253426
oauth_provider.context.current_tokens = valid_tokens
34263427
oauth_provider.context.token_expiry_time = time.time() - 100 # expired
@@ -3447,8 +3448,12 @@ async def test_eager_refresh_skips_refresh_when_credentials_bound_to_different_i
34473448
api_request = await auth_flow.asend(prm_response)
34483449
assert str(api_request.url) == "https://api.example.com/v1/mcp"
34493450
assert "Authorization" not in api_request.headers
3450-
assert oauth_provider.context.client_info is None
3451-
assert oauth_provider.context.current_tokens is None
3451+
# Credentials and tokens are kept for the anchored 401 path to judge; the
3452+
# unanchored discovery results are discarded.
3453+
assert oauth_provider.context.client_info is not None
3454+
assert oauth_provider.context.current_tokens is not None
3455+
assert oauth_provider.context.protected_resource_metadata is None
3456+
assert oauth_provider.context.auth_server_url is None
34523457
await auth_flow.aclose()
34533458

34543459

@@ -3459,8 +3464,9 @@ async def test_eager_refresh_legacy_path_rechecks_issuer_binding_after_asm(
34593464
"""SEP-2352 on the legacy no-PRM path: the binding is checked against the ASM issuer.
34603465
34613466
PRM discovery fails so the issuer is only known once origin-fallback ASM discovery
3462-
succeeds; credentials bound to a different issuer are then dropped and the refresh is
3463-
skipped, exactly as on the 401 path's post-ASM re-evaluation.
3467+
succeeds; on a mismatch the refresh is skipped and the rejected metadata is
3468+
discarded (keeping it could leak the rejected issuer's endpoints into a later 401
3469+
flow's registration step), while the credentials are left for the 401 path to judge.
34643470
"""
34653471
oauth_provider.context.current_tokens = valid_tokens
34663472
oauth_provider.context.token_expiry_time = time.time() - 100 # expired
@@ -3494,10 +3500,101 @@ async def test_eager_refresh_legacy_path_rechecks_issuer_binding_after_asm(
34943500
api_request = await auth_flow.asend(asm_response)
34953501
assert str(api_request.url) == "https://api.example.com/v1/mcp"
34963502
assert "Authorization" not in api_request.headers
3497-
assert oauth_provider.context.client_info is None
3498-
assert oauth_provider.context.current_tokens is None
3499-
# The just-discovered metadata is for the current server and is kept for the 401 flow.
3500-
assert oauth_provider.context.oauth_metadata is not None
3503+
# Credentials and tokens are kept for the anchored 401 path to judge; the metadata
3504+
# whose issuer failed the binding check is discarded, mirroring the 401 path's
3505+
# defensive clear.
3506+
assert oauth_provider.context.client_info is not None
3507+
assert oauth_provider.context.current_tokens is not None
3508+
assert oauth_provider.context.oauth_metadata is None
3509+
await auth_flow.aclose()
3510+
3511+
3512+
@pytest.mark.anyio
3513+
async def test_eager_refresh_treats_foreign_prm_as_failed_discovery(
3514+
oauth_provider: OAuthClientProvider, valid_tokens: OAuthToken
3515+
):
3516+
"""A blind well-known probe returning some other co-hosted resource's PRM is skipped.
3517+
3518+
Without a WWW-Authenticate hint, a resource-mismatched PRM is not an error (the 401
3519+
path's authoritative semantics) but simply not our document: discovery falls through
3520+
to the next URL and ultimately to the legacy ``{origin}/token`` refresh, instead of
3521+
raising out of the auth flow before the original request is ever sent.
3522+
"""
3523+
oauth_provider.context.current_tokens = valid_tokens
3524+
oauth_provider.context.token_expiry_time = time.time() - 100 # expired
3525+
oauth_provider.context.client_info = OAuthClientInformationFull(
3526+
client_id="test_client",
3527+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
3528+
token_endpoint_auth_method="none",
3529+
)
3530+
oauth_provider._initialized = True
3531+
3532+
auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
3533+
3534+
# Path-based well-known serves a *different* co-hosted resource's PRM: skipped.
3535+
prm_request = await auth_flow.__anext__()
3536+
foreign_prm = httpx2.Response(
3537+
200,
3538+
content=(
3539+
b'{"resource": "https://api.example.com/other-api", '
3540+
b'"authorization_servers": ["https://elsewhere.example.com"]}'
3541+
),
3542+
request=prm_request,
3543+
)
3544+
prm_request = await auth_flow.asend(foreign_prm)
3545+
assert str(prm_request.url) == "https://api.example.com/.well-known/oauth-protected-resource"
3546+
3547+
# Root well-known 404s; legacy origin ASM fallback 404s; refresh uses {origin}/token.
3548+
asm_request = await auth_flow.asend(httpx2.Response(404, request=prm_request))
3549+
assert str(asm_request.url) == "https://api.example.com/.well-known/oauth-authorization-server"
3550+
refresh_request = await auth_flow.asend(httpx2.Response(404, request=asm_request))
3551+
assert refresh_request.method == "POST"
3552+
assert str(refresh_request.url) == "https://api.example.com/token"
3553+
assert oauth_provider.context.protected_resource_metadata is None
3554+
await auth_flow.aclose()
3555+
3556+
3557+
@pytest.mark.anyio
3558+
async def test_eager_refresh_probes_discovery_only_once_per_context(
3559+
oauth_provider: OAuthClientProvider, valid_tokens: OAuthToken
3560+
):
3561+
"""Against a server publishing no metadata, only the first refresh runs the probes.
3562+
3563+
Subsequent in-process refreshes skip straight to the ``{origin}/token`` fallback
3564+
(the pre-discovery behavior) instead of re-issuing the failed discovery requests on
3565+
every token expiry.
3566+
"""
3567+
oauth_provider.context.current_tokens = valid_tokens
3568+
oauth_provider.context.token_expiry_time = time.time() - 100 # expired
3569+
oauth_provider.context.client_info = OAuthClientInformationFull(
3570+
client_id="test_client",
3571+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
3572+
token_endpoint_auth_method="none",
3573+
)
3574+
oauth_provider._initialized = True
3575+
3576+
# First refresh: probes (2x PRM, 1x legacy ASM) then the fallback refresh, succeeding.
3577+
auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
3578+
request = await auth_flow.__anext__()
3579+
request = await auth_flow.asend(httpx2.Response(404, request=request))
3580+
request = await auth_flow.asend(httpx2.Response(404, request=request))
3581+
refresh_request = await auth_flow.asend(httpx2.Response(404, request=request))
3582+
assert str(refresh_request.url) == "https://api.example.com/token"
3583+
refresh_response = httpx2.Response(
3584+
200,
3585+
json={"access_token": "refreshed_token", "token_type": "Bearer", "expires_in": 3600},
3586+
request=refresh_request,
3587+
)
3588+
api_request = await auth_flow.asend(refresh_response)
3589+
assert api_request.headers["Authorization"] == "Bearer refreshed_token"
3590+
await auth_flow.aclose()
3591+
3592+
# Second refresh (token expired again): no probes, straight to the fallback.
3593+
oauth_provider.context.token_expiry_time = time.time() - 100
3594+
auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
3595+
refresh_request = await auth_flow.__anext__()
3596+
assert refresh_request.method == "POST"
3597+
assert str(refresh_request.url) == "https://api.example.com/token"
35013598
await auth_flow.aclose()
35023599

35033600

0 commit comments

Comments
 (0)