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.", + });