Files
HealthLog/tests/integration/settings-erasure-bearer-step-up.test.ts
T
Marc-André Bombeck 5856b2a547 auth: let a token prove a second factor for erasure
Account deletion and the record wipe both demand a fresh second-factor
proof once a factor is enrolled, and that proof lived on the session row.
A native client has no session row, so an account with TOTP or a security
key could not delete itself from the app on any request it could build:
requireFreshMfaIfEnrolled fell through to requireFreshMfa, which resolves
via getSession and answers 401 to a perfectly valid token. App Store
guideline 5.1.1(v) asks for exactly that deletion.

Add requireFreshMfaOrElevationIfEnrolled beside the cookie-only sibling
rather than softening it. Its cookie arm is the sibling's, unchanged;
its Bearer arm takes the single-use elevation the second-factor
management routes already accept, with the fresh-factor rule pinned on,
so a password-proved elevation cannot erase an account any more than a
password login satisfies the gate on the web. No factor rides the request
body: one mint surface, one redemption path. Scope stays fail-closed, so
a narrow token is refused before the header is read.

The elevation is spent immediately before the erasure, after the typed
confirmation and the last-admin check, so a rejected request does not
burn a proof against a 5-per-15-minute mint ceiling. The Bearer branch
of requireMfaManagementAuth moves into a shared helper so both gates read
and claim in the same place.

The elevation guard grows a section naming the two routes, because the
set of surfaces an elevation can unlock only stays small if adding to it
is a visible edit.
2026-09-02 11:51:09 +02:00

364 lines
12 KiB
TypeScript

/**
* Account deletion and the record wipe over Bearer, for an account with a
* second factor — against a real Postgres, the real mint, and the real gate.
*
* App Store guideline 5.1.1(v) requires that an account created in the app can
* be deleted from the app. Until now an MFA-enrolled account could not: both
* erasure routes demanded a cookie-side fresh factor and a Bearer transport had
* no way to present one. The cases below pin that a native client CAN now
* clear the gate, and only by re-proving a second factor through
* `POST /api/auth/step-up` — a token alone, a password proof, another token's
* elevation, and a narrow-scope token all still fail closed.
*/
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-erasure-step-up-32-bytes-minimum-1234567890";
const { hashToken } = await import("@/lib/auth/hmac");
const { hashPassword } = await import("@/lib/auth/password");
const USER_ID = "user-erasure-step-up";
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();
await getPrismaClient().user.create({
data: {
id: USER_ID,
username: "erasure",
email: "erasure@example.test",
timezone: "UTC",
passwordHash: await hashPassword(PASSWORD),
},
});
});
async function mintToken(
label: string,
permissions: string[] = ["*"],
): Promise<{ raw: string; id: string }> {
const raw = `hlk_${label}${"0".repeat(64 - label.length)}`;
const row = await getPrismaClient().apiToken.create({
data: {
userId: USER_ID,
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: string, 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);
}
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;
}
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 mint(body: Record<string, unknown>): Promise<string> {
const { POST } = await import("@/app/api/auth/step-up/route");
const res = await POST(req("/api/auth/step-up", "POST", body));
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { elevation: string } };
return json.data.elevation;
}
async function deleteAccount(): Promise<Response> {
const { DELETE } = await import("@/app/api/settings/account/route");
return DELETE(
req("/api/settings/account", "DELETE", { confirm: "DELETE_ACCOUNT" }),
);
}
async function wipeData(): Promise<Response> {
const { DELETE } = await import("@/app/api/settings/data/route");
return DELETE(req("/api/settings/data", "DELETE", { confirm: "DELETE" }));
}
async function errorCodeOf(res: Response): Promise<string | undefined> {
const json = (await res.json()) as { meta?: { errorCode?: string } };
return json.meta?.errorCode;
}
async function userExists(): Promise<boolean> {
return (
(await getPrismaClient().user.findUnique({ where: { id: USER_ID } })) !==
null
);
}
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 ?? "",
);
}
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);
}
describe("DELETE /api/settings/account over Bearer with a second factor", () => {
it("refuses a token alone with auth.stepup.required and deletes nothing", async () => {
await enrolTotp();
useToken((await mintToken("acc-none")).raw);
useElevation(null);
const res = await deleteAccount();
expect(res.status).toBe(401);
expect(await errorCodeOf(res)).toBe("auth.stepup.required");
expect(await userExists()).toBe(true);
});
it("refuses a password-proved elevation and keeps it unspent", async () => {
await enrolTotp();
useToken((await mintToken("acc-pw")).raw);
const elevation = await mint({ method: "password", password: PASSWORD });
useElevation(elevation);
const res = await deleteAccount();
expect(res.status).toBe(401);
expect(await errorCodeOf(res)).toBe("auth.stepup.required");
expect(await userExists()).toBe(true);
await expectAuditReason(
"auth.stepup.elevation.rejected",
"insufficient_factor",
);
const row = await getPrismaClient().stepUpElevation.findFirst();
expect(row?.consumedAt).toBeNull();
});
it("refuses a narrow-scope token before any elevation is looked at", async () => {
await enrolTotp();
useToken((await mintToken("acc-narrow", ["fhir:read"])).raw);
useElevation(`hle_${"f".repeat(64)}`);
const res = await deleteAccount();
expect(res.status).toBe(403);
expect(await userExists()).toBe(true);
expect(await auditReasons("auth.stepup.elevation.rejected")).toEqual([]);
});
it("refuses an elevation minted by another token of the same account", async () => {
const secret = await enrolTotp();
const minter = await mintToken("acc-minter");
const redeemer = await mintToken("acc-redeemer");
useToken(minter.raw);
const elevation = await mint({
method: "totp",
code: currentTotpCode(secret),
});
useToken(redeemer.raw);
useElevation(elevation);
const res = await deleteAccount();
expect(res.status).toBe(401);
expect(await userExists()).toBe(true);
await expectAuditReason("auth.stepup.elevation.rejected", "wrong_token");
});
it("deletes the account end to end on a TOTP-proved elevation", async () => {
const secret = await enrolTotp();
const token = await mintToken("acc-totp");
useToken(token.raw);
const elevation = await mint({
method: "totp",
code: currentTotpCode(secret),
});
useElevation(elevation);
const res = await deleteAccount();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ data: { deleted: true }, error: null });
expect(await userExists()).toBe(false);
// The token and its elevation went with the account.
expect(await getPrismaClient().apiToken.count()).toBe(0);
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
});
it("still deletes a single-factor account on a bare wildcard token", async () => {
useToken((await mintToken("acc-plain")).raw);
useElevation(null);
const res = await deleteAccount();
expect(res.status).toBe(200);
expect(await userExists()).toBe(false);
});
it("does not spend the elevation on a refused confirmation", async () => {
const secret = await enrolTotp();
useToken((await mintToken("acc-422")).raw);
const elevation = await mint({
method: "totp",
code: currentTotpCode(secret),
});
useElevation(elevation);
const { DELETE } = await import("@/app/api/settings/account/route");
const res = await DELETE(
req("/api/settings/account", "DELETE", { confirm: "nope" }),
);
expect(res.status).toBe(422);
expect(await userExists()).toBe(true);
const row = await getPrismaClient().stepUpElevation.findFirst();
expect(row?.consumedAt).toBeNull();
});
});
describe("DELETE /api/settings/data over Bearer with a second factor", () => {
it("refuses a token alone with auth.stepup.required", async () => {
await enrolTotp();
useToken((await mintToken("data-none")).raw);
useElevation(null);
const res = await wipeData();
expect(res.status).toBe(401);
expect(await errorCodeOf(res)).toBe("auth.stepup.required");
});
it("wipes on a TOTP-proved elevation and spends it exactly once", async () => {
const secret = await enrolTotp();
useToken((await mintToken("data-totp")).raw);
await getPrismaClient().measurement.create({
data: {
userId: USER_ID,
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date(),
},
});
const elevation = await mint({
method: "totp",
code: currentTotpCode(secret),
});
useElevation(elevation);
const first = await wipeData();
expect(first.status).toBe(200);
expect(
await getPrismaClient().measurement.count({ where: { userId: USER_ID } }),
).toBe(0);
// The account and its second factor survive a record wipe.
const user = await getPrismaClient().user.findUnique({
where: { id: USER_ID },
select: { totpConfirmedAt: true },
});
expect(user?.totpConfirmedAt).not.toBeNull();
// The wipe's own model list carries `ApiToken` and `StepUpElevation`, so
// the caller's credential goes with the record it erased. That is
// pre-existing behaviour rather than anything this gate introduced — a
// single-factor Bearer caller has always lost its token here — and it is
// asserted rather than assumed, because a native client has to sign in
// again afterwards.
expect(
await getPrismaClient().apiToken.count({ where: { userId: USER_ID } }),
).toBe(0);
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
// Which means a replay of the very same request is refused outright.
const second = await wipeData();
expect(second.status).toBe(401);
});
});