Files
HealthLog/src/lib/validations/step-up.ts
T
Marc-André Bombeck 7b5bc130d8 fix(auth): require a fresh proof before a second factor is added
A browser session on its own could start TOTP enrollment or register a
security key. Combined with a pending secret counting as a factor, that let
a stolen session give itself a second factor, register a passkey with it,
and keep access after the owner changed the password.

TOTP setup and security-key registration now pass on the cookie path only
when the session signed in or completed a second factor in the last five
minutes, or when the body carries a proof in the step-up shape (password,
a confirmed authenticator code, or a passkey or security-key assertion).
The settings cards ask for that proof when the server answers
auth.reproof.required and retry the same call with it. The Bearer path is
unchanged: its elevation already is the proof.

Every route that checks a password or a factor as a re-proof now uses one
verifier and one per-account budget of five attempts per fifteen minutes,
shared with the step-up mint, and records each refused proof as
auth.mfa.failed. Passkey enrollment and password change had neither the
limit (passkey enrollment) nor the audit row (both). A structural test
freezes the set of credential-verifying routes and requires a throttle and
an audit call in each.
2026-09-26 15:55:57 +02:00

70 lines
2.7 KiB
TypeScript

/**
* 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. Four accepted proofs, discriminated on
* `method`, and the choice is an AUTHORISATION input rather than a preference:
*
* - `password` — the account password. Reaches exactly what a plain cookie
* session reaches. Unavailable on an account provisioned through OIDC SSO
* that never set one.
* - `totp` — a current code from the enrolled authenticator.
* - `webauthn` — an assertion from a registered second-factor security key,
* begun at `/api/auth/step-up/options` with `method: "webauthn"`.
* - `passkey` — an assertion from a primary passkey, begun at the same
* endpoint with `method: "passkey"`.
*
* Only the last three satisfy the fresh-factor routes, mirroring the web, where
* a password login never stamps a session second-factor-verified.
*
* 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("totp"),
code: z
.string()
.trim()
.regex(/^\d{6}$/, "Enter the 6-digit code from your authenticator app"),
}),
z.object({
method: z.literal("webauthn"),
challengeId: z.string().min(1),
credential: webauthnCredentialSchema,
}),
z.object({
method: z.literal("passkey"),
challengeId: z.string().min(1),
credential: webauthnCredentialSchema,
}),
])
.meta({
id: "StepUpMintRequest",
description:
"Re-prove a factor to mint a single-use, token-bound step-up elevation. `password` reaches the same routes a plain cookie session reaches; `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes (disable, recovery-code rotation, security-key removal), matching exactly the ceremonies for which the web stamps a session second-factor-verified.",
});
export type StepUpMintRequest = z.infer<typeof stepUpMintSchema>;
/** Which assertion ceremony to begin at `POST /api/auth/step-up/options`. */
export const stepUpOptionsSchema = z
.object({
method: z
.enum(["passkey", "webauthn"])
.describe(
"`passkey` begins an assertion against the account's primary passkeys; `webauthn` against its registered second-factor security keys.",
),
})
.meta({ id: "StepUpOptionsRequest" });