Merge remote-tracking branch 'origin/main' into fix/source-priority-labels

This commit is contained in:
Marc-André Bombeck committed 2026-09-02 12:09:58 +02:00
commit 4918e9fd63
10 files changed
+987 -46

No files matched your search

+16
View File
@@ -24,6 +24,22 @@ stays in the language you read the app in.
### Fixed
- **An account with two-factor can be deleted from the app.** Deleting
the account, and erasing the record while keeping the account, both demand a
fresh second-factor proof once a factor is enrolled. That proof lived on the
session row, which a native client does not have, so anyone who had turned on
TOTP or registered a security key was refused on both endpoints from the app
no matter what they sent — the only way through was a browser. The app can
now re-prove a factor the same way it already does for second-factor
management: mint a single-use elevation at `POST /api/auth/step-up` with a
TOTP code, security key, or passkey, and send it back in the `X-Step-Up`
header alongside the deletion. The web path is untouched, a token on its own
still gets nowhere, a password-proved elevation is refused for erasure just as
a password login never satisfied the gate on the web, and the proof is spent
only when the erasure is about to run, so a mistyped confirmation does not
burn it. One thing to know when erasing the record over a token: API tokens
are part of what that request deletes, so the app signs itself out and has to
sign in again.
- **Anthropic keys work again on current Claude models.** Every JSON surface
on the Anthropic provider (the daily briefing, the status cards, lab and
document extraction, anything that asks the model for a JSON object) had
+55 -14
View File
@@ -1989,8 +1989,15 @@ paths:
Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST
/api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST
/api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE
/api/auth/me/mfa/webauthn/{id}. The last three of those and disable require a fresh-factor proof. GET
/api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.
/api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data. The last three MFA
routes, disable, and both erasure routes require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation
at all. Nothing else accepts one — admin endpoints stay cookie-only.
The two erasure routes joined the set so an account with a second factor can be deleted from a native app: their
gate was cookie-only, which left an enrolled user unable to delete their own account from the app at all. They
take the elevation the same way as the rest — fresh factor required, spent only when the erasure is about to
run.
requestBody:
required: true
content:
@@ -11965,10 +11972,19 @@ paths:
Two gates, and they compose. The body must carry the typed confirmation. And for an account with a second factor
enrolled — a confirmed TOTP secret OR a registered security key — the session must additionally carry a fresh
factor proof, which is cookie-only by construction: a Bearer transport carries no `mfaVerifiedAt` and therefore
cannot satisfy it at any scope. An account with NO second factor keeps the typed-confirmation-only contract on
either transport, deliberately, because a user who never enrolled has no ceremony to complete.
enrolled — a confirmed TOTP secret OR a registered security key — the caller must additionally present a fresh
factor proof. An account with NO second factor keeps the typed-confirmation-only contract on either transport,
deliberately, because a user who never enrolled has no ceremony to complete.
How the fresh proof is presented depends on the transport. A cookie session carries it on the session row:
complete the second factor within the last five minutes and retry. A Bearer caller presents a single-use
elevation from `POST /api/auth/step-up` in the `X-Step-Up` header, minted against a `totp`, `webauthn`, or
`passkey` proof — a `password`-proved elevation is refused here exactly as a password login never satisfies
step-up on the web. The elevation is bound to the token that minted it, valid five minutes, and spent only when
the deletion is about to run, so a rejected confirmation does not burn it. A recovery code is not accepted on
the Bearer path: the mint does not take one, so an account that has lost its authenticator deletes itself from
the web.
Two refusals are not about the caller's credentials at all. The last remaining admin cannot delete themselves,
@@ -11993,9 +12009,20 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"401":
description: Not authenticated — or the account has a second factor and the session carries no proof of it fresh enough
for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`).
Re-verify the factor on the web and retry; a Bearer token cannot clear this arm.
description: Not authenticated — or the account has a second factor and the caller carries no proof of it fresh enough
for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). On
a cookie session, re-verify the factor and retry; on a Bearer token, mint an elevation at `POST
/api/auth/step-up` with a `totp`, `webauthn`, or `passkey` proof and resend with `X-Step-Up`. The same code
covers an elevation that is unknown, expired, already spent, minted for a different token, or minted from
too weak a factor — the refusals are deliberately indistinguishable on the wire, and the real reason is
audited.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"403":
description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal
happens before any second-factor proof is looked at.
content:
application/json:
schema:
@@ -12181,13 +12208,19 @@ paths:
data.
Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account,
which is cookie-only by construction. The confirmation string is `DELETE`, NOT the `DELETE_ACCOUNT` that route
wants.
Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account — on
a cookie session the second factor completed within the last five minutes, on a Bearer token a single-use
`X-Step-Up` elevation minted at `POST /api/auth/step-up` against a `totp`, `webauthn`, or `passkey` proof. The
confirmation string is `DELETE`, NOT the `DELETE_ACCOUNT` that route wants.
One audit row survives the wipe that clears the rest of the audit history: it is written after the transaction
commits, as the receipt for the erasure the person asked for.
A Bearer client should expect to be signed out by its own request: API tokens are on the wipe list, so the token
that called this — and any step-up elevation minted from it — is removed along with the record. Sign in again
rather than retrying with the same credential.
requestBody:
required: true
content:
@@ -12202,9 +12235,17 @@ paths:
schema:
$ref: "#/components/schemas/DataWipeEnvelope"
"401":
description: Not authenticated — or the account has a second factor and the session carries no proof of it fresh enough
description: Not authenticated — or the account has a second factor and the caller carries no proof of it fresh enough
for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). A
Bearer token cannot clear this arm.
Bearer caller clears it with an `X-Step-Up` elevation from `POST /api/auth/step-up`; the same code covers an
elevation that is unknown, expired, already spent, bound to another token, or minted from too weak a factor.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"403":
description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal
happens before any second-factor proof is looked at.
content:
application/json:
schema:
+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 },
});
};
}
+2 -1
View File
@@ -840,7 +840,8 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"WHICH factor you re-prove decides WHAT the elevation reaches. `password` reaches the same routes a plain cookie session reaches. `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes — MFA disable, recovery-code regeneration, security-key removal — which is precisely the set of ceremonies for which the web marks a session second-factor-verified. The response carries `satisfiesFreshFactor` so a client can choose the right ceremony up front rather than discovering the refusal after spending a proof. A recovery code is NOT accepted here; an account that has lost its authenticator manages its second factor on the web.\n\n" +
"The elevation is bound to the exact token that minted it (another token, including the same account's, cannot redeem it), single-use, and valid for five minutes — the same window the cookie path uses. Present it as `X-Step-Up: hle_…` alongside the normal `Authorization: Bearer` header. It is spent only when the target route is about to act, so a 429, a 422, or a wrong code does not burn it.\n\n" +
"Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per account (5 / 15 min) and per source address.\n\n" +
"Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}. The last three of those and disable require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.",
"Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data. The last three MFA routes, disable, and both erasure routes require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.\n\n" +
"The two erasure routes joined the set so an account with a second factor can be deleted from a native app: their gate was cookie-only, which left an enrolled user unable to delete their own account from the app at all. They take the elevation the same way as the rest — fresh factor required, spent only when the erasure is about to run.",
requestBody: {
required: true,
content: { "application/json": { schema: stepUpMintSchema } },
+17 -5
View File
@@ -562,7 +562,8 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Permanently delete the account and everything in it",
description:
"Erasure, not deactivation: the user row goes, every related row cascades with it, and the account's own audit history — IP addresses, login geo — is purged inside the same transaction rather than being left orphaned. There is no undo and no grace period.\n\n" +
"Two gates, and they compose. The body must carry the typed confirmation. And for an account with a second factor enrolled — a confirmed TOTP secret OR a registered security key — the session must additionally carry a fresh factor proof, which is cookie-only by construction: a Bearer transport carries no `mfaVerifiedAt` and therefore cannot satisfy it at any scope. An account with NO second factor keeps the typed-confirmation-only contract on either transport, deliberately, because a user who never enrolled has no ceremony to complete.\n\n" +
"Two gates, and they compose. The body must carry the typed confirmation. And for an account with a second factor enrolled — a confirmed TOTP secret OR a registered security key — the caller must additionally present a fresh factor proof. An account with NO second factor keeps the typed-confirmation-only contract on either transport, deliberately, because a user who never enrolled has no ceremony to complete.\n\n" +
"How the fresh proof is presented depends on the transport. A cookie session carries it on the session row: complete the second factor within the last five minutes and retry. A Bearer caller presents a single-use elevation from `POST /api/auth/step-up` in the `X-Step-Up` header, minted against a `totp`, `webauthn`, or `passkey` proof — a `password`-proved elevation is refused here exactly as a password login never satisfies step-up on the web. The elevation is bound to the token that minted it, valid five minutes, and spent only when the deletion is about to run, so a rejected confirmation does not burn it. A recovery code is not accepted on the Bearer path: the mint does not take one, so an account that has lost its authenticator deletes itself from the web.\n\n" +
"Two refusals are not about the caller's credentials at all. The last remaining admin cannot delete themselves, and the last Guardian of a managed profile cannot leave that profile unattended.",
requestBody: {
required: true,
@@ -597,7 +598,12 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
"401": {
description:
"Not authenticated — or the account has a second factor and the session carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). Re-verify the factor on the web and retry; a Bearer token cannot clear this arm.",
"Not authenticated — or the account has a second factor and the caller carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). On a cookie session, re-verify the factor and retry; on a Bearer token, mint an elevation at `POST /api/auth/step-up` with a `totp`, `webauthn`, or `passkey` proof and resend with `X-Step-Up`. The same code covers an elevation that is unknown, expired, already spent, minted for a different token, or minted from too weak a factor — the refusals are deliberately indistinguishable on the wire, and the real reason is audited.",
content: { "application/json": { schema: errorEnvelope } },
},
"403": {
description:
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.",
content: { "application/json": { schema: errorEnvelope } },
},
},
@@ -749,8 +755,9 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"The other half of account deletion: this removes what the account HAS and keeps what signs into it. Ninety-nine models are hard-deleted for this user — measurements and every derived rollup, medications and their intake history, mood, labs, documents, cycle, workouts, nutrients, notification channels, Coach conversations, consent receipts, audit history — with no `deletedAt` filter, so soft-deleted tombstones go too. The account row survives with its personal columns reset: body and identity fields, home location, cached AI output. What is deliberately kept is the credential set (password hash, SSO binding, second-factor secret and enrolment, trusted-device state) and the interface preferences (timezone, locale, units, time and date format, glucose unit), because those are about the account rather than the record.\n\n" +
"The model list is not written at the route. It is declared once in the wipe plan and held against the Prisma schema by a completeness test, which exists because this endpoint once deleted thirteen tables out of eighty-odd while telling people it had deleted everything.\n\n" +
"One transaction, with a two-minute budget: a half-applied wipe is worse than none, because the person is told it succeeded either way. Every cached payload for the account is evicted afterwards so no surface serves pre-wipe data.\n\n" +
"Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account, which is cookie-only by construction. The confirmation string is `DELETE`, NOT the `DELETE_ACCOUNT` that route wants.\n\n" +
"One audit row survives the wipe that clears the rest of the audit history: it is written after the transaction commits, as the receipt for the erasure the person asked for.",
"Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account — on a cookie session the second factor completed within the last five minutes, on a Bearer token a single-use `X-Step-Up` elevation minted at `POST /api/auth/step-up` against a `totp`, `webauthn`, or `passkey` proof. The confirmation string is `DELETE`, NOT the `DELETE_ACCOUNT` that route wants.\n\n" +
"One audit row survives the wipe that clears the rest of the audit history: it is written after the transaction commits, as the receipt for the erasure the person asked for.\n\n" +
"A Bearer client should expect to be signed out by its own request: API tokens are on the wipe list, so the token that called this — and any step-up elevation minted from it — is removed along with the record. Sign in again rather than retrying with the same credential.",
requestBody: {
required: true,
content: { "application/json": { schema: dataWipeRequest } },
@@ -777,7 +784,12 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
"401": {
description:
"Not authenticated — or the account has a second factor and the session carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). A Bearer token cannot clear this arm.",
"Not authenticated — or the account has a second factor and the caller carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). A Bearer caller clears it with an `X-Step-Up` elevation from `POST /api/auth/step-up`; the same code covers an elevation that is unknown, expired, already spent, bound to another token, or minted from too weak a factor.",
content: { "application/json": { schema: errorEnvelope } },
},
"403": {
description:
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.",
content: { "application/json": { schema: errorEnvelope } },
},
},
@@ -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);
});
});