Files
HealthLog/e2e/mcp-oauth-consent-redirect.spec.ts
Marc-André Bombeck a6daa84c17 test(e2e): meet the recent-proof gate from a stale session, as a person does
Connecting an assistant and making a share link ask for a sign-in or
re-proof within five minutes. The shared jar is signed in once in global
setup, so the specs passed early in a run and failed later. They now start
from a session of their own signed in ten minutes ago: the consent page
withholds Allow and links to the confirm page, where the password re-proof
returns to the consent request; the share form opens the re-proof dialog
and creates the link after the password is given.
2026-09-27 03:00:36 +02:00

141 lines
5.5 KiB
TypeScript

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 { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup";
import {
completeReproofWithPassword,
useStaleSession,
} from "./setup/recent-proof";
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.
*
* Allowing a connection asks for a sign-in or re-proof within five minutes, so
* the journey starts from a session signed in ten minutes ago: the consent
* page must withhold the Allow form and link to the confirm page, and the
* person confirms with their password there before they can allow.
*/
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);
}
const endSession = await useStaleSession(page, E2E_USER.username);
try {
// Without a recent proof there is no form to allow with, only the way to
// give one.
await page.goto(authorize.toString());
const allow = page.getByRole("button", { name: "Allow" });
await expect(
page.getByRole("heading", { name: "Confirm it is you" }),
).toBeVisible();
await expect(allow).toHaveCount(0);
await page.getByRole("link", { name: "Confirm and continue" }).click();
await page.waitForURL((url) => url.pathname === "/confirm-access");
await page.getByRole("button", { name: "Confirm", exact: true }).click();
await completeReproofWithPassword(page, E2E_USER.password);
// The confirm page returns to the exact consent request it came from.
await page.waitForURL((url) => url.pathname === authorize.pathname);
await expect(allow).toBeVisible();
await allow.click();
await page.waitForURL((url) => url.toString().startsWith(callbackUrl), {
timeout: 15_000,
});
} finally {
await endSession();
}
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);
});
});