Skip to content

Commit 4472428

Browse files
authored
Bind client credentials to their authorization server (SEP-2352) (#2933)
1 parent 3169922 commit 4472428

9 files changed

Lines changed: 141 additions & 4 deletions

File tree

.github/actions/conformance/expected-failures.2026-07-28.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@ client:
4646
# SEP-2243 (HTTP standardization): no fixture handler / client header support yet.
4747
- http-custom-headers
4848
- http-invalid-tool-headers
49-
# SEP-2352 (authorization server migration): client does not re-register when
50-
# PRM authorization_servers changes.
49+
# SEP-2352 (authorization server migration): the client re-registers and does not reuse the old
50+
# AS credentials, but the 2026-mode mock rejects the MCP POST before the migration 401 fires
51+
# (client.py drives the 2025 stateful lifecycle), so the re-register check is never reached.
52+
# Unblocks with the 2026 stateless client lifecycle.
5153
- auth/authorization-server-migration
5254
# auth/enterprise-managed-authorization (SEP-990) is in the 2025 baseline but
5355
# NOT here: the harness skips it as inapplicable at --spec-version 2026-07-28

.github/actions/conformance/expected-failures.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ client:
2121
# SEP-2243 (HTTP standardization): no fixture handler / client header support yet.
2222
- http-custom-headers
2323
- http-invalid-tool-headers
24-
# SEP-2352 (authorization server migration): client does not re-register when
25-
# PRM authorization_servers changes.
24+
# SEP-2352 (authorization server migration): the client re-registers and does not reuse the old
25+
# AS credentials, but this 2026-introduced scenario runs at 2026-07-28, where client.py's 2025
26+
# stateful lifecycle is rejected (400 on initialize) before the migration 401 fires, so the
27+
# re-register check is never reached. Unblocks with the 2026 stateless client lifecycle.
2628
- auth/authorization-server-migration
2729

2830
# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---

docs/migration.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,6 +1322,10 @@ If you relied on extra fields round-tripping through MCP types, move that data i
13221322

13231323
## New Features
13241324

1325+
### OAuth client credentials are bound to their authorization server (SEP-2352)
1326+
1327+
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`.
1328+
13251329
### Step-up authorization unions previously requested scopes (SEP-2350)
13261330

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

src/mcp/client/auth/oauth2.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
create_client_info_from_metadata_url,
2626
create_client_registration_request,
2727
create_oauth_metadata_request,
28+
credentials_match_issuer,
2829
extract_field_from_www_auth,
2930
extract_resource_metadata_from_www_auth,
3031
extract_scope_from_www_auth,
@@ -564,6 +565,20 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
564565
else:
565566
logger.debug(f"Protected resource metadata discovery failed: {url}")
566567

568+
# SEP-2352: stored credentials are bound to the issuer that registered them.
569+
# If the authorization server changed, drop them (and the old tokens) so the
570+
# flow re-registers instead of presenting another server's credentials.
571+
if (
572+
self.context.client_info is not None
573+
and self.context.auth_server_url is not None
574+
and not credentials_match_issuer(
575+
self.context.client_info, self.context.auth_server_url, self.context.client_metadata_url
576+
)
577+
):
578+
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
579+
self.context.client_info = None
580+
self.context.clear_tokens()
581+
567582
asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(
568583
self.context.auth_server_url, self.context.server_url
569584
)
@@ -595,6 +610,13 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
595610

596611
# Step 4: Register client or use URL-based client ID (CIMD)
597612
if not self.context.client_info:
613+
# SEP-2352: bind the credentials to the issuing AS. Prefer the PRM-advertised
614+
# authorization server; on the legacy no-PRM path fall back to the issuer from
615+
# the discovered metadata so the binding is still recorded.
616+
bound_issuer = self.context.auth_server_url
617+
if bound_issuer is None and self.context.oauth_metadata is not None:
618+
bound_issuer = str(self.context.oauth_metadata.issuer)
619+
598620
if should_use_client_metadata_url(
599621
self.context.oauth_metadata, self.context.client_metadata_url
600622
):
@@ -604,6 +626,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
604626
self.context.client_metadata_url, # type: ignore[arg-type]
605627
redirect_uris=self.context.client_metadata.redirect_uris,
606628
)
629+
client_information.issuer = bound_issuer
607630
self.context.client_info = client_information
608631
await self.context.storage.set_client_info(client_information)
609632
else:
@@ -615,6 +638,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
615638
)
616639
registration_response = yield registration_request
617640
client_information = await handle_registration_response(registration_response)
641+
client_information.issuer = bound_issuer
618642
self.context.client_info = client_information
619643
await self.context.storage.set_client_info(client_information)
620644

src/mcp/client/auth/utils.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,26 @@ def is_valid_client_metadata_url(url: str | None) -> bool:
325325
return False
326326

327327

328+
def credentials_match_issuer(
329+
client_info: OAuthClientInformationFull, issuer: str, client_metadata_url: str | None
330+
) -> bool:
331+
"""Whether stored client credentials may be reused against `issuer` (SEP-2352).
332+
333+
A URL-based client ID (CIMD) is portable across authorization servers — the same self-hosted
334+
document is resolved by whichever server is in use — so it always matches; CIMD is identified
335+
by the client ID being the configured `client_metadata_url`, not by URL shape (a registration
336+
server may also issue URL-shaped IDs that are bound to it). Credentials with a recorded issuer
337+
match only when it equals `issuer` (simple string comparison). Credentials with no recorded
338+
issuer (pre-registered, or stored before issuer binding existed) carry no binding to enforce
339+
and are left as-is.
340+
"""
341+
if client_metadata_url is not None and client_info.client_id == client_metadata_url:
342+
return True
343+
if client_info.issuer is None:
344+
return True
345+
return client_info.issuer == issuer
346+
347+
328348
def should_use_client_metadata_url(
329349
oauth_metadata: OAuthMetadata | None,
330350
client_metadata_url: str | None,

src/mcp/shared/auth.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ class OAuthClientInformationFull(OAuthClientMetadata):
133133
client_secret: str | None = None
134134
client_id_issued_at: int | None = None
135135
client_secret_expires_at: int | None = None
136+
# SEP-2352: the issuer these credentials were registered with, recorded by the SDK (not an
137+
# RFC 7591 field) to detect authorization-server migration and avoid cross-AS credential reuse.
138+
issuer: str | None = None
136139

137140

138141
class OAuthMetadata(BaseModel):

tests/client/test_auth.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
create_client_info_from_metadata_url,
2020
create_client_registration_request,
2121
create_oauth_metadata_request,
22+
credentials_match_issuer,
2223
extract_field_from_www_auth,
2324
extract_resource_metadata_from_www_auth,
2425
extract_scope_from_www_auth,
@@ -2794,3 +2795,41 @@ def test_validate_metadata_issuer_rejects_mismatch():
27942795
def test_union_scopes(previous: str | None, new: str | None, expected: str | None):
27952796
"""SEP-2350: union merges previous and new scopes, dedups, and preserves order."""
27962797
assert union_scopes(previous, new) == expected
2798+
2799+
2800+
def test_credentials_match_issuer_same_issuer():
2801+
info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer="https://as")
2802+
assert credentials_match_issuer(info, "https://as", None) is True
2803+
2804+
2805+
def test_credentials_match_issuer_different_issuer():
2806+
info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer="https://as")
2807+
assert credentials_match_issuer(info, "https://other", None) is False
2808+
2809+
2810+
def test_credentials_match_issuer_no_recorded_issuer_is_left_alone():
2811+
"""Credentials with no bound issuer (pre-registered / legacy) carry no binding to enforce."""
2812+
info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")])
2813+
assert credentials_match_issuer(info, "https://as", None) is True
2814+
2815+
2816+
def test_credentials_match_issuer_cimd_is_portable():
2817+
"""A client_id equal to the configured client_metadata_url (CIMD) is portable across servers."""
2818+
cimd_url = "https://client.example/metadata.json"
2819+
info = OAuthClientInformationFull(
2820+
client_id=cimd_url,
2821+
redirect_uris=[AnyUrl("http://localhost/cb")],
2822+
token_endpoint_auth_method="none",
2823+
issuer="https://as",
2824+
)
2825+
assert credentials_match_issuer(info, "https://other", cimd_url) is True
2826+
2827+
2828+
def test_credentials_match_issuer_url_shaped_dcr_id_is_not_portable():
2829+
"""A URL-shaped client_id from DCR (not the configured CIMD URL) stays bound to its issuer."""
2830+
info = OAuthClientInformationFull(
2831+
client_id="https://as.example.com/clients/123",
2832+
redirect_uris=[AnyUrl("http://localhost/cb")],
2833+
issuer="https://as.example.com",
2834+
)
2835+
assert credentials_match_issuer(info, "https://other", "https://client.example/metadata.json") is False

tests/interaction/_requirements.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3360,6 +3360,15 @@ def __post_init__(self) -> None:
33603360
transports=("streamable-http",),
33613361
note="OAuth is HTTP-only.",
33623362
),
3363+
"client-auth:as-binding": Requirement(
3364+
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-binding",
3365+
behavior=(
3366+
"Stored client credentials are bound to the issuer that registered them; when the authorization "
3367+
"server changes, the client discards them and re-registers rather than reusing them (SEP-2352)."
3368+
),
3369+
transports=("streamable-http",),
3370+
note="OAuth is HTTP-only.",
3371+
),
33633372
"client-auth:invalid-client-clears-all": Requirement(
33643373
source="sdk",
33653374
behavior=(

tests/interaction/auth/test_lifecycle.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,40 @@ async def test_a_403_step_up_re_authorizes_with_the_union_of_prior_and_challenge
207207
assert authorize_params(headless.authorize_urls[1])["scope"] == "mcp write"
208208

209209

210+
@requirement("client-auth:as-binding")
211+
async def test_credentials_bound_to_a_different_issuer_are_discarded_and_the_client_re_registers() -> None:
212+
"""Credentials bound to a stale issuer are dropped and re-registered against the current AS.
213+
214+
The stored client is bound (SEP-2352) to a different issuer than the one the server's PRM
215+
advertises, simulating an authorization-server migration. The client must discard it, perform
216+
Dynamic Client Registration with the current AS, and never present the stale `client_id` at the
217+
authorize or token endpoints.
218+
"""
219+
recorded, on_request = record_requests()
220+
provider = InMemoryAuthorizationServerProvider()
221+
stale = seeded_client(provider, client_id="stale-as-client", issuer="https://old-as.example.com")
222+
storage = InMemoryTokenStorage(client_info=stale)
223+
server = Server("guarded", on_list_tools=list_tools)
224+
225+
with anyio.fail_after(5):
226+
async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as (
227+
client,
228+
_,
229+
):
230+
await client.list_tools()
231+
232+
# The client re-registered with the current AS...
233+
assert path_counts(recorded)[("POST", "/register")] == 1
234+
# ...and the stale client_id never reached the authorize or token endpoints.
235+
authorize_and_token = find(recorded, "GET", "/authorize") + find(recorded, "POST", "/token")
236+
assert all("stale-as-client" not in r.url.query.decode() for r in authorize_and_token)
237+
assert all("stale-as-client" not in r.content.decode() for r in find(recorded, "POST", "/token"))
238+
# The persisted client is now bound to the current AS.
239+
assert storage.client_info is not None
240+
assert storage.client_info.client_id != "stale-as-client"
241+
assert storage.client_info.issuer == f"{BASE_URL}/"
242+
243+
210244
@requirement("client-auth:401-after-auth-throws")
211245
async def test_a_second_401_after_a_completed_oauth_flow_surfaces_without_looping() -> None:
212246
"""A 401 on the post-auth retry surfaces as an error rather than re-entering discovery.

0 commit comments

Comments
 (0)