From 1757b35327f0dbbacd93b03fabafb8d59c93d209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 27 May 2026 07:40:05 +0200 Subject: [PATCH] feat(auth): record an identifier HMAC on failed-login audit rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failed-login audit rows currently carry `ipAddress` plus `reason` (`user_not_found_or_no_password` or `invalid_password`). The typed identifier is deliberately omitted (H-1 contract: PII never lands in operator artefacts) — but that leaves no anchor a future spray detector can group on. Recording `hashToken(identifier)` alongside the reason keeps the H-1 contract intact (HMAC-SHA256 under `API_TOKEN_HMAC_KEY` is one- way; the raw identifier never serialises into the row) while giving any future "same identifier hit from many IPs" detector a correlation key. Test mock for `hashToken` now returns a deterministic hex string that does not echo the raw input — mirrors the real HMAC shape and keeps the existing "audit row must not contain the typed email" assertion passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/auth/login/__tests__/route.test.ts | 9 ++++++++- src/app/api/auth/login/route.ts | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/app/api/auth/login/__tests__/route.test.ts b/src/app/api/auth/login/__tests__/route.test.ts index 8d926404e..759ffc97f 100644 --- a/src/app/api/auth/login/__tests__/route.test.ts +++ b/src/app/api/auth/login/__tests__/route.test.ts @@ -47,7 +47,14 @@ vi.mock("@/lib/db-compat", () => ({ })); vi.mock("@/lib/auth/hmac", () => ({ - hashToken: vi.fn((raw: string) => `hashed:${raw}`), + // Return a deterministic hex string that does NOT echo the raw + // value — mirrors the real HMAC-SHA256 shape. The audit row's + // privacy contract is "the typed identifier never serialises into + // the row's body", so the mock must not embed the raw input. + hashToken: vi.fn( + (raw: string) => + "aa".repeat(32) + (raw.length % 8).toString(16), + ), })); vi.mock("@/lib/logging/transports", () => ({ diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 49619ded4..147d5f0da 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -3,6 +3,7 @@ import { loginPasswordSchema } from "@/lib/validations/auth"; import { verifyPassword } from "@/lib/auth/password"; import { createSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; +import { hashToken } from "@/lib/auth/hmac"; import { apiSuccess, apiError, @@ -54,6 +55,12 @@ export const POST = apiHandler(async (request: NextRequest) => { const { email, password } = parsed.data; const identifier = email.trim(); + // HMAC of the typed identifier — keyed by `API_TOKEN_HMAC_KEY`, + // mirrors the `/api/auth/check-user` pattern. The raw identifier + // stays out of the audit row (H-1 contract); the hash gives a + // future spray-detector a forensic anchor it can correlate across + // IPs without having to look up users by email. + const identifierHash = hashToken(identifier); const user = await prisma.user.findFirst({ where: { @@ -67,10 +74,13 @@ export const POST = apiHandler(async (request: NextRequest) => { if (!user || !user.passwordHash) { // v1.4.43 W3-SECURITY (H-1): never write the typed identifier into // the audit row — `reason` already tells the operator what - // happened, and PII must not land in operator artefacts. + // happened, and PII must not land in operator artefacts. The + // HMAC anchor below is one-way — recoverable only with the HMAC + // key (operator secret), used purely to group same-identifier + // attempts across IPs. await auditLog("auth.login.failed", { ipAddress: ip, - details: { reason: "user_not_found_or_no_password" }, + details: { reason: "user_not_found_or_no_password", identifierHash }, }); return apiError("Invalid credentials", 401); } @@ -80,7 +90,7 @@ export const POST = apiHandler(async (request: NextRequest) => { await auditLog("auth.login.failed", { userId: user.id, ipAddress: ip, - details: { reason: "invalid_password" }, + details: { reason: "invalid_password", identifierHash }, }); return apiError("Invalid credentials", 401); }