Skip to content

Commit 76542f8

Browse files
committed
Handle the CIMD issuer change beside the SEP-2352 guard so stale metadata is dropped too
The CIMD token drop ran after authorization-server metadata rediscovery, so when that rediscovery failed the previous server's cached metadata survived under a record already re-stamped with the new issuer. Doing it at the same point as the existing bound-credentials guard, right after PRM names the issuer, drops the cached metadata as that guard does. A unit test pins all three effects.
1 parent ab40324 commit 76542f8

2 files changed

Lines changed: 64 additions & 15 deletions

File tree

src/mcp/client/auth/oauth2.py

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,18 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
652652
# Any cached AS metadata is for the old server; drop it so a failed
653653
# rediscovery cannot leak the old registration/token endpoints into Step 4.
654654
self.context.oauth_metadata = None
655+
elif (
656+
self.context.client_info is not None
657+
and self.context.client_info.client_id == self.context.client_metadata_url
658+
and self.context.auth_server_url is not None
659+
and self.context.client_info.issuer not in (None, self.context.auth_server_url)
660+
):
661+
# A CIMD client_id is portable across authorization servers; the tokens issued
662+
# under it and the cached metadata are not. Keep the record, re-stamped.
663+
self.context.clear_tokens()
664+
self.context.oauth_metadata = None
665+
self.context.client_info.issuer = self.context.auth_server_url
666+
await self.context.storage.set_client_info(self.context.client_info)
655667

656668
asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(
657669
self.context.auth_server_url, self.context.server_url
@@ -748,21 +760,6 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
748760
# Held tokens belong to a previous client and cannot be refreshed by this one.
749761
self.context.clear_tokens()
750762

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-
766763
# Step 5: Refresh with the stored refresh token first (RFC 6749 §6); run the full
767764
# authorization only when there is none or the server rejects it.
768765
refreshed = False

tests/client/test_auth.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3279,3 +3279,55 @@ async def test_expired_token_is_not_refreshed_ahead_of_the_request_before_metada
32793279

32803280
with pytest.raises(StopAsyncIteration):
32813281
await auth_flow.asend(httpx2.Response(200, request=request))
3282+
3283+
3284+
@pytest.mark.anyio
3285+
async def test_cimd_record_is_restamped_and_its_tokens_and_cached_metadata_dropped_when_prm_names_a_new_issuer(
3286+
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage, valid_tokens: OAuthToken
3287+
) -> None:
3288+
"""SEP-2352 for CIMD: the URL client_id survives an authorization-server change, nothing else does.
3289+
3290+
A long-lived provider holds a CIMD record stamped with the old issuer, tokens minted there, and
3291+
the old server's cached metadata. As soon as PRM names a different issuer, the tokens and the
3292+
cached metadata are dropped and the record is re-stamped and persisted, so a failed
3293+
rediscovery cannot leave the old endpoints in play and no refresh reaches the new server.
3294+
"""
3295+
cimd_url = "https://client.example.com/.well-known/mcp-client"
3296+
provider = OAuthClientProvider(
3297+
server_url="https://api.example.com/v1/mcp",
3298+
client_metadata=client_metadata,
3299+
storage=mock_storage,
3300+
client_metadata_url=cimd_url,
3301+
)
3302+
provider.context.client_info = OAuthClientInformationFull(
3303+
client_id=cimd_url, token_endpoint_auth_method="none", issuer="https://old-as.example.com"
3304+
)
3305+
provider.context.current_tokens = valid_tokens
3306+
provider.context.token_expiry_time = time.time() + 1800
3307+
provider.context.oauth_metadata = OAuthMetadata(
3308+
issuer=AnyHttpUrl("https://old-as.example.com"),
3309+
authorization_endpoint=AnyHttpUrl("https://old-as.example.com/authorize"),
3310+
token_endpoint=AnyHttpUrl("https://old-as.example.com/token"),
3311+
)
3312+
provider._initialized = True
3313+
3314+
auth_flow = provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
3315+
request = await auth_flow.__anext__()
3316+
prm_req = await auth_flow.asend(httpx2.Response(401, request=request))
3317+
prm_response = httpx2.Response(
3318+
200,
3319+
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://new-as.example.com"]}',
3320+
request=prm_req,
3321+
)
3322+
asm_req = await auth_flow.asend(prm_response)
3323+
3324+
assert str(asm_req.url) == "https://new-as.example.com/.well-known/oauth-authorization-server"
3325+
assert provider.context.current_tokens is None
3326+
assert provider.context.oauth_metadata is None
3327+
assert provider.context.client_info is not None
3328+
assert (provider.context.client_info.client_id, provider.context.client_info.issuer) == (
3329+
cimd_url,
3330+
"https://new-as.example.com",
3331+
)
3332+
assert mock_storage._client_info is provider.context.client_info
3333+
await auth_flow.aclose()

0 commit comments

Comments
 (0)