Split the registration request model from the registered-client record (#3181)

This commit is contained in:
Max 2026-07-27 23:11:01 +01:00 • committed by GitHub
commit d3ffe87960
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 573 additions and 129 deletions

View file

@ -11,7 +11,7 @@ import string
import time
from collections.abc import AsyncGenerator, Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any, Protocol
from typing import Any, Protocol, get_args
from urllib.parse import quote, urlencode, urljoin, urlparse
import anyio
@ -19,7 +19,7 @@ import httpx2
from mcp_types.version import is_version_at_least
from pydantic import BaseModel, Field, ValidationError
from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError
from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
from mcp.client.auth.utils import (
build_oauth_authorization_server_metadata_discovery_urls,
build_protected_resource_metadata_discovery_urls,
@ -48,6 +48,7 @@ from mcp.shared.auth import (
OAuthMetadata,
OAuthToken,
ProtectedResourceMetadata,
TokenEndpointAuthMethod,
)
from mcp.shared.auth_utils import (
calculate_token_expiry,
@ -58,6 +59,55 @@ from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
logger = logging.getLogger(__name__)
# Methods a registered client's record may carry without a token request being an error,
# derived from the set the SDK is willing to request so the two cannot drift. `None`/"none"
# send no client secret. `private_key_jwt` sends none from here either: only
# `PrivateKeyJWTOAuthProvider` signs the assertion, and only in its client-credentials
# exchange, so its inherited refresh path must pass through here without raising - a refresh
# the server then rejects falls back to a fresh client-credentials exchange, which signs.
# Anything else is a method no client here can apply.
_KNOWN_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = (None, *get_args(TokenEndpointAuthMethod))
# Methods that authenticate the token request with the minted `client_secret`; a
# registration assigning one is only usable if the server issued that secret.
_SECRET_TOKEN_ENDPOINT_AUTH_METHODS = ("client_secret_post", "client_secret_basic")
# Methods a registration completed by the authorization-code flow can act on. That flow
# authenticates the token request with the minted client secret (or nothing); it holds no key
# to sign a `private_key_jwt` assertion, so a server assigning that method has registered a
# client this flow cannot use. `PrivateKeyJWTOAuthProvider` never registers dynamically.
_REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = tuple(
method for method in _KNOWN_TOKEN_ENDPOINT_AUTH_METHODS if method != "private_key_jwt"
)
def check_registration_usable(client_info: OAuthClientInformationFull) -> None:
"""Confirm a registration this flow completed is one it can act on.
RFC 7591 §3.2.1 lets the authorization server replace requested metadata and leaves it to
the client to "check the values in the response to determine if the registration is
sufficient for use". Two substitutions make the minted credentials unusable, and both are
judged here - before the record is persisted or any interactive authorization begins -
rather than surfacing later as an opaque failure at the token endpoint: a token-endpoint
auth method the authorization-code flow cannot apply (one it does not implement, or
`private_key_jwt`, whose assertion this flow has no key to sign), and a secret-based
method the flow could apply but for which the server issued no `client_secret`.
Raises:
OAuthRegistrationError: The server registered the client with a
`token_endpoint_auth_method` this flow cannot apply, or with a secret-based
method but no `client_secret`.
"""
method = client_info.token_endpoint_auth_method
if method not in _REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS:
raise OAuthRegistrationError(
f"Authorization server registered the client with unsupported token_endpoint_auth_method {method!r}"
)
if method in _SECRET_TOKEN_ENDPOINT_AUTH_METHODS and client_info.client_secret is None:
raise OAuthRegistrationError(
f"Authorization server registered the client for {method!r} but issued no client_secret"
)
class PKCEParameters(BaseModel):
"""PKCE (Proof Key for Code Exchange) parameters."""
@ -190,6 +240,12 @@ class OAuthContext:
Returns:
Tuple of (updated_data, updated_headers)
Raises:
OAuthTokenError: The client record carries a `token_endpoint_auth_method` this
client does not know. A dynamic registration assigning an unusable method is
rejected earlier, by `check_registration_usable`; this fires for a stored or
pre-registered record that reaches a token request with such a method.
"""
if headers is None:
headers = {} # pragma: no cover
@ -199,7 +255,7 @@ class OAuthContext:
auth_method = self.client_info.token_endpoint_auth_method
if auth_method == "client_secret_basic" and self.client_info.client_id and self.client_info.client_secret:
if auth_method == "client_secret_basic" and self.client_info.client_secret:
# URL-encode client ID and secret per RFC 6749 Section 2.3.1
encoded_id = quote(self.client_info.client_id, safe="")
encoded_secret = quote(self.client_info.client_secret, safe="")
@ -208,11 +264,14 @@ class OAuthContext:
headers["Authorization"] = f"Basic {encoded_credentials}"
# Don't include client_secret in body for basic auth
data = {k: v for k, v in data.items() if k != "client_secret"}
elif auth_method == "client_secret_post" and self.client_info.client_id and self.client_info.client_secret:
elif auth_method == "client_secret_post" and self.client_info.client_secret:
# Include client_id and client_secret in request body (RFC 6749 §2.3.1)
data["client_id"] = self.client_info.client_id
data["client_secret"] = self.client_info.client_secret
# For auth_method == "none", don't add any client_secret
elif auth_method not in _KNOWN_TOKEN_ENDPOINT_AUTH_METHODS:
raise OAuthTokenError(f"Registered client uses unsupported token_endpoint_auth_method {auth_method!r}")
# For "none" (or absent), don't add any client_secret; "private_key_jwt" adds its
# assertion in the provider that implements it, not here.
return data, headers
@ -664,6 +723,7 @@ class OAuthClientProvider(httpx2.Auth):
)
registration_response = yield registration_request
client_information = await handle_registration_response(registration_response)
check_registration_usable(client_information)
# Only record the issuer when the registration above actually targeted
# the discovered AS — either via its published registration_endpoint,
# or because the resource-origin /register fallback is on the issuer's

View file

@ -1,9 +1,11 @@
import re
from typing import Any, cast
from urllib.parse import urljoin, urlparse
from httpx2 import Request, Response
from mcp_types import LATEST_PROTOCOL_VERSION
from pydantic import AnyUrl, ValidationError
from pydantic_core import from_json
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
from mcp.shared.auth import (
@ -299,10 +301,17 @@ async def handle_registration_response(response: Response) -> OAuthClientInforma
try:
content = await response.aread()
client_info = OAuthClientInformationFull.model_validate_json(content)
return client_info
except ValidationError as e: # pragma: no cover
raise OAuthRegistrationError(f"Invalid registration response: {e}")
body = from_json(content)
# `issuer` is the SDK's own binding of these credentials to the server they were
# registered with (SEP-2352), stamped by the auth flow - never sourced from the
# wire, so it is dropped before the body is parsed rather than trusted or cleared.
if isinstance(body, dict):
cast(dict[str, Any], body).pop("issuer", None)
return OAuthClientInformationFull.model_validate(body)
except ValueError as e:
# `from_json` reports malformed bytes/JSON as ValueError, and pydantic's
# ValidationError is itself a ValueError, so both parse layers surface here.
raise OAuthRegistrationError(f"Invalid registration response: {e}") from e
def is_valid_client_metadata_url(url: str | None) -> bool:
@ -381,8 +390,8 @@ def create_client_info_from_metadata_url(
Args:
client_metadata_url: The URL to use as the client_id
redirect_uris: The redirect URIs from the client metadata (passed through for
compatibility with OAuthClientInformationFull which inherits from OAuthClientMetadata)
redirect_uris: The redirect URIs from the client metadata, recorded on the client
information alongside the client_id
Returns:
OAuthClientInformationFull with the URL as client_id

View file

@ -50,6 +50,18 @@ class RegistrationHandler:
# If auth method is None, default to client_secret_post
if client_metadata.token_endpoint_auth_method is None:
client_metadata.token_endpoint_auth_method = "client_secret_post"
# This server authenticates token requests with the client secret it mints; it holds
# no client key to verify a private_key_jwt assertion, so confirming that method would
# register a client whose every token request is then rejected. Refuse it instead
# (RFC 7591 §3.2.2), before minting credentials the client could never use.
if client_metadata.token_endpoint_auth_method == "private_key_jwt":
return PydanticJSONResponse(
content=RegistrationErrorResponse(
error="invalid_client_metadata",
error_description="token_endpoint_auth_method 'private_key_jwt' is not supported",
),
status_code=400,
)
client_secret = None
if client_metadata.token_endpoint_auth_method != "none": # pragma: no branch
@ -106,33 +118,27 @@ class RegistrationHandler:
)
client_id_issued_at = int(time.time())
client_secret_expires_at = (
client_id_issued_at + self.options.client_secret_expiry_seconds
if self.options.client_secret_expiry_seconds is not None
else None
)
# RFC 7591 §3.2.1: client_secret_expires_at is REQUIRED whenever a client_secret is
# issued, with 0 (not omission) meaning it never expires; a public client gets none.
client_secret_expires_at = None
if client_secret is not None:
client_secret_expires_at = (
client_id_issued_at + self.options.client_secret_expiry_seconds
if self.options.client_secret_expiry_seconds is not None
else 0
)
client_info = OAuthClientInformationFull(
client_id=client_id,
client_id_issued_at=client_id_issued_at,
client_secret=client_secret,
client_secret_expires_at=client_secret_expires_at,
# passthrough information from the client request
redirect_uris=client_metadata.redirect_uris,
token_endpoint_auth_method=client_metadata.token_endpoint_auth_method,
grant_types=client_metadata.grant_types,
response_types=client_metadata.response_types,
client_name=client_metadata.client_name,
client_uri=client_metadata.client_uri,
logo_uri=client_metadata.logo_uri,
scope=client_metadata.scope,
contacts=client_metadata.contacts,
tos_uri=client_metadata.tos_uri,
policy_uri=client_metadata.policy_uri,
jwks_uri=client_metadata.jwks_uri,
jwks=client_metadata.jwks,
software_id=client_metadata.software_id,
software_version=client_metadata.software_version,
# RFC 7591 §3.2.1: the response returns all registered metadata about the client, so
# the record is the whole validated request plus the credentials minted here - built
# from the request's dump so no metadata field can be silently omitted from the echo.
client_info = OAuthClientInformationFull.model_validate(
{
**client_metadata.model_dump(),
"client_id": client_id,
"client_id_issued_at": client_id_issued_at,
"client_secret": client_secret,
"client_secret_expires_at": client_secret_expires_at,
}
)
try:
# Register client

View file

@ -1,10 +1,27 @@
from typing import Any, Literal
from typing import Any, Literal, cast
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator, model_validator
# RFC 7523 JWT bearer grant; SEP-990 leg 2 uses this to present the ID-JAG.
JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
# Token-endpoint client authentication methods this SDK's clients request, and the set
# `OAuthContext.prepare_token_auth` recognizes on a registered client (`private_key_jwt` is
# applied by `PrivateKeyJWTOAuthProvider`; the rest send a client secret or nothing).
TokenEndpointAuthMethod = Literal["none", "client_secret_post", "client_secret_basic", "private_key_jwt"]
# grant_types a client requests when it does not specify its own (RFC 7591 §2).
DEFAULT_GRANT_TYPES = ["authorization_code", "refresh_token"]
def _empty_str_to_none(v: object) -> object:
# RFC 7591 §2 marks these URL fields OPTIONAL; a "" placeholder means absent, so it
# must not fail AnyHttpUrl validation. (The registered-client record applies the same
# rule to every member; this coercion serves the request model.)
if v == "":
return None
return v
class OAuthToken(BaseModel):
"""See https://datatracker.ietf.org/doc/html/rfc6749#section-5.1"""
@ -47,32 +64,21 @@ class InvalidRedirectUriError(Exception):
self.message = message
class OAuthClientMetadata(BaseModel):
"""RFC 7591 OAuth 2.0 Dynamic Client Registration Metadata.
class OAuthClientMetadataBase(BaseModel):
"""RFC 7591 OAuth 2.0 Dynamic Client Registration metadata shared verbatim by the
registration request (`OAuthClientMetadata`) and the authorization server's record of a
registered client (`OAuthClientInformationFull`). Fields whose acceptable values differ
between the two - what this SDK sends versus what a third-party server may echo - are
declared on each model rather than here.
See https://datatracker.ietf.org/doc/html/rfc7591#section-2
"""
model_config = ConfigDict(url_preserve_empty_path=True)
redirect_uris: list[AnyUrl] | None = Field(..., min_length=1)
# supported auth methods for the token endpoint
token_endpoint_auth_method: (
Literal["none", "client_secret_post", "client_secret_basic", "private_key_jwt"] | None
) = None
# supported grant_types of this implementation
grant_types: list[
Literal["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"] | str
] = [
"authorization_code",
"refresh_token",
]
# The MCP spec requires the "code" response type, but OAuth
# servers may also return additional types they support
response_types: list[str] = ["code"]
scope: str | None = None
# SEP-837: OIDC application_type. Defaults to "native" since MCP clients typically use
# loopback redirect URIs; set "web" for remote browser-based clients on a non-local host.
application_type: Literal["web", "native"] = "native"
# these fields are currently unused, but we support & store them for potential
# future use
@ -97,13 +103,76 @@ class OAuthClientMetadata(BaseModel):
)
@classmethod
def _empty_string_optional_url_to_none(cls, v: object) -> object:
# RFC 7591 §2 marks these URL fields OPTIONAL. Some authorization servers
# echo omitted metadata back as "" instead of dropping the keys, which
# AnyHttpUrl would otherwise reject — throwing away an otherwise valid
# registration response. Treat "" as absent.
if v == "":
return None
return v
# These URL fields are OPTIONAL; an echoed "" would otherwise fail AnyHttpUrl
# and throw away an otherwise valid registration response.
return _empty_str_to_none(v)
class OAuthClientMetadata(OAuthClientMetadataBase):
"""RFC 7591 OAuth 2.0 Dynamic Client Registration request metadata: what an MCP
client sends when it registers. Field values are narrowed to what this SDK will put
on the wire; parsing the authorization server's response is `OAuthClientInformationFull`'s
job. See https://datatracker.ietf.org/doc/html/rfc7591#section-2
"""
redirect_uris: list[AnyUrl] | None = Field(..., min_length=1)
# supported auth methods for the token endpoint
token_endpoint_auth_method: TokenEndpointAuthMethod | None = None
# supported grant_types of this implementation
grant_types: list[
Literal["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"] | str
] = list(DEFAULT_GRANT_TYPES)
# SEP-837: OIDC application_type. Defaults to "native" since MCP clients typically use
# loopback redirect URIs; set "web" for remote browser-based clients on a non-local host.
application_type: Literal["web", "native"] = "native"
class OAuthClientInformationFull(OAuthClientMetadataBase):
"""RFC 7591 OAuth 2.0 Dynamic Client Registration client information response
(client information plus metadata) - the authorization server's record of a
registered client. See https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1
A third-party authorization server "MAY reject or replace any of the client's
requested metadata values submitted during the registration and substitute them with
suitable values", so `application_type`, `token_endpoint_auth_method`, and `grant_types`
are typed to accept any string the server echoes, and `redirect_uris` may be absent or
empty. A member the server serializes as a placeholder - an explicit `null`, or `""` -
is read as an omitted key, so the field's default applies rather than the parse failing.
Whether a substituted value is usable is decided where the value is used, not at parse.
`redirect_uris` elements are still parsed as URLs, as the authorization server compares
them against a client's requested `redirect_uri`.
"""
redirect_uris: list[AnyUrl] | None = None
# RFC 7591 §3.2.1: the server may assign an auth method other than the one requested,
# including methods this SDK does not implement, or omit it.
token_endpoint_auth_method: str | None = None
grant_types: list[str] = list(DEFAULT_GRANT_TYPES)
# SEP-837: OIDC application_type. OIDC Registration §2 defines "web" and "native", but
# servers echo other strings or an explicit null; the value is informational here.
application_type: str | None = None
# RFC 7591 §3.2.1: client_id is REQUIRED in a client information response - a body
# without one is not a registration, whatever else it echoes.
client_id: str
client_secret: str | None = None
client_id_issued_at: int | None = None
client_secret_expires_at: int | None = None
# SEP-2352: the issuer these credentials were registered with, recorded by the SDK (not an
# RFC 7591 field) to detect authorization-server migration and avoid cross-AS credential reuse.
issuer: str | None = None
@model_validator(mode="before")
@classmethod
def _placeholder_members_read_as_omitted(cls, data: object) -> object:
# Servers dump unset members of their client record as null, or echo them as "",
# instead of omitting the keys. Either placeholder would otherwise fail the parse of a
# list field (or read "" as an unrecognized method) and discard an already-provisioned
# registration; a placeholder and an absent key mean the same thing.
if isinstance(data, dict):
members = cast(dict[str, Any], data)
return {key: value for key, value in members.items() if value is not None and value != ""}
return data
def validate_scope(self, requested_scope: str | None) -> list[str] | None:
if requested_scope is None:
@ -118,27 +187,15 @@ class OAuthClientMetadata(BaseModel):
def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
if redirect_uri is not None:
# Validate redirect_uri against client's registered redirect URIs
if self.redirect_uris is None or redirect_uri not in self.redirect_uris:
if not self.redirect_uris or redirect_uri not in self.redirect_uris:
raise InvalidRedirectUriError(f"Redirect URI '{redirect_uri}' not registered for client")
return redirect_uri
elif self.redirect_uris is not None and len(self.redirect_uris) == 1:
elif self.redirect_uris and len(self.redirect_uris) == 1:
return self.redirect_uris[0]
else:
raise InvalidRedirectUriError("redirect_uri must be specified when client has multiple registered URIs")
class OAuthClientInformationFull(OAuthClientMetadata):
"""RFC 7591 OAuth 2.0 Dynamic Client Registration full response
(client information plus metadata).
"""
client_id: str | None = None
client_secret: str | None = None
client_id_issued_at: int | None = None
client_secret_expires_at: int | None = None
# SEP-2352: the issuer these credentials were registered with, recorded by the SDK (not an
# RFC 7591 field) to detect authorization-server migration and avoid cross-AS credential reuse.
issuer: str | None = None
raise InvalidRedirectUriError(
"redirect_uri must be specified unless the client has exactly one registered URI"
)
class OAuthMetadata(BaseModel):