diff --git a/e2e/mfa-step-up-journey.spec.ts b/e2e/mfa-step-up-journey.spec.ts new file mode 100644 index 000000000..7a42acb95 --- /dev/null +++ b/e2e/mfa-step-up-journey.spec.ts @@ -0,0 +1,448 @@ +import type { + APIResponse, + BrowserContext, + Page, + Response, +} from "@playwright/test"; +import * as OTPAuth from "otpauth"; + +import { + TOTP_ALGORITHM, + TOTP_DIGITS, + TOTP_PERIOD_SECONDS, +} from "@/lib/auth/mfa/totp"; + +import { expect, test } from "./setup/test"; +import { + clearAuthRateLimits, + dropMfaAccount, + expireStepUp, + latestChallengeAttempts, + latestMfaFailure, + 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 code the sign-in spends + * is the code the next stage replays; the recovery batch that 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 + * + * Five 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 code the previous sign-in just spent, replayed at the next challenge + * — 401. The server implements this as a monotonic `User.totpLastStep` + * floor advanced on every accept (`src/lib/auth/mfa/verify-factor.ts`), 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; + * - one login past the documented per-IP cap — 429, with the bucket's own + * headers. Measured in one project, for the reason written at that step. + * + * ## Reading the replay refusal, not guessing it + * + * Both TOTP refusals answer with the same generic 401, so the status code + * cannot say WHY. A code that has drifted out of its ±1-step window is refused + * before the replay floor is ever consulted, which means a status-only control + * would stay green with the floor deleted. The route records the distinction — + * `auth.mfa.failed` with `details.replay`, awaited before it answers — so both + * refusals assert that verdict as a pair: false for the wrong code, true for + * the replay. + * + * ## 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. 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. + * + * The one place the boundary matters is that code's second life as the replay: + * it stays in-window for two steps past the one it names, and the sign-out and + * sign-in between the two submissions eat into that. So the journey parks for + * the next step boundary — bounded by a single period, and only when the + * current step has too little left — instead of hoping the wall clock is kind. + */ + +const RECOVERY_CODE_COUNT = 10; +const REGENERATE_ENDPOINT = "/api/auth/me/mfa/recovery-codes/regenerate"; +/** `/api/auth/login`, per IP per 15 minutes (`src/lib/rate-limit.ts`). */ +const LOGIN_ATTEMPT_LIMIT = 5; +/** The project that measures that cap — see the closing step for why one. */ +const THROTTLE_PROBE_PROJECT = "chromium-desktop"; +/** + * The margin the accept→replay pair needs. A code named for step N verifies + * until the end of step N+1 (drift −1), so generating it one step ahead leaves + * at least two periods; below this the journey waits for the next boundary. + */ +const REPLAY_MARGIN_MS = 75_000; + +/** 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: TOTP_ALGORITHM, + digits: TOTP_DIGITS, + period: TOTP_PERIOD_SECONDS, + secret: OTPAuth.Secret.fromBase32(secretBase32), + }).generate({ timestamp: atMs }); +} + +/** + * Park until the next TOTP step starts, when the current one has too little + * left for the code about to be minted to survive its replay. + * + * Bounded by a single period and usually a no-op: it waits only for the last + * seconds of a step, and a step is 30 of them. + */ +async function alignToStepBoundary(page: Page): Promise { + const periodMs = TOTP_PERIOD_SECONDS * 1000; + const now = Date.now(); + const nextBoundary = (Math.floor(now / periodMs) + 1) * periodMs; + // A code minted now names the next step and verifies until two periods + // after that step begins. + if (nextBoundary + 2 * periodMs - now >= REPLAY_MARGIN_MS) return; + await page.waitForTimeout(nextBoundary - now); +} + +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.getByTestId("login-email").fill(account.username); + await page.getByTestId("login-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()` so the dialog survives the + * request in flight; the mutation takes it down again when the request + * settles, whatever the answer was. That matters on a REFUSAL, where the card + * behind the dialog is the only thing that says why — on a success the + * fresh-codes panel replaces the whole branch the dialog lives in and it would + * unmount either way. So the dialog being gone here is a product assertion, + * not housekeeping: nothing in this helper dismisses it. + */ +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 expect(page.getByTestId("recovery-regenerate-dialog")).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(); +} + +/** + * Spend the login bucket from empty and hand back the answer that went over. + * + * The bucket is per-IP, and the sibling browser project running this same file + * shares it: its sign-ins clear the bucket, and its own attempts fill it. Both + * directions disturb a round rather than fake one — a disturbed round is + * retried, and a round only counts when the whole documented shape holds, the + * cap's worth of attempts answered normally and the next one refused. Returns + * null when no round came out clean, which fails loudly at the call site. + */ +async function reachLoginCap( + page: Page, + account: MfaJourneyAccount, +): Promise { + const attempt = () => + page.request.post("/api/auth/login", { + data: { email: account.username, password: "not-this-account-password" }, + }); + + for (let round = 0; round < 3; round += 1) { + await clearAuthRateLimits(); + let clean = true; + for (let i = 0; i < LOGIN_ATTEMPT_LIMIT && clean; i += 1) { + clean = (await attempt()).status() === 401; + } + if (!clean) continue; + const capped = await attempt(); + if (capped.status() === 429) return capped; + } + return null; +} + +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, + }) => { + // Five sign-ins, an Argon2id verify on each, an enrolment, two + // regenerations and a bounded park on a step boundary. Comfortably + // outside the suite's 30 s default. + test.setTimeout(240_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 spentCode = ""; + 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); + + await page + .getByTestId("totp-confirm-code") + .fill(totpCodeAt(secret, Date.now())); + 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, on a card the closed dialog no longer covers. + 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 wrong codes and counts each 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); + // The negative half of the replay pair: a code that never matched the + // secret is not a replay, and the server says so. + expect((await latestMfaFailure(account)).replay).toBe(false); + + // A second refusal moves the counter again rather than resetting it. + expect((await submitFactor(page, "000001")).status()).toBe(401); + expect(await latestChallengeAttempts(account)).toBe(2); + }); + + await test.step("accepts a current code and lands on the dashboard", async () => { + await alignToStepBoundary(page); + // One step ahead of now — inside the ±1 drift window and necessarily + // above the floor the enrolment code left. See the file header. + spentCode = totpCodeAt(secret, Date.now() + TOTP_PERIOD_SECONDS * 1000); + const answer = await submitFactor(page, spentCode); + expect(answer.status()).toBe(200); + await page.waitForURL("/"); + expect(await sessionCookie(context)).toBeDefined(); + }); + + await test.step("refuses the code the last sign-in spent", async () => { + expect((await page.request.post("/api/auth/logout")).status()).toBe(200); + await submitPassword(page, account); + await expect(page.getByTestId("mfa-login-step")).toBeVisible(); + + const answer = await submitFactor(page, spentCode); + expect(answer.status()).toBe(401); + await expect(page.getByTestId("mfa-error")).toBeVisible(); + expect(await sessionCookie(context)).toBeUndefined(); + expect(await latestChallengeAttempts(account)).toBe(1); + // The code is still inside its own life — the floor is what refused it, + // and this is the assertion that fails if the floor is removed. + expect((await latestMfaFailure(account)).replay).toBe(true); + }); + + await test.step("spends a recovery code once", async () => { + await page.getByTestId("mfa-recovery-toggle").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-recovery-toggle").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); + }); + + // Every sign-in above clears this bucket, which makes the throttle the one + // auth control the journey rides on and never sees. It costs one step to + // see it: the account is about to be dropped and nothing else signs in + // from here. + // + // Measured in ONE project. The bucket is keyed per IP and every spec on + // the machine is that one IP, the sibling browser project running this + // same file included — two probes emptying and filling one bucket beside + // each other measure their own interference, not the limiter, and were + // seen to. The limit is server behaviour with no viewport in it, so the + // second project would only re-measure the first. + if (test.info().project.name === THROTTLE_PROBE_PROJECT) { + await test.step("refuses the login past the per-IP cap", async () => { + const capped = await reachLoginCap(page, account); + expect( + capped, + "no round of login attempts reached the cap", + ).not.toBeNull(); + expect(capped?.status()).toBe(429); + expect(capped?.headers()["x-ratelimit-remaining"]).toBe("0"); + expect(capped?.headers()["x-ratelimit-reset"]).toBeTruthy(); + // Hand the bucket back to the machine — it is shared per IP. + await clearAuthRateLimits(); + }); + } + }); +}); diff --git a/e2e/setup/mfa-journey-fixture.ts b/e2e/setup/mfa-journey-fixture.ts new file mode 100644 index 000000000..3718f969a --- /dev/null +++ b/e2e/setup/mfa-journey-fixture.ts @@ -0,0 +1,268 @@ +/** + * 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 two anonymous auth buckets this journey spends. + * + * Both keys are `${prefix}:${ip}` and every spec on the machine is the same IP, + * so this is the clear `global-setup.ts` runs between its own logins — narrowed + * to the surfaces the journey actually touches. The wider `auth:%` it used to + * run also swept register, passkey and reset buckets, which this file has no + * business emptying: a future spec measuring one of those throttles would find + * its evidence deleted by an unrelated sign-in and never know. + * + * Call it before each sign-in; the product's 429 is a real answer, but it is + * not the answer the journey's stages are about — the closing stage asks for it + * deliberately, and clears afterwards. + */ +export async function clearAuthRateLimits(): Promise { + const pool = connect(); + try { + await pool.query( + `DELETE FROM rate_limits + WHERE key LIKE 'auth:login:%' OR key LIKE 'auth:mfa-verify:%'`, + ); + } 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(); + } +} + +/** + * The server's own verdict on the account's most recent refused factor. + * + * `/api/auth/mfa/verify` answers every refusal with the same 401, so the wire + * cannot tell a replayed code from a wrong one — and that distinction is the + * whole subject of the replay control. The route does record it: it writes + * `auth.mfa.failed` with `details.replay` and AWAITS that write before + * responding, so the row is on disk by the time the response lands. + * + * Read it rather than inferring it. A replayed code that has drifted out of its + * ±1-step window is refused as out-of-window — same 401, `replay: false` — and + * a control that only reads the status code cannot tell the two apart, which + * means it would stay green with the replay floor deleted. + */ +export async function latestMfaFailure( + account: MfaJourneyAccount, +): Promise<{ replay: boolean }> { + const pool = connect(); + try { + const res = await pool.query<{ details: string | null }>( + `SELECT details FROM audit_logs + WHERE user_id = (SELECT id FROM users WHERE username = $1) + AND action = 'auth.mfa.failed' + ORDER BY created_at DESC + LIMIT 1`, + [account.username], + ); + const row = res.rows[0]; + if (!row?.details) { + throw new Error( + `[mfa-journey-fixture] no refused factor recorded for ${account.username}`, + ); + } + const details = JSON.parse(row.details) as { replay?: unknown }; + if (typeof details.replay !== "boolean") { + throw new Error( + `[mfa-journey-fixture] refusal for ${account.username} carries no replay verdict`, + ); + } + return { replay: details.replay }; + } 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..2ece6eabc 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" ? (