diff --git a/CHANGELOG.md b/CHANGELOG.md index dcc00fa68..8903e3142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index db213bb8f..0c3981f36 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/__tests__/step-up-elevation-guard.test.ts b/src/__tests__/step-up-elevation-guard.test.ts index 16589e1d1..1b8bb5fdd 100644 --- a/src/__tests__/step-up-elevation-guard.test.ts +++ b/src/__tests__/step-up-elevation-guard.test.ts @@ -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( diff --git a/src/app/api/settings/account/route.ts b/src/app/api/settings/account/route.ts index d976062ab..4f863defc 100644 --- a/src/app/api/settings/account/route.ts +++ b/src/app/api/settings/account/route.ts @@ -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 diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts index eac7a533f..409320ccc 100644 --- a/src/app/api/settings/data/route.ts +++ b/src/app/api/settings/data/route.ts @@ -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; try { counts = await prisma.$transaction( diff --git a/src/lib/__tests__/require-fresh-mfa-or-elevation-if-enrolled.test.ts b/src/lib/__tests__/require-fresh-mfa-or-elevation-if-enrolled.test.ts new file mode 100644 index 000000000..e065570ec --- /dev/null +++ b/src/lib/__tests__/require-fresh-mfa-or-elevation-if-enrolled.test.ts @@ -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, +): Promise { + 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" }), + }), + ); + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 796c42e42..63f6cdf28 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -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; +}; + +/** + * 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 { + 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> { + 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 }, + }); }; } diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 265690f28..96d5efdd9 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -840,7 +840,8 @@ export const authPaths: NonNullable = { "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 } }, diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 600af5e49..085fee9ab 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -562,7 +562,8 @@ export const settingsPaths: NonNullable = { 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 = { ...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 = { "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 = { ...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 } }, }, }, diff --git a/tests/integration/settings-erasure-bearer-step-up.test.ts b/tests/integration/settings-erasure-bearer-step-up.test.ts new file mode 100644 index 000000000..a9cb80e69 --- /dev/null +++ b/tests/integration/settings-erasure-bearer-step-up.test.ts @@ -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 = {}; + 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 { + 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): Promise { + 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 { + const { DELETE } = await import("@/app/api/settings/account/route"); + return DELETE( + req("/api/settings/account", "DELETE", { confirm: "DELETE_ACCOUNT" }), + ); +} + +async function wipeData(): Promise { + const { DELETE } = await import("@/app/api/settings/data/route"); + return DELETE(req("/api/settings/data", "DELETE", { confirm: "DELETE" })); +} + +async function errorCodeOf(res: Response): Promise { + const json = (await res.json()) as { meta?: { errorCode?: string } }; + return json.meta?.errorCode; +} + +async function userExists(): Promise { + return ( + (await getPrismaClient().user.findUnique({ where: { id: USER_ID } })) !== + null + ); +} + +async function auditReasons(action: string): Promise { + 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 { + 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); + }); +});