Files
HealthLog/src/lib/validations/step-up.ts
T
Marc-André Bombeck 0991d754e4 fix(auth): make the re-proved factor decide what an elevation reaches
A step-up elevation minted from the account password satisfied the fresh-factor
routes. That is a second-factor bypass: a stolen token plus a known password
could rotate the recovery codes — which take no factor in the body — and then
spend one of them to disable the second factor outright. The web has never
allowed that chain, because a password login does not stamp mfaVerifiedAt.

The method is now an authorisation input. Only totp, webauthn, and passkey
satisfy a fresh-factor gate, which is exactly the set of ceremonies the web
stamps a session for; password reaches what a plain cookie session reaches and
stops. The mint gains TOTP and security-key arms so a TOTP-only account can
still manage its factor natively, and the response says which reach it bought.

The gate also returns a discriminated context. session.id meant a session row on
one arm and a token row on the other, and the disable route passed the token id
to a session-scoped query — excluding nothing, so it deleted every browser
session and revoked the caller's own device login. The transport is now in the
type and the compiler refuses the confusion.

Redemption splits into validate-then-commit so a 429, a 422, or a wrong code no
longer burns a proof against a five-per-fifteen-minute mint ceiling. The claim
is UPDATE ... RETURNING, so the statement that decides is the one that reports.
The mint is transactional behind a partial unique index. Sign-out-everywhere and
both password-reset paths clear elevations. The status read drops to plain
requireAuth — it carries no credential material — and every mutation stays gated.
2026-07-19 13:48:46 +02:00

68 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.",
});
/** 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" });