Bind client credentials to their authorization server (SEP-2352) (#2933)
This commit is contained in:
parent
3169922492
commit
44724284b3
3 changed files with 141 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue