Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 28 additions & 9 deletions src/fastmcp/server/auth/auth.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
71 changes: 71 additions & 0 deletions tests/server/auth/test_oauth_mounting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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/",
]