feat(auth): add token-bound step-up elevations for second-factor management

Second-factor management has been cookie-only since v1.23. The gate is right:
a Bearer token is a credential whose provenance the server cannot see, and
tearing down a second factor must not be reachable from the weaker of an
interactive login and a value pasted into a script. What was missing was never
Bearer access to those routes — it was a way to say, over a transport with no
session row, that the human holding this device just re-proved a factor.

An elevation is that proof. It is minted only against a re-proved account
password or primary passkey, bound to the exact token that re-proved, single-use
via a conditional UPDATE, five-minute-lived to match the cookie path's step-up
window, and stored hashed. A stolen token mints nothing; a second token cannot
redeem another's elevation; a password change drops every one the account holds.

requireMfaManagementAuth is the one gate the management routes share. On a
cookie session it delegates to the original helpers unchanged, so the web flow
runs the code it always did. requireAdmin stays cookie-only and out of reach.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 12:42:00 +02:00
1 parent 571e415eaa
commit e557d9e9e6
19 files changed
+808 -51

No files matched your search

@@ -0,0 +1,32 @@
-- v1.30.34 — token-bound step-up elevations.
--
-- The Bearer-transport equivalent of `sessions.mfa_verified_at`. A row is
-- minted only after a password or primary-passkey re-proof, is bound to the
-- exact API token that re-proved, is single-use (`consumed_at`), and expires
-- within minutes. Only the HMAC of the opaque secret is stored.
CREATE TABLE "step_up_elevations" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"api_token_id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"method" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"consumed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "step_up_elevations_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "step_up_elevations_token_hash_key" ON "step_up_elevations"("token_hash");
CREATE INDEX "step_up_elevations_user_id_idx" ON "step_up_elevations"("user_id");
CREATE INDEX "step_up_elevations_api_token_id_idx" ON "step_up_elevations"("api_token_id");
CREATE INDEX "step_up_elevations_expires_at_idx" ON "step_up_elevations"("expires_at");
ALTER TABLE "step_up_elevations" ADD CONSTRAINT "step_up_elevations_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Cascade on the token binding: revoking or deleting a token must never leave a
-- redeemable elevation behind.
ALTER TABLE "step_up_elevations" ADD CONSTRAINT "step_up_elevations_api_token_id_fkey"
FOREIGN KEY ("api_token_id") REFERENCES "api_tokens"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+53
View File
@@ -658,6 +658,10 @@ model User {
// skips the second factor (factor 2 only) within the window; the password
// is still required. Revoked when the second factor is removed.
trustedDevices TrustedDevice[]
// v1.30.34 — short-lived, token-bound step-up elevations. The Bearer-transport
// equivalent of `Session.mfaVerifiedAt`: a token client re-proves a factor and
// receives one single-use elevation good for one MFA-management call.
stepUpElevations StepUpElevation[]
measurements Measurement[]
medications Medication[]
intakeEvents MedicationIntakeEvent[]
@@ -3103,12 +3107,61 @@ model ApiToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull)
// v1.30.34 — step-up elevations bound to this token. Cascade-deleted with it.
stepUpElevations StepUpElevation[]
@@index([userId])
@@index([mcpConnectionId])
@@map("api_tokens")
}
// v1.30.34 — a step-up elevation: the Bearer-transport answer to
// `Session.mfaVerifiedAt`.
//
// The web gates second-factor management on a freshness stamp written onto a
// session row. A token client has no session row, so it had no way to express
// "the human holding this device just re-proved a factor" and the whole
// MFA-management surface was unreachable from a native client.
//
// A row here is that proof, and it is deliberately the weakest credential in
// the tree:
// - it is minted ONLY after a password or primary-passkey re-proof, so
// presenting a stolen token gains nothing;
// - `apiTokenId` binds it to the exact token that re-proved, so a second
// token — the same user's included — cannot redeem it;
// - `consumedAt` makes it single-use, claimed by a conditional UPDATE so two
// concurrent redemptions cannot both win;
// - `expiresAt` caps it at minutes, matching the cookie path's step-up window.
//
// Only the hash of the opaque `hle_` secret is stored, so a table dump yields
// nothing redeemable — the same posture as ApiToken, RefreshToken, and Session.
// Revoking the token strands every elevation bound to it by construction:
// redemption runs behind Bearer resolution, which refuses a revoked token.
model StepUpElevation {
id String @id @default(cuid())
userId String @map("user_id")
// The `ApiToken` row this elevation is bound to. Cascade so a deleted token
// cannot leave a redeemable elevation behind.
apiTokenId String @map("api_token_id")
// HMAC-SHA256 (`hashToken`) of the raw `hle_<64 hex>` secret. Unique so a
// redemption is a single indexed lookup and a collision cannot alias two rows.
tokenHash String @unique @map("token_hash")
// Which factor was re-proved: "password" or "passkey". Audit/telemetry only —
// never an authorisation input.
method String
expiresAt DateTime @map("expires_at")
consumedAt DateTime? @map("consumed_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
apiToken ApiToken @relation(fields: [apiTokenId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([apiTokenId])
@@index([expiresAt])
@@map("step_up_elevations")
}
// MCP OAuth connection — the revocable, theft-detectable anchor for a remote
// connector's refresh-token chain (Phase 3 security fix, H2).
//