import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import test from 'node:test'; import { Algorithm as Argon2Algorithm, hashRawSync as argon2HashRawSync } from '@node-rs/argon2'; import { createUserAuth } from './user-auth.mjs'; function hashPasswordPbkdf2(password, salt) { return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex'); } function hashPasswordArgon2id(password, salt) { return argon2HashRawSync(password, { salt: Buffer.from(salt, 'hex'), parallelism: 1, outputLen: 32, memoryCost: 64 * 1024, timeCost: 3, algorithm: Argon2Algorithm.Argon2id, }).toString('hex'); } function createAuthPool(userRow) { const sessions = []; return { async query(sql, params = []) { if (sql.includes('FROM h5_users u') && sql.includes('WHERE u.username = ?')) { return userRow ? [[userRow]] : [[]]; } if (sql.includes('UPDATE h5_users') && sql.includes('password_algorithm')) { userRow.salt = params[0]; userRow.password_hash = params[1]; userRow.password_algorithm = params[2]; return [[]]; } if (sql.includes('INSERT INTO h5_login_sessions')) { sessions.push({ token_hash: params[2], user_id: params[1], expires_at: params[3], revoked_at: null, }); return [[]]; } if (sql.includes('FROM h5_login_sessions s')) { const row = sessions.find((item) => item.token_hash === params[0] && !item.revoked_at); if (!row) return [[]]; return [[ { user_id: row.user_id, expires_at: row.expires_at, role: userRow.role, status: userRow.status, }, ]]; } if (sql.includes('UPDATE h5_login_sessions SET expires_at')) { const row = sessions.find((item) => item.token_hash === params[1]); if (row) row.expires_at = params[0]; return [[]]; } if (sql.includes('UPDATE h5_login_sessions SET revoked_at')) { for (const row of sessions) { if (row.user_id === params[1]) row.revoked_at = params[0]; } return [[]]; } if (sql.includes('FROM h5_users u') && sql.includes('WHERE u.id = ?')) { return [[userRow]]; } return [[]]; }, }; } test('login rate limits repeated failures', async () => { const auth = createUserAuth(createAuthPool(null), { loginMaxFailures: 2, loginFailureWindowMs: 60_000, persistSessions: false, }); assert.equal((await auth.login({ username: 'john', password: 'bad', ip: '1.1.1.1' })).ok, false); assert.equal((await auth.login({ username: 'john', password: 'bad', ip: '1.1.1.1' })).ok, false); const blocked = await auth.login({ username: 'john', password: 'secret', ip: '1.1.1.1' }); assert.equal(blocked.ok, false); assert.ok(blocked.retryAfterMs > 0); }); test('verify rejects disabled users after login', async () => { const salt = '00112233445566778899aabbccddeeff'; const userRow = { id: 'user-1', username: 'john', slug: 'john', email: 'john@example.com', display_name: 'John', role: 'user', status: 'active', plan_type: 'free', workspace_root: '/tmp/john', salt, password_hash: hashPasswordArgon2id('secret', salt), password_algorithm: 'argon2id', balance_cents: 100, tokens_used: 0, }; const auth = createUserAuth(createAuthPool(userRow), { persistSessions: true }); const login = await auth.login({ username: 'john', password: 'secret', ip: '1.1.1.1' }); assert.equal(login.ok, true); userRow.status = 'disabled'; assert.equal(await auth.verify(login.token), null); }); test('resetPassword updates hash when username and email match', async () => { const salt = '11112222333344445555666677778888'; const userRow = { id: 'user-1', username: 'john', email: 'john@example.com', status: 'active', salt, password_hash: hashPasswordPbkdf2('old-secret', salt), password_algorithm: 'pbkdf2-sha512', }; const pool = { async query(sql, params = []) { if (sql.includes('SELECT id, email, status FROM h5_users WHERE username = ?')) { return userRow.username === params[0] ? [[userRow]] : [[]]; } if (sql.includes('UPDATE h5_users SET salt = ?')) { userRow.salt = params[0]; userRow.password_hash = params[1]; userRow.password_algorithm = params[2]; return [[]]; } if (sql.includes('UPDATE h5_login_sessions SET revoked_at')) { return [[]]; } return [[]]; }, }; const auth = createUserAuth(pool, { persistSessions: false }); const ok = await auth.resetPassword({ username: 'john', email: 'john@example.com', password: 'new-secret', }); assert.equal(ok.ok, true); assert.equal(userRow.password_algorithm, 'argon2id'); assert.equal(userRow.password_hash, hashPasswordArgon2id('new-secret', userRow.salt)); const bad = await auth.resetPassword({ username: 'john', email: 'wrong@example.com', password: 'another', }); assert.equal(bad.ok, false); }); test('login migrates pbkdf2 passwords to argon2id after successful verification', async () => { const salt = '9999aaaabbbbccccddddeeeeffff0000'; const userRow = { id: 'user-1', username: 'john', slug: 'john', email: 'john@example.com', display_name: 'John', role: 'user', status: 'active', plan_type: 'free', workspace_root: '/tmp/john', salt, password_hash: hashPasswordPbkdf2('secret', salt), password_algorithm: 'pbkdf2-sha512', balance_cents: 100, tokens_used: 0, }; const auth = createUserAuth(createAuthPool(userRow), { persistSessions: false }); const login = await auth.login({ username: 'john', password: 'secret', ip: '1.1.1.1' }); assert.equal(login.ok, true); assert.equal(userRow.password_algorithm, 'argon2id'); assert.equal(userRow.password_hash, hashPasswordArgon2id('secret', userRow.salt)); });