|
1 | 1 | """Tests for OAuth proxy client registration (DCR).""" |
2 | 2 |
|
| 3 | +import pytest |
3 | 4 | from mcp.shared.auth import OAuthClientInformationFull |
4 | 5 | from pydantic import AnyUrl |
5 | 6 |
|
| 7 | +from fastmcp.server.auth.oauth_proxy.models import InvalidRedirectUriError |
| 8 | + |
6 | 9 |
|
7 | 10 | class TestOAuthProxyClientRegistration: |
8 | 11 | """Tests for OAuth proxy client registration (DCR).""" |
@@ -67,3 +70,81 @@ async def test_enforcing_allowed_redirect_uris(self, oauth_proxy): |
67 | 70 | assert retrieved.allowed_redirect_uri_patterns == [ |
68 | 71 | "http://localhost:12345/updated_callback" |
69 | 72 | ] |
| 73 | + |
| 74 | + |
| 75 | +class TestUpstreamClientIdFallback: |
| 76 | + """Tests for clients that skip DCR and use the upstream client_id directly.""" |
| 77 | + |
| 78 | + async def test_upstream_client_id_returns_synthetic_client(self, oauth_proxy): |
| 79 | + """Clients that skip DCR and use upstream client_id directly are accepted.""" |
| 80 | + # oauth_proxy fixture uses "test-client-id" as upstream_client_id |
| 81 | + client = await oauth_proxy.get_client("test-client-id") |
| 82 | + assert client is not None |
| 83 | + assert client.client_id == "test-client-id" |
| 84 | + assert client.client_secret is None |
| 85 | + assert client.token_endpoint_auth_method == "none" |
| 86 | + |
| 87 | + async def test_upstream_client_id_inherits_allowed_redirect_uris(self, oauth_proxy): |
| 88 | + """Synthetic upstream client respects the proxy's redirect URI restrictions.""" |
| 89 | + oauth_proxy._allowed_client_redirect_uris = ["http://localhost:*"] |
| 90 | + client = await oauth_proxy.get_client("test-client-id") |
| 91 | + assert client is not None |
| 92 | + assert client.allowed_redirect_uri_patterns == ["http://localhost:*"] |
| 93 | + |
| 94 | + async def test_unknown_client_id_still_returns_none(self, oauth_proxy): |
| 95 | + """Non-upstream, unregistered IDs still return None.""" |
| 96 | + client = await oauth_proxy.get_client("some-random-client-id") |
| 97 | + assert client is None |
| 98 | + |
| 99 | + async def test_redirect_uri_allowed_when_no_pattern_restriction(self, oauth_proxy): |
| 100 | + """Any redirect URI is accepted when allowed_client_redirect_uris is None.""" |
| 101 | + assert oauth_proxy._allowed_client_redirect_uris is None |
| 102 | + client = await oauth_proxy.get_client("test-client-id") |
| 103 | + assert client is not None |
| 104 | + uri = client.validate_redirect_uri(AnyUrl("https://claude.ai/oauth/callback")) |
| 105 | + assert str(uri) == "https://claude.ai/oauth/callback" |
| 106 | + |
| 107 | + async def test_redirect_uri_validated_against_patterns(self, oauth_proxy): |
| 108 | + """Redirect URI validation honours allowed_client_redirect_uris when set.""" |
| 109 | + oauth_proxy._allowed_client_redirect_uris = ["http://localhost:*"] |
| 110 | + client = await oauth_proxy.get_client("test-client-id") |
| 111 | + assert client is not None |
| 112 | + |
| 113 | + # Allowed URI passes |
| 114 | + uri = client.validate_redirect_uri(AnyUrl("http://localhost:12345/callback")) |
| 115 | + assert str(uri) == "http://localhost:12345/callback" |
| 116 | + |
| 117 | + # Disallowed URI raises |
| 118 | + with pytest.raises(InvalidRedirectUriError): |
| 119 | + client.validate_redirect_uri(AnyUrl("https://evil.example.com/callback")) |
| 120 | + |
| 121 | + async def test_redirect_uri_blocked_when_empty_allowlist(self, oauth_proxy): |
| 122 | + """Empty allowed_client_redirect_uris blocks all redirect URIs, including localhost.""" |
| 123 | + oauth_proxy._allowed_client_redirect_uris = [] |
| 124 | + client = await oauth_proxy.get_client("test-client-id") |
| 125 | + assert client is not None |
| 126 | + |
| 127 | + with pytest.raises(InvalidRedirectUriError): |
| 128 | + client.validate_redirect_uri(AnyUrl("http://localhost/callback")) |
| 129 | + |
| 130 | + with pytest.raises(InvalidRedirectUriError): |
| 131 | + client.validate_redirect_uri(AnyUrl("https://claude.ai/oauth/callback")) |
| 132 | + |
| 133 | + async def test_none_redirect_uri_validated_against_patterns(self, oauth_proxy): |
| 134 | + """redirect_uri=None resolves to the placeholder then validates against patterns.""" |
| 135 | + # Placeholder is http://localhost — a pattern that can't match it forces rejection. |
| 136 | + oauth_proxy._allowed_client_redirect_uris = ["https://myapp.example.com/*"] |
| 137 | + client = await oauth_proxy.get_client("test-client-id") |
| 138 | + assert client is not None |
| 139 | + |
| 140 | + with pytest.raises(InvalidRedirectUriError): |
| 141 | + client.validate_redirect_uri(None) |
| 142 | + |
| 143 | + async def test_none_redirect_uri_rejected_when_empty_allowlist(self, oauth_proxy): |
| 144 | + """redirect_uri=None is rejected when allowlist is empty ([] blocks the resolved URI too).""" |
| 145 | + oauth_proxy._allowed_client_redirect_uris = [] |
| 146 | + client = await oauth_proxy.get_client("test-client-id") |
| 147 | + assert client is not None |
| 148 | + |
| 149 | + with pytest.raises(InvalidRedirectUriError): |
| 150 | + client.validate_redirect_uri(None) |
0 commit comments