mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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
@@ -8,6 +8,8 @@ vi.mock("@/lib/db", () => ({
|
||||
user: { findFirst: vi.fn() },
|
||||
apiToken: { create: vi.fn() },
|
||||
refreshToken: { create: vi.fn() },
|
||||
// v1.23 — login checks for a registered second-factor security key.
|
||||
webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -97,6 +99,7 @@ function makeRequest(headers: Record<string, string> = {}): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never);
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(verifyPassword).mockResolvedValue(true);
|
||||
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
|
||||
vi.mocked(prisma.refreshToken.create).mockResolvedValue({
|
||||
|
||||
@@ -96,17 +96,30 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
// v1.23 — second-factor gate. The password is correct, but an account with
|
||||
// a confirmed TOTP factor is NOT yet authenticated: no `Session` row and no
|
||||
// token bundle is minted here. Instead a single-use, ~5-minute MFA ticket is
|
||||
// a confirmed second factor (a TOTP secret and/or a registered WebAuthn
|
||||
// security key) is NOT yet authenticated: no `Session` row and no token
|
||||
// bundle is minted here. Instead a single-use, ~5-minute MFA ticket is
|
||||
// returned (the partial state lives in the ticket, never in a half-built
|
||||
// session) and the client completes the login at `/api/auth/mfa/verify`.
|
||||
// session) and the client completes the login at `/api/auth/mfa/verify`
|
||||
// (TOTP / recovery) or `/api/auth/mfa/webauthn/verify` (security key).
|
||||
//
|
||||
// Enumeration note: `meta.mfaRequired` is only ever returned AFTER a valid
|
||||
// password, and the invalid-credentials response above is identical
|
||||
// regardless of MFA state — an attacker without the password cannot learn
|
||||
// whether an account has MFA.
|
||||
if (user.totpConfirmedAt) {
|
||||
const hasTotp = Boolean(user.totpConfirmedAt);
|
||||
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
const hasWebauthn = webauthnKeyCount > 0;
|
||||
|
||||
if (hasTotp || hasWebauthn) {
|
||||
const challenge = await createMfaChallenge(user.id, "login");
|
||||
// Recovery codes are only ever issued alongside TOTP enrollment, so the
|
||||
// recovery method is offered exactly when TOTP is active.
|
||||
const methods: ("totp" | "recovery" | "webauthn")[] = [];
|
||||
if (hasTotp) methods.push("totp", "recovery");
|
||||
if (hasWebauthn) methods.push("webauthn");
|
||||
await auditLog("auth.mfa.challenge", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
@@ -123,7 +136,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
meta: {
|
||||
mfaRequired: true,
|
||||
mfaTicket: challenge.ticket,
|
||||
methods: ["totp", "recovery"],
|
||||
methods,
|
||||
},
|
||||
},
|
||||
{ status: 200 },
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* GET /api/auth/me/mfa
|
||||
*
|
||||
* Second-factor status for the security settings hub: whether TOTP is active,
|
||||
* how many recovery codes remain, and the registered WebAuthn security keys.
|
||||
* Cookie-only (`requireCookieAuth`) — managing MFA is never a Bearer surface.
|
||||
*
|
||||
* Returns only metadata: no secret, no recovery-code plaintext, no public key.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { countRemainingRecoveryCodes } from "@/lib/auth/mfa/recovery-codes";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireCookieAuth();
|
||||
|
||||
const [recoveryCodesRemaining, webauthn] = await Promise.all([
|
||||
countRemainingRecoveryCodes(user.id),
|
||||
prisma.webauthnMfaCredential.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
annotate({ action: { name: "auth.mfa.status" } });
|
||||
|
||||
return apiSuccess({
|
||||
totp: { enabled: Boolean(user.totpConfirmedAt) },
|
||||
recoveryCodesRemaining,
|
||||
webauthn,
|
||||
// The security hub also drives the passkey-upgrade nudge from this one
|
||||
// status read, so it knows whether the user has already dismissed it.
|
||||
passkeyNudgeDismissed: Boolean(user.passkeyUpgradeNudgeDismissed),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* PATCH /api/auth/me/mfa/webauthn/[id] — rename a registered security key.
|
||||
* DELETE /api/auth/me/mfa/webauthn/[id] — remove one (step-up gated).
|
||||
*
|
||||
* Rename is cookie-authenticated (non-destructive). Removal is a sensitive
|
||||
* action: it is gated on a fresh second-factor step-up (`requireFreshMfa`,
|
||||
* cookie-only — a Bearer token can never satisfy it).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
apiHandler,
|
||||
requireCookieAuth,
|
||||
requireFreshMfa,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { mfaWebauthnRenameSchema } from "@/lib/validations/mfa";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = mfaWebauthnRenameSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
|
||||
const existing = await prisma.webauthnMfaCredential.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Security key not found", 404);
|
||||
}
|
||||
|
||||
const updated = await prisma.webauthnMfaCredential.update({
|
||||
where: { id },
|
||||
data: { name: parsed.data.name },
|
||||
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.mfa.webauthn.rename" } });
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
// Step-up gate first — throws StepUpRequiredError (401 + errorCode) if the
|
||||
// session is not freshly second-factor-verified.
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.webauthnMfaCredential.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true, name: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Security key not found", 404);
|
||||
}
|
||||
|
||||
await prisma.webauthnMfaCredential.delete({ where: { id } });
|
||||
|
||||
await auditLog("auth.mfa.webauthn.remove", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { credentialId: id, name: existing.name },
|
||||
});
|
||||
annotate({ action: { name: "auth.mfa.webauthn.remove" } });
|
||||
|
||||
return apiSuccess({ success: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireCookieAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { webauthnMfaCredential: { create: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/mfa/webauthn", () => ({
|
||||
createMfaRegistrationOptions: vi.fn(),
|
||||
verifyMfaRegistration: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { POST as REGISTER_OPTIONS } from "../options/route";
|
||||
import { POST as REGISTER_VERIFY } from "../verify/route";
|
||||
import { requireCookieAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
createMfaRegistrationOptions,
|
||||
verifyMfaRegistration,
|
||||
} from "@/lib/auth/mfa/webauthn";
|
||||
|
||||
const USER = { id: "user-1", username: "u", email: "u@example.com" };
|
||||
|
||||
function verifyRequest(body: Record<string, unknown>): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/auth/me/mfa/webauthn/register/verify",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(requireCookieAuth).mockResolvedValue({ user: USER } as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/me/mfa/webauthn/register/options", () => {
|
||||
it("returns ceremony options + challenge id", async () => {
|
||||
vi.mocked(createMfaRegistrationOptions).mockResolvedValue({
|
||||
options: { challenge: "abc" },
|
||||
challengeId: "ch-1",
|
||||
} as never);
|
||||
|
||||
const res = await REGISTER_OPTIONS();
|
||||
const body = (await res.json()) as {
|
||||
data: { options: unknown; challengeId: string };
|
||||
};
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.challengeId).toBe("ch-1");
|
||||
expect(createMfaRegistrationOptions).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"u@example.com",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/me/mfa/webauthn/register/verify", () => {
|
||||
const credential = {
|
||||
id: "cred-1",
|
||||
rawId: "cred-1",
|
||||
type: "public-key",
|
||||
response: { transports: ["usb"] },
|
||||
};
|
||||
|
||||
it("stores the credential when verification succeeds", async () => {
|
||||
vi.mocked(verifyMfaRegistration).mockResolvedValue({
|
||||
verified: true,
|
||||
registrationInfo: {
|
||||
credential: {
|
||||
id: "cred-1",
|
||||
publicKey: new Uint8Array([1, 2, 3]),
|
||||
counter: 0,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(prisma.webauthnMfaCredential.create).mockResolvedValue({
|
||||
id: "row-1",
|
||||
name: "My key",
|
||||
createdAt: new Date(),
|
||||
lastUsedAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await REGISTER_VERIFY(
|
||||
verifyRequest({ challengeId: "ch-1", credential, name: "My key" }),
|
||||
);
|
||||
const body = (await res.json()) as { data: { id: string; name: string } };
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.id).toBe("row-1");
|
||||
expect(verifyMfaRegistration).toHaveBeenCalledWith(
|
||||
"ch-1",
|
||||
"user-1",
|
||||
credential,
|
||||
);
|
||||
const createArgs = vi.mocked(prisma.webauthnMfaCredential.create).mock
|
||||
.calls[0][0];
|
||||
expect(createArgs.data.userId).toBe("user-1");
|
||||
expect(createArgs.data.credentialId).toBe("cred-1");
|
||||
expect(createArgs.data.name).toBe("My key");
|
||||
});
|
||||
|
||||
it("returns 400 when verification fails", async () => {
|
||||
vi.mocked(verifyMfaRegistration).mockResolvedValue({
|
||||
verified: false,
|
||||
} as never);
|
||||
|
||||
const res = await REGISTER_VERIFY(
|
||||
verifyRequest({ challengeId: "ch-1", credential }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.webauthnMfaCredential.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 for a malformed body", async () => {
|
||||
const res = await REGISTER_VERIFY(verifyRequest({ challengeId: "ch-1" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* POST /api/auth/me/mfa/webauthn/register/options
|
||||
*
|
||||
* Begin registering a WebAuthn security key as a second factor. Cookie-only —
|
||||
* an API token can never enrol MFA. Returns the SimpleWebAuthn creation
|
||||
* options + the server-issued challenge id to present back at /register/verify.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireCookieAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:webauthn:register:${user.id}`,
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const res = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
res.headers.set(k, v);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
const { options, challengeId } = await createMfaRegistrationOptions(
|
||||
user.id,
|
||||
user.email ?? user.username,
|
||||
);
|
||||
|
||||
annotate({ action: { name: "auth.mfa.webauthn.register-options" } });
|
||||
|
||||
return apiSuccess({ options, challengeId });
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* POST /api/auth/me/mfa/webauthn/register/verify
|
||||
*
|
||||
* Finish registering a WebAuthn security key as a second factor. Cookie-only.
|
||||
* Verifies the attestation against the user-bound challenge and stores the
|
||||
* credential in `WebauthnMfaCredential` (kept separate from primary passkeys).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyMfaRegistration } from "@/lib/auth/mfa/webauthn";
|
||||
import { mfaWebauthnRegisterVerifySchema } from "@/lib/validations/mfa";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = mfaWebauthnRegisterVerifySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
const { challengeId, credential, name } = parsed.data;
|
||||
|
||||
const verification = await verifyMfaRegistration(
|
||||
challengeId,
|
||||
user.id,
|
||||
credential,
|
||||
);
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return apiError("Security key verification failed", 400);
|
||||
}
|
||||
|
||||
const { registrationInfo } = verification;
|
||||
const created = await prisma.webauthnMfaCredential.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: name ?? "Security key",
|
||||
credentialId: registrationInfo.credential.id,
|
||||
credentialPublicKey: Buffer.from(registrationInfo.credential.publicKey),
|
||||
counter: BigInt(registrationInfo.credential.counter),
|
||||
transports:
|
||||
((credential.response as Record<string, unknown> | undefined)
|
||||
?.transports as string[]) ?? [],
|
||||
},
|
||||
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
|
||||
});
|
||||
|
||||
await auditLog("auth.mfa.webauthn.register", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
annotate({ action: { name: "auth.mfa.webauthn.register" } });
|
||||
|
||||
return apiSuccess(created);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* POST /api/auth/me/passkey-nudge/dismiss
|
||||
*
|
||||
* Permanently dismiss the "add a passkey" upgrade nudge for the calling user.
|
||||
* Cookie-only; idempotent. Sets `User.passkeyUpgradeNudgeDismissed = true`.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireCookieAuth();
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passkeyUpgradeNudgeDismissed: true },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.passkey.nudge.dismiss" } });
|
||||
return apiSuccess({ dismissed: true });
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/mfa/challenge", () => ({
|
||||
loadActiveChallenge: vi.fn(),
|
||||
recordChallengeFailure: vi.fn().mockResolvedValue({ exhausted: false }),
|
||||
claimChallenge: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/mfa/webauthn", () => ({
|
||||
verifyMfaAuthentication: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/login-response", () => ({
|
||||
finishLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
loadActiveChallenge,
|
||||
claimChallenge,
|
||||
recordChallengeFailure,
|
||||
} from "@/lib/auth/mfa/challenge";
|
||||
import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
|
||||
function makeRequest(body: Record<string, unknown>): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/mfa/webauthn/verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const VALID_BODY = {
|
||||
mfaTicket: "ticket-1",
|
||||
challengeId: "ch-1",
|
||||
credential: {
|
||||
id: "cred-1",
|
||||
rawId: "cred-1",
|
||||
type: "public-key",
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
ip: "203.0.113.1",
|
||||
} as never);
|
||||
vi.mocked(loadActiveChallenge).mockResolvedValue({
|
||||
id: "mfa-1",
|
||||
userId: "user-1",
|
||||
kind: "login",
|
||||
attempts: 0,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "u",
|
||||
onboardingCompletedAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(recordChallengeFailure).mockResolvedValue({
|
||||
exhausted: false,
|
||||
attempts: 1,
|
||||
} as never);
|
||||
vi.mocked(finishLogin).mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { user: { id: "user-1" } } }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/mfa/webauthn/verify", () => {
|
||||
it("finishes login when the assertion verifies and the ticket is claimed", async () => {
|
||||
vi.mocked(verifyMfaAuthentication).mockResolvedValue(true as never);
|
||||
vi.mocked(claimChallenge).mockResolvedValue(true as never);
|
||||
|
||||
const res = await POST(makeRequest(VALID_BODY));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(verifyMfaAuthentication).toHaveBeenCalledWith(
|
||||
"ch-1",
|
||||
"user-1",
|
||||
VALID_BODY.credential,
|
||||
);
|
||||
expect(claimChallenge).toHaveBeenCalledWith("mfa-1");
|
||||
expect(finishLogin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mfaVerified: true,
|
||||
source: "mfa.webauthn.verify",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects with 401 and burns an attempt when the assertion fails", async () => {
|
||||
vi.mocked(verifyMfaAuthentication).mockResolvedValue(false as never);
|
||||
|
||||
const res = await POST(makeRequest(VALID_BODY));
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(recordChallengeFailure).toHaveBeenCalledWith("mfa-1");
|
||||
expect(claimChallenge).not.toHaveBeenCalled();
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects with 401 for an unknown / expired ticket", async () => {
|
||||
vi.mocked(loadActiveChallenge).mockResolvedValue(null as never);
|
||||
|
||||
const res = await POST(makeRequest(VALID_BODY));
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(verifyMfaAuthentication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not mint a session if the ticket claim is lost (concurrent)", async () => {
|
||||
vi.mocked(verifyMfaAuthentication).mockResolvedValue(true as never);
|
||||
vi.mocked(claimChallenge).mockResolvedValue(false as never);
|
||||
|
||||
const res = await POST(makeRequest(VALID_BODY));
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when rate limited", async () => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
ip: "203.0.113.1",
|
||||
} as never);
|
||||
|
||||
const res = await POST(makeRequest(VALID_BODY));
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* POST /api/auth/mfa/webauthn/verify/options
|
||||
*
|
||||
* Begin a mid-login second-factor security-key assertion. The caller presents
|
||||
* the opaque `mfaTicket` from the `meta.mfaRequired` login response; the server
|
||||
* resolves the password-identified user and returns assertion options scoped to
|
||||
* that user's registered MFA security keys (never an empty allow-list — a
|
||||
* non-resident credential is not discoverable).
|
||||
*
|
||||
* Anonymous surface (no session yet), rate-limited per IP. A single generic
|
||||
* 401 covers every unknown / expired / consumed ticket so a guesser learns
|
||||
* nothing.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { loadActiveChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
import { mfaWebauthnLoginOptionsSchema } from "@/lib/validations/mfa";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:mfa-verify",
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please try again later.", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = mfaWebauthnLoginOptionsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
|
||||
const challenge = await loadActiveChallenge(parsed.data.mfaTicket);
|
||||
if (!challenge) {
|
||||
annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
const result = await createMfaAuthenticationOptions(challenge.userId);
|
||||
if (!result) {
|
||||
// No registered security key for this account — the client should fall
|
||||
// back to a TOTP / recovery code.
|
||||
annotate({ action: { name: "auth.mfa.webauthn.no_key" } });
|
||||
return apiError("No security key registered", 409);
|
||||
}
|
||||
|
||||
annotate({ action: { name: "auth.mfa.webauthn.verify-options" } });
|
||||
return apiSuccess({
|
||||
options: result.options,
|
||||
challengeId: result.challengeId,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* POST /api/auth/mfa/webauthn/verify
|
||||
*
|
||||
* Complete a mid-login second-factor login challenge with a WebAuthn security
|
||||
* key. The caller presents the login `mfaTicket` plus the assertion from
|
||||
* /verify/options. On success the SAME session/token bundle the password path
|
||||
* issues is returned (web → cookie; native → access+refresh), with the session
|
||||
* stamped `mfaVerifiedAt`.
|
||||
*
|
||||
* Security invariants mirror /api/auth/mfa/verify (the TOTP path):
|
||||
* - No session/token exists until the assertion verifies — the partial state
|
||||
* lives in the ticket, never in a half-built session.
|
||||
* - The ticket is claimed once, atomically, only AFTER the assertion verifies.
|
||||
* - A failed assertion increments the ticket attempt counter and burns it at
|
||||
* the cap. Anonymous surface is rate-limited per IP on top of the cap.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { apiError, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import {
|
||||
loadActiveChallenge,
|
||||
recordChallengeFailure,
|
||||
claimChallenge,
|
||||
} from "@/lib/auth/mfa/challenge";
|
||||
import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
|
||||
import { mfaWebauthnLoginVerifySchema } from "@/lib/validations/mfa";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:mfa-verify",
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please try again later.", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = mfaWebauthnLoginVerifySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
const { mfaTicket, challengeId, credential } = parsed.data;
|
||||
|
||||
const challenge = await loadActiveChallenge(mfaTicket);
|
||||
if (!challenge) {
|
||||
annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: challenge.userId },
|
||||
select: { id: true, username: true, onboardingCompletedAt: true },
|
||||
});
|
||||
if (!user) {
|
||||
await recordChallengeFailure(challenge.id);
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
let verified = false;
|
||||
try {
|
||||
verified = await verifyMfaAuthentication(challengeId, user.id, credential);
|
||||
} catch {
|
||||
// A malformed / expired WebAuthn challenge is a failed attempt, not a 500.
|
||||
verified = false;
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
const { exhausted } = await recordChallengeFailure(challenge.id);
|
||||
await auditLog("auth.mfa.failed", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { stage: "login", method: "webauthn", exhausted },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.mfa.failed" },
|
||||
meta: { method: "webauthn", exhausted },
|
||||
});
|
||||
return apiError("Security key verification failed", 401);
|
||||
}
|
||||
|
||||
// Assertion verified — claim the ticket atomically. Only the single winning
|
||||
// caller proceeds to mint a session.
|
||||
const claimed = await claimChallenge(challenge.id);
|
||||
if (!claimed) {
|
||||
annotate({ action: { name: "auth.mfa.verify.claim_lost" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
await auditLog("auth.mfa.verified", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { method: "webauthn" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.mfa.verified" },
|
||||
meta: { method: "webauthn" },
|
||||
});
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
return finishLogin({
|
||||
user,
|
||||
request,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
source: "mfa.webauthn.verify",
|
||||
mfaVerified: true,
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,62 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { webauthnKeyNameSchema } from "@/lib/validations/mfa";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const passkeyRenameSchema = z.object({ name: webauthnKeyNameSchema });
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = passkeyRenameSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
|
||||
const passkey = await prisma.passkey.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!passkey || passkey.userId !== user.id) {
|
||||
return apiError("Passkey not found", 404);
|
||||
}
|
||||
|
||||
const updated = await prisma.passkey.update({
|
||||
where: { id },
|
||||
data: { name: parsed.data.name },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
credentialDeviceType: true,
|
||||
credentialBackedUp: true,
|
||||
createdAt: true,
|
||||
lastUsedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.passkey.rename" } });
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (
|
||||
|
||||
@@ -16,6 +16,7 @@ export const GET = apiHandler(async () => {
|
||||
credentialDeviceType: true,
|
||||
credentialBackedUp: true,
|
||||
createdAt: true,
|
||||
lastUsedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user