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

@@ -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 -5
View File
@@ -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) {
+4 -3
View File
@@ -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),
+14 -6
View File
@@ -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}`,
+11 -5
View File
@@ -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}`,
+8 -11
View File
@@ -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,
+6
View File
@@ -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.
+51
View File
@@ -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 });
});
+188
View File
@@ -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,
});
});