120 lines · 5.2 KB · TypeScript
RawDownload
// 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();      });    });  }),);