mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Add an opt-in "remember this device" at the second-factor step. On opt-in a random token is minted, set in an httpOnly+Secure+SameSite=Strict cookie, and only its HMAC hash is stored in a new TrustedDevice table. A later sign-in from the same browser still requires the password (factor 1); a matching, unexpired trusted device lets the login skip factor 2 only. The token is never accepted for step-up — a trusted-device login does not stamp mfaVerifiedAt — so a stolen device cookie cannot reach a destructive action. Trusted devices are listed and revocable from the security surface, are revoked wholesale when the second factor is removed or the user signs out everywhere, and cascade away on account deletion. A recovery-code login never trusts the browser. Migration 0204 adds the table (and the instance-wide mfa_required policy column that the enforcement work reads).
126 lines
4.2 KiB
TypeScript
126 lines
4.2 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");
|
|
|
|
/**
|
|
* A recovery code as displayed (`XXXXX-XXXXX`) — accepted case-insensitively
|
|
* with or without the separator; the server normalises before comparing.
|
|
*/
|
|
export const recoveryCodeSchema = z.string().trim().min(8).max(32);
|
|
|
|
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`.
|
|
*/
|
|
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.",
|
|
});
|