Split the registration request model from the registered-client record (#3181)
This commit is contained in:
parent
b9422f1c9b
commit
d3ffe87960
1 changed files with 573 additions and 129 deletions
|
|
@ -1,9 +1,9 @@
|
|||
"""Tests for OAuth 2.0 shared code."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from pydantic import AnyUrl, ValidationError
|
||||
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata
|
||||
from mcp.shared.auth import InvalidRedirectUriError, OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata
|
||||
|
||||
|
||||
def test_oauth():
|
||||
|
|
@ -110,8 +110,8 @@ def test_valid_url_passes_through_unchanged():
|
|||
|
||||
|
||||
def test_information_full_inherits_coercion():
|
||||
"""OAuthClientInformationFull subclasses OAuthClientMetadata, so the
|
||||
same coercion applies to DCR responses parsed via the full model."""
|
||||
"""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"],
|
||||
|
|
@ -130,6 +130,107 @@ def test_information_full_inherits_coercion():
|
|||
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 = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue