From e557d9e9e69d23c8223bab8a18ed6c01ea682a42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 12:42:00 +0200 Subject: [PATCH 1/9] feat(auth): add token-bound step-up elevations for second-factor management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-factor management has been cookie-only since v1.23. The gate is right: a Bearer token is a credential whose provenance the server cannot see, and tearing down a second factor must not be reachable from the weaker of an interactive login and a value pasted into a script. What was missing was never Bearer access to those routes — it was a way to say, over a transport with no session row, that the human holding this device just re-proved a factor. An elevation is that proof. It is minted only against a re-proved account password or primary passkey, bound to the exact token that re-proved, single-use via a conditional UPDATE, five-minute-lived to match the cookie path's step-up window, and stored hashed. A stolen token mints nothing; a second token cannot redeem another's elevation; a password change drops every one the account holds. requireMfaManagementAuth is the one gate the management routes share. On a cookie session it delegates to the original helpers unchanged, so the web flow runs the code it always did. requireAdmin stays cookie-only and out of reach. --- .../0256_step_up_elevation/migration.sql | 32 +++ prisma/schema.prisma | 53 +++++ .../admin/users/[id]/reset-password/route.ts | 6 + src/app/api/auth/me/mfa/disable/route.ts | 12 +- .../me/mfa/recovery-codes/regenerate/route.ts | 12 +- src/app/api/auth/me/mfa/route.ts | 7 +- src/app/api/auth/me/mfa/totp/confirm/route.ts | 20 +- src/app/api/auth/me/mfa/totp/setup/route.ts | 16 +- .../api/auth/me/mfa/webauthn/[id]/route.ts | 19 +- .../me/mfa/webauthn/register/options/route.ts | 11 +- .../me/mfa/webauthn/register/verify/route.ts | 11 +- src/app/api/auth/password/route.ts | 6 + src/app/api/auth/step-up/options/route.ts | 51 ++++ src/app/api/auth/step-up/route.ts | 188 +++++++++++++++ src/lib/api-handler.ts | 150 +++++++++++- src/lib/auth/step-up.ts | 218 ++++++++++++++++++ src/lib/logging/redact.ts | 7 +- src/lib/validations/mfa.ts | 2 +- src/lib/validations/step-up.ts | 38 +++ 19 files changed, 808 insertions(+), 51 deletions(-) create mode 100644 prisma/migrations/0256_step_up_elevation/migration.sql create mode 100644 src/app/api/auth/step-up/options/route.ts create mode 100644 src/app/api/auth/step-up/route.ts create mode 100644 src/lib/auth/step-up.ts create mode 100644 src/lib/validations/step-up.ts diff --git a/prisma/migrations/0256_step_up_elevation/migration.sql b/prisma/migrations/0256_step_up_elevation/migration.sql new file mode 100644 index 000000000..c16c4f3da --- /dev/null +++ b/prisma/migrations/0256_step_up_elevation/migration.sql @@ -0,0 +1,32 @@ +-- 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"); +CREATE INDEX "step_up_elevations_user_id_idx" ON "step_up_elevations"("user_id"); +CREATE INDEX "step_up_elevations_api_token_id_idx" ON "step_up_elevations"("api_token_id"); +CREATE INDEX "step_up_elevations_expires_at_idx" ON "step_up_elevations"("expires_at"); + +ALTER TABLE "step_up_elevations" ADD CONSTRAINT "step_up_elevations_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Cascade on the token binding: revoking or deleting a token must never leave a +-- redeemable elevation behind. +ALTER TABLE "step_up_elevations" ADD CONSTRAINT "step_up_elevations_api_token_id_fkey" + FOREIGN KEY ("api_token_id") REFERENCES "api_tokens"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ffaaff10a..9a645a2c4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -658,6 +658,10 @@ model User { // skips the second factor (factor 2 only) within the window; the password // is still required. Revoked when the second factor is removed. trustedDevices TrustedDevice[] + // v1.30.34 — short-lived, token-bound step-up elevations. The Bearer-transport + // equivalent of `Session.mfaVerifiedAt`: a token client re-proves a factor and + // receives one single-use elevation good for one MFA-management call. + stepUpElevations StepUpElevation[] measurements Measurement[] medications Medication[] intakeEvents MedicationIntakeEvent[] @@ -3103,12 +3107,61 @@ 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" or "passkey". Audit/telemetry only — + // never an authorisation input. + 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]) + @@map("step_up_elevations") +} + // MCP OAuth connection — the revocable, theft-detectable anchor for a remote // connector's refresh-token chain (Phase 3 security fix, H2). // diff --git a/src/app/api/admin/users/[id]/reset-password/route.ts b/src/app/api/admin/users/[id]/reset-password/route.ts index 2da8adf47..024ccb29a 100644 --- a/src/app/api/admin/users/[id]/reset-password/route.ts +++ b/src/app/api/admin/users/[id]/reset-password/route.ts @@ -11,6 +11,7 @@ import { import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { destroyAllSessions } from "@/lib/auth/session"; +import { revokeStepUpElevations } from "@/lib/auth/step-up"; import { NextRequest, NextResponse } from "next/server"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { checkPasswordBreach } from "@/lib/auth/hibp"; @@ -83,6 +84,11 @@ export const POST = apiHandler( data: { passwordHash }, }); + // v1.30.34 — an operator-forced rotation invalidates the target's step-up + // elevations for the same reason a self-service change does: they attest to + // a credential that no longer exists. + await revokeStepUpElevations(id); + // Invalidate all sessions of the target user await destroyAllSessions(id); diff --git a/src/app/api/auth/me/mfa/disable/route.ts b/src/app/api/auth/me/mfa/disable/route.ts index d5ab54a18..144eb4c1b 100644 --- a/src/app/api/auth/me/mfa/disable/route.ts +++ b/src/app/api/auth/me/mfa/disable/route.ts @@ -2,8 +2,9 @@ * POST /api/auth/me/mfa/disable * * Tear down the second factor. Double-gated: - * 1. `requireFreshMfa` — a cookie session that completed a second factor - * within the step-up window (Bearer can never satisfy this). + * 1. `requireMfaManagementAuth({ freshFactor: true })` — a cookie session that + * completed a second factor within the step-up window, OR a Bearer token + * presenting a single-use elevation it minted by re-proving a factor. * 2. A current TOTP or recovery code in the body — proves live possession at * the moment of the destructive action, not just a stale fresh-session. * @@ -12,9 +13,8 @@ */ import { apiHandler, - requireFreshMfa, + requireMfaManagementAuth, HttpError, - MFA_STEP_UP_MAX_AGE_SECONDS, } from "@/lib/api-handler"; import { apiError, @@ -38,7 +38,9 @@ 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 { user, session } = await requireMfaManagementAuth({ + freshFactor: true, + }); const rl = await checkRateLimit( `mfa:disable:${user.id}`, diff --git a/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts b/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts index a82e3d87d..e6c97c3d5 100644 --- a/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts +++ b/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts @@ -2,15 +2,15 @@ * POST /api/auth/me/mfa/recovery-codes/regenerate * * Issue a fresh recovery-code batch, invalidating the entire prior set in one - * transaction. Step-up gated (`requireFreshMfa`) — a recovery-code rotation is - * a sensitive action; Bearer can never satisfy the gate. The new codes are - * returned **once**. + * transaction. Step-up gated (`requireMfaManagementAuth({ freshFactor: true })`) + * — a recovery-code rotation is sensitive, so it takes either a freshly + * second-factor-verified cookie session or a single-use step-up elevation minted + * by the calling token. The new codes are returned **once**. */ import { apiHandler, - requireFreshMfa, + requireMfaManagementAuth, HttpError, - MFA_STEP_UP_MAX_AGE_SECONDS, } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; @@ -24,7 +24,7 @@ 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 { user } = await requireMfaManagementAuth({ freshFactor: true }); // Defence in depth — regeneration only makes sense for an active factor. if (!user.totpConfirmedAt) { diff --git a/src/app/api/auth/me/mfa/route.ts b/src/app/api/auth/me/mfa/route.ts index 2498f5e62..df6c775f4 100644 --- a/src/app/api/auth/me/mfa/route.ts +++ b/src/app/api/auth/me/mfa/route.ts @@ -3,11 +3,12 @@ * * 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. + * Gated by `requireMfaManagementAuth`: a cookie session, or a Bearer token + * presenting a single-use step-up elevation. A token alone is not enough. * * Returns only metadata: no secret, no recovery-code plaintext, no public key. */ -import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; +import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; @@ -16,7 +17,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 requireMfaManagementAuth(); const [recoveryCodesRemaining, webauthn] = await Promise.all([ countRemainingRecoveryCodes(user.id), diff --git a/src/app/api/auth/me/mfa/totp/confirm/route.ts b/src/app/api/auth/me/mfa/totp/confirm/route.ts index 12bf3b78e..8bad88724 100644 --- a/src/app/api/auth/me/mfa/totp/confirm/route.ts +++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts @@ -6,13 +6,21 @@ * the accepted time-step (replay guard), generates + persists the Argon2id- * hashed recovery codes, and returns the codes **once** for the user to save. * - * The completing session is also stamped `mfaVerifiedAt = now()` so the user - * can immediately manage the freshly-enabled factor (disable / regenerate - * codes) without an extra step-up round-trip. + * On the cookie path the completing session is stamped `mfaVerifiedAt = now()` + * so the user can immediately manage the freshly-enabled factor (disable / + * regenerate codes) without an extra step-up round-trip. A Bearer caller has no + * session row to stamp — the stamping `updateMany` simply matches nothing — and + * mints a fresh elevation per action instead. * - * Cookie-only (`requireCookieAuth`): an API token can never enrol MFA. + * Gated by `requireMfaManagementAuth`: a cookie session, or a Bearer token + * presenting a single-use step-up elevation. A token on its own can still never + * enrol MFA. */ -import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler"; +import { + apiHandler, + requireMfaManagementAuth, + HttpError, +} from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -38,7 +46,7 @@ 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 { user, session } = await requireMfaManagementAuth(); const rl = await checkRateLimit( `mfa:confirm:${user.id}`, diff --git a/src/app/api/auth/me/mfa/totp/setup/route.ts b/src/app/api/auth/me/mfa/totp/setup/route.ts index 57fa885d5..662aa2206 100644 --- a/src/app/api/auth/me/mfa/totp/setup/route.ts +++ b/src/app/api/auth/me/mfa/totp/setup/route.ts @@ -8,11 +8,17 @@ * not active until `/confirm` verifies a code, so `totpConfirmedAt` stays * null here. * - * Cookie-only (`requireCookieAuth`): an API token can never enrol MFA. The - * recovery-code batch is issued at `/confirm` (after the factor is proven), - * not here, so an abandoned setup never persists codes. + * Gated by `requireMfaManagementAuth`: a cookie session, or a Bearer token + * presenting a single-use step-up elevation minted against a re-proved factor. + * A token on its own can still never enrol MFA. The recovery-code batch is + * issued at `/confirm` (after the factor is proven), not here, so an abandoned + * setup never persists codes. */ -import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler"; +import { + apiHandler, + requireMfaManagementAuth, + HttpError, +} from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -27,7 +33,7 @@ const SETUP_RATE_LIMIT = 5; const SETUP_WINDOW_MS = 15 * 60 * 1000; export const POST = apiHandler(async (req: Request) => { - const { user } = await requireCookieAuth(); + const { user } = await requireMfaManagementAuth(); const rl = await checkRateLimit( `mfa:setup:${user.id}`, diff --git a/src/app/api/auth/me/mfa/webauthn/[id]/route.ts b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts index 8c83c7981..6aab56136 100644 --- a/src/app/api/auth/me/mfa/webauthn/[id]/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts @@ -2,17 +2,14 @@ * PATCH /api/auth/me/mfa/webauthn/[id] — rename a registered security key. * DELETE /api/auth/me/mfa/webauthn/[id] — remove one (step-up gated). * - * Rename is cookie-authenticated (non-destructive). Removal is a sensitive - * action: it is gated on a fresh second-factor step-up (`requireFreshMfa`, - * cookie-only — a Bearer token can never satisfy it). + * Both go through `requireMfaManagementAuth`: a cookie session, or a Bearer + * token presenting a single-use step-up elevation. Removal additionally demands + * the fresh-factor arm (`freshFactor: true`) — on the cookie path a session that + * completed a second factor inside the step-up window, on the Bearer path an + * elevation minted against a re-proved factor. */ import { NextRequest } from "next/server"; -import { - apiHandler, - requireCookieAuth, - requireFreshMfa, - MFA_STEP_UP_MAX_AGE_SECONDS, -} from "@/lib/api-handler"; +import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -31,7 +28,7 @@ export const PATCH = apiHandler( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) => { - const { user } = await requireCookieAuth(); + const { user } = await requireMfaManagementAuth(); const { id } = await params; const { data: body, error: jsonError } = await safeJson(request, { @@ -70,7 +67,7 @@ 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 { user } = await requireMfaManagementAuth({ freshFactor: true }); const { id } = await params; const existing = await prisma.webauthnMfaCredential.findUnique({ diff --git a/src/app/api/auth/me/mfa/webauthn/register/options/route.ts b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts index 350cf7f82..109acbbf3 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/options/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts @@ -1,11 +1,12 @@ /** * POST /api/auth/me/mfa/webauthn/register/options * - * Begin registering a WebAuthn security key as a second factor. Cookie-only — - * an API token can never enrol MFA. Returns the SimpleWebAuthn creation - * options + the server-issued challenge id to present back at /register/verify. + * Begin registering a WebAuthn security key as a second factor. Takes a cookie + * session or a Bearer token presenting a single-use step-up elevation; a token + * on its own can never enrol MFA. Returns the SimpleWebAuthn creation options + + * the server-issued challenge id to present back at /register/verify. */ -import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; +import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -14,7 +15,7 @@ import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn"; export const dynamic = "force-dynamic"; export const POST = apiHandler(async () => { - const { user } = await requireCookieAuth(); + const { user } = await requireMfaManagementAuth(); const rl = await checkRateLimit( `mfa:webauthn:register:${user.id}`, diff --git a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts index 8a9e0c5d7..b3b651ca3 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts @@ -1,12 +1,13 @@ /** * POST /api/auth/me/mfa/webauthn/register/verify * - * Finish registering a WebAuthn security key as a second factor. Cookie-only. - * Verifies the attestation against the user-bound challenge and stores the - * credential in `WebauthnMfaCredential` (kept separate from primary passkeys). + * Finish registering a WebAuthn security key as a second factor. Takes a cookie + * session or a Bearer token presenting a single-use step-up elevation. Verifies + * the attestation against the user-bound challenge and stores the credential in + * `WebauthnMfaCredential` (kept separate from primary passkeys). */ import { NextRequest } from "next/server"; -import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; +import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -23,7 +24,7 @@ import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; export const dynamic = "force-dynamic"; export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireCookieAuth(); + const { user } = await requireMfaManagementAuth(); const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 3371b0a0c..ae97c2792 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -22,6 +22,7 @@ import { } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { destroyAllSessions, createSession } from "@/lib/auth/session"; +import { revokeStepUpElevations } from "@/lib/auth/step-up"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { checkPasswordBreach } from "@/lib/auth/hibp"; import { getServerTranslator } from "@/lib/i18n/server-translator"; @@ -102,6 +103,11 @@ export const POST = apiHandler(async (request: NextRequest) => { data: { passwordHash: newHash }, }); + // v1.30.34 — a step-up elevation is a statement about a credential that no + // longer exists. Drop every one the account holds before the sessions go, so + // an elevation minted moments before the rotation cannot outlive it. + await revokeStepUpElevations(user.id); + // Invalidate all existing sessions and create a fresh one. // v1.4.22 W5 reconcile (Sr-H1) — `createSession` re-anchors the // `hl_onboarding` cookie itself. diff --git a/src/app/api/auth/step-up/options/route.ts b/src/app/api/auth/step-up/options/route.ts new file mode 100644 index 000000000..f9caae99d --- /dev/null +++ b/src/app/api/auth/step-up/options/route.ts @@ -0,0 +1,51 @@ +/** + * POST /api/auth/step-up/options + * + * Begin a primary-passkey assertion for the step-up mint below. Returns + * SimpleWebAuthn assertion options scoped to the calling account's registered + * passkeys, 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 passkey gets a + * 409 telling the client to use the password arm instead. + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireBearerAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } 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"; + +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 passkeyCount = await prisma.passkey.count({ + where: { userId: user.id }, + }); + if (passkeyCount === 0) { + annotate({ action: { name: "auth.stepup.passkey.none" } }); + return apiError("No passkey registered", 409); + } + + const { options, challengeId } = await createAuthenticationOptions(user.id); + + annotate({ action: { name: "auth.stepup.passkey.options" } }); + return apiSuccess({ options, challengeId }); +}); diff --git a/src/app/api/auth/step-up/route.ts b/src/app/api/auth/step-up/route.ts new file mode 100644 index 000000000..ec1e6e228 --- /dev/null +++ b/src/app/api/auth/step-up/route.ts @@ -0,0 +1,188 @@ +/** + * 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. + * + * 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 { + mintStepUpElevation, + 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); + } + + let proved = false; + let method: StepUpMethod = parsed.data.method; + // Audit-only detail. Never reaches the response — see the file header. + let failure = "invalid"; + + 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 passkey either. + failure = "no_password"; + } else { + proved = await verifyPassword(user.passwordHash, parsed.data.password); + if (!proved) failure = "bad_password"; + } + } else { + method = "passkey"; + // Bind the challenge to the caller BEFORE verifying. `verifyAuthentication` + // resolves a challenge by id alone; pinning ownership here means a challenge + // minted for another account cannot be carried into this ceremony. + const challenge = await prisma.authChallenge.findUnique({ + where: { id: parsed.data.challengeId }, + select: { userId: true }, + }); + if (!challenge || challenge.userId !== user.id) { + 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 { + // A malformed / expired challenge is a failed attempt, not a 500. + 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, + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 850c0dca3..df6d0bdd7 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -3,7 +3,7 @@ import { headers } from "next/headers"; import type { User } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { WideEventBuilder } from "./logging/event-builder"; -import { eventStorage, getEvent } from "./logging/context"; +import { annotate, eventStorage, getEvent } from "./logging/context"; import { emitIfSampled } from "./logging/transports"; import { redactOptional, redactSecrets } from "./logging/redact"; import { getSession } from "./auth/session"; @@ -13,6 +13,10 @@ import { BearerAuthError, type ScopeRequirement, } from "./auth/bearer"; +import { + redeemStepUpElevation, + STEP_UP_ELEVATION_TTL_SECONDS, +} from "./auth/step-up"; import { AssistantDisabledError } from "./feature-flags"; import { ConsentRequiredError } from "./ai/consent-guard"; import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config"; @@ -620,6 +624,150 @@ export async function requireFreshMfaIfEnrolled( return auth; } +/** + * v1.30.34 — resolve a caller by Bearer token ONLY, refusing a cookie session. + * + * The mirror image of `requireCookieAuth`, and it exists for one surface: the + * step-up mint endpoints, which are the Bearer transport's own re-authentication + * flow and have no meaning for a browser (a browser re-proves a factor at login + * and carries the result on its session row). Refusing the cookie keeps the mint + * surface entirely outside the cookie's blast radius — no ambient credential can + * reach it, so the class of attack where a browser is induced to fire a request + * on the user's behalf simply does not apply. + * + * Scope handling is the standard fail-closed default: no declared scope, so only + * a cookie-equivalent (`["*"]`) token is admitted. A narrow token — an MCP grant, + * a medication-ingest grant — is refused 403 by the resolver. + */ +export async function requireBearerAuth(): Promise { + let authHeader: string | null = null; + try { + const headerList = await headers(); + authHeader = headerList.get("authorization"); + } catch { + authHeader = null; + } + if (!authHeader?.startsWith("Bearer ")) { + throw new HttpError(401, "Not authenticated"); + } + return await authenticateBearer(authHeader.slice(7), undefined); +} + +/** + * 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"; + +/** + * v1.30.34 — the single gate for second-factor management. + * + * Every MFA-management route 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. + * The token alone is never enough: with no header, or a header that does not + * redeem, the caller gets the same `auth.stepup.required` a stale cookie + * session gets. + * + * `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`: the + * account must actually have a second factor enrolled. Set by the destructive + * routes (disable, recovery-code rotation, security-key removal). + */ +export async function requireMfaManagementAuth( + options: { freshFactor?: boolean } = {}, +): Promise { + // Cookie first, and via the original helpers — the web path runs the same + // code it always did. + const sessionData = await getSession(); + if (sessionData) { + return options.freshFactor + ? await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS) + : await requireCookieAuth(); + } + + // Bearer path. Resolution first: an unknown, revoked, expired, or narrow-scope + // token is refused here and never gets as far as presenting an elevation. + const auth = await requireBearerAuth(); + const apiTokenId = auth.session.id; + + 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 }, + }); + throw new StepUpRequiredError(); + } + + const redeemed = await redeemStepUpElevation({ + rawToken: raw, + userId: auth.user.id, + apiTokenId, + }); + + if (!redeemed.ok) { + // 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, or minted for a different token. + auditLog("auth.stepup.elevation.rejected", { + userId: auth.user.id, + details: { reason: redeemed.reason }, + }).catch(() => {}); + annotate({ + action: { name: "auth.stepup.elevation.rejected" }, + meta: { reason: redeemed.reason }, + }); + throw new StepUpRequiredError(); + } + + // 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 (options.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"); + } + } + + annotate({ + action: { name: "auth.stepup.elevation.accepted" }, + meta: { method: redeemed.method }, + }); + + return auth; +} + /** * Report unhandled errors to GlitchTip (fire-and-forget). * Uses dynamic import to avoid circular dependencies and startup cost. diff --git a/src/lib/auth/step-up.ts b/src/lib/auth/step-up.ts new file mode 100644 index 000000000..734c214c1 --- /dev/null +++ b/src/lib/auth/step-up.ts @@ -0,0 +1,218 @@ +/** + * 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. + * + * THE PROPERTIES, and why each one is load-bearing: + * + * Minted only against a re-proved factor. Presenting the token alone yields + * nothing — that is the entire point. A stolen token gains no more reach than + * it had before this module existed. + * + * 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. Theft of + * the elevation in transit is therefore useless without also holding the exact + * token it was minted for. + * + * Single-use. Redemption is a conditional UPDATE (see `redeemStepUpElevation`) + * so two concurrent redemptions cannot both succeed. One management action per + * proof, by construction rather than by convention. + * + * Short-lived. `STEP_UP_ELEVATION_TTL_SECONDS` mirrors the cookie path's + * `MFA_STEP_UP_MAX_AGE_SECONDS` exactly, so both transports carry the same + * freshness promise and neither is the softer way in. + * + * Stored hashed. Only `hashToken(secret)` reaches the database — the same + * posture as ApiToken, RefreshToken, and the session secret. A table dump + * yields nothing redeemable, and the raw value exists only in the mint + * response and the client's memory. + * + * INVALIDATION comes from two directions. A password change calls + * `revokeStepUpElevations` outright. 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 "@/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; it is long + * enough to type a code from an authenticator app and short enough that an + * elevation captured off a screen or a proxy log is stale before it is useful. + * + * 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. Recorded for audit; never an authorisation input. */ +export type StepUpMethod = "password" | "passkey"; + +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. + * + * Any earlier unconsumed elevation for the same token is deleted first. A fresh + * proof supersedes an older one, so there is never more than one redeemable + * elevation per token; that both bounds the table and shrinks the window in + * which a captured elevation is worth anything. + */ +export async function mintStepUpElevation(opts: { + userId: string; + apiTokenId: string; + method: StepUpMethod; +}): Promise { + const raw = `${ELEVATION_PREFIX}${randomBytes(ELEVATION_SECRET_BYTES).toString("hex")}`; + const expiresAt = new Date(Date.now() + STEP_UP_ELEVATION_TTL_SECONDS * 1000); + + await prisma.stepUpElevation.deleteMany({ + where: { apiTokenId: opts.apiTokenId, consumedAt: null }, + }); + + await prisma.stepUpElevation.create({ + data: { + userId: opts.userId, + apiTokenId: opts.apiTokenId, + tokenHash: hashToken(raw), + method: opts.method, + expiresAt, + }, + select: { id: true }, + }); + + // Opportunistic housekeeping — cheap, indexed, and keeps the table from + // accumulating spent rows without needing a scheduled job for a table that + // only ever sees a handful of rows per user per year. + prisma.stepUpElevation + .deleteMany({ where: { expiresAt: { lt: new Date() } } }) + .catch(() => {}); + + return { token: raw, expiresAt }; +} + +/** Why a redemption was refused. Audit detail only — never surfaced to a caller. */ +export type StepUpRedeemFailure = + "malformed" | "unknown" | "wrong_token" | "consumed" | "expired"; + +export type StepUpRedeemResult = + | { ok: true; method: StepUpMethod } + | { ok: false; reason: StepUpRedeemFailure }; + +/** + * 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` (Prisma's `updateMany`), and the caller proceeds only on + * `count === 1`. 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 matches zero rows. There is + * no read-then-write window to lose, which is exactly why the check-then-update + * shape was not used. + * + * Both binding fields are in the predicate rather than checked afterwards, so a + * mismatched token cannot be claimed and then rejected — it is never claimed at + * all, and the legitimate holder's elevation survives someone else's attempt. + */ +export async function redeemStepUpElevation(opts: { + rawToken: string; + userId: string; + apiTokenId: string; +}): Promise { + if (!opts.rawToken.startsWith(ELEVATION_PREFIX)) { + return { ok: false, reason: "malformed" }; + } + + const tokenHash = hashToken(opts.rawToken); + const now = new Date(); + + const claimed = await prisma.stepUpElevation.updateMany({ + where: { + tokenHash, + userId: opts.userId, + apiTokenId: opts.apiTokenId, + consumedAt: null, + expiresAt: { gt: now }, + }, + data: { consumedAt: now }, + }); + + if (claimed.count === 1) { + const row = await prisma.stepUpElevation.findUnique({ + where: { tokenHash }, + select: { method: true }, + }); + return { ok: true, method: (row?.method as StepUpMethod) ?? "password" }; + } + + // Classify the refusal for the audit row only. The caller returns one generic + // response for every branch below, so this read tells an operator what + // happened without telling a prober anything. + const existing = await prisma.stepUpElevation.findUnique({ + where: { tokenHash }, + select: { + apiTokenId: true, + userId: true, + consumedAt: true, + expiresAt: 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" }; +} + +/** + * Drop every elevation for a user. + * + * Called when the account password changes: an elevation is a statement about a + * credential that no longer exists, so it must not outlive it. Deliberately + * unconditional over the user's rows — consumed ones go too, since there is no + * reason to keep them once the anchor moved. + */ +export async function revokeStepUpElevations(userId: string): Promise { + await prisma.stepUpElevation.deleteMany({ where: { userId } }); +} diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts index 3f91a101e..8c52e78ac 100644 --- a/src/lib/logging/redact.ts +++ b/src/lib/logging/redact.ts @@ -83,12 +83,13 @@ export function redactSecrets(input: string): string { // tiny safety margin against `sk-1`-style false-positives in code // identifiers. Capture group 1 = the leading separator (preserved). .replace(/(^|[^A-Za-z0-9])sk-(?:ant-)?[A-Za-z0-9_-]{8,}/g, "$1[REDACTED]") - // HealthLog native API tokens (`hlk_<64hex>` access, `hlr_<64hex>` - // refresh). Same boundary-aware shape as the `sk-` rule. The + // HealthLog hex-bodied credentials: `hlk_` access token, `hlr_` refresh + // token, `hls_` session cookie secret (v1.30.32), `hle_` step-up + // elevation (v1.30.34). Same boundary-aware shape as the `sk-` rule. The // idempotency replay-cache already rejects bodies containing // these prefixes (CLAUDE.md headless-client-API patterns); this // is the matching egress guard for log/error surfaces. - .replace(/(^|[^A-Za-z0-9])hl[kr]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]") + .replace(/(^|[^A-Za-z0-9])hl[kres]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]") // v1.22.0 — the MCP OAuth bridge artifacts: the authorization code // (`hlac_`), refresh token (`hlrt_`), and DCR client id (`hlc_`). All // are signed, base64url-bodied, and credential-bearing, so they are diff --git a/src/lib/validations/mfa.ts b/src/lib/validations/mfa.ts index ca22284b3..746a1702e 100644 --- a/src/lib/validations/mfa.ts +++ b/src/lib/validations/mfa.ts @@ -57,7 +57,7 @@ export const mfaVerifySchema = z * `@simplewebauthn/server`; this just pins the envelope shape for OpenAPI and * the runtime `safeParse`. */ -const webauthnCredentialSchema = z +export const webauthnCredentialSchema = z .object({ id: z.string(), rawId: z.string(), diff --git a/src/lib/validations/step-up.ts b/src/lib/validations/step-up.ts new file mode 100644 index 000000000..c167bab42 --- /dev/null +++ b/src/lib/validations/step-up.ts @@ -0,0 +1,38 @@ +/** + * 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. + * + * Two accepted proofs, discriminated on `method`: + * - `password` — the account password. Unavailable on an account that signed + * in through OIDC SSO and never set one; such an account uses the passkey + * arm, or manages its second factor on the web. + * - `passkey` — an assertion against a primary passkey the device already + * holds, begun at `/api/auth/step-up/options`. + * + * 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("passkey"), + challengeId: z.string().min(1), + credential: webauthnCredentialSchema, + }), + ]) + .meta({ + id: "StepUpMintRequest", + description: + "Re-prove the account password or a primary passkey to mint a single-use, token-bound step-up elevation.", + }); From 433d983e6054432e496f18cf0fd396ddeebd10b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 12:47:04 +0200 Subject: [PATCH 2/9] test(auth): pin the step-up elevation boundary and publish its contract Adds the structural guard that freezes the exact route set an elevation can unlock, plus the behavioural suite that drives each threat case against a real Postgres: a token alone mints nothing, an elevation minted for one token is refused for another (same account included), a second redemption fails, a concurrent redemption lets exactly one winner through, an expired one is refused, an elevation never reaches requireAdmin, a wrong password is throttled and audited without saying what was wrong, and the cookie flow is untouched. The OpenAPI registry carries both new routes and names the accepting set in prose, so the client implements against the contract rather than against a description of it. --- docs/api/openapi.yaml | 233 ++++++- src/__tests__/step-up-elevation-guard.test.ts | 155 +++++ src/lib/openapi/routes/auth.ts | 115 +++- tests/integration/step-up-elevation.test.ts | 580 ++++++++++++++++++ 4 files changed, 1059 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/step-up-elevation-guard.test.ts create mode 100644 tests/integration/step-up-elevation.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1b8fc1dc1..f3cf43e20 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1601,13 +1601,81 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/auth/step-up/options: + post: + tags: + - Auth + summary: Begin a passkey re-proof for a step-up elevation (Bearer only) + description: "Returns SimpleWebAuthn assertion options scoped to the calling account's primary passkeys, plus a + challenge id to present at POST /api/auth/step-up. 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 + passkey registered — use the password arm of the mint instead." + 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 the account password or a primary passkey and receive an opaque elevation that authorises exactly ONE + second-factor-management call. + + + The elevation is the Bearer transport's equivalent of the web's fresh-second-factor session stamp. It 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. + + + 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 — + 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): GET /api/auth/me/mfa; 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}. 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 +1696,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 +1733,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 +1757,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 +1774,10 @@ paths: get: tags: - Auth - summary: Second-factor status (cookie session only) + summary: Second-factor status (cookie session or step-up elevation) 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, code, or public 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. responses: "200": description: Second-factor status. @@ -1721,7 +1793,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 +1812,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 +1841,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 +1876,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 @@ -11393,6 +11472,65 @@ components: - code - codeVerifier description: Exchange a one-time native OIDC handoff code (+ its PKCE verifier) for the standard native token bundle. + 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: 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 the account password or a primary passkey to mint a single-use, token-bound step-up elevation. + type: object MfaDisableRequest: type: object properties: @@ -15271,6 +15409,75 @@ 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 + required: + - elevation + - expiresAt + - expiresInSeconds + additionalProperties: false TotpSetupEnvelope: type: object properties: diff --git a/src/__tests__/step-up-elevation-guard.test.ts b/src/__tests__/step-up-elevation-guard.test.ts new file mode 100644 index 000000000..89c4a71d1 --- /dev/null +++ b/src/__tests__/step-up-elevation-guard.test.ts @@ -0,0 +1,155 @@ +/** + * 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. + * + * Like the Bearer-scope guards next door, these are tripwires rather than + * proofs. They cannot show the allowlist is correct — only that it has not + * changed without someone editing this file, which is where the reviewer's + * attention is supposed to land. + */ +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"); +} + +function filesMatching(re: RegExp): string[] { + return sourceFiles().filter((rel) => re.test(read(rel))); +} + +/** + * Every route an elevation can unlock. This is the whole set — the second-factor + * management surface and nothing besides. + * + * What is deliberately ABSENT is as much the point as what is present: + * - 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; + * a native client has no 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/route.ts", + "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 routes call the gate", () => { + const callers = filesMatching(/requireMfaManagementAuth\(/).filter( + // The gate's own definition names itself. + (rel) => rel !== "lib/api-handler.ts", + ); + expect(callers).toEqual(ELEVATION_ROUTES); + }); + + it("no admin route calls the gate", () => { + const admin = filesMatching(/requireMfaManagementAuth\(/).filter((rel) => + rel.startsWith("app/api/admin/"), + ); + expect(admin).toEqual([]); + }); + + it("the old cookie-only gates no longer linger on the MFA surface", () => { + // A route that still called `requireCookieAuth` / `requireFreshMfa` + // directly would be outside the single gate, so its posture could drift + // from the rest of the set without tripping the allowlist above. + for (const rel of ELEVATION_ROUTES) { + const src = read(rel); + expect(src).not.toMatch(/await requireCookieAuth\(/); + expect(src).not.toMatch(/await requireFreshMfa\(/); + } + }); +}); + +describe("S2 — redemption happens in exactly one place", () => { + it("only the gate redeems an elevation", () => { + const redeemers = filesMatching(/redeemStepUpElevation\(/).filter( + (rel) => rel !== "lib/auth/step-up.ts", + ); + expect(redeemers).toEqual(["lib/api-handler.ts"]); + }); + + it("only the mint route mints one", () => { + const minters = filesMatching(/mintStepUpElevation\(/).filter( + (rel) => rel !== "lib/auth/step-up.ts", + ); + expect(minters).toEqual(["app/api/auth/step-up/route.ts"]); + }); + + it("no route reads the elevation header for itself", () => { + // The header is the gate's private input. A route reading it directly + // would be hand-rolling an authorisation decision outside the one place + // that is reviewed for it. + const readers = sourceFiles() + .filter((p) => p.endsWith("/route.ts")) + .filter((rel) => /["']x-step-up["']/i.test(read(rel))); + expect(readers).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()"); + // No Bearer resolution, and no elevation redemption, inside the admin gate. + expect(body).not.toMatch(/authenticateBearer|resolveBearerToken/); + expect(body).not.toMatch(/redeemStepUpElevation|STEP_UP_ELEVATION_HEADER/); + }); +}); + +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", + ]) { + const src = read(rel); + expect(src).toMatch(/await requireBearerAuth\(\)/); + expect(src).not.toMatch(/requireAuth\(|requireCookieAuth\(/); + } + }); + + 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("); + }); +}); diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 78d8fe777..2384a7ffd 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -18,6 +18,7 @@ import { mfaWebauthnLoginVerifySchema, } from "@/lib/validations/mfa"; import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native"; +import { stepUpMintSchema } from "@/lib/validations/step-up"; import { dataEnvelope, stdResponses, errorEnvelope } from "./shared"; // ── Sub-schemas owned here (route-specific shapes) ─────────────────── @@ -284,6 +285,36 @@ 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(), + }) + .meta({ id: "StepUpMintResponse" }); + export const authPaths: NonNullable = { "/api/auth/check-user": { post: { @@ -392,12 +423,65 @@ export const authPaths: NonNullable = { }, }, }, + "/api/auth/step-up/options": { + post: { + tags: ["Auth"], + summary: "Begin a passkey re-proof for a step-up elevation (Bearer only)", + description: + "Returns SimpleWebAuthn assertion options scoped to the calling account's primary passkeys, plus a challenge id to present at POST /api/auth/step-up. 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 passkey registered — use the password arm of the mint instead.", + 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 the account password or a primary passkey and receive an opaque elevation that authorises exactly ONE second-factor-management call.\n\n" + + "The elevation is the Bearer transport's equivalent of the web's fresh-second-factor session stamp. It 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.\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 — 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): GET /api/auth/me/mfa; 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}. 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 +502,10 @@ export const authPaths: NonNullable = { "/api/auth/me/mfa/totp/confirm": { post: { tags: ["Auth"], - summary: "Confirm TOTP enrollment (cookie session only)", + summary: "Confirm TOTP enrollment (cookie session or step-up elevation)", description: - "Verifies a code against the pending secret, activates the factor, and returns the one-time recovery codes. Cookie-only.", + "Verifies a code against the pending secret, activates the factor, and returns the one-time recovery codes." + + MFA_MANAGEMENT_AUTH_NOTE, requestBody: { required: true, content: { "application/json": { schema: totpConfirmSchema } }, @@ -451,7 +536,8 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Disable the second factor (step-up gated)", description: - "Requires a fresh second-factor step-up (cookie session) AND a current TOTP or recovery code. Clears the secret and deletes recovery codes. Bearer can never satisfy the step-up gate.", + "Requires a fresh second-factor step-up AND a current TOTP or recovery code. Clears the secret and deletes recovery codes." + + MFA_MANAGEMENT_AUTH_NOTE, requestBody: { required: true, content: { "application/json": { schema: mfaDisableSchema } }, @@ -474,7 +560,8 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Regenerate recovery codes (step-up gated)", description: - "Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated; Bearer can never satisfy the gate.", + "Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated." + + MFA_MANAGEMENT_AUTH_NOTE, responses: { "200": { description: "Fresh recovery codes issued.", @@ -494,9 +581,10 @@ export const authPaths: NonNullable = { "/api/auth/me/mfa": { get: { tags: ["Auth"], - summary: "Second-factor status (cookie session only)", + summary: "Second-factor status (cookie session or step-up elevation)", 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, code, or public key." + + MFA_MANAGEMENT_AUTH_NOTE, responses: { "200": { description: "Second-factor status.", @@ -515,7 +603,8 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Begin registering a security key as a second factor", description: - "Returns SimpleWebAuthn creation options + a challenge id. Cookie-only — a Bearer token cannot enrol MFA.", + "Returns SimpleWebAuthn creation options + a challenge id." + + MFA_MANAGEMENT_AUTH_NOTE, responses: { "200": { description: "Registration options issued.", @@ -537,7 +626,8 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Finish registering a security key as a second factor", description: - "Verifies the attestation against the user-bound challenge and stores the credential in the second-factor store (separate from primary passkeys). Cookie-only.", + "Verifies the attestation against the user-bound challenge and stores the credential in the second-factor store (separate from primary passkeys)." + + MFA_MANAGEMENT_AUTH_NOTE, requestBody: { required: true, content: { @@ -568,6 +658,9 @@ export const authPaths: NonNullable = { patch: { tags: ["Auth"], summary: "Rename a registered security key", + description: + "Rename a registered second-factor security key." + + MFA_MANAGEMENT_AUTH_NOTE, requestParams: { path: z.object({ id: z.string() }) }, requestBody: { required: true, @@ -596,7 +689,7 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Remove a registered security key (step-up gated)", description: - "Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate.", + "Requires a fresh second-factor step-up." + MFA_MANAGEMENT_AUTH_NOTE, requestParams: { path: z.object({ id: z.string() }) }, responses: { "200": { diff --git a/tests/integration/step-up-elevation.test.ts b/tests/integration/step-up-elevation.test.ts new file mode 100644 index 000000000..4bfa41372 --- /dev/null +++ b/tests/integration/step-up-elevation.test.ts @@ -0,0 +1,580 @@ +/** + * Behavioural suite for token-bound step-up elevations, against a real Postgres + * and the real gate — no mocked Prisma, no mocked auth. + * + * This mechanism lets a Bearer token reach a surface that has been cookie-only + * since v1.23, so the cases below are written as attacks rather than as happy + * paths. Each one must fail closed if its protection is removed; a green suite + * over a broken gate would be worse than no suite at all. + * + * Two of them need a real database to mean anything. Single-use atomicity is a + * claim about what Postgres does when two writers race the same row, and the + * unit mocks cannot produce that race — E3b runs the two redemptions genuinely + * concurrently against one connection pool. The token binding is a claim about a + * WHERE clause, and only a real query proves the clause is in it. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-step-up-elevation-32-bytes-minimum-1234567890"; + +const { hashToken } = await import("@/lib/auth/hmac"); +const { hashPassword } = await import("@/lib/auth/password"); + +const USER_ID = "user-step-up-test"; +const OTHER_USER_ID = "user-step-up-other"; +const PASSWORD = "correct horse battery staple 42"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + const passwordHash = await hashPassword(PASSWORD); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "step-up", + email: "step-up@example.test", + timezone: "UTC", + passwordHash, + }, + }); + await getPrismaClient().user.create({ + data: { + id: OTHER_USER_ID, + username: "step-up-other", + email: "step-up-other@example.test", + timezone: "UTC", + passwordHash, + }, + }); +}); + +/** Mint a real wildcard `ApiToken` row. Does NOT arm the header. */ +async function mintToken( + label: string, + userId = USER_ID, +): Promise<{ raw: string; id: string }> { + const raw = `hlk_${label}${"0".repeat(64 - label.length)}`; + const row = await getPrismaClient().apiToken.create({ + data: { + userId, + name: label, + tokenHash: hashToken(raw), + permissions: ["*"], + }, + select: { id: true }, + }); + return { raw, id: row.id }; +} + +function useToken(raw: string): void { + headerJar.set("authorization", `Bearer ${raw}`); +} + +function useElevation(raw: string | null): void { + if (raw === null) headerJar.delete("x-step-up"); + else headerJar.set("x-step-up", raw); +} + +function req(path: string, method = "GET", body?: unknown): NextRequest { + const headers: Record = {}; + const auth = headerJar.get("authorization"); + if (auth) headers.authorization = auth; + const init: RequestInit = { method, headers }; + if (body !== undefined) { + headers["content-type"] = "application/json"; + init.body = JSON.stringify(body); + } + return new NextRequest(`https://health.example${path}`, init as never); +} + +/** Drive the mint endpoint with a password proof. */ +async function mintElevation( + password: string, +): Promise<{ status: number; body: Record }> { + const { POST } = await import("@/app/api/auth/step-up/route"); + const res = await POST( + req("/api/auth/step-up", "POST", { method: "password", password }), + ); + return { status: res.status, body: (await res.json()) as never }; +} + +/** GET /api/auth/me/mfa — the cheapest route in the elevation-accepting set. */ +async function callMfaStatus(): Promise { + const { GET } = await import("@/app/api/auth/me/mfa/route"); + return await GET(req("/api/auth/me/mfa")); +} + +async function auditReasons(action: string): Promise { + const rows = await getPrismaClient().auditLog.findMany({ + where: { action }, + orderBy: { createdAt: "asc" }, + }); + return rows.map( + (r) => (JSON.parse(r.details ?? "{}") as { reason?: string }).reason ?? "", + ); +} + +// ── E1 — a stolen token, on its own, gains nothing ─────────────────── + +describe("E1 — the token alone is not a step-up proof", () => { + it("refuses an MFA-management call with no elevation header", async () => { + const { raw } = await mintToken("e1a"); + useToken(raw); + useElevation(null); + + const res = await callMfaStatus(); + + // 401 with the machine code the client branches on to launch a re-proof — + // not a 200, and not the 403 an unauthorised token would get. + expect(res.status).toBe(401); + }); + + it("mints nothing when the body carries no factor proof", async () => { + const { raw } = await mintToken("e1b"); + useToken(raw); + + const { POST } = await import("@/app/api/auth/step-up/route"); + const res = await POST(req("/api/auth/step-up", "POST", {})); + + expect(res.status).toBe(422); + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); + + it("refuses a fabricated elevation value", async () => { + const { raw } = await mintToken("e1c"); + useToken(raw); + useElevation(`hle_${"f".repeat(64)}`); + + expect((await callMfaStatus()).status).toBe(401); + await new Promise((r) => setTimeout(r, 100)); + expect(await auditReasons("auth.stepup.elevation.rejected")).toContain( + "unknown", + ); + }); +}); + +// ── E2 — the elevation is bound to one token ───────────────────────── + +describe("E2 — an elevation is not portable between tokens", () => { + it("refuses redemption by a second token of the SAME user", async () => { + const a = await mintToken("e2a"); + const b = await mintToken("e2b"); + + useToken(a.raw); + const minted = await mintElevation(PASSWORD); + expect(minted.status).toBe(200); + const elevation = (minted.body.data as { elevation: string }).elevation; + + // Same account, same scopes, different token row. The binding is the only + // thing standing between these two, which is exactly the point. + useToken(b.raw); + useElevation(elevation); + const res = await callMfaStatus(); + + expect(res.status).toBe(401); + await new Promise((r) => setTimeout(r, 100)); + expect(await auditReasons("auth.stepup.elevation.rejected")).toContain( + "wrong_token", + ); + + // And the legitimate holder's elevation survived the attempt. + const row = await getPrismaClient().stepUpElevation.findFirst(); + expect(row?.consumedAt).toBeNull(); + + useToken(a.raw); + useElevation(elevation); + expect((await callMfaStatus()).status).toBe(200); + }); + + it("refuses redemption by another user's token", async () => { + const mine = await mintToken("e2c"); + const theirs = await mintToken("e2d", OTHER_USER_ID); + + useToken(mine.raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + useToken(theirs.raw); + useElevation(elevation); + + expect((await callMfaStatus()).status).toBe(401); + }); +}); + +// ── E3 — single use, including under concurrency ───────────────────── + +describe("E3 — an elevation is consumed exactly once", () => { + it("refuses a second sequential redemption", async () => { + const { raw } = await mintToken("e3a"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + useElevation(elevation); + expect((await callMfaStatus()).status).toBe(200); + expect((await callMfaStatus()).status).toBe(401); + + await new Promise((r) => setTimeout(r, 100)); + expect(await auditReasons("auth.stepup.elevation.rejected")).toContain( + "consumed", + ); + }); + + it("lets exactly one of two CONCURRENT redemptions win", async () => { + const { raw, id } = await mintToken("e3b"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + // Straight at the redemption primitive, so the race is between two real + // conditional UPDATEs on one row rather than between two HTTP handlers that + // happen to interleave. A check-then-update implementation passes the + // sequential case above and fails here. + const { redeemStepUpElevation } = await import("@/lib/auth/step-up"); + const attempt = () => + redeemStepUpElevation({ + rawToken: elevation, + userId: USER_ID, + apiTokenId: id, + }); + + const results = await Promise.all([ + attempt(), + attempt(), + attempt(), + attempt(), + ]); + + expect(results.filter((r) => r.ok)).toHaveLength(1); + expect(results.filter((r) => !r.ok)).toHaveLength(3); + }); +}); + +// ── E4 — expiry ────────────────────────────────────────────────────── + +describe("E4 — an expired elevation is refused", () => { + it("refuses one whose window has passed", async () => { + const { raw, id } = await mintToken("e4a"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + // Age the row rather than the clock: the expiry lives in the redemption's + // WHERE clause, so this is the honest way to exercise it. + await getPrismaClient().stepUpElevation.updateMany({ + where: { apiTokenId: id }, + data: { expiresAt: new Date(Date.now() - 1_000) }, + }); + + useElevation(elevation); + expect((await callMfaStatus()).status).toBe(401); + + await new Promise((r) => setTimeout(r, 100)); + expect(await auditReasons("auth.stepup.elevation.rejected")).toContain( + "expired", + ); + }); + + it("mints a five-minute window, matching the cookie step-up", async () => { + const { raw } = await mintToken("e4b"); + useToken(raw); + const { body } = await mintElevation(PASSWORD); + const data = body.data as { expiresInSeconds: number }; + + const { MFA_STEP_UP_MAX_AGE_SECONDS } = await import("@/lib/api-handler"); + expect(data.expiresInSeconds).toBe(MFA_STEP_UP_MAX_AGE_SECONDS); + }); +}); + +// ── E5 — the elevation cannot reach admin ──────────────────────────── + +describe("E5 — an elevation does not satisfy requireAdmin", () => { + it("refuses an admin route even for an ADMIN user holding a valid elevation", async () => { + await getPrismaClient().user.update({ + where: { id: USER_ID }, + data: { role: "ADMIN" }, + }); + + const { raw } = await mintToken("e5a"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + useElevation(elevation); + + const { GET } = await import("@/app/api/admin/users/route"); + const res = await GET(req("/api/admin/users")); + + // Cookie-only, and the elevation is not a cookie. The account being a + // genuine admin is what makes this test worth having: the refusal is the + // transport, not the role. + expect(res.status).toBe(401); + + // Unconsumed — the admin gate never even looked at it. + const row = await getPrismaClient().stepUpElevation.findFirst(); + expect(row?.consumedAt).toBeNull(); + }); +}); + +// ── E6 — a wrong password mints nothing, is throttled, is audited ──── + +describe("E6 — the mint refuses, throttles, and records a bad password", () => { + it("returns a generic 401 and writes no row", async () => { + const { raw } = await mintToken("e6a"); + useToken(raw); + + const res = await mintElevation("not the password"); + + expect(res.status).toBe(401); + // Same prose for every cause — the response must not say whether the + // password was wrong or the account has none. + expect(res.body.error).toBe("Verification failed"); + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); + + it("is indistinguishable from an account with no password set", async () => { + await getPrismaClient().user.update({ + where: { id: USER_ID }, + data: { passwordHash: null }, + }); + const { raw } = await mintToken("e6b"); + useToken(raw); + + const res = await mintElevation("anything at all"); + + expect(res.status).toBe(401); + expect(res.body.error).toBe("Verification failed"); + }); + + it("audits the failure with the real reason", async () => { + const { raw } = await mintToken("e6c"); + useToken(raw); + await mintElevation("wrong"); + + const rows = await getPrismaClient().auditLog.findMany({ + where: { action: "auth.stepup.mint.failed" }, + }); + expect(rows).toHaveLength(1); + expect(JSON.parse(rows[0].details ?? "{}")).toMatchObject({ + method: "password", + reason: "bad_password", + }); + }); + + it("throttles a guessing run", async () => { + const { raw } = await mintToken("e6d"); + useToken(raw); + + const statuses: number[] = []; + for (let i = 0; i < 7; i++) { + statuses.push((await mintElevation(`guess-${i}`)).status); + } + + // Five attempts per fifteen minutes, then the bucket closes — the same + // ceiling the password-change route uses, for the same Argon2id reason. + expect(statuses.filter((s) => s === 401)).toHaveLength(5); + expect(statuses.filter((s) => s === 429)).toHaveLength(2); + + // And the throttle holds even once the caller finds the right password. + expect((await mintElevation(PASSWORD)).status).toBe(429); + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); +}); + +// ── E7 — the web path is untouched ─────────────────────────────────── + +describe("E7 — the cookie flow is unchanged", () => { + async function useCookieSession(userId = USER_ID): Promise { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session.id; + } + + it("serves an MFA-management read on a plain cookie session", async () => { + await useCookieSession(); + expect((await callMfaStatus()).status).toBe(200); + }); + + it("still refuses a step-up-gated route on a cookie with no fresh factor", async () => { + await useCookieSession(); + const { POST } = + await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route"); + const res = await POST( + req("/api/auth/me/mfa/recovery-codes/regenerate", "POST"), + ); + expect(res.status).toBe(401); + }); + + it("admits a step-up-gated route on a freshly verified cookie session", async () => { + const sessionId = await useCookieSession(); + await getPrismaClient().user.update({ + where: { id: USER_ID }, + data: { totpConfirmedAt: new Date() }, + }); + await getPrismaClient().session.update({ + where: { id: sessionId }, + data: { mfaVerifiedAt: new Date() }, + }); + + const { POST } = + await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route"); + const res = await POST( + req("/api/auth/me/mfa/recovery-codes/regenerate", "POST"), + ); + expect(res.status).toBe(200); + }); + + it("ignores an elevation header when a cookie session is present", async () => { + // The cookie branch returns before the header is ever read. Proven by the + // elevation surviving unconsumed. + const { raw, id } = await mintToken("e7a"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + await useCookieSession(); + useElevation(elevation); + expect((await callMfaStatus()).status).toBe(200); + + const row = await getPrismaClient().stepUpElevation.findFirst({ + where: { apiTokenId: id }, + }); + expect(row?.consumedAt).toBeNull(); + }); +}); + +// ── E8 — invalidation on credential rotation ───────────────────────── + +describe("E8 — an elevation does not outlive its anchor", () => { + it("is dropped when the account password changes", async () => { + const { raw } = await mintToken("e8a"); + useToken(raw); + await mintElevation(PASSWORD); + expect(await getPrismaClient().stepUpElevation.count()).toBe(1); + + const { revokeStepUpElevations } = await import("@/lib/auth/step-up"); + await revokeStepUpElevations(USER_ID); + + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); + + it("is unredeemable once the bound token is revoked", async () => { + const { raw, id } = await mintToken("e8b"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + await getPrismaClient().apiToken.update({ + where: { id }, + data: { revoked: true }, + }); + + useElevation(elevation); + // Refused at Bearer resolution, before the elevation is even looked at — + // which is why no code in the revoke path needs to know about elevations. + expect((await callMfaStatus()).status).toBe(401); + + const row = await getPrismaClient().stepUpElevation.findFirst(); + expect(row?.consumedAt).toBeNull(); + }); + + it("is cascade-deleted with the token row", async () => { + const { raw, id } = await mintToken("e8c"); + useToken(raw); + await mintElevation(PASSWORD); + + await getPrismaClient().apiToken.delete({ where: { id } }); + + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); +}); + +// ── E9 — the happy path actually works end to end ──────────────────── + +describe("E9 — a native client can manage its second factor", () => { + it("mints, redeems, and enrols TOTP", async () => { + const { raw } = await mintToken("e9a"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + useElevation(elevation); + const { POST } = await import("@/app/api/auth/me/mfa/totp/setup/route"); + const res = await POST(req("/api/auth/me/mfa/totp/setup", "POST")); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { totpSecret: string; otpauthUri: string }; + }; + expect(body.data.totpSecret).toBeTruthy(); + expect(body.data.otpauthUri).toMatch(/^otpauth:\/\//); + + // The pending secret landed, and the elevation is spent — a second action + // needs a second proof. + const user = await getPrismaClient().user.findUnique({ + where: { id: USER_ID }, + }); + expect(user?.totpSecretEncrypted).toBeTruthy(); + expect(user?.totpConfirmedAt).toBeNull(); + expect((await callMfaStatus()).status).toBe(401); + }); + + it("refuses the mint surface to a cookie session", async () => { + const session = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + headerJar.delete("authorization"); + + const { POST } = await import("@/app/api/auth/step-up/route"); + const res = await POST( + req("/api/auth/step-up", "POST", { + method: "password", + password: PASSWORD, + }), + ); + + expect(res.status).toBe(401); + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); +}); From ba45a25cb0e54b1c64bcfe7ef22f46958c92d135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 13:14:38 +0200 Subject: [PATCH 3/9] test(auth): retarget the MFA route mocks at the shared gate The three unit suites that stubbed `requireCookieAuth` / `requireFreshMfa` directly now stub `requireMfaManagementAuth`, and the password-change suite stubs the elevation revocation the route gained. Adds the unit checks that need no database: the elevation TTL is pinned to the cookie path's step-up window so the two cannot drift apart, the minted value is a prefixed CSPRNG secret whose plaintext never reaches the row, and a value carrying another credential's prefix is refused without costing a query. --- .../me/mfa/disable/__tests__/route.test.ts | 6 +- .../webauthn/register/__tests__/route.test.ts | 8 +- .../api/auth/password/__tests__/route.test.ts | 6 + src/lib/auth/__tests__/step-up.test.ts | 109 ++++++++++++++++++ tests/integration/step-up-elevation.test.ts | 11 +- 5 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 src/lib/auth/__tests__/step-up.test.ts diff --git a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts index dfd88440c..422ed90d9 100644 --- a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts +++ b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts @@ -7,7 +7,7 @@ vi.mock("@/lib/api-handler", async () => { ); return { ...actual, - requireFreshMfa: vi.fn(), + requireMfaManagementAuth: vi.fn(), }; }); @@ -39,7 +39,7 @@ vi.mock("@/lib/auth/mfa/verify-factor", () => ({ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); import { POST } from "../route"; -import { requireFreshMfa } from "@/lib/api-handler"; +import { requireMfaManagementAuth } from "@/lib/api-handler"; import { prisma } from "@/lib/db"; import { destroyOtherSessions } from "@/lib/auth/session"; import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; @@ -54,7 +54,7 @@ function req() { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(requireFreshMfa).mockResolvedValue({ + vi.mocked(requireMfaManagementAuth).mockResolvedValue({ user: { id: "u1" }, session: { id: "sess-current" }, } as never); diff --git a/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts index 37551d36d..30655630f 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts @@ -8,7 +8,7 @@ vi.mock("@/lib/auth/mfa-enrollment", () => ({ vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn, - requireCookieAuth: vi.fn(), + requireMfaManagementAuth: vi.fn(), })); vi.mock("@/lib/db", () => ({ @@ -33,7 +33,7 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); import { POST as REGISTER_OPTIONS } from "../options/route"; import { POST as REGISTER_VERIFY } from "../verify/route"; -import { requireCookieAuth } from "@/lib/api-handler"; +import { requireMfaManagementAuth } from "@/lib/api-handler"; import { prisma } from "@/lib/db"; import { checkRateLimit } from "@/lib/rate-limit"; import { @@ -56,7 +56,9 @@ function verifyRequest(body: Record): NextRequest { beforeEach(() => { vi.resetAllMocks(); - vi.mocked(requireCookieAuth).mockResolvedValue({ user: USER } as never); + vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + user: USER, + } as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); }); diff --git a/src/app/api/auth/password/__tests__/route.test.ts b/src/app/api/auth/password/__tests__/route.test.ts index f5fe53763..e13ecff29 100644 --- a/src/app/api/auth/password/__tests__/route.test.ts +++ b/src/app/api/auth/password/__tests__/route.test.ts @@ -24,6 +24,12 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.30.34 — the route drops the account's step-up elevations alongside its +// sessions, so the rotation cannot leave a proof of the old credential standing. +vi.mock("@/lib/auth/step-up", () => ({ + revokeStepUpElevations: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(), destroyAllSessions: vi.fn().mockResolvedValue(undefined), diff --git a/src/lib/auth/__tests__/step-up.test.ts b/src/lib/auth/__tests__/step-up.test.ts new file mode 100644 index 000000000..70a46d2b0 --- /dev/null +++ b/src/lib/auth/__tests__/step-up.test.ts @@ -0,0 +1,109 @@ +/** + * Unit-level checks on the elevation primitive that need no database. + * + * The behavioural work — binding, single use, expiry, the concurrent race — + * 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 pair of + * constants that must not drift apart, and the shape of the value the mint + * hands out. + */ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + stepUpElevation: { + create: vi.fn().mockResolvedValue({ id: "elevation-1" }), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + findUnique: vi.fn().mockResolvedValue(null), + }, + }, +})); + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-step-up-unit-32-bytes-minimum-abcdefghijklmnop"; + +const { + mintStepUpElevation, + redeemStepUpElevation, + STEP_UP_ELEVATION_TTL_SECONDS, +} = await import("@/lib/auth/step-up"); +const { MFA_STEP_UP_MAX_AGE_SECONDS } = await import("@/lib/api-handler"); + +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, and the plaintext never reaches the row", async () => { + const { prisma } = await import("@/lib/db"); + const { token } = await mintStepUpElevation({ + userId: "u1", + apiTokenId: "t1", + method: "password", + }); + + expect(token).toMatch(/^hle_[0-9a-f]{64}$/); + + const created = vi.mocked(prisma.stepUpElevation.create).mock.calls[0][0]; + const stored = (created.data as { tokenHash: string }).tokenHash; + expect(stored).not.toContain(token); + expect(stored).toMatch(/^[0-9a-f]{64}$/); + }); + + it("differs on every mint", async () => { + const a = await mintStepUpElevation({ + userId: "u1", + apiTokenId: "t1", + method: "password", + }); + const b = await mintStepUpElevation({ + userId: "u1", + apiTokenId: "t1", + method: "password", + }); + expect(a.token).not.toBe(b.token); + }); +}); + +describe("redemption rejects a wrong-shaped value before touching the database", () => { + it("refuses a value carrying another credential's prefix", async () => { + const { prisma } = await import("@/lib/db"); + vi.mocked(prisma.stepUpElevation.updateMany).mockClear(); + + // `hls_` is the session cookie secret and `hlk_` an access token. Neither + // is an elevation, and neither should cost a query to find that out. + for (const value of [ + `hls_${"a".repeat(64)}`, + `hlk_${"a".repeat(64)}`, + "", + ]) { + const result = await redeemStepUpElevation({ + rawToken: value, + userId: "u1", + apiTokenId: "t1", + }); + expect(result).toEqual({ ok: false, reason: "malformed" }); + } + + expect(prisma.stepUpElevation.updateMany).not.toHaveBeenCalled(); + }); +}); + +describe("the elevation header name is stable", () => { + it("is the lowercase form the gate reads", async () => { + const { STEP_UP_ELEVATION_HEADER } = await import("@/lib/api-handler"); + // Header lookups in the gate go through `headers().get()`, which is + // case-insensitive; the constant is lowercase so a direct Map-backed test + // jar matches it too. + expect(STEP_UP_ELEVATION_HEADER).toBe("x-step-up"); + expect(STEP_UP_ELEVATION_HEADER).toBe( + STEP_UP_ELEVATION_HEADER.toLowerCase(), + ); + }); +}); diff --git a/tests/integration/step-up-elevation.test.ts b/tests/integration/step-up-elevation.test.ts index 4bfa41372..f85eacd37 100644 --- a/tests/integration/step-up-elevation.test.ts +++ b/tests/integration/step-up-elevation.test.ts @@ -129,10 +129,17 @@ async function mintElevation( return { status: res.status, body: (await res.json()) as never }; } +/** + * Some handlers in the set declare no request parameter (`apiHandler(async () => + * …)`), so their wrapped type takes none. The runtime always passes one; this + * keeps the call sites honest without loosening the handlers themselves. + */ +type RouteFn = (request: NextRequest) => Promise; + /** GET /api/auth/me/mfa — the cheapest route in the elevation-accepting set. */ async function callMfaStatus(): Promise { const { GET } = await import("@/app/api/auth/me/mfa/route"); - return await GET(req("/api/auth/me/mfa")); + return await (GET as unknown as RouteFn)(req("/api/auth/me/mfa")); } async function auditReasons(action: string): Promise { @@ -338,7 +345,7 @@ describe("E5 — an elevation does not satisfy requireAdmin", () => { useElevation(elevation); const { GET } = await import("@/app/api/admin/users/route"); - const res = await GET(req("/api/admin/users")); + const res = await (GET as unknown as RouteFn)(req("/api/admin/users")); // Cookie-only, and the elevation is not a cookie. The account being a // genuine admin is what makes this test worth having: the refusal is the From 0991d754e4867581c83a2d446775e5829fd029b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 13:48:46 +0200 Subject: [PATCH 4/9] fix(auth): make the re-proved factor decide what an elevation reaches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A step-up elevation minted from the account password satisfied the fresh-factor routes. That is a second-factor bypass: a stolen token plus a known password could rotate the recovery codes — which take no factor in the body — and then spend one of them to disable the second factor outright. The web has never allowed that chain, because a password login does not stamp mfaVerifiedAt. The method is now an authorisation input. Only totp, webauthn, and passkey satisfy a fresh-factor gate, which is exactly the set of ceremonies the web stamps a session for; password reaches what a plain cookie session reaches and stops. The mint gains TOTP and security-key arms so a TOTP-only account can still manage its factor natively, and the response says which reach it bought. The gate also returns a discriminated context. session.id meant a session row on one arm and a token row on the other, and the disable route passed the token id to a session-scoped query — excluding nothing, so it deleted every browser session and revoked the caller's own device login. The transport is now in the type and the compiler refuses the confusion. Redemption splits into validate-then-commit so a 429, a 422, or a wrong code no longer burns a proof against a five-per-fifteen-minute mint ceiling. The claim is UPDATE ... RETURNING, so the statement that decides is the one that reports. The mint is transactional behind a partial unique index. Sign-out-everywhere and both password-reset paths clear elevations. The status read drops to plain requireAuth — it carries no credential material — and every mutation stays gated. --- .../migration.sql | 8 + prisma/schema.prisma | 7 + src/__tests__/step-up-elevation-guard.test.ts | 218 +++++-- src/app/api/auth/me/mfa/disable/route.ts | 17 +- .../me/mfa/recovery-codes/regenerate/route.ts | 5 +- src/app/api/auth/me/mfa/route.ts | 17 +- src/app/api/auth/me/mfa/totp/confirm/route.ts | 20 +- src/app/api/auth/me/mfa/totp/setup/route.ts | 6 +- .../api/auth/me/mfa/webauthn/[id]/route.ts | 12 +- .../me/mfa/webauthn/register/options/route.ts | 5 +- .../me/mfa/webauthn/register/verify/route.ts | 7 +- src/app/api/auth/me/sessions/route.ts | 5 +- src/app/api/auth/step-up/options/route.ts | 44 +- src/app/api/auth/step-up/route.ts | 72 ++- src/lib/api-handler.ts | 160 +++-- src/lib/auth/__tests__/step-up.test.ts | 178 ++++-- src/lib/auth/session.ts | 37 +- src/lib/auth/step-up.ts | 329 +++++++---- src/lib/idempotency.ts | 2 +- src/lib/jobs/reminder/cleanup-handlers.ts | 30 + src/lib/jobs/reminder/register-maintenance.ts | 15 + src/lib/validations/step-up.ts | 45 +- .../sessions-and-known-devices.test.ts | 5 +- tests/integration/step-up-elevation.test.ts | 557 +++++++++++++++++- 24 files changed, 1496 insertions(+), 305 deletions(-) rename prisma/migrations/{0256_step_up_elevation => 0260_step_up_elevation}/migration.sql (78%) diff --git a/prisma/migrations/0256_step_up_elevation/migration.sql b/prisma/migrations/0260_step_up_elevation/migration.sql similarity index 78% rename from prisma/migrations/0256_step_up_elevation/migration.sql rename to prisma/migrations/0260_step_up_elevation/migration.sql index c16c4f3da..bf827994a 100644 --- a/prisma/migrations/0256_step_up_elevation/migration.sql +++ b/prisma/migrations/0260_step_up_elevation/migration.sql @@ -19,6 +19,14 @@ CREATE TABLE "step_up_elevations" ( ); 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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9a645a2c4..a43118859 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3159,6 +3159,13 @@ model StepUpElevation { @@index([userId]) @@index([apiTokenId]) @@index([expiresAt]) + // NOTE: migration 0260 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") } diff --git a/src/__tests__/step-up-elevation-guard.test.ts b/src/__tests__/step-up-elevation-guard.test.ts index 89c4a71d1..7b0581c8f 100644 --- a/src/__tests__/step-up-elevation-guard.test.ts +++ b/src/__tests__/step-up-elevation-guard.test.ts @@ -7,10 +7,20 @@ * unable to grow by accident. Behavioural tests prove the mechanism works; * these prove nobody quietly pointed it somewhere else. * - * Like the Bearer-scope guards next door, these are tripwires rather than - * proofs. They cannot show the allowlist is correct — only that it has not - * changed without someone editing this file, which is where the reviewer's - * attention is supposed to land. + * 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"; @@ -34,19 +44,65 @@ function read(rel: string): string { return readFileSync(join(SRC, rel), "utf8"); } -function filesMatching(re: RegExp): string[] { - return sourceFiles().filter((rel) => re.test(read(rel))); +/** 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 surface and nothing besides. + * 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; - * a native client has no equivalent to manage; + * - 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 @@ -54,7 +110,6 @@ function filesMatching(re: RegExp): string[] { * exists to prevent. */ const ELEVATION_ROUTES = [ - "app/api/auth/me/mfa/route.ts", "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", @@ -65,56 +120,98 @@ const ELEVATION_ROUTES = [ ].sort(); describe("S1 — the elevation-accepting route set is frozen", () => { - it("only the known MFA-management routes call the gate", () => { - const callers = filesMatching(/requireMfaManagementAuth\(/).filter( - // The gate's own definition names itself. + it("only the known MFA-management mutations call the gate", () => { + const found = callers("requireMfaManagementAuth", API_HANDLER).filter( (rel) => rel !== "lib/api-handler.ts", ); - expect(callers).toEqual(ELEVATION_ROUTES); + 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 = filesMatching(/requireMfaManagementAuth\(/).filter((rel) => - rel.startsWith("app/api/admin/"), + 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", () => { - // A route that still called `requireCookieAuth` / `requireFreshMfa` - // directly would be outside the single gate, so its posture could drift - // from the rest of the set without tripping the allowlist above. for (const rel of ELEVATION_ROUTES) { - const src = read(rel); - expect(src).not.toMatch(/await requireCookieAuth\(/); - expect(src).not.toMatch(/await requireFreshMfa\(/); + 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 — redemption happens in exactly one place", () => { - it("only the gate redeems an elevation", () => { - const redeemers = filesMatching(/redeemStepUpElevation\(/).filter( +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(redeemers).toEqual(["lib/api-handler.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 minters = filesMatching(/mintStepUpElevation\(/).filter( + const found = callers("mintStepUpElevation", STEP_UP_MODULE).filter( (rel) => rel !== "lib/auth/step-up.ts", ); - expect(minters).toEqual(["app/api/auth/step-up/route.ts"]); + expect(found).toEqual(["app/api/auth/step-up/route.ts"]); }); - it("no route reads the elevation header for itself", () => { - // The header is the gate's private input. A route reading it directly - // would be hand-rolling an authorisation decision outside the one place - // that is reviewed for it. - const readers = sourceFiles() - .filter((p) => p.endsWith("/route.ts")) - .filter((rel) => /["']x-step-up["']/i.test(read(rel))); - expect(readers).toEqual([]); + 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([]); }); }); @@ -126,9 +223,10 @@ describe("S3 — requireAdmin stays cookie-only", () => { src.indexOf("export async function requireCookieAuth("), ); expect(body).toContain("await getSession()"); - // No Bearer resolution, and no elevation redemption, inside the admin gate. expect(body).not.toMatch(/authenticateBearer|resolveBearerToken/); - expect(body).not.toMatch(/redeemStepUpElevation|STEP_UP_ELEVATION_HEADER/); + expect(body).not.toMatch( + /claimStepUpElevation|validateStepUpElevation|STEP_UP_ELEVATION_HEADER|requireMfaManagementAuth/, + ); }); }); @@ -138,9 +236,11 @@ describe("S4 — the mint surface refuses a cookie", () => { "app/api/auth/step-up/route.ts", "app/api/auth/step-up/options/route.ts", ]) { - const src = read(rel); - expect(src).toMatch(/await requireBearerAuth\(\)/); - expect(src).not.toMatch(/requireAuth\(|requireCookieAuth\(/); + expect(importedAs(rel, "requireBearerAuth", API_HANDLER)).toEqual([ + "requireBearerAuth", + ]); + expect(importedAs(rel, "requireAuth", API_HANDLER)).toEqual([]); + expect(importedAs(rel, "requireCookieAuth", API_HANDLER)).toEqual([]); } }); @@ -153,3 +253,39 @@ describe("S4 — the mint surface refuses a cookie", () => { expect(body).not.toContain("getSession("); }); }); + +describe("S5 — a password proof cannot satisfy a fresh-factor gate", () => { + it("the fresh-factor set excludes password", async () => { + // The B1 invariant, pinned at the definition. The web stamps + // `mfaVerifiedAt` only for a completed second factor or a primary passkey + // login; the Bearer arm must not be looser. + const { FRESH_FACTOR_METHODS, isFreshFactorMethod } = + await import("@/lib/auth/step-up"); + expect([...FRESH_FACTOR_METHODS].sort()).toEqual([ + "passkey", + "totp", + "webauthn", + ]); + expect(isFreshFactorMethod("password")).toBe(false); + }); + + it("the destructive routes all request the fresh-factor arm", () => { + // Miss this on one route and a password-proved elevation tears down the + // second factor there. + for (const rel of [ + "app/api/auth/me/mfa/disable/route.ts", + "app/api/auth/me/mfa/recovery-codes/regenerate/route.ts", + "app/api/auth/me/mfa/webauthn/[id]/route.ts", + ]) { + expect(read(rel)).toMatch(/freshFactor:\s*true/); + } + }); + + it("the claim re-checks the factor rather than trusting the validation", () => { + const src = read("lib/auth/step-up.ts"); + const claim = src.slice( + src.indexOf("export async function claimStepUpElevation("), + ); + expect(claim).toMatch(/requireFreshFactor\s*&&\s*!isFreshFactorMethod/); + }); +}); diff --git a/src/app/api/auth/me/mfa/disable/route.ts b/src/app/api/auth/me/mfa/disable/route.ts index 144eb4c1b..50d22d12a 100644 --- a/src/app/api/auth/me/mfa/disable/route.ts +++ b/src/app/api/auth/me/mfa/disable/route.ts @@ -38,9 +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 requireMfaManagementAuth({ - freshFactor: true, - }); + const auth = await requireMfaManagementAuth({ freshFactor: true }); + const { user } = auth; const rl = await checkRateLimit( `mfa:disable:${user.id}`, @@ -85,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 }, @@ -102,7 +106,12 @@ export const POST = apiHandler(async (req: Request) => { // every "remember this device" trusted device (all handled by // destroyOtherSessions), keeping only the caller's current session. A stale // session or a trusted-device cookie must not survive the factor's removal. - await destroyOtherSessions(user.id, session.id); + await destroyOtherSessions( + user.id, + auth.transport === "cookie" + ? { kind: "session", sessionId: auth.session.id } + : { kind: "accessToken", accessTokenHash: auth.accessTokenHash }, + ); await auditLog("auth.mfa.disabled", { userId: user.id, diff --git a/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts b/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts index e6c97c3d5..341515dc8 100644 --- a/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts +++ b/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts @@ -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 requireMfaManagementAuth({ freshFactor: true }); + const auth = await requireMfaManagementAuth({ freshFactor: true }); + const { user } = auth; // Defence in depth — regeneration only makes sense for an active factor. if (!user.totpConfirmedAt) { @@ -44,6 +45,8 @@ export const POST = apiHandler(async (req: Request) => { return res; } + await auth.commitElevation(); + const recoveryCodes = await regenerateRecoveryCodes(user.id); await auditLog("auth.mfa.recovery_regenerated", { diff --git a/src/app/api/auth/me/mfa/route.ts b/src/app/api/auth/me/mfa/route.ts index df6c775f4..d283a10d8 100644 --- a/src/app/api/auth/me/mfa/route.ts +++ b/src/app/api/auth/me/mfa/route.ts @@ -3,12 +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. - * Gated by `requireMfaManagementAuth`: a cookie session, or a Bearer token - * presenting a single-use step-up elevation. A token alone is not enough. + * 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, requireMfaManagementAuth } 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"; @@ -17,7 +26,7 @@ import { countRemainingRecoveryCodes } from "@/lib/auth/mfa/recovery-codes"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireMfaManagementAuth(); + const { user } = await requireAuth(); const [recoveryCodesRemaining, webauthn] = await Promise.all([ countRemainingRecoveryCodes(user.id), diff --git a/src/app/api/auth/me/mfa/totp/confirm/route.ts b/src/app/api/auth/me/mfa/totp/confirm/route.ts index 8bad88724..8e701bf04 100644 --- a/src/app/api/auth/me/mfa/totp/confirm/route.ts +++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts @@ -46,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 requireMfaManagementAuth(); + const auth = await requireMfaManagementAuth(); + const { user } = auth; const rl = await checkRateLimit( `mfa:confirm:${user.id}`, @@ -103,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 }, @@ -115,11 +120,14 @@ export const POST = apiHandler(async (req: Request) => { await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } }); await persistRecoveryCodes(tx, user.id, recoveryCodes); // Stamp the completing session so the user can immediately manage the - // factor under step-up without re-verifying. - await tx.session.updateMany({ - where: { id: session.id }, - data: { mfaVerifiedAt: new Date() }, - }); + // factor under step-up without re-verifying. Cookie transport only — a + // Bearer caller has no session row, and mints a fresh elevation per action. + if (auth.transport === "cookie") { + await tx.session.updateMany({ + where: { id: auth.session.id }, + data: { mfaVerifiedAt: new Date() }, + }); + } }); // v1.23 — the account now has an active second factor, so any diff --git a/src/app/api/auth/me/mfa/totp/setup/route.ts b/src/app/api/auth/me/mfa/totp/setup/route.ts index 662aa2206..a9ef6b5ed 100644 --- a/src/app/api/auth/me/mfa/totp/setup/route.ts +++ b/src/app/api/auth/me/mfa/totp/setup/route.ts @@ -33,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 requireMfaManagementAuth(); + const auth = await requireMfaManagementAuth(); + const { user } = auth; const rl = await checkRateLimit( `mfa:setup:${user.id}`, @@ -56,6 +57,9 @@ export const POST = apiHandler(async (req: Request) => { throw new HttpError(409, "A second factor is already active"); } + // Rate limit and the already-active check have passed; the write is next. + await auth.commitElevation(); + const secret = generateTotpSecret(); const account = user.email ?? user.username; const otpauthUri = buildOtpauthUri(secret, account); diff --git a/src/app/api/auth/me/mfa/webauthn/[id]/route.ts b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts index 6aab56136..61af3c671 100644 --- a/src/app/api/auth/me/mfa/webauthn/[id]/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts @@ -28,7 +28,8 @@ export const PATCH = apiHandler( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) => { - const { user } = await requireMfaManagementAuth(); + const auth = await requireMfaManagementAuth(); + const { user } = auth; const { id } = await params; const { data: body, error: jsonError } = await safeJson(request, { @@ -49,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 }, @@ -67,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 requireMfaManagementAuth({ freshFactor: true }); + const auth = await requireMfaManagementAuth({ freshFactor: true }); + const { user } = auth; const { id } = await params; const existing = await prisma.webauthnMfaCredential.findUnique({ @@ -78,6 +82,10 @@ export const DELETE = apiHandler( return apiError("Security key not found", 404); } + // Ownership resolved and the row is about to go — spend the elevation now, + // so a 404 for someone else's key id does not burn it. + await auth.commitElevation(); + await prisma.webauthnMfaCredential.delete({ where: { id } }); await auditLog("auth.mfa.webauthn.remove", { diff --git a/src/app/api/auth/me/mfa/webauthn/register/options/route.ts b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts index 109acbbf3..a472d8d55 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/options/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts @@ -15,7 +15,8 @@ import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn"; export const dynamic = "force-dynamic"; export const POST = apiHandler(async () => { - const { user } = await requireMfaManagementAuth(); + const auth = await requireMfaManagementAuth(); + const { user } = auth; const rl = await checkRateLimit( `mfa:webauthn:register:${user.id}`, @@ -30,6 +31,8 @@ export const POST = apiHandler(async () => { return res; } + await auth.commitElevation(); + const { options, challengeId } = await createMfaRegistrationOptions( user.id, user.email ?? user.username, diff --git a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts index b3b651ca3..371e91174 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts @@ -24,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 requireMfaManagementAuth(); + const auth = await requireMfaManagementAuth(); + const { user } = auth; const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, @@ -46,6 +47,10 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Security key verification failed", 400); } + // The attestation verified and the credential is about to be stored — a + // failed ceremony above must not have cost the caller their elevation. + await auth.commitElevation(); + const { registrationInfo } = verification; const created = await prisma.webauthnMfaCredential.create({ data: { diff --git a/src/app/api/auth/me/sessions/route.ts b/src/app/api/auth/me/sessions/route.ts index 440f4d44a..24f41a095 100644 --- a/src/app/api/auth/me/sessions/route.ts +++ b/src/app/api/auth/me/sessions/route.ts @@ -88,7 +88,10 @@ export const GET = apiHandler(async () => { export const DELETE = apiHandler(async (request: NextRequest) => { const { user, session } = await requireAuth(); - const { sessionsRevoked } = await destroyOtherSessions(user.id, session.id); + const { sessionsRevoked } = await destroyOtherSessions(user.id, { + kind: "session", + sessionId: session.id, + }); await auditLog("auth.session.revoke_others", { userId: user.id, diff --git a/src/app/api/auth/step-up/options/route.ts b/src/app/api/auth/step-up/options/route.ts index f9caae99d..30392af0c 100644 --- a/src/app/api/auth/step-up/options/route.ts +++ b/src/app/api/auth/step-up/options/route.ts @@ -1,23 +1,30 @@ /** * POST /api/auth/step-up/options * - * Begin a primary-passkey assertion for the step-up mint below. Returns - * SimpleWebAuthn assertion options scoped to the calling account's registered - * passkeys, plus the server-issued challenge id to present back at - * `POST /api/auth/step-up`. + * 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 passkey gets a - * 409 telling the client to use the password arm instead. + * 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 } from "@/lib/api-response"; +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"; @@ -36,6 +43,29 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } + 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 }, }); diff --git a/src/app/api/auth/step-up/route.ts b/src/app/api/auth/step-up/route.ts index ec1e6e228..7744cc320 100644 --- a/src/app/api/auth/step-up/route.ts +++ b/src/app/api/auth/step-up/route.ts @@ -4,6 +4,15 @@ * 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 @@ -37,8 +46,11 @@ import { } 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"; @@ -103,32 +115,62 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Invalid request", 422); } + const method: StepUpMethod = parsed.data.method; let proved = false; - let method: StepUpMethod = parsed.data.method; // Audit-only detail. Never reaches the response — see the file header. let failure = "invalid"; + /** + * Resolve a WebAuthn challenge and confirm it belongs to THIS account before + * any verification runs. Both ceremony helpers resolve a challenge by id + * alone, so without this a challenge minted for another account could be + * carried into the ceremony. + */ + const challengeBelongsToCaller = async (id: string): Promise => { + const row = await prisma.authChallenge.findUnique({ + where: { id }, + select: { userId: true }, + }); + return Boolean(row && row.userId === user.id); + }; + if (parsed.data.method === "password") { if (!user.passwordHash) { // An SSO-provisioned account has no password to re-prove. Same refusal as // a wrong one: the account's credential shape is not a token holder's to // enumerate. `/api/auth/step-up/options` is the honest discovery path — - // it 409s when there is no passkey either. + // 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 { - method = "passkey"; - // Bind the challenge to the caller BEFORE verifying. `verifyAuthentication` - // resolves a challenge by id alone; pinning ownership here means a challenge - // minted for another account cannot be carried into this ceremony. - const challenge = await prisma.authChallenge.findUnique({ - where: { id: parsed.data.challengeId }, - select: { userId: true }, - }); - if (!challenge || challenge.userId !== user.id) { + if (!(await challengeBelongsToCaller(parsed.data.challengeId))) { failure = "foreign_challenge"; } else { try { @@ -142,7 +184,6 @@ export const POST = apiHandler(async (request: NextRequest) => { result.verification.verified && result.passkey.userId === user.id; if (!proved) failure = "bad_assertion"; } catch { - // A malformed / expired challenge is a failed attempt, not a 500. failure = "bad_assertion"; } } @@ -184,5 +225,12 @@ export const POST = apiHandler(async (request: NextRequest) => { elevation: token, expiresAt: expiresAt.toISOString(), expiresInSeconds: STEP_UP_ELEVATION_TTL_SECONDS, + method, + // Whether this elevation reaches the fresh-factor routes (disable, + // recovery-code rotation, security-key removal). A password-proved + // elevation does not, exactly as a password login does not on the web. + // Surfaced so the client can pick the right ceremony up front instead of + // discovering the refusal after spending a proof. + satisfiesFreshFactor: isFreshFactorMethod(method), }); }); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index df6d0bdd7..96b32e4a9 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -14,9 +14,11 @@ import { type ScopeRequirement, } from "./auth/bearer"; import { - redeemStepUpElevation, + 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"; @@ -624,6 +626,27 @@ 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. * @@ -639,7 +662,7 @@ export async function requireFreshMfaIfEnrolled( * 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 { +export async function requireBearerAuth(): Promise { let authHeader: string | null = null; try { const headerList = await headers(); @@ -650,7 +673,13 @@ export async function requireBearerAuth(): Promise { if (!authHeader?.startsWith("Bearer ")) { throw new HttpError(401, "Not authenticated"); } - return await authenticateBearer(authHeader.slice(7), undefined); + const raw = authHeader.slice(7); + const auth = await authenticateBearer(raw, undefined); + return { + ...auth, + apiTokenId: auth.session.id, + accessTokenHash: hashToken(raw), + }; } /** @@ -660,10 +689,40 @@ export async function requireBearerAuth(): Promise { */ export const STEP_UP_ELEVATION_HEADER = "x-step-up"; +/** + * What an MFA-management route received, with the transport made explicit. + * + * The two arms carry DIFFERENT fields on purpose. A cookie caller has a session + * row and a Bearer caller does not, so `session` exists only on the cookie arm + * and the compiler refuses to read it on the other. This is the fix for a real + * defect: the MFA-disable route passed `session.id` to `destroyOtherSessions`, + * which on the Bearer path was an ApiToken id — matching no session row, so the + * "keep the current one" exclusion excluded nothing and the caller revoked its + * own device login. A comment would not have caught that; a type does. + */ +export type MfaManagementContext = { + user: User; + /** + * Spend the elevation. Call it immediately BEFORE the mutation, once every + * cheap validation has passed — a 429, a 422, or a wrong factor code must not + * burn a proof the user then has to mint again against a 5-per-15-minute + * ceiling. + * + * A no-op on the cookie arm (the session stamp is not consumable). On the + * Bearer arm it is the atomic single-use claim, and it THROWS + * `StepUpRequiredError` if the claim is lost — so a concurrent redemption + * still yields exactly one winner even though validation happened earlier. + */ + commitElevation: () => Promise; +} & ( + | { transport: "cookie"; session: { id: string; expiresAt: Date } } + | { transport: "bearer"; apiTokenId: string; accessTokenHash: string } +); + /** * v1.30.34 — the single gate for second-factor management. * - * Every MFA-management route goes through here and nothing else does. The set + * 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. @@ -677,35 +736,48 @@ export const STEP_UP_ELEVATION_HEADER = "x-step-up"; * all. * * BEARER + ELEVATION — a token that resolves cleanly AND presents a valid, - * unconsumed elevation minted for that same token against a re-proved factor. - * The token alone is never enough: with no header, or a header that does not - * redeem, the caller gets the same `auth.stepup.required` a stale cookie - * session gets. + * 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`: the - * account must actually have a second factor enrolled. Set by the destructive - * routes (disable, recovery-code rotation, security-key removal). + * @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 { +): Promise { + const freshFactor = options.freshFactor === true; + // Cookie first, and via the original helpers — the web path runs the same // code it always did. const sessionData = await getSession(); if (sessionData) { - return options.freshFactor + 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(); - const apiTokenId = auth.session.id; let raw: string | null = null; try { @@ -718,36 +790,43 @@ export async function requireMfaManagementAuth( if (!raw) { annotate({ action: { name: "auth.stepup.elevation.missing" }, - meta: { maxAgeSeconds: STEP_UP_ELEVATION_TTL_SECONDS }, + meta: { maxAgeSeconds: STEP_UP_ELEVATION_TTL_SECONDS, freshFactor }, }); throw new StepUpRequiredError(); } - const redeemed = await redeemStepUpElevation({ - rawToken: raw, - userId: auth.user.id, - apiTokenId, - }); - - if (!redeemed.ok) { + 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, or minted for a different token. + // 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: redeemed.reason }, + details: { reason, freshFactor }, }).catch(() => {}); annotate({ action: { name: "auth.stepup.elevation.rejected" }, - meta: { reason: redeemed.reason }, + meta: { reason, freshFactor }, }); - throw new StepUpRequiredError(); - } + // 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 (options.freshFactor) { + if (freshFactor) { let enrolled = Boolean(auth.user.totpConfirmedAt); if (!enrolled) { const keys = await prisma.webauthnMfaCredential.count({ @@ -760,12 +839,25 @@ export async function requireMfaManagementAuth( } } - annotate({ - action: { name: "auth.stepup.elevation.accepted" }, - meta: { method: redeemed.method }, - }); - - return auth; + 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 }, + }); + }, + }; } /** diff --git a/src/lib/auth/__tests__/step-up.test.ts b/src/lib/auth/__tests__/step-up.test.ts index 70a46d2b0..f37365ddb 100644 --- a/src/lib/auth/__tests__/step-up.test.ts +++ b/src/lib/auth/__tests__/step-up.test.ts @@ -1,23 +1,33 @@ /** * Unit-level checks on the elevation primitive that need no database. * - * The behavioural work — binding, single use, expiry, the concurrent race — - * 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 pair of - * constants that must not drift apart, and the shape of the value the mint - * hands out. + * 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 } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { readFileSync } from "node:fs"; + +const createMock = vi.fn().mockResolvedValue({ id: "elevation-1" }); vi.mock("@/lib/db", () => ({ prisma: { + $transaction: vi.fn(async (fn: (tx: unknown) => Promise) => + fn({ + stepUpElevation: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + create: createMock, + }, + }), + ), stepUpElevation: { - create: vi.fn().mockResolvedValue({ id: "elevation-1" }), deleteMany: vi.fn().mockResolvedValue({ count: 0 }), - updateMany: vi.fn().mockResolvedValue({ count: 0 }), findUnique: vi.fn().mockResolvedValue(null), }, + $queryRaw: vi.fn().mockResolvedValue([]), }, })); @@ -26,10 +36,32 @@ process.env.API_TOKEN_HMAC_KEY ??= const { mintStepUpElevation, - redeemStepUpElevation, + claimStepUpElevation, + validateStepUpElevation, + isFreshFactorMethod, + FRESH_FACTOR_METHODS, STEP_UP_ELEVATION_TTL_SECONDS, } = await import("@/lib/auth/step-up"); -const { MFA_STEP_UP_MAX_AGE_SECONDS } = await import("@/lib/api-handler"); +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", () => { @@ -40,70 +72,114 @@ describe("the two step-up windows stay pinned together", () => { }); describe("the minted value", () => { - it("is a prefixed 32-byte CSPRNG secret, and the plaintext never reaches the row", async () => { - const { prisma } = await import("@/lib/db"); - const { token } = await mintStepUpElevation({ - userId: "u1", - apiTokenId: "t1", - method: "password", - }); - + it("is a prefixed 32-byte CSPRNG secret", async () => { + const { token } = await mint(); expect(token).toMatch(/^hle_[0-9a-f]{64}$/); + }); - const created = vi.mocked(prisma.stepUpElevation.create).mock.calls[0][0]; - const stored = (created.data as { tokenHash: string }).tokenHash; - expect(stored).not.toContain(token); - expect(stored).toMatch(/^[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 mintStepUpElevation({ - userId: "u1", - apiTokenId: "t1", - method: "password", - }); - const b = await mintStepUpElevation({ - userId: "u1", - apiTokenId: "t1", - method: "password", - }); + const a = await mint(); + const b = await mint(); expect(a.token).not.toBe(b.token); }); }); -describe("redemption rejects a wrong-shaped value before touching the database", () => { - it("refuses a value carrying another credential's prefix", async () => { - const { prisma } = await import("@/lib/db"); - vi.mocked(prisma.stepUpElevation.updateMany).mockClear(); +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(); - // `hls_` is the session cookie secret and `hlk_` an access token. Neither - // is an elevation, and neither should cost a query to find that out. - for (const value of [ - `hls_${"a".repeat(64)}`, - `hlk_${"a".repeat(64)}`, - "", - ]) { - const result = await redeemStepUpElevation({ + 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.stepUpElevation.updateMany).not.toHaveBeenCalled(); + 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", async () => { - const { STEP_UP_ELEVATION_HEADER } = await import("@/lib/api-handler"); - // Header lookups in the gate go through `headers().get()`, which is - // case-insensitive; the constant is lowercase so a direct Map-backed test - // jar matches it too. + it("is the lowercase form the gate reads", () => { expect(STEP_UP_ELEVATION_HEADER).toBe("x-step-up"); expect(STEP_UP_ELEVATION_HEADER).toBe( STEP_UP_ELEVATION_HEADER.toLowerCase(), ); }); }); + +describe("the credential prefix lists do not drift apart", () => { + it("the idempotency cache and the log redactor both know `hle_`", async () => { + // Two parallel denylists guard the same class of value. They were already + // one prefix out of step (`hls_` was in one and not the other), which is + // how the next one gets missed. + const idempotency = readFileSync("src/lib/idempotency.ts", "utf8"); + expect(idempotency).toContain("hle_"); + + const { redactSecrets } = await import("@/lib/logging/redact"); + const secret = `hle_${"a".repeat(64)}`; + expect(redactSecrets(`elevation=${secret}`)).not.toContain(secret); + expect(redactSecrets(`session=hls_${"b".repeat(64)}`)).toContain( + "[REDACTED]", + ); + }); +}); diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 4cb7839b2..cdc22a74a 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -368,6 +368,16 @@ export async function destroyAllSessions(userId: string): Promise { ]); } +/** + * How the caller of a "revoke everything else" action authenticated. + * + * Exists so the caller's own credential can be spared without any code having + * to guess what kind of id it is holding — see `destroyOtherSessions`. + */ +export type CurrentCredential = + | { kind: "session"; sessionId: string } + | { kind: "accessToken"; accessTokenHash: string }; + /** * v1.23 — "sign out everywhere" for the user-facing active-session surface * (issue #64). Distinct from `destroyAllSessions`: this keeps the caller's @@ -382,20 +392,41 @@ export async function destroyAllSessions(userId: string): Promise { */ export async function destroyOtherSessions( userId: string, - currentSessionId: string, + current: CurrentCredential, ): Promise<{ sessionsRevoked: number }> { + // Which rows describe the CALLER depends on how the caller authenticated, and + // the two shapes are not interchangeable. A cookie caller is a `Session` row. + // A Bearer caller is an `ApiToken` whose device login is the `RefreshToken` + // carrying the matching `accessTokenHash` — it has no session row at all, so + // every session goes. Passing an ApiToken id in as a session id (which an + // earlier revision did) silently excluded nothing and revoked the caller's own + // refresh token, logging the calling device out at its next rotation. + const keptSessionId = + current.kind === "session" ? current.sessionId : undefined; + const keptRefreshWhere = + current.kind === "accessToken" + ? { accessTokenHash: { not: current.accessTokenHash } } + : {}; + const [deleted] = await prisma.$transaction([ prisma.session.deleteMany({ - where: { userId, id: { not: currentSessionId } }, + where: { + userId, + ...(keptSessionId ? { id: { not: keptSessionId } } : {}), + }, }), prisma.refreshToken.updateMany({ - where: { userId, revokedAt: null }, + where: { userId, revokedAt: null, ...keptRefreshWhere }, data: { revokedAt: new Date() }, }), // v1.23 — "sign out everywhere" also drops every trusted device so a // remembered browser can no longer skip the second factor (§1.7: a device // cookie is killed on sign-out-everywhere). prisma.trustedDevice.deleteMany({ where: { userId } }), + // v1.30.34 — and every step-up elevation. A user who signs out everywhere + // expects nothing to remain that could act on the account; a live elevation + // is exactly that. + prisma.stepUpElevation.deleteMany({ where: { userId } }), ]); return { sessionsRevoked: deleted.count }; } diff --git a/src/lib/auth/step-up.ts b/src/lib/auth/step-up.ts index 734c214c1..d3aa78834 100644 --- a/src/lib/auth/step-up.ts +++ b/src/lib/auth/step-up.ts @@ -16,36 +16,41 @@ * session; a token client has no session to stamp. An elevation is that proof, * made explicit and made disposable. * - * THE PROPERTIES, and why each one is load-bearing: + * 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 — that is the entire point. A stolen token gains no more reach than - * it had before this module existed. + * 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. Theft of - * the elevation in transit is therefore useless without also holding the exact - * token it was minted for. + * be redeemed by a second token, not even another of the same user's. * - * Single-use. Redemption is a conditional UPDATE (see `redeemStepUpElevation`) - * so two concurrent redemptions cannot both succeed. One management action per - * proof, by construction rather than by convention. + * Single-use. The claim is one conditional UPDATE, so two concurrent + * redemptions cannot both succeed. * - * Short-lived. `STEP_UP_ELEVATION_TTL_SECONDS` mirrors the cookie path's - * `MFA_STEP_UP_MAX_AGE_SECONDS` exactly, so both transports carry the same - * freshness promise and neither is the softer way in. + * 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. A table dump - * yields nothing redeemable, and the raw value exists only in the mint - * response and the client's memory. + * posture as ApiToken, RefreshToken, and the session secret. * - * INVALIDATION comes from two directions. A password change calls - * `revokeStepUpElevations` outright. 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. + * 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"; @@ -65,16 +70,48 @@ const ELEVATION_SECRET_BYTES = 32; * 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; it is long - * enough to type a code from an authenticator app and short enough that an - * elevation captured off a screen or a proxy log is stale before it is useful. + * 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. Recorded for audit; never an authorisation input. */ -export type StepUpMethod = "password" | "passkey"; +/** + * Which factor was re-proved. This is an AUTHORISATION INPUT — see + * `FRESH_FACTOR_METHODS` and the file header. + * + * password — the account password. Reaches what a plain cookie session + * reaches, and no further. + * totp — a current code from the enrolled authenticator. + * webauthn — an assertion from a registered second-factor security key. + * passkey — an assertion from a primary passkey. At parity with + * `/api/auth/passkey/login-verify`, which stamps `mfaVerifiedAt` + * on the web for the very same ceremony. + */ +export type StepUpMethod = "password" | "totp" | "webauthn" | "passkey"; + +/** + * The methods that satisfy a fresh-factor gate — exactly the set for which the + * web writes `mfaVerifiedAt`. `password` is deliberately absent: a password + * login has never satisfied step-up on the web and must not start here. + * + * A recovery code is also absent, and that IS a narrowing relative to the web, + * where `/api/auth/mfa/verify` accepts one and does stamp the session. The + * consequence is that an account which has lost its authenticator cannot + * disable its second factor from the app — only from the web. That asymmetry is + * deliberate and flagged rather than quietly closed, because admitting a + * recovery code here means spending one break-glass credential to authorise + * spending another. + */ +export const FRESH_FACTOR_METHODS: ReadonlySet = new Set([ + "totp", + "webauthn", + "passkey", +]); + +export function isFreshFactorMethod(method: StepUpMethod): boolean { + return FRESH_FACTOR_METHODS.has(method); +} export interface MintedElevation { /** The raw `hle_<64 hex>` value. Returned once; never persisted, never logged. */ @@ -89,10 +126,12 @@ export interface MintedElevation { * not check anything, it records a decision already made. Keep the two adjacent * so that stays obvious. * - * Any earlier unconsumed elevation for the same token is deleted first. A fresh - * proof supersedes an older one, so there is never more than one redeemable - * elevation per token; that both bounds the table and shrinks the window in - * which a captured elevation is worth anything. + * 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; @@ -102,60 +141,135 @@ export async function mintStepUpElevation(opts: { const raw = `${ELEVATION_PREFIX}${randomBytes(ELEVATION_SECRET_BYTES).toString("hex")}`; const expiresAt = new Date(Date.now() + STEP_UP_ELEVATION_TTL_SECONDS * 1000); - await prisma.stepUpElevation.deleteMany({ - where: { apiTokenId: opts.apiTokenId, consumedAt: null }, - }); + 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 }, + }); + }); - await prisma.stepUpElevation.create({ - data: { - userId: opts.userId, - apiTokenId: opts.apiTokenId, - tokenHash: hashToken(raw), - method: opts.method, - expiresAt, - }, - select: { id: true }, - }); - - // Opportunistic housekeeping — cheap, indexed, and keeps the table from - // accumulating spent rows without needing a scheduled job for a table that - // only ever sees a handful of rows per user per year. - prisma.stepUpElevation - .deleteMany({ where: { expiresAt: { lt: new Date() } } }) - .catch(() => {}); + 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 a redemption was refused. Audit detail only — never surfaced to a caller. */ -export type StepUpRedeemFailure = - "malformed" | "unknown" | "wrong_token" | "consumed" | "expired"; +/** 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 StepUpRedeemResult = - | { ok: true; method: StepUpMethod } - | { ok: false; reason: StepUpRedeemFailure }; +export type StepUpOutcome = + { ok: true; method: StepUpMethod } | { ok: false; reason: StepUpRejection }; + +/** + * Check an elevation WITHOUT consuming it. + * + * Split from the claim so a route can refuse a bad request — a 429, a malformed + * body, a wrong TOTP code — without burning the caller's elevation. Burning one + * on a validation failure was a self-inflicted lockout: the mint allows five + * attempts per fifteen minutes, so five fat-fingered codes would lock a user out + * of their own second-factor settings. + * + * This is NOT an authorisation decision on its own, and nothing may mutate on + * the strength of it. `claimStepUpElevation` still has to win the race before + * any effect happens; this only decides whether the request is worth processing. + */ +export async function validateStepUpElevation(opts: { + rawToken: string; + userId: string; + apiTokenId: string; + requireFreshFactor: boolean; +}): Promise { + if (!opts.rawToken.startsWith(ELEVATION_PREFIX)) { + return { ok: false, reason: "malformed" }; + } + + const row = await prisma.stepUpElevation.findUnique({ + where: { tokenHash: hashToken(opts.rawToken) }, + select: { + userId: true, + apiTokenId: true, + consumedAt: true, + expiresAt: true, + method: true, + }, + }); + + if (!row) return { ok: false, reason: "unknown" }; + if (row.userId !== opts.userId || row.apiTokenId !== opts.apiTokenId) { + return { ok: false, reason: "wrong_token" }; + } + if (row.consumedAt) return { ok: false, reason: "consumed" }; + if (row.expiresAt <= new Date()) return { ok: false, reason: "expired" }; + + const method = row.method as StepUpMethod; + if (opts.requireFreshFactor && !isFreshFactorMethod(method)) { + return { ok: false, reason: "insufficient_factor" }; + } + + return { ok: true, method }; +} /** * Consume an elevation, or refuse. * - * SINGLE-USE ATOMICITY. The claim is one conditional `UPDATE ... WHERE + * 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` (Prisma's `updateMany`), and the caller proceeds only on - * `count === 1`. 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 matches zero rows. There is - * no read-then-write window to lose, which is exactly why the check-then-update + * 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. * - * Both binding fields are in the predicate rather than checked afterwards, so a - * mismatched token cannot be claimed and then rejected — it is never claimed at - * all, and the legitimate holder's elevation survives someone else's attempt. + * `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 redeemStepUpElevation(opts: { +export async function claimStepUpElevation(opts: { rawToken: string; userId: string; apiTokenId: string; -}): Promise { + requireFreshFactor: boolean; +}): Promise { if (!opts.rawToken.startsWith(ELEVATION_PREFIX)) { return { ok: false, reason: "malformed" }; } @@ -163,56 +277,69 @@ export async function redeemStepUpElevation(opts: { const tokenHash = hashToken(opts.rawToken); const now = new Date(); - const claimed = await prisma.stepUpElevation.updateMany({ - where: { - tokenHash, - userId: opts.userId, - apiTokenId: opts.apiTokenId, - consumedAt: null, - expiresAt: { gt: now }, - }, - data: { consumedAt: now }, - }); + 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.count === 1) { - const row = await prisma.stepUpElevation.findUnique({ + 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: { method: true }, + select: { userId: true, apiTokenId: true, consumedAt: true }, }); - return { ok: true, method: (row?.method as StepUpMethod) ?? "password" }; + 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" }; } - // Classify the refusal for the audit row only. The caller returns one generic - // response for every branch below, so this read tells an operator what - // happened without telling a prober anything. - const existing = await prisma.stepUpElevation.findUnique({ - where: { tokenHash }, - select: { - apiTokenId: true, - userId: true, - consumedAt: true, - expiresAt: true, - }, - }); - if (!existing) return { ok: false, reason: "unknown" }; - if ( - existing.userId !== opts.userId || - existing.apiTokenId !== opts.apiTokenId - ) { - return { ok: false, reason: "wrong_token" }; + const method = claimed[0].method as StepUpMethod; + if (opts.requireFreshFactor && !isFreshFactorMethod(method)) { + return { ok: false, reason: "insufficient_factor" }; } - if (existing.consumedAt) return { ok: false, reason: "consumed" }; - return { ok: false, reason: "expired" }; + + return { ok: true, method }; } /** * Drop every elevation for a user. * - * Called when the account password changes: an elevation is a statement about a - * credential that no longer exists, so it must not outlive it. Deliberately + * Called when the account password changes (self-service or operator-forced) + * and on sign-out-everywhere. An elevation is a statement about a credential and + * a moment, and neither survives either of those events. Deliberately * unconditional over the user's rows — consumed ones go too, since there is no * reason to keep them once the anchor moved. */ export async function revokeStepUpElevations(userId: string): Promise { await prisma.stepUpElevation.deleteMany({ where: { userId } }); } + +/** + * Delete every elevation whose window has closed. + * + * Runs on the queue, not on a request. The earlier fire-and-forget sweep on the + * mint path was a cross-tenant unbounded DELETE competing with the request for a + * pool connection, and recurring work belongs on pg-boss. An expired row is + * already unredeemable — the predicate in `claimStepUpElevation` refuses it — so + * this is hygiene, not enforcement, and a missed run breaks nothing. + */ +export async function pruneExpiredStepUpElevations(): Promise { + const { count } = await prisma.stepUpElevation.deleteMany({ + where: { expiresAt: { lt: new Date() } }, + }); + return count; +} diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index a10155bc2..ad0ba614a 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -426,7 +426,7 @@ export function withIdempotency< // or any other word containing `sk-` would otherwise // silently break idempotency for benign retries). const SECRET_PATTERN = - /(?:\b(?:hlk_|hlr_|hls_|hlv_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; + /(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; const cloned = response.clone(); const text = await cloned.text(); if (!SECRET_PATTERN.test(text)) { diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts index 105367bdb..bdbe007be 100644 --- a/src/lib/jobs/reminder/cleanup-handlers.ts +++ b/src/lib/jobs/reminder/cleanup-handlers.ts @@ -40,6 +40,10 @@ const PUSH_ATTEMPT_RETENTION_DAYS = 90; // the two never drift. Slots at 03:40 between push-attempt cleanup (03:35) // and the drain (03:45) inside the existing 03:xx maintenance window. +export interface StepUpElevationCleanupPayload { + _?: never; +} + export interface RateLimitCleanupPayload { triggeredAt: string; } @@ -193,6 +197,32 @@ 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. + */ +export async function handleStepUpElevationCleanup( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.step_up_elevation_cleanup", async (evt) => { + const p = getWorkerPrisma(); + try { + const { count } = await p.stepUpElevation.deleteMany({ + where: { expiresAt: { lt: new Date() } }, + }); + evt.addMeta("step_up_elevation_cleanup_deleted", count); + } catch (err) { + evt.addWarning(`step-up-elevation-cleanup failed: ${err}`); + } + }); +} + export async function handleAuditLogCleanup( jobs: Job[], ) { diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index c258a79ca..397ea802d 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -134,6 +134,7 @@ import { } from "./backup-handlers"; import { RateLimitCleanupPayload, + StepUpElevationCleanupPayload, IdempotencyCleanupPayload, AuditLogCleanupPayload, MoodReminderCleanupPayload, @@ -145,6 +146,7 @@ import { handleRateLimitCleanup, handleIdempotencyCleanup, handleAuditLogCleanup, + handleStepUpElevationCleanup, CoachMessageCleanupPayload, handleCoachMessageCleanup, McpTokenCleanupPayload, @@ -184,6 +186,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) @@ -280,6 +288,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, @@ -380,6 +389,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], @@ -554,6 +564,11 @@ export async function registerMaintenanceQueues( { localConcurrency: 1 }, handleAuditLogCleanup, ); + await boss.work( + STEP_UP_ELEVATION_CLEANUP_QUEUE, + { localConcurrency: 1 }, + handleStepUpElevationCleanup, + ); await boss.work( OFFHOST_BACKUP_QUEUE, { localConcurrency: 1 }, diff --git a/src/lib/validations/step-up.ts b/src/lib/validations/step-up.ts index c167bab42..96028cd6e 100644 --- a/src/lib/validations/step-up.ts +++ b/src/lib/validations/step-up.ts @@ -7,14 +7,20 @@ import { z } from "zod/v4"; import { webauthnCredentialSchema } from "@/lib/validations/mfa"; /** - * Re-prove a factor to mint an elevation. + * 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: * - * Two accepted proofs, discriminated on `method`: - * - `password` — the account password. Unavailable on an account that signed - * in through OIDC SSO and never set one; such an account uses the passkey - * arm, or manages its second factor on the web. - * - `passkey` — an assertion against a primary passkey the device already - * holds, begun at `/api/auth/step-up/options`. + * - `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. @@ -25,6 +31,18 @@ export const stepUpMintSchema = z 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), @@ -34,5 +52,16 @@ export const stepUpMintSchema = z .meta({ id: "StepUpMintRequest", description: - "Re-prove the account password or a primary passkey to mint a single-use, token-bound step-up elevation.", + "Re-prove a factor to mint a single-use, token-bound step-up elevation. `password` reaches the same routes a plain cookie session reaches; `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes (disable, recovery-code rotation, security-key removal), matching exactly the ceremonies for which the web stamps a session second-factor-verified.", }); + +/** Which assertion ceremony to begin at `POST /api/auth/step-up/options`. */ +export const stepUpOptionsSchema = z + .object({ + method: z + .enum(["passkey", "webauthn"]) + .describe( + "`passkey` begins an assertion against the account's primary passkeys; `webauthn` against its registered second-factor security keys.", + ), + }) + .meta({ id: "StepUpOptionsRequest" }); diff --git a/tests/integration/sessions-and-known-devices.test.ts b/tests/integration/sessions-and-known-devices.test.ts index fb9cbc7c5..c54e97f91 100644 --- a/tests/integration/sessions-and-known-devices.test.ts +++ b/tests/integration/sessions-and-known-devices.test.ts @@ -58,7 +58,10 @@ describe("destroyOtherSessions", () => { }, }); - const result = await destroyOtherSessions(user.id, current.id); + const result = await destroyOtherSessions(user.id, { + kind: "session", + sessionId: current.id, + }); expect(result.sessionsRevoked).toBe(2); const remaining = await prisma.session.findMany({ diff --git a/tests/integration/step-up-elevation.test.ts b/tests/integration/step-up-elevation.test.ts index f85eacd37..d62ac379e 100644 --- a/tests/integration/step-up-elevation.test.ts +++ b/tests/integration/step-up-elevation.test.ts @@ -15,6 +15,7 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; +import * as OTPAuth from "otpauth"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -118,15 +119,79 @@ function req(path: string, method = "GET", body?: unknown): NextRequest { return new NextRequest(`https://health.example${path}`, init as never); } +/** Drive the mint endpoint with an arbitrary proof body. */ +async function mintWith( + body: Record, +): Promise<{ status: number; body: Record }> { + const { POST } = await import("@/app/api/auth/step-up/route"); + const res = await POST(req("/api/auth/step-up", "POST", body)); + return { status: res.status, body: (await res.json()) as never }; +} + /** Drive the mint endpoint with a password proof. */ async function mintElevation( password: string, ): Promise<{ status: number; body: Record }> { - const { POST } = await import("@/app/api/auth/step-up/route"); - const res = await POST( - req("/api/auth/step-up", "POST", { method: "password", password }), - ); - return { status: res.status, body: (await res.json()) as never }; + return mintWith({ method: "password", password }); +} + +/** + * Mint a TOTP-proved elevation the way a real client would: enrol a secret, + * then present a live code for it. This is the only fresh-factor arm reachable + * without a WebAuthn authenticator, so it carries the fresh-factor coverage. + */ +async function enrolTotp(): Promise { + const { generateTotpSecret } = await import("@/lib/auth/mfa/totp"); + const { encrypt } = await import("@/lib/crypto"); + const secret = generateTotpSecret(); + await getPrismaClient().user.update({ + where: { id: USER_ID }, + data: { + totpSecretEncrypted: encrypt(secret), + totpConfirmedAt: new Date(), + totpLastStep: null, + }, + }); + return secret; +} + +/** Mirrors the server's TOTP parameters — there is no code generator to import. */ +function currentTotpCode(secretBase32: string): string { + const totp = new OTPAuth.TOTP({ + issuer: "HealthLog", + label: "HealthLog", + algorithm: "SHA1", + digits: 6, + period: 30, + secret: OTPAuth.Secret.fromBase32(secretBase32), + }); + return totp.generate({ timestamp: Date.now() }); +} + +async function mintTotpElevation( + secret: string, +): Promise<{ status: number; body: Record }> { + return mintWith({ method: "totp", code: currentTotpCode(secret) }); +} + +/** + * Seed a known recovery-code batch and return the plaintext. + * + * The disable route wants a live factor in its BODY on top of the step-up. A + * TOTP code spent minting the elevation cannot be reused there — the shared + * verifier burns its time-step, by design — so within one 30-second window the + * body factor has to be something else. That is exactly how the web behaves + * too. + */ +async function seedRecoveryCodes(): Promise { + const { regenerateRecoveryCodes } = + await import("@/lib/auth/mfa/recovery-codes"); + return regenerateRecoveryCodes(USER_ID); +} + +/** Pull the raw elevation out of a successful mint. */ +function elevationOf(result: { body: Record }): string { + return (result.body.data as { elevation: string }).elevation; } /** @@ -136,7 +201,19 @@ async function mintElevation( */ type RouteFn = (request: NextRequest) => Promise; -/** GET /api/auth/me/mfa — the cheapest route in the elevation-accepting set. */ +/** + * POST /api/auth/me/mfa/totp/setup — the cheapest MUTATION in the + * elevation-accepting set, and the probe every redemption case drives. + * + * NOT `GET /api/auth/me/mfa`: that read is plain `requireAuth()` now, so using + * it here would have quietly stopped exercising the elevation path at all. + */ +async function callMfaSetup(): Promise { + const { POST } = await import("@/app/api/auth/me/mfa/totp/setup/route"); + return await POST(req("/api/auth/me/mfa/totp/setup", "POST")); +} + +/** The status read — plain Bearer, no elevation. */ async function callMfaStatus(): Promise { const { GET } = await import("@/app/api/auth/me/mfa/route"); return await (GET as unknown as RouteFn)(req("/api/auth/me/mfa")); @@ -160,7 +237,7 @@ describe("E1 — the token alone is not a step-up proof", () => { useToken(raw); useElevation(null); - const res = await callMfaStatus(); + const res = await callMfaSetup(); // 401 with the machine code the client branches on to launch a re-proof — // not a 200, and not the 403 an unauthorised token would get. @@ -183,7 +260,7 @@ describe("E1 — the token alone is not a step-up proof", () => { useToken(raw); useElevation(`hle_${"f".repeat(64)}`); - expect((await callMfaStatus()).status).toBe(401); + expect((await callMfaSetup()).status).toBe(401); await new Promise((r) => setTimeout(r, 100)); expect(await auditReasons("auth.stepup.elevation.rejected")).toContain( "unknown", @@ -207,7 +284,7 @@ describe("E2 — an elevation is not portable between tokens", () => { // thing standing between these two, which is exactly the point. useToken(b.raw); useElevation(elevation); - const res = await callMfaStatus(); + const res = await callMfaSetup(); expect(res.status).toBe(401); await new Promise((r) => setTimeout(r, 100)); @@ -221,7 +298,7 @@ describe("E2 — an elevation is not portable between tokens", () => { useToken(a.raw); useElevation(elevation); - expect((await callMfaStatus()).status).toBe(200); + expect((await callMfaSetup()).status).toBe(200); }); it("refuses redemption by another user's token", async () => { @@ -236,7 +313,7 @@ describe("E2 — an elevation is not portable between tokens", () => { useToken(theirs.raw); useElevation(elevation); - expect((await callMfaStatus()).status).toBe(401); + expect((await callMfaSetup()).status).toBe(401); }); }); @@ -251,8 +328,8 @@ describe("E3 — an elevation is consumed exactly once", () => { ).elevation; useElevation(elevation); - expect((await callMfaStatus()).status).toBe(200); - expect((await callMfaStatus()).status).toBe(401); + expect((await callMfaSetup()).status).toBe(200); + expect((await callMfaSetup()).status).toBe(401); await new Promise((r) => setTimeout(r, 100)); expect(await auditReasons("auth.stepup.elevation.rejected")).toContain( @@ -271,15 +348,16 @@ describe("E3 — an elevation is consumed exactly once", () => { // conditional UPDATEs on one row rather than between two HTTP handlers that // happen to interleave. A check-then-update implementation passes the // sequential case above and fails here. - const { redeemStepUpElevation } = await import("@/lib/auth/step-up"); + const { claimStepUpElevation } = await import("@/lib/auth/step-up"); const attempt = () => - redeemStepUpElevation({ + claimStepUpElevation({ rawToken: elevation, userId: USER_ID, apiTokenId: id, + requireFreshFactor: false, }); - const results = await Promise.all([ + const results: Awaited>[] = await Promise.all([ attempt(), attempt(), attempt(), @@ -309,7 +387,7 @@ describe("E4 — an expired elevation is refused", () => { }); useElevation(elevation); - expect((await callMfaStatus()).status).toBe(401); + expect((await callMfaSetup()).status).toBe(401); await new Promise((r) => setTimeout(r, 100)); expect(await auditReasons("auth.stepup.elevation.rejected")).toContain( @@ -434,9 +512,9 @@ describe("E7 — the cookie flow is unchanged", () => { return session.id; } - it("serves an MFA-management read on a plain cookie session", async () => { + it("serves an MFA-management mutation on a plain cookie session", async () => { await useCookieSession(); - expect((await callMfaStatus()).status).toBe(200); + expect((await callMfaSetup()).status).toBe(200); }); it("still refuses a step-up-gated route on a cookie with no fresh factor", async () => { @@ -479,7 +557,7 @@ describe("E7 — the cookie flow is unchanged", () => { await useCookieSession(); useElevation(elevation); - expect((await callMfaStatus()).status).toBe(200); + expect((await callMfaSetup()).status).toBe(200); const row = await getPrismaClient().stepUpElevation.findFirst({ where: { apiTokenId: id }, @@ -491,14 +569,85 @@ describe("E7 — the cookie flow is unchanged", () => { // ── E8 — invalidation on credential rotation ───────────────────────── describe("E8 — an elevation does not outlive its anchor", () => { - it("is dropped when the account password changes", async () => { + it("is dropped by a real password change, through the route", async () => { + // Driven through POST /api/auth/password rather than by calling the + // revocation helper. Calling the helper directly proved only that the helper + // deletes rows — deleting the call from BOTH password routes left the old + // version of this test green, so the invalidation story was uncovered. const { raw } = await mintToken("e8a"); useToken(raw); + const elevation = elevationOf(await mintElevation(PASSWORD)); + expect(await getPrismaClient().stepUpElevation.count()).toBe(1); + + const NEW_PASSWORD = "Zt7#qvbLm2xR!e9Wd4Kp"; + const { POST } = await import("@/app/api/auth/password/route"); + const res = await POST( + req("/api/auth/password", "POST", { + currentPassword: PASSWORD, + newPassword: NEW_PASSWORD, + confirmPassword: NEW_PASSWORD, + }), + ); + expect(res.status).toBe(200); + + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + + // And the value the client still holds is dead. The password route mints a + // replacement cookie session, which the shared jar would otherwise pick up + // and satisfy the gate on the cookie arm — clear it so this probes the + // Bearer arm, which is the one under test. + cookieJar.clear(); + useElevation(elevation); + expect((await callMfaSetup()).status).toBe(401); + }); + + it("is dropped by an operator-forced reset, through the admin route", async () => { + const { raw } = await mintToken("e8a2"); + useToken(raw); await mintElevation(PASSWORD); expect(await getPrismaClient().stepUpElevation.count()).toBe(1); - const { revokeStepUpElevations } = await import("@/lib/auth/step-up"); - await revokeStepUpElevations(USER_ID); + // The admin route is cookie-only, so the reset arrives on an ADMIN session + // while the elevation belongs to the target's token. + await getPrismaClient().user.update({ + where: { id: OTHER_USER_ID }, + data: { role: "ADMIN" }, + }); + const adminSession = await getPrismaClient().session.create({ + data: { + userId: OTHER_USER_ID, + expiresAt: new Date(Date.now() + 3_600_000), + }, + }); + cookieJar.set("healthlog_session", adminSession.id); + headerJar.delete("authorization"); + + const { POST } = + await import("@/app/api/admin/users/[id]/reset-password/route"); + const res = await POST( + req(`/api/admin/users/${USER_ID}/reset-password`, "POST", { + password: "operator chosen long passphrase 77", + }), + { params: Promise.resolve({ id: USER_ID }) }, + ); + expect(res.status).toBe(200); + + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); + + it("is dropped by sign-out-everywhere", async () => { + const { raw } = await mintToken("e8a3"); + useToken(raw); + await mintElevation(PASSWORD); + + const session = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + const { destroyOtherSessions } = await import("@/lib/auth/session"); + await destroyOtherSessions(USER_ID, { + kind: "session", + sessionId: session.id, + }); expect(await getPrismaClient().stepUpElevation.count()).toBe(0); }); @@ -518,7 +667,7 @@ describe("E8 — an elevation does not outlive its anchor", () => { useElevation(elevation); // Refused at Bearer resolution, before the elevation is even looked at — // which is why no code in the revoke path needs to know about elevations. - expect((await callMfaStatus()).status).toBe(401); + expect((await callMfaSetup()).status).toBe(401); const row = await getPrismaClient().stepUpElevation.findFirst(); expect(row?.consumedAt).toBeNull(); @@ -563,7 +712,7 @@ describe("E9 — a native client can manage its second factor", () => { }); expect(user?.totpSecretEncrypted).toBeTruthy(); expect(user?.totpConfirmedAt).toBeNull(); - expect((await callMfaStatus()).status).toBe(401); + expect((await callMfaSetup()).status).toBe(401); }); it("refuses the mint surface to a cookie session", async () => { @@ -585,3 +734,361 @@ describe("E9 — a native client can manage its second factor", () => { expect(await getPrismaClient().stepUpElevation.count()).toBe(0); }); }); + +// ── E10 — the fresh-factor rule (B1) ───────────────────────────────── + +describe("E10 — a password proof cannot reach the destructive routes", () => { + /** + * The attack this closes, in the order it ran before the fix: + * 1. mint an elevation with the account password (a stolen token plus a + * known password is the whole prerequisite); + * 2. POST recovery-codes/regenerate — which takes NO factor in its body — + * and receive ten plaintext recovery codes; + * 3. POST disable with one of them, satisfying the body check. + * The second factor is gone. On the web that chain is impossible because a + * password login never stamps `mfaVerifiedAt`. + */ + async function regenerate(): Promise { + const { POST } = + await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route"); + return POST(req("/api/auth/me/mfa/recovery-codes/regenerate", "POST")); + } + + async function disable(code: string, method = "recovery"): Promise { + const { POST } = await import("@/app/api/auth/me/mfa/disable/route"); + return POST(req("/api/auth/me/mfa/disable", "POST", { code, method })); + } + + async function removeKey(id: string): Promise { + const { DELETE } = + await import("@/app/api/auth/me/mfa/webauthn/[id]/route"); + return DELETE(req(`/api/auth/me/mfa/webauthn/${id}`, "DELETE"), { + params: Promise.resolve({ id }), + }); + } + + it("refuses recovery-code regeneration on a password-proved elevation", async () => { + await enrolTotp(); + const { raw } = await mintToken("e10a"); + useToken(raw); + useElevation(elevationOf(await mintElevation(PASSWORD))); + + const res = await regenerate(); + + expect(res.status).toBe(401); + // No codes were minted, so step 2 of the chain yields nothing. + expect(await getPrismaClient().mfaRecoveryCode.count()).toBe(0); + await new Promise((r) => setTimeout(r, 100)); + expect(await auditReasons("auth.stepup.elevation.rejected")).toContain( + "insufficient_factor", + ); + }); + + it("refuses disable on a password-proved elevation", async () => { + const secret = await enrolTotp(); + const { raw } = await mintToken("e10b"); + useToken(raw); + useElevation(elevationOf(await mintElevation(PASSWORD))); + + const res = await disable(currentTotpCode(secret), "totp"); + + expect(res.status).toBe(401); + const user = await getPrismaClient().user.findUnique({ + where: { id: USER_ID }, + }); + expect(user?.totpConfirmedAt).not.toBeNull(); + }); + + it("refuses security-key removal on a password-proved elevation", async () => { + const key = await getPrismaClient().webauthnMfaCredential.create({ + data: { + userId: USER_ID, + name: "key", + credentialId: "cred-e10c", + credentialPublicKey: Buffer.from([1, 2, 3]), + transports: ["internal"], + }, + }); + const { raw } = await mintToken("e10c"); + useToken(raw); + useElevation(elevationOf(await mintElevation(PASSWORD))); + + expect((await removeKey(key.id)).status).toBe(401); + expect(await getPrismaClient().webauthnMfaCredential.count()).toBe(1); + }); + + it("does NOT burn the elevation when the factor is too weak", async () => { + // The refusal is about reach, not validity. The same elevation must still + // work on the routes it was always entitled to. + await enrolTotp(); + const { raw } = await mintToken("e10d"); + useToken(raw); + const elevation = elevationOf(await mintElevation(PASSWORD)); + + useElevation(elevation); + expect((await regenerate()).status).toBe(401); + + const row = await getPrismaClient().stepUpElevation.findFirst(); + expect(row?.consumedAt).toBeNull(); + }); + + it("admits the SAME routes a plain cookie session reaches", async () => { + // The password arm is not useless — it is exactly cookie-equivalent. + const { raw } = await mintToken("e10e"); + useToken(raw); + useElevation(elevationOf(await mintElevation(PASSWORD))); + + expect((await callMfaSetup()).status).toBe(200); + }); + + it("admits the destructive routes on a TOTP-proved elevation", async () => { + const secret = await enrolTotp(); + const { raw } = await mintToken("e10f"); + useToken(raw); + + const minted = await mintTotpElevation(secret); + expect(minted.status).toBe(200); + expect( + (minted.body.data as { satisfiesFreshFactor: boolean }) + .satisfiesFreshFactor, + ).toBe(true); + + useElevation(elevationOf(minted)); + const res = await regenerate(); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { recoveryCodes: string[] }; + }; + expect(body.data.recoveryCodes.length).toBeGreaterThan(0); + }); + + it("marks a password-proved elevation as not fresh-factor in the mint response", async () => { + const { raw } = await mintToken("e10g"); + useToken(raw); + const minted = await mintElevation(PASSWORD); + const data = minted.body.data as { + method: string; + satisfiesFreshFactor: boolean; + }; + expect(data.method).toBe("password"); + expect(data.satisfiesFreshFactor).toBe(false); + }); + + it("refuses a stale TOTP code and does not mint", async () => { + const secret = await enrolTotp(); + const { raw } = await mintToken("e10h"); + useToken(raw); + + // Spend the code through the shared verifier first; the replay guard must + // then refuse the same code here, exactly as it does at login. + const code = currentTotpCode(secret); + const { verifyMfaFactor } = await import("@/lib/auth/mfa/verify-factor"); + const user = await getPrismaClient().user.findUnique({ + where: { id: USER_ID }, + }); + await verifyMfaFactor(user!, "totp", code); + + const res = await mintWith({ method: "totp", code }); + + expect(res.status).toBe(401); + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); +}); + +// ── E11 — the disable route keeps the caller signed in (B2) ────────── + +describe("E11 — disabling MFA over Bearer spares the calling device", () => { + it("revokes every OTHER refresh token but not the caller's own", async () => { + const secret = await enrolTotp(); + const { raw, id } = await mintToken("e11a"); + + const { hashToken } = await import("@/lib/auth/hmac"); + // The caller's own device login: the RefreshToken row cross-referencing the + // access token being presented. + const mine = await getPrismaClient().refreshToken.create({ + data: { + userId: USER_ID, + tokenHash: hashToken(`hlr_mine${"0".repeat(56)}`), + accessTokenHash: hashToken(raw), + expiresAt: new Date(Date.now() + 30 * 24 * 3_600_000), + }, + }); + // Another device. + const theirs = await getPrismaClient().refreshToken.create({ + data: { + userId: USER_ID, + tokenHash: hashToken(`hlr_theirs${"0".repeat(54)}`), + accessTokenHash: hashToken(`hlk_other${"0".repeat(55)}`), + expiresAt: new Date(Date.now() + 30 * 24 * 3_600_000), + }, + }); + // And a browser session, which has no "current" on the Bearer path. + const webSession = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + + const codes = await seedRecoveryCodes(); + useToken(raw); + useElevation(elevationOf(await mintTotpElevation(secret))); + + const { POST } = await import("@/app/api/auth/me/mfa/disable/route"); + const res = await POST( + req("/api/auth/me/mfa/disable", "POST", { + code: codes[0], + method: "recovery", + }), + ); + expect(res.status).toBe(200); + + // The defect: passing the ApiToken id where a Session id was expected made + // the "keep the current one" exclusion match nothing, so the caller's own + // refresh token was revoked and the app logged itself out at next rotation. + const mineAfter = await getPrismaClient().refreshToken.findUnique({ + where: { id: mine.id }, + }); + const theirsAfter = await getPrismaClient().refreshToken.findUnique({ + where: { id: theirs.id }, + }); + expect(mineAfter?.revokedAt).toBeNull(); + expect(theirsAfter?.revokedAt).not.toBeNull(); + + // Web sessions all go — none of them is the caller. + expect( + await getPrismaClient().session.findUnique({ + where: { id: webSession.id }, + }), + ).toBeNull(); + + // And the calling token itself is untouched, so the next request works. + const token = await getPrismaClient().apiToken.findUnique({ + where: { id }, + }); + expect(token?.revoked).toBe(false); + }); + + it("keeps the caller's own session on the cookie path, as before", async () => { + const secret = await enrolTotp(); + const current = await getPrismaClient().session.create({ + data: { + userId: USER_ID, + expiresAt: new Date(Date.now() + 3_600_000), + mfaVerifiedAt: new Date(), + }, + }); + const other = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", current.id); + headerJar.delete("authorization"); + + const { POST } = await import("@/app/api/auth/me/mfa/disable/route"); + const res = await POST( + req("/api/auth/me/mfa/disable", "POST", { + code: currentTotpCode(secret), + method: "totp", + }), + ); + expect(res.status).toBe(200); + + expect( + await getPrismaClient().session.findUnique({ where: { id: current.id } }), + ).not.toBeNull(); + expect( + await getPrismaClient().session.findUnique({ where: { id: other.id } }), + ).toBeNull(); + }); +}); + +// ── E12 — validation failures do not burn the proof ────────────────── + +describe("E12 — a rejected request keeps the elevation spendable", () => { + it("survives a wrong TOTP code on disable", async () => { + const secret = await enrolTotp(); + const codes = await seedRecoveryCodes(); + const { raw } = await mintToken("e12a"); + useToken(raw); + const elevation = elevationOf(await mintTotpElevation(secret)); + + useElevation(elevation); + const { POST } = await import("@/app/api/auth/me/mfa/disable/route"); + const bad = await POST( + req("/api/auth/me/mfa/disable", "POST", { + code: "000000", + method: "totp", + }), + ); + expect(bad.status).toBe(401); + + // Unspent — otherwise five fat-fingered codes would exhaust the 5-per-15-min + // mint ceiling and lock the user out of their own security settings. + const row = await getPrismaClient().stepUpElevation.findFirst(); + expect(row?.consumedAt).toBeNull(); + + // And the retry goes through on the SAME elevation. + const good = await POST( + req("/api/auth/me/mfa/disable", "POST", { + code: codes[0], + method: "recovery", + }), + ); + expect(good.status).toBe(200); + }); + + it("survives a malformed body", async () => { + const { raw } = await mintToken("e12b"); + useToken(raw); + useElevation(elevationOf(await mintElevation(PASSWORD))); + + const { POST } = + await import("@/app/api/auth/me/mfa/webauthn/register/verify/route"); + const res = await POST( + req("/api/auth/me/mfa/webauthn/register/verify", "POST", { nope: 1 }), + ); + expect(res.status).toBe(422); + + const row = await getPrismaClient().stepUpElevation.findFirst(); + expect(row?.consumedAt).toBeNull(); + }); +}); + +// ── E13 — the status read is plain Bearer ──────────────────────────── + +describe("E13 — the second-factor status read needs no elevation", () => { + it("serves a token with no elevation header at all", async () => { + const { raw } = await mintToken("e13a"); + useToken(raw); + useElevation(null); + + const res = await callMfaStatus(); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { totp: { enabled: boolean }; webauthn: unknown[] }; + }; + expect(body.data.totp).toBeDefined(); + expect(Array.isArray(body.data.webauthn)).toBe(true); + }); + + it("carries no credential material", async () => { + await getPrismaClient().webauthnMfaCredential.create({ + data: { + userId: USER_ID, + name: "key", + credentialId: "cred-e13b-secret", + credentialPublicKey: Buffer.from([9, 9, 9]), + transports: ["internal"], + }, + }); + const { raw } = await mintToken("e13b"); + useToken(raw); + + const text = await (await callMfaStatus()).text(); + + // The justification for relaxing this route is that the payload is inert. + // Pin it: no credential id, no public key, no codes. + expect(text).not.toContain("cred-e13b-secret"); + expect(text).not.toContain("credentialPublicKey"); + expect(text).not.toContain("credentialId"); + }); +}); From 4cd0adc46d889545b8586a712de6f6b7705bb366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 14:29:34 +0200 Subject: [PATCH 5/9] docs(api): publish the factor-dependent reach of a step-up elevation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract now names which factors satisfy the fresh-factor routes and which do not, carries `satisfiesFreshFactor` on the mint response so a client can pick the ceremony before spending a proof, and describes the options endpoint's two ceremony kinds. The second-factor status read is documented as plain authentication — it carries no credential material, so it needs no elevation. --- docs/api/openapi.yaml | 144 +++++++++++++++++--- src/lib/openapi/routes/auth.ts | 36 +++-- tests/integration/step-up-elevation.test.ts | 50 +++++++ 3 files changed, 198 insertions(+), 32 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f3cf43e20..11374f413 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1605,11 +1605,18 @@ paths: post: tags: - Auth - summary: Begin a passkey re-proof for a step-up elevation (Bearer only) - description: "Returns SimpleWebAuthn assertion options scoped to the calling account's primary passkeys, plus a - challenge id to present at POST /api/auth/step-up. 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 - passkey registered — use the password arm of the mint instead." + 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. @@ -1632,26 +1639,34 @@ paths: - Auth summary: Mint a single-use step-up elevation (Bearer only) description: >- - Re-prove the account password or a primary passkey and receive an opaque elevation that authorises exactly ONE - second-factor-management call. + Re-prove a factor and receive an opaque elevation that authorises exactly ONE second-factor-management call. - The elevation is the Bearer transport's equivalent of the web's fresh-second-factor session stamp. It 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. + 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 — - returns the same 401 with the same prose, and is audited server-side. Rate-limited per account (5 / 15 min) and - per source address. + 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): GET /api/auth/me/mfa; 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}. Nothing else accepts one — admin endpoints stay cookie-only. + Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST + /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST + /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE + /api/auth/me/mfa/webauthn/{id}. 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: @@ -1774,10 +1789,11 @@ paths: get: tags: - Auth - summary: Second-factor status (cookie session or step-up elevation) + 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. 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. + 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. @@ -11472,6 +11488,18 @@ 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 @@ -11486,6 +11514,60 @@ components: 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: @@ -11529,7 +11611,10 @@ components: - method - challengeId - credential - description: Re-prove the account password or a primary passkey to mint a single-use, token-bound step-up elevation. + 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 @@ -15473,10 +15558,25 @@ components: 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 diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 2384a7ffd..ac408777c 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -18,7 +18,10 @@ import { mfaWebauthnLoginVerifySchema, } from "@/lib/validations/mfa"; import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native"; -import { stepUpMintSchema } from "@/lib/validations/step-up"; +import { + stepUpMintSchema, + stepUpOptionsSchema, +} from "@/lib/validations/step-up"; import { dataEnvelope, stdResponses, errorEnvelope } from "./shared"; // ── Sub-schemas owned here (route-specific shapes) ─────────────────── @@ -312,6 +315,14 @@ const stepUpMintResponse = z ), 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" }); @@ -426,9 +437,14 @@ export const authPaths: NonNullable = { "/api/auth/step-up/options": { post: { tags: ["Auth"], - summary: "Begin a passkey re-proof for a step-up elevation (Bearer only)", + summary: + "Begin a WebAuthn re-proof for a step-up elevation (Bearer only)", description: - "Returns SimpleWebAuthn assertion options scoped to the calling account's primary passkeys, plus a challenge id to present at POST /api/auth/step-up. 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 passkey registered — use the password arm of the mint instead.", + '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.", @@ -454,10 +470,11 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Mint a single-use step-up elevation (Bearer only)", description: - "Re-prove the account password or a primary passkey and receive an opaque elevation that authorises exactly ONE second-factor-management call.\n\n" + - "The elevation is the Bearer transport's equivalent of the web's fresh-second-factor session stamp. It 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.\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 — 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): GET /api/auth/me/mfa; 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}. Nothing else accepts one — admin endpoints stay cookie-only.", + "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 } }, @@ -581,10 +598,9 @@ export const authPaths: NonNullable = { "/api/auth/me/mfa": { get: { tags: ["Auth"], - summary: "Second-factor status (cookie session or step-up elevation)", + 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." + - MFA_MANAGEMENT_AUTH_NOTE, + "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.", diff --git a/tests/integration/step-up-elevation.test.ts b/tests/integration/step-up-elevation.test.ts index d62ac379e..7f528a4d8 100644 --- a/tests/integration/step-up-elevation.test.ts +++ b/tests/integration/step-up-elevation.test.ts @@ -817,6 +817,25 @@ describe("E10 — a password proof cannot reach the destructive routes", () => { expect(await getPrismaClient().webauthnMfaCredential.count()).toBe(1); }); + it("refuses at the CLAIM too, not only at the validation", async () => { + // Defence in depth, and it needs its own test: the gate validates before it + // claims, so removing the check inside `claimStepUpElevation` leaves every + // route-level case above green. Driven straight at the primitive. + const { raw, id } = await mintToken("e10i"); + useToken(raw); + const elevation = elevationOf(await mintElevation(PASSWORD)); + + const { claimStepUpElevation } = await import("@/lib/auth/step-up"); + const claimed = await claimStepUpElevation({ + rawToken: elevation, + userId: USER_ID, + apiTokenId: id, + requireFreshFactor: true, + }); + + expect(claimed).toEqual({ ok: false, reason: "insufficient_factor" }); + }); + it("does NOT burn the elevation when the factor is too weak", async () => { // The refusal is about reach, not validity. The same elevation must still // work on the routes it was always entitled to. @@ -1035,6 +1054,37 @@ describe("E12 — a rejected request keeps the elevation spendable", () => { expect(good.status).toBe(200); }); + it("IS spent once the route actually acts", async () => { + // The other half of the two-phase split. Deferring the claim buys nothing if + // the route then forgets to make it — the elevation would stay redeemable + // for the rest of its five minutes. + // + // Driven through recovery-code regeneration rather than disable: disable + // calls `destroyOtherSessions`, which deletes the account's elevations + // outright, so the row is gone afterwards either way and the assertion + // would pass whether or not the route committed. Regeneration leaves the + // row in place, so `consumedAt` is a real observation. + const secret = await enrolTotp(); + const { raw } = await mintToken("e12c"); + useToken(raw); + const elevation = elevationOf(await mintTotpElevation(secret)); + + useElevation(elevation); + const { POST } = + await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route"); + const ok = await POST( + req("/api/auth/me/mfa/recovery-codes/regenerate", "POST"), + ); + expect(ok.status).toBe(200); + + const row = await getPrismaClient().stepUpElevation.findFirst(); + expect(row).not.toBeNull(); + expect(row?.consumedAt).not.toBeNull(); + + // And the spent value opens nothing else. + expect((await callMfaSetup()).status).toBe(401); + }); + it("survives a malformed body", async () => { const { raw } = await mintToken("e12b"); useToken(raw); From f1c977a7a780dd45079ae024a4a6d130e581065e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 14:43:05 +0200 Subject: [PATCH 6/9] test(auth): give the MFA fixtures a Bearer transport The disable-route suite only ever supplied a cookie-shaped context, which is why the token-id-as-session-id defect was invisible to it. It now covers both arms and asserts the credential shape handed to the session revoker, plus that the elevation is spent before the teardown and not spent when the factor code is wrong. --- .../me/mfa/disable/__tests__/route.test.ts | 65 ++++++++++++++++++- .../webauthn/register/__tests__/route.test.ts | 3 + .../auth/me/sessions/__tests__/route.test.ts | 5 +- src/lib/jobs/reminder/cleanup-handlers.ts | 4 ++ 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts index 422ed90d9..7884d3f52 100644 --- a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts +++ b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts @@ -55,8 +55,10 @@ function req() { beforeEach(() => { vi.clearAllMocks(); vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "cookie", user: { id: "u1" }, session: { id: "sess-current" }, + commitElevation: vi.fn().mockResolvedValue(undefined), } as never); vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: true } as never); vi.mocked(prisma.$transaction).mockImplementation(async (cb: unknown) => { @@ -74,7 +76,68 @@ describe("POST /api/auth/me/mfa/disable", () => { it("revokes other sessions + trusted devices (via destroyOtherSessions), keeping the current one", async () => { const res = await POST(req()); expect(res.status).toBe(200); - expect(destroyOtherSessions).toHaveBeenCalledWith("u1", "sess-current"); + expect(destroyOtherSessions).toHaveBeenCalledWith("u1", { + kind: "session", + sessionId: "sess-current", + }); + }); + + it("spares the CALLER's own device login on the Bearer path", async () => { + // A Bearer caller has no session row. The earlier revision passed + // `session.id` — an ApiToken id on this arm — into a session-scoped query, + // so the "keep the current one" exclusion excluded nothing: every browser + // session went AND the caller's own refresh token was revoked, logging the + // app out at its next rotation. A cookie-shaped fixture could never see it. + vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "bearer", + user: { id: "u1" }, + apiTokenId: "token-row-1", + accessTokenHash: "hash-of-caller-access-token", + commitElevation: vi.fn().mockResolvedValue(undefined), + } as never); + + const res = await POST(req()); + + expect(res.status).toBe(200); + expect(destroyOtherSessions).toHaveBeenCalledWith("u1", { + kind: "accessToken", + accessTokenHash: "hash-of-caller-access-token", + }); + }); + + it("spends the elevation before tearing the factor down", async () => { + const commitElevation = vi.fn().mockResolvedValue(undefined); + vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "bearer", + user: { id: "u1" }, + apiTokenId: "token-row-1", + accessTokenHash: "h", + commitElevation, + } as never); + + await POST(req()); + + expect(commitElevation).toHaveBeenCalledTimes(1); + }); + + it("does NOT spend the elevation when the factor code is wrong", async () => { + const commitElevation = vi.fn().mockResolvedValue(undefined); + vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "bearer", + user: { id: "u1" }, + apiTokenId: "token-row-1", + accessTokenHash: "h", + commitElevation, + } as never); + vi.mocked(verifyMfaFactor).mockResolvedValue({ + ok: false, + replay: false, + } as never); + + const res = await POST(req()); + + expect(res.status).toBe(401); + expect(commitElevation).not.toHaveBeenCalled(); }); it("rejects a wrong current factor without revoking anything", async () => { diff --git a/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts index 30655630f..e7704c9ca 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts @@ -57,7 +57,10 @@ function verifyRequest(body: Record): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "cookie", user: USER, + session: { id: "sess-1" }, + commitElevation: vi.fn().mockResolvedValue(undefined), } as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); }); diff --git a/src/app/api/auth/me/sessions/__tests__/route.test.ts b/src/app/api/auth/me/sessions/__tests__/route.test.ts index 575dca26f..17bf18a59 100644 --- a/src/app/api/auth/me/sessions/__tests__/route.test.ts +++ b/src/app/api/auth/me/sessions/__tests__/route.test.ts @@ -157,7 +157,10 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.data.sessionsRevoked).toBe(3); - expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", "sess-current"); + expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", { + kind: "session", + sessionId: "sess-current", + }); }); }); diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts index bdbe007be..b68c70cd2 100644 --- a/src/lib/jobs/reminder/cleanup-handlers.ts +++ b/src/lib/jobs/reminder/cleanup-handlers.ts @@ -205,6 +205,10 @@ export async function handleIdempotencyCleanup( * 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[], From 29e8cd8c4fb4ddcc88935bb5e8abfd664641fa75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 16:51:03 +0200 Subject: [PATCH 7/9] test(integration): truncate step_up_elevations between runs --- tests/integration/setup.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index 56fdd8094..22c97536d 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -96,6 +96,7 @@ export async function truncateAllTables(client: PrismaClient): Promise { "refresh_tokens", "reminder_phase_configs", "sessions", + "step_up_elevations", "telegram_reminder_messages", "telegram_scheduled_deletions", "user_achievements", From a2fc6ea243bda40668cbe17a860e2fc66ed4b48a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 23 Jul 2026 09:16:03 +0200 Subject: [PATCH 8/9] fix(auth): reconcile step-up elevations with main at v1.32.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renumber the elevation migration from 0260 to 0267 — main claimed 0260 for an unrelated table while this branch sat unmerged, and 0266 is now the highest committed number. Two loose ends the merge itself did not touch: the schema comment on StepUpElevation.method still called it "audit/telemetry only, never an authorisation input" — the exact claim the fresh-factor fix (a prior commit on this branch) made false, since the method is now precisely what decides whether an elevation reaches the destructive MFA routes. Reworded to match. And pruneExpiredStepUpElevations in step-up.ts was dead code: the actual sweep runs in cleanup-handlers.ts against the worker Prisma client for reasons that function could never satisfy against the request-scoped one. Removed rather than left to drift. --- .../migration.sql | 0 prisma/schema.prisma | 9 ++++++--- src/lib/auth/step-up.ts | 20 +++++-------------- 3 files changed, 11 insertions(+), 18 deletions(-) rename prisma/migrations/{0260_step_up_elevation => 0267_step_up_elevation}/migration.sql (100%) diff --git a/prisma/migrations/0260_step_up_elevation/migration.sql b/prisma/migrations/0267_step_up_elevation/migration.sql similarity index 100% rename from prisma/migrations/0260_step_up_elevation/migration.sql rename to prisma/migrations/0267_step_up_elevation/migration.sql diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bcec3d1b9..61e8fff79 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3257,8 +3257,11 @@ model StepUpElevation { // 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" or "passkey". Audit/telemetry only — - // never an authorisation input. + // 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") @@ -3270,7 +3273,7 @@ model StepUpElevation { @@index([userId]) @@index([apiTokenId]) @@index([expiresAt]) - // NOTE: migration 0260 additionally creates a PARTIAL unique index — + // 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 diff --git a/src/lib/auth/step-up.ts b/src/lib/auth/step-up.ts index d3aa78834..b718e54c2 100644 --- a/src/lib/auth/step-up.ts +++ b/src/lib/auth/step-up.ts @@ -328,18 +328,8 @@ export async function revokeStepUpElevations(userId: string): Promise { await prisma.stepUpElevation.deleteMany({ where: { userId } }); } -/** - * Delete every elevation whose window has closed. - * - * Runs on the queue, not on a request. The earlier fire-and-forget sweep on the - * mint path was a cross-tenant unbounded DELETE competing with the request for a - * pool connection, and recurring work belongs on pg-boss. An expired row is - * already unredeemable — the predicate in `claimStepUpElevation` refuses it — so - * this is hygiene, not enforcement, and a missed run breaks nothing. - */ -export async function pruneExpiredStepUpElevations(): Promise { - const { count } = await prisma.stepUpElevation.deleteMany({ - where: { expiresAt: { lt: new Date() } }, - }); - return count; -} +// 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. From 22c92a7e9dc2fc789214ef6e9226d96ece199be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 23 Jul 2026 12:48:42 +0200 Subject: [PATCH 9/9] =?UTF-8?q?chore(release):=20v1.32.3=20=E2=80=94=20nat?= =?UTF-8?q?ive=20step-up=20elevations=20for=20iOS=202FA=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 21 +++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c18822e10..abd7cd83e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## [Unreleased] +## [1.32.3] — 2026-07-23 + +Security release. It ships on its own so the change to how a second factor is +proven can be watched in isolation. + +- **A native client can manage its own second factor.** The iOS app can now + turn two-factor authentication on or off, add or remove a security key, and + regenerate recovery codes over its API session — actions that were reachable + only from a browser before. It does so through a short-lived, single-use + elevation the app earns by re-proving a factor, never by presenting its + token alone. +- **The re-proved factor decides what the elevation reaches.** Re-proving with + a password reaches exactly what a plain browser session reaches; the actions + a browser gates behind a completed second factor now require the same over + the API — a re-proved TOTP code or security key, never the password alone. + Administrative endpoints remain reachable only from a browser session, + unchanged. + +One migration (0267), additive. Addresses the native 2FA-management gap +(healthlog-iOS #57). + ## [1.32.2] — 2026-07-23 Security release. Ships on its own and ahead of everything queued, because it diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e0268565f..c5b78d02f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.2 + version: 1.32.3 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 7472ddb99..a05dfc191 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.2", + "version": "1.32.3", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 11a5a8b8f..f06b7724d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.2"; + /* @sw-version-fallback */ "v1.32.3"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`