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