Improve OAuth protected resource metadata URL construction per RFC 9728 (#1407)

This commit is contained in:
Marcus Shu 2025-10-06 20:52:44 +08:00 • committed by GitHub
commit df3e428ee8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 192 additions and 32 deletions

View file

@ -32,7 +32,7 @@ class ResourceServerSettings(BaseSettings):
# Server settings # Server settings
host: str = "localhost" host: str = "localhost"
port: int = 8001 port: int = 8001
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001") server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001/mcp")
# Authorization Server settings # Authorization Server settings
auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000")
@ -137,7 +137,7 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http
# Create settings # Create settings
host = "localhost" host = "localhost"
server_url = f"http://{host}:{port}" server_url = f"http://{host}:{port}/mcp"
settings = ResourceServerSettings( settings = ResourceServerSettings(
host=host, host=host,
port=port, port=port,

View file

@ -1,5 +1,6 @@
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any from typing import Any
from urllib.parse import urlparse
from pydantic import AnyHttpUrl from pydantic import AnyHttpUrl
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
@ -186,6 +187,25 @@ def build_metadata(
return metadata return metadata
def build_resource_metadata_url(resource_server_url: AnyHttpUrl) -> AnyHttpUrl:
"""
Build RFC 9728 compliant protected resource metadata URL.
Inserts /.well-known/oauth-protected-resource between host and resource path
as specified in RFC 9728 §3.1.
Args:
resource_server_url: The resource server URL (e.g., https://example.com/mcp)
Returns:
The metadata URL (e.g., https://example.com/.well-known/oauth-protected-resource/mcp)
"""
parsed = urlparse(str(resource_server_url))
# Handle trailing slash: if path is just "/", treat as empty
resource_path = parsed.path if parsed.path != "/" else ""
return AnyHttpUrl(f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource{resource_path}")
def create_protected_resource_routes( def create_protected_resource_routes(
resource_url: AnyHttpUrl, resource_url: AnyHttpUrl,
authorization_servers: list[AnyHttpUrl], authorization_servers: list[AnyHttpUrl],
@ -218,9 +238,15 @@ def create_protected_resource_routes(
handler = ProtectedResourceMetadataHandler(metadata) handler = ProtectedResourceMetadataHandler(metadata)
# RFC 9728 §3.1: Register route at /.well-known/oauth-protected-resource + resource path
metadata_url = build_resource_metadata_url(resource_url)
# Extract just the path part for route registration
parsed = urlparse(str(metadata_url))
well_known_path = parsed.path
return [ return [
Route( Route(
"/.well-known/oauth-protected-resource", well_known_path,
endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]), endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]),
methods=["GET", "OPTIONS"], methods=["GET", "OPTIONS"],
) )

View file

@ -825,11 +825,10 @@ class FastMCP(Generic[LifespanResultT]):
# Determine resource metadata URL # Determine resource metadata URL
resource_metadata_url = None resource_metadata_url = None
if self.settings.auth and self.settings.auth.resource_server_url: if self.settings.auth and self.settings.auth.resource_server_url:
from pydantic import AnyHttpUrl from mcp.server.auth.routes import build_resource_metadata_url
resource_metadata_url = AnyHttpUrl( # Build compliant metadata URL for WWW-Authenticate header
str(self.settings.auth.resource_server_url).rstrip("/") + "/.well-known/oauth-protected-resource" resource_metadata_url = build_resource_metadata_url(self.settings.auth.resource_server_url)
)
# Auth is enabled, wrap the endpoints with RequireAuthMiddleware # Auth is enabled, wrap the endpoints with RequireAuthMiddleware
routes.append( routes.append(
@ -938,11 +937,10 @@ class FastMCP(Generic[LifespanResultT]):
# Determine resource metadata URL # Determine resource metadata URL
resource_metadata_url = None resource_metadata_url = None
if self.settings.auth and self.settings.auth.resource_server_url: if self.settings.auth and self.settings.auth.resource_server_url:
from pydantic import AnyHttpUrl from mcp.server.auth.routes import build_resource_metadata_url
resource_metadata_url = AnyHttpUrl( # Build compliant metadata URL for WWW-Authenticate header
str(self.settings.auth.resource_server_url).rstrip("/") + "/.well-known/oauth-protected-resource" resource_metadata_url = build_resource_metadata_url(self.settings.auth.resource_server_url)
)
routes.append( routes.append(
Route( Route(
@ -961,23 +959,13 @@ class FastMCP(Generic[LifespanResultT]):
# Add protected resource metadata endpoint if configured as RS # Add protected resource metadata endpoint if configured as RS
if self.settings.auth and self.settings.auth.resource_server_url: if self.settings.auth and self.settings.auth.resource_server_url:
from mcp.server.auth.handlers.metadata import ProtectedResourceMetadataHandler from mcp.server.auth.routes import create_protected_resource_routes
from mcp.server.auth.routes import cors_middleware
from mcp.shared.auth import ProtectedResourceMetadata
protected_resource_metadata = ProtectedResourceMetadata( routes.extend(
resource=self.settings.auth.resource_server_url, create_protected_resource_routes(
authorization_servers=[self.settings.auth.issuer_url], resource_url=self.settings.auth.resource_server_url,
scopes_supported=self.settings.auth.required_scopes, authorization_servers=[self.settings.auth.issuer_url],
) scopes_supported=self.settings.auth.required_scopes,
routes.append(
Route(
"/.well-known/oauth-protected-resource",
endpoint=cors_middleware(
ProtectedResourceMetadataHandler(protected_resource_metadata).handle,
["GET", "OPTIONS"],
),
methods=["GET", "OPTIONS"],
) )
) )

View file

@ -8,7 +8,7 @@ from inline_snapshot import snapshot
from pydantic import AnyHttpUrl from pydantic import AnyHttpUrl
from starlette.applications import Starlette from starlette.applications import Starlette
from mcp.server.auth.routes import create_protected_resource_routes from mcp.server.auth.routes import build_resource_metadata_url, create_protected_resource_routes
@pytest.fixture @pytest.fixture
@ -36,10 +36,11 @@ async def test_client(test_app: Starlette):
@pytest.mark.anyio @pytest.mark.anyio
async def test_metadata_endpoint(test_client: httpx.AsyncClient): async def test_metadata_endpoint_with_path(test_client: httpx.AsyncClient):
"""Test the OAuth 2.0 Protected Resource metadata endpoint.""" """Test the OAuth 2.0 Protected Resource metadata endpoint for path-based resource."""
response = await test_client.get("/.well-known/oauth-protected-resource") # For resource with path "/resource", metadata should be accessible at the path-aware location
response = await test_client.get("/.well-known/oauth-protected-resource/resource")
assert response.json() == snapshot( assert response.json() == snapshot(
{ {
"resource": "https://example.com/resource", "resource": "https://example.com/resource",
@ -50,3 +51,148 @@ async def test_metadata_endpoint(test_client: httpx.AsyncClient):
"bearer_methods_supported": ["header"], "bearer_methods_supported": ["header"],
} }
) )
@pytest.mark.anyio
async def test_metadata_endpoint_root_path_returns_404(test_client: httpx.AsyncClient):
"""Test that root path returns 404 for path-based resource."""
# Root path should return 404 for path-based resources
response = await test_client.get("/.well-known/oauth-protected-resource")
assert response.status_code == 404
@pytest.fixture
def root_resource_app():
"""Fixture to create protected resource routes for root-level resource."""
# Create routes for a resource without path component
protected_resource_routes = create_protected_resource_routes(
resource_url=AnyHttpUrl("https://example.com"),
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
scopes_supported=["read"],
resource_name="Root Resource",
)
app = Starlette(routes=protected_resource_routes)
return app
@pytest.fixture
async def root_resource_client(root_resource_app: Starlette):
"""Fixture to create an HTTP client for the root resource app."""
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=root_resource_app), base_url="https://mcptest.com"
) as client:
yield client
@pytest.mark.anyio
async def test_metadata_endpoint_without_path(root_resource_client: httpx.AsyncClient):
"""Test metadata endpoint for root-level resource."""
# For root resource, metadata should be at standard location
response = await root_resource_client.get("/.well-known/oauth-protected-resource")
assert response.status_code == 200
assert response.json() == snapshot(
{
"resource": "https://example.com/",
"authorization_servers": ["https://auth.example.com/"],
"scopes_supported": ["read"],
"resource_name": "Root Resource",
"bearer_methods_supported": ["header"],
}
)
class TestMetadataUrlConstruction:
"""Test URL construction utility function."""
def test_url_without_path(self):
"""Test URL construction for resource without path component."""
resource_url = AnyHttpUrl("https://example.com")
result = build_resource_metadata_url(resource_url)
assert str(result) == "https://example.com/.well-known/oauth-protected-resource"
def test_url_with_path_component(self):
"""Test URL construction for resource with path component."""
resource_url = AnyHttpUrl("https://example.com/mcp")
result = build_resource_metadata_url(resource_url)
assert str(result) == "https://example.com/.well-known/oauth-protected-resource/mcp"
def test_url_with_trailing_slash_only(self):
"""Test URL construction for resource with trailing slash only."""
resource_url = AnyHttpUrl("https://example.com/")
result = build_resource_metadata_url(resource_url)
# Trailing slash should be treated as empty path
assert str(result) == "https://example.com/.well-known/oauth-protected-resource"
@pytest.mark.parametrize(
"resource_url,expected_url",
[
("https://example.com", "https://example.com/.well-known/oauth-protected-resource"),
("https://example.com/", "https://example.com/.well-known/oauth-protected-resource"),
("https://example.com/mcp", "https://example.com/.well-known/oauth-protected-resource/mcp"),
("http://localhost:8001/mcp", "http://localhost:8001/.well-known/oauth-protected-resource/mcp"),
],
)
def test_various_resource_configurations(self, resource_url: str, expected_url: str):
"""Test URL construction with various resource configurations."""
result = build_resource_metadata_url(AnyHttpUrl(resource_url))
assert str(result) == expected_url
class TestRouteConsistency:
"""Test consistency between URL generation and route registration."""
def test_route_path_matches_metadata_url(self):
"""Test that route path matches the generated metadata URL."""
resource_url = AnyHttpUrl("https://example.com/mcp")
# Generate metadata URL
metadata_url = build_resource_metadata_url(resource_url)
# Create routes
routes = create_protected_resource_routes(
resource_url=resource_url,
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
)
# Extract path from metadata URL
from urllib.parse import urlparse
metadata_path = urlparse(str(metadata_url)).path
# Verify consistency
assert len(routes) == 1
assert routes[0].path == metadata_path
@pytest.mark.parametrize(
"resource_url,expected_path",
[
("https://example.com", "/.well-known/oauth-protected-resource"),
("https://example.com/", "/.well-known/oauth-protected-resource"),
("https://example.com/mcp", "/.well-known/oauth-protected-resource/mcp"),
],
)
def test_consistent_paths_for_various_resources(self, resource_url: str, expected_path: str):
"""Test that URL generation and route creation are consistent."""
resource_url_obj = AnyHttpUrl(resource_url)
# Test URL generation
metadata_url = build_resource_metadata_url(resource_url_obj)
from urllib.parse import urlparse
url_path = urlparse(str(metadata_url)).path
# Test route creation
routes = create_protected_resource_routes(
resource_url=resource_url_obj,
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
)
route_path = routes[0].path
# Both should match expected path
assert url_path == expected_path
assert route_path == expected_path
assert url_path == route_path