|
10 | 10 | import httpx |
11 | 11 | import pytest |
12 | 12 | from inline_snapshot import Is, snapshot |
13 | | -from pydantic import AnyHttpUrl, AnyUrl |
| 13 | +from pydantic import AnyHttpUrl, AnyUrl, ValidationError |
14 | 14 |
|
15 | 15 | from mcp.client.auth import OAuthClientProvider, PKCEParameters |
16 | 16 | from mcp.client.auth.exceptions import OAuthFlowError |
@@ -857,6 +857,79 @@ def text(self): |
857 | 857 | assert "Registration failed: 400" in str(exc_info.value) |
858 | 858 |
|
859 | 859 |
|
| 860 | +class TestOAuthClientMetadataEmptyUrlCoercion: |
| 861 | + """RFC 7591 §2 marks client_uri/logo_uri/tos_uri/policy_uri/jwks_uri as OPTIONAL. |
| 862 | + Some authorization servers echo the client's omitted metadata back as "" |
| 863 | + instead of dropping the keys; without coercion, AnyHttpUrl rejects "" and |
| 864 | + the whole registration response is thrown away even though the server |
| 865 | + returned a valid client_id.""" |
| 866 | + |
| 867 | + @pytest.mark.parametrize( |
| 868 | + "empty_field", |
| 869 | + ["client_uri", "logo_uri", "tos_uri", "policy_uri", "jwks_uri"], |
| 870 | + ) |
| 871 | + def test_optional_url_empty_string_coerced_to_none(self, empty_field: str): |
| 872 | + data = { |
| 873 | + "redirect_uris": ["https://example.com/callback"], |
| 874 | + empty_field: "", |
| 875 | + } |
| 876 | + metadata = OAuthClientMetadata.model_validate(data) |
| 877 | + assert getattr(metadata, empty_field) is None |
| 878 | + |
| 879 | + def test_all_optional_urls_empty_together(self): |
| 880 | + data = { |
| 881 | + "redirect_uris": ["https://example.com/callback"], |
| 882 | + "client_uri": "", |
| 883 | + "logo_uri": "", |
| 884 | + "tos_uri": "", |
| 885 | + "policy_uri": "", |
| 886 | + "jwks_uri": "", |
| 887 | + } |
| 888 | + metadata = OAuthClientMetadata.model_validate(data) |
| 889 | + assert metadata.client_uri is None |
| 890 | + assert metadata.logo_uri is None |
| 891 | + assert metadata.tos_uri is None |
| 892 | + assert metadata.policy_uri is None |
| 893 | + assert metadata.jwks_uri is None |
| 894 | + |
| 895 | + def test_valid_url_passes_through_unchanged(self): |
| 896 | + data = { |
| 897 | + "redirect_uris": ["https://example.com/callback"], |
| 898 | + "client_uri": "https://udemy.com/", |
| 899 | + } |
| 900 | + metadata = OAuthClientMetadata.model_validate(data) |
| 901 | + assert str(metadata.client_uri) == "https://udemy.com/" |
| 902 | + |
| 903 | + def test_information_full_inherits_coercion(self): |
| 904 | + """OAuthClientInformationFull subclasses OAuthClientMetadata, so the |
| 905 | + same coercion applies to DCR responses parsed via the full model.""" |
| 906 | + data = { |
| 907 | + "client_id": "abc123", |
| 908 | + "redirect_uris": ["https://example.com/callback"], |
| 909 | + "client_uri": "", |
| 910 | + "logo_uri": "", |
| 911 | + "tos_uri": "", |
| 912 | + "policy_uri": "", |
| 913 | + "jwks_uri": "", |
| 914 | + } |
| 915 | + info = OAuthClientInformationFull.model_validate(data) |
| 916 | + assert info.client_id == "abc123" |
| 917 | + assert info.client_uri is None |
| 918 | + assert info.logo_uri is None |
| 919 | + assert info.tos_uri is None |
| 920 | + assert info.policy_uri is None |
| 921 | + assert info.jwks_uri is None |
| 922 | + |
| 923 | + def test_invalid_non_empty_url_still_rejected(self): |
| 924 | + """Coercion must only touch empty strings — garbage URLs still raise.""" |
| 925 | + data = { |
| 926 | + "redirect_uris": ["https://example.com/callback"], |
| 927 | + "client_uri": "not a url", |
| 928 | + } |
| 929 | + with pytest.raises(ValidationError): |
| 930 | + OAuthClientMetadata.model_validate(data) |
| 931 | + |
| 932 | + |
860 | 933 | class TestCreateClientRegistrationRequest: |
861 | 934 | """Test client registration request creation.""" |
862 | 935 |
|
|
0 commit comments