Files
HealthLog/tests/integration/step-up-elevation.test.ts
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

1330 lines
48 KiB
TypeScript

/**
* 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);
},
})),
};
});
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<string, string> = {};
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<string, unknown>,
): Promise<{ status: number; body: Record<string, unknown> }> {
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<string, unknown> }> {
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<string> {
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<string, unknown> }> {
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<string[]> {
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, unknown> }): 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<Response>;
/**
* 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<Response> {
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<Response> {
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<string[]> {
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<void> {
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<ReturnType<typeof attempt>>[] = 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<string> {
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 },
{ reach: "everything" },
);
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<Response> {
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<Response> {
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<Response> {
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<string> {
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<Response> {
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<string> {
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<string | undefined> {
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");
});
});