6b0166f3d1
OAuthClientInformationFull, the client's parse of the authorization server's Dynamic Client Registration response, inherited from OAuthClientMetadata, the request the client sends. That typed the response as though it had to be a request this SDK would send. RFC 7591 3.2.1 says otherwise: the server may reject or replace any requested metadata value, and real servers echo an application_type outside OIDC Registration's web/native, an explicit null, an auth method the SDK does not implement, or an empty redirect_uris. Each raised ValidationError on a 2xx response, after the server had already provisioned the client, so the registration was discarded and orphaned. Make the two models siblings over a shared OAuthClientMetadataBase. The request keeps its strict types, so the SDK still refuses to send an unregistered application_type. The record accepts what a server may echo: application_type and token_endpoint_auth_method are str | None (with an echoed "" read as absent, as the optional URL fields already were), grant_types is list[str], and redirect_uris may be absent or empty. client_id is now required, as RFC 7591 3.2.1 makes it in the response. Whether a substituted value is usable is judged where it matters, not at parse: an auth method the client cannot apply is reported as an OAuthRegistrationError when the registration completes, before the record is stored or any interactive authorization begins, and prepare_token_auth reports the same for a stored record. The recognized set is derived from the one TokenEndpointAuthMethod type so the two cannot drift. The bundled registration endpoint now returns all registered metadata in its 201 response, building the record from the validated request's dump so a field can no longer be silently dropped from the echo; it previously omitted application_type, reporting the default in place of a client's "web".
222 lines
9.0 KiB
Python
222 lines
9.0 KiB
Python
"""Tests for OAuth 2.0 shared code."""
|
|
|
|
import pytest
|
|
from pydantic import AnyUrl, ValidationError
|
|
|
|
from mcp.shared.auth import InvalidRedirectUriError, OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata
|
|
|
|
|
|
def test_oauth():
|
|
"""Should not throw when parsing OAuth metadata."""
|
|
OAuthMetadata.model_validate(
|
|
{
|
|
"issuer": "https://example.com",
|
|
"authorization_endpoint": "https://example.com/oauth2/authorize",
|
|
"token_endpoint": "https://example.com/oauth2/token",
|
|
"scopes_supported": ["read", "write"],
|
|
"response_types_supported": ["code", "token"],
|
|
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
|
|
}
|
|
)
|
|
|
|
|
|
def test_oidc():
|
|
"""Should not throw when parsing OIDC metadata."""
|
|
OAuthMetadata.model_validate(
|
|
{
|
|
"issuer": "https://example.com",
|
|
"authorization_endpoint": "https://example.com/oauth2/authorize",
|
|
"token_endpoint": "https://example.com/oauth2/token",
|
|
"end_session_endpoint": "https://example.com/logout",
|
|
"id_token_signing_alg_values_supported": ["RS256"],
|
|
"jwks_uri": "https://example.com/.well-known/jwks.json",
|
|
"response_types_supported": ["code", "token"],
|
|
"revocation_endpoint": "https://example.com/oauth2/revoke",
|
|
"scopes_supported": ["openid", "read", "write"],
|
|
"subject_types_supported": ["public"],
|
|
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
|
|
"userinfo_endpoint": "https://example.com/oauth2/userInfo",
|
|
}
|
|
)
|
|
|
|
|
|
def test_oauth_with_jarm():
|
|
"""Should not throw when parsing OAuth metadata that includes JARM response modes."""
|
|
OAuthMetadata.model_validate(
|
|
{
|
|
"issuer": "https://example.com",
|
|
"authorization_endpoint": "https://example.com/oauth2/authorize",
|
|
"token_endpoint": "https://example.com/oauth2/token",
|
|
"scopes_supported": ["read", "write"],
|
|
"response_types_supported": ["code", "token"],
|
|
"response_modes_supported": [
|
|
"query",
|
|
"fragment",
|
|
"form_post",
|
|
"query.jwt",
|
|
"fragment.jwt",
|
|
"form_post.jwt",
|
|
"jwt",
|
|
],
|
|
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
|
|
}
|
|
)
|
|
|
|
|
|
# RFC 7591 §2 marks client_uri/logo_uri/tos_uri/policy_uri/jwks_uri as OPTIONAL.
|
|
# Some authorization servers echo the client's omitted metadata back as ""
|
|
# instead of dropping the keys; without coercion, AnyHttpUrl rejects "" and
|
|
# the whole registration response is thrown away even though the server
|
|
# returned a valid client_id.
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"empty_field",
|
|
["client_uri", "logo_uri", "tos_uri", "policy_uri", "jwks_uri"],
|
|
)
|
|
def test_optional_url_empty_string_coerced_to_none(empty_field: str):
|
|
data = {
|
|
"redirect_uris": ["https://example.com/callback"],
|
|
empty_field: "",
|
|
}
|
|
metadata = OAuthClientMetadata.model_validate(data)
|
|
assert getattr(metadata, empty_field) is None
|
|
|
|
|
|
def test_all_optional_urls_empty_together():
|
|
data = {
|
|
"redirect_uris": ["https://example.com/callback"],
|
|
"client_uri": "",
|
|
"logo_uri": "",
|
|
"tos_uri": "",
|
|
"policy_uri": "",
|
|
"jwks_uri": "",
|
|
}
|
|
metadata = OAuthClientMetadata.model_validate(data)
|
|
assert metadata.client_uri is None
|
|
assert metadata.logo_uri is None
|
|
assert metadata.tos_uri is None
|
|
assert metadata.policy_uri is None
|
|
assert metadata.jwks_uri is None
|
|
|
|
|
|
def test_valid_url_passes_through_unchanged():
|
|
data = {
|
|
"redirect_uris": ["https://example.com/callback"],
|
|
"client_uri": "https://udemy.com/",
|
|
}
|
|
metadata = OAuthClientMetadata.model_validate(data)
|
|
assert str(metadata.client_uri) == "https://udemy.com/"
|
|
|
|
|
|
def test_information_full_inherits_coercion():
|
|
"""OAuthClientInformationFull shares the metadata base, so the same
|
|
coercion applies to DCR responses parsed via the full model."""
|
|
data = {
|
|
"client_id": "abc123",
|
|
"redirect_uris": ["https://example.com/callback"],
|
|
"client_uri": "",
|
|
"logo_uri": "",
|
|
"tos_uri": "",
|
|
"policy_uri": "",
|
|
"jwks_uri": "",
|
|
}
|
|
info = OAuthClientInformationFull.model_validate(data)
|
|
assert info.client_id == "abc123"
|
|
assert info.client_uri is None
|
|
assert info.logo_uri is None
|
|
assert info.tos_uri is None
|
|
assert info.policy_uri is None
|
|
assert info.jwks_uri is None
|
|
|
|
|
|
# RFC 7591 §3.2.1 lets the authorization server reject or replace any requested metadata
|
|
# value in its registration response. Real servers echo values outside the sets the client
|
|
# would send (an unregistered application_type, an explicit null, an auth method the SDK
|
|
# does not implement, an empty redirect_uris array); a parse failure there discards a
|
|
# registration whose client_id the server has already provisioned.
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"substituted",
|
|
[
|
|
pytest.param({"application_type": "confidential"}, id="unregistered-application-type"),
|
|
pytest.param({"application_type": ""}, id="empty-application-type"),
|
|
pytest.param({"application_type": None}, id="null-application-type"),
|
|
pytest.param({"token_endpoint_auth_method": "client_secret_jwt"}, id="unimplemented-auth-method"),
|
|
pytest.param({"grant_types": ["authorization_code", "client_credentials"]}, id="extra-grant-type"),
|
|
pytest.param({"redirect_uris": []}, id="empty-redirect-uris"),
|
|
],
|
|
)
|
|
def test_client_information_accepts_server_substituted_metadata(substituted: dict[str, object]):
|
|
data = {"client_id": "abc123", "client_secret": "s3cr3t", **substituted}
|
|
info = OAuthClientInformationFull.model_validate(data)
|
|
assert info.client_id == "abc123"
|
|
assert info.client_secret == "s3cr3t"
|
|
|
|
|
|
def test_client_information_without_echoed_metadata_still_parses():
|
|
"""A response holding only the credentials the server minted is a usable registration."""
|
|
info = OAuthClientInformationFull.model_validate({"client_id": "abc123"})
|
|
assert info.client_id == "abc123"
|
|
assert info.redirect_uris is None
|
|
assert info.application_type is None
|
|
|
|
|
|
def test_every_request_metadata_field_exists_on_the_client_record():
|
|
"""The registration handler builds its 201 echo from the request's dump; every request
|
|
field must exist on the record so none can be silently dropped from the response."""
|
|
assert set(OAuthClientMetadata.model_fields) <= set(OAuthClientInformationFull.model_fields)
|
|
|
|
|
|
def test_a_registration_response_without_a_client_id_is_rejected():
|
|
"""RFC 7591 §3.2.1 makes client_id REQUIRED; a body without one is not a registration,
|
|
however permissive the parse is about the metadata around it."""
|
|
with pytest.raises(ValidationError):
|
|
OAuthClientInformationFull.model_validate({"application_type": "web"})
|
|
|
|
|
|
@pytest.mark.parametrize("empty_field", ["token_endpoint_auth_method", "application_type"])
|
|
def test_client_information_empty_string_metadata_coerced_to_absent(empty_field: str):
|
|
"""An echoed "" reads as absent, matching the URL-field coercion, so it is neither
|
|
stored as a value nor later mistaken for an unrecognized method or type."""
|
|
info = OAuthClientInformationFull.model_validate({"client_id": "abc123", empty_field: ""})
|
|
assert getattr(info, empty_field) is None
|
|
|
|
|
|
@pytest.mark.parametrize("redirect_uris", [None, []], ids=["absent", "empty"])
|
|
@pytest.mark.parametrize(
|
|
"redirect_uri", [None, AnyUrl("https://example.com/callback")], ids=["unspecified", "specified"]
|
|
)
|
|
def test_client_with_no_registered_redirect_uris_cannot_resolve_a_redirect(
|
|
redirect_uris: list[str] | None, redirect_uri: AnyUrl | None
|
|
):
|
|
"""With no registered redirect URIs (absent or empty), no redirect resolves - neither a
|
|
supplied one (nothing to match against) nor an unspecified one (no single default)."""
|
|
info = OAuthClientInformationFull.model_validate({"client_id": "abc123", "redirect_uris": redirect_uris})
|
|
with pytest.raises(InvalidRedirectUriError):
|
|
info.validate_redirect_uri(redirect_uri)
|
|
|
|
|
|
def test_request_metadata_restricts_application_type_to_the_values_the_sdk_sends():
|
|
"""What the SDK sends stays narrow even though what it accepts back is wide."""
|
|
with pytest.raises(ValidationError):
|
|
OAuthClientMetadata.model_validate(
|
|
{"redirect_uris": ["https://example.com/callback"], "application_type": "confidential"}
|
|
)
|
|
|
|
|
|
def test_request_metadata_requires_at_least_one_redirect_uri():
|
|
with pytest.raises(ValidationError):
|
|
OAuthClientMetadata.model_validate({"redirect_uris": []})
|
|
|
|
|
|
def test_invalid_non_empty_url_still_rejected():
|
|
"""Coercion must only touch empty strings — garbage URLs still raise."""
|
|
data = {
|
|
"redirect_uris": ["https://example.com/callback"],
|
|
"client_uri": "not a url",
|
|
}
|
|
with pytest.raises(ValidationError):
|
|
OAuthClientMetadata.model_validate(data)
|