mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +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
+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