diff --git a/src/app/api/auth/check-user/__tests__/route.test.ts b/src/app/api/auth/check-user/__tests__/route.test.ts new file mode 100644 index 000000000..60b90f6f9 --- /dev/null +++ b/src/app/api/auth/check-user/__tests__/route.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// --- Mocks must come before importing the route. --- + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findFirst: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +// The real `hashToken` is an HMAC-SHA256 (one-way + key-dependent); the +// shape only matters here, so a deterministic stand-in keeps the test +// stable without standing up the env-keyed CipherSuite. The PII check +// below asserts the raw identifier never appears in the audit payload. +vi.mock("@/lib/auth/hmac", () => ({ + hashToken: vi.fn( + (raw: string) => + `hash-${Buffer.from(raw, "utf-8") + .toString("hex") + .slice(0, 16) + .padStart(16, "0")}`, + ), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, remaining: 30, resetAt: 0 }), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/logging/transports", () => ({ + emitIfSampled: vi.fn(), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { hashToken } from "@/lib/auth/hmac"; + +function expectedHash(identifier: string): string { + return (hashToken as unknown as (s: string) => string)(identifier); +} + +function makeRequest(body: { identifier: string }): NextRequest { + return new NextRequest("http://localhost/api/auth/check-user", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.mocked(prisma.user.findFirst).mockReset(); + vi.mocked(auditLog).mockReset(); + vi.mocked(auditLog).mockResolvedValue(undefined as never); +}); + +describe("POST /api/auth/check-user — audit-log row (M-1)", () => { + it("writes an audit row for the not_found branch with identifier_hash (never raw)", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + + const res = await POST(makeRequest({ identifier: "ghost@example.com" })); + expect(res.status).toBe(200); + + expect(auditLog).toHaveBeenCalledTimes(1); + const args = vi.mocked(auditLog).mock.calls[0]; + expect(args[0]).toBe("auth.check-user"); + expect(args[1]).toEqual({ + ipAddress: "unknown", + details: { + branch: "not_found", + identifier_hash: expectedHash("ghost@example.com"), + }, + }); + // The raw identifier must not leak into the details — every other + // /api/auth/* audit row already hashes; the check-user route follows + // the same shape. + const details = args[1]?.details as Record | undefined; + expect(details).not.toHaveProperty("identifier"); + expect(JSON.stringify(details)).not.toContain("ghost@example.com"); + }); + + it("writes an audit row for the passkey_only branch with userId attached", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + id: "user-1", + passwordHash: null, + _count: { passkeys: 2 }, + } as never); + + const res = await POST(makeRequest({ identifier: "marc" })); + expect(res.status).toBe(200); + + expect(auditLog).toHaveBeenCalledTimes(1); + expect(vi.mocked(auditLog).mock.calls[0][1]).toEqual({ + userId: "user-1", + ipAddress: "unknown", + details: { + branch: "passkey_only", + identifier_hash: expectedHash("marc"), + }, + }); + }); + + it("writes an audit row for the email_fallback branch with userId attached", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + id: "user-2", + passwordHash: "$argon2id$...", + _count: { passkeys: 0 }, + } as never); + + const res = await POST(makeRequest({ identifier: "marc@example.com" })); + expect(res.status).toBe(200); + + const args = vi.mocked(auditLog).mock.calls[0]; + expect(args[1]?.details).toEqual({ + branch: "email_fallback", + identifier_hash: expectedHash("marc@example.com"), + }); + expect(args[1]?.userId).toBe("user-2"); + }); + + it("writes an audit row for the exists branch (no passkey + no password)", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + id: "user-3", + passwordHash: null, + _count: { passkeys: 0 }, + } as never); + + const res = await POST(makeRequest({ identifier: "stub" })); + expect(res.status).toBe(200); + + const args = vi.mocked(auditLog).mock.calls[0]; + expect(args[1]?.details).toEqual({ + branch: "exists", + identifier_hash: expectedHash("stub"), + }); + expect(args[1]?.userId).toBe("user-3"); + }); +}); diff --git a/src/app/api/auth/check-user/route.ts b/src/app/api/auth/check-user/route.ts index 5af669687..6591231b8 100644 --- a/src/app/api/auth/check-user/route.ts +++ b/src/app/api/auth/check-user/route.ts @@ -40,6 +40,8 @@ import { prisma } from "@/lib/db"; import { apiSuccess, apiError, safeJson, getClientIp } from "@/lib/api-response"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { hashToken } from "@/lib/auth/hmac"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; @@ -92,8 +94,21 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); + // v1.4.43 W13 M-1 — persist a hashed-identifier audit row so an admin + // can later answer "did anyone enumerate accounts last week?". The + // wide-event `annotate()` log is sampled and short-retention; the + // audit ledger is durable. We hash the identifier (same shape as the + // `auth.login.failed` row) so the literal email/username never lands + // in `AuditLog.details` — the v1.4.20 retroactive PII directive bars + // raw user-typed strings from operator-readable artefacts. + const identifierHash = hashToken(identifier); + if (!user) { annotate({ action: { name: "auth.check-user" }, meta: { branch: "not_found" } }); + await auditLog("auth.check-user", { + ipAddress: ip, + details: { branch: "not_found", identifier_hash: identifierHash }, + }); return apiSuccess({ branch: "not_found" satisfies CheckUserBranch, hasPasskey: false, @@ -110,5 +125,10 @@ export const POST = apiHandler(async (request: NextRequest) => { else branch = "exists"; annotate({ action: { name: "auth.check-user" }, meta: { branch } }); + await auditLog("auth.check-user", { + userId: user.id, + ipAddress: ip, + details: { branch, identifier_hash: identifierHash }, + }); return apiSuccess({ branch, hasPasskey, hasPassword }); });