Files
HealthLog/e2e/mfa-step-up-journey.spec.ts
Marc-André Bombeck b254814bae test(e2e): walk the re-proof dialog when the rotation's step-up has aged out
An aged-out step-up no longer ends in a refusal sentence on the card: the
refusal names the proofs it takes and the shared re-proof dialog opens above
the pending confirmation. The journey now asserts that dialog, that
cancelling it sends nothing more and leaves the codes as they were, and that
proving the factor there runs the rotation once more and issues a fresh
batch. The proof's code waits for the step after the enrolment code, which
the replay floor would refuse.

Refs #959
2026-10-17 10:43:57 +02:00

541 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"` naming the proofs it would
* take, and the re-proof dialog in place of the rotation. Cancelling it
* sends nothing more and changes nothing; proving the factor there runs
* the rotation once more, and only then does it go through;
* - 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<void> {
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);
}
function stepOf(atMs: number): number {
return Math.floor(atMs / (TOTP_PERIOD_SECONDS * 1000));
}
/**
* Park until the TOTP step after `step` has begun, so a code minted for now is
* above the replay floor that step's code left. Bounded by one period.
*/
async function alignPastStep(page: Page, step: number): Promise<void> {
const nextBoundary = (step + 1) * TOTP_PERIOD_SECONDS * 1000;
const wait = nextBoundary - Date.now();
if (wait > 0) await page.waitForTimeout(wait + 250);
}
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<Response> {
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<Response> {
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 and confirm it, without waiting for the end.
*
* The confirming click calls `preventDefault()` so the dialog survives the
* request in flight, and it stays up for as long as the rotation is pending —
* which, when the step-up has aged out, includes the re-proof dialog that opens
* above it. Returns the route's first answer.
*/
async function confirmRegeneration(page: Page): Promise<Response> {
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();
return answer;
}
/**
* Open the regeneration dialog, confirm it, and return the route's answer.
*
* The mutation takes the dialog down again when the request settles; on a
* success the fresh-codes panel replaces the whole branch the dialog lives in.
* So the dialog being gone here is a product assertion, not housekeeping:
* nothing in this helper dismisses it.
*/
async function regenerateRecoveryCodes(page: Page): Promise<Response> {
const settled = await confirmRegeneration(page);
await expect(page.getByTestId("recovery-regenerate-dialog")).toBeHidden();
return settled;
}
/** The first refusal of an aged-out rotation: what the server said. */
async function expectStepUpRefusal(answer: Response): Promise<void> {
expect(answer.status()).toBe(401);
const body = (await answer.json()) as {
data: null;
meta?: { errorCode?: string; methods?: string[] };
};
expect(body.data).toBeNull();
expect(body.meta?.errorCode).toBe("auth.stepup.required");
// The proofs that clear it. A password never stands in for the second
// factor, so it is not offered.
expect(body.meta?.methods).toEqual(["totp"]);
}
/** The codes currently on screen, gated on the panel being painted. */
async function visibleRecoveryCodes(page: Page): Promise<string[]> {
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<APIResponse | null> {
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[] = [];
let enrolmentStep = 0;
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);
const enrolledAt = Date.now();
enrolmentStep = stepOf(enrolledAt);
await page
.getByTestId("totp-confirm-code")
.fill(totpCodeAt(secret, enrolledAt));
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("asks for the factor again once the step-up has aged out", async () => {
await page.getByTestId("recovery-codes-dismiss").click();
await expireStepUp(account);
const regenerateDialog = page.getByTestId("recovery-regenerate-dialog");
const reproofDialog = page.getByTestId("factor-reauth-dialog");
const regenerateCalls: string[] = [];
page.on("request", (req) => {
if (req.url().includes(REGENERATE_ENDPOINT))
regenerateCalls.push(req.method());
});
// The refusal opens the re-proof dialog above the pending confirmation,
// and the dialog holds the focus.
await expectStepUpRefusal(await confirmRegeneration(page));
await expect(reproofDialog).toBeVisible();
await expect
.poll(() =>
reproofDialog.evaluate((el) => el.contains(document.activeElement)),
)
.toBe(true);
await expect(page.getByTestId("totp-error")).toBeHidden();
// Cancelled: nothing more is sent, both dialogs go, no sentence and no
// fresh batch on the card. The live codes are still the live codes.
await reproofDialog.getByRole("button", { name: /cancel/i }).click();
await expect(reproofDialog).toBeHidden();
await expect(regenerateDialog).toBeHidden();
await expect(page.getByTestId("totp-error")).toBeHidden();
await expect(page.getByTestId("recovery-codes")).toBeHidden();
expect(regenerateCalls).toEqual(["POST"]);
// Proved: the rotation runs once more and goes through.
await expectStepUpRefusal(await confirmRegeneration(page));
await expect(reproofDialog).toBeVisible();
// The code the enrolment spent is below the replay floor; wait for the
// step after it when it is still current. Bounded by one period.
await alignPastStep(page, enrolmentStep);
await reproofDialog
.locator('input[autocomplete="one-time-code"]')
.fill(totpCodeAt(secret, Date.now()));
const proof = page.waitForResponse(
(res) =>
res.url().includes("/api/auth/reproof") &&
res.request().method() === "POST",
);
const retried = page.waitForResponse(
(res) =>
res.url().includes(REGENERATE_ENDPOINT) &&
res.request().method() === "POST" &&
res.status() === 200,
);
await page.getByTestId("factor-reauth-submit").click();
expect((await proof).status()).toBe(200);
expect((await retried).status()).toBe(200);
await expect(reproofDialog).toBeHidden();
await expect(regenerateDialog).toBeHidden();
expect(regenerateCalls).toEqual(["POST", "POST", "POST"]);
const rotated = await visibleRecoveryCodes(page);
expect(rotated).not.toEqual(liveRecoveryCodes);
await expect(page.getByTestId("totp-error")).toBeHidden();
liveRecoveryCodes = rotated;
});
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();
});
}
});
});