/** * Behavioural suite for token-bound step-up elevations, against a real Postgres * and the real gate — no mocked Prisma, no mocked auth. * * This mechanism lets a Bearer token reach a surface that has been cookie-only * since v1.23, so the cases below are written as attacks rather than as happy * paths. Each one must fail closed if its protection is removed; a green suite * over a broken gate would be worse than no suite at all. * * Two of them need a real database to mean anything. Single-use atomicity is a * claim about what Postgres does when two writers race the same row, and the * unit mocks cannot produce that race — E3b runs the two redemptions genuinely * concurrently against one connection pool. The token binding is a claim about a * WHERE clause, and only a real query proves the clause is in it. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; import * as OTPAuth from "otpauth"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; process.env.API_TOKEN_HMAC_KEY ??= "test-hmac-key-step-up-elevation-32-bytes-minimum-1234567890"; const { hashToken } = await import("@/lib/auth/hmac"); const { hashPassword } = await import("@/lib/auth/password"); const USER_ID = "user-step-up-test"; const OTHER_USER_ID = "user-step-up-other"; const PASSWORD = "correct horse battery staple 42"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); return { headers: vi.fn(async () => ({ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, })), cookies: vi.fn(async () => ({ get: (name: string) => { const value = cookieJar.get(name); return value ? { name, value } : undefined; }, set: (name: string, value: string) => { cookieJar.set(name, value); }, delete: (name: string) => { cookieJar.delete(name); }, })), }; }); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); beforeEach(async () => { await truncateAllTables(getPrismaClient()); cookieJar.clear(); headerJar.clear(); const passwordHash = await hashPassword(PASSWORD); await getPrismaClient().user.create({ data: { id: USER_ID, username: "step-up", email: "step-up@example.test", timezone: "UTC", passwordHash, }, }); await getPrismaClient().user.create({ data: { id: OTHER_USER_ID, username: "step-up-other", email: "step-up-other@example.test", timezone: "UTC", passwordHash, }, }); }); /** Mint a real wildcard `ApiToken` row. Does NOT arm the header. */ async function mintToken( label: string, userId = USER_ID, ): Promise<{ raw: string; id: string }> { const raw = `hlk_${label}${"0".repeat(64 - label.length)}`; const row = await getPrismaClient().apiToken.create({ data: { userId, name: label, tokenHash: hashToken(raw), permissions: ["*"], }, select: { id: true }, }); return { raw, id: row.id }; } function useToken(raw: string): void { headerJar.set("authorization", `Bearer ${raw}`); } function useElevation(raw: string | null): void { if (raw === null) headerJar.delete("x-step-up"); else headerJar.set("x-step-up", raw); } function req(path: string, method = "GET", body?: unknown): NextRequest { const headers: Record = {}; const auth = headerJar.get("authorization"); if (auth) headers.authorization = auth; const init: RequestInit = { method, headers }; if (body !== undefined) { headers["content-type"] = "application/json"; init.body = JSON.stringify(body); } return new NextRequest(`https://health.example${path}`, init as never); } /** Drive the mint endpoint with an arbitrary proof body. */ async function mintWith( body: Record, ): Promise<{ status: number; body: Record }> { const { POST } = await import("@/app/api/auth/step-up/route"); const res = await POST(req("/api/auth/step-up", "POST", body)); return { status: res.status, body: (await res.json()) as never }; } /** Drive the mint endpoint with a password proof. */ async function mintElevation( password: string, ): Promise<{ status: number; body: Record }> { return mintWith({ method: "password", password }); } /** * Mint a TOTP-proved elevation the way a real client would: enrol a secret, * then present a live code for it. This is the only fresh-factor arm reachable * without a WebAuthn authenticator, so it carries the fresh-factor coverage. */ async function enrolTotp(): Promise { const { generateTotpSecret } = await import("@/lib/auth/mfa/totp"); const { encrypt } = await import("@/lib/crypto"); const secret = generateTotpSecret(); await getPrismaClient().user.update({ where: { id: USER_ID }, data: { totpSecretEncrypted: encrypt(secret), totpConfirmedAt: new Date(), totpLastStep: null, }, }); return secret; } /** Mirrors the server's TOTP parameters — there is no code generator to import. */ function currentTotpCode(secretBase32: string): string { const totp = new OTPAuth.TOTP({ issuer: "HealthLog", label: "HealthLog", algorithm: "SHA1", digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(secretBase32), }); return totp.generate({ timestamp: Date.now() }); } async function mintTotpElevation( secret: string, ): Promise<{ status: number; body: Record }> { return mintWith({ method: "totp", code: currentTotpCode(secret) }); } /** * Seed a known recovery-code batch and return the plaintext. * * The disable route wants a live factor in its BODY on top of the step-up. A * TOTP code spent minting the elevation cannot be reused there — the shared * verifier burns its time-step, by design — so within one 30-second window the * body factor has to be something else. That is exactly how the web behaves * too. */ async function seedRecoveryCodes(): Promise { const { regenerateRecoveryCodes } = await import("@/lib/auth/mfa/recovery-codes"); return regenerateRecoveryCodes(USER_ID); } /** Pull the raw elevation out of a successful mint. */ function elevationOf(result: { body: Record }): string { return (result.body.data as { elevation: string }).elevation; } /** * Some handlers in the set declare no request parameter (`apiHandler(async () => * …)`), so their wrapped type takes none. The runtime always passes one; this * keeps the call sites honest without loosening the handlers themselves. */ type RouteFn = (request: NextRequest) => Promise; /** * POST /api/auth/me/mfa/totp/setup — the cheapest MUTATION in the * elevation-accepting set, and the probe every redemption case drives. * * NOT `GET /api/auth/me/mfa`: that read is plain `requireAuth()` now, so using * it here would have quietly stopped exercising the elevation path at all. */ async function callMfaSetup(): Promise { const { POST } = await import("@/app/api/auth/me/mfa/totp/setup/route"); return await POST(req("/api/auth/me/mfa/totp/setup", "POST")); } /** The status read — plain Bearer, no elevation. */ async function callMfaStatus(): Promise { const { GET } = await import("@/app/api/auth/me/mfa/route"); return await (GET as unknown as RouteFn)(req("/api/auth/me/mfa")); } async function auditReasons(action: string): Promise { const rows = await getPrismaClient().auditLog.findMany({ where: { action }, orderBy: { createdAt: "asc" }, }); return rows.map( (r) => (JSON.parse(r.details ?? "{}") as { reason?: string }).reason ?? "", ); } /** * Poll until an audit reason lands, instead of sleeping a fixed span and hoping. * * The audit write is fire-and-forget, so the old `setTimeout(r, 100)` before * reading the ledger was a race the assertion loses whenever the runner is * busy — which is exactly when CI is slowest and least willing to explain * itself. The failure looked like a broken refusal ("expected [] to include * 'consumed'") when the refusal had in fact worked and only its breadcrumb was * still in flight. On timeout this asserts against the real list, so a genuine * regression still reports what WAS written rather than a bare timeout. */ async function expectAuditReason( action: string, reason: string, timeoutMs = 5_000, ): Promise { const deadline = Date.now() + timeoutMs; let seen: string[] = []; while (Date.now() < deadline) { seen = await auditReasons(action); if (seen.includes(reason)) return; await new Promise((r) => setTimeout(r, 25)); } expect(seen).toContain(reason); } // ── E1 — a stolen token, on its own, gains nothing ─────────────────── describe("E1 — the token alone is not a step-up proof", () => { it("refuses an MFA-management call with no elevation header", async () => { const { raw } = await mintToken("e1a"); useToken(raw); useElevation(null); const res = await callMfaSetup(); // 401 with the machine code the client branches on to launch a re-proof — // not a 200, and not the 403 an unauthorised token would get. expect(res.status).toBe(401); }); it("mints nothing when the body carries no factor proof", async () => { const { raw } = await mintToken("e1b"); useToken(raw); const { POST } = await import("@/app/api/auth/step-up/route"); const res = await POST(req("/api/auth/step-up", "POST", {})); expect(res.status).toBe(422); expect(await getPrismaClient().stepUpElevation.count()).toBe(0); }); it("refuses a fabricated elevation value", async () => { const { raw } = await mintToken("e1c"); useToken(raw); useElevation(`hle_${"f".repeat(64)}`); expect((await callMfaSetup()).status).toBe(401); await expectAuditReason("auth.stepup.elevation.rejected", "unknown"); }); }); // ── E2 — the elevation is bound to one token ───────────────────────── describe("E2 — an elevation is not portable between tokens", () => { it("refuses redemption by a second token of the SAME user", async () => { const a = await mintToken("e2a"); const b = await mintToken("e2b"); useToken(a.raw); const minted = await mintElevation(PASSWORD); expect(minted.status).toBe(200); const elevation = (minted.body.data as { elevation: string }).elevation; // Same account, same scopes, different token row. The binding is the only // thing standing between these two, which is exactly the point. useToken(b.raw); useElevation(elevation); const res = await callMfaSetup(); expect(res.status).toBe(401); await expectAuditReason("auth.stepup.elevation.rejected", "wrong_token"); // And the legitimate holder's elevation survived the attempt. const row = await getPrismaClient().stepUpElevation.findFirst(); expect(row?.consumedAt).toBeNull(); useToken(a.raw); useElevation(elevation); expect((await callMfaSetup()).status).toBe(200); }); it("refuses redemption by another user's token", async () => { const mine = await mintToken("e2c"); const theirs = await mintToken("e2d", OTHER_USER_ID); useToken(mine.raw); const elevation = ( (await mintElevation(PASSWORD)).body.data as { elevation: string } ).elevation; useToken(theirs.raw); useElevation(elevation); expect((await callMfaSetup()).status).toBe(401); }); }); // ── E3 — single use, including under concurrency ───────────────────── describe("E3 — an elevation is consumed exactly once", () => { it("refuses a second sequential redemption", async () => { const { raw } = await mintToken("e3a"); useToken(raw); const elevation = ( (await mintElevation(PASSWORD)).body.data as { elevation: string } ).elevation; useElevation(elevation); expect((await callMfaSetup()).status).toBe(200); expect((await callMfaSetup()).status).toBe(401); await expectAuditReason("auth.stepup.elevation.rejected", "consumed"); }); it("lets exactly one of two CONCURRENT redemptions win", async () => { const { raw, id } = await mintToken("e3b"); useToken(raw); const elevation = ( (await mintElevation(PASSWORD)).body.data as { elevation: string } ).elevation; // Straight at the redemption primitive, so the race is between two real // conditional UPDATEs on one row rather than between two HTTP handlers that // happen to interleave. A check-then-update implementation passes the // sequential case above and fails here. const { claimStepUpElevation } = await import("@/lib/auth/step-up"); const attempt = () => claimStepUpElevation({ rawToken: elevation, userId: USER_ID, apiTokenId: id, requireFreshFactor: false, }); const results: Awaited>[] = await Promise.all([ attempt(), attempt(), attempt(), attempt(), ]); expect(results.filter((r) => r.ok)).toHaveLength(1); expect(results.filter((r) => !r.ok)).toHaveLength(3); }); }); // ── E4 — expiry ────────────────────────────────────────────────────── describe("E4 — an expired elevation is refused", () => { it("refuses one whose window has passed", async () => { const { raw, id } = await mintToken("e4a"); useToken(raw); const elevation = ( (await mintElevation(PASSWORD)).body.data as { elevation: string } ).elevation; // Age the row rather than the clock: the expiry lives in the redemption's // WHERE clause, so this is the honest way to exercise it. await getPrismaClient().stepUpElevation.updateMany({ where: { apiTokenId: id }, data: { expiresAt: new Date(Date.now() - 1_000) }, }); useElevation(elevation); expect((await callMfaSetup()).status).toBe(401); await expectAuditReason("auth.stepup.elevation.rejected", "expired"); }); it("mints a five-minute window, matching the cookie step-up", async () => { const { raw } = await mintToken("e4b"); useToken(raw); const { body } = await mintElevation(PASSWORD); const data = body.data as { expiresInSeconds: number }; const { MFA_STEP_UP_MAX_AGE_SECONDS } = await import("@/lib/api-handler"); expect(data.expiresInSeconds).toBe(MFA_STEP_UP_MAX_AGE_SECONDS); }); }); // ── E5 — the elevation cannot reach admin ──────────────────────────── describe("E5 — an elevation does not satisfy requireAdmin", () => { it("refuses an admin route even for an ADMIN user holding a valid elevation", async () => { await getPrismaClient().user.update({ where: { id: USER_ID }, data: { role: "ADMIN" }, }); const { raw } = await mintToken("e5a"); useToken(raw); const elevation = ( (await mintElevation(PASSWORD)).body.data as { elevation: string } ).elevation; useElevation(elevation); const { GET } = await import("@/app/api/admin/users/route"); const res = await (GET as unknown as RouteFn)(req("/api/admin/users")); // Cookie-only, and the elevation is not a cookie. The account being a // genuine admin is what makes this test worth having: the refusal is the // transport, not the role. expect(res.status).toBe(401); // Unconsumed — the admin gate never even looked at it. const row = await getPrismaClient().stepUpElevation.findFirst(); expect(row?.consumedAt).toBeNull(); }); }); // ── E6 — a wrong password mints nothing, is throttled, is audited ──── describe("E6 — the mint refuses, throttles, and records a bad password", () => { it("returns a generic 401 and writes no row", async () => { const { raw } = await mintToken("e6a"); useToken(raw); const res = await mintElevation("not the password"); expect(res.status).toBe(401); // Same prose for every cause — the response must not say whether the // password was wrong or the account has none. expect(res.body.error).toBe("Verification failed"); expect(await getPrismaClient().stepUpElevation.count()).toBe(0); }); it("is indistinguishable from an account with no password set", async () => { await getPrismaClient().user.update({ where: { id: USER_ID }, data: { passwordHash: null }, }); const { raw } = await mintToken("e6b"); useToken(raw); const res = await mintElevation("anything at all"); expect(res.status).toBe(401); expect(res.body.error).toBe("Verification failed"); }); it("audits the failure with the real reason", async () => { const { raw } = await mintToken("e6c"); useToken(raw); await mintElevation("wrong"); const rows = await getPrismaClient().auditLog.findMany({ where: { action: "auth.stepup.mint.failed" }, }); expect(rows).toHaveLength(1); expect(JSON.parse(rows[0].details ?? "{}")).toMatchObject({ method: "password", reason: "bad_password", }); }); it("throttles a guessing run", async () => { const { raw } = await mintToken("e6d"); useToken(raw); const statuses: number[] = []; for (let i = 0; i < 7; i++) { statuses.push((await mintElevation(`guess-${i}`)).status); } // Five attempts per fifteen minutes, then the bucket closes — the same // ceiling the password-change route uses, for the same Argon2id reason. expect(statuses.filter((s) => s === 401)).toHaveLength(5); expect(statuses.filter((s) => s === 429)).toHaveLength(2); // And the throttle holds even once the caller finds the right password. expect((await mintElevation(PASSWORD)).status).toBe(429); expect(await getPrismaClient().stepUpElevation.count()).toBe(0); }); }); // ── E7 — the web path is untouched ─────────────────────────────────── describe("E7 — the cookie flow is unchanged", () => { async function useCookieSession(userId = USER_ID): Promise { const session = await getPrismaClient().session.create({ data: { userId, expiresAt: new Date(Date.now() + 3_600_000) }, }); cookieJar.set("healthlog_session", session.id); return session.id; } it("serves an MFA-management mutation on a plain cookie session", async () => { await useCookieSession(); expect((await callMfaSetup()).status).toBe(200); }); it("still refuses a step-up-gated route on a cookie with no fresh factor", async () => { await useCookieSession(); const { POST } = await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route"); const res = await POST( req("/api/auth/me/mfa/recovery-codes/regenerate", "POST"), ); expect(res.status).toBe(401); }); it("admits a step-up-gated route on a freshly verified cookie session", async () => { const sessionId = await useCookieSession(); await getPrismaClient().user.update({ where: { id: USER_ID }, data: { totpConfirmedAt: new Date() }, }); await getPrismaClient().session.update({ where: { id: sessionId }, data: { mfaVerifiedAt: new Date() }, }); const { POST } = await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route"); const res = await POST( req("/api/auth/me/mfa/recovery-codes/regenerate", "POST"), ); expect(res.status).toBe(200); }); it("ignores an elevation header when a cookie session is present", async () => { // The cookie branch returns before the header is ever read. Proven by the // elevation surviving unconsumed. const { raw, id } = await mintToken("e7a"); useToken(raw); const elevation = ( (await mintElevation(PASSWORD)).body.data as { elevation: string } ).elevation; await useCookieSession(); useElevation(elevation); expect((await callMfaSetup()).status).toBe(200); const row = await getPrismaClient().stepUpElevation.findFirst({ where: { apiTokenId: id }, }); expect(row?.consumedAt).toBeNull(); }); }); // ── E8 — invalidation on credential rotation ───────────────────────── describe("E8 — an elevation does not outlive its anchor", () => { it("is dropped by a real password change, through the route", async () => { // Driven through POST /api/auth/password rather than by calling the // revocation helper. Calling the helper directly proved only that the helper // deletes rows — deleting the call from BOTH password routes left the old // version of this test green, so the invalidation story was uncovered. const { raw } = await mintToken("e8a"); useToken(raw); const elevation = elevationOf(await mintElevation(PASSWORD)); expect(await getPrismaClient().stepUpElevation.count()).toBe(1); const NEW_PASSWORD = "Zt7#qvbLm2xR!e9Wd4Kp"; const { POST } = await import("@/app/api/auth/password/route"); const res = await POST( req("/api/auth/password", "POST", { currentPassword: PASSWORD, newPassword: NEW_PASSWORD, confirmPassword: NEW_PASSWORD, }), ); expect(res.status).toBe(200); expect(await getPrismaClient().stepUpElevation.count()).toBe(0); // And the value the client still holds is dead. The password route mints a // replacement cookie session, which the shared jar would otherwise pick up // and satisfy the gate on the cookie arm — clear it so this probes the // Bearer arm, which is the one under test. cookieJar.clear(); useElevation(elevation); expect((await callMfaSetup()).status).toBe(401); }); it("is dropped by an operator-forced reset, through the admin route", async () => { const { raw } = await mintToken("e8a2"); useToken(raw); await mintElevation(PASSWORD); expect(await getPrismaClient().stepUpElevation.count()).toBe(1); // The admin route is cookie-only, so the reset arrives on an ADMIN session // while the elevation belongs to the target's token. await getPrismaClient().user.update({ where: { id: OTHER_USER_ID }, data: { role: "ADMIN" }, }); const adminSession = await getPrismaClient().session.create({ data: { userId: OTHER_USER_ID, expiresAt: new Date(Date.now() + 3_600_000), }, }); cookieJar.set("healthlog_session", adminSession.id); headerJar.delete("authorization"); const { POST } = await import("@/app/api/admin/users/[id]/reset-password/route"); const res = await POST( req(`/api/admin/users/${USER_ID}/reset-password`, "POST", { password: "operator chosen long passphrase 77", }), { params: Promise.resolve({ id: USER_ID }) }, ); expect(res.status).toBe(200); expect(await getPrismaClient().stepUpElevation.count()).toBe(0); }); it("is dropped by sign-out-everywhere", async () => { const { raw } = await mintToken("e8a3"); useToken(raw); await mintElevation(PASSWORD); const session = await getPrismaClient().session.create({ data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, }); const { destroyOtherSessions } = await import("@/lib/auth/session"); await destroyOtherSessions(USER_ID, { kind: "session", sessionId: session.id, }); expect(await getPrismaClient().stepUpElevation.count()).toBe(0); }); it("is unredeemable once the bound token is revoked", async () => { const { raw, id } = await mintToken("e8b"); useToken(raw); const elevation = ( (await mintElevation(PASSWORD)).body.data as { elevation: string } ).elevation; await getPrismaClient().apiToken.update({ where: { id }, data: { revoked: true }, }); useElevation(elevation); // Refused at Bearer resolution, before the elevation is even looked at — // which is why no code in the revoke path needs to know about elevations. expect((await callMfaSetup()).status).toBe(401); const row = await getPrismaClient().stepUpElevation.findFirst(); expect(row?.consumedAt).toBeNull(); }); it("is cascade-deleted with the token row", async () => { const { raw, id } = await mintToken("e8c"); useToken(raw); await mintElevation(PASSWORD); await getPrismaClient().apiToken.delete({ where: { id } }); expect(await getPrismaClient().stepUpElevation.count()).toBe(0); }); }); // ── E9 — the happy path actually works end to end ──────────────────── describe("E9 — a native client can manage its second factor", () => { it("mints, redeems, and enrols TOTP", async () => { const { raw } = await mintToken("e9a"); useToken(raw); const elevation = ( (await mintElevation(PASSWORD)).body.data as { elevation: string } ).elevation; useElevation(elevation); const { POST } = await import("@/app/api/auth/me/mfa/totp/setup/route"); const res = await POST(req("/api/auth/me/mfa/totp/setup", "POST")); expect(res.status).toBe(200); const body = (await res.json()) as { data: { totpSecret: string; otpauthUri: string }; }; expect(body.data.totpSecret).toBeTruthy(); expect(body.data.otpauthUri).toMatch(/^otpauth:\/\//); // The pending secret landed, and the elevation is spent — a second action // needs a second proof. const user = await getPrismaClient().user.findUnique({ where: { id: USER_ID }, }); expect(user?.totpSecretEncrypted).toBeTruthy(); expect(user?.totpConfirmedAt).toBeNull(); expect((await callMfaSetup()).status).toBe(401); }); it("refuses the mint surface to a cookie session", async () => { const session = await getPrismaClient().session.create({ data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, }); cookieJar.set("healthlog_session", session.id); headerJar.delete("authorization"); const { POST } = await import("@/app/api/auth/step-up/route"); const res = await POST( req("/api/auth/step-up", "POST", { method: "password", password: PASSWORD, }), ); expect(res.status).toBe(401); expect(await getPrismaClient().stepUpElevation.count()).toBe(0); }); }); // ── E10 — the fresh-factor rule (B1) ───────────────────────────────── describe("E10 — a password proof cannot reach the destructive routes", () => { /** * The attack this closes, in the order it ran before the fix: * 1. mint an elevation with the account password (a stolen token plus a * known password is the whole prerequisite); * 2. POST recovery-codes/regenerate — which takes NO factor in its body — * and receive ten plaintext recovery codes; * 3. POST disable with one of them, satisfying the body check. * The second factor is gone. On the web that chain is impossible because a * password login never stamps `mfaVerifiedAt`. */ async function regenerate(): Promise { const { POST } = await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route"); return POST(req("/api/auth/me/mfa/recovery-codes/regenerate", "POST")); } async function disable(code: string, method = "recovery"): Promise { const { POST } = await import("@/app/api/auth/me/mfa/disable/route"); return POST(req("/api/auth/me/mfa/disable", "POST", { code, method })); } async function removeKey(id: string): Promise { const { DELETE } = await import("@/app/api/auth/me/mfa/webauthn/[id]/route"); return DELETE(req(`/api/auth/me/mfa/webauthn/${id}`, "DELETE"), { params: Promise.resolve({ id }), }); } it("refuses recovery-code regeneration on a password-proved elevation", async () => { await enrolTotp(); const { raw } = await mintToken("e10a"); useToken(raw); useElevation(elevationOf(await mintElevation(PASSWORD))); const res = await regenerate(); expect(res.status).toBe(401); // No codes were minted, so step 2 of the chain yields nothing. expect(await getPrismaClient().mfaRecoveryCode.count()).toBe(0); await expectAuditReason( "auth.stepup.elevation.rejected", "insufficient_factor", ); }); it("refuses disable on a password-proved elevation", async () => { const secret = await enrolTotp(); const { raw } = await mintToken("e10b"); useToken(raw); useElevation(elevationOf(await mintElevation(PASSWORD))); const res = await disable(currentTotpCode(secret), "totp"); expect(res.status).toBe(401); const user = await getPrismaClient().user.findUnique({ where: { id: USER_ID }, }); expect(user?.totpConfirmedAt).not.toBeNull(); }); it("refuses security-key removal on a password-proved elevation", async () => { const key = await getPrismaClient().webauthnMfaCredential.create({ data: { userId: USER_ID, name: "key", credentialId: "cred-e10c", credentialPublicKey: Buffer.from([1, 2, 3]), transports: ["internal"], }, }); const { raw } = await mintToken("e10c"); useToken(raw); useElevation(elevationOf(await mintElevation(PASSWORD))); expect((await removeKey(key.id)).status).toBe(401); expect(await getPrismaClient().webauthnMfaCredential.count()).toBe(1); }); it("refuses at the CLAIM too, not only at the validation", async () => { // Defence in depth, and it needs its own test: the gate validates before it // claims, so removing the check inside `claimStepUpElevation` leaves every // route-level case above green. Driven straight at the primitive. const { raw, id } = await mintToken("e10i"); useToken(raw); const elevation = elevationOf(await mintElevation(PASSWORD)); const { claimStepUpElevation } = await import("@/lib/auth/step-up"); const claimed = await claimStepUpElevation({ rawToken: elevation, userId: USER_ID, apiTokenId: id, requireFreshFactor: true, }); expect(claimed).toEqual({ ok: false, reason: "insufficient_factor" }); }); it("does NOT burn the elevation when the factor is too weak", async () => { // The refusal is about reach, not validity. The same elevation must still // work on the routes it was always entitled to. await enrolTotp(); const { raw } = await mintToken("e10d"); useToken(raw); const elevation = elevationOf(await mintElevation(PASSWORD)); useElevation(elevation); expect((await regenerate()).status).toBe(401); const row = await getPrismaClient().stepUpElevation.findFirst(); expect(row?.consumedAt).toBeNull(); }); it("admits the SAME routes a plain cookie session reaches", async () => { // The password arm is not useless — it is exactly cookie-equivalent. const { raw } = await mintToken("e10e"); useToken(raw); useElevation(elevationOf(await mintElevation(PASSWORD))); expect((await callMfaSetup()).status).toBe(200); }); it("admits the destructive routes on a TOTP-proved elevation", async () => { const secret = await enrolTotp(); const { raw } = await mintToken("e10f"); useToken(raw); const minted = await mintTotpElevation(secret); expect(minted.status).toBe(200); expect( (minted.body.data as { satisfiesFreshFactor: boolean }) .satisfiesFreshFactor, ).toBe(true); useElevation(elevationOf(minted)); const res = await regenerate(); expect(res.status).toBe(200); const body = (await res.json()) as { data: { recoveryCodes: string[] }; }; expect(body.data.recoveryCodes.length).toBeGreaterThan(0); }); it("marks a password-proved elevation as not fresh-factor in the mint response", async () => { const { raw } = await mintToken("e10g"); useToken(raw); const minted = await mintElevation(PASSWORD); const data = minted.body.data as { method: string; satisfiesFreshFactor: boolean; }; expect(data.method).toBe("password"); expect(data.satisfiesFreshFactor).toBe(false); }); it("refuses a stale TOTP code and does not mint", async () => { const secret = await enrolTotp(); const { raw } = await mintToken("e10h"); useToken(raw); // Spend the code through the shared verifier first; the replay guard must // then refuse the same code here, exactly as it does at login. const code = currentTotpCode(secret); const { verifyMfaFactor } = await import("@/lib/auth/mfa/verify-factor"); const user = await getPrismaClient().user.findUnique({ where: { id: USER_ID }, }); await verifyMfaFactor(user!, "totp", code); const res = await mintWith({ method: "totp", code }); expect(res.status).toBe(401); expect(await getPrismaClient().stepUpElevation.count()).toBe(0); }); }); // ── E11 — the disable route keeps the caller signed in (B2) ────────── describe("E11 — disabling MFA over Bearer spares the calling device", () => { it("revokes every OTHER refresh token but not the caller's own", async () => { const secret = await enrolTotp(); const { raw, id } = await mintToken("e11a"); const { hashToken } = await import("@/lib/auth/hmac"); // The caller's own device login: the RefreshToken row cross-referencing the // access token being presented. const mine = await getPrismaClient().refreshToken.create({ data: { userId: USER_ID, tokenHash: hashToken(`hlr_mine${"0".repeat(56)}`), accessTokenHash: hashToken(raw), expiresAt: new Date(Date.now() + 30 * 24 * 3_600_000), }, }); // Another device. const theirs = await getPrismaClient().refreshToken.create({ data: { userId: USER_ID, tokenHash: hashToken(`hlr_theirs${"0".repeat(54)}`), accessTokenHash: hashToken(`hlk_other${"0".repeat(55)}`), expiresAt: new Date(Date.now() + 30 * 24 * 3_600_000), }, }); // And a browser session, which has no "current" on the Bearer path. const webSession = await getPrismaClient().session.create({ data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, }); const codes = await seedRecoveryCodes(); useToken(raw); useElevation(elevationOf(await mintTotpElevation(secret))); const { POST } = await import("@/app/api/auth/me/mfa/disable/route"); const res = await POST( req("/api/auth/me/mfa/disable", "POST", { code: codes[0], method: "recovery", }), ); expect(res.status).toBe(200); // The defect: passing the ApiToken id where a Session id was expected made // the "keep the current one" exclusion match nothing, so the caller's own // refresh token was revoked and the app logged itself out at next rotation. const mineAfter = await getPrismaClient().refreshToken.findUnique({ where: { id: mine.id }, }); const theirsAfter = await getPrismaClient().refreshToken.findUnique({ where: { id: theirs.id }, }); expect(mineAfter?.revokedAt).toBeNull(); expect(theirsAfter?.revokedAt).not.toBeNull(); // Web sessions all go — none of them is the caller. expect( await getPrismaClient().session.findUnique({ where: { id: webSession.id }, }), ).toBeNull(); // And the calling token itself is untouched, so the next request works. const token = await getPrismaClient().apiToken.findUnique({ where: { id }, }); expect(token?.revoked).toBe(false); }); it("keeps the caller's own session on the cookie path, as before", async () => { const secret = await enrolTotp(); const current = await getPrismaClient().session.create({ data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000), mfaVerifiedAt: new Date(), }, }); const other = await getPrismaClient().session.create({ data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, }); cookieJar.set("healthlog_session", current.id); headerJar.delete("authorization"); const { POST } = await import("@/app/api/auth/me/mfa/disable/route"); const res = await POST( req("/api/auth/me/mfa/disable", "POST", { code: currentTotpCode(secret), method: "totp", }), ); expect(res.status).toBe(200); expect( await getPrismaClient().session.findUnique({ where: { id: current.id } }), ).not.toBeNull(); expect( await getPrismaClient().session.findUnique({ where: { id: other.id } }), ).toBeNull(); }); }); // ── E12 — validation failures do not burn the proof ────────────────── describe("E12 — a rejected request keeps the elevation spendable", () => { it("survives a wrong TOTP code on disable", async () => { const secret = await enrolTotp(); const codes = await seedRecoveryCodes(); const { raw } = await mintToken("e12a"); useToken(raw); const elevation = elevationOf(await mintTotpElevation(secret)); useElevation(elevation); const { POST } = await import("@/app/api/auth/me/mfa/disable/route"); const bad = await POST( req("/api/auth/me/mfa/disable", "POST", { code: "000000", method: "totp", }), ); expect(bad.status).toBe(401); // Unspent — otherwise five fat-fingered codes would exhaust the 5-per-15-min // mint ceiling and lock the user out of their own security settings. const row = await getPrismaClient().stepUpElevation.findFirst(); expect(row?.consumedAt).toBeNull(); // And the retry goes through on the SAME elevation. const good = await POST( req("/api/auth/me/mfa/disable", "POST", { code: codes[0], method: "recovery", }), ); expect(good.status).toBe(200); }); it("IS spent once the route actually acts", async () => { // The other half of the two-phase split. Deferring the claim buys nothing if // the route then forgets to make it — the elevation would stay redeemable // for the rest of its five minutes. // // Driven through recovery-code regeneration rather than disable: disable // calls `destroyOtherSessions`, which deletes the account's elevations // outright, so the row is gone afterwards either way and the assertion // would pass whether or not the route committed. Regeneration leaves the // row in place, so `consumedAt` is a real observation. const secret = await enrolTotp(); const { raw } = await mintToken("e12c"); useToken(raw); const elevation = elevationOf(await mintTotpElevation(secret)); useElevation(elevation); const { POST } = await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route"); const ok = await POST( req("/api/auth/me/mfa/recovery-codes/regenerate", "POST"), ); expect(ok.status).toBe(200); const row = await getPrismaClient().stepUpElevation.findFirst(); expect(row).not.toBeNull(); expect(row?.consumedAt).not.toBeNull(); // And the spent value opens nothing else. expect((await callMfaSetup()).status).toBe(401); }); it("survives a malformed body", async () => { const { raw } = await mintToken("e12b"); useToken(raw); useElevation(elevationOf(await mintElevation(PASSWORD))); const { POST } = await import("@/app/api/auth/me/mfa/webauthn/register/verify/route"); const res = await POST( req("/api/auth/me/mfa/webauthn/register/verify", "POST", { nope: 1 }), ); expect(res.status).toBe(422); const row = await getPrismaClient().stepUpElevation.findFirst(); expect(row?.consumedAt).toBeNull(); }); }); // ── E13 — the status read is plain Bearer ──────────────────────────── describe("E13 — the second-factor status read needs no elevation", () => { it("serves a token with no elevation header at all", async () => { const { raw } = await mintToken("e13a"); useToken(raw); useElevation(null); const res = await callMfaStatus(); expect(res.status).toBe(200); const body = (await res.json()) as { data: { totp: { enabled: boolean }; webauthn: unknown[] }; }; expect(body.data.totp).toBeDefined(); expect(Array.isArray(body.data.webauthn)).toBe(true); }); it("carries no credential material", async () => { await getPrismaClient().webauthnMfaCredential.create({ data: { userId: USER_ID, name: "key", credentialId: "cred-e13b-secret", credentialPublicKey: Buffer.from([9, 9, 9]), transports: ["internal"], }, }); const { raw } = await mintToken("e13b"); useToken(raw); const text = await (await callMfaStatus()).text(); // The justification for relaxing this route is that the payload is inert. // Pin it: no credential id, no public key, no codes. expect(text).not.toContain("cred-e13b-secret"); expect(text).not.toContain("credentialPublicKey"); expect(text).not.toContain("credentialId"); }); }); // ── E14 — passkey removal is gated, and a passkey satisfies the gate ── /** * `DELETE /api/auth/passkeys/{id}` used to take a plain cookie session or a * wildcard Bearer, while the second-factor key beside it demanded a fresh * proof. That was the softer gate on the PRIMARY sign-in credential, so it now * goes through `requireMfaManagementAuth({ freshFactor: true, proofSource: * "any-possession" })`. * * THE PROOF SOURCE IS THE PART THAT NEEDS PROVING, and the cases split on the * difference between the two refusals rather than on the status, because both * are 401 and only one of them is survivable: * * `auth.stepup.required` — re-prove and retry. A gate. * `auth.stepup.mfa_not_enrolled` — the account holds nothing that could ever * produce the proof. A dead end, and the reachability pre-check is what * decides which one a caller gets. * * A passkey-only account holds no SECOND factor, so the default pre-check would * hand it the dead end over a proof it was already carrying — its passkey stamps * `mfaVerifiedAt` on login and is one of the mint's arms. `"any-possession"` is * what makes its refusal the survivable one, and E14b then shows it actually * surviving: a session stamped the way `/api/auth/passkey/login-verify` stamps * it goes straight through. * * The Bearer happy path is driven with TOTP rather than a passkey assertion for * the reason `enrolTotp` above already gives — it is the only fresh-factor arm * reachable without a real authenticator. What it proves is the redemption and * the spend; the passkey arm's admission is proved by E14a/E14b on the pre-check * that governs both transports. * * Mutation check: drop `proofSource` from the route and E14a's errorCode * assertion plus E14b go red; drop `freshFactor` and E14a, E14c and E14d's spend * go red; move `commitElevation()` above the 404 branch and E14f goes red. */ describe("E14 — removing a passkey needs a fresh possession proof", () => { async function seedPasskey(label: string): Promise { const row = await getPrismaClient().passkey.create({ data: { userId: USER_ID, name: label, credentialId: `cred-${label}`, credentialPublicKey: Buffer.from([1, 2, 3]), credentialDeviceType: "multiDevice", transports: ["internal"], }, select: { id: true }, }); return row.id; } async function callDelete(id: string): Promise { const { DELETE } = await import("@/app/api/auth/passkeys/[id]/route"); return await DELETE(req(`/api/auth/passkeys/${id}`, "DELETE"), { params: Promise.resolve({ id }), }); } async function useCookieSession(): Promise { const session = await getPrismaClient().session.create({ data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, }); cookieJar.set("healthlog_session", session.id); return session.id; } async function errorCodeOf(res: Response): Promise { const body = (await res.json()) as { meta?: { errorCode?: string } }; return body.meta?.errorCode; } it("refuses a passkey-only cookie session, and the refusal is re-provable", async () => { const passkeyId = await seedPasskey("e14a"); // No TOTP, no security key. Only the passkey it is trying to remove. await useCookieSession(); const res = await callDelete(passkeyId); expect(res.status).toBe(401); // The whole point: NOT `mfa_not_enrolled`. This account can re-prove. expect(await errorCodeOf(res)).toBe("auth.stepup.required"); expect(await getPrismaClient().passkey.count()).toBe(1); }); it("admits that same account once its session carries a passkey login", async () => { const passkeyId = await seedPasskey("e14b"); const sessionId = await useCookieSession(); // Exactly what `/api/auth/passkey/login-verify` writes on a passkey login // (v1.23, M-review M1). Still no second factor anywhere on the account. await getPrismaClient().session.update({ where: { id: sessionId }, data: { mfaVerifiedAt: new Date() }, }); expect((await callDelete(passkeyId)).status).toBe(200); expect(await getPrismaClient().passkey.count()).toBe(0); }); it("refuses a stale passkey login, so the window is real", async () => { const passkeyId = await seedPasskey("e14c"); const sessionId = await useCookieSession(); await getPrismaClient().session.update({ where: { id: sessionId }, data: { mfaVerifiedAt: new Date(Date.now() - 6 * 60 * 1000), }, }); const res = await callDelete(passkeyId); expect(res.status).toBe(401); expect(await errorCodeOf(res)).toBe("auth.stepup.required"); expect(await getPrismaClient().passkey.count()).toBe(1); }); it("refuses a Bearer client presenting no elevation", async () => { const passkeyId = await seedPasskey("e14d"); const { raw } = await mintToken("e14d"); useToken(raw); useElevation(null); const res = await callDelete(passkeyId); expect(res.status).toBe(401); expect(await getPrismaClient().passkey.count()).toBe(1); }); it("admits the native client on a factor-proved elevation, and spends it", async () => { const passkeyId = await seedPasskey("e14e"); const secret = await enrolTotp(); const { raw, id: tokenId } = await mintToken("e14e"); useToken(raw); const elevation = elevationOf(await mintTotpElevation(secret)); useElevation(elevation); expect((await callDelete(passkeyId)).status).toBe(200); expect(await getPrismaClient().passkey.count()).toBe(0); const row = await getPrismaClient().stepUpElevation.findFirst({ where: { apiTokenId: tokenId }, }); expect(row?.consumedAt).not.toBeNull(); }); it("does not burn the elevation on a foreign passkey id", async () => { await seedPasskey("e14f"); const secret = await enrolTotp(); const { raw, id: tokenId } = await mintToken("e14f"); useToken(raw); const elevation = elevationOf(await mintTotpElevation(secret)); useElevation(elevation); expect((await callDelete("pk-does-not-exist")).status).toBe(404); const row = await getPrismaClient().stepUpElevation.findFirst({ where: { apiTokenId: tokenId }, }); expect(row?.consumedAt).toBeNull(); }); it("tells an account holding nothing that there is nothing to prove with", async () => { // No passkey, no TOTP, no security key. There is no credential this gate // could ever accept, and the refusal says so rather than inviting a // re-verification that cannot happen. Reachable only with a stale id — an // account with no passkey has nothing here to delete. await useCookieSession(); const res = await callDelete("pk-does-not-exist"); expect(res.status).toBe(401); expect(await errorCodeOf(res)).toBe("auth.stepup.mfa_not_enrolled"); }); });