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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-02 11:51:09 +02:00
1 parent ad0a055fdc
commit 5856b2a547
6 files changed
+897 -26

No files matched your search

+67 -2
View File
@@ -118,11 +118,15 @@ function callers(exportName: string, moduleRe: RegExp): string[] {
* consult this mechanism at all;
* - the trusted-device routes. "Remember this browser" is a browser concept
* with no native equivalent to manage;
* - `requireFreshMfaIfEnrolled`'s callers (password change, account deletion,
* data reset, encrypted export, encryption-key rotation). Those are
* - `requireFreshMfaIfEnrolled`'s callers (password change, encrypted
* export, encryption-key rotation, the sharing invite). Those are
* destructive account actions, not second-factor management, and letting an
* elevation satisfy them would be exactly the silent widening this file
* exists to prevent.
*
* Account deletion and the record wipe used to sit in that last bullet. They
* left it deliberately, and through a different gate: see `ERASURE_ROUTES`
* below. The widening is written down there, which is what this file is for.
*/
const ELEVATION_ROUTES = [
"app/api/auth/passkeys/[id]/route.ts",
@@ -135,6 +139,28 @@ const ELEVATION_ROUTES = [
"app/api/auth/me/mfa/webauthn/register/verify/route.ts",
].sort();
/**
* The two erasure routes that accept a FRESH-FACTOR elevation over Bearer.
*
* App Store guideline 5.1.1(v) requires that an account created in the app can
* be deleted from the app. Both routes gated an MFA-enrolled account on a
* cookie-side fresh factor and a Bearer transport had no way to present one,
* so a native-app user with a second factor could not delete their account at
* all. They now resolve through `requireFreshMfaOrElevationIfEnrolled`, the
* sibling of `requireFreshMfaIfEnrolled` whose cookie arm is unchanged and
* whose Bearer arm runs the same elevation validation the MFA-management
* routes use, with `freshFactor` pinned on — so a password-proved elevation
* cannot erase an account, exactly as a password login cannot on the web.
*
* The set is two routes and must stay two. Password change, the encrypted
* export, key rotation and the sharing invite keep the cookie-only sibling;
* moving one of them here is a visible edit to this list, not a drive-by.
*/
const ERASURE_ROUTES = [
"app/api/settings/account/route.ts",
"app/api/settings/data/route.ts",
].sort();
describe("S1 — the elevation-accepting route set is frozen", () => {
// A sweep that finds nothing agrees with every allowlist, so the size of
// the tree being read is asserted before anything is concluded from it.
@@ -200,6 +226,45 @@ describe("S1 — the elevation-accepting route set is frozen", () => {
});
});
describe("S6 — the erasure routes accept a fresh-factor elevation", () => {
it("only the two erasure routes call the erasure gate", () => {
const found = callers(
"requireFreshMfaOrElevationIfEnrolled",
API_HANDLER,
).filter((rel) => rel !== "lib/api-handler.ts");
expect(found).toEqual(ERASURE_ROUTES);
});
it("neither erasure route still carries the cookie-only sibling", () => {
for (const rel of ERASURE_ROUTES) {
expect(importedAs(rel, "requireFreshMfaIfEnrolled", API_HANDLER)).toEqual(
[],
);
expect(importedAs(rel, "requireFreshMfa", API_HANDLER)).toEqual([]);
}
});
it("every erasure route spends its elevation", () => {
for (const rel of ERASURE_ROUTES) {
expect(read(rel)).toMatch(/\.commitElevation\(\)/);
}
});
it("the erasure gate pins the fresh-factor arm and never reads a body factor", () => {
// A password-proved elevation must not erase an account, and the gate
// must not grow a second, inline factor check beside the elevation: one
// mint surface, one redemption path.
const src = read("lib/api-handler.ts");
const start = src.indexOf(
"export async function requireFreshMfaOrElevationIfEnrolled(",
);
expect(start).toBeGreaterThan(-1);
const body = src.slice(start, src.indexOf("\nexport ", start + 1));
expect(body).toMatch(/freshFactor:\s*true/);
expect(body).not.toMatch(/verifyMfaFactor|verifyTotp|recoveryCode/);
});
});
describe("S2 — mint and redeem happen in exactly one place each", () => {
it("only the gate claims an elevation", () => {
const found = callers("claimStepUpElevation", STEP_UP_MODULE).filter(
+24 -2
View File
@@ -4,7 +4,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { NextRequest } from "next/server";
import {
apiHandler,
requireFreshMfaIfEnrolled,
requireFreshMfaOrElevationIfEnrolled,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -23,9 +23,20 @@ export const dynamic = "force-dynamic";
* step-up (within MFA_STEP_UP_MAX_AGE_SECONDS) in addition to the typed
* confirmation, so a hijacked live session cannot erase the record. Accounts
* without MFA are unaffected and keep the typed-confirmation-only contract.
*
* Over Bearer the fresh step-up is a single-use elevation from
* `POST /api/auth/step-up` in the `X-Step-Up` header, minted against a TOTP,
* security-key, or passkey proof — a password proof is refused. App Store
* guideline 5.1.1(v) requires in-app deletion, and until this arm existed an
* MFA-enrolled account could not delete itself from the native app at all.
* The elevation is spent immediately before the erasure, after every cheap
* refusal, so a bad confirmation does not burn it.
*/
export const DELETE = apiHandler(async (request: NextRequest) => {
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
const auth = await requireFreshMfaOrElevationIfEnrolled(
MFA_STEP_UP_MAX_AGE_SECONDS,
);
const { user } = auth;
let confirm = "";
try {
@@ -57,6 +68,17 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
const userId = user.id;
const username = user.username;
// Every refusal that costs nothing has passed; spend the Bearer elevation
// now (a no-op on the cookie path). A lost claim throws the same 401 as a
// missing one, so a concurrent redemption still has exactly one winner.
//
// One refusal can still land after this: the last-Guardian 409, which is
// decided under the record lock inside the lifecycle transaction and has no
// cheap pre-check. That caller spends a proof on a refusal and has to mint
// another. Moving the spend past the transaction would be the worse trade —
// the elevation would stay redeemable while the erasure was already running.
await auth.commitElevation();
try {
await deleteGuardianAccountWithLifecycle(userId, async (tx) => {
// The invariant has now passed, so this success event cannot survive a
+12 -3
View File
@@ -4,7 +4,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { NextRequest } from "next/server";
import {
apiHandler,
requireFreshMfaIfEnrolled,
requireFreshMfaOrElevationIfEnrolled,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -47,10 +47,15 @@ const WIPE_TRANSACTION_MAX_WAIT_MS = 15_000;
*
* v1.23 — gated by a fresh step-up for MFA-enrolled accounts (see the account
* deletion route); single-factor accounts keep the typed-confirmation-only
* contract.
* contract. Over Bearer the step-up is a fresh-factor elevation in the
* `X-Step-Up` header, spent right before the transaction — the same arm the
* account deletion route carries, for the same App Store reason.
*/
export const DELETE = apiHandler(async (request: NextRequest) => {
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
const auth = await requireFreshMfaOrElevationIfEnrolled(
MFA_STEP_UP_MAX_AGE_SECONDS,
);
const { user } = auth;
let confirm = "";
try {
@@ -70,6 +75,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
const userId = user.id;
// The confirmation has passed; spend the Bearer elevation before the wipe
// (a no-op on the cookie path).
await auth.commitElevation();
let counts: Record<string, number>;
try {
counts = await prisma.$transaction(
@@ -0,0 +1,314 @@
/**
* `requireFreshMfaOrElevationIfEnrolled` — the erasure gate that a native
* client can clear.
*
* The cookie arm is `requireFreshMfaIfEnrolled` byte for byte; the cases here
* that cover it exist so a later edit to the sibling cannot quietly change what
* a browser session gets. The Bearer arm is the new part: an MFA-enrolled
* account presenting a fresh-factor step-up elevation passes, everything else
* on Bearer is refused with the same 401 the MFA-management routes emit.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
session: { findUnique: vi.fn() },
apiToken: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
stepUpElevation: { findUnique: vi.fn() },
$queryRaw: vi.fn(),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "hash") }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
const headersGet = vi.fn<(name: string) => string | null>();
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: headersGet })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import {
requireFreshMfaOrElevationIfEnrolled,
StepUpRequiredError,
HttpError,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "../api-handler";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { headers } from "next/headers";
import { auditLog } from "@/lib/auth/audit";
const MFA_USER = {
id: "user-1",
role: "USER" as const,
username: "u",
totpConfirmedAt: new Date("2020-01-01"),
};
const PLAIN_USER = { ...MFA_USER, totpConfirmedAt: null };
const ELEVATION = `hle_${"a".repeat(64)}`;
beforeEach(() => {
vi.resetAllMocks();
headersGet.mockReturnValue(null);
vi.mocked(headers).mockImplementation(
async () => ({ get: headersGet }) as never,
);
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
});
function mockCookieSession(user: unknown) {
vi.mocked(getSession).mockResolvedValue({
session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) },
user,
} as never);
}
/** A wildcard (or narrow) Bearer caller, optionally presenting an elevation. */
function mockBearer(opts: {
user: unknown;
permissions?: string[];
elevation?: string | null;
}) {
vi.mocked(getSession).mockResolvedValue(null as never);
headersGet.mockImplementation((n) => {
const name = n.toLowerCase();
if (name === "authorization") return "Bearer hlk_xyz";
if (name === "x-step-up") return opts.elevation ?? null;
return null;
});
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-1",
userId: "user-1",
permissions: opts.permissions ?? ["*"],
revoked: false,
expiresAt: null,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(opts.user as never);
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
}
/** A stored elevation row as `validateStepUpElevation` reads it. */
function mockElevationRow(method: string, overrides: object = {}) {
vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue({
userId: "user-1",
apiTokenId: "tok-1",
consumedAt: null,
expiresAt: new Date(Date.now() + 60_000),
method,
...overrides,
} as never);
}
async function rejectedWith(
promise: Promise<unknown>,
): Promise<StepUpRequiredError> {
let caught: unknown;
try {
await promise;
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(StepUpRequiredError);
return caught as StepUpRequiredError;
}
describe("requireFreshMfaOrElevationIfEnrolled — cookie arm is unchanged", () => {
it("passes a non-MFA user straight through without a freshness read", async () => {
mockCookieSession(PLAIN_USER);
const ctx = await requireFreshMfaOrElevationIfEnrolled(
MFA_STEP_UP_MAX_AGE_SECONDS,
);
expect(ctx.user.id).toBe("user-1");
expect(ctx.authMethod).toBe("cookie");
expect(prisma.session.findUnique).not.toHaveBeenCalled();
await expect(ctx.commitElevation()).resolves.toBeUndefined();
});
it("passes an MFA user with a fresh verification", async () => {
mockCookieSession(MFA_USER);
vi.mocked(prisma.session.findUnique).mockResolvedValue({
mfaVerifiedAt: new Date(Date.now() - 60_000),
} as never);
const ctx = await requireFreshMfaOrElevationIfEnrolled(
MFA_STEP_UP_MAX_AGE_SECONDS,
);
expect(ctx.user.id).toBe("user-1");
// The session stamp is not consumable, so committing is a no-op and the
// elevation store is never touched on the cookie arm.
await ctx.commitElevation();
expect(prisma.$queryRaw).not.toHaveBeenCalled();
expect(prisma.stepUpElevation.findUnique).not.toHaveBeenCalled();
});
it("blocks an MFA user without a fresh verification", async () => {
mockCookieSession(MFA_USER);
vi.mocked(prisma.session.findUnique).mockResolvedValue({
mfaVerifiedAt: null,
} as never);
const err = await rejectedWith(
requireFreshMfaOrElevationIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS),
);
expect(err.errorCode).toBe("auth.stepup.required");
});
it("ignores an elevation header when a cookie session is present", async () => {
mockCookieSession(MFA_USER);
headersGet.mockImplementation((n) =>
n.toLowerCase() === "x-step-up" ? ELEVATION : null,
);
vi.mocked(prisma.session.findUnique).mockResolvedValue({
mfaVerifiedAt: null,
} as never);
await rejectedWith(
requireFreshMfaOrElevationIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS),
);
expect(prisma.stepUpElevation.findUnique).not.toHaveBeenCalled();
});
});
describe("requireFreshMfaOrElevationIfEnrolled — Bearer arm", () => {
it("passes a non-MFA wildcard token through with no elevation, as before", async () => {
mockBearer({ user: PLAIN_USER });
const ctx = await requireFreshMfaOrElevationIfEnrolled(
MFA_STEP_UP_MAX_AGE_SECONDS,
);
expect(ctx.authMethod).toBe("bearer");
expect(prisma.stepUpElevation.findUnique).not.toHaveBeenCalled();
await expect(ctx.commitElevation()).resolves.toBeUndefined();
});
it("refuses a narrow-scope token before any elevation is read", async () => {
mockBearer({
user: MFA_USER,
permissions: ["fhir:read"],
elevation: ELEVATION,
});
let caught: unknown;
try {
await requireFreshMfaOrElevationIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(HttpError);
expect(caught).not.toBeInstanceOf(StepUpRequiredError);
expect((caught as HttpError).statusCode).toBe(403);
expect(prisma.stepUpElevation.findUnique).not.toHaveBeenCalled();
});
it("refuses an MFA-enrolled token with no elevation as auth.stepup.required", async () => {
mockBearer({ user: MFA_USER, elevation: null });
const err = await rejectedWith(
requireFreshMfaOrElevationIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS),
);
expect(err.statusCode).toBe(401);
expect(err.errorCode).toBe("auth.stepup.required");
expect(prisma.stepUpElevation.findUnique).not.toHaveBeenCalled();
});
it("refuses an unknown elevation and audits the reason", async () => {
mockBearer({ user: MFA_USER, elevation: ELEVATION });
vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue(
null as never,
);
const err = await rejectedWith(
requireFreshMfaOrElevationIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS),
);
expect(err.errorCode).toBe("auth.stepup.required");
expect(auditLog).toHaveBeenCalledWith(
"auth.stepup.elevation.rejected",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({ reason: "unknown" }),
}),
);
});
it("refuses a password-proved elevation: the factor is too weak for erasure", async () => {
mockBearer({ user: MFA_USER, elevation: ELEVATION });
mockElevationRow("password");
const err = await rejectedWith(
requireFreshMfaOrElevationIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS),
);
expect(err.errorCode).toBe("auth.stepup.required");
expect(auditLog).toHaveBeenCalledWith(
"auth.stepup.elevation.rejected",
expect.objectContaining({
details: expect.objectContaining({ reason: "insufficient_factor" }),
}),
);
});
it("refuses an elevation minted for another token", async () => {
mockBearer({ user: MFA_USER, elevation: ELEVATION });
mockElevationRow("totp", { apiTokenId: "tok-other" });
await rejectedWith(
requireFreshMfaOrElevationIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS),
);
expect(auditLog).toHaveBeenCalledWith(
"auth.stepup.elevation.rejected",
expect.objectContaining({
details: expect.objectContaining({ reason: "wrong_token" }),
}),
);
});
it("admits a TOTP-proved elevation and spends it on commit", async () => {
mockBearer({ user: MFA_USER, elevation: ELEVATION });
mockElevationRow("totp");
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ method: "totp" },
] as never);
const ctx = await requireFreshMfaOrElevationIfEnrolled(
MFA_STEP_UP_MAX_AGE_SECONDS,
);
expect(ctx.user.id).toBe("user-1");
expect(ctx.authMethod).toBe("bearer");
// Validation does not spend: the claim runs only when the route commits.
expect(prisma.$queryRaw).not.toHaveBeenCalled();
await ctx.commitElevation();
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
});
it("admits a security-key-only account on a webauthn-proved elevation", async () => {
mockBearer({ user: PLAIN_USER, elevation: ELEVATION });
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
mockElevationRow("webauthn");
const ctx = await requireFreshMfaOrElevationIfEnrolled(
MFA_STEP_UP_MAX_AGE_SECONDS,
);
expect(ctx.user.id).toBe("user-1");
});
it("throws at commit when the claim is lost, so a race has one winner", async () => {
mockBearer({ user: MFA_USER, elevation: ELEVATION });
mockElevationRow("totp");
const ctx = await requireFreshMfaOrElevationIfEnrolled(
MFA_STEP_UP_MAX_AGE_SECONDS,
);
// Between validation and commit the elevation was spent elsewhere.
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
mockElevationRow("totp", { consumedAt: new Date() });
await rejectedWith(ctx.commitElevation());
expect(auditLog).toHaveBeenCalledWith(
"auth.stepup.elevation.rejected",
expect.objectContaining({
details: expect.objectContaining({ reason: "consumed" }),
}),
);
});
});
+117 -19
View File
@@ -1243,6 +1243,78 @@ export async function requireFreshMfaIfEnrolled(
return auth;
}
/**
* What an erasure route received: the caller, plus the hook that spends a
* Bearer elevation. See `MfaManagementContext.commitElevation` for the
* contract — call it immediately before the mutation, after every cheap
* refusal (a bad confirmation, the last-admin check) has passed, so a 422
* does not burn a proof the user then has to mint again.
*/
export type ErasureAuthContext = AuthContext & {
commitElevation: () => Promise<void>;
};
/**
* The sibling of `requireFreshMfaIfEnrolled` for the two erasure routes —
* account deletion and the record wipe — and only those.
*
* WHY IT EXISTS. App Store guideline 5.1.1(v) requires that an account created
* in the app can be deleted from the app. `requireFreshMfaIfEnrolled` gates an
* MFA-enrolled account on `requireFreshMfa`, which is cookie-only by
* construction, so a native-app user with a second factor could not delete
* their account from the app at all: the Bearer transport had no way to
* present a fresh factor and the route answered 401 "Not authenticated" to a
* perfectly valid token. This helper adds the Bearer arm without touching
* `requireFreshMfa`, which stays cookie-only.
*
* THE THREE ARMS.
*
* No second factor enrolled — pass through on either transport, exactly as
* the sibling does. A user who never enrolled has no ceremony to complete.
*
* Cookie + enrolled — `requireFreshMfa`, unchanged: `Session.mfaVerifiedAt`
* within the window. A browser session cannot tell the two helpers apart.
*
* Bearer + enrolled — a step-up elevation in the `X-Step-Up` header, minted
* by `POST /api/auth/step-up` against a re-proved factor, validated by the
* same code the MFA-management routes use and with `freshFactor` pinned ON:
* a `totp`, `webauthn`, or `passkey` proof passes; a password proof is
* refused with the same generic 401, because a password login has never
* satisfied step-up on the web and must not start here. The elevation is
* token-bound, single-use, and five minutes wide — the cookie window.
*
* WHAT IT DOES NOT DO. Scope is `requireAuth()`'s fail-closed default, so a
* narrow token is refused 403 before the header is read. A recovery code is
* not accepted on Bearer — the mint does not take one, and `FRESH_FACTOR_METHODS`
* says why; an account that has lost its authenticator erases itself on the
* web, where `/api/auth/mfa/verify` accepts the code and stamps the session.
* And the gate takes no factor in the BODY: there is one mint surface and one
* redemption path, which is what `step-up-elevation-guard.test.ts` freezes.
*/
export async function requireFreshMfaOrElevationIfEnrolled(
maxAgeSeconds: number,
): Promise<ErasureAuthContext> {
const auth = await requireAuth();
const noop = async () => {};
if (!(await hasSecondFactorEnrolled(auth.user))) {
return { ...auth, commitElevation: noop };
}
if (auth.authMethod === "cookie") {
await requireFreshMfa(maxAgeSeconds);
return { ...auth, commitElevation: noop };
}
// On the Bearer path `requireAuth` puts the resolved `ApiToken` row id in
// `session.id` — the binding the elevation was minted against.
const commitElevation = await resolveBearerElevation(
{ user: auth.user, apiTokenId: auth.session.id },
{ freshFactor: true, proofSource: "second-factor" },
);
return { ...auth, commitElevation };
}
/**
* Does this account have a SECOND FACTOR at all?
*
@@ -1457,6 +1529,37 @@ export async function requireMfaManagementAuth(
// Bearer path. Resolution first: an unknown, revoked, expired, or narrow-scope
// token is refused here and never gets as far as presenting an elevation.
const auth = await requireBearerAuth();
const commitElevation = await resolveBearerElevation(
{ user: auth.user, apiTokenId: auth.apiTokenId },
{ freshFactor, proofSource },
);
return {
transport: "bearer",
user: auth.user,
apiTokenId: auth.apiTokenId,
accessTokenHash: auth.accessTokenHash,
commitElevation,
};
}
/**
* The Bearer elevation arm, shared by `requireMfaManagementAuth` and
* `requireFreshMfaOrElevationIfEnrolled` so there is exactly one place that
* reads the header, validates, refuses, and claims.
*
* Takes an ALREADY-RESOLVED Bearer caller: the token's own validity and scope
* were decided before this runs, and nothing here can widen that decision. It
* returns the commit closure — the atomic single-use claim — and throws
* `StepUpRequiredError` for a missing, unknown, foreign, spent, expired, or
* too-weak elevation, with the real reason in the audit row and none of it on
* the wire.
*/
async function resolveBearerElevation(
auth: { user: User; apiTokenId: string },
options: { freshFactor: boolean; proofSource: FreshFactorProofSource },
): Promise<() => Promise<void>> {
const { freshFactor, proofSource } = options;
let raw: string | null = null;
try {
@@ -1473,6 +1576,7 @@ export async function requireMfaManagementAuth(
});
throw new StepUpRequiredError();
}
const rawToken = raw;
const refusal = (reason: string): StepUpRequiredError => {
// One audit row with the machine reason, one generic refusal on the wire.
@@ -1495,7 +1599,7 @@ export async function requireMfaManagementAuth(
// Validate WITHOUT consuming. The route runs its own cheap checks next and
// spends the elevation only when it is about to act.
const validated = await validateStepUpElevation({
rawToken: raw,
rawToken,
userId: auth.user.id,
apiTokenId: auth.apiTokenId,
requireFreshFactor: freshFactor,
@@ -1511,24 +1615,18 @@ export async function requireMfaManagementAuth(
throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled");
}
return {
transport: "bearer",
user: auth.user,
apiTokenId: auth.apiTokenId,
accessTokenHash: auth.accessTokenHash,
commitElevation: async () => {
const claimed = await claimStepUpElevation({
rawToken: raw,
userId: auth.user.id,
apiTokenId: auth.apiTokenId,
requireFreshFactor: freshFactor,
});
if (!claimed.ok) throw refusal(claimed.reason);
annotate({
action: { name: "auth.stepup.elevation.accepted" },
meta: { method: claimed.method, freshFactor },
});
},
return async () => {
const claimed = await claimStepUpElevation({
rawToken,
userId: auth.user.id,
apiTokenId: auth.apiTokenId,
requireFreshFactor: freshFactor,
});
if (!claimed.ok) throw refusal(claimed.reason);
annotate({
action: { name: "auth.stepup.elevation.accepted" },
meta: { method: claimed.method, freshFactor },
});
};
}
@@ -0,0 +1,363 @@
/**
* 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);
});
});