"""Tests for OAuth Proxy consent page display, CSP policy, and consent binding cookie.""" import re import secrets import time from unittest.mock import Mock from urllib.parse import parse_qs, urlparse import pytest from key_value.aio.stores.memory import MemoryStore from mcp.server.auth.provider import AuthorizationParams from mcp.shared.auth import OAuthClientInformationFull from mcp_types import Icon from pydantic import AnyUrl from starlette.applications import Starlette from starlette.testclient import TestClient from fastmcp import FastMCP from fastmcp.server.auth.auth import AccessToken, TokenVerifier from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.oauth_proxy.models import OAuthTransaction class _Verifier(TokenVerifier): """Minimal token verifier for security tests.""" def __init__(self): self.required_scopes = ["read"] async def verify_token(self, token: str): return AccessToken( token=token, client_id="c", scopes=self.required_scopes, expires_at=None ) @pytest.fixture def oauth_proxy_https(): """OAuthProxy configured with HTTPS base_url for __Host- cookies.""" return OAuthProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", upstream_client_id="client-id", upstream_client_secret="client-secret", token_verifier=_Verifier(), base_url="https://myserver.example", client_storage=MemoryStore(), jwt_signing_key="test-secret", ) @pytest.fixture def oauth_proxy_https_remember(): """OAuthProxy in 'remember' mode for silent-consent cookie tests.""" return OAuthProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", upstream_client_id="client-id", upstream_client_secret="client-secret", token_verifier=_Verifier(), base_url="https://myserver.example", client_storage=MemoryStore(), jwt_signing_key="test-secret", require_authorization_consent="remember", ) @pytest.fixture def oauth_proxy_https_with_cookie_domain(): """OAuthProxy using an explicit cookie domain for platform compatibility.""" return OAuthProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", upstream_client_id="client-id", upstream_client_secret="client-secret", token_verifier=_Verifier(), base_url="https://myserver.example", client_storage=MemoryStore(), jwt_signing_key="test-secret", consent_cookie_domain="myserver.example", ) async def _start_flow( proxy: OAuthProxy, client_id: str, redirect: str ) -> tuple[str, str]: """Register client and start auth; returns (txn_id, consent_url).""" await proxy.register_client( OAuthClientInformationFull( client_id=client_id, client_secret="s", redirect_uris=[AnyUrl(redirect)], ) ) params = AuthorizationParams( redirect_uri=AnyUrl(redirect), redirect_uri_provided_explicitly=True, state="client-state-xyz", code_challenge="challenge", scopes=["read"], ) consent_url = await proxy.authorize( OAuthClientInformationFull( client_id=client_id, client_secret="s", redirect_uris=[AnyUrl(redirect)], ), params, ) qs = parse_qs(urlparse(consent_url).query) return qs["txn_id"][0], consent_url def _extract_csrf(html: str) -> str | None: """Extract CSRF token from HTML form.""" m = re.search(r"name=\"csrf_token\"\s+value=\"([^\"]+)\"", html) return m.group(1) if m else None class TestConsentCookieDomain: def test_parent_domain_is_rejected(self): with pytest.raises( ValueError, match="consent_cookie_domain must exactly match the hostname in base_url", ): OAuthProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", upstream_client_id="client-id", upstream_client_secret="client-secret", token_verifier=_Verifier(), base_url="https://myserver.example", client_storage=MemoryStore(), jwt_signing_key="test-secret", consent_cookie_domain="example", ) def test_domain_requires_https(self): with pytest.raises( ValueError, match="consent_cookie_domain requires an HTTPS base_url" ): OAuthProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", upstream_client_id="client-id", upstream_client_secret="client-secret", token_verifier=_Verifier(), base_url="http://myserver.example", client_storage=MemoryStore(), jwt_signing_key="test-secret", consent_cookie_domain="myserver.example", ) async def test_explicit_domain_completes_consent_with_secure_cookies( self, oauth_proxy_https_with_cookie_domain ): txn_id, _ = await _start_flow( oauth_proxy_https_with_cookie_domain, "explicit-domain-client", "http://localhost:6000/callback", ) app = Starlette(routes=oauth_proxy_https_with_cookie_domain.get_routes()) with TestClient(app, base_url="https://myserver.example") as client: consent = client.get(f"/consent?txn_id={txn_id}") csrf = _extract_csrf(consent.text) assert csrf set_cookie = consent.headers.get("set-cookie", "") assert "__Secure-MCP_CONSENT_STATE=" in set_cookie assert "__Host-" not in set_cookie assert "Secure" in set_cookie assert "HttpOnly" in set_cookie assert "SameSite=lax" in set_cookie assert "Domain=myserver.example" in set_cookie response = client.post( "/consent", data={ "action": "approve", "txn_id": txn_id, "csrf_token": csrf, }, follow_redirects=False, ) assert response.status_code in (302, 303) binding_cookie = response.headers.get("set-cookie", "") assert "__Secure-MCP_CONSENT_BINDING=" in binding_cookie assert "Domain=myserver.example" in binding_cookie async def test_explicit_domain_does_not_accept_host_prefix( self, oauth_proxy_https_with_cookie_domain ): txn_id, _ = await _start_flow( oauth_proxy_https_with_cookie_domain, "wrong-prefix-client", "http://localhost:6001/callback", ) app = Starlette(routes=oauth_proxy_https_with_cookie_domain.get_routes()) with TestClient(app, base_url="https://myserver.example") as browser: consent = browser.get(f"/consent?txn_id={txn_id}") csrf = _extract_csrf(consent.text) assert csrf signed_state = consent.cookies["__Secure-MCP_CONSENT_STATE"] with TestClient(app, base_url="https://myserver.example") as browser: browser.cookies.set("__Host-MCP_CONSENT_STATE", signed_state) response = browser.post( "/consent", data={ "action": "approve", "txn_id": txn_id, "csrf_token": csrf, }, follow_redirects=False, ) assert response.status_code == 403 async def test_default_does_not_accept_secure_prefix(self, oauth_proxy_https): txn_id, _ = await _start_flow( oauth_proxy_https, "default-policy-client", "http://localhost:6002/callback", ) app = Starlette(routes=oauth_proxy_https.get_routes()) with TestClient(app) as browser: consent = browser.get(f"/consent?txn_id={txn_id}") csrf = _extract_csrf(consent.text) assert csrf signed_state = consent.cookies["__Host-MCP_CONSENT_STATE"] with TestClient(app) as browser: browser.cookies.set("__Secure-MCP_CONSENT_STATE", signed_state) response = browser.post( "/consent", data={ "action": "approve", "txn_id": txn_id, "csrf_token": csrf, }, follow_redirects=False, ) assert response.status_code == 403 def test_default_remains_host_only(self, oauth_proxy_https): assert ( oauth_proxy_https._cookie_name("MCP_CONSENT_STATE") == "__Host-MCP_CONSENT_STATE" ) assert oauth_proxy_https._consent_cookie_domain is None class TestConsentPageServerIcon: """Tests for server icon display in OAuth consent screen.""" async def test_consent_screen_displays_server_icon(self): """Test that consent screen shows server's custom icon when available.""" # Create mock JWT verifier verifier = Mock(spec=TokenVerifier) verifier.required_scopes = ["read"] verifier.verify_token = Mock(return_value=None) # Create OAuthProxy proxy = OAuthProxy( upstream_authorization_endpoint="https://oauth.example.com/authorize", upstream_token_endpoint="https://oauth.example.com/token", upstream_client_id="upstream-client", upstream_client_secret="upstream-secret", token_verifier=verifier, base_url="https://proxy.example.com", client_storage=MemoryStore(), jwt_signing_key="test-secret", ) # Create FastMCP server with custom icon server = FastMCP( name="My Custom Server", auth=proxy, icons=[Icon(src="https://example.com/custom-icon.png")], website_url="https://example.com", ) # Create HTTP app app = server.http_app() # Register a test client with the proxy client_info = OAuthClientInformationFull( client_id="test-client", client_secret="test-secret", redirect_uris=[AnyUrl("http://localhost:12345/callback")], ) await proxy.register_client(client_info) # Create a transaction manually txn_id = "test-txn-id" transaction = OAuthTransaction( txn_id=txn_id, client_id="test-client", client_redirect_uri="http://localhost:12345/callback", client_state="client-state", code_challenge="challenge", code_challenge_method="S256", scopes=["read"], created_at=time.time(), ) await proxy._transaction_store.put(key=txn_id, value=transaction) # Make request to consent page with TestClient(app) as client: response = client.get(f"/consent?txn_id={txn_id}") # Check that response is successful assert response.status_code == 200 # Check that HTML contains custom icon assert "https://example.com/custom-icon.png" in response.text # Check that server name is used as alt text assert 'alt="My Custom Server"' in response.text async def test_consent_screen_falls_back_to_fastmcp_logo(self): """Test that consent screen shows FastMCP logo when no server icon provided.""" # Create mock JWT verifier verifier = Mock(spec=TokenVerifier) verifier.required_scopes = ["read"] verifier.verify_token = Mock(return_value=None) # Create OAuthProxy proxy = OAuthProxy( upstream_authorization_endpoint="https://oauth.example.com/authorize", upstream_token_endpoint="https://oauth.example.com/token", upstream_client_id="upstream-client", upstream_client_secret="upstream-secret", token_verifier=verifier, base_url="https://proxy.example.com", client_storage=MemoryStore(), jwt_signing_key="test-secret", ) # Create FastMCP server without icon server = FastMCP(name="Server Without Icon", auth=proxy) # Create HTTP app app = server.http_app() # Register a test client client_info = OAuthClientInformationFull( client_id="test-client", client_secret="test-secret", redirect_uris=[AnyUrl("http://localhost:12345/callback")], ) await proxy.register_client(client_info) # Create a transaction txn_id = "test-txn-id" transaction = OAuthTransaction( txn_id=txn_id, client_id="test-client", client_redirect_uri="http://localhost:12345/callback", client_state="client-state", code_challenge="challenge", code_challenge_method="S256", scopes=["read"], created_at=time.time(), ) await proxy._transaction_store.put(key=txn_id, value=transaction) # Make request to consent page with TestClient(app) as client: response = client.get(f"/consent?txn_id={txn_id}") # Check that response is successful assert response.status_code == 200 # Check that HTML contains FastMCP logo assert "gofastmcp.com/assets/brand/blue-logo.png" in response.text # Check that alt text is still the server name assert 'alt="Server Without Icon"' in response.text async def test_consent_screen_escapes_server_name(self): """Test that server name is properly HTML-escaped.""" # Create mock JWT verifier verifier = Mock(spec=TokenVerifier) verifier.required_scopes = ["read"] verifier.verify_token = Mock(return_value=None) # Create OAuthProxy proxy = OAuthProxy( upstream_authorization_endpoint="https://oauth.example.com/authorize", upstream_token_endpoint="https://oauth.example.com/token", upstream_client_id="upstream-client", upstream_client_secret="upstream-secret", token_verifier=verifier, base_url="https://proxy.example.com", client_storage=MemoryStore(), jwt_signing_key="test-secret", ) # Create FastMCP server with special characters in name server = FastMCP( name='Server', auth=proxy, icons=[Icon(src="https://example.com/icon.png")], ) # Create HTTP app app = server.http_app() # Register a test client client_info = OAuthClientInformationFull( client_id="test-client", client_secret="test-secret", redirect_uris=[AnyUrl("http://localhost:12345/callback")], ) await proxy.register_client(client_info) # Create a transaction txn_id = "test-txn-id" transaction = OAuthTransaction( txn_id=txn_id, client_id="test-client", client_redirect_uri="http://localhost:12345/callback", client_state="client-state", code_challenge="challenge", code_challenge_method="S256", scopes=["read"], created_at=time.time(), ) await proxy._transaction_store.put(key=txn_id, value=transaction) # Make request to consent page with TestClient(app) as client: response = client.get(f"/consent?txn_id={txn_id}") # Check that response is successful assert response.status_code == 200 # Check that script tag is escaped assert "