Skip to content

Commit b8fc48d

Browse files
committed
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.
1 parent 1331131 commit b8fc48d

9 files changed

Lines changed: 119 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: 17 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,18 @@ 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(self.context.client_info, self.context.auth_server_url)
575+
):
576+
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
577+
self.context.client_info = None
578+
self.context.clear_tokens()
579+
567580
asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(
568581
self.context.auth_server_url, self.context.server_url
569582
)
@@ -604,6 +617,8 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
604617
self.context.client_metadata_url, # type: ignore[arg-type]
605618
redirect_uris=self.context.client_metadata.redirect_uris,
606619
)
620+
# SEP-2352: bind the credentials to the issuing authorization server
621+
client_information.issuer = self.context.auth_server_url
607622
self.context.client_info = client_information
608623
await self.context.storage.set_client_info(client_information)
609624
else:
@@ -615,6 +630,8 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
615630
)
616631
registration_response = yield registration_request
617632
client_information = await handle_registration_response(registration_response)
633+
# SEP-2352: bind the credentials to the issuing authorization server
634+
client_information.issuer = self.context.auth_server_url
618635
self.context.client_info = client_information
619636
await self.context.storage.set_client_info(client_information)
620637

src/mcp/client/auth/utils.py

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

327327

328+
def credentials_match_issuer(client_info: OAuthClientInformationFull, issuer: str) -> bool:
329+
"""Whether stored client credentials may be reused against `issuer` (SEP-2352).
330+
331+
URL-based client IDs (CIMD) are portable across authorization servers — the same self-hosted
332+
document is resolved by whichever server is in use — so they always match. Credentials with a
333+
recorded issuer match only when it equals `issuer` (simple string comparison). Credentials
334+
with no recorded issuer (pre-registered, or stored before issuer binding existed) carry no
335+
binding to enforce and are left as-is.
336+
"""
337+
if is_valid_client_metadata_url(client_info.client_id):
338+
return True
339+
if client_info.issuer is None:
340+
return True
341+
return client_info.issuer == issuer
342+
343+
328344
def should_use_client_metadata_url(
329345
oauth_metadata: OAuthMetadata | None,
330346
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: 28 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,
@@ -2793,3 +2794,30 @@ def test_validate_metadata_issuer_rejects_mismatch():
27932794
def test_union_scopes(previous: str | None, new: str | None, expected: str | None):
27942795
"""SEP-2350: union merges previous and new scopes, dedups, and preserves order."""
27952796
assert union_scopes(previous, new) == expected
2797+
2798+
2799+
def test_credentials_match_issuer_same_issuer():
2800+
info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer="https://as")
2801+
assert credentials_match_issuer(info, "https://as") is True
2802+
2803+
2804+
def test_credentials_match_issuer_different_issuer():
2805+
info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer="https://as")
2806+
assert credentials_match_issuer(info, "https://other") is False
2807+
2808+
2809+
def test_credentials_match_issuer_no_recorded_issuer_is_left_alone():
2810+
"""Credentials with no bound issuer (pre-registered / legacy) carry no binding to enforce."""
2811+
info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")])
2812+
assert credentials_match_issuer(info, "https://as") is True
2813+
2814+
2815+
def test_credentials_match_issuer_cimd_is_portable():
2816+
"""A URL-based client_id (CIMD) is portable across authorization servers."""
2817+
info = OAuthClientInformationFull(
2818+
client_id="https://client.example/metadata.json",
2819+
redirect_uris=[AnyUrl("http://localhost/cb")],
2820+
token_endpoint_auth_method="none",
2821+
issuer="https://as",
2822+
)
2823+
assert credentials_match_issuer(info, "https://other") is True

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)