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

+149 -1
View File
@@ -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.
+218
View File
@@ -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 } });
}
+4 -3
View File
@@ -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
+1 -1
View File
@@ -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(),
+38
View File
@@ -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.",
});