mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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).
|
||||
//
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* POST /api/auth/me/mfa/disable
|
||||
*
|
||||
* Tear down the second factor. Double-gated:
|
||||
* 1. `requireFreshMfa` — a cookie session that completed a second factor
|
||||
* within the step-up window (Bearer can never satisfy this).
|
||||
* 1. `requireMfaManagementAuth({ freshFactor: true })` — a cookie session that
|
||||
* completed a second factor within the step-up window, OR a Bearer token
|
||||
* presenting a single-use elevation it minted by re-proving a factor.
|
||||
* 2. A current TOTP or recovery code in the body — proves live possession at
|
||||
* the moment of the destructive action, not just a stale fresh-session.
|
||||
*
|
||||
@@ -12,9 +13,8 @@
|
||||
*/
|
||||
import {
|
||||
apiHandler,
|
||||
requireFreshMfa,
|
||||
requireMfaManagementAuth,
|
||||
HttpError,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
@@ -38,7 +38,9 @@ const DISABLE_WINDOW_MS = 15 * 60 * 1000;
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
// Step-up gate first — throws StepUpRequiredError (401 + errorCode) if the
|
||||
// session is not freshly second-factor-verified.
|
||||
const { user, session } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
const { user, session } = await requireMfaManagementAuth({
|
||||
freshFactor: true,
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:disable:${user.id}`,
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
* POST /api/auth/me/mfa/recovery-codes/regenerate
|
||||
*
|
||||
* Issue a fresh recovery-code batch, invalidating the entire prior set in one
|
||||
* transaction. Step-up gated (`requireFreshMfa`) — a recovery-code rotation is
|
||||
* a sensitive action; Bearer can never satisfy the gate. The new codes are
|
||||
* returned **once**.
|
||||
* transaction. Step-up gated (`requireMfaManagementAuth({ freshFactor: true })`)
|
||||
* — a recovery-code rotation is sensitive, so it takes either a freshly
|
||||
* second-factor-verified cookie session or a single-use step-up elevation minted
|
||||
* by the calling token. The new codes are returned **once**.
|
||||
*/
|
||||
import {
|
||||
apiHandler,
|
||||
requireFreshMfa,
|
||||
requireMfaManagementAuth,
|
||||
HttpError,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -24,7 +24,7 @@ const REGEN_RATE_LIMIT = 5;
|
||||
const REGEN_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
const { user } = await requireMfaManagementAuth({ freshFactor: true });
|
||||
|
||||
// Defence in depth — regeneration only makes sense for an active factor.
|
||||
if (!user.totpConfirmedAt) {
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
*
|
||||
* Second-factor status for the security settings hub: whether TOTP is active,
|
||||
* how many recovery codes remain, and the registered WebAuthn security keys.
|
||||
* Cookie-only (`requireCookieAuth`) — managing MFA is never a Bearer surface.
|
||||
* Gated by `requireMfaManagementAuth`: a cookie session, or a Bearer token
|
||||
* presenting a single-use step-up elevation. A token alone is not enough.
|
||||
*
|
||||
* Returns only metadata: no secret, no recovery-code plaintext, no public key.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -16,7 +17,7 @@ import { countRemainingRecoveryCodes } from "@/lib/auth/mfa/recovery-codes";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { user } = await requireMfaManagementAuth();
|
||||
|
||||
const [recoveryCodesRemaining, webauthn] = await Promise.all([
|
||||
countRemainingRecoveryCodes(user.id),
|
||||
|
||||
@@ -6,13 +6,21 @@
|
||||
* the accepted time-step (replay guard), generates + persists the Argon2id-
|
||||
* hashed recovery codes, and returns the codes **once** for the user to save.
|
||||
*
|
||||
* The completing session is also stamped `mfaVerifiedAt = now()` so the user
|
||||
* can immediately manage the freshly-enabled factor (disable / regenerate
|
||||
* codes) without an extra step-up round-trip.
|
||||
* On the cookie path the completing session is stamped `mfaVerifiedAt = now()`
|
||||
* so the user can immediately manage the freshly-enabled factor (disable /
|
||||
* regenerate codes) without an extra step-up round-trip. A Bearer caller has no
|
||||
* session row to stamp — the stamping `updateMany` simply matches nothing — and
|
||||
* mints a fresh elevation per action instead.
|
||||
*
|
||||
* Cookie-only (`requireCookieAuth`): an API token can never enrol MFA.
|
||||
* Gated by `requireMfaManagementAuth`: a cookie session, or a Bearer token
|
||||
* presenting a single-use step-up elevation. A token on its own can still never
|
||||
* enrol MFA.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
requireMfaManagementAuth,
|
||||
HttpError,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -38,7 +46,7 @@ const CONFIRM_RATE_LIMIT = 10;
|
||||
const CONFIRM_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user, session } = await requireCookieAuth();
|
||||
const { user, session } = await requireMfaManagementAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:confirm:${user.id}`,
|
||||
|
||||
@@ -8,11 +8,17 @@
|
||||
* not active until `/confirm` verifies a code, so `totpConfirmedAt` stays
|
||||
* null here.
|
||||
*
|
||||
* Cookie-only (`requireCookieAuth`): an API token can never enrol MFA. The
|
||||
* recovery-code batch is issued at `/confirm` (after the factor is proven),
|
||||
* not here, so an abandoned setup never persists codes.
|
||||
* Gated by `requireMfaManagementAuth`: a cookie session, or a Bearer token
|
||||
* presenting a single-use step-up elevation minted against a re-proved factor.
|
||||
* A token on its own can still never enrol MFA. The recovery-code batch is
|
||||
* issued at `/confirm` (after the factor is proven), not here, so an abandoned
|
||||
* setup never persists codes.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
requireMfaManagementAuth,
|
||||
HttpError,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -27,7 +33,7 @@ const SETUP_RATE_LIMIT = 5;
|
||||
const SETUP_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { user } = await requireMfaManagementAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:setup:${user.id}`,
|
||||
|
||||
@@ -2,17 +2,14 @@
|
||||
* PATCH /api/auth/me/mfa/webauthn/[id] — rename a registered security key.
|
||||
* DELETE /api/auth/me/mfa/webauthn/[id] — remove one (step-up gated).
|
||||
*
|
||||
* Rename is cookie-authenticated (non-destructive). Removal is a sensitive
|
||||
* action: it is gated on a fresh second-factor step-up (`requireFreshMfa`,
|
||||
* cookie-only — a Bearer token can never satisfy it).
|
||||
* Both go through `requireMfaManagementAuth`: a cookie session, or a Bearer
|
||||
* token presenting a single-use step-up elevation. Removal additionally demands
|
||||
* the fresh-factor arm (`freshFactor: true`) — on the cookie path a session that
|
||||
* completed a second factor inside the step-up window, on the Bearer path an
|
||||
* elevation minted against a re-proved factor.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
apiHandler,
|
||||
requireCookieAuth,
|
||||
requireFreshMfa,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -31,7 +28,7 @@ export const PATCH = apiHandler(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { user } = await requireMfaManagementAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
@@ -70,7 +67,7 @@ export const DELETE = apiHandler(
|
||||
) => {
|
||||
// Step-up gate first — throws StepUpRequiredError (401 + errorCode) if the
|
||||
// session is not freshly second-factor-verified.
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
const { user } = await requireMfaManagementAuth({ freshFactor: true });
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.webauthnMfaCredential.findUnique({
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* POST /api/auth/me/mfa/webauthn/register/options
|
||||
*
|
||||
* Begin registering a WebAuthn security key as a second factor. Cookie-only —
|
||||
* an API token can never enrol MFA. Returns the SimpleWebAuthn creation
|
||||
* options + the server-issued challenge id to present back at /register/verify.
|
||||
* Begin registering a WebAuthn security key as a second factor. Takes a cookie
|
||||
* session or a Bearer token presenting a single-use step-up elevation; a token
|
||||
* on its own can never enrol MFA. Returns the SimpleWebAuthn creation options +
|
||||
* the server-issued challenge id to present back at /register/verify.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -14,7 +15,7 @@ import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { user } = await requireMfaManagementAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:webauthn:register:${user.id}`,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* POST /api/auth/me/mfa/webauthn/register/verify
|
||||
*
|
||||
* Finish registering a WebAuthn security key as a second factor. Cookie-only.
|
||||
* Verifies the attestation against the user-bound challenge and stores the
|
||||
* credential in `WebauthnMfaCredential` (kept separate from primary passkeys).
|
||||
* Finish registering a WebAuthn security key as a second factor. Takes a cookie
|
||||
* session or a Bearer token presenting a single-use step-up elevation. Verifies
|
||||
* the attestation against the user-bound challenge and stores the credential in
|
||||
* `WebauthnMfaCredential` (kept separate from primary passkeys).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -23,7 +24,7 @@ import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { user } = await requireMfaManagementAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
|
||||
@@ -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,51 @@
|
||||
/**
|
||||
* POST /api/auth/step-up/options
|
||||
*
|
||||
* Begin a primary-passkey assertion for the step-up mint below. Returns
|
||||
* SimpleWebAuthn assertion options scoped to the calling account's registered
|
||||
* passkeys, plus the server-issued challenge id to present back at
|
||||
* `POST /api/auth/step-up`.
|
||||
*
|
||||
* Bearer-only (`requireBearerAuth`) — a browser has no use for this surface; it
|
||||
* re-proves a factor at login and carries the result on its session row. The
|
||||
* allow-list is never empty by construction: an account with no passkey gets a
|
||||
* 409 telling the client to use the password arm instead.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireBearerAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { createAuthenticationOptions } from "@/lib/auth/passkey";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireBearerAuth();
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:step-up",
|
||||
20,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please try again later.", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const passkeyCount = await prisma.passkey.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
if (passkeyCount === 0) {
|
||||
annotate({ action: { name: "auth.stepup.passkey.none" } });
|
||||
return apiError("No passkey registered", 409);
|
||||
}
|
||||
|
||||
const { options, challengeId } = await createAuthenticationOptions(user.id);
|
||||
|
||||
annotate({ action: { name: "auth.stepup.passkey.options" } });
|
||||
return apiSuccess({ options, challengeId });
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* POST /api/auth/step-up
|
||||
*
|
||||
* Re-prove a factor and receive a single-use, token-bound step-up elevation for
|
||||
* one second-factor-management call.
|
||||
*
|
||||
* This is the whole point of the mechanism, so it is worth being blunt about
|
||||
* what it is not: it is NOT a way to turn a token into a stronger credential.
|
||||
* Presenting the token gets you as far as this endpoint and no further — the
|
||||
* body must carry a fresh proof of the account password or of a primary passkey
|
||||
* the device holds. A stolen token can call this all day and mint nothing.
|
||||
*
|
||||
* Bearer-only (`requireBearerAuth`). A browser cannot reach it even with a valid
|
||||
* session, which keeps an ambient cookie credential out of the mint path
|
||||
* entirely.
|
||||
*
|
||||
* Failures are deliberately indistinguishable. A wrong password, an account with
|
||||
* no password set, a passkey belonging to someone else, a stale challenge — all
|
||||
* return the same 401 with the same prose. The audit row carries the real reason;
|
||||
* the wire carries none of it.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireBearerAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
checkRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import {
|
||||
mintStepUpElevation,
|
||||
STEP_UP_ELEVATION_TTL_SECONDS,
|
||||
type StepUpMethod,
|
||||
} from "@/lib/auth/step-up";
|
||||
import { stepUpMintSchema } from "@/lib/validations/step-up";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Per-account ceiling. Tight because the password arm runs an Argon2id verify
|
||||
* against a user-supplied string — the same 5-per-15-minutes the password-change
|
||||
* route uses, for the same reason.
|
||||
*/
|
||||
const MINT_LIMIT = 5;
|
||||
const MINT_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const auth = await requireBearerAuth();
|
||||
const { user } = auth;
|
||||
// `requireAuth`'s contract puts the resolved `ApiToken` row id here. It is the
|
||||
// binding the minted elevation is tied to.
|
||||
const apiTokenId = auth.session.id;
|
||||
const ip = getClientIp(request);
|
||||
|
||||
// Two buckets. The per-IP surface bucket collapses to the tight shared anon
|
||||
// bucket when the proxy trust chain is broken, so a misconfigured deployment
|
||||
// caps a spray rather than opening one.
|
||||
const ipRl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:step-up",
|
||||
20,
|
||||
MINT_WINDOW_MS,
|
||||
);
|
||||
if (!ipRl.allowed) {
|
||||
return apiError("Too many attempts. Please try again later.", 429, {
|
||||
headers: rateLimitHeaders(ipRl),
|
||||
});
|
||||
}
|
||||
|
||||
const userRl = await checkRateLimit(
|
||||
`auth:step-up:${user.id}`,
|
||||
MINT_LIMIT,
|
||||
MINT_WINDOW_MS,
|
||||
);
|
||||
if (!userRl.allowed) {
|
||||
await auditLog("auth.stepup.mint.rate_limited", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
annotate({ action: { name: "auth.stepup.mint.rate_limited" } });
|
||||
return apiError("Too many attempts. Please wait 15 minutes.", 429, {
|
||||
headers: rateLimitHeaders(userRl),
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = stepUpMintSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
|
||||
let proved = false;
|
||||
let method: StepUpMethod = parsed.data.method;
|
||||
// Audit-only detail. Never reaches the response — see the file header.
|
||||
let failure = "invalid";
|
||||
|
||||
if (parsed.data.method === "password") {
|
||||
if (!user.passwordHash) {
|
||||
// An SSO-provisioned account has no password to re-prove. Same refusal as
|
||||
// a wrong one: the account's credential shape is not a token holder's to
|
||||
// enumerate. `/api/auth/step-up/options` is the honest discovery path —
|
||||
// it 409s when there is no passkey either.
|
||||
failure = "no_password";
|
||||
} else {
|
||||
proved = await verifyPassword(user.passwordHash, parsed.data.password);
|
||||
if (!proved) failure = "bad_password";
|
||||
}
|
||||
} else {
|
||||
method = "passkey";
|
||||
// Bind the challenge to the caller BEFORE verifying. `verifyAuthentication`
|
||||
// resolves a challenge by id alone; pinning ownership here means a challenge
|
||||
// minted for another account cannot be carried into this ceremony.
|
||||
const challenge = await prisma.authChallenge.findUnique({
|
||||
where: { id: parsed.data.challengeId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!challenge || challenge.userId !== user.id) {
|
||||
failure = "foreign_challenge";
|
||||
} else {
|
||||
try {
|
||||
const result = await verifyAuthentication(
|
||||
parsed.data.challengeId,
|
||||
parsed.data.credential,
|
||||
);
|
||||
// Both halves matter: a verified assertion against SOMEONE ELSE'S
|
||||
// passkey proves possession of a factor, just not of this account's.
|
||||
proved =
|
||||
result.verification.verified && result.passkey.userId === user.id;
|
||||
if (!proved) failure = "bad_assertion";
|
||||
} catch {
|
||||
// A malformed / expired challenge is a failed attempt, not a 500.
|
||||
failure = "bad_assertion";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!proved) {
|
||||
await auditLog("auth.stepup.mint.failed", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { method: parsed.data.method, reason: failure },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.stepup.mint.failed" },
|
||||
meta: { method: parsed.data.method, reason: failure },
|
||||
});
|
||||
return apiError("Verification failed", 401);
|
||||
}
|
||||
|
||||
const { token, expiresAt } = await mintStepUpElevation({
|
||||
userId: user.id,
|
||||
apiTokenId,
|
||||
method,
|
||||
});
|
||||
|
||||
await auditLog("auth.stepup.mint.succeeded", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { method },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.stepup.mint.succeeded" },
|
||||
meta: { method },
|
||||
});
|
||||
|
||||
// `elevation` is the raw secret and is returned exactly once. It is never
|
||||
// persisted in plaintext and never logged — no wide-event field carries a
|
||||
// response body, and `redactSecrets` holds an `hle_` rule regardless.
|
||||
return apiSuccess({
|
||||
elevation: token,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
expiresInSeconds: STEP_UP_ELEVATION_TTL_SECONDS,
|
||||
});
|
||||
});
|
||||
+149
-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,10 @@ import {
|
||||
BearerAuthError,
|
||||
type ScopeRequirement,
|
||||
} from "./auth/bearer";
|
||||
import {
|
||||
redeemStepUpElevation,
|
||||
STEP_UP_ELEVATION_TTL_SECONDS,
|
||||
} from "./auth/step-up";
|
||||
import { AssistantDisabledError } from "./feature-flags";
|
||||
import { ConsentRequiredError } from "./ai/consent-guard";
|
||||
import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config";
|
||||
@@ -620,6 +624,150 @@ export async function requireFreshMfaIfEnrolled(
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.30.34 — resolve a caller by Bearer token ONLY, refusing a cookie session.
|
||||
*
|
||||
* The mirror image of `requireCookieAuth`, and it exists for one surface: the
|
||||
* step-up mint endpoints, which are the Bearer transport's own re-authentication
|
||||
* flow and have no meaning for a browser (a browser re-proves a factor at login
|
||||
* and carries the result on its session row). Refusing the cookie keeps the mint
|
||||
* surface entirely outside the cookie's blast radius — no ambient credential can
|
||||
* reach it, so the class of attack where a browser is induced to fire a request
|
||||
* on the user's behalf simply does not apply.
|
||||
*
|
||||
* Scope handling is the standard fail-closed default: no declared scope, so only
|
||||
* a cookie-equivalent (`["*"]`) token is admitted. A narrow token — an MCP grant,
|
||||
* a medication-ingest grant — is refused 403 by the resolver.
|
||||
*/
|
||||
export async function requireBearerAuth(): Promise<AuthContext> {
|
||||
let authHeader: string | null = null;
|
||||
try {
|
||||
const headerList = await headers();
|
||||
authHeader = headerList.get("authorization");
|
||||
} catch {
|
||||
authHeader = null;
|
||||
}
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
throw new HttpError(401, "Not authenticated");
|
||||
}
|
||||
return await authenticateBearer(authHeader.slice(7), undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Header carrying a step-up elevation on the Bearer path. Never logged — the
|
||||
* wide event captures no request headers at all, and `redactSecrets` carries an
|
||||
* `hle_` rule as a second line of defence.
|
||||
*/
|
||||
export const STEP_UP_ELEVATION_HEADER = "x-step-up";
|
||||
|
||||
/**
|
||||
* v1.30.34 — the single gate for second-factor management.
|
||||
*
|
||||
* Every MFA-management route goes through here and nothing else does. The set
|
||||
* is frozen by `src/__tests__/step-up-elevation-guard.test.ts`, so a future
|
||||
* route cannot quietly join it: widening the reach of an elevation has to be a
|
||||
* visible edit to that allowlist.
|
||||
*
|
||||
* Two accepted proofs, and they are equals rather than a primary and a fallback:
|
||||
*
|
||||
* COOKIE — unchanged, byte for byte. A cookie session delegates to
|
||||
* `requireCookieAuth` / `requireFreshMfa` exactly as before this function
|
||||
* existed. The web flow cannot regress here because there is no new code on
|
||||
* its path; the elevation branch is only reached when there is no session at
|
||||
* all.
|
||||
*
|
||||
* BEARER + ELEVATION — a token that resolves cleanly AND presents a valid,
|
||||
* unconsumed elevation minted for that same token against a re-proved factor.
|
||||
* The token alone is never enough: with no header, or a header that does not
|
||||
* redeem, the caller gets the same `auth.stepup.required` a stale cookie
|
||||
* session gets.
|
||||
*
|
||||
* `requireAdmin` is untouched and stays cookie-only. An elevation cannot reach
|
||||
* it — not because a check refuses one, but because `requireAdmin` resolves
|
||||
* through `getSession()` and never consults this function or the header.
|
||||
*
|
||||
* @param options.freshFactor mirrors the cookie path's `requireFreshMfa`: the
|
||||
* account must actually have a second factor enrolled. Set by the destructive
|
||||
* routes (disable, recovery-code rotation, security-key removal).
|
||||
*/
|
||||
export async function requireMfaManagementAuth(
|
||||
options: { freshFactor?: boolean } = {},
|
||||
): Promise<AuthContext> {
|
||||
// Cookie first, and via the original helpers — the web path runs the same
|
||||
// code it always did.
|
||||
const sessionData = await getSession();
|
||||
if (sessionData) {
|
||||
return options.freshFactor
|
||||
? await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS)
|
||||
: await requireCookieAuth();
|
||||
}
|
||||
|
||||
// Bearer path. Resolution first: an unknown, revoked, expired, or narrow-scope
|
||||
// token is refused here and never gets as far as presenting an elevation.
|
||||
const auth = await requireBearerAuth();
|
||||
const apiTokenId = auth.session.id;
|
||||
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
const headerList = await headers();
|
||||
raw = headerList.get(STEP_UP_ELEVATION_HEADER);
|
||||
} catch {
|
||||
raw = null;
|
||||
}
|
||||
|
||||
if (!raw) {
|
||||
annotate({
|
||||
action: { name: "auth.stepup.elevation.missing" },
|
||||
meta: { maxAgeSeconds: STEP_UP_ELEVATION_TTL_SECONDS },
|
||||
});
|
||||
throw new StepUpRequiredError();
|
||||
}
|
||||
|
||||
const redeemed = await redeemStepUpElevation({
|
||||
rawToken: raw,
|
||||
userId: auth.user.id,
|
||||
apiTokenId,
|
||||
});
|
||||
|
||||
if (!redeemed.ok) {
|
||||
// One audit row with the machine reason, one generic refusal on the wire.
|
||||
// A prober learns only "not accepted" — never whether the elevation was
|
||||
// unknown, already spent, expired, or minted for a different token.
|
||||
auditLog("auth.stepup.elevation.rejected", {
|
||||
userId: auth.user.id,
|
||||
details: { reason: redeemed.reason },
|
||||
}).catch(() => {});
|
||||
annotate({
|
||||
action: { name: "auth.stepup.elevation.rejected" },
|
||||
meta: { reason: redeemed.reason },
|
||||
});
|
||||
throw new StepUpRequiredError();
|
||||
}
|
||||
|
||||
// Parity with the cookie path: `requireFreshMfa` refuses an account with no
|
||||
// second factor enrolled, because a step-up-gated action is meaningless there.
|
||||
// The Bearer path holds the same line rather than becoming the softer route.
|
||||
if (options.freshFactor) {
|
||||
let enrolled = Boolean(auth.user.totpConfirmedAt);
|
||||
if (!enrolled) {
|
||||
const keys = await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: auth.user.id },
|
||||
});
|
||||
enrolled = keys > 0;
|
||||
}
|
||||
if (!enrolled) {
|
||||
throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled");
|
||||
}
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.stepup.elevation.accepted" },
|
||||
meta: { method: redeemed.method },
|
||||
});
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report unhandled errors to GlitchTip (fire-and-forget).
|
||||
* Uses dynamic import to avoid circular dependencies and startup cost.
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Token-bound step-up elevations — the Bearer-transport answer to
|
||||
* `Session.mfaVerifiedAt`.
|
||||
*
|
||||
* WHY THIS EXISTS. Second-factor management (enrol TOTP, confirm it, disable
|
||||
* it, rotate recovery codes, add or remove a security key) is gated cookie-only.
|
||||
* That gate is correct and stays: a Bearer token is a credential whose
|
||||
* provenance the server cannot see — it may have come from an interactive login
|
||||
* on the owner's phone, or from a value pasted into a script on a shared
|
||||
* machine. Tearing down someone's second factor must not be reachable from the
|
||||
* weaker of those two.
|
||||
*
|
||||
* What was missing was never Bearer ACCESS to those routes. It was a way to
|
||||
* express, over a transport that has no session row, "the human holding this
|
||||
* device just re-proved a factor". The web writes that as a timestamp on the
|
||||
* session; a token client has no session to stamp. An elevation is that proof,
|
||||
* made explicit and made disposable.
|
||||
*
|
||||
* THE PROPERTIES, and why each one is load-bearing:
|
||||
*
|
||||
* Minted only against a re-proved factor. Presenting the token alone yields
|
||||
* nothing — that is the entire point. A stolen token gains no more reach than
|
||||
* it had before this module existed.
|
||||
*
|
||||
* Bound to one token (`apiTokenId`). An elevation minted by the phone cannot
|
||||
* be redeemed by a second token, not even another of the same user's. Theft of
|
||||
* the elevation in transit is therefore useless without also holding the exact
|
||||
* token it was minted for.
|
||||
*
|
||||
* Single-use. Redemption is a conditional UPDATE (see `redeemStepUpElevation`)
|
||||
* so two concurrent redemptions cannot both succeed. One management action per
|
||||
* proof, by construction rather than by convention.
|
||||
*
|
||||
* Short-lived. `STEP_UP_ELEVATION_TTL_SECONDS` mirrors the cookie path's
|
||||
* `MFA_STEP_UP_MAX_AGE_SECONDS` exactly, so both transports carry the same
|
||||
* freshness promise and neither is the softer way in.
|
||||
*
|
||||
* Stored hashed. Only `hashToken(secret)` reaches the database — the same
|
||||
* posture as ApiToken, RefreshToken, and the session secret. A table dump
|
||||
* yields nothing redeemable, and the raw value exists only in the mint
|
||||
* response and the client's memory.
|
||||
*
|
||||
* INVALIDATION comes from two directions. A password change calls
|
||||
* `revokeStepUpElevations` outright. A revoked or deleted token strands its
|
||||
* elevations without any code here running: redemption sits behind Bearer
|
||||
* resolution, which refuses a revoked token, and the FK cascades on delete.
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
|
||||
/**
|
||||
* Marks a value as a step-up elevation. Distinct from every other credential
|
||||
* prefix in the tree (`hlk_` access, `hlr_` refresh, `hls_` session,
|
||||
* `hlh_` OIDC handoff) so a value can never be mistaken for the wrong kind.
|
||||
*/
|
||||
const ELEVATION_PREFIX = "hle_";
|
||||
const ELEVATION_SECRET_BYTES = 32;
|
||||
|
||||
/**
|
||||
* How long an elevation stays redeemable: five minutes.
|
||||
*
|
||||
* Chosen to equal `MFA_STEP_UP_MAX_AGE_SECONDS`, the window the cookie path has
|
||||
* used for step-up since v1.23, rather than picked independently. Two transports
|
||||
* guarding the same actions should not offer two different freshness promises —
|
||||
* if they diverged, the looser one would simply become the way in. Five minutes
|
||||
* also sits at the tight end of the 5–15 minute band OWASP recommends for
|
||||
* re-authentication, which is where destructive actions belong; it is long
|
||||
* enough to type a code from an authenticator app and short enough that an
|
||||
* elevation captured off a screen or a proxy log is stale before it is useful.
|
||||
*
|
||||
* A test pins the two constants together so a future edit to one is caught.
|
||||
*/
|
||||
export const STEP_UP_ELEVATION_TTL_SECONDS = 5 * 60;
|
||||
|
||||
/** Which factor was re-proved. Recorded for audit; never an authorisation input. */
|
||||
export type StepUpMethod = "password" | "passkey";
|
||||
|
||||
export interface MintedElevation {
|
||||
/** The raw `hle_<64 hex>` value. Returned once; never persisted, never logged. */
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint an elevation for `(userId, apiTokenId)` after a factor has been re-proved.
|
||||
*
|
||||
* The caller is responsible for having verified the factor — this function does
|
||||
* not check anything, it records a decision already made. Keep the two adjacent
|
||||
* so that stays obvious.
|
||||
*
|
||||
* Any earlier unconsumed elevation for the same token is deleted first. A fresh
|
||||
* proof supersedes an older one, so there is never more than one redeemable
|
||||
* elevation per token; that both bounds the table and shrinks the window in
|
||||
* which a captured elevation is worth anything.
|
||||
*/
|
||||
export async function mintStepUpElevation(opts: {
|
||||
userId: string;
|
||||
apiTokenId: string;
|
||||
method: StepUpMethod;
|
||||
}): Promise<MintedElevation> {
|
||||
const raw = `${ELEVATION_PREFIX}${randomBytes(ELEVATION_SECRET_BYTES).toString("hex")}`;
|
||||
const expiresAt = new Date(Date.now() + STEP_UP_ELEVATION_TTL_SECONDS * 1000);
|
||||
|
||||
await prisma.stepUpElevation.deleteMany({
|
||||
where: { apiTokenId: opts.apiTokenId, consumedAt: null },
|
||||
});
|
||||
|
||||
await prisma.stepUpElevation.create({
|
||||
data: {
|
||||
userId: opts.userId,
|
||||
apiTokenId: opts.apiTokenId,
|
||||
tokenHash: hashToken(raw),
|
||||
method: opts.method,
|
||||
expiresAt,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Opportunistic housekeeping — cheap, indexed, and keeps the table from
|
||||
// accumulating spent rows without needing a scheduled job for a table that
|
||||
// only ever sees a handful of rows per user per year.
|
||||
prisma.stepUpElevation
|
||||
.deleteMany({ where: { expiresAt: { lt: new Date() } } })
|
||||
.catch(() => {});
|
||||
|
||||
return { token: raw, expiresAt };
|
||||
}
|
||||
|
||||
/** Why a redemption was refused. Audit detail only — never surfaced to a caller. */
|
||||
export type StepUpRedeemFailure =
|
||||
"malformed" | "unknown" | "wrong_token" | "consumed" | "expired";
|
||||
|
||||
export type StepUpRedeemResult =
|
||||
| { ok: true; method: StepUpMethod }
|
||||
| { ok: false; reason: StepUpRedeemFailure };
|
||||
|
||||
/**
|
||||
* Consume an elevation, or refuse.
|
||||
*
|
||||
* SINGLE-USE ATOMICITY. The claim is one conditional `UPDATE ... WHERE
|
||||
* token_hash = $1 AND user_id = $2 AND api_token_id = $3 AND consumed_at IS NULL
|
||||
* AND expires_at > $4` (Prisma's `updateMany`), and the caller proceeds only on
|
||||
* `count === 1`. Postgres under READ COMMITTED serialises the two writers on the
|
||||
* row lock: the loser re-evaluates the predicate against the winner's committed
|
||||
* version, finds `consumed_at` no longer null, and matches zero rows. There is
|
||||
* no read-then-write window to lose, which is exactly why the check-then-update
|
||||
* shape was not used.
|
||||
*
|
||||
* Both binding fields are in the predicate rather than checked afterwards, so a
|
||||
* mismatched token cannot be claimed and then rejected — it is never claimed at
|
||||
* all, and the legitimate holder's elevation survives someone else's attempt.
|
||||
*/
|
||||
export async function redeemStepUpElevation(opts: {
|
||||
rawToken: string;
|
||||
userId: string;
|
||||
apiTokenId: string;
|
||||
}): Promise<StepUpRedeemResult> {
|
||||
if (!opts.rawToken.startsWith(ELEVATION_PREFIX)) {
|
||||
return { ok: false, reason: "malformed" };
|
||||
}
|
||||
|
||||
const tokenHash = hashToken(opts.rawToken);
|
||||
const now = new Date();
|
||||
|
||||
const claimed = await prisma.stepUpElevation.updateMany({
|
||||
where: {
|
||||
tokenHash,
|
||||
userId: opts.userId,
|
||||
apiTokenId: opts.apiTokenId,
|
||||
consumedAt: null,
|
||||
expiresAt: { gt: now },
|
||||
},
|
||||
data: { consumedAt: now },
|
||||
});
|
||||
|
||||
if (claimed.count === 1) {
|
||||
const row = await prisma.stepUpElevation.findUnique({
|
||||
where: { tokenHash },
|
||||
select: { method: true },
|
||||
});
|
||||
return { ok: true, method: (row?.method as StepUpMethod) ?? "password" };
|
||||
}
|
||||
|
||||
// Classify the refusal for the audit row only. The caller returns one generic
|
||||
// response for every branch below, so this read tells an operator what
|
||||
// happened without telling a prober anything.
|
||||
const existing = await prisma.stepUpElevation.findUnique({
|
||||
where: { tokenHash },
|
||||
select: {
|
||||
apiTokenId: true,
|
||||
userId: true,
|
||||
consumedAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
});
|
||||
if (!existing) return { ok: false, reason: "unknown" };
|
||||
if (
|
||||
existing.userId !== opts.userId ||
|
||||
existing.apiTokenId !== opts.apiTokenId
|
||||
) {
|
||||
return { ok: false, reason: "wrong_token" };
|
||||
}
|
||||
if (existing.consumedAt) return { ok: false, reason: "consumed" };
|
||||
return { ok: false, reason: "expired" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every elevation for a user.
|
||||
*
|
||||
* Called when the account password changes: an elevation is a statement about a
|
||||
* credential that no longer exists, so it must not outlive it. Deliberately
|
||||
* unconditional over the user's rows — consumed ones go too, since there is no
|
||||
* reason to keep them once the anchor moved.
|
||||
*/
|
||||
export async function revokeStepUpElevations(userId: string): Promise<void> {
|
||||
await prisma.stepUpElevation.deleteMany({ where: { userId } });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,38 @@
|
||||
/**
|
||||
* Request schemas for the step-up elevation surface. Single-sourced here so the
|
||||
* runtime `safeParse` and the OpenAPI registry describe the identical wire
|
||||
* contract.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { webauthnCredentialSchema } from "@/lib/validations/mfa";
|
||||
|
||||
/**
|
||||
* Re-prove a factor to mint an elevation.
|
||||
*
|
||||
* Two accepted proofs, discriminated on `method`:
|
||||
* - `password` — the account password. Unavailable on an account that signed
|
||||
* in through OIDC SSO and never set one; such an account uses the passkey
|
||||
* arm, or manages its second factor on the web.
|
||||
* - `passkey` — an assertion against a primary passkey the device already
|
||||
* holds, begun at `/api/auth/step-up/options`.
|
||||
*
|
||||
* The password is length-bounded so a body cap is not the only thing standing
|
||||
* between a caller and an unbounded Argon2id verify.
|
||||
*/
|
||||
export const stepUpMintSchema = z
|
||||
.discriminatedUnion("method", [
|
||||
z.object({
|
||||
method: z.literal("password"),
|
||||
password: z.string().min(1).max(512),
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal("passkey"),
|
||||
challengeId: z.string().min(1),
|
||||
credential: webauthnCredentialSchema,
|
||||
}),
|
||||
])
|
||||
.meta({
|
||||
id: "StepUpMintRequest",
|
||||
description:
|
||||
"Re-prove the account password or a primary passkey to mint a single-use, token-bound step-up elevation.",
|
||||
});
|
||||
Reference in new issue
Block a user