From b8fc48d057323e3b71bfc25a338fcf257d590b5c Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 19:26:20 +0200 Subject: [PATCH 1/2] Bind client credentials to their authorization server (SEP-2352) Persisted client credentials are now bound to the issuer that registered them: OAuthClientInformationFull records an issuer, set by the SDK after DCR/CIMD. When protected resource metadata points at a different authorization server, the client discards the bound credentials and old tokens and re-registers, instead of presenting one server's client_id to another. URL-based client IDs (CIMD) are portable and always match; credentials with no recorded issuer (pre-registered, or stored before this change) carry no binding to enforce and are left as-is. No TokenStorage protocol change - the issuer round-trips through the existing get_client_info/set_client_info. Follows the Go SDK's approach. The auth/authorization-server-migration conformance scenario's re-register check is satisfied in spirit (no-reuse and no-cross-AS checks pass) but the scenario stays baselined: it runs at 2026-07-28, where client.py's 2025 lifecycle is rejected before the migration 401 fires. It unblocks with the 2026 stateless client lifecycle. --- .../expected-failures.2026-07-28.yml | 6 ++-- .../actions/conformance/expected-failures.yml | 6 ++-- docs/migration.md | 4 +++ src/mcp/client/auth/oauth2.py | 17 ++++++++++ src/mcp/client/auth/utils.py | 16 +++++++++ src/mcp/shared/auth.py | 3 ++ tests/client/test_auth.py | 28 +++++++++++++++ tests/interaction/_requirements.py | 9 +++++ tests/interaction/auth/test_lifecycle.py | 34 +++++++++++++++++++ 9 files changed, 119 insertions(+), 4 deletions(-) diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index fbb4ee142..14e85f7a9 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -46,8 +46,10 @@ client: # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. - http-custom-headers - http-invalid-tool-headers - # SEP-2352 (authorization server migration): client does not re-register when - # PRM authorization_servers changes. + # SEP-2352 (authorization server migration): the client re-registers and does not reuse the old + # AS credentials, but the 2026-mode mock rejects the MCP POST before the migration 401 fires + # (client.py drives the 2025 stateful lifecycle), so the re-register check is never reached. + # Unblocks with the 2026 stateless client lifecycle. - auth/authorization-server-migration # auth/enterprise-managed-authorization (SEP-990) is in the 2025 baseline but # NOT here: the harness skips it as inapplicable at --spec-version 2026-07-28 diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index c8efe82e2..816723b2f 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -21,8 +21,10 @@ client: # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. - http-custom-headers - http-invalid-tool-headers - # SEP-2352 (authorization server migration): client does not re-register when - # PRM authorization_servers changes. + # SEP-2352 (authorization server migration): the client re-registers and does not reuse the old + # AS credentials, but this 2026-introduced scenario runs at 2026-07-28, where client.py's 2025 + # stateful lifecycle is rejected (400 on initialize) before the migration 401 fires, so the + # re-register check is never reached. Unblocks with the 2026 stateless client lifecycle. - auth/authorization-server-migration # --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 --- diff --git a/docs/migration.md b/docs/migration.md index 4fa9260ba..02990d779 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1322,6 +1322,10 @@ If you relied on extra fields round-tripping through MCP types, move that data i ## New Features +### OAuth client credentials are bound to their authorization server (SEP-2352) + +Persisted OAuth client credentials are now bound to the authorization server that issued them: `OAuthClientInformationFull` records an `issuer`, set by the SDK after registration. When a server's protected resource metadata later points at a different authorization server, the client discards the bound credentials (and the old tokens) and re-registers with the new server instead of presenting one server's `client_id` to another. URL-based client IDs (CIMD) are portable and unaffected; credentials with no recorded issuer (pre-registered, or stored before this change) are left as-is. No API change for existing `TokenStorage` implementations - the `issuer` round-trips through the unchanged `get_client_info`/`set_client_info`. + ### Step-up authorization unions previously requested scopes (SEP-2350) When a `403 insufficient_scope` challenge triggers step-up re-authorization, the OAuth client now requests the union of the previously requested scopes and the newly challenged scopes, instead of replacing the scope with only the challenged ones. This keeps permissions granted for earlier operations from being dropped when a later operation escalates. No API change; the wider scope is sent automatically on the re-authorization request. diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 8984d3892..8404c8f67 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -25,6 +25,7 @@ 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,18 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. 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) + ): + 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 ) @@ -604,6 +617,8 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. self.context.client_metadata_url, # type: ignore[arg-type] redirect_uris=self.context.client_metadata.redirect_uris, ) + # SEP-2352: bind the credentials to the issuing authorization server + client_information.issuer = self.context.auth_server_url self.context.client_info = client_information await self.context.storage.set_client_info(client_information) else: @@ -615,6 +630,8 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. ) registration_response = yield registration_request client_information = await handle_registration_response(registration_response) + # SEP-2352: bind the credentials to the issuing authorization server + client_information.issuer = self.context.auth_server_url self.context.client_info = client_information await self.context.storage.set_client_info(client_information) diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 992cc26ff..b6bae8919 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -325,6 +325,22 @@ def is_valid_client_metadata_url(url: str | None) -> bool: return False +def credentials_match_issuer(client_info: OAuthClientInformationFull, issuer: str) -> bool: + """Whether stored client credentials may be reused against `issuer` (SEP-2352). + + URL-based client IDs (CIMD) are portable across authorization servers — the same self-hosted + document is resolved by whichever server is in use — so they always match. 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 is_valid_client_metadata_url(client_info.client_id): + 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, diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index f86a4d923..4fabb1a89 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -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): diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index c05f5c4b2..e3b7071b9 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -19,6 +19,7 @@ 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, @@ -2793,3 +2794,30 @@ def test_validate_metadata_issuer_rejects_mismatch(): def test_union_scopes(previous: str | None, new: str | None, expected: str | None): """SEP-2350: union merges previous and new scopes, dedups, and preserves order.""" assert union_scopes(previous, new) == expected + + +def test_credentials_match_issuer_same_issuer(): + info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer="https://as") + assert credentials_match_issuer(info, "https://as") is True + + +def test_credentials_match_issuer_different_issuer(): + info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer="https://as") + assert credentials_match_issuer(info, "https://other") is False + + +def test_credentials_match_issuer_no_recorded_issuer_is_left_alone(): + """Credentials with no bound issuer (pre-registered / legacy) carry no binding to enforce.""" + info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")]) + assert credentials_match_issuer(info, "https://as") is True + + +def test_credentials_match_issuer_cimd_is_portable(): + """A URL-based client_id (CIMD) is portable across authorization servers.""" + info = OAuthClientInformationFull( + client_id="https://client.example/metadata.json", + redirect_uris=[AnyUrl("http://localhost/cb")], + token_endpoint_auth_method="none", + issuer="https://as", + ) + assert credentials_match_issuer(info, "https://other") is True diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 0efc32399..1bd766f54 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -3360,6 +3360,15 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="OAuth is HTTP-only.", ), + "client-auth:as-binding": Requirement( + source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-binding", + behavior=( + "Stored client credentials are bound to the issuer that registered them; when the authorization " + "server changes, the client discards them and re-registers rather than reusing them (SEP-2352)." + ), + transports=("streamable-http",), + note="OAuth is HTTP-only.", + ), "client-auth:invalid-client-clears-all": Requirement( source="sdk", behavior=( diff --git a/tests/interaction/auth/test_lifecycle.py b/tests/interaction/auth/test_lifecycle.py index c34204cfc..f2cf962a1 100644 --- a/tests/interaction/auth/test_lifecycle.py +++ b/tests/interaction/auth/test_lifecycle.py @@ -207,6 +207,40 @@ async def test_a_403_step_up_re_authorizes_with_the_union_of_prior_and_challenge assert authorize_params(headless.authorize_urls[1])["scope"] == "mcp write" +@requirement("client-auth:as-binding") +async def test_credentials_bound_to_a_different_issuer_are_discarded_and_the_client_re_registers() -> None: + """Credentials bound to a stale issuer are dropped and re-registered against the current AS. + + The stored client is bound (SEP-2352) to a different issuer than the one the server's PRM + advertises, simulating an authorization-server migration. The client must discard it, perform + Dynamic Client Registration with the current AS, and never present the stale `client_id` at the + authorize or token endpoints. + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider() + stale = seeded_client(provider, client_id="stale-as-client", issuer="https://old-as.example.com") + storage = InMemoryTokenStorage(client_info=stale) + server = Server("guarded", on_list_tools=list_tools) + + with anyio.fail_after(5): + async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as ( + client, + _, + ): + await client.list_tools() + + # The client re-registered with the current AS... + assert path_counts(recorded)[("POST", "/register")] == 1 + # ...and the stale client_id never reached the authorize or token endpoints. + authorize_and_token = find(recorded, "GET", "/authorize") + find(recorded, "POST", "/token") + assert all("stale-as-client" not in r.url.query.decode() for r in authorize_and_token) + assert all("stale-as-client" not in r.content.decode() for r in find(recorded, "POST", "/token")) + # The persisted client is now bound to the current AS. + assert storage.client_info is not None + assert storage.client_info.client_id != "stale-as-client" + assert storage.client_info.issuer == f"{BASE_URL}/" + + @requirement("client-auth:401-after-auth-throws") async def test_a_second_401_after_a_completed_oauth_flow_surfaces_without_looping() -> None: """A 401 on the post-auth retry surfaces as an error rather than re-entering discovery. From 523b32b0fb81763cdcbb2997186508dc1dc47d91 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 19:34:05 +0200 Subject: [PATCH 2/2] Harden SEP-2352 issuer binding (Codex review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stamp the bound issuer from the discovered oauth_metadata.issuer when PRM did not advertise an authorization server (legacy no-PRM path), instead of leaving it None — otherwise migrated resources could reuse the old DCR client_id. - Detect CIMD portability by the client_id equaling the configured client_metadata_url, not by URL shape, so a registration server that issues a URL-shaped client_id is still treated as bound to its issuer. --- src/mcp/client/auth/oauth2.py | 17 ++++++++++++----- src/mcp/client/auth/utils.py | 18 +++++++++++------- tests/client/test_auth.py | 23 +++++++++++++++++------ 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 8404c8f67..675bb92be 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -571,7 +571,9 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. 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) + 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 @@ -608,6 +610,13 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. # 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 ): @@ -617,8 +626,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. self.context.client_metadata_url, # type: ignore[arg-type] redirect_uris=self.context.client_metadata.redirect_uris, ) - # SEP-2352: bind the credentials to the issuing authorization server - client_information.issuer = self.context.auth_server_url + client_information.issuer = bound_issuer self.context.client_info = client_information await self.context.storage.set_client_info(client_information) else: @@ -630,8 +638,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. ) registration_response = yield registration_request client_information = await handle_registration_response(registration_response) - # SEP-2352: bind the credentials to the issuing authorization server - client_information.issuer = self.context.auth_server_url + client_information.issuer = bound_issuer self.context.client_info = client_information await self.context.storage.set_client_info(client_information) diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index b6bae8919..f10264a33 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -325,16 +325,20 @@ def is_valid_client_metadata_url(url: str | None) -> bool: return False -def credentials_match_issuer(client_info: OAuthClientInformationFull, issuer: str) -> bool: +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). - URL-based client IDs (CIMD) are portable across authorization servers — the same self-hosted - document is resolved by whichever server is in use — so they always match. 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. + 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 is_valid_client_metadata_url(client_info.client_id): + 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 diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index e3b7071b9..925162413 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -2798,26 +2798,37 @@ def test_union_scopes(previous: str | None, new: str | None, expected: str | Non def test_credentials_match_issuer_same_issuer(): info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer="https://as") - assert credentials_match_issuer(info, "https://as") is True + assert credentials_match_issuer(info, "https://as", None) is True def test_credentials_match_issuer_different_issuer(): info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer="https://as") - assert credentials_match_issuer(info, "https://other") is False + assert credentials_match_issuer(info, "https://other", None) is False def test_credentials_match_issuer_no_recorded_issuer_is_left_alone(): """Credentials with no bound issuer (pre-registered / legacy) carry no binding to enforce.""" info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")]) - assert credentials_match_issuer(info, "https://as") is True + assert credentials_match_issuer(info, "https://as", None) is True def test_credentials_match_issuer_cimd_is_portable(): - """A URL-based client_id (CIMD) is portable across authorization servers.""" + """A client_id equal to the configured client_metadata_url (CIMD) is portable across servers.""" + cimd_url = "https://client.example/metadata.json" info = OAuthClientInformationFull( - client_id="https://client.example/metadata.json", + client_id=cimd_url, redirect_uris=[AnyUrl("http://localhost/cb")], token_endpoint_auth_method="none", issuer="https://as", ) - assert credentials_match_issuer(info, "https://other") is True + assert credentials_match_issuer(info, "https://other", cimd_url) is True + + +def test_credentials_match_issuer_url_shaped_dcr_id_is_not_portable(): + """A URL-shaped client_id from DCR (not the configured CIMD URL) stays bound to its issuer.""" + info = OAuthClientInformationFull( + client_id="https://as.example.com/clients/123", + redirect_uris=[AnyUrl("http://localhost/cb")], + issuer="https://as.example.com", + ) + assert credentials_match_issuer(info, "https://other", "https://client.example/metadata.json") is False