import httpx import pytest from pydantic import AnyHttpUrl from fastmcp import FastMCP from fastmcp.server.auth import MultiAuth, RemoteAuthProvider, TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.providers.jwt import StaticTokenVerifier class RaisingVerifier(TokenVerifier): """A verifier that always raises, for testing exception resilience.""" async def verify_token(self, token: str) -> AccessToken | None: raise RuntimeError("simulated failure") class TestMultiAuthInit: """Test MultiAuth initialization and validation.""" def test_requires_server_or_verifiers(self): """MultiAuth with neither server nor verifiers raises ValueError.""" with pytest.raises(ValueError, match="at least a server or one verifier"): MultiAuth() def test_server_only(self): verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) provider = RemoteAuthProvider( token_verifier=verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url="https://api.example.com", ) auth = MultiAuth(server=provider) assert auth.server is provider assert auth.verifiers == [] def test_verifiers_only(self): v = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) auth = MultiAuth(verifiers=[v]) assert auth.server is None assert auth.verifiers == [v] def test_single_verifier_not_in_list(self): """A single TokenVerifier (not in a list) is accepted.""" v = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) auth = MultiAuth(verifiers=v) assert auth.verifiers == [v] def test_base_url_from_server(self): verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) provider = RemoteAuthProvider( token_verifier=verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url="https://api.example.com", ) auth = MultiAuth(server=provider) assert auth.base_url == AnyHttpUrl("https://api.example.com/") def test_base_url_override(self): verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) provider = RemoteAuthProvider( token_verifier=verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url="https://api.example.com", ) auth = MultiAuth(server=provider, base_url="https://override.example.com") assert auth.base_url == AnyHttpUrl("https://override.example.com/") def test_resource_base_url_from_server(self): verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) provider = RemoteAuthProvider( token_verifier=verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url="https://auth.example.com/proxy", resource_base_url="https://api.example.com", ) auth = MultiAuth(server=provider) assert auth.resource_base_url == AnyHttpUrl("https://api.example.com/") def test_resource_base_url_override(self): verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) provider = RemoteAuthProvider( token_verifier=verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url="https://auth.example.com/proxy", resource_base_url="https://api.example.com", ) auth = MultiAuth( server=provider, resource_base_url="https://override.example.com", ) assert auth.resource_base_url == AnyHttpUrl("https://override.example.com/") # Override must propagate to the wrapped server so get_routes() # serves metadata consistent with the outer auth challenge URL. assert provider.resource_base_url == AnyHttpUrl("https://override.example.com/") def test_required_scopes_from_server(self): verifier = StaticTokenVerifier( tokens={"t": {"client_id": "c", "scopes": ["read"]}}, required_scopes=["read"], ) provider = RemoteAuthProvider( token_verifier=verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url="https://api.example.com", ) auth = MultiAuth(server=provider) assert auth.required_scopes == ["read"] class TestMultiAuthVerifyToken: """Test MultiAuth token verification chain.""" async def test_server_verified_first(self): """Server's verify_token is tried before verifiers.""" server_verifier = StaticTokenVerifier( tokens={"server_token": {"client_id": "server-client", "scopes": []}} ) server = RemoteAuthProvider( token_verifier=server_verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url="https://api.example.com", ) extra = StaticTokenVerifier( tokens={"extra_token": {"client_id": "extra-client", "scopes": []}} ) auth = MultiAuth(server=server, verifiers=[extra]) result = await auth.verify_token("server_token") assert result is not None assert result.client_id == "server-client" async def test_falls_back_to_verifiers(self): """When server rejects a token, verifiers are tried.""" server_verifier = StaticTokenVerifier( tokens={"server_token": {"client_id": "server-client", "scopes": []}} ) server = RemoteAuthProvider( token_verifier=server_verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url="https://api.example.com", ) extra = StaticTokenVerifier( tokens={"m2m_token": {"client_id": "m2m-service", "scopes": []}} ) auth = MultiAuth(server=server, verifiers=[extra]) result = await auth.verify_token("m2m_token") assert result is not None assert result.client_id == "m2m-service" async def test_verifier_order_matters(self): """Verifiers are tried in order; first match wins.""" v1 = StaticTokenVerifier( tokens={"shared_token": {"client_id": "first", "scopes": []}} ) v2 = StaticTokenVerifier( tokens={"shared_token": {"client_id": "second", "scopes": []}} ) auth = MultiAuth(verifiers=[v1, v2]) result = await auth.verify_token("shared_token") assert result is not None assert result.client_id == "first" async def test_no_match_returns_none(self): """When no server or verifier accepts the token, returns None.""" v = StaticTokenVerifier(tokens={"known": {"client_id": "c", "scopes": []}}) auth = MultiAuth(verifiers=[v]) result = await auth.verify_token("unknown") assert result is None async def test_verifiers_only_no_server(self): """MultiAuth with only verifiers (no server) works.""" v1 = StaticTokenVerifier(tokens={"token_a": {"client_id": "a", "scopes": []}}) v2 = StaticTokenVerifier(tokens={"token_b": {"client_id": "b", "scopes": []}}) auth = MultiAuth(verifiers=[v1, v2]) result_a = await auth.verify_token("token_a") assert result_a is not None assert result_a.client_id == "a" result_b = await auth.verify_token("token_b") assert result_b is not None assert result_b.client_id == "b" async def test_raising_verifier_does_not_break_chain(self): """If a verifier raises, the chain continues to the next source.""" good = StaticTokenVerifier( tokens={"valid": {"client_id": "good-client", "scopes": []}} ) auth = MultiAuth(verifiers=[RaisingVerifier(), good]) result = await auth.verify_token("valid") assert result is not None assert result.client_id == "good-client" async def test_raising_server_does_not_break_chain(self): """If the server raises, verifiers are still tried.""" good = StaticTokenVerifier( tokens={"valid": {"client_id": "fallback", "scopes": []}} ) auth = MultiAuth(server=RaisingVerifier(), verifiers=[good]) result = await auth.verify_token("valid") assert result is not None assert result.client_id == "fallback" async def test_all_raising_returns_none(self): """If every source raises, verify_token returns None.""" auth = MultiAuth(verifiers=[RaisingVerifier(), RaisingVerifier()]) result = await auth.verify_token("anything") assert result is None async def test_server_match_short_circuits(self): """When the server matches, verifiers are not consulted.""" # Both server and verifier know the same token with different client_ids server_verifier = StaticTokenVerifier( tokens={"token": {"client_id": "from-server", "scopes": []}} ) server = RemoteAuthProvider( token_verifier=server_verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url="https://api.example.com", ) extra = StaticTokenVerifier( tokens={"token": {"client_id": "from-verifier", "scopes": []}} ) auth = MultiAuth(server=server, verifiers=[extra]) result = await auth.verify_token("token") assert result is not None assert result.client_id == "from-server" class TestMultiAuthRoutes: """Test that routes delegate to the server.""" def test_routes_from_server(self): verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) server = RemoteAuthProvider( token_verifier=verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url="https://api.example.com", ) auth = MultiAuth(server=server) routes = auth.get_routes(mcp_path="/mcp") # RemoteAuthProvider creates a protected resource metadata route assert len(routes) >= 1 def test_no_routes_without_server(self): v = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) auth = MultiAuth(verifiers=[v]) assert auth.get_routes() == [] def test_well_known_routes_delegate_to_server(self): """get_well_known_routes delegates to the server's implementation.""" verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) server = RemoteAuthProvider( token_verifier=verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url="https://api.example.com", ) auth = MultiAuth(server=server) well_known = auth.get_well_known_routes(mcp_path="/mcp") server_well_known = server.get_well_known_routes(mcp_path="/mcp") # MultiAuth should produce the same well-known routes as the server assert len(well_known) == len(server_well_known) assert [r.path for r in well_known] == [r.path for r in server_well_known] def test_well_known_routes_empty_without_server(self): v = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) auth = MultiAuth(verifiers=[v]) assert auth.get_well_known_routes() == [] def test_required_scopes_explicit_empty_list(self): """Passing required_scopes=[] explicitly clears inherited scopes.""" verifier = StaticTokenVerifier( tokens={"t": {"client_id": "c", "scopes": ["read"]}}, required_scopes=["read"], ) server = RemoteAuthProvider( token_verifier=verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url="https://api.example.com", ) # Server has required_scopes=["read"], but we explicitly clear them auth = MultiAuth(server=server, required_scopes=[]) assert auth.required_scopes == [] class TestMultiAuthIntegration: """Integration tests: MultiAuth with a real FastMCP HTTP app.""" async def test_multi_auth_rejects_bad_tokens(self): """End-to-end: MultiAuth rejects unknown tokens at the HTTP layer.""" oauth_tokens = StaticTokenVerifier( tokens={ "oauth_token": { "client_id": "interactive-client", "scopes": ["read"], } } ) m2m_tokens = StaticTokenVerifier( tokens={ "m2m_token": { "client_id": "backend-service", "scopes": ["read"], } } ) auth = MultiAuth(verifiers=[oauth_tokens, m2m_tokens]) mcp = FastMCP("test", auth=auth) app = mcp.http_app(path="/mcp") async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://localhost", ) as client: # No token → 401 response = await client.get("/mcp") assert response.status_code == 401 # Bad token → 401 response = await client.get( "/mcp", headers={"Authorization": "Bearer bad_token"} ) assert response.status_code == 401 async def test_multi_auth_with_server_provides_routes(self): """MultiAuth with a server exposes the server's metadata routes.""" verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) server = RemoteAuthProvider( token_verifier=verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url="https://api.example.com", ) extra = StaticTokenVerifier(tokens={"m2m": {"client_id": "svc", "scopes": []}}) auth = MultiAuth(server=server, verifiers=[extra]) mcp = FastMCP("test", auth=auth) app = mcp.http_app(path="/mcp") async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="https://api.example.com", ) as client: # Protected resource metadata should be available response = await client.get("/.well-known/oauth-protected-resource/mcp") assert response.status_code == 200 data = response.json() assert data["resource"] == "https://api.example.com/mcp" async def test_multi_auth_uses_server_resource_base_url_in_auth_challenge(self): """Auth challenges should advertise resource metadata from resource_base_url.""" verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) server = RemoteAuthProvider( token_verifier=verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url="https://auth.example.com/proxy", resource_base_url="https://api.example.com", ) auth = MultiAuth(server=server) mcp = FastMCP("test", auth=auth) app = mcp.http_app(path="/mcp") async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://localhost", ) as client: response = await client.get("/mcp") assert response.status_code == 401 assert ( 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"' in response.headers["www-authenticate"] ) async def test_multi_auth_override_propagates_to_served_metadata(self): """Override on MultiAuth must propagate so served metadata matches the challenge.""" verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) server = RemoteAuthProvider( token_verifier=verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url="https://auth.example.com/proxy", ) auth = MultiAuth(server=server, resource_base_url="https://api.example.com") mcp = FastMCP("test", auth=auth) app = mcp.http_app(path="/mcp") async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://localhost", ) as client: response = await client.get("/mcp") assert response.status_code == 401 assert ( 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"' in response.headers["www-authenticate"] ) metadata_response = await client.get( "/.well-known/oauth-protected-resource/mcp" ) assert metadata_response.status_code == 200 assert metadata_response.json()["resource"] == "https://api.example.com/mcp" old_path_response = await client.get( "/.well-known/oauth-protected-resource/proxy/mcp" ) assert old_path_response.status_code == 404 async def test_multi_auth_accepts_valid_verifier_token(self): """MultiAuth accepts tokens from verifiers (not just the server). Verifies that both server and verifier tokens pass the HTTP auth middleware. We use GET /mcp to check: 401 means auth rejected, any other status means auth accepted and the request reached the MCP session layer. """ interactive_tokens = StaticTokenVerifier( tokens={ "interactive_token": { "client_id": "interactive-client", "scopes": [], } } ) m2m_tokens = StaticTokenVerifier( tokens={ "m2m_token": { "client_id": "backend-service", "scopes": [], } } ) auth = MultiAuth(verifiers=[interactive_tokens, m2m_tokens]) mcp = FastMCP("test", auth=auth) app = mcp.http_app(path="/mcp") async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app, raise_app_exceptions=False), base_url="http://localhost", ) as client: # No token → 401 response = await client.get("/mcp") assert response.status_code == 401 # Interactive token passes auth (non-401 means auth accepted) response = await client.get( "/mcp", headers={"Authorization": "Bearer interactive_token"} ) assert response.status_code != 401 # M2M token also passes auth response = await client.get( "/mcp", headers={"Authorization": "Bearer m2m_token"} ) assert response.status_code != 401 # Bad token → 401 response = await client.get( "/mcp", headers={"Authorization": "Bearer bad_token"} ) assert response.status_code == 401 class TestMultiAuthSetMcpPath: """Test that set_mcp_path propagates to server and verifiers.""" def test_propagates_to_server(self): verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) server = RemoteAuthProvider( token_verifier=verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url="https://api.example.com", ) auth = MultiAuth(server=server) auth.set_mcp_path("/mcp") assert server._mcp_path == "/mcp" def test_propagates_to_verifiers(self): v1 = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) v2 = StaticTokenVerifier(tokens={"t2": {"client_id": "c2", "scopes": []}}) auth = MultiAuth(verifiers=[v1, v2]) auth.set_mcp_path("/mcp") assert v1._mcp_path == "/mcp" assert v2._mcp_path == "/mcp"