Skip to content

Commit eb14262

Browse files
jlowinclaude
andauthored
Allow upstream client_id to be used directly without DCR (#3957)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 98f69bd commit eb14262

4 files changed

Lines changed: 141 additions & 11 deletions

File tree

src/fastmcp/server/auth/oauth_proxy/models.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,11 +242,19 @@ def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
242242
if pattern_matches:
243243
return redirect_uri
244244

245-
# Patterns configured but didn't match
246-
if self.allowed_redirect_uri_patterns:
245+
# Patterns configured but didn't match (None means "allow all"; [] means "block all")
246+
if self.allowed_redirect_uri_patterns is not None:
247247
raise InvalidRedirectUriError(
248248
f"Redirect URI '{redirect_uri}' does not match allowed patterns."
249249
)
250250

251-
# No redirect_uri provided or no patterns configured — use base validation
252-
return super().validate_redirect_uri(redirect_uri)
251+
# redirect_uri is None with no CIMD document: let base class resolve the URI
252+
# (handles the single-registered-URI shortcut for DCR clients), then validate
253+
# the resolved URI against patterns so [] and other restrictions are enforced.
254+
resolved = super().validate_redirect_uri(redirect_uri)
255+
if self.allowed_redirect_uri_patterns is not None:
256+
if not validate_redirect_uri(resolved, self.allowed_redirect_uri_patterns):
257+
raise InvalidRedirectUriError(
258+
f"Redirect URI '{resolved}' does not match allowed patterns."
259+
)
260+
return resolved

src/fastmcp/server/auth/oauth_proxy/proxy.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,24 @@ async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
698698
await self._client_store.put(key=client_id, value=cimd_client)
699699
return cimd_client
700700

701+
# Some MCP clients (e.g. claude.ai) skip Dynamic Client Registration and
702+
# send the upstream OAuth App's client_id directly in the /authorize request.
703+
# Synthesize a client on-the-fly so these clients aren't rejected with 400.
704+
if client_id == self._upstream_client_id:
705+
logger.debug(
706+
"Client %s matched upstream client_id — synthesizing client without DCR",
707+
client_id,
708+
)
709+
return ProxyDCRClient(
710+
client_id=client_id,
711+
client_secret=None,
712+
redirect_uris=[AnyUrl("http://localhost")],
713+
grant_types=["authorization_code", "refresh_token"],
714+
scope=self._default_scope_str,
715+
token_endpoint_auth_method="none",
716+
allowed_redirect_uri_patterns=self._allowed_client_redirect_uris,
717+
)
718+
701719
return None
702720

703721
@override

tests/server/auth/oauth_proxy/test_client_registration.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
"""Tests for OAuth proxy client registration (DCR)."""
22

3+
import pytest
34
from mcp.shared.auth import OAuthClientInformationFull
45
from pydantic import AnyUrl
56

7+
from fastmcp.server.auth.oauth_proxy.models import InvalidRedirectUriError
8+
69

710
class TestOAuthProxyClientRegistration:
811
"""Tests for OAuth proxy client registration (DCR)."""
@@ -67,3 +70,81 @@ async def test_enforcing_allowed_redirect_uris(self, oauth_proxy):
6770
assert retrieved.allowed_redirect_uri_patterns == [
6871
"http://localhost:12345/updated_callback"
6972
]
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)

tests/server/auth/test_oauth_proxy_redirect_validation.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,20 +106,18 @@ def test_default_not_applied_when_custom_patterns_supplied(self):
106106
with pytest.raises(InvalidRedirectUriError):
107107
client.validate_redirect_uri(AnyUrl("https://example.com"))
108108

109-
def test_empty_list_allows_none(self):
110-
"""Test that empty pattern list allows no URIs."""
109+
def test_empty_list_blocks_all(self):
110+
"""Empty allowed_redirect_uri_patterns blocks all redirect URIs, including pre-registered ones."""
111111
client = ProxyDCRClient(
112112
client_id="test",
113113
client_secret="secret",
114114
redirect_uris=[AnyUrl("http://localhost:3000")],
115115
allowed_redirect_uri_patterns=[],
116116
)
117117

118-
# Nothing should be allowed (except the pre-registered redirect_uris via fallback)
119-
# Pre-registered URI should work via fallback to base validation
120-
assert client.validate_redirect_uri(AnyUrl("http://localhost:3000"))
121-
122-
# Non-registered URIs should be rejected
118+
# All URIs must be rejected — [] means "block all", not "fall back to redirect_uris"
119+
with pytest.raises(InvalidRedirectUriError):
120+
client.validate_redirect_uri(AnyUrl("http://localhost:3000"))
123121
with pytest.raises(InvalidRedirectUriError):
124122
client.validate_redirect_uri(AnyUrl("http://example.com"))
125123
with pytest.raises(InvalidRedirectUriError):
@@ -139,6 +137,31 @@ def test_none_redirect_uri(self):
139137
result = client.validate_redirect_uri(None)
140138
assert result == AnyUrl("http://localhost:3000")
141139

140+
def test_none_redirect_uri_with_matching_patterns(self):
141+
"""DCR client with single URI and patterns: None resolves and validates against patterns."""
142+
client = ProxyDCRClient(
143+
client_id="test",
144+
client_secret="secret",
145+
redirect_uris=[AnyUrl("http://localhost:3000")],
146+
allowed_redirect_uri_patterns=["http://localhost:*"],
147+
)
148+
149+
# Resolves to registered URI which matches the pattern — should succeed
150+
result = client.validate_redirect_uri(None)
151+
assert result == AnyUrl("http://localhost:3000")
152+
153+
def test_none_redirect_uri_with_nonmatching_patterns(self):
154+
"""DCR client with single URI and patterns: None raises if resolved URI doesn't match."""
155+
client = ProxyDCRClient(
156+
client_id="test",
157+
client_secret="secret",
158+
redirect_uris=[AnyUrl("http://localhost:3000")],
159+
allowed_redirect_uri_patterns=["https://myapp.example.com/*"],
160+
)
161+
162+
with pytest.raises(InvalidRedirectUriError):
163+
client.validate_redirect_uri(None)
164+
142165
def test_cimd_none_redirect_uri_single_exact(self):
143166
"""CIMD clients may omit redirect_uri only when a single exact URI exists."""
144167
cimd_doc = CIMDDocument(

0 commit comments

Comments
 (0)