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

+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 } },
},
},