Skip to content

Commit 40fe046

Browse files
committed
Address review: seal the parse boundary and judge secret-less methods
An SDK-internal field must never be sourced from the wire, not sourced then cleared: the registration response body is now parsed with issuer dropped before validation, so an echoed member neither seeds the SEP-2352 binding nor fails the parse when malformed. The parse goes through pydantic's JSON layer so malformed bytes still surface as OAuthRegistrationError. Both placeholder styles servers use for unset members - null, and "" - now read as omitted keys across the whole record, closing the gap for list fields and retiring the field-by-field empty-string coercions. An empty client_id is accordingly no client_id, so the unrepresentable empty-identifier record and its dead guards in prepare_token_auth go. check_registration_usable now also rejects a secret-based method for which the server issued no client_secret, before persistence and before any interactive authorization, instead of letting the token request go out unauthenticated after the browser round-trip. The bundled registration endpoint refuses private_key_jwt with 400 invalid_client_metadata: it authenticates token requests with the secret it mints and holds no client key to verify an assertion, so confirming the method would register a client it could never authenticate - which this SDK's own client would then immediately declare unusable, orphaning the record. Correct prose that overstated the private_key_jwt refresh path (the tolerance keeps prepare_token_auth from raising so a rejected refresh can fall back to a fresh, signed client-credentials exchange), the stale Raises text in prepare_token_auth, and the client failure-model page, which described OAuthRegistrationError as only a refusal.
1 parent 470bff3 commit 40fe046

12 files changed

Lines changed: 141 additions & 126 deletions

File tree

docs/client/oauth-clients.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ There is one more no-human situation: the client belongs to an enterprise whose
131131

132132
## When it fails
133133

134-
When the OAuth flow goes wrong, the provider raises an `OAuthFlowError` from `mcp.client.auth`. It has two subclasses. `OAuthRegistrationError` means the authorization server refused to register you. `OAuthTokenError` means the token endpoint said no. One `except OAuthFlowError:` covers discovery, registration, authorization, and exchange.
134+
When the OAuth flow goes wrong, the provider raises an `OAuthFlowError` from `mcp.client.auth`. It has two subclasses. `OAuthRegistrationError` means registration did not yield a client you can use: the authorization server refused to register you, or it did register you but with credentials this flow cannot use (for instance an authentication method it does not implement). `OAuthTokenError` means the token endpoint said no. One `except OAuthFlowError:` covers discovery, registration, authorization, and exchange.
135135

136136
Not everything is a flow error. The network can still fail; those are ordinary `httpx2` exceptions and pass through untouched.
137137

docs/migration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2196,9 +2196,9 @@ class OAuthClientInformationFull(OAuthClientMetadataBase): ... # server record
21962196

21972197
On `OAuthClientInformationFull`, `application_type` and `token_endpoint_auth_method` are now `str | None`, `grant_types` is `list[str]`, and `redirect_uris` is optional (`list[AnyUrl] | None`, no minimum length). `client_id` is now required (`str`): [RFC 7591 §3.2.1](https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1) makes it mandatory in the response, and a record of a registered client without one was never meaningful. Code that only reads these fields is unaffected. Code that relied on `isinstance(client_info, OAuthClientMetadata)`, or passed an `OAuthClientInformationFull` where an `OAuthClientMetadata` is expected, must reference the record type directly. `validate_scope()` and `validate_redirect_uri()` moved with the record: they are methods of `OAuthClientInformationFull` (the type authorization-server code holds) and are no longer available on `OAuthClientMetadata`.
21982198

2199-
A registration response the server sends is no longer rejected on these fields: an echoed `""` for `token_endpoint_auth_method` or `application_type` reads as absent (as it already did for the optional URL fields), and a member serialized as an explicit `null` reads as an omitted key, so its default applies. Whether a substituted value is usable is judged where it matters, not at parse. When Dynamic Client Registration completes with a `token_endpoint_auth_method` the authorization-code flow cannot apply - anything other than `none`, `client_secret_post`, or `client_secret_basic`, including `private_key_jwt`, whose assertion that flow has no key to sign - the client raises `OAuthRegistrationError` naming the method, before the record is stored or authorization begins. Separately, a stored or pre-registered record carrying a method the SDK does not know at all raises `OAuthTokenError` when it reaches the token exchange (`private_key_jwt` on such a record is fine: `PrivateKeyJWTOAuthProvider` supplies the assertion, and its refresh path still works).
2199+
A registration response the server sends is no longer rejected on these fields: a member serialized as a placeholder - an explicit `null`, or `""` - reads as an omitted key, so its default applies. Whether a substituted value is usable is judged where it matters, not at parse. When Dynamic Client Registration completes with credentials the authorization-code flow cannot use - a `token_endpoint_auth_method` other than `none`, `client_secret_post`, or `client_secret_basic` (including `private_key_jwt`, whose assertion that flow has no key to sign), or a secret-based method for which the server issued no `client_secret` - the client raises `OAuthRegistrationError` naming the problem, before the record is stored or authorization begins. Separately, a stored or pre-registered record carrying a method the SDK does not know at all raises `OAuthTokenError` when it reaches the token exchange; `private_key_jwt` on such a record does not raise there, so `PrivateKeyJWTOAuthProvider`, which signs its assertion only in the client-credentials exchange, still recovers from a rejected refresh by exchanging afresh.
22002200

2201-
The SDK's own registration endpoint also now returns all registered metadata in its 201 response (RFC 7591 §3.2.1), including the client's `application_type`, which v1 dropped from the echo (silently reporting the default in place of a client's `"web"`).
2201+
The SDK's own registration endpoint now returns all registered metadata in its 201 response (RFC 7591 §3.2.1) - including the client's `application_type`, which v1 dropped from the echo (silently reporting the default in place of a client's `"web"`), and `client_secret_expires_at` (`0` when the secret never expires) whenever a `client_secret` is issued. It also now answers a `private_key_jwt` registration with `400 invalid_client_metadata` rather than confirming a method it authenticates no requests with.
22022202

22032203
### Stricter client authentication at `/token` and `/revoke`
22042204

src/mcp/client/auth/oauth2.py

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,17 @@
6161

6262
# Methods a registered client's record may carry without a token request being an error,
6363
# derived from the set the SDK is willing to request so the two cannot drift. `None`/"none"
64-
# send no client secret; `private_key_jwt` sends none from here either, its assertion being
65-
# added by `PrivateKeyJWTOAuthProvider` (whose inherited refresh path passes through
66-
# `prepare_token_auth`). Anything else is a method no client here can apply.
64+
# send no client secret. `private_key_jwt` sends none from here either: only
65+
# `PrivateKeyJWTOAuthProvider` signs the assertion, and only in its client-credentials
66+
# exchange, so its inherited refresh path must pass through here without raising - a refresh
67+
# the server then rejects falls back to a fresh client-credentials exchange, which signs.
68+
# Anything else is a method no client here can apply.
6769
_KNOWN_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = (None, *get_args(TokenEndpointAuthMethod))
6870

71+
# Methods that authenticate the token request with the minted `client_secret`; a
72+
# registration assigning one is only usable if the server issued that secret.
73+
_SECRET_TOKEN_ENDPOINT_AUTH_METHODS = ("client_secret_post", "client_secret_basic")
74+
6975
# Methods a registration completed by the authorization-code flow can act on. That flow
7076
# authenticates the token request with the minted client secret (or nothing); it holds no key
7177
# to sign a `private_key_jwt` assertion, so a server assigning that method has registered a
@@ -80,20 +86,26 @@ def check_registration_usable(client_info: OAuthClientInformationFull) -> None:
8086
8187
RFC 7591 §3.2.1 lets the authorization server replace requested metadata and leaves it to
8288
the client to "check the values in the response to determine if the registration is
83-
sufficient for use". The one substitution that makes the minted credentials unusable is a
84-
token-endpoint auth method the authorization-code flow cannot apply - one it does not
85-
implement, or `private_key_jwt`, whose assertion this flow has no key to sign - so it is
86-
judged here, before the record is persisted or any interactive authorization begins,
87-
rather than surfacing later as an opaque failure at the token endpoint.
89+
sufficient for use". Two substitutions make the minted credentials unusable, and both are
90+
judged here - before the record is persisted or any interactive authorization begins -
91+
rather than surfacing later as an opaque failure at the token endpoint: a token-endpoint
92+
auth method the authorization-code flow cannot apply (one it does not implement, or
93+
`private_key_jwt`, whose assertion this flow has no key to sign), and a secret-based
94+
method the flow could apply but for which the server issued no `client_secret`.
8895
8996
Raises:
9097
OAuthRegistrationError: The server registered the client with a
91-
`token_endpoint_auth_method` this flow cannot apply.
98+
`token_endpoint_auth_method` this flow cannot apply, or with a secret-based
99+
method but no `client_secret`.
92100
"""
93-
if client_info.token_endpoint_auth_method not in _REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS:
101+
method = client_info.token_endpoint_auth_method
102+
if method not in _REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS:
103+
raise OAuthRegistrationError(
104+
f"Authorization server registered the client with unsupported token_endpoint_auth_method {method!r}"
105+
)
106+
if method in _SECRET_TOKEN_ENDPOINT_AUTH_METHODS and client_info.client_secret is None:
94107
raise OAuthRegistrationError(
95-
"Authorization server registered the client with unsupported token_endpoint_auth_method "
96-
f"{client_info.token_endpoint_auth_method!r}"
108+
f"Authorization server registered the client for {method!r} but issued no client_secret"
97109
)
98110

99111

@@ -230,10 +242,10 @@ def prepare_token_auth(
230242
Tuple of (updated_data, updated_headers)
231243
232244
Raises:
233-
OAuthTokenError: The registered client's `token_endpoint_auth_method` is one this
234-
client does not implement. The authorization server may assign a method other
235-
than the one requested (RFC 7591 §3.2.1); the registration record accepts it,
236-
and the mismatch is reported here, where the method is applied.
245+
OAuthTokenError: The client record carries a `token_endpoint_auth_method` this
246+
client does not know. A dynamic registration assigning an unusable method is
247+
rejected earlier, by `check_registration_usable`; this fires for a stored or
248+
pre-registered record that reaches a token request with such a method.
237249
"""
238250
if headers is None:
239251
headers = {} # pragma: no cover
@@ -243,7 +255,7 @@ def prepare_token_auth(
243255

244256
auth_method = self.client_info.token_endpoint_auth_method
245257

246-
if auth_method == "client_secret_basic" and self.client_info.client_id and self.client_info.client_secret:
258+
if auth_method == "client_secret_basic" and self.client_info.client_secret:
247259
# URL-encode client ID and secret per RFC 6749 Section 2.3.1
248260
encoded_id = quote(self.client_info.client_id, safe="")
249261
encoded_secret = quote(self.client_info.client_secret, safe="")
@@ -252,7 +264,7 @@ def prepare_token_auth(
252264
headers["Authorization"] = f"Basic {encoded_credentials}"
253265
# Don't include client_secret in body for basic auth
254266
data = {k: v for k, v in data.items() if k != "client_secret"}
255-
elif auth_method == "client_secret_post" and self.client_info.client_id and self.client_info.client_secret:
267+
elif auth_method == "client_secret_post" and self.client_info.client_secret:
256268
# Include client_id and client_secret in request body (RFC 6749 §2.3.1)
257269
data["client_id"] = self.client_info.client_id
258270
data["client_secret"] = self.client_info.client_secret

src/mcp/client/auth/utils.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import re
2+
from typing import Any, cast
23
from urllib.parse import urljoin, urlparse
34

45
from httpx2 import Request, Response
56
from mcp_types import LATEST_PROTOCOL_VERSION
67
from pydantic import AnyUrl, ValidationError
8+
from pydantic_core import from_json
79

810
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
911
from mcp.shared.auth import (
@@ -299,14 +301,17 @@ async def handle_registration_response(response: Response) -> OAuthClientInforma
299301

300302
try:
301303
content = await response.aread()
302-
client_info = OAuthClientInformationFull.model_validate_json(content)
303-
except ValidationError as e:
304+
body = from_json(content)
305+
# `issuer` is the SDK's own binding of these credentials to the server they were
306+
# registered with (SEP-2352), stamped by the auth flow - never sourced from the
307+
# wire, so it is dropped before the body is parsed rather than trusted or cleared.
308+
if isinstance(body, dict):
309+
cast(dict[str, Any], body).pop("issuer", None)
310+
return OAuthClientInformationFull.model_validate(body)
311+
except ValueError as e:
312+
# `from_json` reports malformed bytes/JSON as ValueError, and pydantic's
313+
# ValidationError is itself a ValueError, so both parse layers surface here.
304314
raise OAuthRegistrationError(f"Invalid registration response: {e}") from e
305-
# `issuer` is the SDK's own binding of these credentials to the server they were
306-
# registered with (SEP-2352), stamped by the auth flow - never taken from the wire.
307-
# A body echoing an "issuer" member must not seed the trust binding.
308-
client_info.issuer = None
309-
return client_info
310315

311316

312317
def is_valid_client_metadata_url(url: str | None) -> bool:

src/mcp/server/auth/handlers/register.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,18 @@ async def handle(self, request: Request) -> Response:
5050
# If auth method is None, default to client_secret_post
5151
if client_metadata.token_endpoint_auth_method is None:
5252
client_metadata.token_endpoint_auth_method = "client_secret_post"
53+
# This server authenticates token requests with the client secret it mints; it holds
54+
# no client key to verify a private_key_jwt assertion, so confirming that method would
55+
# register a client whose every token request is then rejected. Refuse it instead
56+
# (RFC 7591 §3.2.2), before minting credentials the client could never use.
57+
if client_metadata.token_endpoint_auth_method == "private_key_jwt":
58+
return PydanticJSONResponse(
59+
content=RegistrationErrorResponse(
60+
error="invalid_client_metadata",
61+
error_description="token_endpoint_auth_method 'private_key_jwt' is not supported",
62+
),
63+
status_code=400,
64+
)
5365

5466
client_secret = None
5567
if client_metadata.token_endpoint_auth_method != "none": # pragma: no branch

src/mcp/shared/auth.py

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515

1616

1717
def _empty_str_to_none(v: object) -> object:
18-
# Some authorization servers echo omitted metadata back as "" instead of dropping the
19-
# key. RFC 7591 §2 marks these fields OPTIONAL; treat "" as absent rather than rejecting
20-
# an otherwise valid registration response over a placeholder.
18+
# RFC 7591 §2 marks these URL fields OPTIONAL; a "" placeholder means absent, so it
19+
# must not fail AnyHttpUrl validation. (The registered-client record applies the same
20+
# rule to every member; this coercion serves the request model.)
2121
if v == "":
2222
return None
2323
return v
@@ -136,11 +136,11 @@ class OAuthClientInformationFull(OAuthClientMetadataBase):
136136
requested metadata values submitted during the registration and substitute them with
137137
suitable values", so `application_type`, `token_endpoint_auth_method`, and `grant_types`
138138
are typed to accept any string the server echoes, and `redirect_uris` may be absent or
139-
empty. A member the server serializes as an explicit `null` is read as omitted, so the
140-
field's default applies rather than the parse failing. Whether a substituted value is
141-
usable is decided where the value is used, not at parse. `redirect_uris` elements are
142-
still parsed as URLs, as the authorization server compares them against a client's
143-
requested `redirect_uri`.
139+
empty. A member the server serializes as a placeholder - an explicit `null`, or `""` -
140+
is read as an omitted key, so the field's default applies rather than the parse failing.
141+
Whether a substituted value is usable is decided where the value is used, not at parse.
142+
`redirect_uris` elements are still parsed as URLs, as the authorization server compares
143+
them against a client's requested `redirect_uri`.
144144
"""
145145

146146
redirect_uris: list[AnyUrl] | None = None
@@ -164,22 +164,16 @@ class OAuthClientInformationFull(OAuthClientMetadataBase):
164164

165165
@model_validator(mode="before")
166166
@classmethod
167-
def _explicit_null_reads_as_omitted(cls, data: object) -> object:
168-
# Servers that serialize their client record dump unset members as null instead of
169-
# omitting the keys; a null for a list field would otherwise fail the parse (and
170-
# discard an already-provisioned registration). null and absent mean the same thing.
167+
def _placeholder_members_read_as_omitted(cls, data: object) -> object:
168+
# Servers dump unset members of their client record as null, or echo them as "",
169+
# instead of omitting the keys. Either placeholder would otherwise fail the parse of a
170+
# list field (or read "" as an unrecognized method) and discard an already-provisioned
171+
# registration; a placeholder and an absent key mean the same thing.
171172
if isinstance(data, dict):
172173
members = cast(dict[str, Any], data)
173-
return {key: value for key, value in members.items() if value is not None}
174+
return {key: value for key, value in members.items() if value is not None and value != ""}
174175
return data
175176

176-
@field_validator("token_endpoint_auth_method", "application_type", mode="before")
177-
@classmethod
178-
def _empty_string_optional_metadata_to_none(cls, v: object) -> object:
179-
# An echoed "" here would otherwise read as an unrecognized method/type; treat it as
180-
# absent, matching the URL-field coercion.
181-
return _empty_str_to_none(v)
182-
183177
def validate_scope(self, requested_scope: str | None) -> list[str] | None:
184178
if requested_scope is None:
185179
return None

tests/client/auth/extensions/test_client_credentials.py

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -149,44 +149,6 @@ async def test_exchange_token_client_secret_post_includes_client_id(self, mock_s
149149
# Should NOT have Basic auth header
150150
assert "Authorization" not in request.headers
151151

152-
@pytest.mark.anyio
153-
async def test_exchange_token_client_secret_post_without_client_id(self, mock_storage: MockTokenStorage):
154-
"""Test client_secret_post skips body credentials when client_id is empty."""
155-
provider = ClientCredentialsOAuthProvider(
156-
server_url="https://api.example.com/v1/mcp",
157-
storage=mock_storage,
158-
client_id="placeholder",
159-
client_secret="test-client-secret",
160-
token_endpoint_auth_method="client_secret_post",
161-
scope="read write",
162-
)
163-
await provider._initialize()
164-
provider.context.oauth_metadata = OAuthMetadata(
165-
issuer=AnyHttpUrl("https://api.example.com"),
166-
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
167-
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
168-
)
169-
provider.context.protocol_version = "2025-06-18"
170-
# Override client_info to have an empty client_id (edge case)
171-
provider.context.client_info = OAuthClientInformationFull(
172-
redirect_uris=None,
173-
client_id="",
174-
client_secret="test-client-secret",
175-
grant_types=["client_credentials"],
176-
token_endpoint_auth_method="client_secret_post",
177-
scope="read write",
178-
)
179-
180-
request = await provider._perform_authorization()
181-
182-
content = urllib.parse.unquote_plus(request.content.decode())
183-
assert "grant_type=client_credentials" in content
184-
# Neither client_id nor client_secret should be in body since client_id is empty
185-
# (RFC 6749 §2.3.1 requires both for client_secret_post)
186-
assert "client_id=" not in content
187-
assert "client_secret=" not in content
188-
assert "Authorization" not in request.headers
189-
190152
@pytest.mark.anyio
191153
async def test_exchange_token_without_scopes(self, mock_storage: MockTokenStorage):
192154
"""Test token exchange without scopes."""

0 commit comments

Comments
 (0)