Skip to content

Commit caa022f

Browse files
committed
Fold the SEP-2352 issuer-binding rules into one method used at both discovery points
The bound-credential discard and the CIMD keep-but-rebind case were spelled out inline where PRM names the issuer, and only the former where the issuer is first learned from AS metadata on the legacy no-PRM path. One method now applies both rules wherever the issuer becomes known, so a CIMD record's carried-over tokens are dropped on that path too. A unit test covers it.
1 parent 7f01cd0 commit caa022f

2 files changed

Lines changed: 84 additions & 42 deletions

File tree

src/mcp/client/auth/oauth2.py

Lines changed: 30 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,29 @@ async def _handle_refresh_response(self, response: httpx2.Response) -> bool:
550550
self._initialized = False
551551
return False
552552

553+
async def _apply_issuer_binding(self, issuer: str) -> bool:
554+
"""Apply SEP-2352 to the held registration now that the authorization server's issuer is known.
555+
556+
Credentials bound to another issuer are discarded with their tokens so the flow re-registers.
557+
A CIMD record is portable, so it is kept and re-stamped, but tokens it carried over from
558+
another issuer (or of unknown origin, when the record is unstamped) are dropped. Returns
559+
True when the held state was for a different issuer.
560+
"""
561+
client_info = self.context.client_info
562+
if client_info is None:
563+
return False
564+
if not credentials_match_issuer(client_info, issuer, self.context.client_metadata_url):
565+
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
566+
self.context.client_info = None
567+
self.context.clear_tokens()
568+
return True
569+
if client_info.client_id == self.context.client_metadata_url and client_info.issuer != issuer:
570+
self.context.clear_tokens()
571+
client_info.issuer = issuer
572+
await self.context.storage.set_client_info(client_info)
573+
return True
574+
return False
575+
553576
async def _initialize(self) -> None:
554577
"""Load stored tokens and client info."""
555578
self.context.current_tokens = await self.context.storage.get_tokens()
@@ -636,35 +659,13 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
636659
else:
637660
logger.debug(f"Protected resource metadata discovery failed: {url}")
638661

639-
# SEP-2352: stored credentials are bound to the issuer that registered them.
640-
# If the authorization server changed, drop them (and the old tokens) so the
641-
# flow re-registers instead of presenting another server's credentials.
642-
if (
643-
self.context.client_info is not None
644-
and self.context.auth_server_url is not None
645-
and not credentials_match_issuer(
646-
self.context.client_info, self.context.auth_server_url, self.context.client_metadata_url
647-
)
662+
# SEP-2352: stored credentials and tokens belong to the issuer they came from.
663+
if self.context.auth_server_url is not None and await self._apply_issuer_binding(
664+
self.context.auth_server_url
648665
):
649-
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
650-
self.context.client_info = None
651-
self.context.clear_tokens()
652666
# Any cached AS metadata is for the old server; drop it so a failed
653-
# rediscovery cannot leak the old registration/token endpoints into Step 4.
654-
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 != 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. An
663-
# unstamped record's tokens have unknown provenance and are dropped the same way.
664-
self.context.clear_tokens()
667+
# rediscovery cannot leak the old endpoints into Steps 4-5.
665668
self.context.oauth_metadata = None
666-
self.context.client_info.issuer = self.context.auth_server_url
667-
await self.context.storage.set_client_info(self.context.client_info)
668669

669670
asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(
670671
self.context.auth_server_url, self.context.server_url
@@ -688,21 +689,9 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
688689
logger.debug(f"OAuth metadata discovery failed: {url}")
689690

690691
# SEP-2352: on the legacy no-PRM path the issuer is only known after ASM
691-
# discovery, so re-evaluate the binding here using the discovered metadata
692-
# issuer (mirroring the bound_issuer fallback in Step 4).
693-
if (
694-
self.context.client_info is not None
695-
and self.context.auth_server_url is None
696-
and self.context.oauth_metadata is not None
697-
and not credentials_match_issuer(
698-
self.context.client_info,
699-
str(self.context.oauth_metadata.issuer),
700-
self.context.client_metadata_url,
701-
)
702-
):
703-
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
704-
self.context.client_info = None
705-
self.context.clear_tokens()
692+
# discovery (mirroring the bound_issuer fallback in Step 4).
693+
if self.context.auth_server_url is None and self.context.oauth_metadata is not None:
694+
await self._apply_issuer_binding(str(self.context.oauth_metadata.issuer))
706695

707696
# Step 3: Apply scope selection strategy
708697
self.context.client_metadata.scope = get_client_metadata_scopes(

tests/client/test_auth.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import json
55
import time
66
from unittest import mock
7-
from urllib.parse import parse_qs, quote, unquote, urlparse
7+
from urllib.parse import parse_qs, parse_qsl, quote, unquote, urlparse
88

99
import httpx2
1010
import pytest
@@ -3336,3 +3336,56 @@ async def test_cimd_record_is_restamped_and_its_tokens_and_cached_metadata_dropp
33363336
)
33373337
assert mock_storage._client_info is provider.context.client_info
33383338
await auth_flow.aclose()
3339+
3340+
3341+
@pytest.mark.anyio
3342+
async def test_cimd_record_is_restamped_and_its_tokens_dropped_when_only_asm_reveals_a_new_issuer(
3343+
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage, valid_tokens: OAuthToken
3344+
) -> None:
3345+
"""The CIMD rebinding also applies on the legacy no-PRM path, where the issuer is learned from AS metadata.
3346+
3347+
PRM discovery 404s, so the issuer only becomes known from the root well-known metadata; it
3348+
differs from the record's stamp, so the tokens are dropped and the record re-stamped before
3349+
any refresh could be attempted, and the flow proceeds to authorize rather than refresh.
3350+
"""
3351+
cimd_url = "https://client.example.com/.well-known/mcp-client"
3352+
provider = OAuthClientProvider(
3353+
server_url="https://api.example.com/v1/mcp",
3354+
client_metadata=client_metadata,
3355+
storage=mock_storage,
3356+
client_metadata_url=cimd_url,
3357+
)
3358+
provider.context.client_info = OAuthClientInformationFull(
3359+
client_id=cimd_url, token_endpoint_auth_method="none", issuer="https://old-as.example.com"
3360+
)
3361+
provider.context.current_tokens = valid_tokens
3362+
provider.context.token_expiry_time = time.time() + 1800
3363+
provider._initialized = True
3364+
provider._perform_authorization_code_grant = mock.AsyncMock(return_value=("auth-code", "verifier"))
3365+
3366+
auth_flow = provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
3367+
request = await auth_flow.__anext__()
3368+
prm_req = await auth_flow.asend(httpx2.Response(401, request=request))
3369+
prm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
3370+
asm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
3371+
assert str(asm_req.url) == "https://api.example.com/.well-known/oauth-authorization-server"
3372+
asm_response = httpx2.Response(
3373+
200,
3374+
content=(
3375+
b'{"issuer": "https://api.example.com", '
3376+
b'"authorization_endpoint": "https://api.example.com/authorize", '
3377+
b'"token_endpoint": "https://api.example.com/token", '
3378+
b'"client_id_metadata_document_supported": true}'
3379+
),
3380+
request=asm_req,
3381+
)
3382+
next_req = await auth_flow.asend(asm_response)
3383+
3384+
assert dict(parse_qsl(next_req.content.decode()))["grant_type"] == "authorization_code"
3385+
assert provider.context.client_info is not None
3386+
assert (provider.context.client_info.client_id, provider.context.client_info.issuer) == (
3387+
cimd_url,
3388+
"https://api.example.com",
3389+
)
3390+
assert mock_storage._client_info is provider.context.client_info
3391+
await auth_flow.aclose()

0 commit comments

Comments
 (0)