Files

242 lines
10 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("placeholder", [None, ""], ids=["null", "empty-string"])
@pytest.mark.parametrize(
"member",
["grant_types", "response_types", "redirect_uris", "application_type", "token_endpoint_auth_method", "scope"],
)
def test_client_information_reads_a_placeholder_member_as_an_omitted_key(member: str, placeholder: object):
"""A server that dumps unset members as null, or echoes them as "", still yields a
usable registration: a placeholder and an absent key mean the same, so the field's
default applies - including for list fields, where the placeholder is not a valid list."""
info = OAuthClientInformationFull.model_validate({"client_id": "abc123", member: placeholder})
defaults = OAuthClientInformationFull.model_validate({"client_id": "abc123"})
assert getattr(info, member) == getattr(defaults, member)
def test_a_placeholder_client_id_is_a_missing_client_id():
"""The placeholder rule applies to the credential too: an empty client_id is no client_id,
so the body is rejected rather than parsing as a registration with an empty identifier."""
with pytest.raises(ValidationError):
OAuthClientInformationFull.model_validate({"client_id": ""})
def test_client_information_that_is_not_an_object_still_fails_the_parse():
"""The null-as-omitted coercion only touches JSON objects; a body that is not one is
passed through and rejected as a normal validation failure rather than swallowed."""
with pytest.raises(ValidationError):
OAuthClientInformationFull.model_validate("not-an-object")
@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)