mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-17 19:19:12 +02:00
520 lines
21 KiB
Python
520 lines
21 KiB
Python
"""Tests for OAuth proxy client registration (DCR)."""
|
|
|
|
import httpx2
|
|
import pytest
|
|
from mcp.server.auth.provider import RegistrationError
|
|
from mcp.shared.auth import OAuthClientInformationFull
|
|
from pydantic import AnyUrl
|
|
from starlette.applications import Starlette
|
|
|
|
from fastmcp.server.auth.oauth_proxy.models import InvalidRedirectUriError
|
|
|
|
|
|
class TestOAuthProxyClientRegistration:
|
|
"""Tests for OAuth proxy client registration (DCR)."""
|
|
|
|
async def test_register_client(self, oauth_proxy):
|
|
"""Test client registration creates ProxyDCRClient."""
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="original-client",
|
|
client_secret="original-secret",
|
|
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
|
)
|
|
|
|
await oauth_proxy.register_client(client_info)
|
|
|
|
# Client should be retrievable with original credentials
|
|
stored = await oauth_proxy.get_client("original-client")
|
|
assert stored is not None
|
|
assert stored.client_id == "original-client"
|
|
# Proxy uses token_endpoint_auth_method="none", so client_secret is not stored
|
|
assert stored.client_secret is None
|
|
|
|
async def test_register_client_allows_external_https_by_default(self, oauth_proxy):
|
|
"""Default DCR registration accepts ordinary external HTTPS callbacks."""
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="https-client",
|
|
client_secret="original-secret",
|
|
redirect_uris=[AnyUrl("https://client.example.com/callback")],
|
|
)
|
|
|
|
await oauth_proxy.register_client(client_info)
|
|
|
|
stored = await oauth_proxy.get_client("https-client")
|
|
assert stored is not None
|
|
assert stored.redirect_uris == [AnyUrl("https://client.example.com/callback")]
|
|
|
|
async def test_register_client_rejects_unsafe_redirect_scheme_by_default(
|
|
self, oauth_proxy
|
|
):
|
|
"""Default DCR registration rejects active browser redirect schemes."""
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="javascript-client",
|
|
client_secret="original-secret",
|
|
redirect_uris=[AnyUrl("javascript:alert(document.cookie)//")],
|
|
)
|
|
|
|
with pytest.raises(RegistrationError, match="invalid_redirect_uri"):
|
|
await oauth_proxy.register_client(client_info)
|
|
|
|
async def test_register_client_without_redirect_uris_defers_allowlist_validation(
|
|
self, oauth_proxy
|
|
):
|
|
"""DCR clients may omit redirect_uris until the authorization request."""
|
|
oauth_proxy._allowed_client_redirect_uris = ["https://client.example/*"]
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="deferred-client",
|
|
client_secret="original-secret",
|
|
redirect_uris=None,
|
|
)
|
|
|
|
await oauth_proxy.register_client(client_info)
|
|
|
|
stored = await oauth_proxy.get_client("deferred-client")
|
|
assert stored is not None
|
|
assert stored.redirect_uris is not None
|
|
assert str(stored.redirect_uris[0]).rstrip("/") == "http://localhost"
|
|
|
|
redirect_uri = stored.validate_redirect_uri(
|
|
AnyUrl("https://client.example/callback")
|
|
)
|
|
assert str(redirect_uri) == "https://client.example/callback"
|
|
|
|
with pytest.raises(InvalidRedirectUriError):
|
|
stored.validate_redirect_uri(None)
|
|
|
|
async def test_get_registered_client(self, oauth_proxy):
|
|
"""Test retrieving a registered client."""
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="test-client",
|
|
client_secret="test-secret",
|
|
redirect_uris=[AnyUrl("http://localhost:8080/callback")],
|
|
)
|
|
await oauth_proxy.register_client(client_info)
|
|
|
|
retrieved = await oauth_proxy.get_client("test-client")
|
|
assert retrieved is not None
|
|
assert retrieved.client_id == "test-client"
|
|
|
|
async def test_get_unregistered_client_returns_none(self, oauth_proxy):
|
|
"""Test that unregistered clients return None."""
|
|
client = await oauth_proxy.get_client("unknown-client")
|
|
assert client is None
|
|
|
|
async def test_dcr_client_rejects_unregistered_redirect_uri(self, oauth_proxy):
|
|
"""DCR clients honor their registered redirect_uris by default."""
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="original-client",
|
|
client_secret="original-secret",
|
|
redirect_uris=[AnyUrl("http://localhost:6274/oauth/callback")],
|
|
)
|
|
|
|
await oauth_proxy.register_client(client_info)
|
|
|
|
retrieved = await oauth_proxy.get_client("original-client")
|
|
assert retrieved is not None
|
|
|
|
with pytest.raises(InvalidRedirectUriError):
|
|
retrieved.validate_redirect_uri(AnyUrl("http://evil.com/anything"))
|
|
with pytest.raises(InvalidRedirectUriError):
|
|
retrieved.validate_redirect_uri(AnyUrl("http://localhost:6274/other"))
|
|
|
|
uri = retrieved.validate_redirect_uri(
|
|
AnyUrl("http://localhost:51353/oauth/callback")
|
|
)
|
|
assert str(uri) == "http://localhost:51353/oauth/callback"
|
|
|
|
async def test_dcr_client_accepts_registered_external_redirect_uri(
|
|
self, oauth_proxy
|
|
):
|
|
"""Open DCR still accepts arbitrary redirect URIs that clients register."""
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="external-client",
|
|
client_secret="external-secret",
|
|
redirect_uris=[AnyUrl("https://client.example.com/oauth/callback")],
|
|
)
|
|
|
|
await oauth_proxy.register_client(client_info)
|
|
|
|
retrieved = await oauth_proxy.get_client("external-client")
|
|
assert retrieved is not None
|
|
|
|
uri = retrieved.validate_redirect_uri(
|
|
AnyUrl("https://client.example.com/oauth/callback")
|
|
)
|
|
assert str(uri) == "https://client.example.com/oauth/callback"
|
|
|
|
async def test_enforcing_allowed_redirect_uris(self, oauth_proxy):
|
|
"""Test enforcing allowed redirect uris configuration."""
|
|
|
|
oauth_proxy._allowed_client_redirect_uris = ["http://localhost:12345/callback"]
|
|
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="original-client",
|
|
client_secret="original-secret",
|
|
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
|
)
|
|
|
|
await oauth_proxy.register_client(client_info)
|
|
retrieved = await oauth_proxy.get_client("original-client")
|
|
assert retrieved.allowed_redirect_uri_patterns == [
|
|
"http://localhost:12345/callback"
|
|
]
|
|
|
|
oauth_proxy._allowed_client_redirect_uris = [
|
|
"http://localhost:12345/updated_callback"
|
|
]
|
|
|
|
retrieved = await oauth_proxy.get_client("original-client")
|
|
assert retrieved.allowed_redirect_uri_patterns == [
|
|
"http://localhost:12345/updated_callback"
|
|
]
|
|
|
|
async def test_update_default_scopes_applies_to_dcr_registration(self, oauth_proxy):
|
|
"""DCR clients without scope should receive the updated default scopes."""
|
|
oauth_proxy.update_default_scopes(["read", "write", "calendar"])
|
|
|
|
app = Starlette(routes=oauth_proxy.get_routes())
|
|
transport = httpx2.ASGITransport(app=app)
|
|
|
|
async with httpx2.AsyncClient(
|
|
transport=transport,
|
|
base_url="https://myserver.com",
|
|
) as client:
|
|
response = await client.post(
|
|
"/register",
|
|
json={
|
|
"redirect_uris": ["https://client.example.com/callback"],
|
|
"client_name": "Test Client",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
client_info = response.json()
|
|
assert client_info["scope"] == "read write calendar"
|
|
|
|
registered_client = await oauth_proxy.get_client(client_info["client_id"])
|
|
assert registered_client is not None
|
|
assert registered_client.scope == "read write calendar"
|
|
|
|
@pytest.mark.parametrize(
|
|
"requested_auth_method",
|
|
[None, "client_secret_post", "client_secret_basic"],
|
|
)
|
|
async def test_dcr_response_is_public_client(
|
|
self, oauth_proxy, requested_auth_method
|
|
):
|
|
"""The DCR response must describe the public client the proxy actually
|
|
stores — never a confidential method / secret the proxy does not enforce
|
|
and does not advertise in server metadata.
|
|
"""
|
|
registration = {"redirect_uris": ["https://client.example.com/callback"]}
|
|
if requested_auth_method is not None:
|
|
registration["token_endpoint_auth_method"] = requested_auth_method
|
|
|
|
app = Starlette(routes=oauth_proxy.get_routes())
|
|
transport = httpx2.ASGITransport(app=app)
|
|
|
|
async with httpx2.AsyncClient(
|
|
transport=transport,
|
|
base_url="https://myserver.com",
|
|
) as client:
|
|
response = await client.post("/register", json=registration)
|
|
|
|
assert response.status_code == 201
|
|
client_info = response.json()
|
|
assert client_info["token_endpoint_auth_method"] == "none"
|
|
assert client_info.get("client_secret") is None
|
|
|
|
|
|
class TestApplicationTypeRegistration:
|
|
"""SEP-837: DCR registration honors the client's application_type."""
|
|
|
|
async def test_default_application_type_is_native(self, oauth_proxy):
|
|
"""Omitting application_type defaults to native (the SDK default), so a
|
|
loopback redirect URI registers successfully."""
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="default-client",
|
|
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
|
)
|
|
|
|
await oauth_proxy.register_client(client_info)
|
|
|
|
stored = await oauth_proxy.get_client("default-client")
|
|
assert stored is not None
|
|
assert stored.application_type == "native"
|
|
|
|
async def test_native_loopback_range_registers_then_authorizes_new_port(
|
|
self, oauth_proxy
|
|
):
|
|
"""A native client on 127.0.0.2 keeps loopback port flexibility.
|
|
|
|
Registration accepts the whole 127.0.0.0/8 range, and the stored client
|
|
must then authorize a different ephemeral port on that same address.
|
|
"""
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="loopback-range-client",
|
|
redirect_uris=[AnyUrl("http://127.0.0.2:3000/callback")],
|
|
application_type="native",
|
|
)
|
|
|
|
await oauth_proxy.register_client(client_info)
|
|
|
|
stored = await oauth_proxy.get_client("loopback-range-client")
|
|
assert stored is not None
|
|
|
|
uri = stored.validate_redirect_uri(AnyUrl("http://127.0.0.2:54321/callback"))
|
|
assert str(uri) == "http://127.0.0.2:54321/callback"
|
|
|
|
# A different host is still rejected — flexibility is loopback-only.
|
|
with pytest.raises(InvalidRedirectUriError):
|
|
stored.validate_redirect_uri(
|
|
AnyUrl("http://evil.example.com:54321/callback")
|
|
)
|
|
|
|
async def test_native_client_accepts_loopback(self, oauth_proxy):
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="native-client",
|
|
redirect_uris=[AnyUrl("http://127.0.0.1:55555/callback")],
|
|
application_type="native",
|
|
)
|
|
|
|
await oauth_proxy.register_client(client_info)
|
|
|
|
stored = await oauth_proxy.get_client("native-client")
|
|
assert stored is not None
|
|
assert stored.application_type == "native"
|
|
|
|
async def test_web_client_accepts_https(self, oauth_proxy):
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="web-client",
|
|
redirect_uris=[AnyUrl("https://client.example.com/callback")],
|
|
application_type="web",
|
|
)
|
|
|
|
await oauth_proxy.register_client(client_info)
|
|
|
|
stored = await oauth_proxy.get_client("web-client")
|
|
assert stored is not None
|
|
assert stored.application_type == "web"
|
|
|
|
async def test_web_client_rejects_loopback(self, oauth_proxy):
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="web-loopback-client",
|
|
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
|
application_type="web",
|
|
)
|
|
|
|
with pytest.raises(RegistrationError, match="application_type 'web'"):
|
|
await oauth_proxy.register_client(client_info)
|
|
|
|
async def test_web_client_rejects_custom_scheme(self, oauth_proxy):
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="web-custom-client",
|
|
redirect_uris=[AnyUrl("com.example.app:/oauth/callback")],
|
|
application_type="web",
|
|
)
|
|
|
|
with pytest.raises(RegistrationError, match="application_type 'web'"):
|
|
await oauth_proxy.register_client(client_info)
|
|
|
|
async def test_web_client_without_redirect_uris_is_rejected(self, oauth_proxy):
|
|
"""A web client with no redirect_uris could never authorize.
|
|
|
|
Omitted redirect_uris fall back to the `http://localhost` placeholder,
|
|
which a web client can never use (loopback http fails its own rule), so
|
|
registering one would only create a client guaranteed to fail later.
|
|
"""
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="web-no-uris",
|
|
redirect_uris=None,
|
|
application_type="web",
|
|
)
|
|
|
|
with pytest.raises(RegistrationError, match="required for application_type"):
|
|
await oauth_proxy.register_client(client_info)
|
|
|
|
async def test_native_client_without_redirect_uris_still_allowed(self, oauth_proxy):
|
|
"""Native clients may still defer redirect_uris to authorization time."""
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="native-no-uris",
|
|
redirect_uris=None,
|
|
application_type="native",
|
|
)
|
|
|
|
await oauth_proxy.register_client(client_info)
|
|
|
|
stored = await oauth_proxy.get_client("native-no-uris")
|
|
assert stored is not None
|
|
|
|
@pytest.mark.parametrize("application_type", ["web", "native"])
|
|
async def test_unsafe_scheme_rejected_regardless_of_type(
|
|
self, oauth_proxy, application_type
|
|
):
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="unsafe-client",
|
|
redirect_uris=[AnyUrl("javascript:alert(document.cookie)//")],
|
|
application_type=application_type,
|
|
)
|
|
|
|
with pytest.raises(RegistrationError, match="invalid_redirect_uri"):
|
|
await oauth_proxy.register_client(client_info)
|
|
|
|
|
|
class TestApplicationTypeRegistrationOverHTTP:
|
|
"""SEP-837: application_type is honored on the real POST /register route.
|
|
|
|
The SDK's RegistrationHandler parses application_type but drops it before
|
|
calling register_client, so these tests exercise the actual ASGI route to
|
|
prove FastMCP recovers the value end to end (a direct register_client call
|
|
would not catch the SDK dropping the field)."""
|
|
|
|
async def _register(self, oauth_proxy, payload: dict):
|
|
app = Starlette(routes=oauth_proxy.get_routes())
|
|
transport = httpx2.ASGITransport(app=app)
|
|
async with httpx2.AsyncClient(
|
|
transport=transport,
|
|
base_url="https://myserver.com",
|
|
) as client:
|
|
return await client.post("/register", json=payload)
|
|
|
|
async def test_web_client_with_loopback_rejected_over_http(self, oauth_proxy):
|
|
response = await self._register(
|
|
oauth_proxy,
|
|
{
|
|
"redirect_uris": ["http://localhost:12345/callback"],
|
|
"application_type": "web",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
body = response.json()
|
|
assert body["error"] == "invalid_redirect_uri"
|
|
assert "application_type 'web'" in body["error_description"]
|
|
|
|
async def test_web_client_with_https_accepted_over_http(self, oauth_proxy):
|
|
response = await self._register(
|
|
oauth_proxy,
|
|
{
|
|
"redirect_uris": ["https://client.example.com/callback"],
|
|
"application_type": "web",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
body = response.json()
|
|
assert body["application_type"] == "web"
|
|
|
|
stored = await oauth_proxy.get_client(body["client_id"])
|
|
assert stored is not None
|
|
assert stored.application_type == "web"
|
|
|
|
async def test_native_client_with_loopback_accepted_over_http(self, oauth_proxy):
|
|
response = await self._register(
|
|
oauth_proxy,
|
|
{
|
|
"redirect_uris": ["http://localhost:12345/callback"],
|
|
"application_type": "native",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
body = response.json()
|
|
assert body["application_type"] == "native"
|
|
|
|
async def test_default_application_type_is_native_over_http(self, oauth_proxy):
|
|
"""Omitting application_type over HTTP defaults to native, so a loopback
|
|
redirect is accepted (preserving pre-SEP-837 behavior)."""
|
|
response = await self._register(
|
|
oauth_proxy,
|
|
{"redirect_uris": ["http://localhost:12345/callback"]},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
body = response.json()
|
|
assert body["application_type"] == "native"
|
|
|
|
|
|
class TestUpstreamClientIdFallback:
|
|
"""Tests for clients that skip DCR and use the upstream client_id directly."""
|
|
|
|
async def test_upstream_client_id_returns_synthetic_client(self, oauth_proxy):
|
|
"""Clients that skip DCR and use upstream client_id directly are accepted."""
|
|
# oauth_proxy fixture uses "test-client-id" as upstream_client_id
|
|
client = await oauth_proxy.get_client("test-client-id")
|
|
assert client is not None
|
|
assert client.client_id == "test-client-id"
|
|
assert client.client_secret is None
|
|
assert client.token_endpoint_auth_method == "none"
|
|
|
|
async def test_upstream_client_id_inherits_allowed_redirect_uris(self, oauth_proxy):
|
|
"""Synthetic upstream client respects the proxy's redirect URI restrictions."""
|
|
oauth_proxy._allowed_client_redirect_uris = ["http://localhost:*"]
|
|
client = await oauth_proxy.get_client("test-client-id")
|
|
assert client is not None
|
|
assert client.allowed_redirect_uri_patterns == ["http://localhost:*"]
|
|
|
|
async def test_unknown_client_id_still_returns_none(self, oauth_proxy):
|
|
"""Non-upstream, unregistered IDs still return None."""
|
|
client = await oauth_proxy.get_client("some-random-client-id")
|
|
assert client is None
|
|
|
|
async def test_redirect_uri_allowed_when_no_pattern_restriction(self, oauth_proxy):
|
|
"""Ordinary redirect URIs are accepted when allowed_client_redirect_uris is None."""
|
|
assert oauth_proxy._allowed_client_redirect_uris is None
|
|
client = await oauth_proxy.get_client("test-client-id")
|
|
assert client is not None
|
|
uri = client.validate_redirect_uri(AnyUrl("https://claude.ai/oauth/callback"))
|
|
assert str(uri) == "https://claude.ai/oauth/callback"
|
|
uri = client.validate_redirect_uri(
|
|
AnyUrl("cursor://anysphere.cursor-mcp/oauth/callback")
|
|
)
|
|
assert str(uri) == "cursor://anysphere.cursor-mcp/oauth/callback"
|
|
|
|
with pytest.raises(InvalidRedirectUriError):
|
|
client.validate_redirect_uri(AnyUrl("javascript:alert(document.cookie)//"))
|
|
|
|
async def test_redirect_uri_validated_against_patterns(self, oauth_proxy):
|
|
"""Redirect URI validation honours allowed_client_redirect_uris when set."""
|
|
oauth_proxy._allowed_client_redirect_uris = ["http://localhost:*"]
|
|
client = await oauth_proxy.get_client("test-client-id")
|
|
assert client is not None
|
|
|
|
# Allowed URI passes
|
|
uri = client.validate_redirect_uri(AnyUrl("http://localhost:12345/callback"))
|
|
assert str(uri) == "http://localhost:12345/callback"
|
|
|
|
# Disallowed URI raises
|
|
with pytest.raises(InvalidRedirectUriError):
|
|
client.validate_redirect_uri(AnyUrl("https://evil.example.com/callback"))
|
|
|
|
async def test_redirect_uri_blocked_when_empty_allowlist(self, oauth_proxy):
|
|
"""Empty allowed_client_redirect_uris blocks all redirect URIs, including localhost."""
|
|
oauth_proxy._allowed_client_redirect_uris = []
|
|
client = await oauth_proxy.get_client("test-client-id")
|
|
assert client is not None
|
|
|
|
with pytest.raises(InvalidRedirectUriError):
|
|
client.validate_redirect_uri(AnyUrl("http://localhost/callback"))
|
|
|
|
with pytest.raises(InvalidRedirectUriError):
|
|
client.validate_redirect_uri(AnyUrl("https://claude.ai/oauth/callback"))
|
|
|
|
async def test_none_redirect_uri_validated_against_patterns(self, oauth_proxy):
|
|
"""redirect_uri=None resolves to the placeholder then validates against patterns."""
|
|
# Placeholder is http://localhost — a pattern that can't match it forces rejection.
|
|
oauth_proxy._allowed_client_redirect_uris = ["https://myapp.example.com/*"]
|
|
client = await oauth_proxy.get_client("test-client-id")
|
|
assert client is not None
|
|
|
|
with pytest.raises(InvalidRedirectUriError):
|
|
client.validate_redirect_uri(None)
|
|
|
|
async def test_none_redirect_uri_rejected_when_empty_allowlist(self, oauth_proxy):
|
|
"""redirect_uri=None is rejected when allowlist is empty ([] blocks the resolved URI too)."""
|
|
oauth_proxy._allowed_client_redirect_uris = []
|
|
client = await oauth_proxy.get_client("test-client-id")
|
|
assert client is not None
|
|
|
|
with pytest.raises(InvalidRedirectUriError):
|
|
client.validate_redirect_uri(None)
|