Skip to content

Commit d3ffe87

Browse files
authored
Split the registration request model from the registered-client record (#3181)
1 parent b9422f1 commit d3ffe87

12 files changed

Lines changed: 573 additions & 129 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 a token could not be obtained: the token endpoint said no, or a stored client record carries an authentication method this client cannot apply, which is reported while building the token request rather than sent. 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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2184,6 +2184,27 @@ client_metadata = OAuthClientMetadata(
21842184

21852185
Under OIDC, omitting `application_type` defaults to `"web"`, which an authorization server may reject for the `localhost` redirect URIs native clients use; sending `"native"` avoids that. Non-OIDC servers ignore the parameter.
21862186

2187+
### `OAuthClientInformationFull` no longer subclasses `OAuthClientMetadata`, and parses server-substituted metadata
2188+
2189+
`OAuthClientMetadata` is the registration request a client sends; `OAuthClientInformationFull` is the authorization server's record of a registered client, parsed from its Dynamic Client Registration response. In v1 the second inherited from the first, which typed the response as though it had to be a request this SDK would send. It does not: [RFC 7591 §3.2.1](https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1) lets the server "reject or replace any of the client's requested metadata values submitted during the registration and substitute them with suitable values", and real servers return an `application_type` outside OIDC Registration's `web`/`native`, an explicit `null`, a `token_endpoint_auth_method` the SDK does not implement, or an empty `redirect_uris`. The inherited strict types turned each of those into a `ValidationError` on a 2xx response - after the server had already provisioned the client, so the registration was discarded and orphaned.
2190+
2191+
The two are now siblings over a shared `OAuthClientMetadataBase`. `OAuthClientMetadata` keeps its strict types (the SDK still refuses to *send* an unregistered `application_type`), while `OAuthClientInformationFull` accepts what a server may echo:
2192+
2193+
```python
2194+
# v1
2195+
class OAuthClientInformationFull(OAuthClientMetadata): ...
2196+
2197+
# v2
2198+
class OAuthClientMetadata(OAuthClientMetadataBase): ... # request: strict
2199+
class OAuthClientInformationFull(OAuthClientMetadataBase): ... # server record: tolerant
2200+
```
2201+
2202+
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`.
2203+
2204+
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.
2205+
2206+
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.
2207+
21872208
### Stricter client authentication at `/token` and `/revoke`
21882209

21892210
v2 hardens client authentication on SDK-hosted authorization servers (`create_auth_routes`) in two ways. Both apply automatically; server code only needs changing if you hand-provision client records.

src/mcp/client/auth/oauth2.py

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@
1111
import time
1212
from collections.abc import AsyncGenerator, Awaitable, Callable
1313
from dataclasses import dataclass, field
14-
from typing import Any, Protocol
14+
from typing import Any, Protocol, get_args
1515
from urllib.parse import quote, urlencode, urljoin, urlparse
1616

1717
import anyio
1818
import httpx2
1919
from mcp_types.version import is_version_at_least
2020
from pydantic import BaseModel, Field, ValidationError
2121

22-
from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError
22+
from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
2323
from mcp.client.auth.utils import (
2424
build_oauth_authorization_server_metadata_discovery_urls,
2525
build_protected_resource_metadata_discovery_urls,
@@ -48,6 +48,7 @@
4848
OAuthMetadata,
4949
OAuthToken,
5050
ProtectedResourceMetadata,
51+
TokenEndpointAuthMethod,
5152
)
5253
from mcp.shared.auth_utils import (
5354
calculate_token_expiry,
@@ -58,6 +59,55 @@
5859

5960
logger = logging.getLogger(__name__)
6061

62+
# Methods a registered client's record may carry without a token request being an error,
63+
# 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: 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.
69+
_KNOWN_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = (None, *get_args(TokenEndpointAuthMethod))
70+
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+
75+
# Methods a registration completed by the authorization-code flow can act on. That flow
76+
# authenticates the token request with the minted client secret (or nothing); it holds no key
77+
# to sign a `private_key_jwt` assertion, so a server assigning that method has registered a
78+
# client this flow cannot use. `PrivateKeyJWTOAuthProvider` never registers dynamically.
79+
_REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = tuple(
80+
method for method in _KNOWN_TOKEN_ENDPOINT_AUTH_METHODS if method != "private_key_jwt"
81+
)
82+
83+
84+
def check_registration_usable(client_info: OAuthClientInformationFull) -> None:
85+
"""Confirm a registration this flow completed is one it can act on.
86+
87+
RFC 7591 §3.2.1 lets the authorization server replace requested metadata and leaves it to
88+
the client to "check the values in the response to determine if the registration is
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`.
95+
96+
Raises:
97+
OAuthRegistrationError: The server registered the client with a
98+
`token_endpoint_auth_method` this flow cannot apply, or with a secret-based
99+
method but no `client_secret`.
100+
"""
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:
107+
raise OAuthRegistrationError(
108+
f"Authorization server registered the client for {method!r} but issued no client_secret"
109+
)
110+
61111

62112
class PKCEParameters(BaseModel):
63113
"""PKCE (Proof Key for Code Exchange) parameters."""
@@ -190,6 +240,12 @@ def prepare_token_auth(
190240
191241
Returns:
192242
Tuple of (updated_data, updated_headers)
243+
244+
Raises:
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.
193249
"""
194250
if headers is None:
195251
headers = {} # pragma: no cover
@@ -199,7 +255,7 @@ def prepare_token_auth(
199255

200256
auth_method = self.client_info.token_endpoint_auth_method
201257

202-
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:
203259
# URL-encode client ID and secret per RFC 6749 Section 2.3.1
204260
encoded_id = quote(self.client_info.client_id, safe="")
205261
encoded_secret = quote(self.client_info.client_secret, safe="")
@@ -208,11 +264,14 @@ def prepare_token_auth(
208264
headers["Authorization"] = f"Basic {encoded_credentials}"
209265
# Don't include client_secret in body for basic auth
210266
data = {k: v for k, v in data.items() if k != "client_secret"}
211-
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:
212268
# Include client_id and client_secret in request body (RFC 6749 §2.3.1)
213269
data["client_id"] = self.client_info.client_id
214270
data["client_secret"] = self.client_info.client_secret
215-
# For auth_method == "none", don't add any client_secret
271+
elif auth_method not in _KNOWN_TOKEN_ENDPOINT_AUTH_METHODS:
272+
raise OAuthTokenError(f"Registered client uses unsupported token_endpoint_auth_method {auth_method!r}")
273+
# For "none" (or absent), don't add any client_secret; "private_key_jwt" adds its
274+
# assertion in the provider that implements it, not here.
216275

217276
return data, headers
218277

@@ -664,6 +723,7 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
664723
)
665724
registration_response = yield registration_request
666725
client_information = await handle_registration_response(registration_response)
726+
check_registration_usable(client_information)
667727
# Only record the issuer when the registration above actually targeted
668728
# the discovered AS — either via its published registration_endpoint,
669729
# or because the resource-origin /register fallback is on the issuer's

src/mcp/client/auth/utils.py

Lines changed: 15 additions & 6 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,10 +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-
return client_info
304-
except ValidationError as e: # pragma: no cover
305-
raise OAuthRegistrationError(f"Invalid registration response: {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.
314+
raise OAuthRegistrationError(f"Invalid registration response: {e}") from e
306315

307316

308317
def is_valid_client_metadata_url(url: str | None) -> bool:
@@ -381,8 +390,8 @@ def create_client_info_from_metadata_url(
381390
382391
Args:
383392
client_metadata_url: The URL to use as the client_id
384-
redirect_uris: The redirect URIs from the client metadata (passed through for
385-
compatibility with OAuthClientInformationFull which inherits from OAuthClientMetadata)
393+
redirect_uris: The redirect URIs from the client metadata, recorded on the client
394+
information alongside the client_id
386395
387396
Returns:
388397
OAuthClientInformationFull with the URL as client_id

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

Lines changed: 32 additions & 26 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
@@ -106,33 +118,27 @@ async def handle(self, request: Request) -> Response:
106118
)
107119

108120
client_id_issued_at = int(time.time())
109-
client_secret_expires_at = (
110-
client_id_issued_at + self.options.client_secret_expiry_seconds
111-
if self.options.client_secret_expiry_seconds is not None
112-
else None
113-
)
121+
# RFC 7591 §3.2.1: client_secret_expires_at is REQUIRED whenever a client_secret is
122+
# issued, with 0 (not omission) meaning it never expires; a public client gets none.
123+
client_secret_expires_at = None
124+
if client_secret is not None:
125+
client_secret_expires_at = (
126+
client_id_issued_at + self.options.client_secret_expiry_seconds
127+
if self.options.client_secret_expiry_seconds is not None
128+
else 0
129+
)
114130

115-
client_info = OAuthClientInformationFull(
116-
client_id=client_id,
117-
client_id_issued_at=client_id_issued_at,
118-
client_secret=client_secret,
119-
client_secret_expires_at=client_secret_expires_at,
120-
# passthrough information from the client request
121-
redirect_uris=client_metadata.redirect_uris,
122-
token_endpoint_auth_method=client_metadata.token_endpoint_auth_method,
123-
grant_types=client_metadata.grant_types,
124-
response_types=client_metadata.response_types,
125-
client_name=client_metadata.client_name,
126-
client_uri=client_metadata.client_uri,
127-
logo_uri=client_metadata.logo_uri,
128-
scope=client_metadata.scope,
129-
contacts=client_metadata.contacts,
130-
tos_uri=client_metadata.tos_uri,
131-
policy_uri=client_metadata.policy_uri,
132-
jwks_uri=client_metadata.jwks_uri,
133-
jwks=client_metadata.jwks,
134-
software_id=client_metadata.software_id,
135-
software_version=client_metadata.software_version,
131+
# RFC 7591 §3.2.1: the response returns all registered metadata about the client, so
132+
# the record is the whole validated request plus the credentials minted here - built
133+
# from the request's dump so no metadata field can be silently omitted from the echo.
134+
client_info = OAuthClientInformationFull.model_validate(
135+
{
136+
**client_metadata.model_dump(),
137+
"client_id": client_id,
138+
"client_id_issued_at": client_id_issued_at,
139+
"client_secret": client_secret,
140+
"client_secret_expires_at": client_secret_expires_at,
141+
}
136142
)
137143
try:
138144
# Register client

0 commit comments

Comments
 (0)