diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py index adae95b7d..814f905f0 100644 --- a/src/fastmcp/server/auth/auth.py +++ b/src/fastmcp/server/auth/auth.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from mcp.server.auth.middleware.auth_context import AuthContextMiddleware from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend @@ -28,6 +28,10 @@ from starlette.middleware.authentication import AuthenticationMiddleware from starlette.routing import Route +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + class AccessToken(_SDKAccessToken): """AccessToken that includes all JWT claims.""" @@ -294,20 +298,27 @@ def __init__( required_scopes: Scopes that are required for all requests. """ - # Convert URLs to proper types - if isinstance(base_url, str): - base_url = AnyHttpUrl(base_url) - super().__init__(base_url=base_url, required_scopes=required_scopes) - self.base_url = base_url if issuer_url is None: - self.issuer_url = base_url + self.issuer_url = self.base_url elif isinstance(issuer_url, str): self.issuer_url = AnyHttpUrl(issuer_url) else: self.issuer_url = issuer_url + # Log if issuer_url and base_url differ (requires additional setup) + if ( + self.base_url is not None + and self.issuer_url is not None + and str(self.base_url) != str(self.issuer_url) + ): + logger.info( + f"OAuth endpoints at {self.base_url}, issuer at {self.issuer_url}. " + f"Ensure well-known routes are accessible at root ({self.issuer_url}/.well-known/). " + f"See: https://gofastmcp.com/deployment/http#mounting-authenticated-servers" + ) + # Initialize OAuth Authorization Server Provider OAuthAuthorizationServerProvider.__init__(self) @@ -348,9 +359,17 @@ def get_routes( """ # Create standard OAuth authorization server routes + # Pass base_url as issuer_url to ensure metadata declares endpoints where + # they're actually accessible (operational routes are mounted at + # base_url) + assert self.base_url is not None # typing check + assert ( + self.issuer_url is not None + ) # typing check (issuer_url defaults to base_url) + oauth_routes = create_auth_routes( provider=self, - issuer_url=self.issuer_url, + issuer_url=self.base_url, service_documentation_url=self.service_documentation_url, client_registration_options=self.client_registration_options, revocation_options=self.revocation_options, @@ -369,7 +388,7 @@ def get_routes( ) protected_routes = create_protected_resource_routes( resource_url=resource_url, - authorization_servers=[self.issuer_url], + authorization_servers=[cast(AnyHttpUrl, self.issuer_url)], scopes_supported=supported_scopes, ) oauth_routes.extend(protected_routes) diff --git a/tests/server/auth/test_oauth_mounting.py b/tests/server/auth/test_oauth_mounting.py index 28d72e2ae..6f97277e1 100644 --- a/tests/server/auth/test_oauth_mounting.py +++ b/tests/server/auth/test_oauth_mounting.py @@ -14,6 +14,7 @@ from fastmcp import FastMCP from fastmcp.server.auth import RemoteAuthProvider +from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.providers.jwt import StaticTokenVerifier @@ -194,3 +195,73 @@ async def test_nested_mounting(self, test_tokens): data = response.json() assert data["resource"] == "https://api.example.com/outer/inner/mcp" + + async def test_oauth_authorization_server_metadata_with_base_url_and_issuer_url( + self, test_tokens + ): + """Test OAuth authorization server metadata when base_url and issuer_url differ. + + This validates the fix for issue #2287 where operational OAuth endpoints + (/authorize, /token) should be declared at base_url in the metadata, + not at issuer_url. + + Scenario: FastMCP server mounted at /api prefix + - issuer_url: https://api.example.com (root level) + - base_url: https://api.example.com/api (includes mount prefix) + - Expected: metadata declares endpoints at base_url + """ + # Create OAuth proxy with different base_url and issuer_url + token_verifier = StaticTokenVerifier(tokens=test_tokens) + auth_provider = OAuthProxy( + upstream_authorization_endpoint="https://upstream.example.com/authorize", + upstream_token_endpoint="https://upstream.example.com/token", + upstream_client_id="test-client-id", + upstream_client_secret="test-client-secret", + token_verifier=token_verifier, + base_url="https://api.example.com/api", # Includes mount prefix + issuer_url="https://api.example.com", # Root level + ) + + mcp = FastMCP("test-server", auth=auth_provider) + mcp_app = mcp.http_app(path="/mcp") + + # Get well-known routes for mounting at root + well_known_routes = auth_provider.get_well_known_routes(mcp_path="/mcp") + + # Mount the app under /api prefix + parent_app = Starlette( + routes=[ + *well_known_routes, # Well-known routes at root level + Mount("/api", app=mcp_app), # MCP app under /api + ], + lifespan=mcp_app.lifespan, + ) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=parent_app), + base_url="https://api.example.com", + ) as client: + # Fetch the authorization server metadata + response = await client.get("/.well-known/oauth-authorization-server") + assert response.status_code == 200 + + metadata = response.json() + + # CRITICAL: The metadata should declare endpoints at base_url, + # not issuer_url, because that's where they're actually mounted + assert ( + metadata["authorization_endpoint"] + == "https://api.example.com/api/authorize" + ) + assert metadata["token_endpoint"] == "https://api.example.com/api/token" + assert ( + metadata["registration_endpoint"] + == "https://api.example.com/api/register" + ) + + # The issuer field should use base_url (where the server is actually running) + # Note: MCP SDK may or may not add a trailing slash + assert metadata["issuer"] in [ + "https://api.example.com/api", + "https://api.example.com/api/", + ]