security: constant-time authorize + uniform audit summaries (#40)

Prevent user-enumeration via login-response timing and audit-log content.
All failing branches now run argon2.verify against a precomputed dummy
hash (discarding the result), and emit a single "Login failed" audit
summary. Detailed reason stays in the server-only pino logger.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 08:50:25 +02:00
parent c0ea1d0cb9
commit 03030639d7
2 changed files with 128 additions and 10 deletions
+106 -6
View File
@@ -10,7 +10,7 @@
* runtime and is covered by E2E tests instead.
*/
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
// ── next-auth imports next/server without .js extension which fails in vitest
// node env. Mock the whole module so the error classes can be imported.
@@ -26,16 +26,31 @@ vi.mock("next-auth", () => {
// ── All other side-effectful imports auth.ts pulls in ───────────────────────
vi.mock("./runtime-env.js", () => ({ assertSecureRuntimeEnv: vi.fn() }));
vi.mock("next-auth/providers/credentials", () => ({ default: vi.fn() }));
vi.mock("@capakraken/db", () => ({
prisma: { user: {}, systemSettings: {}, activeSession: {} },
// Capture the config passed to Credentials() so we can call authorize().
const credentialsCalls: Array<{ authorize: (...args: unknown[]) => unknown }> = [];
vi.mock("next-auth/providers/credentials", () => ({
default: vi.fn((cfg: { authorize: (...args: unknown[]) => unknown }) => {
credentialsCalls.push(cfg);
return cfg;
}),
}));
const prismaMock = {
user: { findUnique: vi.fn(), update: vi.fn() },
systemSettings: { findUnique: vi.fn() },
activeSession: { create: vi.fn(), findMany: vi.fn(), deleteMany: vi.fn(), delete: vi.fn() },
};
vi.mock("@capakraken/db", () => ({ prisma: prismaMock }));
vi.mock("@capakraken/api/middleware/rate-limit", () => ({
authRateLimiter: vi.fn().mockResolvedValue({ allowed: true }),
}));
vi.mock("@capakraken/api/middleware/rate-limit", () => ({ authRateLimiter: vi.fn() }));
vi.mock("@capakraken/api/lib/audit", () => ({ createAuditEntry: vi.fn() }));
vi.mock("@capakraken/api/lib/logger", () => ({
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("@node-rs/argon2", () => ({ verify: vi.fn() }));
const argonVerifyMock = vi.fn();
vi.mock("@node-rs/argon2", () => ({ verify: argonVerifyMock }));
// ── Import the exported error classes after mocks are in place ───────────────
const { MfaRequiredError, MfaRequiredSetupError, InvalidTotpError } = await import("./auth.js");
@@ -66,3 +81,88 @@ describe("MFA CredentialsSignin error classes — code property", () => {
expect(new InvalidTotpError().constructor.name).toBe("InvalidTotpError");
});
});
describe("authorize() — login timing / enumeration defence", () => {
const authorize = credentialsCalls[0]?.authorize;
if (!authorize) {
it.skip("authorize was not captured", () => {});
return;
}
beforeEach(() => {
argonVerifyMock.mockReset();
prismaMock.user.findUnique.mockReset();
prismaMock.user.update.mockReset();
prismaMock.systemSettings.findUnique.mockReset();
});
it("runs argon2.verify against a dummy hash when the user is not found", async () => {
prismaMock.user.findUnique.mockResolvedValue(null);
argonVerifyMock.mockResolvedValue(false);
const result = await authorize(
{ email: "nobody@example.com", password: "s3cret-password" },
undefined,
);
expect(result).toBeNull();
expect(argonVerifyMock).toHaveBeenCalledTimes(1);
const [hashArg, passwordArg] = argonVerifyMock.mock.calls[0]!;
expect(typeof hashArg).toBe("string");
expect(hashArg).toMatch(/^\$argon2id\$/);
expect(passwordArg).toBe("s3cret-password");
});
it("runs argon2.verify against a dummy hash when the account is deactivated", async () => {
prismaMock.user.findUnique.mockResolvedValue({
id: "u1",
email: "x@example.com",
isActive: false,
passwordHash: "$argon2id$real$hash",
});
argonVerifyMock.mockResolvedValue(false);
const result = await authorize({ email: "x@example.com", password: "wrong" }, undefined);
expect(result).toBeNull();
expect(argonVerifyMock).toHaveBeenCalledTimes(1);
expect(argonVerifyMock.mock.calls[0]![0]).toMatch(/^\$argon2id\$/);
});
it("records a uniform 'Login failed' audit summary for every failure branch", async () => {
const { createAuditEntry } = await import("@capakraken/api/lib/audit");
const auditMock = createAuditEntry as unknown as ReturnType<typeof vi.fn>;
auditMock.mockClear();
// Branch 1: user not found
prismaMock.user.findUnique.mockResolvedValueOnce(null);
argonVerifyMock.mockResolvedValueOnce(false);
await authorize({ email: "a@example.com", password: "p" }, undefined);
// Branch 2: deactivated account
prismaMock.user.findUnique.mockResolvedValueOnce({
id: "u1",
email: "b@example.com",
isActive: false,
passwordHash: "$argon2id$h",
});
argonVerifyMock.mockResolvedValueOnce(false);
await authorize({ email: "b@example.com", password: "p" }, undefined);
// Branch 3: wrong password
prismaMock.user.findUnique.mockResolvedValueOnce({
id: "u2",
email: "c@example.com",
isActive: true,
passwordHash: "$argon2id$h",
});
argonVerifyMock.mockResolvedValueOnce(false);
await authorize({ email: "c@example.com", password: "p" }, undefined);
const summaries = auditMock.mock.calls.map(
(call: unknown[]) => (call[0] as { summary: string }).summary,
);
expect(summaries).toEqual(["Login failed", "Login failed", "Login failed"]);
});
});