diff --git a/routes/mcp_routes.py b/routes/mcp_routes.py index ca2722b5b..a0ade88b6 100644 --- a/routes/mcp_routes.py +++ b/routes/mcp_routes.py @@ -108,6 +108,12 @@ def _load_disabled_map(): db.close() +def _mcp_oauth_redirect_uri() -> str: + """Shared callback URL for legacy Google and generic MCP OAuth flows.""" + from src.mcp_oauth import REDIRECT_URI + return REDIRECT_URI + + def setup_mcp_routes(mcp_manager: McpManager): """Setup MCP routes with the provided manager.""" @@ -445,9 +451,9 @@ def setup_mcp_routes(mcp_manager: McpManager): client_id = keys["client_id"] scopes = oauth_cfg.get("scopes", []) - # For Desktop App creds, redirect to localhost — the user will + # For Desktop App creds, default to localhost — the user will # paste the resulting URL back if they're on a different device. - redirect_uri = "http://localhost:7000/api/mcp/oauth/callback" + redirect_uri = _mcp_oauth_redirect_uri() params = { "client_id": client_id, @@ -469,7 +475,7 @@ def setup_mcp_routes(mcp_manager: McpManager): return RedirectResponse(auth_url) else: # Remote device — show paste-back page - return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host)) + return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host, redirect_uri)) finally: db.close() @@ -536,7 +542,7 @@ def setup_mcp_routes(mcp_manager: McpManager): client_id = keys["client_id"] client_secret = keys["client_secret"] - redirect_uri = "http://localhost:7000/api/mcp/oauth/callback" + redirect_uri = _mcp_oauth_redirect_uri() async with httpx.AsyncClient() as client: resp = await client.post( @@ -603,13 +609,19 @@ def setup_mcp_routes(mcp_manager: McpManager): return router -def _oauth_authorize_page(auth_url: str, server_id: str, host: str) -> str: +def _oauth_authorize_page( + auth_url: str, + server_id: str, + host: str, + redirect_uri: str = "http://localhost:7000/api/mcp/oauth/callback", +) -> str: """Page with Google sign-in link and URL paste-back form for remote access.""" # Escape values interpolated into the page: `host` comes from the request # Host header and `server_id` from the OAuth state — neither is trusted. auth_url = html.escape(auth_url, quote=True) server_id = html.escape(server_id, quote=True) host = html.escape(host, quote=True) + redirect_uri = html.escape(redirect_uri, quote=True) return f""" Authorize — Odysseus @@ -654,7 +666,7 @@ def _oauth_authorize_page(auth_url: str, server_id: str, host: str) -> str:

Paste the URL from your browser after signing in:

- +
""" diff --git a/tests/test_security_regressions.py b/tests/test_security_regressions.py index 6d03f2bf3..30d1ccd23 100644 --- a/tests/test_security_regressions.py +++ b/tests/test_security_regressions.py @@ -972,7 +972,7 @@ def test_mcp_oauth_page_escapes_reflected_values(): src = Path(__file__).resolve().parents[1] / "routes" / "mcp_routes.py" text = src.read_text() body = text.split("def _oauth_authorize_page(", 1)[1].split("return f", 1)[0] - for var in ("auth_url", "server_id", "host"): + for var in ("auth_url", "server_id", "host", "redirect_uri"): assert f"{var} = html.escape({var}" in body, var @@ -981,6 +981,18 @@ def _import_mcp_routes(): return importlib.import_module("routes.mcp_routes") +def test_google_mcp_oauth_uses_configured_redirect_base(monkeypatch): + monkeypatch.setenv("OAUTH_REDIRECT_BASE_URL", "https://odysseus.example/app/") + monkeypatch.delenv("APP_PUBLIC_URL", raising=False) + sys.modules.pop("src.mcp_oauth", None) + mcp_routes = _import_mcp_routes() + + assert ( + mcp_routes._mcp_oauth_redirect_uri() + == "https://odysseus.example/app/api/mcp/oauth/callback" + ) + + def test_mcp_oauth_paths_resolve_under_data_dir(tmp_path, monkeypatch): mcp_routes = _import_mcp_routes() monkeypatch.setattr(mcp_routes, "MCP_OAUTH_DIR", str(tmp_path / "data" / "mcp_oauth"))