const test = require('node:test'); const assert = require('node:assert/strict'); const { hashPassword, encodePasswordRecord, verifyEncodedPassword, hashScryptPassword, verifyCredentialPassword } = require('../src/auth'); const { createPasswordCallbacks, createAccountLockSessionHook, createLoginRateLimitResetHook, createPasskeyAuthenticationGuard, resolvePasskeyRpID, getRoleTwoFactorAuthError, ensureBetterAuthClientIp } = require('../src/web/lib/auth/better-auth'); 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 })); });