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..675bb92be 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,20 @@ 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, 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 @@ 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 ): @@ -604,6 +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, ) + 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 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. ) 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) diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 992cc26ff..f10264a33 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -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, 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..925162413 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,41 @@ 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", 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", 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", None) is True + + +def test_credentials_match_issuer_cimd_is_portable(): + """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=cimd_url, + redirect_uris=[AnyUrl("http://localhost/cb")], + token_endpoint_auth_method="none", + issuer="https://as", + ) + 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 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.