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