mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
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;
|
||||
@@ -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).
|
||||
//
|
||||
|
||||
Reference in new issue
Block a user