// Selfhost (browser, recorded): a connecting MCP client must pass a human// approval screen before it is granted a token. The recording (video + trace +// per-step screenshots) is the artifact.//// The self-host serving layer forces `prompt=consent` on every MCP authorize// (src/auth/force-mcp-consent) and Better Auth is configured with a consent// page (`oidcConfig.consentPage` → the SPA's /oauth/consent), so an// authenticated authorize stops on the approval screen rather than auto-issuing// a code. This scenario drives that: authorize → approval screen → Approve →// back to the client with a code.//// (Driven from a signed-in browser rather than typing into the login page// because the approval step — not the login UI — is the subject, and the login// page does not cleanly resume the OAuth request after sign-in.)import { createHash, randomBytes, randomUUID } from "node:crypto"; import { expect } from "@effect/vitest";import { Effect } from "effect"; import { scenario } from "../src/scenario";import { Browser, Target } from "../src/services"; interface AuthServerMetadata { readonly authorization_endpoint: string; readonly registration_endpoint: string;} // Registered at DCR; the approval screen looks this up to show "Connect …?".const CLIENT_NAME = "Claude (MCP) — demo"; scenario( "MCP OAuth · a connecting client must pass the approval screen", { timeout: 180_000 }, Effect.gen(function* () { const target = yield* Target; const browser = yield* Browser; // A signed-in identity (the browser context is seeded with its session // cookies) — the subject is what an MCP client's authorize request does for // an already-authenticated user. const identity = yield* target.newIdentity(); // Authorization-server discovery + dynamic client registration (what a real // MCP client does before it ever opens a browser). The redirect lands back // in the app so the recording ends on the authenticated console. const metadata = (yield* Effect.promise(() => fetch(new URL("/.well-known/oauth-authorization-server", target.baseUrl)).then((r) => r.json(), ), )) as AuthServerMetadata; const redirectUri = new URL("/", target.baseUrl).toString(); const registered = (yield* Effect.promise(() => fetch(metadata.registration_endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ client_name: CLIENT_NAME, redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", }), }).then((r) => r.json()), )) as { readonly client_id: string }; const verifier = randomBytes(32).toString("base64url"); const authorizeUrl = new URL(metadata.authorization_endpoint); authorizeUrl.searchParams.set("client_id", registered.client_id); authorizeUrl.searchParams.set("redirect_uri", redirectUri); authorizeUrl.searchParams.set("response_type", "code"); authorizeUrl.searchParams.set("state", randomUUID()); authorizeUrl.searchParams.set( "code_challenge", createHash("sha256").update(verifier).digest("base64url"), ); authorizeUrl.searchParams.set("code_challenge_method", "S256"); const authorize = authorizeUrl.toString(); yield* browser.session(identity, async ({ page, step }) => { await step("A signed-in user is using their Executor instance", async () => { await page.goto("/", { waitUntil: "networkidle" }); // Confirm we're in the app, not bounced to sign-in. expect(new URL(page.url()).pathname, "the session is active (not on /login)").not.toBe( "/login", ); }); await step( "An MCP client requests authorization — the approval screen gates it", async () => { // A connecting MCP client opens this authorize URL. The server forces // prompt=consent, so it stops here on the approval screen instead of // auto-issuing a code. await page.goto(authorize, { waitUntil: "networkidle" }); await page.locator("#mcp-consent-allow").waitFor(); expect( new URL(page.url()).pathname, "the authorize request lands on the approval screen, not a redirect", ).toBe("/mcp-consent"); // The screen shows the registered client NAME (looked up server-side // and appended to the consent redirect), not just the opaque id. await page.getByText(`Connect ${CLIENT_NAME}?`).waitFor(); }, ); await step("Allow — the client is granted a code and sent back", async () => { await page.locator("#mcp-consent-allow").click(); await page.waitForURL((url) => url.searchParams.has("code"), { timeout: 20_000 }); const landed = new URL(page.url()); expect(landed.origin + landed.pathname, "approval redirects back to the client").toBe( redirectUri.replace(/\/$/, "") + "/", ); expect( landed.searchParams.get("code"), "a code is granted only after explicit approval", ).toBeTruthy(); }); }); }),);
// Selfhost (browser, recorded): a connecting MCP client must pass a human
// approval screen before it is granted a token. The recording (video + trace +
// per-step screenshots) is the artifact.
//
// The self-host serving layer forces `prompt=consent` on every MCP authorize
// (src/auth/force-mcp-consent) and Better Auth is configured with a consent
// page (`oidcConfig.consentPage` → the SPA's /oauth/consent), so an
// authenticated authorize stops on the approval screen rather than auto-issuing
// a code. This scenario drives that: authorize → approval screen → Approve →
// back to the client with a code.
//
// (Driven from a signed-in browser rather than typing into the login page
// because the approval step — not the login UI — is the subject, and the login
// page does not cleanly resume the OAuth request after sign-in.)
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { scenario } from "../src/scenario";
import { Browser, Target } from "../src/services";
interface AuthServerMetadata {
readonly authorization_endpoint: string;
readonly registration_endpoint: string;
}
// Registered at DCR; the approval screen looks this up to show "Connect …?".
const CLIENT_NAME = "Claude (MCP) — demo";
scenario(
"MCP OAuth · a connecting client must pass the approval screen",
{ timeout: 180_000 },
Effect.gen(function* () {
const target = yield* Target;
const browser = yield* Browser;
// A signed-in identity (the browser context is seeded with its session
// cookies) — the subject is what an MCP client's authorize request does for
// an already-authenticated user.
const identity = yield* target.newIdentity();
// Authorization-server discovery + dynamic client registration (what a real
// MCP client does before it ever opens a browser). The redirect lands back
// in the app so the recording ends on the authenticated console.
const metadata = (yield* Effect.promise(() =>
fetch(new URL("/.well-known/oauth-authorization-server", target.baseUrl)).then((r) =>
r.json(),
),
)) as AuthServerMetadata;
const redirectUri = new URL("/", target.baseUrl).toString();
const registered = (yield* Effect.promise(() =>
fetch(metadata.registration_endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
client_name: CLIENT_NAME,
redirect_uris: [redirectUri],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
}),
}).then((r) => r.json()),
)) as { readonly client_id: string };
const verifier = randomBytes(32).toString("base64url");
const authorizeUrl = new URL(metadata.authorization_endpoint);
authorizeUrl.searchParams.set("client_id", registered.client_id);
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
authorizeUrl.searchParams.set("response_type", "code");
authorizeUrl.searchParams.set("state", randomUUID());
authorizeUrl.searchParams.set(
"code_challenge",
createHash("sha256").update(verifier).digest("base64url"),
);
authorizeUrl.searchParams.set("code_challenge_method", "S256");
const authorize = authorizeUrl.toString();
yield* browser.session(identity, async ({ page, step }) => {
await step("A signed-in user is using their Executor instance", async () => {
await page.goto("/", { waitUntil: "networkidle" });
// Confirm we're in the app, not bounced to sign-in.
expect(new URL(page.url()).pathname, "the session is active (not on /login)").not.toBe(
"/login",
);
});
await step(
"An MCP client requests authorization — the approval screen gates it",
async () => {
// A connecting MCP client opens this authorize URL. The server forces
// prompt=consent, so it stops here on the approval screen instead of
// auto-issuing a code.
await page.goto(authorize, { waitUntil: "networkidle" });
await page.locator("#mcp-consent-allow").waitFor();
expect(
new URL(page.url()).pathname,
"the authorize request lands on the approval screen, not a redirect",
).toBe("/mcp-consent");
// The screen shows the registered client NAME (looked up server-side
// and appended to the consent redirect), not just the opaque id.
await page.getByText(`Connect ${CLIENT_NAME}?`).waitFor();
},
);
await step("Allow — the client is granted a code and sent back", async () => {
await page.locator("#mcp-consent-allow").click();
await page.waitForURL((url) => url.searchParams.has("code"), { timeout: 20_000 });
const landed = new URL(page.url());
expect(landed.origin + landed.pathname, "approval redirects back to the client").toBe(
redirectUri.replace(/\/$/, "") + "/",
);
expect(
landed.searchParams.get("code"),
"a code is granted only after explicit approval",
).toBeTruthy();
});
});
}),
);