From 7b7a77b292e9593ba99d701c0770405e4bed6fbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 15 Sep 2026 14:03:18 +0200 Subject: [PATCH] test(e2e): walk the MCP consent screen back to a loopback callback A connector signing in through the MCP OAuth consent screen gets stuck after Allow in Chromium: the grant is issued, but the browser never reaches the client's callback. Only a real browser enforces the page's CSP on that redirect, so this is a browser journey. It registers a client with a local loopback callback, clicks Allow and requires the callback to receive a code, state and issuer. It runs against the second server, which now carries APP_URL because the MCP surface fails closed without a configured origin. Refs #983 --- e2e/mcp-oauth-consent-redirect.spec.ts | 111 +++++++++++++++++++++++++ playwright.config.ts | 14 +++- 2 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 e2e/mcp-oauth-consent-redirect.spec.ts diff --git a/e2e/mcp-oauth-consent-redirect.spec.ts b/e2e/mcp-oauth-consent-redirect.spec.ts new file mode 100644 index 000000000..8477e2181 --- /dev/null +++ b/e2e/mcp-oauth-consent-redirect.spec.ts @@ -0,0 +1,111 @@ +import { createHash, randomBytes } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { SSR_PREFETCH_BASE_URL } from "./setup/ssr-prefetch-server"; + +/** + * The MCP consent screen hands the browser back to the connecting client. + * + * After "Allow", the consent form posts to its own origin and the server + * answers with a redirect to the client's `redirect_uri`. Chromium applies the + * consent page's CSP `form-action` to that redirect as well, so a page that + * only allows `'self'` strands the user on the consent screen while the grant + * has already been issued server-side. Only a real browser enforces CSP, which + * is why this is an e2e journey and not a route test. + * + * It runs against the second server, the one started with `APP_URL`: the MCP + * surface fails closed without a configured origin. The callback is a local + * loopback listener, which is also what desktop MCP clients register. + */ +test.describe("MCP OAuth consent", () => { + test.use({ + storageState: STORAGE_STATE_PATH, + baseURL: SSR_PREFETCH_BASE_URL, + }); + + let callback: Server; + let callbackUrl = ""; + const hits: URL[] = []; + + test.beforeAll(async () => { + callback = createServer((req, res) => { + hits.push(new URL(req.url ?? "/", callbackUrl)); + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end("Callback

Connected.

"); + }); + await new Promise((resolve) => + callback.listen(0, "127.0.0.1", () => resolve()), + ); + const { port } = callback.address() as AddressInfo; + callbackUrl = `http://127.0.0.1:${port}/callback`; + }); + + test.afterAll(async () => { + await new Promise((resolve) => callback.close(() => resolve())); + }); + + test("Allow lands on the client's callback with a code", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "The redirect is enforced by the browser engine, not the layout; one Chromium run is the proof.", + ); + + // Register over the page's own connection rather than the shared API + // request pool, which can hand out a socket the server already closed. + await page.goto("/api/version"); + const registration = await page.evaluate(async (redirectUri) => { + const res = await fetch("/api/mcp/oauth/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: "E2E consent client", + redirect_uris: [redirectUri], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code"], + response_types: ["code"], + }), + }); + return { status: res.status, body: await res.json().catch(() => null) }; + }, callbackUrl); + expect(registration.status, JSON.stringify(registration.body)).toBe(201); + const clientId = (registration.body as { client_id: string }).client_id; + + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + const state = randomBytes(8).toString("hex"); + const origin = new URL(SSR_PREFETCH_BASE_URL).origin; + const authorize = new URL("/api/mcp/oauth/authorize", origin); + for (const [key, value] of Object.entries({ + response_type: "code", + client_id: clientId, + redirect_uri: callbackUrl, + code_challenge: challenge, + code_challenge_method: "S256", + scope: "health:read", + state, + resource: `${origin}/mcp`, + })) { + authorize.searchParams.set(key, value); + } + + await page.goto(authorize.toString()); + const allow = page.getByRole("button", { name: "Allow" }); + await expect(allow).toBeVisible(); + await allow.click(); + + await page.waitForURL((url) => url.toString().startsWith(callbackUrl), { + timeout: 15_000, + }); + const hit = hits.at(-1); + expect(hit, "the callback listener was never reached").toBeDefined(); + expect(hit!.searchParams.get("code")).toMatch(/^hlac_/); + expect(hit!.searchParams.get("state")).toBe(state); + expect(hit!.searchParams.get("iss")).toBe(origin); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index a09ee667f..3d4e78b96 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -298,10 +298,15 @@ export default defineConfig({ // rebuilt it. The suite could not see it, because with the prefetch // off both sides paint the same skeleton. // - // One extra server on 3100 closes that. Only - // `dashboard-ssr-prefetch-hydration.spec.ts` talks to it, and it - // asserts the one thing the mocked specs cannot: that the page the - // server streamed is the page the browser keeps. + // One extra server on 3100 closes that. It asserts the one thing the + // mocked specs cannot: that the page the server streamed is the page + // the browser keeps (`dashboard-ssr-prefetch-hydration.spec.ts`). + // + // It also carries `APP_URL`, as a real deployment does. The MCP OAuth + // surface fails closed without a configured origin, so the consent + // journey (`mcp-oauth-consent-redirect.spec.ts`) runs here. The shared + // server stays without it, because `APP_URL` also narrows the accepted + // passkey origins and shapes invite links for every other spec. { command: `PORT=${SSR_PREFETCH_PORT} HOSTNAME=127.0.0.1 ${JSON.stringify(process.execPath)} .next/standalone/server.js`, url: `${SSR_PREFETCH_BASE_URL}/api/version`, @@ -313,6 +318,7 @@ export default defineConfig({ ...process.env, NATIVE_CANVAS: "off", DASHBOARD_SSR_PREFETCH: "true", + APP_URL: SSR_PREFETCH_BASE_URL, }, }, // The mail-configured, scheduler-free server, on its own port.