Files
pulse-signage/test/better-auth-password.test.js
lzstealth e6c48c6340
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m22s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s
Release v2.20.4
2026-10-25 20:47:38 +00:00

203 lines
10 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const { hashPassword, encodePasswordRecord, verifyEncodedPassword, hashScryptPassword, verifyCredentialPassword } = require('../src/auth');
const { createPasswordCallbacks, createAccountLockSessionHook, createLoginRateLimitResetHook, createLoginAuditHook, createPasskeyAuthenticationGuard, resolvePasskeyRpID, getRoleTwoFactorAuthError, ensureBetterAuthClientIp, getTrustedProxies } = require('../src/web/lib/auth/better-auth');
test('completed password, two-factor, and passkey logins record an audit event with request metadata', async () => {
const events = [];
const pool = {};
const hook = createLoginAuditHook(pool, async function (actualPool, req, event) {
assert.equal(actualPool, pool);
events.push({ req, event });
});
for (const path of ['/sign-in/username', '/sign-in/email', '/two-factor/verify-totp', '/two-factor/verify-backup-code', '/passkey/verify-authentication']) {
await hook({
path,
headers: new Headers({ 'x-forwarded-for': '192.0.2.1', 'user-agent': 'test-browser' }),
context: { newSession: { session: { ipAddress: '192.0.2.1' }, user: { id: 7, username: 'alice' } } }
});
}
assert.equal(events.length, 5);
for (const { req, event } of events) {
assert.deepEqual(event, { category: 'authentication', eventType: 'login.success', actorUserId: 7, targetType: 'user', targetId: 7, targetLabel: 'alice' });
assert.equal(req.headers['user-agent'], 'test-browser');
assert.equal(req.headers['x-forwarded-for'], '192.0.2.1');
assert.equal(req.ip, '192.0.2.1');
}
});
test('failed login, pending two-factor, and unrelated session creation do not log a successful login', async () => {
const hook = createLoginAuditHook({}, async function () { assert.fail('Login must not be audited as successful'); });
for (const path of ['/sign-in/username', '/two-factor/verify-totp', '/passkey/verify-authentication']) {
await hook({ path, context: { newSession: null } });
}
await hook({ path: '/two-factor/enable', context: { newSession: { session: {}, user: { id: 7 } } } });
});
test('Better Auth trusted proxies accept comma-separated IPs and CIDRs without trusting proxies by default', async () => {
const { getIPFromHeader } = await import('@better-auth/core/utils/ip');
assert.deepEqual(getTrustedProxies(''), []);
assert.deepEqual(getTrustedProxies(' , '), []);
const trustedProxies = getTrustedProxies(' 192.0.2.2, 198.51.100.0/24, , ::1 ');
assert.deepEqual(trustedProxies, ['192.0.2.2', '198.51.100.0/24', '::1']);
const chain = '203.0.113.1, 192.0.2.2, 198.51.100.2';
assert.equal(getIPFromHeader(chain, { trustedProxies: getTrustedProxies('') }), null);
assert.equal(getIPFromHeader(chain, { trustedProxies }), '203.0.113.1');
});
test('Better Auth client IP falls back to the socket address for direct requests', () => {
for (const address of ['192.0.2.1', '::1', '::ffff:192.0.2.1']) {
const req = { headers: {}, socket: { remoteAddress: address } };
ensureBetterAuthClientIp(req);
assert.equal(req.headers['x-forwarded-for'], address);
}
});
test('Better Auth client IP preserves forwarded addresses and does not invent a missing address', () => {
const req = { headers: { 'x-forwarded-for': '192.0.2.1, 192.0.2.2' }, socket: { remoteAddress: '192.0.2.3' } };
ensureBetterAuthClientIp(req);
assert.equal(req.headers['x-forwarded-for'], '192.0.2.1, 192.0.2.2');
for (const directReq of [{ headers: {} }, { headers: {}, socket: {} }]) {
ensureBetterAuthClientIp(directReq);
assert.equal(directReq.headers['x-forwarded-for'], undefined);
}
});
test('passkey RP ID uses localhost for local HTTP-IP configuration and the canonical hostname otherwise', () => {
assert.equal(resolvePasskeyRpID('http://192.168.0.80:8080'), 'localhost');
assert.equal(resolvePasskeyRpID('http://localhost:8080'), 'localhost');
assert.equal(resolvePasskeyRpID('https://signage.example.com'), 'signage.example.com');
});
test('passkey authentication is blocked for accounts with individual or role-required two-factor authentication', async () => {
const queries = [];
let account = { two_factor_enabled: 1, role_requires_two_factor: 0 };
class TestAPIError extends Error {
constructor(code, details) { super(details.message); this.code = code; }
}
const guard = createPasskeyAuthenticationGuard({
async query(sql, params) {
queries.push([String(sql), params]);
return [[account]];
}
}, TestAPIError);
await assert.rejects(guard({ clientData: { id: 'credential-id' } }), error => error.code === 'FORBIDDEN');
account = { two_factor_enabled: 0, role_requires_two_factor: 1 };
await assert.rejects(guard({ clientData: { id: 'credential-id' } }), error => error.code === 'FORBIDDEN');
account = { two_factor_enabled: 0, role_requires_two_factor: 0 };
await guard({ clientData: { id: 'credential-id' } });
assert.deepEqual(queries[0][1], ['credential-id']);
assert.match(queries[0][0], /r\.require_two_factor = 1/);
assert.equal(queries.length, 3);
});
test('required two-factor policy blocks disabling and restricts pending enrollment auth endpoints', () => {
const actor = { requiresTwoFactor: true, twoFactorEnabled: false };
assert.equal(getRoleTwoFactorAuthError(actor, '/api/auth/two-factor/disable').code, 'TWO_FACTOR_REQUIRED_BY_ROLE');
assert.equal(getRoleTwoFactorAuthError({ ...actor, twoFactorEnabled: true }, '/api/auth/two-factor/disable/').code, 'TWO_FACTOR_REQUIRED_BY_ROLE');
assert.equal(getRoleTwoFactorAuthError(actor, '/api/auth/change-email').code, 'TWO_FACTOR_ENROLLMENT_REQUIRED');
for (const endpoint of ['two-factor/enable', 'two-factor/get-totp-uri', 'two-factor/verify-totp', 'two-factor/generate-backup-codes', 'get-session', 'sign-out', 'verify-password']) {
assert.equal(getRoleTwoFactorAuthError(actor, '/api/auth/' + endpoint), null);
}
assert.equal(getRoleTwoFactorAuthError(null, '/api/auth/sign-in/username'), null);
assert.equal(getRoleTwoFactorAuthError({ requiresTwoFactor: false }, '/api/auth/two-factor/disable'), null);
assert.equal(getRoleTwoFactorAuthError({ ...actor, twoFactorEnabled: true }, '/api/auth/change-email'), null);
assert.equal(getRoleTwoFactorAuthError({ requiresTwoFactor: false, authenticatedUser: actor }, '/api/auth/two-factor/disable').code, 'TWO_FACTOR_REQUIRED_BY_ROLE');
});
test('successful sign-in clears only the client login and two-factor rate limits', async () => {
const queries = [];
const hook = createLoginRateLimitResetHook({ async query(sql, params) { queries.push({ sql, params }); } });
const paths = ['/sign-in/username', '/sign-in/email', '/two-factor/verify-totp', '/two-factor/verify-backup-code'];
for (const path of paths) {
await hook({ path, context: { newSession: { session: { ipAddress: '192.0.2.1' } } } });
}
assert.equal(queries.length, paths.length);
for (const query of queries) {
assert.equal(query.sql, 'DELETE FROM a_login_attempts WHERE rate_key IN (?, ?, ?, ?)');
assert.deepEqual(query.params, paths.map(path => '192.0.2.1|' + path));
}
});
test('failed login, pending two-factor, and unrelated endpoints do not clear rate limits', async () => {
const hook = createLoginRateLimitResetHook({ async query() { assert.fail('Rate limits must remain intact'); } });
await hook({ path: '/sign-in/username', context: { newSession: null } });
await hook({ path: '/two-factor/verify-totp', context: { newSession: null } });
await hook({ path: '/email-otp/reset-password', context: { newSession: { session: { ipAddress: '192.0.2.1' } } } });
});
test('Better Auth password adapter verifies existing PBKDF2 records', () => {
const password = 'Strong-password-123!';
const encoded = encodePasswordRecord(hashPassword(password));
assert.equal(verifyEncodedPassword(password, encoded), true);
assert.equal(verifyEncodedPassword('incorrect-password', encoded), false);
});
test('Better Auth password adapter rejects unknown stored hash formats', () => {
assert.equal(verifyEncodedPassword('password', 'unknown$format'), false);
assert.equal(verifyEncodedPassword('password', 'app-user:7'), false);
});
test('Better Auth upgrades valid PBKDF2 passwords to its native scrypt hash on login', async () => {
const password = 'Strong-password-123!';
const legacyHash = encodePasswordRecord(hashPassword(password));
const updates = [];
const crypto = await import('better-auth/crypto');
const callbacks = createPasswordCallbacks({
async query(sql, params) {
updates.push([String(sql), params]);
return [{ affectedRows: 1 }];
}
}, crypto);
assert.equal(await callbacks.verify({ password: password, hash: legacyHash }), true);
assert.equal(updates.length, 1);
assert.match(updates[0][1][0], /^[a-f0-9]{32}:[a-f0-9]{128}$/);
assert.deepEqual(updates[0][1].slice(1), ['credential', legacyHash]);
assert.equal(await crypto.verifyPassword({ hash: updates[0][1][0], password: password }), true);
});
test('Better Auth does not upgrade an incorrect legacy password', async () => {
let updateCount = 0;
const crypto = await import('better-auth/crypto');
const callbacks = createPasswordCallbacks({
async query() { updateCount += 1; return [{ affectedRows: 1 }]; }
}, crypto);
const legacyHash = encodePasswordRecord(hashPassword('Correct-password-123!'));
assert.equal(await callbacks.verify({ password: 'incorrect-password', hash: legacyHash }), false);
assert.equal(updateCount, 0);
});
test('shared auth utility verifies Better Auth native scrypt hashes', async () => {
const password = 'Strong-password-123!';
const hash = await hashScryptPassword(password);
const crypto = await import('better-auth/crypto');
assert.equal(await crypto.verifyPassword({ hash: hash, password: password }), true);
assert.equal(await verifyCredentialPassword(password, hash), true);
assert.equal(await verifyCredentialPassword('incorrect-password', hash), false);
});
test('Better Auth session hook blocks locked users for every sign-in method', async () => {
let accountLocked = true;
const pool = {
async query(_sql, params) {
assert.deepEqual(params, [7]);
return [[{ account_locked: accountLocked }]];
}
};
class TestAPIError extends Error {
constructor(_status, body) {
super(body.message);
}
}
const lockHook = createAccountLockSessionHook(pool, TestAPIError);
await assert.rejects(lockHook({ userId: 7 }), /account is locked/i);
accountLocked = false;
await assert.doesNotReject(lockHook({ userId: 7 }));
});