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

@ -19,6 +19,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,
@ -2794,3 +2795,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

View file

@ -3360,6 +3360,15 @@ REQUIREMENTS: dict[str, Requirement] = {
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=(

View file

@ -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.