Skip to content

Commit 137de1f

Browse files
committed
fix(client/auth): non-fatal SEP-2468 check on eager path; flag discovery only on completion
Address second-round review findings: an issuer-mismatched ASM from a blind eager probe is skipped as failed discovery (falling through to the {origin}/token fallback) instead of raising out of the auth flow before the original request is sent; and eager_discovery_attempted is now set only when the probe sequence completes, so a probe interrupted by a transport failure is retried on the next refresh rather than permanently recorded as done. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjbXueCDdFNJK6imejCXgM
1 parent 7e70eae commit 137de1f

2 files changed

Lines changed: 104 additions & 6 deletions

File tree

src/mcp/client/auth/oauth2.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -593,8 +593,9 @@ async def _refresh_with_discovery(self) -> AsyncGenerator[httpx2.Request, httpx2
593593
Unlike the 401 path, this discovery is unanchored: there is no WWW-Authenticate
594594
``resource_metadata`` hint, only blind well-known probes, so a co-hosted origin
595595
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
596+
treated as best-effort, never authoritative: a resource-mismatched PRM or an
597+
issuer-mismatched ASM counts as a failed discovery rather than an error, and a
598+
SEP-2352 issuer-binding mismatch
598599
skips the eager refresh (so stored credentials are never presented to an
599600
unvalidated authorization server) while leaving the credentials themselves for
600601
the anchored 401 path to judge — that path re-discovers with the server's hint
@@ -605,8 +606,6 @@ async def _refresh_with_discovery(self) -> AsyncGenerator[httpx2.Request, httpx2
605606
rather than a side-channel client.
606607
"""
607608
if self.context.oauth_metadata is None and not self.context.eager_discovery_attempted:
608-
self.context.eager_discovery_attempted = True
609-
610609
# Step 1: protected resource metadata -> authorization server URL (SEP-985).
611610
# Best-effort: a PRM that fails resource validation is some other co-hosted
612611
# resource's document, not ours — skip it; a legacy server without PRM falls
@@ -645,6 +644,7 @@ async def _refresh_with_discovery(self) -> AsyncGenerator[httpx2.Request, httpx2
645644
)
646645
self.context.protected_resource_metadata = None
647646
self.context.auth_server_url = None
647+
self.context.eager_discovery_attempted = True
648648
return
649649

650650
# Step 2: authorization server metadata -> the token endpoint (with fallback
@@ -656,9 +656,13 @@ async def _refresh_with_discovery(self) -> AsyncGenerator[httpx2.Request, httpx2
656656
if not ok:
657657
break
658658
if asm:
659-
# SEP-2468: metadata issuer must match the discovery issuer
660659
if self.context.auth_server_url is not None:
661-
validate_metadata_issuer(asm, self.context.auth_server_url)
660+
try:
661+
# SEP-2468: metadata issuer must match the discovery issuer
662+
validate_metadata_issuer(asm, self.context.auth_server_url)
663+
except OAuthFlowError:
664+
logger.debug(f"Ignoring authorization server metadata with mismatched issuer: {url}")
665+
continue
662666
self.context.oauth_metadata = asm
663667
break
664668
else:
@@ -684,8 +688,14 @@ async def _refresh_with_discovery(self) -> AsyncGenerator[httpx2.Request, httpx2
684688
"skipping refresh and deferring to 401 discovery"
685689
)
686690
self.context.oauth_metadata = None
691+
self.context.eager_discovery_attempted = True
687692
return
688693

694+
# Mark completion only now: an interrupted probe sequence (the transport
695+
# failing mid-discovery closes this generator) is retried on the next
696+
# refresh instead of being recorded as done.
697+
self.context.eager_discovery_attempted = True
698+
689699
refresh_response = yield await self._refresh_token()
690700
if not await self._handle_refresh_response(refresh_response):
691701
# Refresh failed, need full re-authentication

tests/client/test_auth.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3625,3 +3625,91 @@ async def test_eager_refresh_skips_discovery_when_metadata_already_known(
36253625
assert refresh_request.method == "POST"
36263626
assert str(refresh_request.url) == "https://auth.example.com/oauth2/api/v1/token"
36273627
await auth_flow.aclose()
3628+
3629+
3630+
@pytest.mark.anyio
3631+
async def test_eager_refresh_treats_issuer_mismatched_asm_as_failed_discovery(
3632+
oauth_provider: OAuthClientProvider, valid_tokens: OAuthToken
3633+
):
3634+
"""An eagerly probed ASM whose issuer fails SEP-2468 validation is skipped, not fatal.
3635+
3636+
On the hint-less path a mismatched issuer cannot brick the flow: the document is
3637+
ignored, the remaining fallback URLs are tried, and the refresh falls through to
3638+
``{origin}/token`` — the anchored 401 path still applies the authoritative check.
3639+
"""
3640+
oauth_provider.context.current_tokens = valid_tokens
3641+
oauth_provider.context.token_expiry_time = time.time() - 100 # expired
3642+
oauth_provider.context.client_info = OAuthClientInformationFull(
3643+
client_id="test_client",
3644+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
3645+
token_endpoint_auth_method="none",
3646+
)
3647+
oauth_provider._initialized = True
3648+
3649+
auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
3650+
3651+
# PRM discovery succeeds and points at auth.example.com.
3652+
prm_request = await auth_flow.__anext__()
3653+
prm_response = httpx2.Response(
3654+
200,
3655+
content=(
3656+
b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}'
3657+
),
3658+
request=prm_request,
3659+
)
3660+
3661+
# First ASM URL answers with a mismatched issuer (SEP-2468): skipped, next URL tried.
3662+
asm_request = await auth_flow.asend(prm_response)
3663+
assert str(asm_request.url) == "https://auth.example.com/.well-known/oauth-authorization-server"
3664+
mismatched_asm = httpx2.Response(
3665+
200,
3666+
content=(
3667+
b'{"issuer": "https://internal.example.com", '
3668+
b'"authorization_endpoint": "https://internal.example.com/authorize", '
3669+
b'"token_endpoint": "https://internal.example.com/token"}'
3670+
),
3671+
request=asm_request,
3672+
)
3673+
asm_request = await auth_flow.asend(mismatched_asm)
3674+
assert str(asm_request.url) == "https://auth.example.com/.well-known/openid-configuration"
3675+
3676+
# The fallback URL 404s; the refresh falls through to {origin}/token, no raise.
3677+
refresh_request = await auth_flow.asend(httpx2.Response(404, request=asm_request))
3678+
assert refresh_request.method == "POST"
3679+
assert str(refresh_request.url) == "https://api.example.com/token"
3680+
assert oauth_provider.context.oauth_metadata is None
3681+
await auth_flow.aclose()
3682+
3683+
3684+
@pytest.mark.anyio
3685+
async def test_eager_discovery_interrupted_mid_probe_is_retried_on_the_next_refresh(
3686+
oauth_provider: OAuthClientProvider, valid_tokens: OAuthToken
3687+
):
3688+
"""An aborted probe sequence is not recorded as a completed discovery attempt.
3689+
3690+
httpx acloses the auth flow when a probe fails at the transport level; the
3691+
completion flag must stay unset so the next refresh retries discovery instead of
3692+
permanently falling back to ``{origin}/token`` against a server whose token
3693+
endpoint lives elsewhere.
3694+
"""
3695+
oauth_provider.context.current_tokens = valid_tokens
3696+
oauth_provider.context.token_expiry_time = time.time() - 100 # expired
3697+
oauth_provider.context.client_info = OAuthClientInformationFull(
3698+
client_id="test_client",
3699+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
3700+
token_endpoint_auth_method="none",
3701+
)
3702+
oauth_provider._initialized = True
3703+
3704+
# First attempt: the transport dies during the first probe; httpx acloses the flow.
3705+
auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
3706+
first_probe = await auth_flow.__anext__()
3707+
assert "oauth-protected-resource" in str(first_probe.url)
3708+
await auth_flow.aclose()
3709+
assert not oauth_provider.context.eager_discovery_attempted
3710+
3711+
# Next refresh retries discovery from the start rather than skipping to the fallback.
3712+
auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
3713+
retried_probe = await auth_flow.__anext__()
3714+
assert str(retried_probe.url) == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
3715+
await auth_flow.aclose()

0 commit comments

Comments
 (0)