diff --git a/e2e/mfa-step-up-journey.spec.ts b/e2e/mfa-step-up-journey.spec.ts new file mode 100644 index 000000000..19c639cd9 --- /dev/null +++ b/e2e/mfa-step-up-journey.spec.ts @@ -0,0 +1,325 @@ +import type { BrowserContext, Page, Response } from "@playwright/test"; +import * as OTPAuth from "otpauth"; + +import { expect, test } from "./setup/test"; +import { + clearAuthRateLimits, + dropMfaAccount, + expireStepUp, + latestChallengeAttempts, + mfaJourneyAccount, + seedMfaAccount, + type MfaJourneyAccount, +} from "./setup/mfa-journey-fixture"; + +/** + * The whole second-factor path, end to end, in a real browser: enrol TOTP from + * the settings surface, sign out, sign back in through the challenge, and step + * up for an action that demands a recent factor. + * + * ## Why one test and not six + * + * Every stage here is the previous stage's output. The confirm code is the one + * that sets the replay floor; the recovery batch the sign-in spends is the one + * the successful regeneration issued; the expired-step-up refusal is only + * meaningful against the session the successful one just used. Split across + * tests, each would have to re-enrol a factor, and enrolment is exactly what + * the first stage is testing. So it is one journey, with `test.step` marking + * the stages in the report, and the account is thrown away afterwards. + * + * ## Controls + * + * Four refusals ride alongside the four positives, because a gate that has + * never been seen to refuse is not known to be a gate: + * + * - a wrong code at the login challenge — 401, no session cookie, and the + * ticket's attempt counter moved (read from the row, since the wire's 401 + * is deliberately identical for every reason); + * - the enrolment code replayed at the login challenge — 401. The server + * implements this as a monotonic `User.totpLastStep` floor advanced on + * every accept (`src/lib/auth/mfa/verify-factor.ts:57-77`), so a code + * already spent is refused even inside its own 30-second life; + * - the sensitive action once the step-up stamp has aged out — 401 with + * `meta.errorCode = "auth.stepup.required"`, and the card's prompt; + * - a recovery code presented a second time — 401. The matched row is burned + * on first use. + * + * ## Codes without a clock + * + * Every code is computed from the enrolment secret with the `otpauth` + * dependency the server itself verifies against, at an explicit timestamp — so + * nothing here waits for a step boundary. The sign-in code is generated one + * step AHEAD of now: the server accepts ±1 step of drift, and a future step is + * necessarily above the replay floor the enrolment left behind, whichever side + * of a boundary the request lands on. + */ + +const TOTP_PERIOD_SECONDS = 30; +const RECOVERY_CODE_COUNT = 10; +const REGENERATE_ENDPOINT = "/api/auth/me/mfa/recovery-codes/regenerate"; + +/** A code for `atMs`, built the way `src/lib/auth/mfa/totp.ts` verifies it. */ +function totpCodeAt(secretBase32: string, atMs: number): string { + return new OTPAuth.TOTP({ + issuer: "HealthLog", + label: "HealthLog", + algorithm: "SHA1", + digits: 6, + period: TOTP_PERIOD_SECONDS, + secret: OTPAuth.Secret.fromBase32(secretBase32), + }).generate({ timestamp: atMs }); +} + +async function sessionCookie(context: BrowserContext) { + const cookies = await context.cookies(); + return cookies.find((c) => c.name === "healthlog_session"); +} + +/** + * Open the password form and submit the account's credentials. + * + * The login page opens in passkey mode; the password form mounts only after + * the switch. Returns the `/api/auth/login` response so the caller can assert + * what the SERVER said, not only what the page painted — the two answers this + * journey cares about (a session, or a challenge) are both HTTP 200 and only + * the body tells them apart. + */ +async function submitPassword( + page: Page, + account: MfaJourneyAccount, +): Promise { + await clearAuthRateLimits(); + await page.goto("/auth/login"); + await page.getByTestId("login-use-password").click(); + await page.locator("#email").fill(account.username); + await page.locator("#password").fill(account.password); + const answer = page.waitForResponse( + (res) => + res.url().includes("/api/auth/login") && + res.request().method() === "POST", + ); + await page.getByTestId("login-password-submit").click(); + return answer; +} + +/** Submit a factor at the login challenge and return the verify response. */ +async function submitFactor(page: Page, code: string): Promise { + await page.getByTestId("mfa-code-input").fill(code); + const answer = page.waitForResponse( + (res) => + res.url().includes("/api/auth/mfa/verify") && + res.request().method() === "POST", + ); + await page.getByTestId("mfa-code-submit").click(); + return answer; +} + +/** + * Open the regeneration dialog, confirm it, and return the route's answer. + * + * The confirming click calls `preventDefault()`, which is what keeps the + * dialog up while the request is in flight — and nothing takes it down again. + * On SUCCESS that goes unnoticed, because the fresh-codes panel replaces the + * whole branch the dialog lives in and it unmounts with it. On a REFUSAL the + * branch stays, so the dialog is still open (`data-state="open"`, measured) + * over a card that is now showing the step-up message underneath it. So the + * journey dismisses it before reading anything on the card. + */ +async function regenerateRecoveryCodes(page: Page): Promise { + await page.getByTestId("recovery-regenerate").click(); + const answer = page.waitForResponse( + (res) => + res.url().includes(REGENERATE_ENDPOINT) && + res.request().method() === "POST", + ); + await page.getByTestId("recovery-regenerate-confirm").click(); + const settled = await answer; + await page.keyboard.press("Escape"); + await expect(page.locator('[data-slot="alert-dialog-content"]')).toBeHidden(); + return settled; +} + +/** The codes currently on screen, gated on the panel being painted. */ +async function visibleRecoveryCodes(page: Page): Promise { + const panel = page.getByTestId("recovery-codes"); + await expect(panel).toBeVisible(); + const items = panel.locator("li"); + await expect(items).toHaveCount(RECOVERY_CODE_COUNT); + return items.allInnerTexts(); +} + +test.describe("second factor", () => { + test.afterEach(async () => { + await dropMfaAccount(mfaJourneyAccount(test.info().project.name)); + }); + + test("enrols TOTP, signs in with it, and steps up for a sensitive action", async ({ + page, + context, + }) => { + // Four sign-ins, an Argon2id verify on each, an enrolment and two + // regenerations. Comfortably outside the suite's 30 s default. + test.setTimeout(180_000); + + // One account per project: both browser projects run this file, and the + // journey moves the account's factor state, its replay floor and its + // recovery batch. See the fixture's header. + const account: MfaJourneyAccount = mfaJourneyAccount( + test.info().project.name, + ); + await seedMfaAccount(account); + + let secret = ""; + let enrolmentCode = ""; + let liveRecoveryCodes: string[] = []; + + await test.step("signs in with a password alone while no factor exists", async () => { + const answer = await submitPassword(page, account); + expect(answer.status()).toBe(200); + const body = (await answer.json()) as { + meta?: { mfaRequired?: boolean }; + }; + expect(body.meta?.mfaRequired).toBeUndefined(); + await page.waitForURL("/"); + expect(await sessionCookie(context)).toBeDefined(); + }); + + await test.step("enrols an authenticator from the settings surface", async () => { + await page.goto("/settings/security"); + await expect(page.getByTestId("totp-card")).toBeVisible(); + + const setup = page.waitForResponse( + (res) => + res.url().includes("/api/auth/me/mfa/totp/setup") && + res.request().method() === "POST", + ); + await page.getByTestId("totp-setup-start").click(); + const setupAnswer = await setup; + expect(setupAnswer.status()).toBe(200); + const setupBody = (await setupAnswer.json()) as { + data: { otpauthUri: string; totpSecret: string }; + }; + secret = setupBody.data.totpSecret; + // The QR the page renders and the manual secret beside it must describe + // the same authenticator, or a user who scans and a user who types end + // up enrolled against different secrets. + expect(setupBody.data.otpauthUri).toContain(secret); + + enrolmentCode = totpCodeAt(secret, Date.now()); + await page.getByTestId("totp-confirm-code").fill(enrolmentCode); + const confirm = page.waitForResponse( + (res) => + res.url().includes("/api/auth/me/mfa/totp/confirm") && + res.request().method() === "POST", + ); + await page.getByTestId("totp-confirm-submit").click(); + expect((await confirm).status()).toBe(200); + + liveRecoveryCodes = await visibleRecoveryCodes(page); + await expect(page.getByTestId("totp-error")).toBeHidden(); + }); + + await test.step("rotates the recovery codes while the step-up is fresh", async () => { + // The confirming session is stamped `mfaVerifiedAt` by the confirm route, + // so the freshest possible step-up is the one right after enrolment. + await page.getByTestId("recovery-codes-dismiss").click(); + const answer = await regenerateRecoveryCodes(page); + expect(answer.status()).toBe(200); + + const rotated = await visibleRecoveryCodes(page); + expect(rotated).not.toEqual(liveRecoveryCodes); + await expect(page.getByTestId("totp-error")).toBeHidden(); + liveRecoveryCodes = rotated; + }); + + await test.step("refuses the same rotation once the step-up has aged out", async () => { + await page.getByTestId("recovery-codes-dismiss").click(); + await expireStepUp(account); + + const answer = await regenerateRecoveryCodes(page); + expect(answer.status()).toBe(401); + const body = (await answer.json()) as { + data: null; + meta?: { errorCode?: string }; + }; + expect(body.data).toBeNull(); + expect(body.meta?.errorCode).toBe("auth.stepup.required"); + // And the card says so rather than failing silently. + await expect(page.getByTestId("totp-error")).toBeVisible(); + }); + + await test.step("asks for the second factor on the next sign-in", async () => { + expect((await page.request.post("/api/auth/logout")).status()).toBe(200); + + const answer = await submitPassword(page, account); + expect(answer.status()).toBe(200); + const body = (await answer.json()) as { + data: null; + meta?: { mfaRequired?: boolean; methods?: string[] }; + }; + // A correct password is not a session here: the partial state lives in + // the ticket and nothing is minted until the factor passes. + expect(body.data).toBeNull(); + expect(body.meta?.mfaRequired).toBe(true); + expect(body.meta?.methods).toEqual( + expect.arrayContaining(["totp", "recovery"]), + ); + await expect(page.getByTestId("mfa-login-step")).toBeVisible(); + expect(await sessionCookie(context)).toBeUndefined(); + }); + + await test.step("refuses a wrong code and counts the attempt", async () => { + const answer = await submitFactor(page, "000000"); + expect(answer.status()).toBe(401); + await expect(page.getByTestId("mfa-error")).toBeVisible(); + await expect(page.getByTestId("mfa-login-step")).toBeVisible(); + expect(await sessionCookie(context)).toBeUndefined(); + expect(await latestChallengeAttempts(account)).toBe(1); + }); + + await test.step("refuses the enrolment code replayed", async () => { + const answer = await submitFactor(page, enrolmentCode); + expect(answer.status()).toBe(401); + await expect(page.getByTestId("mfa-error")).toBeVisible(); + expect(await sessionCookie(context)).toBeUndefined(); + expect(await latestChallengeAttempts(account)).toBe(2); + }); + + await test.step("accepts a current code and lands on the dashboard", async () => { + // One step ahead of now — inside the ±1 drift window and necessarily + // above the floor the enrolment code left. See the file header. + const answer = await submitFactor( + page, + totpCodeAt(secret, Date.now() + TOTP_PERIOD_SECONDS * 1000), + ); + expect(answer.status()).toBe(200); + await page.waitForURL("/"); + expect(await sessionCookie(context)).toBeDefined(); + }); + + await test.step("spends a recovery code once", async () => { + expect((await page.request.post("/api/auth/logout")).status()).toBe(200); + await submitPassword(page, account); + await expect(page.getByTestId("mfa-login-step")).toBeVisible(); + + await page.getByTestId("mfa-toggle-recovery").click(); + const answer = await submitFactor(page, liveRecoveryCodes[0]); + expect(answer.status()).toBe(200); + await page.waitForURL("/"); + expect(await sessionCookie(context)).toBeDefined(); + }); + + await test.step("refuses the same recovery code a second time", async () => { + expect((await page.request.post("/api/auth/logout")).status()).toBe(200); + await submitPassword(page, account); + await expect(page.getByTestId("mfa-login-step")).toBeVisible(); + + await page.getByTestId("mfa-toggle-recovery").click(); + const answer = await submitFactor(page, liveRecoveryCodes[0]); + expect(answer.status()).toBe(401); + await expect(page.getByTestId("mfa-error")).toBeVisible(); + expect(await sessionCookie(context)).toBeUndefined(); + expect(await latestChallengeAttempts(account)).toBe(1); + }); + }); +}); diff --git a/e2e/setup/mfa-journey-fixture.ts b/e2e/setup/mfa-journey-fixture.ts new file mode 100644 index 000000000..ce6ccac07 --- /dev/null +++ b/e2e/setup/mfa-journey-fixture.ts @@ -0,0 +1,214 @@ +/** + * The second-factor journey's account, and nothing else. + * + * ## Why a throwaway account + * + * Enrolling TOTP changes how an account logs in for every spec that shares it: + * `/api/auth/login` stops minting a session and answers with a challenge + * instead. `global-setup.ts` already learned that once — it has to log the + * guardian in BEFORE stamping its factor, because the password-only capture + * cannot complete a TOTP login. So this journey, which enrols a factor from a + * clean slate and then signs in through it four times, owns an account nobody + * else touches and re-seeds it from scratch on every run. + * + * The username carries the Playwright PROJECT name. Both browser projects run + * this file, `fullyParallel` is on, and the journey moves one account's factor + * state, its `totpLastStep` replay floor, and its recovery codes. Two projects + * driving one account would be testing which browser got there first. A second + * account costs one INSERT and removes the question. + * + * ## Why Postgres rather than the product + * + * Two of the things this journey needs have no endpoint, by design: + * + * - an EXPIRED step-up. `requireFreshMfa` reads `Session.mfaVerifiedAt` and + * compares it against a five-minute window. Nothing clears that stamp on + * request, and waiting five minutes inside a 30-second test is not a test. + * Ageing the stamp is not a bypass and does not weaken the route: the route + * still runs its gate, and what the fixture simulates is time passing. + * - the anonymous auth buckets. `/api/auth/login` allows five attempts per IP + * per fifteen minutes and `/api/auth/mfa/verify` ten; this journey spends + * four and five of them, and every other spec on the machine shares the + * same localhost bucket. `global-setup.ts` clears the same keys between its + * own logins for the same reason. + * + * Seeded through `pg`, the transport `global-setup.ts`, `vault-fixture.ts` and + * `managed-profile-fixture.ts` all use. + */ +import { hash } from "@node-rs/argon2"; +import pg from "pg"; + +export interface MfaJourneyAccount { + username: string; + email: string; + password: string; +} + +/** One account per Playwright project — see the file header. */ +export function mfaJourneyAccount(projectName: string): MfaJourneyAccount { + const slug = projectName.replace(/[^a-z0-9]+/gi, "-").toLowerCase(); + return { + username: `e2e-mfa-${slug}`, + email: `e2e-mfa-${slug}@healthlog.test`, + // 16 chars, same shape as the fixture passwords in `global-setup.ts`. + password: "Hw6!Tq2pB9nZ4vLd", + }; +} + +function connect(): pg.Pool { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error("[mfa-journey-fixture] DATABASE_URL is not set"); + } + return new pg.Pool({ connectionString: url }); +} + +/** Prisma's `cuid()` shape, close enough for a hand-written row. */ +function cuid(): string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let id = "c"; + for (let i = 0; i < 24; i += 1) { + id += chars[Math.floor(Math.random() * chars.length)]; + } + return id; +} + +/** Argon2id at the app's own parameters (`src/lib/auth/argon2-params.mjs`). */ +async function hashPassword(password: string): Promise { + return hash(password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); +} + +/** + * Drop whatever a previous run left and insert the account fresh. + * + * DELETE rather than upsert: the journey's whole subject is the transition from + * no second factor to one, and an account carrying a confirmed secret, a + * `totpLastStep` replay floor, or a half-spent recovery batch from an earlier + * run would start it in the middle. The row cascades to its sessions, its + * recovery codes and its MFA challenges, so one statement clears all of it. + * + * `onboarding_completed_at` and `onboarding_tour_completed` are stamped for the + * reason `global-setup.ts` documents: without them the proxy bounces the new + * session to `/onboarding` and the tour overlay swallows every click. + */ +export async function seedMfaAccount( + account: MfaJourneyAccount, +): Promise { + const pool = connect(); + try { + await pool.query("DELETE FROM users WHERE username = $1", [ + account.username, + ]); + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, + created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true)`, + [ + cuid(), + account.username, + account.email, + await hashPassword(account.password), + new Date(), + ], + ); + } finally { + await pool.end(); + } +} + +/** Remove the account and everything that cascades off it. */ +export async function dropMfaAccount( + account: MfaJourneyAccount, +): Promise { + const pool = connect(); + try { + await pool.query("DELETE FROM users WHERE username = $1", [ + account.username, + ]); + } finally { + await pool.end(); + } +} + +/** + * Empty the anonymous auth buckets. + * + * Both keys are per-IP and every spec on the machine is the same IP, so this is + * the same unconditional clear `global-setup.ts` runs between its own logins. + * Call it before each sign-in this journey performs; the product's 429 is a + * real answer, but it is not the answer any of these tests is about. + */ +export async function clearAuthRateLimits(): Promise { + const pool = connect(); + try { + await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`); + } finally { + await pool.end(); + } +} + +/** + * The attempt counter on the account's most recent login challenge. + * + * `/api/auth/mfa/verify` answers a wrong factor with the same generic 401 it + * gives an unknown ticket, so the throttle it applies is invisible from the + * wire. The counter is the only place the refusal is recorded, and a refusal + * the server does not count is a refusal an attacker can repeat for free — so + * the journey reads it rather than taking the 401 as proof on its own. + */ +export async function latestChallengeAttempts( + account: MfaJourneyAccount, +): Promise { + const pool = connect(); + try { + const res = await pool.query<{ attempts: number }>( + `SELECT attempts FROM mfa_challenges + WHERE user_id = (SELECT id FROM users WHERE username = $1) + ORDER BY created_at DESC + LIMIT 1`, + [account.username], + ); + const row = res.rows[0]; + if (!row) { + throw new Error( + `[mfa-journey-fixture] no login challenge for ${account.username}`, + ); + } + return row.attempts; + } finally { + await pool.end(); + } +} + +/** + * Age the session's step-up stamp past the five-minute window. + * + * A zero-row update means the jar the journey is driving points at no session + * at all, and the refusal that follows would have nothing to do with freshness. + * Fail loudly here instead, the way `managed-profile-fixture.ts` does. + */ +export async function expireStepUp(account: MfaJourneyAccount): Promise { + const pool = connect(); + try { + const aged = await pool.query( + `UPDATE sessions SET mfa_verified_at = NOW() - INTERVAL '10 minutes' + WHERE user_id = (SELECT id FROM users WHERE username = $1) + AND mfa_verified_at IS NOT NULL`, + [account.username], + ); + if ((aged.rowCount ?? 0) === 0) { + throw new Error( + `[mfa-journey-fixture] no step-up-stamped session for ${account.username}`, + ); + } + } finally { + await pool.end(); + } +} diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index eede46032..778fe26b2 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -424,6 +424,7 @@ export default function LoginPage() { {mode === "passkey" ? (