Merge pull request #598 from MBombeck/release/v1.32.3

release/v1.32.3 — native step-up elevations for iOS 2FA management
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-23 13:02:13 +02:00
commit 0908fac738
38 files changed
+3459 -98

No files matched your search

+21
View File
@@ -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
+321 -14
View File
@@ -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:
+1 -1
View File
@@ -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",
@@ -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;
+63
View File
@@ -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).
//
+1 -1
View File
@@ -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/*`
@@ -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/);
});
});
@@ -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);
@@ -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 () => {
+17 -6
View File
@@ -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,
@@ -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", {
+13 -3
View File
@@ -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),
+27 -11
View File
@@ -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
+15 -5
View File
@@ -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);
+16 -11
View File
@@ -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", {
@@ -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<string, unknown>): 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);
});
@@ -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,
@@ -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: {
@@ -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",
});
});
});
+4 -1
View File
@@ -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,
@@ -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),
+6
View File
@@ -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.
+81
View File
@@ -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 });
});
+236
View File
@@ -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<boolean> => {
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),
});
});
+241 -1
View File
@@ -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<BearerAuthContext> {
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<void>;
} & (
| { 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<MfaManagementContext> {
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.
+185
View File
@@ -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<unknown>) =>
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]",
);
});
});
+34 -3
View File
@@ -368,6 +368,16 @@ export async function destroyAllSessions(userId: string): Promise<void> {
]);
}
/**
* 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<void> {
*/
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 };
}
+335
View File
@@ -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<StepUpMethod> = 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<MintedElevation> {
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<StepUpOutcome> {
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<StepUpOutcome> {
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<void> {
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.
+1 -1
View File
@@ -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)) {
+34
View File
@@ -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<StepUpElevationCleanupPayload>[],
) {
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<AuditLogCleanupPayload>[],
) {
@@ -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<StepUpElevationCleanupPayload>(
STEP_UP_ELEVATION_CLEANUP_QUEUE,
{ localConcurrency: 1 },
handleStepUpElevationCleanup,
);
await boss.work<OffhostBackupPayload>(
OFFHOST_BACKUP_QUEUE,
{ localConcurrency: 1 },
+4 -3
View File
@@ -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
+120 -11
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/api/auth/check-user": {
post: {
@@ -392,12 +434,71 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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<ZodOpenApiObject["paths"]> = {
"/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<ZodOpenApiObject["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.",
"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<ZodOpenApiObject["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.",
"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<ZodOpenApiObject["paths"]> = {
"/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<ZodOpenApiObject["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.",
"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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["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.",
"Requires a fresh second-factor step-up." + MFA_MANAGEMENT_AUTH_NOTE,
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
+1 -1
View File
@@ -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(),
+67
View File
@@ -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" });
@@ -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({
+1
View File
@@ -69,6 +69,7 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
"refresh_tokens",
"reminder_phase_configs",
"sessions",
"step_up_elevations",
"telegram_reminder_messages",
"telegram_scheduled_deletions",
"user_achievements",
File diff suppressed because it is too large. Load diff