diff --git a/CHANGELOG.md b/CHANGELOG.md index c18822e10..abd7cd83e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## [Unreleased] +## [1.32.3] — 2026-07-23 + +Security release. It ships on its own so the change to how a second factor is +proven can be watched in isolation. + +- **A native client can manage its own second factor.** The iOS app can now + turn two-factor authentication on or off, add or remove a security key, and + regenerate recovery codes over its API session — actions that were reachable + only from a browser before. It does so through a short-lived, single-use + elevation the app earns by re-proving a factor, never by presenting its + token alone. +- **The re-proved factor decides what the elevation reaches.** Re-proving with + a password reaches exactly what a plain browser session reaches; the actions + a browser gates behind a completed second factor now require the same over + the API — a re-proved TOTP code or security key, never the password alone. + Administrative endpoints remain reachable only from a browser session, + unchanged. + +One migration (0267), additive. Addresses the native 2FA-management gap +(healthlog-iOS #57). + ## [1.32.2] — 2026-07-23 Security release. Ships on its own and ahead of everything queued, because it diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cb95f40c5..c5b78d02f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.2 + version: 1.32.3 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -1601,13 +1601,96 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/auth/step-up/options: + post: + tags: + - Auth + summary: Begin a WebAuthn re-proof for a step-up elevation (Bearer only) + description: "Returns SimpleWebAuthn assertion options plus a challenge id to present at POST /api/auth/step-up. + `method: \"passkey\"` scopes the assertion to the account's primary passkeys; `method: \"webauthn\"` to its + registered second-factor security keys. Bearer-only: a cookie session is refused, because a browser re-proves + its factor at login and carries the result on its session row. Returns 409 when the account has no credential of + the requested kind — fall back to another arm of the mint." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StepUpOptionsRequest" + responses: + "200": + description: Assertion options issued. + content: + application/json: + schema: + $ref: "#/components/schemas/StepUpOptionsEnvelope" + "401": *a1 + "409": + description: No passkey registered on this account. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/auth/step-up: + post: + tags: + - Auth + summary: Mint a single-use step-up elevation (Bearer only) + description: >- + Re-prove a factor and receive an opaque elevation that authorises exactly ONE second-factor-management call. + + + 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. + + + 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. + + + 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. + + + 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. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StepUpMintRequest" + responses: + "200": + description: Factor re-proved — elevation issued. + content: + application/json: + schema: + $ref: "#/components/schemas/StepUpMintEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/auth/me/mfa/totp/setup: post: tags: - Auth - summary: Begin TOTP enrollment (cookie session only) + summary: Begin TOTP enrollment (cookie session or step-up elevation) description: Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is - not active until /confirm. Cookie-only — a Bearer token cannot enrol MFA. + not active until /confirm. Accepts a cookie session, or a Bearer token presenting a single-use elevation from + POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused. responses: "200": description: Pending secret created. @@ -1628,9 +1711,10 @@ paths: post: tags: - Auth - summary: Confirm TOTP enrollment (cookie session only) + summary: Confirm TOTP enrollment (cookie session or step-up elevation) description: Verifies a code against the pending secret, activates the factor, and returns the one-time recovery codes. - Cookie-only. + Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the + `X-Step-Up` header; a Bearer token alone is still refused. requestBody: required: true content: @@ -1664,8 +1748,9 @@ paths: tags: - Auth summary: Disable the second factor (step-up gated) - description: Requires a fresh second-factor step-up (cookie session) AND a current TOTP or recovery code. Clears the - secret and deletes recovery codes. Bearer can never satisfy the step-up gate. + description: Requires a fresh second-factor step-up AND a current TOTP or recovery code. Clears the secret and deletes + recovery codes. Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST + /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused. requestBody: required: true content: @@ -1687,8 +1772,9 @@ paths: tags: - Auth summary: Regenerate recovery codes (step-up gated) - description: Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated; Bearer can - never satisfy the gate. + description: Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated. Accepts a + cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the + `X-Step-Up` header; a Bearer token alone is still refused. responses: "200": description: Fresh recovery codes issued. @@ -1703,9 +1789,11 @@ paths: get: tags: - Auth - summary: Second-factor status (cookie session only) + summary: Second-factor status (cookie session or Bearer token) description: Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata - only — no secret, code, or public key. Cookie-only. + only — no secret, no code, no public key, no credential id. Plain authentication (cookie session or + cookie-equivalent token); unlike every mutation on this surface it needs no step-up elevation, because the + payload carries no credential material. responses: "200": description: Second-factor status. @@ -1721,7 +1809,9 @@ paths: tags: - Auth summary: Begin registering a security key as a second factor - description: Returns SimpleWebAuthn creation options + a challenge id. Cookie-only — a Bearer token cannot enrol MFA. + description: Returns SimpleWebAuthn creation options + a challenge id. Accepts a cookie session, or a Bearer token + presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is + still refused. responses: "200": description: Registration options issued. @@ -1738,7 +1828,8 @@ paths: - Auth summary: Finish registering a security key as a second factor description: Verifies the attestation against the user-bound challenge and stores the credential in the second-factor - store (separate from primary passkeys). Cookie-only. + store (separate from primary passkeys). Accepts a cookie session, or a Bearer token presenting a single-use + elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused. requestBody: required: true content: @@ -1766,6 +1857,9 @@ paths: tags: - Auth summary: Rename a registered security key + description: Rename a registered second-factor security key. Accepts a cookie session, or a Bearer token presenting a + single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still + refused. parameters: - in: path name: id @@ -1798,7 +1892,8 @@ paths: tags: - Auth summary: Remove a registered security key (step-up gated) - description: Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate. + description: Requires a fresh second-factor step-up. Accepts a cookie session, or a Bearer token presenting a single-use + elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused. parameters: - in: path name: id @@ -11412,6 +11507,134 @@ components: - code - codeVerifier description: Exchange a one-time native OIDC handoff code (+ its PKCE verifier) for the standard native token bundle. + StepUpOptionsRequest: + type: object + properties: + method: + type: string + enum: + - passkey + - webauthn + description: "`passkey` begins an assertion against the account's primary passkeys; `webauthn` against its registered + second-factor security keys." + required: + - method + StepUpMintRequest: + oneOf: + - type: object + properties: + method: + type: string + const: password + password: + type: string + minLength: 1 + maxLength: 512 + required: + - method + - password + - type: object + properties: + method: + type: string + const: totp + code: + type: string + pattern: ^\d{6}$ + required: + - method + - code + - type: object + properties: + method: + type: string + const: webauthn + challengeId: + type: string + minLength: 1 + credential: + type: object + properties: + id: + type: string + rawId: + type: string + type: + type: string + const: public-key + response: + type: object + propertyNames: + type: string + additionalProperties: {} + authenticatorAttachment: + type: string + enum: + - platform + - cross-platform + clientExtensionResults: + type: object + propertyNames: + type: string + additionalProperties: {} + required: + - id + - rawId + - type + - response + description: SimpleWebAuthn ceremony response payload. + required: + - method + - challengeId + - credential + - type: object + properties: + method: + type: string + const: passkey + challengeId: + type: string + minLength: 1 + credential: + type: object + properties: + id: + type: string + rawId: + type: string + type: + type: string + const: public-key + response: + type: object + propertyNames: + type: string + additionalProperties: {} + authenticatorAttachment: + type: string + enum: + - platform + - cross-platform + clientExtensionResults: + type: object + propertyNames: + type: string + additionalProperties: {} + required: + - id + - rawId + - type + - response + description: SimpleWebAuthn ceremony response payload. + required: + - method + - challengeId + - credential + description: Re-prove a factor to mint a single-use, token-bound step-up elevation. `password` reaches the same routes a + plain cookie session reaches; `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes + (disable, recovery-code rotation, security-key removal), matching exactly the ceremonies for which the web + stamps a session second-factor-verified. + type: object MfaDisableRequest: type: object properties: @@ -15316,6 +15539,90 @@ components: - data - error additionalProperties: false + StepUpOptionsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/StepUpOptionsResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + StepUpOptionsResponse: + type: object + properties: + options: + type: object + propertyNames: + type: string + additionalProperties: {} + description: SimpleWebAuthn assertion options for the caller's passkeys. + challengeId: + type: string + required: + - options + - challengeId + additionalProperties: false + StepUpMintEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/StepUpMintResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + StepUpMintResponse: + type: object + properties: + elevation: + type: string + description: Opaque single-use elevation (`hle_<64hex>`). Present it in the `X-Step-Up` header on ONE + second-factor-management call. Returned exactly once; store it in memory only. + expiresAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + expiresInSeconds: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + method: + type: string + enum: + - password + - totp + - webauthn + - passkey + description: The factor that was re-proved. + satisfiesFreshFactor: + type: boolean + description: Whether this elevation reaches the fresh-factor routes (MFA disable, recovery-code regeneration, + security-key removal). False for a password proof — mirroring the web, where a password login never marks a + session second-factor-verified. + required: + - elevation + - expiresAt + - expiresInSeconds + - method + - satisfiesFreshFactor + additionalProperties: false TotpSetupEnvelope: type: object properties: diff --git a/package.json b/package.json index 7472ddb99..a05dfc191 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.2", + "version": "1.32.3", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0267_step_up_elevation/migration.sql b/prisma/migrations/0267_step_up_elevation/migration.sql new file mode 100644 index 000000000..bf827994a --- /dev/null +++ b/prisma/migrations/0267_step_up_elevation/migration.sql @@ -0,0 +1,40 @@ +-- v1.30.34 — token-bound step-up elevations. +-- +-- The Bearer-transport equivalent of `sessions.mfa_verified_at`. A row is +-- minted only after a password or primary-passkey re-proof, is bound to the +-- exact API token that re-proved, is single-use (`consumed_at`), and expires +-- within minutes. Only the HMAC of the opaque secret is stored. + +CREATE TABLE "step_up_elevations" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "api_token_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "method" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "consumed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "step_up_elevations_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "step_up_elevations_token_hash_key" ON "step_up_elevations"("token_hash"); + +-- At most ONE redeemable elevation per token, enforced by the database rather +-- than by the mint's delete-then-create ordering. Without it two concurrent +-- mints can interleave so that both rows survive, or the second mint's delete +-- strands the value the first mint already handed to the client. +CREATE UNIQUE INDEX "step_up_elevations_live_per_token_key" + ON "step_up_elevations"("api_token_id") WHERE "consumed_at" IS NULL; + +CREATE INDEX "step_up_elevations_user_id_idx" ON "step_up_elevations"("user_id"); +CREATE INDEX "step_up_elevations_api_token_id_idx" ON "step_up_elevations"("api_token_id"); +CREATE INDEX "step_up_elevations_expires_at_idx" ON "step_up_elevations"("expires_at"); + +ALTER TABLE "step_up_elevations" ADD CONSTRAINT "step_up_elevations_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Cascade on the token binding: revoking or deleting a token must never leave a +-- redeemable elevation behind. +ALTER TABLE "step_up_elevations" ADD CONSTRAINT "step_up_elevations_api_token_id_fkey" + FOREIGN KEY ("api_token_id") REFERENCES "api_tokens"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 37f4a9127..61e8fff79 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -658,6 +658,10 @@ model User { // skips the second factor (factor 2 only) within the window; the password // is still required. Revoked when the second factor is removed. trustedDevices TrustedDevice[] + // v1.30.34 — short-lived, token-bound step-up elevations. The Bearer-transport + // equivalent of `Session.mfaVerifiedAt`: a token client re-proves a factor and + // receives one single-use elevation good for one MFA-management call. + stepUpElevations StepUpElevation[] measurements Measurement[] medications Medication[] intakeEvents MedicationIntakeEvent[] @@ -3214,12 +3218,71 @@ model ApiToken { user User @relation(fields: [userId], references: [id], onDelete: Cascade) mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull) + // v1.30.34 — step-up elevations bound to this token. Cascade-deleted with it. + stepUpElevations StepUpElevation[] @@index([userId]) @@index([mcpConnectionId]) @@map("api_tokens") } +// v1.30.34 — a step-up elevation: the Bearer-transport answer to +// `Session.mfaVerifiedAt`. +// +// The web gates second-factor management on a freshness stamp written onto a +// session row. A token client has no session row, so it had no way to express +// "the human holding this device just re-proved a factor" and the whole +// MFA-management surface was unreachable from a native client. +// +// A row here is that proof, and it is deliberately the weakest credential in +// the tree: +// - it is minted ONLY after a password or primary-passkey re-proof, so +// presenting a stolen token gains nothing; +// - `apiTokenId` binds it to the exact token that re-proved, so a second +// token — the same user's included — cannot redeem it; +// - `consumedAt` makes it single-use, claimed by a conditional UPDATE so two +// concurrent redemptions cannot both win; +// - `expiresAt` caps it at minutes, matching the cookie path's step-up window. +// +// Only the hash of the opaque `hle_` secret is stored, so a table dump yields +// nothing redeemable — the same posture as ApiToken, RefreshToken, and Session. +// Revoking the token strands every elevation bound to it by construction: +// redemption runs behind Bearer resolution, which refuses a revoked token. +model StepUpElevation { + id String @id @default(cuid()) + userId String @map("user_id") + // The `ApiToken` row this elevation is bound to. Cascade so a deleted token + // cannot leave a redeemable elevation behind. + apiTokenId String @map("api_token_id") + // HMAC-SHA256 (`hashToken`) of the raw `hle_<64 hex>` secret. Unique so a + // redemption is a single indexed lookup and a collision cannot alias two rows. + tokenHash String @unique @map("token_hash") + // Which factor was re-proved: "password", "totp", "webauthn", or "passkey". + // This IS an authorisation input, not telemetry — see `FRESH_FACTOR_METHODS` + // in `@/lib/auth/step-up`. Only totp / webauthn / passkey satisfy a + // fresh-factor gate; password reaches what a plain cookie session reaches + // and stops there. + method String + expiresAt DateTime @map("expires_at") + consumedAt DateTime? @map("consumed_at") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + apiToken ApiToken @relation(fields: [apiTokenId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([apiTokenId]) + @@index([expiresAt]) + // NOTE: migration 0267 additionally creates a PARTIAL unique index — + // `(api_token_id) WHERE consumed_at IS NULL` — so at most one elevation per + // token is ever redeemable. Prisma cannot express a partial index, and a + // plain `@@unique([apiTokenId])` would be wrong: it would also forbid minting + // a second elevation after the first was legitimately consumed. The + // constraint therefore lives only in the migration, and the mint below + // handles its unique violation explicitly. + @@map("step_up_elevations") +} + // MCP OAuth connection — the revocable, theft-detectable anchor for a remote // connector's refresh-token chain (Phase 3 security fix, H2). // diff --git a/public/sw.js b/public/sw.js index 11a5a8b8f..f06b7724d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.2"; + /* @sw-version-fallback */ "v1.32.3"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/__tests__/step-up-elevation-guard.test.ts b/src/__tests__/step-up-elevation-guard.test.ts new file mode 100644 index 000000000..7b0581c8f --- /dev/null +++ b/src/__tests__/step-up-elevation-guard.test.ts @@ -0,0 +1,291 @@ +/** + * Structural guards on the step-up elevation boundary. + * + * An elevation lets a Bearer token reach a surface that is otherwise + * cookie-only. That is a deliberate, narrow widening, and its entire safety + * argument rests on the set of routes it can unlock being small, known, and + * unable to grow by accident. Behavioural tests prove the mechanism works; + * these prove nobody quietly pointed it somewhere else. + * + * MATCHING IS ON THE IMPORT, NOT THE CALL. An earlier revision grepped for + * `requireMfaManagementAuth(` and was evadable in one line: + * + * import { requireMfaManagementAuth as gate } from "@/lib/api-handler"; + * const auth = await gate(); + * + * A call-shaped regex sees nothing there. An import-shaped one cannot be dodged + * that way, because the binding has to be imported from the module under some + * local name before it can be called at all. The helpers below parse the import + * specifier — including its `as` alias — and check calls against the LOCAL name, + * so a rename is followed rather than lost. + * + * They remain tripwires, not proofs. They cannot show an allowlist is correct, + * only that it has not changed without someone editing this file. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join, sep } from "node:path"; +import { globSync } from "node:fs"; + +const SRC = join(process.cwd(), "src"); + +function sourceFiles(): string[] { + return globSync("**/*.{ts,tsx}", { cwd: SRC }) + .filter( + (p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"), + ) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .map((p) => p.split(sep).join("/")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +/** Local names bound for `exportName` from any module matching `moduleRe`. */ +function importedAs( + rel: string, + exportName: string, + moduleRe: RegExp, +): string[] { + const src = read(rel); + const names: string[] = []; + for (const m of src.matchAll( + /import\s*\{([\s\S]*?)\}\s*from\s*["']([^"']+)["']/g, + )) { + const [, clause, specifier] = m; + if (!moduleRe.test(specifier)) continue; + for (const part of clause.split(",")) { + const piece = part.trim().replace(/^type\s+/, ""); + if (!piece) continue; + const aliased = piece.match( + /^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/, + ); + if (aliased) { + if (aliased[1] === exportName) names.push(aliased[2]); + } else if (piece === exportName) { + names.push(piece); + } + } + } + return names; +} + +const API_HANDLER = /api-handler$/; +const STEP_UP_MODULE = /auth\/step-up$/; + +/** Files that import `exportName` from the module AND call it. */ +function callers(exportName: string, moduleRe: RegExp): string[] { + return sourceFiles() + .filter((rel) => { + const locals = importedAs(rel, exportName, moduleRe); + if (locals.length === 0) return false; + const src = read(rel); + return locals.some((name) => new RegExp(`\\b${name}\\s*\\(`).test(src)); + }) + .sort(); +} + +/** + * Every route an elevation can unlock. This is the whole set — the second-factor + * management MUTATIONS and nothing besides. + * + * What is deliberately ABSENT is as much the point as what is present: + * - `GET /api/auth/me/mfa`. The status read is plain `requireAuth()`: it + * carries no credential material (no secret, no code, no public key, no + * credential id — only names, dates, and a count), the web shows the same + * screen to any authenticated session, and `/api/auth/me` already exposes + * `totpConfirmedAt` over Bearer. Gating it would force a factor re-proof to + * render a screen and buy nothing; + * - every `/api/admin/*` route. `requireAdmin` is cookie-only and does not + * 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 + * destructive account actions, not second-factor management, and letting an + * elevation satisfy them would be exactly the silent widening this file + * exists to prevent. + */ +const ELEVATION_ROUTES = [ + "app/api/auth/me/mfa/disable/route.ts", + "app/api/auth/me/mfa/recovery-codes/regenerate/route.ts", + "app/api/auth/me/mfa/totp/confirm/route.ts", + "app/api/auth/me/mfa/totp/setup/route.ts", + "app/api/auth/me/mfa/webauthn/[id]/route.ts", + "app/api/auth/me/mfa/webauthn/register/options/route.ts", + "app/api/auth/me/mfa/webauthn/register/verify/route.ts", +].sort(); + +describe("S1 — the elevation-accepting route set is frozen", () => { + it("only the known MFA-management mutations call the gate", () => { + const found = callers("requireMfaManagementAuth", API_HANDLER).filter( + (rel) => rel !== "lib/api-handler.ts", + ); + expect(found).toEqual(ELEVATION_ROUTES); + }); + + it("follows an aliased import rather than losing it", () => { + // Guards the guard. Without this the allowlist above is one rename away + // from meaningless, which is precisely how the previous revision was + // evadable. + const probe = ` + import { requireMfaManagementAuth as gate } from "@/lib/api-handler"; + const auth = await gate(); + `; + const names: string[] = []; + for (const m of probe.matchAll( + /import\s*\{([\s\S]*?)\}\s*from\s*["']([^"']+)["']/g, + )) { + for (const part of m[1].split(",")) { + const aliased = part + .trim() + .match(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/); + if (aliased && aliased[1] === "requireMfaManagementAuth") { + names.push(aliased[2]); + } + } + } + expect(names).toEqual(["gate"]); + expect(new RegExp(`\\b${names[0]}\\s*\\(`).test(probe)).toBe(true); + }); + + it("no admin route calls the gate", () => { + const admin = callers("requireMfaManagementAuth", API_HANDLER).filter( + (rel) => rel.startsWith("app/api/admin/"), + ); + expect(admin).toEqual([]); + }); + + it("the old cookie-only gates no longer linger on the MFA surface", () => { + for (const rel of ELEVATION_ROUTES) { + expect(importedAs(rel, "requireCookieAuth", API_HANDLER)).toEqual([]); + expect(importedAs(rel, "requireFreshMfa", API_HANDLER)).toEqual([]); + } + }); + + it("every gated route spends its elevation", () => { + // `commitElevation` is what makes the proof single-use. A route that gates + // but never commits would leave the elevation redeemable for the rest of + // its window — the one fail-open the two-phase split introduces, closed + // here. + for (const rel of ELEVATION_ROUTES) { + expect(read(rel)).toMatch(/\.commitElevation\(\)/); + } + }); +}); + +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( + (rel) => rel !== "lib/auth/step-up.ts", + ); + expect(found).toEqual(["lib/api-handler.ts"]); + }); + + it("only the gate validates one", () => { + const found = callers("validateStepUpElevation", STEP_UP_MODULE).filter( + (rel) => rel !== "lib/auth/step-up.ts", + ); + expect(found).toEqual(["lib/api-handler.ts"]); + }); + + it("only the mint route mints one", () => { + const found = callers("mintStepUpElevation", STEP_UP_MODULE).filter( + (rel) => rel !== "lib/auth/step-up.ts", + ); + expect(found).toEqual(["app/api/auth/step-up/route.ts"]); + }); + + it("no file outside the gate reads the elevation header", () => { + // Both spellings — the literal string AND the exported constant — across + // every source file, not only `route.ts`. A helper could read it too. + const offenders = sourceFiles() + .filter((rel) => rel !== "lib/api-handler.ts") + .filter((rel) => { + const src = read(rel); + if (/["']x-step-up["']/i.test(src)) return true; + return ( + importedAs(rel, "STEP_UP_ELEVATION_HEADER", API_HANDLER).length > 0 + ); + }); + expect(offenders).toEqual([]); + }); +}); + +describe("S3 — requireAdmin stays cookie-only", () => { + it("resolves through getSession and nothing else", () => { + const src = read("lib/api-handler.ts"); + const body = src.slice( + src.indexOf("export async function requireAdmin("), + src.indexOf("export async function requireCookieAuth("), + ); + expect(body).toContain("await getSession()"); + expect(body).not.toMatch(/authenticateBearer|resolveBearerToken/); + expect(body).not.toMatch( + /claimStepUpElevation|validateStepUpElevation|STEP_UP_ELEVATION_HEADER|requireMfaManagementAuth/, + ); + }); +}); + +describe("S4 — the mint surface refuses a cookie", () => { + it("both step-up routes resolve via requireBearerAuth only", () => { + for (const rel of [ + "app/api/auth/step-up/route.ts", + "app/api/auth/step-up/options/route.ts", + ]) { + expect(importedAs(rel, "requireBearerAuth", API_HANDLER)).toEqual([ + "requireBearerAuth", + ]); + expect(importedAs(rel, "requireAuth", API_HANDLER)).toEqual([]); + expect(importedAs(rel, "requireCookieAuth", API_HANDLER)).toEqual([]); + } + }); + + it("requireBearerAuth never consults the session cookie", () => { + const src = read("lib/api-handler.ts"); + const body = src.slice( + src.indexOf("export async function requireBearerAuth("), + src.indexOf("export const STEP_UP_ELEVATION_HEADER"), + ); + expect(body).not.toContain("getSession("); + }); +}); + +describe("S5 — a password proof cannot satisfy a fresh-factor gate", () => { + it("the fresh-factor set excludes password", async () => { + // The B1 invariant, pinned at the definition. The web stamps + // `mfaVerifiedAt` only for a completed second factor or a primary passkey + // login; the Bearer arm must not be looser. + const { FRESH_FACTOR_METHODS, isFreshFactorMethod } = + await import("@/lib/auth/step-up"); + expect([...FRESH_FACTOR_METHODS].sort()).toEqual([ + "passkey", + "totp", + "webauthn", + ]); + expect(isFreshFactorMethod("password")).toBe(false); + }); + + it("the destructive routes all request the fresh-factor arm", () => { + // Miss this on one route and a password-proved elevation tears down the + // second factor there. + for (const rel of [ + "app/api/auth/me/mfa/disable/route.ts", + "app/api/auth/me/mfa/recovery-codes/regenerate/route.ts", + "app/api/auth/me/mfa/webauthn/[id]/route.ts", + ]) { + expect(read(rel)).toMatch(/freshFactor:\s*true/); + } + }); + + it("the claim re-checks the factor rather than trusting the validation", () => { + const src = read("lib/auth/step-up.ts"); + const claim = src.slice( + src.indexOf("export async function claimStepUpElevation("), + ); + expect(claim).toMatch(/requireFreshFactor\s*&&\s*!isFreshFactorMethod/); + }); +}); diff --git a/src/app/api/admin/users/[id]/reset-password/route.ts b/src/app/api/admin/users/[id]/reset-password/route.ts index 2da8adf47..024ccb29a 100644 --- a/src/app/api/admin/users/[id]/reset-password/route.ts +++ b/src/app/api/admin/users/[id]/reset-password/route.ts @@ -11,6 +11,7 @@ import { import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { destroyAllSessions } from "@/lib/auth/session"; +import { revokeStepUpElevations } from "@/lib/auth/step-up"; import { NextRequest, NextResponse } from "next/server"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { checkPasswordBreach } from "@/lib/auth/hibp"; @@ -83,6 +84,11 @@ export const POST = apiHandler( data: { passwordHash }, }); + // v1.30.34 — an operator-forced rotation invalidates the target's step-up + // elevations for the same reason a self-service change does: they attest to + // a credential that no longer exists. + await revokeStepUpElevations(id); + // Invalidate all sessions of the target user await destroyAllSessions(id); diff --git a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts index dfd88440c..7884d3f52 100644 --- a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts +++ b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts @@ -7,7 +7,7 @@ vi.mock("@/lib/api-handler", async () => { ); return { ...actual, - requireFreshMfa: vi.fn(), + requireMfaManagementAuth: vi.fn(), }; }); @@ -39,7 +39,7 @@ vi.mock("@/lib/auth/mfa/verify-factor", () => ({ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); import { POST } from "../route"; -import { requireFreshMfa } from "@/lib/api-handler"; +import { requireMfaManagementAuth } from "@/lib/api-handler"; import { prisma } from "@/lib/db"; import { destroyOtherSessions } from "@/lib/auth/session"; import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; @@ -54,9 +54,11 @@ function req() { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(requireFreshMfa).mockResolvedValue({ + vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "cookie", user: { id: "u1" }, session: { id: "sess-current" }, + commitElevation: vi.fn().mockResolvedValue(undefined), } as never); vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: true } as never); vi.mocked(prisma.$transaction).mockImplementation(async (cb: unknown) => { @@ -74,7 +76,68 @@ describe("POST /api/auth/me/mfa/disable", () => { it("revokes other sessions + trusted devices (via destroyOtherSessions), keeping the current one", async () => { const res = await POST(req()); expect(res.status).toBe(200); - expect(destroyOtherSessions).toHaveBeenCalledWith("u1", "sess-current"); + expect(destroyOtherSessions).toHaveBeenCalledWith("u1", { + kind: "session", + sessionId: "sess-current", + }); + }); + + it("spares the CALLER's own device login on the Bearer path", async () => { + // A Bearer caller has no session row. The earlier revision passed + // `session.id` — an ApiToken id on this arm — into a session-scoped query, + // so the "keep the current one" exclusion excluded nothing: every browser + // session went AND the caller's own refresh token was revoked, logging the + // app out at its next rotation. A cookie-shaped fixture could never see it. + vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "bearer", + user: { id: "u1" }, + apiTokenId: "token-row-1", + accessTokenHash: "hash-of-caller-access-token", + commitElevation: vi.fn().mockResolvedValue(undefined), + } as never); + + const res = await POST(req()); + + expect(res.status).toBe(200); + expect(destroyOtherSessions).toHaveBeenCalledWith("u1", { + kind: "accessToken", + accessTokenHash: "hash-of-caller-access-token", + }); + }); + + it("spends the elevation before tearing the factor down", async () => { + const commitElevation = vi.fn().mockResolvedValue(undefined); + vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "bearer", + user: { id: "u1" }, + apiTokenId: "token-row-1", + accessTokenHash: "h", + commitElevation, + } as never); + + await POST(req()); + + expect(commitElevation).toHaveBeenCalledTimes(1); + }); + + it("does NOT spend the elevation when the factor code is wrong", async () => { + const commitElevation = vi.fn().mockResolvedValue(undefined); + vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "bearer", + user: { id: "u1" }, + apiTokenId: "token-row-1", + accessTokenHash: "h", + commitElevation, + } as never); + vi.mocked(verifyMfaFactor).mockResolvedValue({ + ok: false, + replay: false, + } as never); + + const res = await POST(req()); + + expect(res.status).toBe(401); + expect(commitElevation).not.toHaveBeenCalled(); }); it("rejects a wrong current factor without revoking anything", async () => { diff --git a/src/app/api/auth/me/mfa/disable/route.ts b/src/app/api/auth/me/mfa/disable/route.ts index d5ab54a18..50d22d12a 100644 --- a/src/app/api/auth/me/mfa/disable/route.ts +++ b/src/app/api/auth/me/mfa/disable/route.ts @@ -2,8 +2,9 @@ * POST /api/auth/me/mfa/disable * * Tear down the second factor. Double-gated: - * 1. `requireFreshMfa` — a cookie session that completed a second factor - * within the step-up window (Bearer can never satisfy this). + * 1. `requireMfaManagementAuth({ freshFactor: true })` — a cookie session that + * completed a second factor within the step-up window, OR a Bearer token + * presenting a single-use elevation it minted by re-proving a factor. * 2. A current TOTP or recovery code in the body — proves live possession at * the moment of the destructive action, not just a stale fresh-session. * @@ -12,9 +13,8 @@ */ import { apiHandler, - requireFreshMfa, + requireMfaManagementAuth, HttpError, - MFA_STEP_UP_MAX_AGE_SECONDS, } from "@/lib/api-handler"; import { apiError, @@ -38,7 +38,8 @@ const DISABLE_WINDOW_MS = 15 * 60 * 1000; export const POST = apiHandler(async (req: Request) => { // Step-up gate first — throws StepUpRequiredError (401 + errorCode) if the // session is not freshly second-factor-verified. - const { user, session } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + const auth = await requireMfaManagementAuth({ freshFactor: true }); + const { user } = auth; const rl = await checkRateLimit( `mfa:disable:${user.id}`, @@ -83,6 +84,11 @@ export const POST = apiHandler(async (req: Request) => { throw new HttpError(401, "Invalid code"); } + // Every cheap check has passed and the teardown is next, so spend the + // elevation now. Placing it here rather than at the gate means a wrong code or + // a 429 above costs the caller nothing. + await auth.commitElevation(); + await prisma.$transaction(async (tx) => { await tx.user.update({ where: { id: user.id }, @@ -100,7 +106,12 @@ export const POST = apiHandler(async (req: Request) => { // every "remember this device" trusted device (all handled by // destroyOtherSessions), keeping only the caller's current session. A stale // session or a trusted-device cookie must not survive the factor's removal. - await destroyOtherSessions(user.id, session.id); + await destroyOtherSessions( + user.id, + auth.transport === "cookie" + ? { kind: "session", sessionId: auth.session.id } + : { kind: "accessToken", accessTokenHash: auth.accessTokenHash }, + ); await auditLog("auth.mfa.disabled", { userId: user.id, diff --git a/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts b/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts index a82e3d87d..341515dc8 100644 --- a/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts +++ b/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts @@ -2,15 +2,15 @@ * POST /api/auth/me/mfa/recovery-codes/regenerate * * Issue a fresh recovery-code batch, invalidating the entire prior set in one - * transaction. Step-up gated (`requireFreshMfa`) — a recovery-code rotation is - * a sensitive action; Bearer can never satisfy the gate. The new codes are - * returned **once**. + * transaction. Step-up gated (`requireMfaManagementAuth({ freshFactor: true })`) + * — a recovery-code rotation is sensitive, so it takes either a freshly + * second-factor-verified cookie session or a single-use step-up elevation minted + * by the calling token. The new codes are returned **once**. */ import { apiHandler, - requireFreshMfa, + requireMfaManagementAuth, HttpError, - MFA_STEP_UP_MAX_AGE_SECONDS, } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; @@ -24,7 +24,8 @@ const REGEN_RATE_LIMIT = 5; const REGEN_WINDOW_MS = 15 * 60 * 1000; export const POST = apiHandler(async (req: Request) => { - const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + const auth = await requireMfaManagementAuth({ freshFactor: true }); + const { user } = auth; // Defence in depth — regeneration only makes sense for an active factor. if (!user.totpConfirmedAt) { @@ -44,6 +45,8 @@ export const POST = apiHandler(async (req: Request) => { return res; } + await auth.commitElevation(); + const recoveryCodes = await regenerateRecoveryCodes(user.id); await auditLog("auth.mfa.recovery_regenerated", { diff --git a/src/app/api/auth/me/mfa/route.ts b/src/app/api/auth/me/mfa/route.ts index 2498f5e62..d283a10d8 100644 --- a/src/app/api/auth/me/mfa/route.ts +++ b/src/app/api/auth/me/mfa/route.ts @@ -3,11 +3,21 @@ * * Second-factor status for the security settings hub: whether TOTP is active, * how many recovery codes remain, and the registered WebAuthn security keys. - * Cookie-only (`requireCookieAuth`) — managing MFA is never a Bearer surface. + * Plain `requireAuth()` — a cookie session or a cookie-equivalent token. This + * read is NOT elevation-gated, and deliberately so: it returns metadata only + * (whether TOTP is on, how many recovery codes remain, and each key's name and + * dates) and no credential material at all — no secret, no code, no public key, + * no credential id. The web shows the same screen to any authenticated session, + * and `/api/auth/me` already exposes `totpConfirmedAt` over Bearer, so gating it + * would only force a factor re-proof to render a screen and buy nothing. + * + * Every MUTATION on this surface stays behind `requireMfaManagementAuth`. The + * argument-less `requireAuth()` keeps the fail-closed scope default, so a + * narrow-scope token gains no reach here. * * Returns only metadata: no secret, no recovery-code plaintext, no public key. */ -import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; @@ -16,7 +26,7 @@ import { countRemainingRecoveryCodes } from "@/lib/auth/mfa/recovery-codes"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireCookieAuth(); + const { user } = await requireAuth(); const [recoveryCodesRemaining, webauthn] = await Promise.all([ countRemainingRecoveryCodes(user.id), diff --git a/src/app/api/auth/me/mfa/totp/confirm/route.ts b/src/app/api/auth/me/mfa/totp/confirm/route.ts index 12bf3b78e..8e701bf04 100644 --- a/src/app/api/auth/me/mfa/totp/confirm/route.ts +++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts @@ -6,13 +6,21 @@ * the accepted time-step (replay guard), generates + persists the Argon2id- * hashed recovery codes, and returns the codes **once** for the user to save. * - * The completing session is also stamped `mfaVerifiedAt = now()` so the user - * can immediately manage the freshly-enabled factor (disable / regenerate - * codes) without an extra step-up round-trip. + * On the cookie path the completing session is stamped `mfaVerifiedAt = now()` + * so the user can immediately manage the freshly-enabled factor (disable / + * regenerate codes) without an extra step-up round-trip. A Bearer caller has no + * session row to stamp — the stamping `updateMany` simply matches nothing — and + * mints a fresh elevation per action instead. * - * Cookie-only (`requireCookieAuth`): an API token can never enrol MFA. + * Gated by `requireMfaManagementAuth`: a cookie session, or a Bearer token + * presenting a single-use step-up elevation. A token on its own can still never + * enrol MFA. */ -import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler"; +import { + apiHandler, + requireMfaManagementAuth, + HttpError, +} from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -38,7 +46,8 @@ const CONFIRM_RATE_LIMIT = 10; const CONFIRM_WINDOW_MS = 15 * 60 * 1000; export const POST = apiHandler(async (req: Request) => { - const { user, session } = await requireCookieAuth(); + const auth = await requireMfaManagementAuth(); + const { user } = auth; const rl = await checkRateLimit( `mfa:confirm:${user.id}`, @@ -95,6 +104,10 @@ export const POST = apiHandler(async (req: Request) => { const recoveryCodes = generateRecoveryCodes(); + // The code verified and the activation is next — spend the elevation here so + // a wrong code above costs the caller nothing. + await auth.commitElevation(); + await prisma.$transaction(async (tx) => { await tx.user.update({ where: { id: user.id }, @@ -107,11 +120,14 @@ export const POST = apiHandler(async (req: Request) => { await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } }); await persistRecoveryCodes(tx, user.id, recoveryCodes); // Stamp the completing session so the user can immediately manage the - // factor under step-up without re-verifying. - await tx.session.updateMany({ - where: { id: session.id }, - data: { mfaVerifiedAt: new Date() }, - }); + // factor under step-up without re-verifying. Cookie transport only — a + // Bearer caller has no session row, and mints a fresh elevation per action. + if (auth.transport === "cookie") { + await tx.session.updateMany({ + where: { id: auth.session.id }, + data: { mfaVerifiedAt: new Date() }, + }); + } }); // v1.23 — the account now has an active second factor, so any diff --git a/src/app/api/auth/me/mfa/totp/setup/route.ts b/src/app/api/auth/me/mfa/totp/setup/route.ts index 57fa885d5..a9ef6b5ed 100644 --- a/src/app/api/auth/me/mfa/totp/setup/route.ts +++ b/src/app/api/auth/me/mfa/totp/setup/route.ts @@ -8,11 +8,17 @@ * not active until `/confirm` verifies a code, so `totpConfirmedAt` stays * null here. * - * Cookie-only (`requireCookieAuth`): an API token can never enrol MFA. The - * recovery-code batch is issued at `/confirm` (after the factor is proven), - * not here, so an abandoned setup never persists codes. + * Gated by `requireMfaManagementAuth`: a cookie session, or a Bearer token + * presenting a single-use step-up elevation minted against a re-proved factor. + * A token on its own can still never enrol MFA. The recovery-code batch is + * issued at `/confirm` (after the factor is proven), not here, so an abandoned + * setup never persists codes. */ -import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler"; +import { + apiHandler, + requireMfaManagementAuth, + HttpError, +} from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -27,7 +33,8 @@ const SETUP_RATE_LIMIT = 5; const SETUP_WINDOW_MS = 15 * 60 * 1000; export const POST = apiHandler(async (req: Request) => { - const { user } = await requireCookieAuth(); + const auth = await requireMfaManagementAuth(); + const { user } = auth; const rl = await checkRateLimit( `mfa:setup:${user.id}`, @@ -50,6 +57,9 @@ export const POST = apiHandler(async (req: Request) => { throw new HttpError(409, "A second factor is already active"); } + // Rate limit and the already-active check have passed; the write is next. + await auth.commitElevation(); + const secret = generateTotpSecret(); const account = user.email ?? user.username; const otpauthUri = buildOtpauthUri(secret, account); diff --git a/src/app/api/auth/me/mfa/webauthn/[id]/route.ts b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts index 8c83c7981..61af3c671 100644 --- a/src/app/api/auth/me/mfa/webauthn/[id]/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts @@ -2,17 +2,14 @@ * PATCH /api/auth/me/mfa/webauthn/[id] — rename a registered security key. * DELETE /api/auth/me/mfa/webauthn/[id] — remove one (step-up gated). * - * Rename is cookie-authenticated (non-destructive). Removal is a sensitive - * action: it is gated on a fresh second-factor step-up (`requireFreshMfa`, - * cookie-only — a Bearer token can never satisfy it). + * Both go through `requireMfaManagementAuth`: a cookie session, or a Bearer + * token presenting a single-use step-up elevation. Removal additionally demands + * the fresh-factor arm (`freshFactor: true`) — on the cookie path a session that + * completed a second factor inside the step-up window, on the Bearer path an + * elevation minted against a re-proved factor. */ import { NextRequest } from "next/server"; -import { - apiHandler, - requireCookieAuth, - requireFreshMfa, - MFA_STEP_UP_MAX_AGE_SECONDS, -} from "@/lib/api-handler"; +import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -31,7 +28,8 @@ export const PATCH = apiHandler( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) => { - const { user } = await requireCookieAuth(); + const auth = await requireMfaManagementAuth(); + const { user } = auth; const { id } = await params; const { data: body, error: jsonError } = await safeJson(request, { @@ -52,6 +50,8 @@ export const PATCH = apiHandler( return apiError("Security key not found", 404); } + await auth.commitElevation(); + const updated = await prisma.webauthnMfaCredential.update({ where: { id }, data: { name: parsed.data.name }, @@ -70,7 +70,8 @@ export const DELETE = apiHandler( ) => { // Step-up gate first — throws StepUpRequiredError (401 + errorCode) if the // session is not freshly second-factor-verified. - const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + const auth = await requireMfaManagementAuth({ freshFactor: true }); + const { user } = auth; const { id } = await params; const existing = await prisma.webauthnMfaCredential.findUnique({ @@ -81,6 +82,10 @@ export const DELETE = apiHandler( return apiError("Security key not found", 404); } + // Ownership resolved and the row is about to go — spend the elevation now, + // so a 404 for someone else's key id does not burn it. + await auth.commitElevation(); + await prisma.webauthnMfaCredential.delete({ where: { id } }); await auditLog("auth.mfa.webauthn.remove", { diff --git a/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts index 37551d36d..e7704c9ca 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts @@ -8,7 +8,7 @@ vi.mock("@/lib/auth/mfa-enrollment", () => ({ vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn, - requireCookieAuth: vi.fn(), + requireMfaManagementAuth: vi.fn(), })); vi.mock("@/lib/db", () => ({ @@ -33,7 +33,7 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); import { POST as REGISTER_OPTIONS } from "../options/route"; import { POST as REGISTER_VERIFY } from "../verify/route"; -import { requireCookieAuth } from "@/lib/api-handler"; +import { requireMfaManagementAuth } from "@/lib/api-handler"; import { prisma } from "@/lib/db"; import { checkRateLimit } from "@/lib/rate-limit"; import { @@ -56,7 +56,12 @@ function verifyRequest(body: Record): NextRequest { beforeEach(() => { vi.resetAllMocks(); - vi.mocked(requireCookieAuth).mockResolvedValue({ user: USER } as never); + vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "cookie", + user: USER, + session: { id: "sess-1" }, + commitElevation: vi.fn().mockResolvedValue(undefined), + } as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); }); diff --git a/src/app/api/auth/me/mfa/webauthn/register/options/route.ts b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts index 350cf7f82..a472d8d55 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/options/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts @@ -1,11 +1,12 @@ /** * POST /api/auth/me/mfa/webauthn/register/options * - * Begin registering a WebAuthn security key as a second factor. Cookie-only — - * an API token can never enrol MFA. Returns the SimpleWebAuthn creation - * options + the server-issued challenge id to present back at /register/verify. + * Begin registering a WebAuthn security key as a second factor. Takes a cookie + * session or a Bearer token presenting a single-use step-up elevation; a token + * on its own can never enrol MFA. Returns the SimpleWebAuthn creation options + + * the server-issued challenge id to present back at /register/verify. */ -import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; +import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -14,7 +15,8 @@ import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn"; export const dynamic = "force-dynamic"; export const POST = apiHandler(async () => { - const { user } = await requireCookieAuth(); + const auth = await requireMfaManagementAuth(); + const { user } = auth; const rl = await checkRateLimit( `mfa:webauthn:register:${user.id}`, @@ -29,6 +31,8 @@ export const POST = apiHandler(async () => { return res; } + await auth.commitElevation(); + const { options, challengeId } = await createMfaRegistrationOptions( user.id, user.email ?? user.username, diff --git a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts index 8a9e0c5d7..371e91174 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts @@ -1,12 +1,13 @@ /** * POST /api/auth/me/mfa/webauthn/register/verify * - * Finish registering a WebAuthn security key as a second factor. Cookie-only. - * Verifies the attestation against the user-bound challenge and stores the - * credential in `WebauthnMfaCredential` (kept separate from primary passkeys). + * Finish registering a WebAuthn security key as a second factor. Takes a cookie + * session or a Bearer token presenting a single-use step-up elevation. Verifies + * the attestation against the user-bound challenge and stores the credential in + * `WebauthnMfaCredential` (kept separate from primary passkeys). */ import { NextRequest } from "next/server"; -import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; +import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -23,7 +24,8 @@ import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; export const dynamic = "force-dynamic"; export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireCookieAuth(); + const auth = await requireMfaManagementAuth(); + const { user } = auth; const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, @@ -45,6 +47,10 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Security key verification failed", 400); } + // The attestation verified and the credential is about to be stored — a + // failed ceremony above must not have cost the caller their elevation. + await auth.commitElevation(); + const { registrationInfo } = verification; const created = await prisma.webauthnMfaCredential.create({ data: { diff --git a/src/app/api/auth/me/sessions/__tests__/route.test.ts b/src/app/api/auth/me/sessions/__tests__/route.test.ts index 575dca26f..17bf18a59 100644 --- a/src/app/api/auth/me/sessions/__tests__/route.test.ts +++ b/src/app/api/auth/me/sessions/__tests__/route.test.ts @@ -157,7 +157,10 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.data.sessionsRevoked).toBe(3); - expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", "sess-current"); + expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", { + kind: "session", + sessionId: "sess-current", + }); }); }); diff --git a/src/app/api/auth/me/sessions/route.ts b/src/app/api/auth/me/sessions/route.ts index 440f4d44a..24f41a095 100644 --- a/src/app/api/auth/me/sessions/route.ts +++ b/src/app/api/auth/me/sessions/route.ts @@ -88,7 +88,10 @@ export const GET = apiHandler(async () => { export const DELETE = apiHandler(async (request: NextRequest) => { const { user, session } = await requireAuth(); - const { sessionsRevoked } = await destroyOtherSessions(user.id, session.id); + const { sessionsRevoked } = await destroyOtherSessions(user.id, { + kind: "session", + sessionId: session.id, + }); await auditLog("auth.session.revoke_others", { userId: user.id, diff --git a/src/app/api/auth/password/__tests__/route.test.ts b/src/app/api/auth/password/__tests__/route.test.ts index f5fe53763..e13ecff29 100644 --- a/src/app/api/auth/password/__tests__/route.test.ts +++ b/src/app/api/auth/password/__tests__/route.test.ts @@ -24,6 +24,12 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.30.34 — the route drops the account's step-up elevations alongside its +// sessions, so the rotation cannot leave a proof of the old credential standing. +vi.mock("@/lib/auth/step-up", () => ({ + revokeStepUpElevations: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(), destroyAllSessions: vi.fn().mockResolvedValue(undefined), diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 3371b0a0c..ae97c2792 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -22,6 +22,7 @@ import { } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { destroyAllSessions, createSession } from "@/lib/auth/session"; +import { revokeStepUpElevations } from "@/lib/auth/step-up"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { checkPasswordBreach } from "@/lib/auth/hibp"; import { getServerTranslator } from "@/lib/i18n/server-translator"; @@ -102,6 +103,11 @@ export const POST = apiHandler(async (request: NextRequest) => { data: { passwordHash: newHash }, }); + // v1.30.34 — a step-up elevation is a statement about a credential that no + // longer exists. Drop every one the account holds before the sessions go, so + // an elevation minted moments before the rotation cannot outlive it. + await revokeStepUpElevations(user.id); + // Invalidate all existing sessions and create a fresh one. // v1.4.22 W5 reconcile (Sr-H1) — `createSession` re-anchors the // `hl_onboarding` cookie itself. diff --git a/src/app/api/auth/step-up/options/route.ts b/src/app/api/auth/step-up/options/route.ts new file mode 100644 index 000000000..30392af0c --- /dev/null +++ b/src/app/api/auth/step-up/options/route.ts @@ -0,0 +1,81 @@ +/** + * POST /api/auth/step-up/options + * + * Begin an assertion ceremony for the step-up mint. Two kinds: + * + * `passkey` — against the account's PRIMARY passkeys. At parity with + * `/api/auth/passkey/login-verify`, which the web treats as a + * second-factor-grade proof. + * `webauthn` — against the account's registered SECOND-FACTOR security keys. + * + * Returns SimpleWebAuthn assertion options plus the server-issued challenge id + * to present back at `POST /api/auth/step-up`. + * + * Bearer-only (`requireBearerAuth`) — a browser has no use for this surface; it + * re-proves a factor at login and carries the result on its session row. The + * allow-list is never empty by construction: an account with no credential of + * the requested kind gets a 409 so the client can offer another arm. + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireBearerAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { createAuthenticationOptions } from "@/lib/auth/passkey"; +import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn"; +import { stepUpOptionsSchema } from "@/lib/validations/step-up"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireBearerAuth(); + + const rl = await checkAuthSurfaceRateLimit( + request, + "auth:step-up", + 20, + 15 * 60 * 1000, + ); + if (!rl.allowed) { + return apiError("Too many attempts. Please try again later.", 429, { + headers: rateLimitHeaders(rl), + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = stepUpOptionsSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid request", 422); + } + + if (parsed.data.method === "webauthn") { + const result = await createMfaAuthenticationOptions(user.id); + if (!result) { + annotate({ action: { name: "auth.stepup.webauthn.no_key" } }); + return apiError("No security key registered", 409); + } + annotate({ action: { name: "auth.stepup.webauthn.options" } }); + return apiSuccess({ + options: result.options, + challengeId: result.challengeId, + }); + } + + const passkeyCount = await prisma.passkey.count({ + where: { userId: user.id }, + }); + if (passkeyCount === 0) { + annotate({ action: { name: "auth.stepup.passkey.none" } }); + return apiError("No passkey registered", 409); + } + + const { options, challengeId } = await createAuthenticationOptions(user.id); + + annotate({ action: { name: "auth.stepup.passkey.options" } }); + return apiSuccess({ options, challengeId }); +}); diff --git a/src/app/api/auth/step-up/route.ts b/src/app/api/auth/step-up/route.ts new file mode 100644 index 000000000..7744cc320 --- /dev/null +++ b/src/app/api/auth/step-up/route.ts @@ -0,0 +1,236 @@ +/** + * POST /api/auth/step-up + * + * Re-prove a factor and receive a single-use, token-bound step-up elevation for + * one second-factor-management call. + * + * WHICH factor is re-proved decides WHAT the elevation reaches. `password` + * reaches exactly what a plain cookie session reaches. `totp`, `webauthn`, and + * `passkey` additionally satisfy the fresh-factor routes — disable, recovery-code + * rotation, security-key removal — and that set is precisely the set of + * ceremonies for which the web stamps a session second-factor-verified. If a + * password-proved elevation opened those routes, a stolen token plus the account + * password would rotate the recovery codes (which take no factor in the body) + * and then spend one of them to disable the second factor outright. + * + * This is the whole point of the mechanism, so it is worth being blunt about + * what it is not: it is NOT a way to turn a token into a stronger credential. + * Presenting the token gets you as far as this endpoint and no further — the + * body must carry a fresh proof of the account password or of a primary passkey + * the device holds. A stolen token can call this all day and mint nothing. + * + * Bearer-only (`requireBearerAuth`). A browser cannot reach it even with a valid + * session, which keeps an ambient cookie credential out of the mint path + * entirely. + * + * Failures are deliberately indistinguishable. A wrong password, an account with + * no password set, a passkey belonging to someone else, a stale challenge — all + * return the same 401 with the same prose. The audit row carries the real reason; + * the wire carries none of it. + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireBearerAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { + checkAuthSurfaceRateLimit, + checkRateLimit, + rateLimitHeaders, +} from "@/lib/rate-limit"; +import { verifyPassword } from "@/lib/auth/password"; +import { verifyAuthentication } from "@/lib/auth/passkey"; +import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn"; +import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; +import { + mintStepUpElevation, + isFreshFactorMethod, + STEP_UP_ELEVATION_TTL_SECONDS, + type StepUpMethod, +} from "@/lib/auth/step-up"; +import { stepUpMintSchema } from "@/lib/validations/step-up"; + +export const dynamic = "force-dynamic"; + +/** + * Per-account ceiling. Tight because the password arm runs an Argon2id verify + * against a user-supplied string — the same 5-per-15-minutes the password-change + * route uses, for the same reason. + */ +const MINT_LIMIT = 5; +const MINT_WINDOW_MS = 15 * 60 * 1000; + +export const POST = apiHandler(async (request: NextRequest) => { + const auth = await requireBearerAuth(); + const { user } = auth; + // `requireAuth`'s contract puts the resolved `ApiToken` row id here. It is the + // binding the minted elevation is tied to. + const apiTokenId = auth.session.id; + const ip = getClientIp(request); + + // Two buckets. The per-IP surface bucket collapses to the tight shared anon + // bucket when the proxy trust chain is broken, so a misconfigured deployment + // caps a spray rather than opening one. + const ipRl = await checkAuthSurfaceRateLimit( + request, + "auth:step-up", + 20, + MINT_WINDOW_MS, + ); + if (!ipRl.allowed) { + return apiError("Too many attempts. Please try again later.", 429, { + headers: rateLimitHeaders(ipRl), + }); + } + + const userRl = await checkRateLimit( + `auth:step-up:${user.id}`, + MINT_LIMIT, + MINT_WINDOW_MS, + ); + if (!userRl.allowed) { + await auditLog("auth.stepup.mint.rate_limited", { + userId: user.id, + ipAddress: ip, + }); + annotate({ action: { name: "auth.stepup.mint.rate_limited" } }); + return apiError("Too many attempts. Please wait 15 minutes.", 429, { + headers: rateLimitHeaders(userRl), + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = stepUpMintSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid request", 422); + } + + const method: StepUpMethod = parsed.data.method; + let proved = false; + // Audit-only detail. Never reaches the response — see the file header. + let failure = "invalid"; + + /** + * Resolve a WebAuthn challenge and confirm it belongs to THIS account before + * any verification runs. Both ceremony helpers resolve a challenge by id + * alone, so without this a challenge minted for another account could be + * carried into the ceremony. + */ + const challengeBelongsToCaller = async (id: string): Promise => { + const row = await prisma.authChallenge.findUnique({ + where: { id }, + select: { userId: true }, + }); + return Boolean(row && row.userId === user.id); + }; + + if (parsed.data.method === "password") { + if (!user.passwordHash) { + // An SSO-provisioned account has no password to re-prove. Same refusal as + // a wrong one: the account's credential shape is not a token holder's to + // enumerate. `/api/auth/step-up/options` is the honest discovery path — + // it 409s when there is no credential of the requested kind either. + failure = "no_password"; + } else { + proved = await verifyPassword(user.passwordHash, parsed.data.password); + if (!proved) failure = "bad_password"; + } + } else if (parsed.data.method === "totp") { + // The shared factor verifier, so the replay guard and the accepted-step + // burn behave exactly as they do at login and at MFA-disable. A code spent + // here cannot be replayed there. + const result = await verifyMfaFactor(user, "totp", parsed.data.code); + proved = result.ok; + if (!proved) failure = result.replay ? "totp_replay" : "bad_totp"; + } else if (parsed.data.method === "webauthn") { + if (!(await challengeBelongsToCaller(parsed.data.challengeId))) { + failure = "foreign_challenge"; + } else { + try { + // Scoped to the caller's own second-factor credentials by the verifier. + proved = await verifyMfaAuthentication( + parsed.data.challengeId, + user.id, + parsed.data.credential, + ); + if (!proved) failure = "bad_assertion"; + } catch { + // A malformed / expired challenge is a failed attempt, not a 500. + failure = "bad_assertion"; + } + } + } else { + if (!(await challengeBelongsToCaller(parsed.data.challengeId))) { + failure = "foreign_challenge"; + } else { + try { + const result = await verifyAuthentication( + parsed.data.challengeId, + parsed.data.credential, + ); + // Both halves matter: a verified assertion against SOMEONE ELSE'S + // passkey proves possession of a factor, just not of this account's. + proved = + result.verification.verified && result.passkey.userId === user.id; + if (!proved) failure = "bad_assertion"; + } catch { + failure = "bad_assertion"; + } + } + } + + if (!proved) { + await auditLog("auth.stepup.mint.failed", { + userId: user.id, + ipAddress: ip, + details: { method: parsed.data.method, reason: failure }, + }); + annotate({ + action: { name: "auth.stepup.mint.failed" }, + meta: { method: parsed.data.method, reason: failure }, + }); + return apiError("Verification failed", 401); + } + + const { token, expiresAt } = await mintStepUpElevation({ + userId: user.id, + apiTokenId, + method, + }); + + await auditLog("auth.stepup.mint.succeeded", { + userId: user.id, + ipAddress: ip, + details: { method }, + }); + annotate({ + action: { name: "auth.stepup.mint.succeeded" }, + meta: { method }, + }); + + // `elevation` is the raw secret and is returned exactly once. It is never + // persisted in plaintext and never logged — no wide-event field carries a + // response body, and `redactSecrets` holds an `hle_` rule regardless. + return apiSuccess({ + elevation: token, + expiresAt: expiresAt.toISOString(), + expiresInSeconds: STEP_UP_ELEVATION_TTL_SECONDS, + method, + // Whether this elevation reaches the fresh-factor routes (disable, + // recovery-code rotation, security-key removal). A password-proved + // elevation does not, exactly as a password login does not on the web. + // Surfaced so the client can pick the right ceremony up front instead of + // discovering the refusal after spending a proof. + satisfiesFreshFactor: isFreshFactorMethod(method), + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 850c0dca3..96b32e4a9 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -3,7 +3,7 @@ import { headers } from "next/headers"; import type { User } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { WideEventBuilder } from "./logging/event-builder"; -import { eventStorage, getEvent } from "./logging/context"; +import { annotate, eventStorage, getEvent } from "./logging/context"; import { emitIfSampled } from "./logging/transports"; import { redactOptional, redactSecrets } from "./logging/redact"; import { getSession } from "./auth/session"; @@ -13,6 +13,12 @@ import { BearerAuthError, type ScopeRequirement, } from "./auth/bearer"; +import { + claimStepUpElevation, + validateStepUpElevation, + STEP_UP_ELEVATION_TTL_SECONDS, +} from "./auth/step-up"; +import { hashToken } from "./auth/hmac"; import { AssistantDisabledError } from "./feature-flags"; import { ConsentRequiredError } from "./ai/consent-guard"; import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config"; @@ -620,6 +626,240 @@ export async function requireFreshMfaIfEnrolled( return auth; } +/** + * A Bearer-resolved caller. + * + * `apiTokenId` and `accessTokenHash` are named rather than smuggled through + * `AuthContext.session.id`. That field means "the session row id" on the cookie + * path and "the ApiToken row id" on the Bearer path, and code that forgot which + * one it held passed a token id to a session-scoped query — deleting every one + * of the user's browser sessions and revoking the caller's own refresh token. + * Naming the fields is what stops that recurring. + */ +export interface BearerAuthContext extends AuthContext { + /** The `ApiToken` row id — the binding a step-up elevation is tied to. */ + apiTokenId: string; + /** + * HMAC of the presented access token, which is what `RefreshToken` + * cross-references in `accessTokenHash`. Lets a route identify the CALLER's + * own device login and spare it when revoking every other one. + */ + accessTokenHash: string; +} + +/** + * v1.30.34 — resolve a caller by Bearer token ONLY, refusing a cookie session. + * + * The mirror image of `requireCookieAuth`, and it exists for one surface: the + * step-up mint endpoints, which are the Bearer transport's own re-authentication + * flow and have no meaning for a browser (a browser re-proves a factor at login + * and carries the result on its session row). Refusing the cookie keeps the mint + * surface entirely outside the cookie's blast radius — no ambient credential can + * reach it, so the class of attack where a browser is induced to fire a request + * on the user's behalf simply does not apply. + * + * Scope handling is the standard fail-closed default: no declared scope, so only + * a cookie-equivalent (`["*"]`) token is admitted. A narrow token — an MCP grant, + * a medication-ingest grant — is refused 403 by the resolver. + */ +export async function requireBearerAuth(): Promise { + let authHeader: string | null = null; + try { + const headerList = await headers(); + authHeader = headerList.get("authorization"); + } catch { + authHeader = null; + } + if (!authHeader?.startsWith("Bearer ")) { + throw new HttpError(401, "Not authenticated"); + } + const raw = authHeader.slice(7); + const auth = await authenticateBearer(raw, undefined); + return { + ...auth, + apiTokenId: auth.session.id, + accessTokenHash: hashToken(raw), + }; +} + +/** + * Header carrying a step-up elevation on the Bearer path. Never logged — the + * wide event captures no request headers at all, and `redactSecrets` carries an + * `hle_` rule as a second line of defence. + */ +export const STEP_UP_ELEVATION_HEADER = "x-step-up"; + +/** + * What an MFA-management route received, with the transport made explicit. + * + * The two arms carry DIFFERENT fields on purpose. A cookie caller has a session + * row and a Bearer caller does not, so `session` exists only on the cookie arm + * and the compiler refuses to read it on the other. This is the fix for a real + * defect: the MFA-disable route passed `session.id` to `destroyOtherSessions`, + * which on the Bearer path was an ApiToken id — matching no session row, so the + * "keep the current one" exclusion excluded nothing and the caller revoked its + * own device login. A comment would not have caught that; a type does. + */ +export type MfaManagementContext = { + user: User; + /** + * Spend the elevation. Call it immediately BEFORE the mutation, once every + * cheap validation has passed — a 429, a 422, or a wrong factor code must not + * burn a proof the user then has to mint again against a 5-per-15-minute + * ceiling. + * + * A no-op on the cookie arm (the session stamp is not consumable). On the + * Bearer arm it is the atomic single-use claim, and it THROWS + * `StepUpRequiredError` if the claim is lost — so a concurrent redemption + * still yields exactly one winner even though validation happened earlier. + */ + commitElevation: () => Promise; +} & ( + | { transport: "cookie"; session: { id: string; expiresAt: Date } } + | { transport: "bearer"; apiTokenId: string; accessTokenHash: string } +); + +/** + * v1.30.34 — the single gate for second-factor management. + * + * Every MFA-management MUTATION goes through here and nothing else does. The set + * is frozen by `src/__tests__/step-up-elevation-guard.test.ts`, so a future + * route cannot quietly join it: widening the reach of an elevation has to be a + * visible edit to that allowlist. + * + * Two accepted proofs, and they are equals rather than a primary and a fallback: + * + * COOKIE — unchanged, byte for byte. A cookie session delegates to + * `requireCookieAuth` / `requireFreshMfa` exactly as before this function + * existed. The web flow cannot regress here because there is no new code on + * its path; the elevation branch is only reached when there is no session at + * all. + * + * BEARER + ELEVATION — a token that resolves cleanly AND presents a valid, + * unconsumed elevation minted for that same token against a re-proved factor + * of sufficient strength. The token alone is never enough. + * + * FRESH FACTOR IS ABOUT WHICH FACTOR, NOT JUST HOW RECENT. On the cookie path + * `requireFreshMfa` reads `Session.mfaVerifiedAt`, and only a completed second + * factor or a primary passkey login ever writes it — a password login does not. + * The Bearer arm holds the identical line through `FRESH_FACTOR_METHODS`: a + * password-proved elevation reaches what a plain cookie session reaches and + * stops there. Without that rule, a stolen token plus the account password could + * rotate the recovery codes and spend one to disable the second factor. + * + * `requireAdmin` is untouched and stays cookie-only. An elevation cannot reach + * it — not because a check refuses one, but because `requireAdmin` resolves + * through `getSession()` and never consults this function or the header. + * + * @param options.freshFactor mirrors the cookie path's `requireFreshMfa`. Set by + * the destructive routes (disable, recovery-code rotation, security-key + * removal). + */ +export async function requireMfaManagementAuth( + options: { freshFactor?: boolean } = {}, +): Promise { + const freshFactor = options.freshFactor === true; + + // Cookie first, and via the original helpers — the web path runs the same + // code it always did. + const sessionData = await getSession(); + if (sessionData) { + const resolved = freshFactor + ? await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS) + : await requireCookieAuth(); + return { + transport: "cookie", + user: resolved.user, + session: resolved.session, + commitElevation: async () => {}, + }; + } + + // 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(); + + let raw: string | null = null; + try { + const headerList = await headers(); + raw = headerList.get(STEP_UP_ELEVATION_HEADER); + } catch { + raw = null; + } + + if (!raw) { + annotate({ + action: { name: "auth.stepup.elevation.missing" }, + meta: { maxAgeSeconds: STEP_UP_ELEVATION_TTL_SECONDS, freshFactor }, + }); + throw new StepUpRequiredError(); + } + + const refusal = (reason: string): StepUpRequiredError => { + // One audit row with the machine reason, one generic refusal on the wire. + // A prober learns only "not accepted" — never whether the elevation was + // unknown, already spent, expired, minted for a different token, or minted + // from a factor too weak for this route. + auditLog("auth.stepup.elevation.rejected", { + userId: auth.user.id, + details: { reason, freshFactor }, + }).catch(() => {}); + annotate({ + action: { name: "auth.stepup.elevation.rejected" }, + meta: { reason, freshFactor }, + }); + // Returned rather than thrown so every call site reads `throw refusal(...)` + // and the compiler narrows the result union afterwards. + return new StepUpRequiredError(); + }; + + // 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, + userId: auth.user.id, + apiTokenId: auth.apiTokenId, + requireFreshFactor: freshFactor, + }); + if (!validated.ok) throw refusal(validated.reason); + + // Parity with the cookie path: `requireFreshMfa` refuses an account with no + // second factor enrolled, because a step-up-gated action is meaningless there. + // The Bearer path holds the same line rather than becoming the softer route. + if (freshFactor) { + let enrolled = Boolean(auth.user.totpConfirmedAt); + if (!enrolled) { + const keys = await prisma.webauthnMfaCredential.count({ + where: { userId: auth.user.id }, + }); + enrolled = keys > 0; + } + if (!enrolled) { + 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 }, + }); + }, + }; +} + /** * Report unhandled errors to GlitchTip (fire-and-forget). * Uses dynamic import to avoid circular dependencies and startup cost. diff --git a/src/lib/auth/__tests__/step-up.test.ts b/src/lib/auth/__tests__/step-up.test.ts new file mode 100644 index 000000000..f37365ddb --- /dev/null +++ b/src/lib/auth/__tests__/step-up.test.ts @@ -0,0 +1,185 @@ +/** + * Unit-level checks on the elevation primitive that need no database. + * + * The behavioural work — binding, single use, expiry, the concurrent race, the + * fresh-factor rule end to end — lives in + * `tests/integration/step-up-elevation.test.ts`, because every one of those + * claims is a claim about what Postgres does with a WHERE clause and a mocked + * client cannot answer it. What is worth pinning here is the arithmetic and the + * constants, both of which a mock CAN answer honestly. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { readFileSync } from "node:fs"; + +const createMock = vi.fn().mockResolvedValue({ id: "elevation-1" }); + +vi.mock("@/lib/db", () => ({ + prisma: { + $transaction: vi.fn(async (fn: (tx: unknown) => Promise) => + fn({ + stepUpElevation: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + create: createMock, + }, + }), + ), + stepUpElevation: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + findUnique: vi.fn().mockResolvedValue(null), + }, + $queryRaw: vi.fn().mockResolvedValue([]), + }, +})); + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-step-up-unit-32-bytes-minimum-abcdefghijklmnop"; + +const { + mintStepUpElevation, + claimStepUpElevation, + validateStepUpElevation, + isFreshFactorMethod, + FRESH_FACTOR_METHODS, + STEP_UP_ELEVATION_TTL_SECONDS, +} = await import("@/lib/auth/step-up"); +const { MFA_STEP_UP_MAX_AGE_SECONDS, STEP_UP_ELEVATION_HEADER } = + await import("@/lib/api-handler"); +const { hashToken } = await import("@/lib/auth/hmac"); + +beforeEach(() => { + createMock.mockClear(); +}); + +async function mint() { + return mintStepUpElevation({ + userId: "u1", + apiTokenId: "t1", + method: "password", + }); +} + +/** The `data` object the mint handed to Prisma. */ +function lastCreateData(): { tokenHash: string; expiresAt: Date } { + return createMock.mock.calls.at(-1)![0].data; +} + +describe("the two step-up windows stay pinned together", () => { + it("matches the cookie path's freshness window exactly", () => { + // If these ever diverge, the looser transport quietly becomes the way in. + // Changing one is a decision; changing one by accident is a hole. + expect(STEP_UP_ELEVATION_TTL_SECONDS).toBe(MFA_STEP_UP_MAX_AGE_SECONDS); + }); +}); + +describe("the minted value", () => { + it("is a prefixed 32-byte CSPRNG secret", async () => { + const { token } = await mint(); + expect(token).toMatch(/^hle_[0-9a-f]{64}$/); + }); + + it("stores the HMAC and nothing derived from the plaintext", async () => { + const { token } = await mint(); + const { tokenHash } = lastCreateData(); + + // Asserted as an EQUALITY against the expected hash. The previous + // `not.toContain` comparison was vacuous — the stored value is 64 chars and + // the token is 68, so a 64-char string can never contain it, and storing + // `token.slice(4)` in cleartext would have passed. + expect(tokenHash).toBe(hashToken(token)); + expect(tokenHash).not.toBe(token); + expect(tokenHash).not.toBe(token.slice(4)); + }); + + it("differs on every mint", async () => { + const a = await mint(); + const b = await mint(); + expect(a.token).not.toBe(b.token); + }); +}); + +describe("the expiry that is actually written", () => { + it("lands five minutes ahead, not five minutes' worth of milliseconds", async () => { + // The returned `expiresInSeconds` is an echo of a constant and proves + // nothing about the row. Dropping the `* 1000` from the mint yields a + // 300-MILLISECOND elevation with every constant still reading 300, so the + // computed timestamp is what has to be asserted. + const before = Date.now(); + const { expiresAt } = await mint(); + const after = Date.now(); + + const written = lastCreateData().expiresAt; + expect(written.getTime()).toBe(expiresAt.getTime()); + + const target = STEP_UP_ELEVATION_TTL_SECONDS * 1000; + expect(written.getTime() - before).toBeGreaterThanOrEqual(target - 1_000); + expect(written.getTime() - after).toBeLessThanOrEqual(target + 1_000); + // And unambiguously far from the millisecond-scale mistake. + expect(written.getTime() - before).toBeGreaterThan(60_000); + }); +}); + +describe("the fresh-factor rule", () => { + it("admits only the methods the web stamps a session for", () => { + expect([...FRESH_FACTOR_METHODS].sort()).toEqual([ + "passkey", + "totp", + "webauthn", + ]); + expect(isFreshFactorMethod("totp")).toBe(true); + expect(isFreshFactorMethod("webauthn")).toBe(true); + expect(isFreshFactorMethod("passkey")).toBe(true); + // The B1 invariant: a password is not a second factor. + expect(isFreshFactorMethod("password")).toBe(false); + }); +}); + +describe("a wrong-shaped value is refused before touching the database", () => { + it.each([ + ["session secret", `hls_${"a".repeat(64)}`], + ["access token", `hlk_${"a".repeat(64)}`], + ["empty", ""], + ])("refuses a %s without a query", async (_label, value) => { + const { prisma } = await import("@/lib/db"); + vi.mocked(prisma.$queryRaw).mockClear(); + vi.mocked(prisma.stepUpElevation.findUnique).mockClear(); + + for (const fn of [claimStepUpElevation, validateStepUpElevation]) { + const result = await fn({ + rawToken: value, + userId: "u1", + apiTokenId: "t1", + requireFreshFactor: false, + }); + expect(result).toEqual({ ok: false, reason: "malformed" }); + } + + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + expect(prisma.stepUpElevation.findUnique).not.toHaveBeenCalled(); + }); +}); + +describe("the elevation header name is stable", () => { + it("is the lowercase form the gate reads", () => { + expect(STEP_UP_ELEVATION_HEADER).toBe("x-step-up"); + expect(STEP_UP_ELEVATION_HEADER).toBe( + STEP_UP_ELEVATION_HEADER.toLowerCase(), + ); + }); +}); + +describe("the credential prefix lists do not drift apart", () => { + it("the idempotency cache and the log redactor both know `hle_`", async () => { + // Two parallel denylists guard the same class of value. They were already + // one prefix out of step (`hls_` was in one and not the other), which is + // how the next one gets missed. + const idempotency = readFileSync("src/lib/idempotency.ts", "utf8"); + expect(idempotency).toContain("hle_"); + + const { redactSecrets } = await import("@/lib/logging/redact"); + const secret = `hle_${"a".repeat(64)}`; + expect(redactSecrets(`elevation=${secret}`)).not.toContain(secret); + expect(redactSecrets(`session=hls_${"b".repeat(64)}`)).toContain( + "[REDACTED]", + ); + }); +}); diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 4cb7839b2..cdc22a74a 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -368,6 +368,16 @@ export async function destroyAllSessions(userId: string): Promise { ]); } +/** + * How the caller of a "revoke everything else" action authenticated. + * + * Exists so the caller's own credential can be spared without any code having + * to guess what kind of id it is holding — see `destroyOtherSessions`. + */ +export type CurrentCredential = + | { kind: "session"; sessionId: string } + | { kind: "accessToken"; accessTokenHash: string }; + /** * v1.23 — "sign out everywhere" for the user-facing active-session surface * (issue #64). Distinct from `destroyAllSessions`: this keeps the caller's @@ -382,20 +392,41 @@ export async function destroyAllSessions(userId: string): Promise { */ export async function destroyOtherSessions( userId: string, - currentSessionId: string, + current: CurrentCredential, ): Promise<{ sessionsRevoked: number }> { + // Which rows describe the CALLER depends on how the caller authenticated, and + // the two shapes are not interchangeable. A cookie caller is a `Session` row. + // A Bearer caller is an `ApiToken` whose device login is the `RefreshToken` + // carrying the matching `accessTokenHash` — it has no session row at all, so + // every session goes. Passing an ApiToken id in as a session id (which an + // earlier revision did) silently excluded nothing and revoked the caller's own + // refresh token, logging the calling device out at its next rotation. + const keptSessionId = + current.kind === "session" ? current.sessionId : undefined; + const keptRefreshWhere = + current.kind === "accessToken" + ? { accessTokenHash: { not: current.accessTokenHash } } + : {}; + const [deleted] = await prisma.$transaction([ prisma.session.deleteMany({ - where: { userId, id: { not: currentSessionId } }, + where: { + userId, + ...(keptSessionId ? { id: { not: keptSessionId } } : {}), + }, }), prisma.refreshToken.updateMany({ - where: { userId, revokedAt: null }, + where: { userId, revokedAt: null, ...keptRefreshWhere }, data: { revokedAt: new Date() }, }), // v1.23 — "sign out everywhere" also drops every trusted device so a // remembered browser can no longer skip the second factor (§1.7: a device // cookie is killed on sign-out-everywhere). prisma.trustedDevice.deleteMany({ where: { userId } }), + // v1.30.34 — and every step-up elevation. A user who signs out everywhere + // expects nothing to remain that could act on the account; a live elevation + // is exactly that. + prisma.stepUpElevation.deleteMany({ where: { userId } }), ]); return { sessionsRevoked: deleted.count }; } diff --git a/src/lib/auth/step-up.ts b/src/lib/auth/step-up.ts new file mode 100644 index 000000000..b718e54c2 --- /dev/null +++ b/src/lib/auth/step-up.ts @@ -0,0 +1,335 @@ +/** + * Token-bound step-up elevations — the Bearer-transport answer to + * `Session.mfaVerifiedAt`. + * + * WHY THIS EXISTS. Second-factor management (enrol TOTP, confirm it, disable + * it, rotate recovery codes, add or remove a security key) is gated cookie-only. + * That gate is correct and stays: a Bearer token is a credential whose + * provenance the server cannot see — it may have come from an interactive login + * on the owner's phone, or from a value pasted into a script on a shared + * machine. Tearing down someone's second factor must not be reachable from the + * weaker of those two. + * + * What was missing was never Bearer ACCESS to those routes. It was a way to + * express, over a transport that has no session row, "the human holding this + * device just re-proved a factor". The web writes that as a timestamp on the + * session; a token client has no session to stamp. An elevation is that proof, + * made explicit and made disposable. + * + * WHICH FACTOR WAS PROVED IS PART OF THE PROOF. `method` is an authorisation + * input, not telemetry. The web stamps `mfaVerifiedAt` only for a completed + * SECOND factor (TOTP, security key) or a primary passkey login — never for a + * password. If a password-proved elevation satisfied the fresh-factor routes, + * a stolen token plus the account password would rotate the recovery codes + * (which take no factor in the body) and then spend one of them to disable the + * second factor outright. So the fresh-factor routes accept only + * `FRESH_FACTOR_METHODS`, and the password arm reaches exactly what a plain + * cookie session reaches — no more. + * + * THE OTHER PROPERTIES, and why each is load-bearing: + * + * Minted only against a re-proved factor. Presenting the token alone yields + * nothing. A stolen token gains no more reach than it had before. + * + * Bound to one token (`apiTokenId`). An elevation minted by the phone cannot + * be redeemed by a second token, not even another of the same user's. + * + * Single-use. The claim is one conditional UPDATE, so two concurrent + * redemptions cannot both succeed. + * + * Short-lived. `STEP_UP_ELEVATION_TTL_SECONDS` equals the cookie path's + * `MFA_STEP_UP_MAX_AGE_SECONDS`, so neither transport is the softer way in. + * + * Stored hashed. Only `hashToken(secret)` reaches the database — the same + * posture as ApiToken, RefreshToken, and the session secret. + * + * INVALIDATION comes from three directions. A password change (self-service or + * operator-forced) and a sign-out-everywhere both call + * `revokeStepUpElevations`. A revoked or deleted token strands its elevations + * without any code here running: redemption sits behind Bearer resolution, + * which refuses a revoked token, and the FK cascades on delete. + */ +import { randomBytes } from "node:crypto"; +import { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { hashToken } from "@/lib/auth/hmac"; + +/** + * Marks a value as a step-up elevation. Distinct from every other credential + * prefix in the tree (`hlk_` access, `hlr_` refresh, `hls_` session, + * `hlh_` OIDC handoff) so a value can never be mistaken for the wrong kind. + */ +const ELEVATION_PREFIX = "hle_"; +const ELEVATION_SECRET_BYTES = 32; + +/** + * How long an elevation stays redeemable: five minutes. + * + * Chosen to equal `MFA_STEP_UP_MAX_AGE_SECONDS`, the window the cookie path has + * used for step-up since v1.23, rather than picked independently. Two transports + * guarding the same actions should not offer two different freshness promises — + * if they diverged, the looser one would simply become the way in. Five minutes + * also sits at the tight end of the 5–15 minute band OWASP recommends for + * re-authentication, which is where destructive actions belong. + * + * A test pins the two constants together so a future edit to one is caught. + */ +export const STEP_UP_ELEVATION_TTL_SECONDS = 5 * 60; + +/** + * Which factor was re-proved. This is an AUTHORISATION INPUT — see + * `FRESH_FACTOR_METHODS` and the file header. + * + * password — the account password. Reaches what a plain cookie session + * reaches, and no further. + * totp — a current code from the enrolled authenticator. + * webauthn — an assertion from a registered second-factor security key. + * passkey — an assertion from a primary passkey. At parity with + * `/api/auth/passkey/login-verify`, which stamps `mfaVerifiedAt` + * on the web for the very same ceremony. + */ +export type StepUpMethod = "password" | "totp" | "webauthn" | "passkey"; + +/** + * The methods that satisfy a fresh-factor gate — exactly the set for which the + * web writes `mfaVerifiedAt`. `password` is deliberately absent: a password + * login has never satisfied step-up on the web and must not start here. + * + * A recovery code is also absent, and that IS a narrowing relative to the web, + * where `/api/auth/mfa/verify` accepts one and does stamp the session. The + * consequence is that an account which has lost its authenticator cannot + * disable its second factor from the app — only from the web. That asymmetry is + * deliberate and flagged rather than quietly closed, because admitting a + * recovery code here means spending one break-glass credential to authorise + * spending another. + */ +export const FRESH_FACTOR_METHODS: ReadonlySet = new Set([ + "totp", + "webauthn", + "passkey", +]); + +export function isFreshFactorMethod(method: StepUpMethod): boolean { + return FRESH_FACTOR_METHODS.has(method); +} + +export interface MintedElevation { + /** The raw `hle_<64 hex>` value. Returned once; never persisted, never logged. */ + token: string; + expiresAt: Date; +} + +/** + * Mint an elevation for `(userId, apiTokenId)` after a factor has been re-proved. + * + * The caller is responsible for having verified the factor — this function does + * not check anything, it records a decision already made. Keep the two adjacent + * so that stays obvious. + * + * The delete-then-create runs in ONE transaction. Without it two concurrent + * mints can interleave so the second's delete removes the first's row after the + * first has already handed its value to the client, leaving a caller holding an + * elevation that can never redeem. The partial unique index from migration 0260 + * is the backstop: if two transactions still collide, one loses on the + * constraint and retries rather than both rows surviving. + */ +export async function mintStepUpElevation(opts: { + userId: string; + apiTokenId: string; + method: StepUpMethod; +}): Promise { + const raw = `${ELEVATION_PREFIX}${randomBytes(ELEVATION_SECRET_BYTES).toString("hex")}`; + const expiresAt = new Date(Date.now() + STEP_UP_ELEVATION_TTL_SECONDS * 1000); + + const write = async () => + prisma.$transaction(async (tx) => { + // A fresh proof supersedes an older one, so there is never more than one + // redeemable elevation per token. That bounds the table and shrinks the + // window in which a captured elevation is worth anything. + await tx.stepUpElevation.deleteMany({ + where: { apiTokenId: opts.apiTokenId, consumedAt: null }, + }); + await tx.stepUpElevation.create({ + data: { + userId: opts.userId, + apiTokenId: opts.apiTokenId, + tokenHash: hashToken(raw), + method: opts.method, + expiresAt, + }, + select: { id: true }, + }); + }); + + try { + await write(); + } catch (err) { + // P2002 on the partial index: a concurrent mint won the race between our + // delete and our insert. One retry (whose delete removes that winner in + // turn) resolves it deterministically. + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + await write(); + } else { + throw err; + } + } + + return { token: raw, expiresAt }; +} + +/** Why an elevation was refused. Audit detail only — never surfaced to a caller. */ +export type StepUpRejection = + | "malformed" + | "unknown" + | "wrong_token" + | "consumed" + | "expired" + | "insufficient_factor"; + +export type StepUpOutcome = + { ok: true; method: StepUpMethod } | { ok: false; reason: StepUpRejection }; + +/** + * Check an elevation WITHOUT consuming it. + * + * Split from the claim so a route can refuse a bad request — a 429, a malformed + * body, a wrong TOTP code — without burning the caller's elevation. Burning one + * on a validation failure was a self-inflicted lockout: the mint allows five + * attempts per fifteen minutes, so five fat-fingered codes would lock a user out + * of their own second-factor settings. + * + * This is NOT an authorisation decision on its own, and nothing may mutate on + * the strength of it. `claimStepUpElevation` still has to win the race before + * any effect happens; this only decides whether the request is worth processing. + */ +export async function validateStepUpElevation(opts: { + rawToken: string; + userId: string; + apiTokenId: string; + requireFreshFactor: boolean; +}): Promise { + if (!opts.rawToken.startsWith(ELEVATION_PREFIX)) { + return { ok: false, reason: "malformed" }; + } + + const row = await prisma.stepUpElevation.findUnique({ + where: { tokenHash: hashToken(opts.rawToken) }, + select: { + userId: true, + apiTokenId: true, + consumedAt: true, + expiresAt: true, + method: true, + }, + }); + + if (!row) return { ok: false, reason: "unknown" }; + if (row.userId !== opts.userId || row.apiTokenId !== opts.apiTokenId) { + return { ok: false, reason: "wrong_token" }; + } + if (row.consumedAt) return { ok: false, reason: "consumed" }; + if (row.expiresAt <= new Date()) return { ok: false, reason: "expired" }; + + const method = row.method as StepUpMethod; + if (opts.requireFreshFactor && !isFreshFactorMethod(method)) { + return { ok: false, reason: "insufficient_factor" }; + } + + return { ok: true, method }; +} + +/** + * Consume an elevation, or refuse. + * + * SINGLE-USE ATOMICITY. The claim is one conditional `UPDATE … WHERE + * token_hash = $1 AND user_id = $2 AND api_token_id = $3 AND consumed_at IS NULL + * AND expires_at > $4 RETURNING method`, and the caller proceeds only on exactly + * one returned row. Postgres under READ COMMITTED serialises the two writers on + * the row lock: the loser re-evaluates the predicate against the winner's + * committed version, finds `consumed_at` no longer null, and returns nothing. + * There is no read-then-write window to lose, which is why the check-then-update + * shape was not used. + * + * `RETURNING method` rather than a follow-up SELECT: a separate read can miss, + * and any fallback for that miss would fabricate an authorisation-relevant + * value — logging a passkey-proved elevation as password-proved, or worse, + * deciding a gate on the invented one. The statement that decides is the + * statement that reports. + * + * The fresh-factor rule is enforced here, on the returned row, not only in + * `validateStepUpElevation`. That validation is advisory; this is the gate. When + * the method is insufficient the row stays consumed — it was a real elevation + * presented in a real redemption, so spending it is right. + */ +export async function claimStepUpElevation(opts: { + rawToken: string; + userId: string; + apiTokenId: string; + requireFreshFactor: boolean; +}): Promise { + if (!opts.rawToken.startsWith(ELEVATION_PREFIX)) { + return { ok: false, reason: "malformed" }; + } + + const tokenHash = hashToken(opts.rawToken); + const now = new Date(); + + const claimed = await prisma.$queryRaw<{ method: string }[]>` + UPDATE step_up_elevations + SET consumed_at = ${now} + WHERE token_hash = ${tokenHash} + AND user_id = ${opts.userId} + AND api_token_id = ${opts.apiTokenId} + AND consumed_at IS NULL + AND expires_at > ${now} + RETURNING method + `; + + if (claimed.length !== 1) { + // Classify for the audit row only. The caller returns one generic response + // for every branch, so this tells an operator what happened without telling + // a prober anything. + const existing = await prisma.stepUpElevation.findUnique({ + where: { tokenHash }, + select: { userId: true, apiTokenId: true, consumedAt: true }, + }); + if (!existing) return { ok: false, reason: "unknown" }; + if ( + existing.userId !== opts.userId || + existing.apiTokenId !== opts.apiTokenId + ) { + return { ok: false, reason: "wrong_token" }; + } + if (existing.consumedAt) return { ok: false, reason: "consumed" }; + return { ok: false, reason: "expired" }; + } + + const method = claimed[0].method as StepUpMethod; + if (opts.requireFreshFactor && !isFreshFactorMethod(method)) { + return { ok: false, reason: "insufficient_factor" }; + } + + return { ok: true, method }; +} + +/** + * Drop every elevation for a user. + * + * Called when the account password changes (self-service or operator-forced) + * and on sign-out-everywhere. An elevation is a statement about a credential and + * a moment, and neither survives either of those events. Deliberately + * unconditional over the user's rows — consumed ones go too, since there is no + * reason to keep them once the anchor moved. + */ +export async function revokeStepUpElevations(userId: string): Promise { + await prisma.stepUpElevation.deleteMany({ where: { userId } }); +} + +// The expired-elevation sweep runs on the pg-boss queue against the WORKER +// Prisma client, not this module's request-scoped one — see +// `handleStepUpElevationCleanup` in `@/lib/jobs/reminder/cleanup-handlers`. +// A copy of the predicate here would be unreachable and only invite drift +// between the two, so it does not exist. diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index 7c5dbb220..5c14a477c 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -430,7 +430,7 @@ export function withIdempotency< // or any other word containing `sk-` would otherwise // silently break idempotency for benign retries). const SECRET_PATTERN = - /(?:\b(?:hlk_|hlr_|hls_|hlv_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; + /(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; const cloned = response.clone(); const text = await cloned.text(); if (!SECRET_PATTERN.test(text)) { diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts index c936342c4..4513975c1 100644 --- a/src/lib/jobs/reminder/cleanup-handlers.ts +++ b/src/lib/jobs/reminder/cleanup-handlers.ts @@ -40,6 +40,10 @@ const PUSH_ATTEMPT_RETENTION_DAYS = 90; // the two never drift. Slots at 03:40 between push-attempt cleanup (03:35) // and the drain (03:45) inside the existing 03:xx maintenance window. +export interface StepUpElevationCleanupPayload { + _?: never; +} + export interface RateLimitCleanupPayload { triggeredAt: string; } @@ -227,6 +231,36 @@ export async function handleIdempotencyCleanup( }); } +/** + * v1.30.34 — sweep expired step-up elevations. + * + * Hygiene, not enforcement: an expired row is already unredeemable because + * `claimStepUpElevation`'s predicate refuses it, so a missed run costs nothing + * but table space. It lives here rather than fire-and-forget on the mint request + * because a cross-tenant unbounded DELETE has no business competing with a + * user's request for a pool connection. + * + * The delete lives here rather than in `@/lib/auth/step-up` because it has to + * run on the WORKER Prisma client; a second copy of the predicate in the auth + * module would be dead code with a licence to drift. + */ +export async function handleStepUpElevationCleanup( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.step_up_elevation_cleanup", async (evt) => { + const p = getWorkerPrisma(); + try { + const { count } = await p.stepUpElevation.deleteMany({ + where: { expiresAt: { lt: new Date() } }, + }); + evt.addMeta("step_up_elevation_cleanup_deleted", count); + } catch (err) { + evt.addWarning(`step-up-elevation-cleanup failed: ${err}`); + } + }); +} + export async function handleAuditLogCleanup( jobs: Job[], ) { diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index 7008e2e90..1ff006be8 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -145,6 +145,7 @@ import { } from "./backup-handlers"; import { RateLimitCleanupPayload, + StepUpElevationCleanupPayload, IdempotencyCleanupPayload, AuditLogCleanupPayload, MoodReminderCleanupPayload, @@ -158,6 +159,7 @@ import { handleRateLimitCleanup, handleIdempotencyCleanup, handleAuditLogCleanup, + handleStepUpElevationCleanup, CoachMessageCleanupPayload, handleCoachMessageCleanup, McpTokenCleanupPayload, @@ -197,6 +199,12 @@ const IDEMPOTENCY_CLEANUP_QUEUE = "idempotency-cleanup"; const IDEMPOTENCY_CLEANUP_CRON = "0 3 * * *"; // daily at 03:00 (Europe/Berlin) +const STEP_UP_ELEVATION_CLEANUP_QUEUE = "step-up-elevation-cleanup"; + +// Every 15 minutes. An elevation lives five, so nothing lingers long; the +// cadence only has to be short enough that the table stays small. +const STEP_UP_ELEVATION_CLEANUP_CRON = "*/15 * * * *"; + const AUDIT_LOG_CLEANUP_QUEUE = "audit-log-cleanup"; const AUDIT_LOG_CLEANUP_CRON = "15 3 * * *"; // daily at 03:15 (Europe/Berlin) @@ -300,6 +308,7 @@ const allQueues = [ RATE_LIMIT_CLEANUP_QUEUE, IDEMPOTENCY_CLEANUP_QUEUE, AUDIT_LOG_CLEANUP_QUEUE, + STEP_UP_ELEVATION_CLEANUP_QUEUE, OFFHOST_BACKUP_QUEUE, RESTORE_DRILL_QUEUE, HOST_METRIC_QUEUE, @@ -410,6 +419,7 @@ const schedules: ScheduleEntry[] = [ [RATE_LIMIT_CLEANUP_QUEUE, RATE_LIMIT_CLEANUP_CRON], [IDEMPOTENCY_CLEANUP_QUEUE, IDEMPOTENCY_CLEANUP_CRON], [AUDIT_LOG_CLEANUP_QUEUE, AUDIT_LOG_CLEANUP_CRON], + [STEP_UP_ELEVATION_CLEANUP_QUEUE, STEP_UP_ELEVATION_CLEANUP_CRON], [OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON], [RESTORE_DRILL_QUEUE, RESTORE_DRILL_CRON], [HOST_METRIC_QUEUE, HOST_METRIC_CRON], @@ -594,6 +604,11 @@ export async function registerMaintenanceQueues( { localConcurrency: 1 }, handleAuditLogCleanup, ); + await boss.work( + STEP_UP_ELEVATION_CLEANUP_QUEUE, + { localConcurrency: 1 }, + handleStepUpElevationCleanup, + ); await boss.work( OFFHOST_BACKUP_QUEUE, { localConcurrency: 1 }, diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts index 3f91a101e..8c52e78ac 100644 --- a/src/lib/logging/redact.ts +++ b/src/lib/logging/redact.ts @@ -83,12 +83,13 @@ export function redactSecrets(input: string): string { // tiny safety margin against `sk-1`-style false-positives in code // identifiers. Capture group 1 = the leading separator (preserved). .replace(/(^|[^A-Za-z0-9])sk-(?:ant-)?[A-Za-z0-9_-]{8,}/g, "$1[REDACTED]") - // HealthLog native API tokens (`hlk_<64hex>` access, `hlr_<64hex>` - // refresh). Same boundary-aware shape as the `sk-` rule. The + // HealthLog hex-bodied credentials: `hlk_` access token, `hlr_` refresh + // token, `hls_` session cookie secret (v1.30.32), `hle_` step-up + // elevation (v1.30.34). Same boundary-aware shape as the `sk-` rule. The // idempotency replay-cache already rejects bodies containing // these prefixes (CLAUDE.md headless-client-API patterns); this // is the matching egress guard for log/error surfaces. - .replace(/(^|[^A-Za-z0-9])hl[kr]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]") + .replace(/(^|[^A-Za-z0-9])hl[kres]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]") // v1.22.0 — the MCP OAuth bridge artifacts: the authorization code // (`hlac_`), refresh token (`hlrt_`), and DCR client id (`hlc_`). All // are signed, base64url-bodied, and credential-bearing, so they are diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 78d8fe777..ac408777c 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -18,6 +18,10 @@ import { mfaWebauthnLoginVerifySchema, } from "@/lib/validations/mfa"; import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native"; +import { + stepUpMintSchema, + stepUpOptionsSchema, +} from "@/lib/validations/step-up"; import { dataEnvelope, stdResponses, errorEnvelope } from "./shared"; // ── Sub-schemas owned here (route-specific shapes) ─────────────────── @@ -284,6 +288,44 @@ const checkUserResponse = z "Account-existence + credential shape. The response is identical whether or not the identifier matched (account-existence is the explicit contract iOS needs); the identifier is never echoed.", }); +/** + * Shared tail for every route the step-up elevation can unlock. Kept in one + * place so the published contract cannot describe the set inconsistently. + */ +const MFA_MANAGEMENT_AUTH_NOTE = + " Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused."; + +// ── v1.30.34 step-up elevation (Bearer transport) ──────────────────── + +const stepUpOptionsResponse = z + .object({ + options: z + .record(z.string(), z.unknown()) + .describe("SimpleWebAuthn assertion options for the caller's passkeys."), + challengeId: z.string(), + }) + .meta({ id: "StepUpOptionsResponse" }); + +const stepUpMintResponse = z + .object({ + elevation: z + .string() + .describe( + "Opaque single-use elevation (`hle_<64hex>`). Present it in the `X-Step-Up` header on ONE second-factor-management call. Returned exactly once; store it in memory only.", + ), + expiresAt: z.iso.datetime({ offset: true }), + expiresInSeconds: z.number().int(), + method: z + .enum(["password", "totp", "webauthn", "passkey"]) + .describe("The factor that was re-proved."), + satisfiesFreshFactor: z + .boolean() + .describe( + "Whether this elevation reaches the fresh-factor routes (MFA disable, recovery-code regeneration, security-key removal). False for a password proof — mirroring the web, where a password login never marks a session second-factor-verified.", + ), + }) + .meta({ id: "StepUpMintResponse" }); + export const authPaths: NonNullable = { "/api/auth/check-user": { post: { @@ -392,12 +434,71 @@ export const authPaths: NonNullable = { }, }, }, + "/api/auth/step-up/options": { + post: { + tags: ["Auth"], + summary: + "Begin a WebAuthn re-proof for a step-up elevation (Bearer only)", + description: + 'Returns SimpleWebAuthn assertion options plus a challenge id to present at POST /api/auth/step-up. `method: "passkey"` scopes the assertion to the account\'s primary passkeys; `method: "webauthn"` to its registered second-factor security keys. Bearer-only: a cookie session is refused, because a browser re-proves its factor at login and carries the result on its session row. Returns 409 when the account has no credential of the requested kind — fall back to another arm of the mint.', + requestBody: { + required: true, + content: { "application/json": { schema: stepUpOptionsSchema } }, + }, + responses: { + "200": { + description: "Assertion options issued.", + content: { + "application/json": { + schema: dataEnvelope( + stepUpOptionsResponse, + "StepUpOptionsEnvelope", + ), + }, + }, + }, + "409": { + description: "No passkey registered on this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/step-up": { + post: { + tags: ["Auth"], + summary: "Mint a single-use step-up elevation (Bearer only)", + description: + "Re-prove a factor and receive an opaque elevation that authorises exactly ONE second-factor-management call.\n\n" + + "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.", + requestBody: { + required: true, + content: { "application/json": { schema: stepUpMintSchema } }, + }, + responses: { + "200": { + description: "Factor re-proved — elevation issued.", + content: { + "application/json": { + schema: dataEnvelope(stepUpMintResponse, "StepUpMintEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/auth/me/mfa/totp/setup": { post: { tags: ["Auth"], - summary: "Begin TOTP enrollment (cookie session only)", + summary: "Begin TOTP enrollment (cookie session or step-up elevation)", description: - "Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm. Cookie-only — a Bearer token cannot enrol MFA.", + "Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm." + + MFA_MANAGEMENT_AUTH_NOTE, responses: { "200": { description: "Pending secret created.", @@ -418,9 +519,10 @@ export const authPaths: NonNullable = { "/api/auth/me/mfa/totp/confirm": { post: { tags: ["Auth"], - summary: "Confirm TOTP enrollment (cookie session only)", + summary: "Confirm TOTP enrollment (cookie session or step-up elevation)", description: - "Verifies a code against the pending secret, activates the factor, and returns the one-time recovery codes. Cookie-only.", + "Verifies a code against the pending secret, activates the factor, and returns the one-time recovery codes." + + MFA_MANAGEMENT_AUTH_NOTE, requestBody: { required: true, content: { "application/json": { schema: totpConfirmSchema } }, @@ -451,7 +553,8 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Disable the second factor (step-up gated)", description: - "Requires a fresh second-factor step-up (cookie session) AND a current TOTP or recovery code. Clears the secret and deletes recovery codes. Bearer can never satisfy the step-up gate.", + "Requires a fresh second-factor step-up AND a current TOTP or recovery code. Clears the secret and deletes recovery codes." + + MFA_MANAGEMENT_AUTH_NOTE, requestBody: { required: true, content: { "application/json": { schema: mfaDisableSchema } }, @@ -474,7 +577,8 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Regenerate recovery codes (step-up gated)", description: - "Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated; Bearer can never satisfy the gate.", + "Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated." + + MFA_MANAGEMENT_AUTH_NOTE, responses: { "200": { description: "Fresh recovery codes issued.", @@ -494,9 +598,9 @@ export const authPaths: NonNullable = { "/api/auth/me/mfa": { get: { tags: ["Auth"], - summary: "Second-factor status (cookie session only)", + summary: "Second-factor status (cookie session or Bearer token)", description: - "Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata only — no secret, code, or public key. Cookie-only.", + "Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata only — no secret, no code, no public key, no credential id. Plain authentication (cookie session or cookie-equivalent token); unlike every mutation on this surface it needs no step-up elevation, because the payload carries no credential material.", responses: { "200": { description: "Second-factor status.", @@ -515,7 +619,8 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Begin registering a security key as a second factor", description: - "Returns SimpleWebAuthn creation options + a challenge id. Cookie-only — a Bearer token cannot enrol MFA.", + "Returns SimpleWebAuthn creation options + a challenge id." + + MFA_MANAGEMENT_AUTH_NOTE, responses: { "200": { description: "Registration options issued.", @@ -537,7 +642,8 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Finish registering a security key as a second factor", description: - "Verifies the attestation against the user-bound challenge and stores the credential in the second-factor store (separate from primary passkeys). Cookie-only.", + "Verifies the attestation against the user-bound challenge and stores the credential in the second-factor store (separate from primary passkeys)." + + MFA_MANAGEMENT_AUTH_NOTE, requestBody: { required: true, content: { @@ -568,6 +674,9 @@ export const authPaths: NonNullable = { patch: { tags: ["Auth"], summary: "Rename a registered security key", + description: + "Rename a registered second-factor security key." + + MFA_MANAGEMENT_AUTH_NOTE, requestParams: { path: z.object({ id: z.string() }) }, requestBody: { required: true, @@ -596,7 +705,7 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Remove a registered security key (step-up gated)", description: - "Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate.", + "Requires a fresh second-factor step-up." + MFA_MANAGEMENT_AUTH_NOTE, requestParams: { path: z.object({ id: z.string() }) }, responses: { "200": { diff --git a/src/lib/validations/mfa.ts b/src/lib/validations/mfa.ts index ca22284b3..746a1702e 100644 --- a/src/lib/validations/mfa.ts +++ b/src/lib/validations/mfa.ts @@ -57,7 +57,7 @@ export const mfaVerifySchema = z * `@simplewebauthn/server`; this just pins the envelope shape for OpenAPI and * the runtime `safeParse`. */ -const webauthnCredentialSchema = z +export const webauthnCredentialSchema = z .object({ id: z.string(), rawId: z.string(), diff --git a/src/lib/validations/step-up.ts b/src/lib/validations/step-up.ts new file mode 100644 index 000000000..96028cd6e --- /dev/null +++ b/src/lib/validations/step-up.ts @@ -0,0 +1,67 @@ +/** + * Request schemas for the step-up elevation surface. Single-sourced here so the + * runtime `safeParse` and the OpenAPI registry describe the identical wire + * contract. + */ +import { z } from "zod/v4"; +import { webauthnCredentialSchema } from "@/lib/validations/mfa"; + +/** + * Re-prove a factor to mint an elevation. Four accepted proofs, discriminated on + * `method`, and the choice is an AUTHORISATION input rather than a preference: + * + * - `password` — the account password. Reaches exactly what a plain cookie + * session reaches. Unavailable on an account provisioned through OIDC SSO + * that never set one. + * - `totp` — a current code from the enrolled authenticator. + * - `webauthn` — an assertion from a registered second-factor security key, + * begun at `/api/auth/step-up/options` with `method: "webauthn"`. + * - `passkey` — an assertion from a primary passkey, begun at the same + * endpoint with `method: "passkey"`. + * + * Only the last three satisfy the fresh-factor routes, mirroring the web, where + * a password login never stamps a session second-factor-verified. + * + * The password is length-bounded so a body cap is not the only thing standing + * between a caller and an unbounded Argon2id verify. + */ +export const stepUpMintSchema = z + .discriminatedUnion("method", [ + z.object({ + method: z.literal("password"), + password: z.string().min(1).max(512), + }), + z.object({ + method: z.literal("totp"), + code: z + .string() + .trim() + .regex(/^\d{6}$/, "Enter the 6-digit code from your authenticator app"), + }), + z.object({ + method: z.literal("webauthn"), + challengeId: z.string().min(1), + credential: webauthnCredentialSchema, + }), + z.object({ + method: z.literal("passkey"), + challengeId: z.string().min(1), + credential: webauthnCredentialSchema, + }), + ]) + .meta({ + id: "StepUpMintRequest", + description: + "Re-prove a factor to mint a single-use, token-bound step-up elevation. `password` reaches the same routes a plain cookie session reaches; `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes (disable, recovery-code rotation, security-key removal), matching exactly the ceremonies for which the web stamps a session second-factor-verified.", + }); + +/** Which assertion ceremony to begin at `POST /api/auth/step-up/options`. */ +export const stepUpOptionsSchema = z + .object({ + method: z + .enum(["passkey", "webauthn"]) + .describe( + "`passkey` begins an assertion against the account's primary passkeys; `webauthn` against its registered second-factor security keys.", + ), + }) + .meta({ id: "StepUpOptionsRequest" }); diff --git a/tests/integration/sessions-and-known-devices.test.ts b/tests/integration/sessions-and-known-devices.test.ts index fb9cbc7c5..c54e97f91 100644 --- a/tests/integration/sessions-and-known-devices.test.ts +++ b/tests/integration/sessions-and-known-devices.test.ts @@ -58,7 +58,10 @@ describe("destroyOtherSessions", () => { }, }); - const result = await destroyOtherSessions(user.id, current.id); + const result = await destroyOtherSessions(user.id, { + kind: "session", + sessionId: current.id, + }); expect(result.sessionsRevoked).toBe(2); const remaining = await prisma.session.findMany({ diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index d35aaf68c..54e0cdebb 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -69,6 +69,7 @@ export async function truncateAllTables(client: PrismaClient): Promise { "refresh_tokens", "reminder_phase_configs", "sessions", + "step_up_elevations", "telegram_reminder_messages", "telegram_scheduled_deletions", "user_achievements", diff --git a/tests/integration/step-up-elevation.test.ts b/tests/integration/step-up-elevation.test.ts new file mode 100644 index 000000000..7f528a4d8 --- /dev/null +++ b/tests/integration/step-up-elevation.test.ts @@ -0,0 +1,1144 @@ +/** + * Behavioural suite for token-bound step-up elevations, against a real Postgres + * and the real gate — no mocked Prisma, no mocked auth. + * + * This mechanism lets a Bearer token reach a surface that has been cookie-only + * since v1.23, so the cases below are written as attacks rather than as happy + * paths. Each one must fail closed if its protection is removed; a green suite + * over a broken gate would be worse than no suite at all. + * + * Two of them need a real database to mean anything. Single-use atomicity is a + * claim about what Postgres does when two writers race the same row, and the + * unit mocks cannot produce that race — E3b runs the two redemptions genuinely + * concurrently against one connection pool. The token binding is a claim about a + * WHERE clause, and only a real query proves the clause is in it. + */ +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-step-up-elevation-32-bytes-minimum-1234567890"; + +const { hashToken } = await import("@/lib/auth/hmac"); +const { hashPassword } = await import("@/lib/auth/password"); + +const USER_ID = "user-step-up-test"; +const OTHER_USER_ID = "user-step-up-other"; +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(); + const passwordHash = await hashPassword(PASSWORD); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "step-up", + email: "step-up@example.test", + timezone: "UTC", + passwordHash, + }, + }); + await getPrismaClient().user.create({ + data: { + id: OTHER_USER_ID, + username: "step-up-other", + email: "step-up-other@example.test", + timezone: "UTC", + passwordHash, + }, + }); +}); + +/** Mint a real wildcard `ApiToken` row. Does NOT arm the header. */ +async function mintToken( + label: string, + userId = USER_ID, +): Promise<{ raw: string; id: string }> { + const raw = `hlk_${label}${"0".repeat(64 - label.length)}`; + const row = await getPrismaClient().apiToken.create({ + data: { + userId, + 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 = "GET", 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); +} + +/** Drive the mint endpoint with an arbitrary proof body. */ +async function mintWith( + body: Record, +): Promise<{ status: number; body: Record }> { + const { POST } = await import("@/app/api/auth/step-up/route"); + const res = await POST(req("/api/auth/step-up", "POST", body)); + return { status: res.status, body: (await res.json()) as never }; +} + +/** Drive the mint endpoint with a password proof. */ +async function mintElevation( + password: string, +): Promise<{ status: number; body: Record }> { + return mintWith({ method: "password", password }); +} + +/** + * Mint a TOTP-proved elevation the way a real client would: enrol a secret, + * then present a live code for it. This is the only fresh-factor arm reachable + * without a WebAuthn authenticator, so it carries the fresh-factor coverage. + */ +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; +} + +/** Mirrors the server's TOTP parameters — there is no code generator to import. */ +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 mintTotpElevation( + secret: string, +): Promise<{ status: number; body: Record }> { + return mintWith({ method: "totp", code: currentTotpCode(secret) }); +} + +/** + * Seed a known recovery-code batch and return the plaintext. + * + * The disable route wants a live factor in its BODY on top of the step-up. A + * TOTP code spent minting the elevation cannot be reused there — the shared + * verifier burns its time-step, by design — so within one 30-second window the + * body factor has to be something else. That is exactly how the web behaves + * too. + */ +async function seedRecoveryCodes(): Promise { + const { regenerateRecoveryCodes } = + await import("@/lib/auth/mfa/recovery-codes"); + return regenerateRecoveryCodes(USER_ID); +} + +/** Pull the raw elevation out of a successful mint. */ +function elevationOf(result: { body: Record }): string { + return (result.body.data as { elevation: string }).elevation; +} + +/** + * Some handlers in the set declare no request parameter (`apiHandler(async () => + * …)`), so their wrapped type takes none. The runtime always passes one; this + * keeps the call sites honest without loosening the handlers themselves. + */ +type RouteFn = (request: NextRequest) => Promise; + +/** + * POST /api/auth/me/mfa/totp/setup — the cheapest MUTATION in the + * elevation-accepting set, and the probe every redemption case drives. + * + * NOT `GET /api/auth/me/mfa`: that read is plain `requireAuth()` now, so using + * it here would have quietly stopped exercising the elevation path at all. + */ +async function callMfaSetup(): Promise { + const { POST } = await import("@/app/api/auth/me/mfa/totp/setup/route"); + return await POST(req("/api/auth/me/mfa/totp/setup", "POST")); +} + +/** The status read — plain Bearer, no elevation. */ +async function callMfaStatus(): Promise { + const { GET } = await import("@/app/api/auth/me/mfa/route"); + return await (GET as unknown as RouteFn)(req("/api/auth/me/mfa")); +} + +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 ?? "", + ); +} + +// ── E1 — a stolen token, on its own, gains nothing ─────────────────── + +describe("E1 — the token alone is not a step-up proof", () => { + it("refuses an MFA-management call with no elevation header", async () => { + const { raw } = await mintToken("e1a"); + useToken(raw); + useElevation(null); + + const res = await callMfaSetup(); + + // 401 with the machine code the client branches on to launch a re-proof — + // not a 200, and not the 403 an unauthorised token would get. + expect(res.status).toBe(401); + }); + + it("mints nothing when the body carries no factor proof", async () => { + const { raw } = await mintToken("e1b"); + useToken(raw); + + const { POST } = await import("@/app/api/auth/step-up/route"); + const res = await POST(req("/api/auth/step-up", "POST", {})); + + expect(res.status).toBe(422); + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); + + it("refuses a fabricated elevation value", async () => { + const { raw } = await mintToken("e1c"); + useToken(raw); + useElevation(`hle_${"f".repeat(64)}`); + + expect((await callMfaSetup()).status).toBe(401); + await new Promise((r) => setTimeout(r, 100)); + expect(await auditReasons("auth.stepup.elevation.rejected")).toContain( + "unknown", + ); + }); +}); + +// ── E2 — the elevation is bound to one token ───────────────────────── + +describe("E2 — an elevation is not portable between tokens", () => { + it("refuses redemption by a second token of the SAME user", async () => { + const a = await mintToken("e2a"); + const b = await mintToken("e2b"); + + useToken(a.raw); + const minted = await mintElevation(PASSWORD); + expect(minted.status).toBe(200); + const elevation = (minted.body.data as { elevation: string }).elevation; + + // Same account, same scopes, different token row. The binding is the only + // thing standing between these two, which is exactly the point. + useToken(b.raw); + useElevation(elevation); + const res = await callMfaSetup(); + + expect(res.status).toBe(401); + await new Promise((r) => setTimeout(r, 100)); + expect(await auditReasons("auth.stepup.elevation.rejected")).toContain( + "wrong_token", + ); + + // And the legitimate holder's elevation survived the attempt. + const row = await getPrismaClient().stepUpElevation.findFirst(); + expect(row?.consumedAt).toBeNull(); + + useToken(a.raw); + useElevation(elevation); + expect((await callMfaSetup()).status).toBe(200); + }); + + it("refuses redemption by another user's token", async () => { + const mine = await mintToken("e2c"); + const theirs = await mintToken("e2d", OTHER_USER_ID); + + useToken(mine.raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + useToken(theirs.raw); + useElevation(elevation); + + expect((await callMfaSetup()).status).toBe(401); + }); +}); + +// ── E3 — single use, including under concurrency ───────────────────── + +describe("E3 — an elevation is consumed exactly once", () => { + it("refuses a second sequential redemption", async () => { + const { raw } = await mintToken("e3a"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + useElevation(elevation); + expect((await callMfaSetup()).status).toBe(200); + expect((await callMfaSetup()).status).toBe(401); + + await new Promise((r) => setTimeout(r, 100)); + expect(await auditReasons("auth.stepup.elevation.rejected")).toContain( + "consumed", + ); + }); + + it("lets exactly one of two CONCURRENT redemptions win", async () => { + const { raw, id } = await mintToken("e3b"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + // Straight at the redemption primitive, so the race is between two real + // conditional UPDATEs on one row rather than between two HTTP handlers that + // happen to interleave. A check-then-update implementation passes the + // sequential case above and fails here. + const { claimStepUpElevation } = await import("@/lib/auth/step-up"); + const attempt = () => + claimStepUpElevation({ + rawToken: elevation, + userId: USER_ID, + apiTokenId: id, + requireFreshFactor: false, + }); + + const results: Awaited>[] = await Promise.all([ + attempt(), + attempt(), + attempt(), + attempt(), + ]); + + expect(results.filter((r) => r.ok)).toHaveLength(1); + expect(results.filter((r) => !r.ok)).toHaveLength(3); + }); +}); + +// ── E4 — expiry ────────────────────────────────────────────────────── + +describe("E4 — an expired elevation is refused", () => { + it("refuses one whose window has passed", async () => { + const { raw, id } = await mintToken("e4a"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + // Age the row rather than the clock: the expiry lives in the redemption's + // WHERE clause, so this is the honest way to exercise it. + await getPrismaClient().stepUpElevation.updateMany({ + where: { apiTokenId: id }, + data: { expiresAt: new Date(Date.now() - 1_000) }, + }); + + useElevation(elevation); + expect((await callMfaSetup()).status).toBe(401); + + await new Promise((r) => setTimeout(r, 100)); + expect(await auditReasons("auth.stepup.elevation.rejected")).toContain( + "expired", + ); + }); + + it("mints a five-minute window, matching the cookie step-up", async () => { + const { raw } = await mintToken("e4b"); + useToken(raw); + const { body } = await mintElevation(PASSWORD); + const data = body.data as { expiresInSeconds: number }; + + const { MFA_STEP_UP_MAX_AGE_SECONDS } = await import("@/lib/api-handler"); + expect(data.expiresInSeconds).toBe(MFA_STEP_UP_MAX_AGE_SECONDS); + }); +}); + +// ── E5 — the elevation cannot reach admin ──────────────────────────── + +describe("E5 — an elevation does not satisfy requireAdmin", () => { + it("refuses an admin route even for an ADMIN user holding a valid elevation", async () => { + await getPrismaClient().user.update({ + where: { id: USER_ID }, + data: { role: "ADMIN" }, + }); + + const { raw } = await mintToken("e5a"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + useElevation(elevation); + + const { GET } = await import("@/app/api/admin/users/route"); + const res = await (GET as unknown as RouteFn)(req("/api/admin/users")); + + // Cookie-only, and the elevation is not a cookie. The account being a + // genuine admin is what makes this test worth having: the refusal is the + // transport, not the role. + expect(res.status).toBe(401); + + // Unconsumed — the admin gate never even looked at it. + const row = await getPrismaClient().stepUpElevation.findFirst(); + expect(row?.consumedAt).toBeNull(); + }); +}); + +// ── E6 — a wrong password mints nothing, is throttled, is audited ──── + +describe("E6 — the mint refuses, throttles, and records a bad password", () => { + it("returns a generic 401 and writes no row", async () => { + const { raw } = await mintToken("e6a"); + useToken(raw); + + const res = await mintElevation("not the password"); + + expect(res.status).toBe(401); + // Same prose for every cause — the response must not say whether the + // password was wrong or the account has none. + expect(res.body.error).toBe("Verification failed"); + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); + + it("is indistinguishable from an account with no password set", async () => { + await getPrismaClient().user.update({ + where: { id: USER_ID }, + data: { passwordHash: null }, + }); + const { raw } = await mintToken("e6b"); + useToken(raw); + + const res = await mintElevation("anything at all"); + + expect(res.status).toBe(401); + expect(res.body.error).toBe("Verification failed"); + }); + + it("audits the failure with the real reason", async () => { + const { raw } = await mintToken("e6c"); + useToken(raw); + await mintElevation("wrong"); + + const rows = await getPrismaClient().auditLog.findMany({ + where: { action: "auth.stepup.mint.failed" }, + }); + expect(rows).toHaveLength(1); + expect(JSON.parse(rows[0].details ?? "{}")).toMatchObject({ + method: "password", + reason: "bad_password", + }); + }); + + it("throttles a guessing run", async () => { + const { raw } = await mintToken("e6d"); + useToken(raw); + + const statuses: number[] = []; + for (let i = 0; i < 7; i++) { + statuses.push((await mintElevation(`guess-${i}`)).status); + } + + // Five attempts per fifteen minutes, then the bucket closes — the same + // ceiling the password-change route uses, for the same Argon2id reason. + expect(statuses.filter((s) => s === 401)).toHaveLength(5); + expect(statuses.filter((s) => s === 429)).toHaveLength(2); + + // And the throttle holds even once the caller finds the right password. + expect((await mintElevation(PASSWORD)).status).toBe(429); + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); +}); + +// ── E7 — the web path is untouched ─────────────────────────────────── + +describe("E7 — the cookie flow is unchanged", () => { + async function useCookieSession(userId = USER_ID): Promise { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session.id; + } + + it("serves an MFA-management mutation on a plain cookie session", async () => { + await useCookieSession(); + expect((await callMfaSetup()).status).toBe(200); + }); + + it("still refuses a step-up-gated route on a cookie with no fresh factor", async () => { + await useCookieSession(); + const { POST } = + await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route"); + const res = await POST( + req("/api/auth/me/mfa/recovery-codes/regenerate", "POST"), + ); + expect(res.status).toBe(401); + }); + + it("admits a step-up-gated route on a freshly verified cookie session", async () => { + const sessionId = await useCookieSession(); + await getPrismaClient().user.update({ + where: { id: USER_ID }, + data: { totpConfirmedAt: new Date() }, + }); + await getPrismaClient().session.update({ + where: { id: sessionId }, + data: { mfaVerifiedAt: new Date() }, + }); + + const { POST } = + await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route"); + const res = await POST( + req("/api/auth/me/mfa/recovery-codes/regenerate", "POST"), + ); + expect(res.status).toBe(200); + }); + + it("ignores an elevation header when a cookie session is present", async () => { + // The cookie branch returns before the header is ever read. Proven by the + // elevation surviving unconsumed. + const { raw, id } = await mintToken("e7a"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + await useCookieSession(); + useElevation(elevation); + expect((await callMfaSetup()).status).toBe(200); + + const row = await getPrismaClient().stepUpElevation.findFirst({ + where: { apiTokenId: id }, + }); + expect(row?.consumedAt).toBeNull(); + }); +}); + +// ── E8 — invalidation on credential rotation ───────────────────────── + +describe("E8 — an elevation does not outlive its anchor", () => { + it("is dropped by a real password change, through the route", async () => { + // Driven through POST /api/auth/password rather than by calling the + // revocation helper. Calling the helper directly proved only that the helper + // deletes rows — deleting the call from BOTH password routes left the old + // version of this test green, so the invalidation story was uncovered. + const { raw } = await mintToken("e8a"); + useToken(raw); + const elevation = elevationOf(await mintElevation(PASSWORD)); + expect(await getPrismaClient().stepUpElevation.count()).toBe(1); + + const NEW_PASSWORD = "Zt7#qvbLm2xR!e9Wd4Kp"; + const { POST } = await import("@/app/api/auth/password/route"); + const res = await POST( + req("/api/auth/password", "POST", { + currentPassword: PASSWORD, + newPassword: NEW_PASSWORD, + confirmPassword: NEW_PASSWORD, + }), + ); + expect(res.status).toBe(200); + + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + + // And the value the client still holds is dead. The password route mints a + // replacement cookie session, which the shared jar would otherwise pick up + // and satisfy the gate on the cookie arm — clear it so this probes the + // Bearer arm, which is the one under test. + cookieJar.clear(); + useElevation(elevation); + expect((await callMfaSetup()).status).toBe(401); + }); + + it("is dropped by an operator-forced reset, through the admin route", async () => { + const { raw } = await mintToken("e8a2"); + useToken(raw); + await mintElevation(PASSWORD); + expect(await getPrismaClient().stepUpElevation.count()).toBe(1); + + // The admin route is cookie-only, so the reset arrives on an ADMIN session + // while the elevation belongs to the target's token. + await getPrismaClient().user.update({ + where: { id: OTHER_USER_ID }, + data: { role: "ADMIN" }, + }); + const adminSession = await getPrismaClient().session.create({ + data: { + userId: OTHER_USER_ID, + expiresAt: new Date(Date.now() + 3_600_000), + }, + }); + cookieJar.set("healthlog_session", adminSession.id); + headerJar.delete("authorization"); + + const { POST } = + await import("@/app/api/admin/users/[id]/reset-password/route"); + const res = await POST( + req(`/api/admin/users/${USER_ID}/reset-password`, "POST", { + password: "operator chosen long passphrase 77", + }), + { params: Promise.resolve({ id: USER_ID }) }, + ); + expect(res.status).toBe(200); + + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); + + it("is dropped by sign-out-everywhere", async () => { + const { raw } = await mintToken("e8a3"); + useToken(raw); + await mintElevation(PASSWORD); + + const session = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + const { destroyOtherSessions } = await import("@/lib/auth/session"); + await destroyOtherSessions(USER_ID, { + kind: "session", + sessionId: session.id, + }); + + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); + + it("is unredeemable once the bound token is revoked", async () => { + const { raw, id } = await mintToken("e8b"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + await getPrismaClient().apiToken.update({ + where: { id }, + data: { revoked: true }, + }); + + useElevation(elevation); + // Refused at Bearer resolution, before the elevation is even looked at — + // which is why no code in the revoke path needs to know about elevations. + expect((await callMfaSetup()).status).toBe(401); + + const row = await getPrismaClient().stepUpElevation.findFirst(); + expect(row?.consumedAt).toBeNull(); + }); + + it("is cascade-deleted with the token row", async () => { + const { raw, id } = await mintToken("e8c"); + useToken(raw); + await mintElevation(PASSWORD); + + await getPrismaClient().apiToken.delete({ where: { id } }); + + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); +}); + +// ── E9 — the happy path actually works end to end ──────────────────── + +describe("E9 — a native client can manage its second factor", () => { + it("mints, redeems, and enrols TOTP", async () => { + const { raw } = await mintToken("e9a"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + useElevation(elevation); + const { POST } = await import("@/app/api/auth/me/mfa/totp/setup/route"); + const res = await POST(req("/api/auth/me/mfa/totp/setup", "POST")); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { totpSecret: string; otpauthUri: string }; + }; + expect(body.data.totpSecret).toBeTruthy(); + expect(body.data.otpauthUri).toMatch(/^otpauth:\/\//); + + // The pending secret landed, and the elevation is spent — a second action + // needs a second proof. + const user = await getPrismaClient().user.findUnique({ + where: { id: USER_ID }, + }); + expect(user?.totpSecretEncrypted).toBeTruthy(); + expect(user?.totpConfirmedAt).toBeNull(); + expect((await callMfaSetup()).status).toBe(401); + }); + + it("refuses the mint surface to a cookie session", async () => { + const session = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + headerJar.delete("authorization"); + + const { POST } = await import("@/app/api/auth/step-up/route"); + const res = await POST( + req("/api/auth/step-up", "POST", { + method: "password", + password: PASSWORD, + }), + ); + + expect(res.status).toBe(401); + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); +}); + +// ── E10 — the fresh-factor rule (B1) ───────────────────────────────── + +describe("E10 — a password proof cannot reach the destructive routes", () => { + /** + * The attack this closes, in the order it ran before the fix: + * 1. mint an elevation with the account password (a stolen token plus a + * known password is the whole prerequisite); + * 2. POST recovery-codes/regenerate — which takes NO factor in its body — + * and receive ten plaintext recovery codes; + * 3. POST disable with one of them, satisfying the body check. + * The second factor is gone. On the web that chain is impossible because a + * password login never stamps `mfaVerifiedAt`. + */ + async function regenerate(): Promise { + const { POST } = + await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route"); + return POST(req("/api/auth/me/mfa/recovery-codes/regenerate", "POST")); + } + + async function disable(code: string, method = "recovery"): Promise { + const { POST } = await import("@/app/api/auth/me/mfa/disable/route"); + return POST(req("/api/auth/me/mfa/disable", "POST", { code, method })); + } + + async function removeKey(id: string): Promise { + const { DELETE } = + await import("@/app/api/auth/me/mfa/webauthn/[id]/route"); + return DELETE(req(`/api/auth/me/mfa/webauthn/${id}`, "DELETE"), { + params: Promise.resolve({ id }), + }); + } + + it("refuses recovery-code regeneration on a password-proved elevation", async () => { + await enrolTotp(); + const { raw } = await mintToken("e10a"); + useToken(raw); + useElevation(elevationOf(await mintElevation(PASSWORD))); + + const res = await regenerate(); + + expect(res.status).toBe(401); + // No codes were minted, so step 2 of the chain yields nothing. + expect(await getPrismaClient().mfaRecoveryCode.count()).toBe(0); + await new Promise((r) => setTimeout(r, 100)); + expect(await auditReasons("auth.stepup.elevation.rejected")).toContain( + "insufficient_factor", + ); + }); + + it("refuses disable on a password-proved elevation", async () => { + const secret = await enrolTotp(); + const { raw } = await mintToken("e10b"); + useToken(raw); + useElevation(elevationOf(await mintElevation(PASSWORD))); + + const res = await disable(currentTotpCode(secret), "totp"); + + expect(res.status).toBe(401); + const user = await getPrismaClient().user.findUnique({ + where: { id: USER_ID }, + }); + expect(user?.totpConfirmedAt).not.toBeNull(); + }); + + it("refuses security-key removal on a password-proved elevation", async () => { + const key = await getPrismaClient().webauthnMfaCredential.create({ + data: { + userId: USER_ID, + name: "key", + credentialId: "cred-e10c", + credentialPublicKey: Buffer.from([1, 2, 3]), + transports: ["internal"], + }, + }); + const { raw } = await mintToken("e10c"); + useToken(raw); + useElevation(elevationOf(await mintElevation(PASSWORD))); + + expect((await removeKey(key.id)).status).toBe(401); + expect(await getPrismaClient().webauthnMfaCredential.count()).toBe(1); + }); + + it("refuses at the CLAIM too, not only at the validation", async () => { + // Defence in depth, and it needs its own test: the gate validates before it + // claims, so removing the check inside `claimStepUpElevation` leaves every + // route-level case above green. Driven straight at the primitive. + const { raw, id } = await mintToken("e10i"); + useToken(raw); + const elevation = elevationOf(await mintElevation(PASSWORD)); + + const { claimStepUpElevation } = await import("@/lib/auth/step-up"); + const claimed = await claimStepUpElevation({ + rawToken: elevation, + userId: USER_ID, + apiTokenId: id, + requireFreshFactor: true, + }); + + expect(claimed).toEqual({ ok: false, reason: "insufficient_factor" }); + }); + + it("does NOT burn the elevation when the factor is too weak", async () => { + // The refusal is about reach, not validity. The same elevation must still + // work on the routes it was always entitled to. + await enrolTotp(); + const { raw } = await mintToken("e10d"); + useToken(raw); + const elevation = elevationOf(await mintElevation(PASSWORD)); + + useElevation(elevation); + expect((await regenerate()).status).toBe(401); + + const row = await getPrismaClient().stepUpElevation.findFirst(); + expect(row?.consumedAt).toBeNull(); + }); + + it("admits the SAME routes a plain cookie session reaches", async () => { + // The password arm is not useless — it is exactly cookie-equivalent. + const { raw } = await mintToken("e10e"); + useToken(raw); + useElevation(elevationOf(await mintElevation(PASSWORD))); + + expect((await callMfaSetup()).status).toBe(200); + }); + + it("admits the destructive routes on a TOTP-proved elevation", async () => { + const secret = await enrolTotp(); + const { raw } = await mintToken("e10f"); + useToken(raw); + + const minted = await mintTotpElevation(secret); + expect(minted.status).toBe(200); + expect( + (minted.body.data as { satisfiesFreshFactor: boolean }) + .satisfiesFreshFactor, + ).toBe(true); + + useElevation(elevationOf(minted)); + const res = await regenerate(); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { recoveryCodes: string[] }; + }; + expect(body.data.recoveryCodes.length).toBeGreaterThan(0); + }); + + it("marks a password-proved elevation as not fresh-factor in the mint response", async () => { + const { raw } = await mintToken("e10g"); + useToken(raw); + const minted = await mintElevation(PASSWORD); + const data = minted.body.data as { + method: string; + satisfiesFreshFactor: boolean; + }; + expect(data.method).toBe("password"); + expect(data.satisfiesFreshFactor).toBe(false); + }); + + it("refuses a stale TOTP code and does not mint", async () => { + const secret = await enrolTotp(); + const { raw } = await mintToken("e10h"); + useToken(raw); + + // Spend the code through the shared verifier first; the replay guard must + // then refuse the same code here, exactly as it does at login. + const code = currentTotpCode(secret); + const { verifyMfaFactor } = await import("@/lib/auth/mfa/verify-factor"); + const user = await getPrismaClient().user.findUnique({ + where: { id: USER_ID }, + }); + await verifyMfaFactor(user!, "totp", code); + + const res = await mintWith({ method: "totp", code }); + + expect(res.status).toBe(401); + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); +}); + +// ── E11 — the disable route keeps the caller signed in (B2) ────────── + +describe("E11 — disabling MFA over Bearer spares the calling device", () => { + it("revokes every OTHER refresh token but not the caller's own", async () => { + const secret = await enrolTotp(); + const { raw, id } = await mintToken("e11a"); + + const { hashToken } = await import("@/lib/auth/hmac"); + // The caller's own device login: the RefreshToken row cross-referencing the + // access token being presented. + const mine = await getPrismaClient().refreshToken.create({ + data: { + userId: USER_ID, + tokenHash: hashToken(`hlr_mine${"0".repeat(56)}`), + accessTokenHash: hashToken(raw), + expiresAt: new Date(Date.now() + 30 * 24 * 3_600_000), + }, + }); + // Another device. + const theirs = await getPrismaClient().refreshToken.create({ + data: { + userId: USER_ID, + tokenHash: hashToken(`hlr_theirs${"0".repeat(54)}`), + accessTokenHash: hashToken(`hlk_other${"0".repeat(55)}`), + expiresAt: new Date(Date.now() + 30 * 24 * 3_600_000), + }, + }); + // And a browser session, which has no "current" on the Bearer path. + const webSession = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + + const codes = await seedRecoveryCodes(); + useToken(raw); + useElevation(elevationOf(await mintTotpElevation(secret))); + + const { POST } = await import("@/app/api/auth/me/mfa/disable/route"); + const res = await POST( + req("/api/auth/me/mfa/disable", "POST", { + code: codes[0], + method: "recovery", + }), + ); + expect(res.status).toBe(200); + + // The defect: passing the ApiToken id where a Session id was expected made + // the "keep the current one" exclusion match nothing, so the caller's own + // refresh token was revoked and the app logged itself out at next rotation. + const mineAfter = await getPrismaClient().refreshToken.findUnique({ + where: { id: mine.id }, + }); + const theirsAfter = await getPrismaClient().refreshToken.findUnique({ + where: { id: theirs.id }, + }); + expect(mineAfter?.revokedAt).toBeNull(); + expect(theirsAfter?.revokedAt).not.toBeNull(); + + // Web sessions all go — none of them is the caller. + expect( + await getPrismaClient().session.findUnique({ + where: { id: webSession.id }, + }), + ).toBeNull(); + + // And the calling token itself is untouched, so the next request works. + const token = await getPrismaClient().apiToken.findUnique({ + where: { id }, + }); + expect(token?.revoked).toBe(false); + }); + + it("keeps the caller's own session on the cookie path, as before", async () => { + const secret = await enrolTotp(); + const current = await getPrismaClient().session.create({ + data: { + userId: USER_ID, + expiresAt: new Date(Date.now() + 3_600_000), + mfaVerifiedAt: new Date(), + }, + }); + const other = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", current.id); + headerJar.delete("authorization"); + + const { POST } = await import("@/app/api/auth/me/mfa/disable/route"); + const res = await POST( + req("/api/auth/me/mfa/disable", "POST", { + code: currentTotpCode(secret), + method: "totp", + }), + ); + expect(res.status).toBe(200); + + expect( + await getPrismaClient().session.findUnique({ where: { id: current.id } }), + ).not.toBeNull(); + expect( + await getPrismaClient().session.findUnique({ where: { id: other.id } }), + ).toBeNull(); + }); +}); + +// ── E12 — validation failures do not burn the proof ────────────────── + +describe("E12 — a rejected request keeps the elevation spendable", () => { + it("survives a wrong TOTP code on disable", async () => { + const secret = await enrolTotp(); + const codes = await seedRecoveryCodes(); + const { raw } = await mintToken("e12a"); + useToken(raw); + const elevation = elevationOf(await mintTotpElevation(secret)); + + useElevation(elevation); + const { POST } = await import("@/app/api/auth/me/mfa/disable/route"); + const bad = await POST( + req("/api/auth/me/mfa/disable", "POST", { + code: "000000", + method: "totp", + }), + ); + expect(bad.status).toBe(401); + + // Unspent — otherwise five fat-fingered codes would exhaust the 5-per-15-min + // mint ceiling and lock the user out of their own security settings. + const row = await getPrismaClient().stepUpElevation.findFirst(); + expect(row?.consumedAt).toBeNull(); + + // And the retry goes through on the SAME elevation. + const good = await POST( + req("/api/auth/me/mfa/disable", "POST", { + code: codes[0], + method: "recovery", + }), + ); + expect(good.status).toBe(200); + }); + + it("IS spent once the route actually acts", async () => { + // The other half of the two-phase split. Deferring the claim buys nothing if + // the route then forgets to make it — the elevation would stay redeemable + // for the rest of its five minutes. + // + // Driven through recovery-code regeneration rather than disable: disable + // calls `destroyOtherSessions`, which deletes the account's elevations + // outright, so the row is gone afterwards either way and the assertion + // would pass whether or not the route committed. Regeneration leaves the + // row in place, so `consumedAt` is a real observation. + const secret = await enrolTotp(); + const { raw } = await mintToken("e12c"); + useToken(raw); + const elevation = elevationOf(await mintTotpElevation(secret)); + + useElevation(elevation); + const { POST } = + await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route"); + const ok = await POST( + req("/api/auth/me/mfa/recovery-codes/regenerate", "POST"), + ); + expect(ok.status).toBe(200); + + const row = await getPrismaClient().stepUpElevation.findFirst(); + expect(row).not.toBeNull(); + expect(row?.consumedAt).not.toBeNull(); + + // And the spent value opens nothing else. + expect((await callMfaSetup()).status).toBe(401); + }); + + it("survives a malformed body", async () => { + const { raw } = await mintToken("e12b"); + useToken(raw); + useElevation(elevationOf(await mintElevation(PASSWORD))); + + const { POST } = + await import("@/app/api/auth/me/mfa/webauthn/register/verify/route"); + const res = await POST( + req("/api/auth/me/mfa/webauthn/register/verify", "POST", { nope: 1 }), + ); + expect(res.status).toBe(422); + + const row = await getPrismaClient().stepUpElevation.findFirst(); + expect(row?.consumedAt).toBeNull(); + }); +}); + +// ── E13 — the status read is plain Bearer ──────────────────────────── + +describe("E13 — the second-factor status read needs no elevation", () => { + it("serves a token with no elevation header at all", async () => { + const { raw } = await mintToken("e13a"); + useToken(raw); + useElevation(null); + + const res = await callMfaStatus(); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { totp: { enabled: boolean }; webauthn: unknown[] }; + }; + expect(body.data.totp).toBeDefined(); + expect(Array.isArray(body.data.webauthn)).toBe(true); + }); + + it("carries no credential material", async () => { + await getPrismaClient().webauthnMfaCredential.create({ + data: { + userId: USER_ID, + name: "key", + credentialId: "cred-e13b-secret", + credentialPublicKey: Buffer.from([9, 9, 9]), + transports: ["internal"], + }, + }); + const { raw } = await mintToken("e13b"); + useToken(raw); + + const text = await (await callMfaStatus()).text(); + + // The justification for relaxing this route is that the payload is inert. + // Pin it: no credential id, no public key, no codes. + expect(text).not.toContain("cred-e13b-secret"); + expect(text).not.toContain("credentialPublicKey"); + expect(text).not.toContain("credentialId"); + }); +});