Skip to content

Commit 4e3b959

Browse files
test: add unit tests for OAuth metadata URL trailing slash fix
Add comprehensive tests verifying that trailing slashes are properly stripped from OAuth metadata URLs to comply with RFC 8414 §3.3 and RFC 9728 §3. Tests cover: - build_metadata() issuer URL trailing slash stripping - Endpoint URL formation without double slashes - Protected resource metadata URL stripping - Authorization server URL stripping - End-to-end integration tests for both OAuth and protected resource metadata endpoints These tests would fail on main (before the fix) but pass on this branch. Co-authored-by: Max Isbey <maxisbey@users.noreply.github.com>
1 parent 2c0f2c3 commit 4e3b959

File tree

1 file changed

+236
-0
lines changed

1 file changed

+236
-0
lines changed
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
"""Tests for OAuth metadata URL trailing slash handling.
2+
3+
These tests verify that trailing slashes are properly stripped from OAuth metadata URLs
4+
to ensure compliance with RFC 8414 §3.3 and RFC 9728 §3, which require that the issuer/
5+
resource URL in the metadata response must be identical to the URL used for discovery.
6+
7+
These tests would fail on main (before the fix) but pass on this branch.
8+
"""
9+
10+
import httpx
11+
import pytest
12+
from pydantic import AnyHttpUrl
13+
from starlette.applications import Starlette
14+
15+
from mcp.server.auth.routes import build_metadata, create_auth_routes, create_protected_resource_routes
16+
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
17+
from tests.server.fastmcp.auth.test_auth_integration import MockOAuthProvider
18+
19+
20+
def test_build_metadata_strips_trailing_slash_from_issuer():
21+
"""Test that build_metadata strips trailing slash from issuer URL.
22+
23+
Pydantic's AnyHttpUrl automatically adds trailing slashes to bare hostnames.
24+
This test verifies that we strip them to comply with RFC 8414 §3.3.
25+
"""
26+
# Use a bare hostname URL which Pydantic will add a trailing slash to
27+
issuer_url = AnyHttpUrl("http://localhost:8000")
28+
29+
metadata = build_metadata(
30+
issuer_url=issuer_url,
31+
service_documentation_url=None,
32+
client_registration_options=ClientRegistrationOptions(enabled=False),
33+
revocation_options=RevocationOptions(enabled=False),
34+
)
35+
36+
# The issuer should NOT have a trailing slash
37+
assert str(metadata.issuer) == "http://localhost:8000"
38+
assert not str(metadata.issuer).endswith("/")
39+
40+
41+
def test_build_metadata_strips_trailing_slash_from_issuer_with_path():
42+
"""Test that build_metadata strips trailing slash from issuer URL with path."""
43+
# URL with path that has trailing slash
44+
issuer_url = AnyHttpUrl("http://localhost:8000/auth/")
45+
46+
metadata = build_metadata(
47+
issuer_url=issuer_url,
48+
service_documentation_url=None,
49+
client_registration_options=ClientRegistrationOptions(enabled=False),
50+
revocation_options=RevocationOptions(enabled=False),
51+
)
52+
53+
# The issuer should NOT have a trailing slash
54+
assert str(metadata.issuer) == "http://localhost:8000/auth"
55+
assert not str(metadata.issuer).endswith("/")
56+
57+
58+
def test_build_metadata_endpoints_have_no_double_slashes():
59+
"""Test that endpoint URLs don't have double slashes when issuer has trailing slash."""
60+
# Use a URL that Pydantic will add trailing slash to
61+
issuer_url = AnyHttpUrl("http://localhost:8000")
62+
63+
metadata = build_metadata(
64+
issuer_url=issuer_url,
65+
service_documentation_url=None,
66+
client_registration_options=ClientRegistrationOptions(enabled=True),
67+
revocation_options=RevocationOptions(enabled=True),
68+
)
69+
70+
# All endpoints should be correctly formed without double slashes
71+
assert str(metadata.authorization_endpoint) == "http://localhost:8000/authorize"
72+
assert str(metadata.token_endpoint) == "http://localhost:8000/token"
73+
assert str(metadata.registration_endpoint) == "http://localhost:8000/register"
74+
assert str(metadata.revocation_endpoint) == "http://localhost:8000/revoke"
75+
76+
# None should have double slashes
77+
assert "//" not in str(metadata.authorization_endpoint).replace("http://", "")
78+
assert "//" not in str(metadata.token_endpoint).replace("http://", "")
79+
assert "//" not in str(metadata.registration_endpoint).replace("http://", "")
80+
assert "//" not in str(metadata.revocation_endpoint).replace("http://", "")
81+
82+
83+
def test_protected_resource_metadata_strips_trailing_slash_from_resource():
84+
"""Test that protected resource metadata strips trailing slash from resource URL.
85+
86+
RFC 9728 §3 requires that the resource URL in the metadata response must be
87+
identical to the URL used for discovery.
88+
"""
89+
# Use a bare hostname URL which Pydantic will add a trailing slash to
90+
resource_url = AnyHttpUrl("http://localhost:8000")
91+
auth_server_url = AnyHttpUrl("http://auth.example.com")
92+
93+
routes = create_protected_resource_routes(
94+
resource_url=resource_url,
95+
authorization_servers=[auth_server_url],
96+
)
97+
98+
# Extract metadata from the handler
99+
# The handler is wrapped in CORS middleware, so we need to unwrap it
100+
route = routes[0]
101+
# Access the app inside the middleware
102+
cors_app = route.endpoint
103+
handler = cors_app.app.func # type: ignore
104+
105+
metadata = handler.__self__.metadata # type: ignore
106+
107+
# The resource URL should NOT have a trailing slash
108+
assert str(metadata.resource) == "http://localhost:8000"
109+
assert not str(metadata.resource).endswith("/")
110+
111+
112+
def test_protected_resource_metadata_strips_trailing_slash_from_authorization_servers():
113+
"""Test that protected resource metadata strips trailing slashes from authorization server URLs."""
114+
resource_url = AnyHttpUrl("http://localhost:8000/resource")
115+
# Use bare hostname URLs which Pydantic will add trailing slashes to
116+
auth_servers = [
117+
AnyHttpUrl("http://auth1.example.com"),
118+
AnyHttpUrl("http://auth2.example.com"),
119+
]
120+
121+
routes = create_protected_resource_routes(
122+
resource_url=resource_url,
123+
authorization_servers=auth_servers,
124+
)
125+
126+
# Extract metadata from the handler
127+
route = routes[0]
128+
cors_app = route.endpoint
129+
handler = cors_app.app.func # type: ignore
130+
metadata = handler.__self__.metadata # type: ignore
131+
132+
# All authorization server URLs should NOT have trailing slashes
133+
assert str(metadata.authorization_servers[0]) == "http://auth1.example.com"
134+
assert str(metadata.authorization_servers[1]) == "http://auth2.example.com"
135+
assert not str(metadata.authorization_servers[0]).endswith("/")
136+
assert not str(metadata.authorization_servers[1]).endswith("/")
137+
138+
139+
@pytest.fixture
140+
def oauth_provider():
141+
"""Return a MockOAuthProvider instance for testing."""
142+
return MockOAuthProvider()
143+
144+
145+
@pytest.fixture
146+
def app(oauth_provider: MockOAuthProvider):
147+
"""Create a Starlette app with OAuth routes using a bare hostname issuer URL."""
148+
# Use a bare hostname which Pydantic will add a trailing slash to
149+
# This simulates the real-world scenario that was failing
150+
issuer_url = AnyHttpUrl("http://localhost:8000")
151+
152+
auth_routes = create_auth_routes(
153+
oauth_provider,
154+
issuer_url=issuer_url,
155+
client_registration_options=ClientRegistrationOptions(enabled=True),
156+
revocation_options=RevocationOptions(enabled=True),
157+
)
158+
159+
return Starlette(routes=auth_routes)
160+
161+
162+
@pytest.fixture
163+
def client(app: Starlette):
164+
"""Create an HTTP client for the OAuth app."""
165+
transport = httpx.ASGITransport(app=app)
166+
return httpx.AsyncClient(transport=transport, base_url="http://localhost:8000")
167+
168+
169+
@pytest.mark.anyio
170+
async def test_oauth_metadata_endpoint_has_no_trailing_slash_in_issuer(client: httpx.AsyncClient):
171+
"""Test that the OAuth metadata endpoint returns issuer without trailing slash.
172+
173+
This is the integration test that verifies the fix works end-to-end.
174+
This test would FAIL on main because the issuer would have a trailing slash.
175+
"""
176+
response = await client.get("/.well-known/oauth-authorization-server")
177+
178+
assert response.status_code == 200
179+
metadata = response.json()
180+
181+
# The issuer should NOT have a trailing slash
182+
assert metadata["issuer"] == "http://localhost:8000"
183+
assert not metadata["issuer"].endswith("/")
184+
185+
# Endpoints should be correctly formed
186+
assert metadata["authorization_endpoint"] == "http://localhost:8000/authorize"
187+
assert metadata["token_endpoint"] == "http://localhost:8000/token"
188+
assert metadata["registration_endpoint"] == "http://localhost:8000/register"
189+
assert metadata["revocation_endpoint"] == "http://localhost:8000/revoke"
190+
191+
192+
@pytest.fixture
193+
def protected_resource_app():
194+
"""Create a Starlette app with protected resource routes using bare hostname URLs."""
195+
# Use bare hostname URLs which Pydantic will add trailing slashes to
196+
resource_url = AnyHttpUrl("http://localhost:9000")
197+
auth_servers = [AnyHttpUrl("http://auth.example.com")]
198+
199+
routes = create_protected_resource_routes(
200+
resource_url=resource_url,
201+
authorization_servers=auth_servers,
202+
scopes_supported=["read", "write"],
203+
)
204+
205+
return Starlette(routes=routes)
206+
207+
208+
@pytest.fixture
209+
def protected_resource_client(protected_resource_app: Starlette):
210+
"""Create an HTTP client for the protected resource app."""
211+
transport = httpx.ASGITransport(app=protected_resource_app)
212+
return httpx.AsyncClient(transport=transport, base_url="http://localhost:9000")
213+
214+
215+
@pytest.mark.anyio
216+
async def test_protected_resource_metadata_endpoint_has_no_trailing_slashes(
217+
protected_resource_client: httpx.AsyncClient,
218+
):
219+
"""Test that protected resource metadata endpoint returns URLs without trailing slashes.
220+
221+
This integration test verifies the fix for protected resource metadata.
222+
This test would FAIL on main because resource and authorization_servers would have trailing slashes.
223+
"""
224+
response = await protected_resource_client.get("/.well-known/oauth-protected-resource")
225+
226+
assert response.status_code == 200
227+
metadata = response.json()
228+
229+
# The resource URL should NOT have a trailing slash
230+
assert metadata["resource"] == "http://localhost:9000"
231+
assert not metadata["resource"].endswith("/")
232+
233+
# Authorization server URLs should NOT have trailing slashes
234+
assert metadata["authorization_servers"] == ["http://auth.example.com"]
235+
for auth_server in metadata["authorization_servers"]:
236+
assert not auth_server.endswith("/")

0 commit comments

Comments
 (0)