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
This commit is contained in:
Marc-André Bombeck committed 2026-09-15 14:03:18 +02:00
1 parent 970c89a5e7
commit 7b7a77b292
2 files changed
+121 -4

No files matched your search

+111
View File
@@ -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("<!doctype html><title>Callback</title><p>Connected.</p>");
});
await new Promise<void>((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<void>((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);
});
});
+10 -4
View File
@@ -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.