Bind client credentials to their authorization server (SEP-2352) (#2933)

This commit is contained in:
Marcelo Trylesinski 2026-06-20 18:47:22 +01:00 • committed by GitHub
commit 44724284b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 141 additions and 4 deletions

View file

@ -25,6 +25,7 @@ from mcp.client.auth.utils import (
create_client_info_from_metadata_url,
create_client_registration_request,
create_oauth_metadata_request,
credentials_match_issuer,
extract_field_from_www_auth,
extract_resource_metadata_from_www_auth,
extract_scope_from_www_auth,
@ -564,6 +565,20 @@ class OAuthClientProvider(httpx.Auth):
else:
logger.debug(f"Protected resource metadata discovery failed: {url}")
# SEP-2352: stored credentials are bound to the issuer that registered them.
# If the authorization server changed, drop them (and the old tokens) so the
# flow re-registers instead of presenting another server's credentials.
if (
self.context.client_info is not None
and self.context.auth_server_url is not None
and not credentials_match_issuer(
self.context.client_info, self.context.auth_server_url, self.context.client_metadata_url
)
):
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
self.context.client_info = None
self.context.clear_tokens()
asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(
self.context.auth_server_url, self.context.server_url
)
@ -595,6 +610,13 @@ class OAuthClientProvider(httpx.Auth):
# Step 4: Register client or use URL-based client ID (CIMD)
if not self.context.client_info:
# SEP-2352: bind the credentials to the issuing AS. Prefer the PRM-advertised
# authorization server; on the legacy no-PRM path fall back to the issuer from
# the discovered metadata so the binding is still recorded.
bound_issuer = self.context.auth_server_url
if bound_issuer is None and self.context.oauth_metadata is not None:
bound_issuer = str(self.context.oauth_metadata.issuer)
if should_use_client_metadata_url(
self.context.oauth_metadata, self.context.client_metadata_url
):
@ -604,6 +626,7 @@ class OAuthClientProvider(httpx.Auth):
self.context.client_metadata_url, # type: ignore[arg-type]
redirect_uris=self.context.client_metadata.redirect_uris,
)
client_information.issuer = bound_issuer
self.context.client_info = client_information
await self.context.storage.set_client_info(client_information)
else:
@ -615,6 +638,7 @@ class OAuthClientProvider(httpx.Auth):
)
registration_response = yield registration_request
client_information = await handle_registration_response(registration_response)
client_information.issuer = bound_issuer
self.context.client_info = client_information
await self.context.storage.set_client_info(client_information)

View file

@ -325,6 +325,26 @@ def is_valid_client_metadata_url(url: str | None) -> bool:
return False
def credentials_match_issuer(
client_info: OAuthClientInformationFull, issuer: str, client_metadata_url: str | None
) -> bool:
"""Whether stored client credentials may be reused against `issuer` (SEP-2352).
A URL-based client ID (CIMD) is portable across authorization servers — the same self-hosted
document is resolved by whichever server is in use — so it always matches; CIMD is identified
by the client ID being the configured `client_metadata_url`, not by URL shape (a registration
server may also issue URL-shaped IDs that are bound to it). Credentials with a recorded issuer
match only when it equals `issuer` (simple string comparison). Credentials with no recorded
issuer (pre-registered, or stored before issuer binding existed) carry no binding to enforce
and are left as-is.
"""
if client_metadata_url is not None and client_info.client_id == client_metadata_url:
return True
if client_info.issuer is None:
return True
return client_info.issuer == issuer
def should_use_client_metadata_url(
oauth_metadata: OAuthMetadata | None,
client_metadata_url: str | None,

View file

@ -133,6 +133,9 @@ class OAuthClientInformationFull(OAuthClientMetadata):
client_secret: str | None = None
client_id_issued_at: int | None = None
client_secret_expires_at: int | None = None
# SEP-2352: the issuer these credentials were registered with, recorded by the SDK (not an
# RFC 7591 field) to detect authorization-server migration and avoid cross-AS credential reuse.
issuer: str | None = None
class OAuthMetadata(BaseModel):