mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
commit
c458d955e4
45 files changed
+4211
-551
No files matched your search
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user