Files
HealthLog/src/lib/validations/mfa.ts
T
Marc-André Bombeck b100c73a62 fix(auth): refuse a sign-in over plain http before it spends a factor (#1097)
A server that issues Secure session cookies cannot sign anyone in on a
plain http:// page: the browser drops the cookie. The login request was
still verified, the MFA ticket minted and then claimed, and the next
attempt answered "Invalid or expired challenge".

The web client now sends an optional clientTransport on the browser
sign-in requests (password login, passkey start, both MFA completions).
When the server issues Secure cookies and the page is plain http on a
non-loopback host, the request is refused with 409
auth.session.insecure_transport before the password is checked, a
ticket is minted or claimed, a code is spent, or a passkey challenge is
issued. A request without the field is never refused. The SSO start
takes client_proto/client_host and sends such a page back to the login
page instead of to the provider.

GET /api/auth/registration-status publishes sessionCookieSecure, and the
login page shows the reason with both fixes and disables the sign-in
controls before anything is sent; on localhost it shows a note only.
SESSION_COOKIE_SECURE keeps its meaning; the header heuristic log line
stays.
2026-10-03 18:21:19 +02:00

123 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";
import { clientTransportSchema } from "@/lib/validations/client-transport";
/** 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).",
),
clientTransport: clientTransportSchema.optional(),
})
.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).",
),
clientTransport: clientTransportSchema.optional(),
})
.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.",
});