mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge remote-tracking branch 'origin/main' into fix/source-priority-labels
This commit is contained in:
commit
4918e9fd63
10 files changed
+987
-46
No files matched your search
@@ -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
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user