Skip to content

Commit 5747cb6

Browse files
authored
Fix OAuth metadata endpoint URLs when base_url differs from issuer_url (#2353)
* Fix OAuth metadata endpoint URLs when base_url differs from issuer_url OAuth operational endpoints (/authorize, /token) are mounted at base_url, but metadata was incorrectly declaring them at issuer_url. This caused clients following the documented mounting pattern to receive incorrect endpoint URLs in /.well-known/oauth-authorization-server. Fixes #2287 * Update auth.py * Remove unnecessary assertion from OAuthProvider init * Add info log when issuer_url differs from base_url
1 parent 7e6610b commit 5747cb6

File tree

2 files changed

+99
-9
lines changed

2 files changed

+99
-9
lines changed

src/fastmcp/server/auth/auth.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Any
3+
from typing import Any, cast
44

55
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
66
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
@@ -28,6 +28,10 @@
2828
from starlette.middleware.authentication import AuthenticationMiddleware
2929
from starlette.routing import Route
3030

31+
from fastmcp.utilities.logging import get_logger
32+
33+
logger = get_logger(__name__)
34+
3135

3236
class AccessToken(_SDKAccessToken):
3337
"""AccessToken that includes all JWT claims."""
@@ -294,20 +298,27 @@ def __init__(
294298
required_scopes: Scopes that are required for all requests.
295299
"""
296300

297-
# Convert URLs to proper types
298-
if isinstance(base_url, str):
299-
base_url = AnyHttpUrl(base_url)
300-
301301
super().__init__(base_url=base_url, required_scopes=required_scopes)
302-
self.base_url = base_url
303302

304303
if issuer_url is None:
305-
self.issuer_url = base_url
304+
self.issuer_url = self.base_url
306305
elif isinstance(issuer_url, str):
307306
self.issuer_url = AnyHttpUrl(issuer_url)
308307
else:
309308
self.issuer_url = issuer_url
310309

310+
# Log if issuer_url and base_url differ (requires additional setup)
311+
if (
312+
self.base_url is not None
313+
and self.issuer_url is not None
314+
and str(self.base_url) != str(self.issuer_url)
315+
):
316+
logger.info(
317+
f"OAuth endpoints at {self.base_url}, issuer at {self.issuer_url}. "
318+
f"Ensure well-known routes are accessible at root ({self.issuer_url}/.well-known/). "
319+
f"See: https://gofastmcp.com/deployment/http#mounting-authenticated-servers"
320+
)
321+
311322
# Initialize OAuth Authorization Server Provider
312323
OAuthAuthorizationServerProvider.__init__(self)
313324

@@ -348,9 +359,17 @@ def get_routes(
348359
"""
349360

350361
# Create standard OAuth authorization server routes
362+
# Pass base_url as issuer_url to ensure metadata declares endpoints where
363+
# they're actually accessible (operational routes are mounted at
364+
# base_url)
365+
assert self.base_url is not None # typing check
366+
assert (
367+
self.issuer_url is not None
368+
) # typing check (issuer_url defaults to base_url)
369+
351370
oauth_routes = create_auth_routes(
352371
provider=self,
353-
issuer_url=self.issuer_url,
372+
issuer_url=self.base_url,
354373
service_documentation_url=self.service_documentation_url,
355374
client_registration_options=self.client_registration_options,
356375
revocation_options=self.revocation_options,
@@ -369,7 +388,7 @@ def get_routes(
369388
)
370389
protected_routes = create_protected_resource_routes(
371390
resource_url=resource_url,
372-
authorization_servers=[self.issuer_url],
391+
authorization_servers=[cast(AnyHttpUrl, self.issuer_url)],
373392
scopes_supported=supported_scopes,
374393
)
375394
oauth_routes.extend(protected_routes)

tests/server/auth/test_oauth_mounting.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from fastmcp import FastMCP
1616
from fastmcp.server.auth import RemoteAuthProvider
17+
from fastmcp.server.auth.oauth_proxy import OAuthProxy
1718
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
1819

1920

@@ -194,3 +195,73 @@ async def test_nested_mounting(self, test_tokens):
194195

195196
data = response.json()
196197
assert data["resource"] == "https://api.example.com/outer/inner/mcp"
198+
199+
async def test_oauth_authorization_server_metadata_with_base_url_and_issuer_url(
200+
self, test_tokens
201+
):
202+
"""Test OAuth authorization server metadata when base_url and issuer_url differ.
203+
204+
This validates the fix for issue #2287 where operational OAuth endpoints
205+
(/authorize, /token) should be declared at base_url in the metadata,
206+
not at issuer_url.
207+
208+
Scenario: FastMCP server mounted at /api prefix
209+
- issuer_url: https://api.example.com (root level)
210+
- base_url: https://api.example.com/api (includes mount prefix)
211+
- Expected: metadata declares endpoints at base_url
212+
"""
213+
# Create OAuth proxy with different base_url and issuer_url
214+
token_verifier = StaticTokenVerifier(tokens=test_tokens)
215+
auth_provider = OAuthProxy(
216+
upstream_authorization_endpoint="https://upstream.example.com/authorize",
217+
upstream_token_endpoint="https://upstream.example.com/token",
218+
upstream_client_id="test-client-id",
219+
upstream_client_secret="test-client-secret",
220+
token_verifier=token_verifier,
221+
base_url="https://api.example.com/api", # Includes mount prefix
222+
issuer_url="https://api.example.com", # Root level
223+
)
224+
225+
mcp = FastMCP("test-server", auth=auth_provider)
226+
mcp_app = mcp.http_app(path="/mcp")
227+
228+
# Get well-known routes for mounting at root
229+
well_known_routes = auth_provider.get_well_known_routes(mcp_path="/mcp")
230+
231+
# Mount the app under /api prefix
232+
parent_app = Starlette(
233+
routes=[
234+
*well_known_routes, # Well-known routes at root level
235+
Mount("/api", app=mcp_app), # MCP app under /api
236+
],
237+
lifespan=mcp_app.lifespan,
238+
)
239+
240+
async with httpx.AsyncClient(
241+
transport=httpx.ASGITransport(app=parent_app),
242+
base_url="https://api.example.com",
243+
) as client:
244+
# Fetch the authorization server metadata
245+
response = await client.get("/.well-known/oauth-authorization-server")
246+
assert response.status_code == 200
247+
248+
metadata = response.json()
249+
250+
# CRITICAL: The metadata should declare endpoints at base_url,
251+
# not issuer_url, because that's where they're actually mounted
252+
assert (
253+
metadata["authorization_endpoint"]
254+
== "https://api.example.com/api/authorize"
255+
)
256+
assert metadata["token_endpoint"] == "https://api.example.com/api/token"
257+
assert (
258+
metadata["registration_endpoint"]
259+
== "https://api.example.com/api/register"
260+
)
261+
262+
# The issuer field should use base_url (where the server is actually running)
263+
# Note: MCP SDK may or may not add a trailing slash
264+
assert metadata["issuer"] in [
265+
"https://api.example.com/api",
266+
"https://api.example.com/api/",
267+
]

0 commit comments

Comments
 (0)