mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
/**
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
Reference in new issue
Block a user