Skip to content

Commit ab40324

Browse files
committed
Address review: refresh-failure parity, CIMD token binding, pinning tests
- A failed refresh resets _initialized inside _handle_refresh_response, so the 401-path refresh site behaves like the pre-request one: the next request re-reads storage and can retry a refresh token that is still good instead of leaving a long-lived headless provider with no tokens. - A CIMD client_id is portable across authorization servers but its tokens are not: when the discovered issuer differs from the record's stamp, keep the record, drop the tokens, and re-stamp before deciding to refresh. - Tests pinning: rejected 401-path refresh falls back to authorization; a headless provider retries the refresh on its next connection; a fresh registration does not present tokens left from a previous client; the CIMD issuer-change case. Manifest wording for the discovered-endpoint entry no longer overclaims about the no-metadata fallback.
1 parent 2bc618a commit ab40324

3 files changed

Lines changed: 189 additions & 12 deletions

File tree

src/mcp/client/auth/oauth2.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]:
374374
if self.context.client_metadata.redirect_uris is None:
375375
raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover
376376
if not self.context.redirect_handler:
377-
raise OAuthFlowError("No redirect handler provided for authorization code grant") # pragma: no cover
377+
raise OAuthFlowError("No redirect handler provided for authorization code grant")
378378
if not self.context.callback_handler:
379379
raise OAuthFlowError("No callback handler provided for authorization code grant") # pragma: no cover
380380

@@ -521,6 +521,8 @@ async def _handle_refresh_response(self, response: httpx2.Response) -> bool:
521521
if response.status_code != 200:
522522
logger.warning(f"Token refresh failed: {response.status_code}")
523523
self.context.clear_tokens()
524+
# Re-read storage on the next request: the failure may have been transient.
525+
self._initialized = False
524526
return False
525527

526528
try:
@@ -545,6 +547,7 @@ async def _handle_refresh_response(self, response: httpx2.Response) -> bool:
545547
except ValidationError: # pragma: no cover
546548
logger.exception("Invalid refresh response")
547549
self.context.clear_tokens()
550+
self._initialized = False
548551
return False
549552

550553
async def _initialize(self) -> None:
@@ -593,12 +596,8 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
593596
and self.context.can_refresh_token()
594597
and self.context.oauth_metadata is not None
595598
):
596-
refresh_request = await self._refresh_token()
597-
refresh_response = yield refresh_request
598-
599-
if not await self._handle_refresh_response(refresh_response):
600-
# Refresh failed, need full re-authentication
601-
self._initialized = False
599+
refresh_response = yield await self._refresh_token()
600+
await self._handle_refresh_response(refresh_response)
602601

603602
if self.context.is_token_valid():
604603
self._add_auth_header(request)
@@ -749,6 +748,21 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
749748
# Held tokens belong to a previous client and cannot be refreshed by this one.
750749
self.context.clear_tokens()
751750

751+
# A CIMD client_id is portable across authorization servers (SEP-2352) but tokens
752+
# issued under it are not: on an issuer change keep the record, drop the tokens.
753+
client_info = self.context.client_info
754+
current_issuer = self.context.auth_server_url or (
755+
str(self.context.oauth_metadata.issuer) if self.context.oauth_metadata else None
756+
)
757+
if (
758+
client_info.client_id == self.context.client_metadata_url
759+
and current_issuer is not None
760+
and client_info.issuer not in (None, current_issuer)
761+
):
762+
self.context.clear_tokens()
763+
client_info.issuer = current_issuer
764+
await self.context.storage.set_client_info(client_info)
765+
752766
# Step 5: Refresh with the stored refresh token first (RFC 6749 §6); run the full
753767
# authorization only when there is none or the server rejects it.
754768
refreshed = False

tests/interaction/_requirements.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3897,11 +3897,14 @@ def __post_init__(self) -> None:
38973897
source="issue:#3240",
38983898
behavior=(
38993899
"A refresh in a process that has not yet discovered the authorization server happens only after "
3900-
"protected-resource and authorization-server metadata discovery and posts to the advertised "
3901-
"token endpoint, never to a path guessed from the server origin."
3900+
"protected-resource and authorization-server metadata discovery and posts to the token endpoint "
3901+
"that metadata advertises."
39023902
),
39033903
transports=("streamable-http",),
3904-
note="OAuth is HTTP-only.",
3904+
note=(
3905+
"OAuth is HTTP-only. When discovery yields no AS metadata at all, the 2025-03-26 origin-derived "
3906+
"fallback endpoint is still used, as it is for the authorization itself."
3907+
),
39053908
),
39063909
"client-auth:resource-parameter": Requirement(
39073910
source=f"{SPEC_BASE_URL}/basic/authorization#resource-parameter-implementation",

tests/interaction/auth/test_lifecycle.py

Lines changed: 162 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,16 @@
1818
from pydantic import AnyHttpUrl, AnyUrl
1919

2020
from mcp import MCPError
21-
from mcp.client.auth import OAuthClientProvider
21+
from mcp.client.auth import OAuthClientProvider, OAuthFlowError
2222
from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider
2323
from mcp.server import Server, ServerRequestContext
24-
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata
24+
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata, OAuthToken
2525
from tests.interaction._connect import BASE_URL
2626
from tests.interaction._requirements import requirement
2727
from tests.interaction.auth._harness import (
2828
REDIRECT_URI,
2929
AppShim,
30+
HeadlessOAuth,
3031
InMemoryTokenStorage,
3132
RecordedRequest,
3233
auth_settings,
@@ -125,6 +126,17 @@ def restarted_headless_provider(storage: InMemoryTokenStorage) -> OAuthClientPro
125126
return OAuthClientProvider(server_url=f"{BASE_URL}/mcp", client_metadata=oauth_client_metadata(), storage=storage)
126127

127128

129+
def restarted_interactive_provider(storage: InMemoryTokenStorage, headless: HeadlessOAuth) -> OAuthClientProvider:
130+
"""A provider as a second process with a browser available constructs it: same storage, fresh state."""
131+
return OAuthClientProvider(
132+
server_url=f"{BASE_URL}/mcp",
133+
client_metadata=oauth_client_metadata(),
134+
storage=storage,
135+
redirect_handler=headless.redirect_handler,
136+
callback_handler=headless.callback_handler,
137+
)
138+
139+
128140
@requirement("client-auth:refresh:transparent")
129141
async def test_an_expired_access_token_is_transparently_refreshed_before_the_next_request() -> None:
130142
"""An access token the client considers expired is refreshed and the new bearer is used.
@@ -466,6 +478,154 @@ async def test_a_restarted_client_refreshes_at_the_token_endpoint_advertised_und
466478
assert not any(r.path.endswith("/authorize") for r in recorded)
467479

468480

481+
@requirement("client-auth:invalid-grant-clears-tokens")
482+
async def test_a_refresh_the_server_rejects_on_the_401_path_falls_back_to_authorization() -> None:
483+
"""When the 401 path's refresh is rejected, the flow runs the full authorization instead of giving up.
484+
485+
Second process with a browser; the harness denies the one refresh with `invalid_grant`. The
486+
recording proves the refresh was tried first, then exactly one authorize and code exchange,
487+
with no re-registration.
488+
"""
489+
provider = InMemoryAuthorizationServerProvider(fail_next_refresh=True)
490+
storage = InMemoryTokenStorage()
491+
with anyio.fail_after(5):
492+
provider.expire_access_token(await first_process_login(provider, storage))
493+
494+
recorded, on_request = record_requests()
495+
headless = HeadlessOAuth()
496+
server = Server("guarded", on_list_tools=list_tools)
497+
with anyio.fail_after(5):
498+
async with connect_with_oauth(
499+
server,
500+
provider=provider,
501+
auth=restarted_interactive_provider(storage, headless),
502+
headless=headless,
503+
on_request=on_request,
504+
) as (client, _):
505+
result = await client.list_tools()
506+
507+
assert result.tools[0].name == "echo"
508+
assert [form_body(r)["grant_type"] for r in find(recorded, "POST", "/token")] == snapshot(
509+
["refresh_token", "authorization_code"]
510+
)
511+
counts = path_counts(recorded)
512+
assert counts[("GET", "/authorize")] == 1
513+
assert counts[("POST", "/register")] == 0
514+
515+
516+
@requirement("client-auth:refresh:on-401")
517+
async def test_a_headless_client_whose_refresh_failed_retries_it_from_storage_on_the_next_connection() -> None:
518+
"""A failed refresh does not wedge a long-lived headless provider: the next connection reloads and refreshes.
519+
520+
The harness denies the first refresh with `invalid_grant` but leaves the refresh token valid,
521+
standing in for a transient token-endpoint failure. The first connect raises (no browser to
522+
fall back to); the second, through the same provider instance, refreshes and succeeds.
523+
"""
524+
provider = InMemoryAuthorizationServerProvider(fail_next_refresh=True)
525+
storage = InMemoryTokenStorage()
526+
with anyio.fail_after(5):
527+
provider.expire_access_token(await first_process_login(provider, storage))
528+
daemon = restarted_headless_provider(storage)
529+
530+
with anyio.fail_after(5):
531+
with pytest.RaisesGroup(pytest.RaisesExc(OAuthFlowError), flatten_subgroups=True):
532+
await connect_with_oauth(
533+
Server("guarded", on_list_tools=list_tools), provider=provider, auth=daemon
534+
).__aenter__()
535+
536+
recorded, on_request = record_requests()
537+
with anyio.fail_after(5):
538+
async with connect_with_oauth(
539+
Server("guarded", on_list_tools=list_tools), provider=provider, auth=daemon, on_request=on_request
540+
) as (client, _):
541+
result = await client.list_tools()
542+
543+
assert result.tools[0].name == "echo"
544+
assert [form_body(r)["grant_type"] for r in find(recorded, "POST", "/token")] == ["refresh_token"]
545+
assert find(recorded, "GET", "/authorize") == []
546+
547+
548+
@requirement("client-auth:refresh:on-401")
549+
async def test_a_fresh_registration_does_not_present_tokens_left_from_a_previous_client() -> None:
550+
"""Tokens found in storage without their registration are not refreshed under a newly registered client.
551+
552+
The second process finds tokens but no `client_info` (lost, or never persisted), so the 401
553+
path registers a new client; the refresh token belonged to the old one and is dropped rather
554+
than presented, and the flow authorizes once. RFC 6749 §6 binds refresh tokens to their client.
555+
"""
556+
provider = InMemoryAuthorizationServerProvider()
557+
storage = InMemoryTokenStorage()
558+
with anyio.fail_after(5):
559+
provider.expire_access_token(await first_process_login(provider, storage))
560+
storage.client_info = None
561+
562+
recorded, on_request = record_requests()
563+
headless = HeadlessOAuth()
564+
server = Server("guarded", on_list_tools=list_tools)
565+
with anyio.fail_after(5):
566+
async with connect_with_oauth(
567+
server,
568+
provider=provider,
569+
auth=restarted_interactive_provider(storage, headless),
570+
headless=headless,
571+
on_request=on_request,
572+
) as (client, _):
573+
result = await client.list_tools()
574+
575+
assert result.tools[0].name == "echo"
576+
assert [(r.method, r.path) for r in recorded[:6]] == snapshot(
577+
[
578+
("POST", "/mcp"),
579+
("GET", "/.well-known/oauth-protected-resource/mcp"),
580+
("GET", "/.well-known/oauth-authorization-server"),
581+
("POST", "/register"),
582+
("GET", "/authorize"),
583+
("POST", "/token"),
584+
]
585+
)
586+
assert [form_body(r)["grant_type"] for r in find(recorded, "POST", "/token")] == ["authorization_code"]
587+
588+
589+
@requirement("client-auth:as-binding")
590+
async def test_a_cimd_client_keeps_its_id_but_drops_its_tokens_when_the_authorization_server_changes() -> None:
591+
"""A CIMD registration is portable across authorization servers; the tokens issued under it are not.
592+
593+
Storage holds a CIMD record stamped with a previous issuer and a refresh token minted there.
594+
On the 401 the discovered issuer differs, so the flow keeps the URL client_id, re-stamps it,
595+
and authorizes afresh instead of presenting the old refresh token to the new server (SEP-2352;
596+
the TypeScript SDK discards tokens on the same mismatch).
597+
"""
598+
recorded, on_request = record_requests()
599+
provider = InMemoryAuthorizationServerProvider()
600+
seeded_client(provider, client_id=CIMD_URL)
601+
stale = OAuthClientInformationFull(
602+
client_id=CIMD_URL,
603+
token_endpoint_auth_method="none",
604+
redirect_uris=[AnyUrl(REDIRECT_URI)],
605+
issuer="https://old-as.example.com",
606+
)
607+
storage = InMemoryTokenStorage(client_info=stale)
608+
storage.tokens = OAuthToken(access_token="issued-by-old-as", refresh_token="refresh-from-old-as", expires_in=3600)
609+
server = Server("guarded", on_list_tools=list_tools)
610+
611+
with anyio.fail_after(5):
612+
async with connect_with_oauth(
613+
server,
614+
provider=provider,
615+
storage=storage,
616+
client_metadata_url=CIMD_URL,
617+
app_shim=shim(serve={ASM_PATH: cimd_supported_metadata()}),
618+
on_request=on_request,
619+
) as (client, _):
620+
await client.list_tools()
621+
622+
assert [form_body(r)["grant_type"] for r in find(recorded, "POST", "/token")] == ["authorization_code"]
623+
assert all(b"refresh-from-old-as" not in r.content for r in recorded)
624+
assert path_counts(recorded)[("POST", "/register")] == 0
625+
assert storage.client_info is not None
626+
assert (storage.client_info.client_id, storage.client_info.issuer) == (CIMD_URL, f"{BASE_URL}/")
627+
628+
469629
@requirement("client-auth:client-credentials")
470630
async def test_client_credentials_provider_obtains_a_token_without_an_authorize_step() -> None:
471631
"""The client-credentials provider connects with no authorize step and a `client_credentials` grant.

0 commit comments

Comments
 (0)