import { randomUUID } from "node:crypto"; import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; scenario( "Login CSRF · state is required, bound to the browser, and consumed after login", { timeout: 180_000 }, Effect.gen(function* () { const target = yield* Target; const browser = yield* Browser; const email = `csrf-${randomUUID()}@e2e.test`; yield* browser.session({ label: "anonymous" }, async ({ page, step }) => { const interceptCallback = async (): Promise => { let callback: string | undefined; // Pause the real provider response before its redirect reaches the app. // Playwright does not route subsequent hops of a redirect chain. await page.route("**/user_management/authorize/submit", async (route) => { const response = await route.fetch({ maxRedirects: 0 }); expect(response.status()).toBe(302); callback = response.headers().location; await route.fulfill({ status: 200, contentType: "text/plain", body: "Authorization ready for callback validation", }); }); await page.goto(new URL("/api/auth/login", target.baseUrl).toString()); await page.getByPlaceholder("new-user@example.com").fill(email); await page.getByRole("button", { name: /Continue/ }).click(); await expect.poll(() => callback).toBeDefined(); await page.unroute("**/user_management/authorize/submit"); if (!callback) throw new Error("AuthKit did not return a callback"); return callback; }; await step("Discard a valid code without state and restart login", async () => { const callback = new URL(await interceptCallback()); callback.searchParams.delete("state"); const response = await page.request.get(callback.toString(), { maxRedirects: 0 }); expect(response.status()).toBe(302); expect(response.headers().location).toBe("/api/auth/login"); expect( (await page.context().cookies()).some((cookie) => cookie.name === "wos-session"), ).toBe(false); }); await step("Refuse a state from another login", async () => { const callback = new URL(await interceptCallback()); callback.searchParams.set("state", "another-browser-state"); const response = await page.request.get(callback.toString(), { maxRedirects: 0 }); expect(response.status()).toBe(400); expect(await response.text()).toBe("Invalid login state"); expect( (await page.context().cookies()).some((cookie) => cookie.name === "wos-session"), ).toBe(false); }); await step("Complete a fresh login, then reject the same callback again", async () => { const callback = await interceptCallback(); await page.goto(callback); await page.waitForURL((url) => url.pathname === "/create-org", { timeout: 30_000 }); const cookies = await page.context().cookies(); expect(cookies.some((cookie) => cookie.name === "wos-session")).toBe(true); expect(cookies.some((cookie) => cookie.name === "wos-login-state")).toBe(false); const me = await page.request.get(new URL("/api/auth/me", target.baseUrl).toString()); expect(me.status()).toBe(200); expect(await me.json()).toMatchObject({ user: { email } }); const replay = await page.request.get(callback, { maxRedirects: 0 }); expect(replay.status()).toBe(400); expect(await replay.text()).toBe("Invalid login state"); }); }); }), ); scenario( "Auth · a provider-initiated login restarts with browser-bound state", { timeout: 180_000 }, Effect.gen(function* () { const target = yield* Target; const browser = yield* Browser; const email = `provider-login-${randomUUID()}@e2e.test`; // Discover this deployment's provider URL without setting a browser cookie. const login = yield* Effect.promise(() => fetch(new URL("/api/auth/login", target.baseUrl), { redirect: "manual" }), ); expect(login.status).toBe(302); const location = login.headers.get("location"); if (!location) throw new Error("Login did not redirect to AuthKit"); const providerUrl = new URL(location); providerUrl.searchParams.delete("state"); yield* browser.session({ label: "anonymous" }, async ({ page, step }) => { await step("Sign in directly at the provider, as a hosted invitation does", async () => { await page.goto(providerUrl.toString()); await page.getByPlaceholder("new-user@example.com").fill(email); const callbackResponse = page.waitForResponse( (response) => new URL(response.url()).pathname === "/api/auth/callback", ); await page.getByRole("button", { name: /Continue/ }).click(); const callback = await callbackResponse; expect(new URL(callback.url()).searchParams.has("state")).toBe(false); expect(callback.status()).toBe(302); expect(callback.headers().location).toBe("/api/auth/login"); await page.waitForURL((url) => url.searchParams.has("state")); expect((await page.context().cookies()).map((cookie) => cookie.name)).not.toContain( "wos-session", ); }); await step("Complete the fresh login and reach the signed-in app", async () => { // The emulator asks again; hosted AuthKit can reuse its browser session. await page.getByPlaceholder("new-user@example.com").fill(email); await page.getByRole("button", { name: /Continue/ }).click(); await page.waitForURL((url) => url.pathname === "/create-org", { timeout: 30_000 }); const me = await page.request.get(new URL("/api/auth/me", target.baseUrl).toString()); expect(me.status()).toBe(200); expect(await me.json()).toMatchObject({ user: { email } }); const cookieNames = (await page.context().cookies()).map((cookie) => cookie.name); expect(cookieNames).toContain("wos-session"); expect(cookieNames).not.toContain("wos-login-state"); }); }); }), );