merge: integrate the v1.23 security settings and MFA wave

# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	src/lib/openapi/routes/auth.ts
#	src/lib/query-keys/auth.ts
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:46:03 +02:00
commit c458d955e4
45 files changed
+4211 -551

No files matched your search

+209 -2
View File
@@ -12,6 +12,10 @@ import {
mfaVerifySchema,
totpConfirmSchema,
mfaDisableSchema,
mfaWebauthnRegisterVerifySchema,
mfaWebauthnRenameSchema,
mfaWebauthnLoginOptionsSchema,
mfaWebauthnLoginVerifySchema,
} from "@/lib/validations/mfa";
import { dataEnvelope, stdResponses, errorEnvelope } from "./shared";
@@ -92,9 +96,9 @@ const mfaRequiredEnvelope = z
"Opaque, single-use, ~5-minute ticket to present to /api/auth/mfa/verify.",
),
methods: z
.array(z.enum(["totp", "recovery"]))
.array(z.enum(["totp", "recovery", "webauthn"]))
.describe(
"Second factors the account can complete the challenge with.",
"Second factors the account can complete the challenge with. `webauthn` is completed via /api/auth/mfa/webauthn/verify; the rest via /api/auth/mfa/verify.",
),
}),
})
@@ -184,6 +188,39 @@ const securityActivityResponse = z
})
.meta({ id: "SecurityActivityResponse" });
// ── v1.23 WebAuthn second-factor + status shapes ─────────────────────
const webauthnCredentialInfo = z
.object({
id: z.string(),
name: z.string(),
createdAt: z.iso.datetime({ offset: true }),
lastUsedAt: z.iso.datetime({ offset: true }).nullable(),
})
.meta({ id: "WebauthnMfaCredentialInfo" });
const mfaStatusResponse = z
.object({
totp: z.object({ enabled: z.boolean() }),
recoveryCodesRemaining: z.number().int(),
webauthn: z.array(webauthnCredentialInfo),
passkeyNudgeDismissed: z.boolean(),
})
.meta({ id: "MfaStatusResponse" });
const webauthnOptionsResponse = z
.object({
options: z
.record(z.string(), z.unknown())
.describe("SimpleWebAuthn options to pass to the browser ceremony."),
challengeId: z.string().describe("Server-issued challenge id."),
})
.meta({ id: "WebauthnOptionsResponse" });
const successFlagResponse = z
.object({ success: z.boolean() })
.meta({ id: "AuthSuccessFlagResponse" });
export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/auth/login": {
post: {
@@ -327,6 +364,176 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/auth/me/mfa": {
get: {
tags: ["Auth"],
summary: "Second-factor status (cookie session only)",
description:
"Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata only — no secret, code, or public key. Cookie-only.",
responses: {
"200": {
description: "Second-factor status.",
content: {
"application/json": {
schema: dataEnvelope(mfaStatusResponse, "MfaStatusEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/auth/me/mfa/webauthn/register/options": {
post: {
tags: ["Auth"],
summary: "Begin registering a security key as a second factor",
description:
"Returns SimpleWebAuthn creation options + a challenge id. Cookie-only — a Bearer token cannot enrol MFA.",
responses: {
"200": {
description: "Registration options issued.",
content: {
"application/json": {
schema: dataEnvelope(
webauthnOptionsResponse,
"MfaWebauthnRegisterOptionsEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/auth/me/mfa/webauthn/register/verify": {
post: {
tags: ["Auth"],
summary: "Finish registering a security key as a second factor",
description:
"Verifies the attestation against the user-bound challenge and stores the credential in the second-factor store (separate from primary passkeys). Cookie-only.",
requestBody: {
required: true,
content: {
"application/json": { schema: mfaWebauthnRegisterVerifySchema },
},
},
responses: {
"200": {
description: "Security key registered.",
content: {
"application/json": {
schema: dataEnvelope(
webauthnCredentialInfo,
"MfaWebauthnRegisterVerifyEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/auth/me/mfa/webauthn/{id}": {
patch: {
tags: ["Auth"],
summary: "Rename a registered security key",
requestBody: {
required: true,
content: { "application/json": { schema: mfaWebauthnRenameSchema } },
},
responses: {
"200": {
description: "Security key renamed.",
content: {
"application/json": {
schema: dataEnvelope(
webauthnCredentialInfo,
"MfaWebauthnRenameEnvelope",
),
},
},
},
...stdResponses,
},
},
delete: {
tags: ["Auth"],
summary: "Remove a registered security key (step-up gated)",
description:
"Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate.",
responses: {
"200": {
description: "Security key removed.",
content: {
"application/json": {
schema: dataEnvelope(
successFlagResponse,
"MfaWebauthnRemoveEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/auth/mfa/webauthn/verify/options": {
post: {
tags: ["Auth"],
summary: "Begin a mid-login security-key assertion",
description:
"Presents the login `mfaTicket` and returns assertion options scoped to the password-identified user's registered security keys. Anonymous surface; rate-limited.",
requestBody: {
required: true,
content: {
"application/json": { schema: mfaWebauthnLoginOptionsSchema },
},
},
responses: {
"200": {
description: "Assertion options issued.",
content: {
"application/json": {
schema: dataEnvelope(
webauthnOptionsResponse,
"MfaWebauthnLoginOptionsEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/auth/mfa/webauthn/verify": {
post: {
tags: ["Auth"],
summary: "Complete a security-key second-factor login challenge",
description:
"Presents the login `mfaTicket` plus the assertion. On success returns the SAME token bundle / session the password path issues, with the session marked second-factor-verified. The ticket is single-use; failures are throttled and the ticket is burned at the attempt cap.",
requestBody: {
required: true,
content: {
"application/json": { schema: mfaWebauthnLoginVerifySchema },
},
},
responses: {
"200": {
description:
"Second factor verified — session + optional bearer issued.",
content: {
"application/json": {
schema: dataEnvelope(
accessRefreshBundle,
"MfaWebauthnVerifyResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/auth/passkey/login-verify": {
post: {
tags: ["Auth"],