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

+14 -1
View File
@@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
session: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
},
}));
@@ -91,8 +92,9 @@ describe("requireFreshMfa", () => {
);
});
it("rejects a user without an active second factor", async () => {
it("rejects a user with neither TOTP nor a security key", async () => {
mockSession({ ...MFA_USER, totpConfirmedAt: null });
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
errorCode: "auth.stepup.mfa_not_enrolled",
});
@@ -100,6 +102,17 @@ describe("requireFreshMfa", () => {
expect(prisma.session.findUnique).not.toHaveBeenCalled();
});
it("accepts a WebAuthn-only account with a fresh stamp", async () => {
mockSession({ ...MFA_USER, totpConfirmedAt: null });
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
vi.mocked(prisma.session.findUnique).mockResolvedValue({
mfaVerifiedAt: new Date(Date.now() - 60_000),
} as never);
const ctx = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
expect(ctx.user.id).toBe("user-1");
});
it("rejects when there is no cookie session — Bearer can never satisfy it", async () => {
// getSession is cookie-only; a Bearer header is irrelevant and never read.
headersGet.mockImplementation((n) =>
+19 -2
View File
@@ -539,8 +539,15 @@ export async function requireFreshMfa(
// The user must actually have a second factor active. A single-factor
// account cannot produce a fresh-MFA proof, so step-up-gated actions are
// unreachable for it by design (the management UI gates enrolment first).
// Either factor counts: a confirmed TOTP secret OR a registered WebAuthn
// security key — both stamp `Session.mfaVerifiedAt` on a completed login.
if (!sessionData.user.totpConfirmedAt) {
throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled");
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
where: { userId: sessionData.user.id },
});
if (webauthnKeyCount === 0) {
throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled");
}
}
// Read the freshness stamp off the live session row — `getSession`'s
@@ -579,7 +586,17 @@ export async function requireFreshMfaIfEnrolled(
maxAgeSeconds: number,
): Promise<AuthContext> {
const auth = await requireAuth();
if (auth.user.totpConfirmedAt) {
// Either factor enrols the account: a confirmed TOTP secret OR a registered
// WebAuthn security key. A webauthn-only user must clear step-up too, so the
// destructive-action boundary tracks `requireFreshMfa`'s either-factor rule.
let enrolled = Boolean(auth.user.totpConfirmedAt);
if (!enrolled) {
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
where: { userId: auth.user.id },
});
enrolled = webauthnKeyCount > 0;
}
if (enrolled) {
await requireFreshMfa(maxAgeSeconds);
}
return auth;
+290
View File
@@ -0,0 +1,290 @@
/**
* WebAuthn as a **second factor** — distinct from the passwordless-primary
* passkey path in `src/lib/auth/passkey.ts`.
*
* Differences from the primary path (see `.planning` design §3.2):
* - Credentials live in `WebauthnMfaCredential`, NOT `Passkey`. A `Passkey`
* row is itself a login credential; an MFA credential is meaningless without
* the password, so conflating the two would silently turn a security-key
* registration into a passwordless login credential.
* - Registration uses `residentKey: "discouraged"` (non-resident → does not
* consume a discoverable-credential slot, so an account can register many
* keys) and `userVerification: "preferred"` (the password already supplied
* one factor; a roaming key need not always carry a PIN/biometric).
* - The login ceremony scopes `allowCredentials` to the **already
* password-identified** user's MFA keys — never an empty allow-list, because
* a non-resident credential is not discoverable.
*
* The transient challenge rides the shared `AuthChallenge` store under
* dedicated `mfa_registration` / `mfa_authentication` types so it never
* collides with the primary-passkey challenges.
*/
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
type AuthenticationResponseJSON,
type RegistrationResponseJSON,
type VerifiedRegistrationResponse,
type VerifiedAuthenticationResponse,
} from "@simplewebauthn/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import {
RP_NAME as rpName,
getRpId,
getExpectedOrigin,
} from "@/lib/auth/webauthn-rp";
// Boundary narrowing mirrors `passkey.ts`: a malformed body fails fast with a
// structured error here, while SimpleWebAuthn still owns the cryptographic
// validation downstream.
const registrationResponseSchema = z
.object({
id: z.string().min(1),
rawId: z.string().min(1),
response: z
.object({
clientDataJSON: z.string().min(1),
attestationObject: z.string().min(1),
transports: z.array(z.string()).optional(),
publicKeyAlgorithm: z.number().optional(),
publicKey: z.string().optional(),
authenticatorData: z.string().optional(),
})
.loose(),
authenticatorAttachment: z.string().optional(),
clientExtensionResults: z.unknown().optional(),
type: z.literal("public-key"),
})
.loose();
const authenticationResponseSchema = z
.object({
id: z.string().min(1),
rawId: z.string().min(1),
response: z
.object({
clientDataJSON: z.string().min(1),
authenticatorData: z.string().min(1),
signature: z.string().min(1),
userHandle: z.string().optional(),
})
.loose(),
authenticatorAttachment: z.string().optional(),
clientExtensionResults: z.unknown().optional(),
type: z.literal("public-key"),
})
.loose();
type Transport =
| "ble"
| "cable"
| "hybrid"
| "internal"
| "nfc"
| "smart-card"
| "usb";
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
async function cleanupExpiredChallenges() {
await prisma.authChallenge.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
}
// ── Registration (settings, cookie-authenticated) ────────────────────
export async function createMfaRegistrationOptions(
userId: string,
username: string,
) {
await cleanupExpiredChallenges();
const existing = await prisma.webauthnMfaCredential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
const options = await generateRegistrationOptions({
rpName,
rpID: getRpId(),
userName: username,
attestationType: "none",
excludeCredentials: existing.map((c) => ({
id: c.credentialId,
transports: c.transports as Transport[],
})),
authenticatorSelection: {
// Non-resident: the password already identifies the account, so the
// credential need not be discoverable — and a non-resident key does not
// burn a discoverable-credential slot, allowing many keys per account.
residentKey: "discouraged",
// A roaming security key supplies the possession factor; UV is a bonus,
// not a requirement, because the password already covered a factor.
userVerification: "preferred",
authenticatorAttachment: "cross-platform",
},
});
const challenge = await prisma.authChallenge.create({
data: {
userId,
challenge: options.challenge,
type: "mfa_registration",
expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
},
});
return { options, challengeId: challenge.id };
}
export async function verifyMfaRegistration(
challengeId: string,
userId: string,
response: unknown,
): Promise<VerifiedRegistrationResponse> {
const challenge = await prisma.authChallenge.findUnique({
where: { id: challengeId },
});
// The challenge must be live, of the registration type, and bound to the
// calling user — a challenge minted for another account can never be reused.
if (
!challenge ||
challenge.expiresAt < new Date() ||
challenge.type !== "mfa_registration" ||
challenge.userId !== userId
) {
throw new Error("Challenge expired or not found");
}
try {
const parsed = registrationResponseSchema.safeParse(response);
if (!parsed.success) {
throw new Error("Registration response shape invalid");
}
return await verifyRegistrationResponse({
response: parsed.data as RegistrationResponseJSON,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
});
} finally {
await prisma.authChallenge
.delete({ where: { id: challengeId } })
.catch(() => {});
}
}
// ── Authentication (mid-login second factor) ─────────────────────────
/**
* Generate an assertion challenge scoped to a single user's MFA credentials.
* The caller has already password-identified the account (via the MFA ticket),
* so `allowCredentials` is always populated — a non-resident credential is not
* discoverable from an empty allow-list. Returns null when the user has no
* security keys registered (the caller should fall back to another factor).
*/
export async function createMfaAuthenticationOptions(userId: string) {
await cleanupExpiredChallenges();
const credentials = await prisma.webauthnMfaCredential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
if (credentials.length === 0) return null;
const options = await generateAuthenticationOptions({
rpID: getRpId(),
userVerification: "preferred",
allowCredentials: credentials.map((c) => ({
id: c.credentialId,
transports: c.transports as Transport[],
})),
});
const challenge = await prisma.authChallenge.create({
data: {
userId,
challenge: options.challenge,
type: "mfa_authentication",
expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
},
});
return { options, challengeId: challenge.id };
}
/**
* Verify a mid-login security-key assertion against the user's MFA credentials.
* Returns true on a verified assertion (counter + last-used are stamped); the
* caller then claims the MFA ticket and finishes the login.
*/
export async function verifyMfaAuthentication(
challengeId: string,
userId: string,
response: unknown,
): Promise<boolean> {
const challenge = await prisma.authChallenge.findUnique({
where: { id: challengeId },
});
if (
!challenge ||
challenge.expiresAt < new Date() ||
challenge.type !== "mfa_authentication" ||
challenge.userId !== userId
) {
throw new Error("Challenge expired or not found");
}
const parsed = authenticationResponseSchema.safeParse(response);
if (!parsed.success) {
throw new Error("Malformed security-key authentication response");
}
const typedResponse = parsed.data as unknown as AuthenticationResponseJSON;
try {
// The presented credential MUST belong to the password-identified user —
// look it up scoped to `userId` so an assertion against another account's
// key can never satisfy this account's second factor.
const credential = await prisma.webauthnMfaCredential.findFirst({
where: { credentialId: typedResponse.id, userId },
});
if (!credential) return false;
const verification: VerifiedAuthenticationResponse =
await verifyAuthenticationResponse({
response: typedResponse,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
credential: {
id: credential.credentialId,
publicKey: credential.credentialPublicKey,
counter: Number(credential.counter),
transports: credential.transports as Transport[],
},
});
if (verification.verified) {
await prisma.webauthnMfaCredential.update({
where: { id: credential.id },
data: {
counter: BigInt(verification.authenticationInfo.newCounter),
lastUsedAt: new Date(),
},
});
}
return verification.verified;
} finally {
await prisma.authChallenge
.delete({ where: { id: challengeId } })
.catch(() => {});
}
}
+14 -35
View File
@@ -10,6 +10,11 @@ import {
} from "@simplewebauthn/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import {
RP_NAME as rpName,
getRpId,
getExpectedOrigin,
} from "@/lib/auth/webauthn-rp";
// v1.4.43 W13 L-3 — explicit Zod narrowing in front of
// `verifyAuthentication`. Replaces the raw
@@ -76,7 +81,9 @@ type Transport =
| "smart-card"
| "usb";
const rpName = "HealthLog";
// The relying-party config (`rpName` / `getRpId` / `getExpectedOrigin`) is
// shared with the second-factor security-key path via `@/lib/auth/webauthn-rp`
// so both ceremonies bind credentials to the identical RP.
async function cleanupExpiredChallenges() {
await prisma.authChallenge.deleteMany({
@@ -84,38 +91,6 @@ async function cleanupExpiredChallenges() {
});
}
function getConfiguredOrigins(): string[] {
const candidates = [
process.env.APP_URL,
process.env.NEXT_PUBLIC_APP_URL,
"http://localhost:3000",
]
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value));
const validOrigins = candidates
.map((value) => {
try {
return new URL(value).origin;
} catch {
return null;
}
})
.filter((value): value is string => Boolean(value));
return Array.from(new Set(validOrigins));
}
function getRpId(): string {
const origins = getConfiguredOrigins();
return new URL(origins[0]).hostname;
}
function getExpectedOrigin(): string | string[] {
const origins = getConfiguredOrigins();
return origins.length === 1 ? origins[0] : origins;
}
// ── Registration ─────────────────────────────────────────
export async function createRegistrationOptions(
@@ -294,10 +269,14 @@ export async function verifyAuthentication(
});
if (verification.verified) {
// Update counter
// Update counter + stamp last-used so the management UI can surface
// when each passkey was last exercised (v1.23 passkey QoL).
await prisma.passkey.update({
where: { id: passkey.id },
data: { counter: BigInt(verification.authenticationInfo.newCounter) },
data: {
counter: BigInt(verification.authenticationInfo.newCounter),
lastUsedAt: new Date(),
},
});
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Shared WebAuthn relying-party configuration.
*
* Both the passwordless-primary passkey path (`src/lib/auth/passkey.ts`) and
* the second-factor security-key path (`src/lib/auth/mfa/webauthn.ts`) resolve
* the same relying-party id / origin from the deployment's configured app URL,
* so the two ceremonies always agree on the RP they bind credentials to.
*
* The candidates mirror the rest of the app: `APP_URL` first, then the public
* build-time URL, then a localhost fallback for dev. A multi-origin deployment
* (rare for a single-tenant self-host, but supported) passes the whole set to
* SimpleWebAuthn's `expectedOrigin`.
*/
/** Human-readable relying-party name shown by the authenticator UI. */
export const RP_NAME = "HealthLog";
export function getConfiguredOrigins(): string[] {
const candidates = [
process.env.APP_URL,
process.env.NEXT_PUBLIC_APP_URL,
"http://localhost:3000",
]
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value));
const validOrigins = candidates
.map((value) => {
try {
return new URL(value).origin;
} catch {
return null;
}
})
.filter((value): value is string => Boolean(value));
return Array.from(new Set(validOrigins));
}
export function getRpId(): string {
const origins = getConfiguredOrigins();
return new URL(origins[0]).hostname;
}
export function getExpectedOrigin(): string | string[] {
const origins = getConfiguredOrigins();
return origins.length === 1 ? origins[0] : origins;
}
+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"],
+8
View File
@@ -48,6 +48,14 @@ export const authKeys = {
*/
securityActivity: () => ["auth", "security-activity"] as const,
/**
* v1.23 — the security settings hub reads second-factor status (TOTP
* enabled, recovery codes remaining, registered security keys, passkey-nudge
* dismissal) from `GET /api/auth/me/mfa`. Mutations that change a factor
* invalidate this key so the hub reflects the new state immediately.
*/
mfaStatus: () => ["auth", "mfa", "status"] as const,
userAiProvider: () => ["user", "ai-provider"] as const,
userProfile: () => ["user", "profile"] as const,
/**
+63
View File
@@ -48,3 +48,66 @@ export const mfaVerifySchema = z
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,
})
.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.",
});