diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index fa88dddcf4..d634c3924b 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -85,13 +85,21 @@ def create_auth_routes( ) client_authenticator = ClientAuthenticator(provider) + # Extract the base path from the issuer URL so that auth routes are + # registered under the same prefix. This is necessary when the server + # sits behind a gateway with a custom base path (e.g., /custom/path). + issuer_path = urlparse(str(issuer_url)).path.rstrip("/") + # Create routes # Allow CORS requests for endpoints meant to be hit by the OAuth client # (with the client secret). This is intended to support things like MCP Inspector, # where the client runs in a web browser. routes = [ Route( - "/.well-known/oauth-authorization-server", + # RFC 8414 3.1: the well-known suffix goes between the authority and + # the issuer's path component, not after it — "/custom/path/.well-known/..." + # is not a valid well-known URI per RFC 8615 3. + "/.well-known/oauth-authorization-server" + issuer_path, endpoint=cors_middleware( MetadataHandler(metadata).handle, ["GET", "OPTIONS"], @@ -99,14 +107,14 @@ def create_auth_routes( methods=["GET", "OPTIONS"], ), Route( - AUTHORIZATION_PATH, + issuer_path + AUTHORIZATION_PATH, # do not allow CORS for authorization endpoint; # clients should just redirect to this endpoint=AuthorizationHandler(provider).handle, methods=["GET", "POST"], ), Route( - TOKEN_PATH, + issuer_path + TOKEN_PATH, endpoint=cors_middleware( TokenHandler( provider, client_authenticator, identity_assertion_enabled=identity_assertion_enabled @@ -124,7 +132,7 @@ def create_auth_routes( ) routes.append( Route( - REGISTRATION_PATH, + issuer_path + REGISTRATION_PATH, endpoint=cors_middleware( registration_handler.handle, ["POST", "OPTIONS"], @@ -137,7 +145,7 @@ def create_auth_routes( revocation_handler = RevocationHandler(provider, client_authenticator) routes.append( Route( - REVOCATION_PATH, + issuer_path + REVOCATION_PATH, endpoint=cors_middleware( revocation_handler.handle, ["POST", "OPTIONS"], diff --git a/tests/server/auth/test_routes.py b/tests/server/auth/test_routes.py index 58685c64c7..8ba2bcddca 100644 --- a/tests/server/auth/test_routes.py +++ b/tests/server/auth/test_routes.py @@ -1,8 +1,9 @@ import pytest from pydantic import AnyHttpUrl -from mcp.server.auth.routes import build_metadata, validate_issuer_url +from mcp.server.auth.routes import build_metadata, create_auth_routes, validate_issuer_url from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions +from tests.server.mcpserver.auth.test_auth_integration import MockOAuthProvider def test_validate_issuer_url_https_allowed(): @@ -70,3 +71,56 @@ def test_build_metadata_serves_issuer_without_trailing_slash(): assert served["issuer"] == "https://as.example.com" assert served["authorization_endpoint"] == "https://as.example.com/authorize" assert served["token_endpoint"] == "https://as.example.com/token" + + +def test_create_auth_routes_default_paths(): + """Auth routes are registered at root when issuer_url has no path.""" + provider = MockOAuthProvider() + routes = create_auth_routes( + provider, + issuer_url=AnyHttpUrl("https://example.com"), + client_registration_options=ClientRegistrationOptions(enabled=True), + revocation_options=RevocationOptions(enabled=True), + ) + paths = [route.path for route in routes] + assert "/.well-known/oauth-authorization-server" in paths + assert "/authorize" in paths + assert "/token" in paths + assert "/register" in paths + assert "/revoke" in paths + + +def test_create_auth_routes_custom_base_path(): + """Auth routes are prefixed with the issuer_url path for gateway deployments. + + Per RFC 8414 3.1 / RFC 8615 3, the well-known discovery URI is rooted at the + domain and inserted *before* the issuer's path component, not after it. + """ + provider = MockOAuthProvider() + routes = create_auth_routes( + provider, + issuer_url=AnyHttpUrl("https://example.com/custom/path"), + client_registration_options=ClientRegistrationOptions(enabled=True), + revocation_options=RevocationOptions(enabled=True), + ) + paths = [route.path for route in routes] + assert "/.well-known/oauth-authorization-server/custom/path" in paths + assert "/custom/path/authorize" in paths + assert "/custom/path/token" in paths + assert "/custom/path/register" in paths + assert "/custom/path/revoke" in paths + + +def test_create_auth_routes_trailing_slash_stripped(): + """Trailing slash on issuer_url path is stripped to avoid double slashes.""" + provider = MockOAuthProvider() + routes = create_auth_routes( + provider, + issuer_url=AnyHttpUrl("https://example.com/base/"), + client_registration_options=ClientRegistrationOptions(enabled=True), + revocation_options=RevocationOptions(enabled=True), + ) + paths = [route.path for route in routes] + assert "/.well-known/oauth-authorization-server/base" in paths + assert "/base/authorize" in paths + assert "/base/token" in paths