mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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.
120 lines
4.0 KiB
TypeScript
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.",
|
|
});
|