Files
HealthLog/src/lib/validations/mfa.ts
T
Marc-André Bombeck e557d9e9e6 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.
2026-07-19 12:42:00 +02:00

120 lines
4.0 KiB
TypeScript

/**
* Request schemas for the second-factor (MFA) surface. Single-sourced here so
* the runtime `safeParse` and the OpenAPI registry (`src/lib/openapi/routes/
* auth.ts`) describe the identical wire contract.
*/
import { z } from "zod/v4";
/** A 6-digit TOTP code. */
export const totpCodeSchema = z
.string()
.trim()
.regex(/^\d{6}$/, "Enter the 6-digit code from your authenticator app");
export const totpConfirmSchema = z.object({
code: totpCodeSchema,
});
/** Disable MFA: prove possession of a current factor (TOTP or recovery code). */
export const mfaDisableSchema = z
.object({
code: z.string().trim().min(6).max(32),
method: z.enum(["totp", "recovery"]).default("totp"),
})
.meta({
id: "MfaDisableRequest",
description:
"Disable the second factor. Requires fresh step-up plus a current TOTP or recovery code.",
});
/** Login second-factor completion: ticket + a TOTP or recovery code. */
export const mfaVerifySchema = z
.object({
mfaTicket: z
.string()
.min(1)
.describe("The opaque single-use ticket from the login response."),
method: z.enum(["totp", "recovery"]).default("totp"),
code: z.string().trim().min(6).max(32),
rememberDevice: z
.boolean()
.optional()
.describe(
"Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required).",
),
})
.meta({
id: "MfaVerifyRequest",
description:
"Complete a second-factor login challenge and receive the same token bundle the password path issues.",
});
// ── WebAuthn security key (second factor) ────────────────────────────
/**
* A SimpleWebAuthn-style ceremony response. Kept permissive at the wire
* boundary — the cryptographic validation lives inside
* `@simplewebauthn/server`; this just pins the envelope shape for OpenAPI and
* the runtime `safeParse`.
*/
export const webauthnCredentialSchema = z
.object({
id: z.string(),
rawId: z.string(),
type: z.literal("public-key"),
response: z.record(z.string(), z.unknown()),
authenticatorAttachment: z.enum(["platform", "cross-platform"]).optional(),
clientExtensionResults: z.record(z.string(), z.unknown()).optional(),
})
.describe("SimpleWebAuthn ceremony response payload.");
/** A human label for a registered security key (settings + rename). */
export const webauthnKeyNameSchema = z.string().trim().min(1).max(64);
/** Register a security key as a second factor (cookie session, settings). */
export const mfaWebauthnRegisterVerifySchema = z
.object({
challengeId: z.string().min(1),
credential: webauthnCredentialSchema,
name: webauthnKeyNameSchema.optional(),
})
.meta({
id: "MfaWebauthnRegisterVerifyRequest",
description:
"Finish registering a WebAuthn security key as a second factor.",
});
/** Rename a registered security key. */
export const mfaWebauthnRenameSchema = z
.object({ name: webauthnKeyNameSchema })
.meta({ id: "MfaWebauthnRenameRequest" });
/** Begin a mid-login security-key assertion: present the login MFA ticket. */
export const mfaWebauthnLoginOptionsSchema = z
.object({
mfaTicket: z
.string()
.min(1)
.describe("The opaque single-use ticket from the login response."),
})
.meta({ id: "MfaWebauthnLoginOptionsRequest" });
/** Complete a mid-login security-key assertion. */
export const mfaWebauthnLoginVerifySchema = z
.object({
mfaTicket: z.string().min(1),
challengeId: z.string().min(1),
credential: webauthnCredentialSchema,
rememberDevice: z
.boolean()
.optional()
.describe(
"Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required).",
),
})
.meta({
id: "MfaWebauthnLoginVerifyRequest",
description:
"Complete a second-factor login challenge with a WebAuthn security key and receive the same token bundle the password path issues.",
});