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
+22 -4
View File
@@ -12,6 +12,15 @@ import { authConfig } from "./auth.config.js";
assertSecureRuntimeEnv();
// Precomputed argon2id hash of a random string we do not retain. Used to run a
// dummy verify() when the user does not exist (or has no password hash) so the
// code path takes the same wall-clock time as a real failed-login for a
// known user. Without this, an attacker can enumerate valid accounts by
// measuring how fast "email not found" returns vs. "password wrong"
// (EAPPS 3.2.7.05 / OWASP ASVS 2.2.1).
const DUMMY_ARGON2_HASH =
"$argon2id$v=19$m=65536,t=3,p=4$dFRrYlpCaTMzd1lHeFMwTw$wZcMWHRxxOy2trvRfOjjKzYP/VQ2k+D01FA54zUlfUw";
// Auth.js v5: throw CredentialsSignin subclasses so the `code` is forwarded
// to the client via SignInResponse.code — plain Error throws become
// CallbackRouteError and the message is never visible to the client.
@@ -88,7 +97,16 @@ const config = {
}
const user = await prisma.user.findUnique({ where: { email } });
// Always run argon2.verify — even when the user doesn't exist or is
// deactivated — so all failing branches incur the same CPU cost. The
// result from the dummy path is discarded; only the shape of the
// audit log / return value changes. Summaries are kept uniform
// ("Login failed") so audit-log contents cannot be used to
// enumerate accounts either; the reason stays in the server-only
// logger.warn.
if (!user?.passwordHash) {
await verify(DUMMY_ARGON2_HASH, password).catch(() => false);
logger.warn({ email, reason: "user_not_found" }, "Failed login attempt");
void createAuditEntry({
db: prisma,
@@ -96,13 +114,14 @@ const config = {
entityId: email.toLowerCase(),
entityName: email,
action: "CREATE",
summary: "Login failed — user not found",
summary: "Login failed",
source: "ui",
});
return null;
}
if (!user.isActive) {
await verify(DUMMY_ARGON2_HASH, password).catch(() => false);
logger.warn(
{ email, userId: user.id, reason: "account_deactivated" },
"Login blocked — account deactivated",
@@ -114,7 +133,7 @@ const config = {
entityName: user.email,
action: "CREATE",
userId: user.id,
summary: "Login blocked — account deactivated",
summary: "Login failed",
source: "ui",
});
return null;
@@ -123,7 +142,6 @@ const config = {
const isValid = await verify(user.passwordHash, password);
if (!isValid) {
logger.warn({ email, reason: "invalid_password" }, "Failed login attempt");
// Audit failed login (bad password)
void createAuditEntry({
db: prisma,
entityType: "Auth",
@@ -131,7 +149,7 @@ const config = {
entityName: user.email,
action: "CREATE",
userId: user.id,
summary: "Login failed — invalid password",
summary: "Login failed",
source: "ui",
});
return null;