import { createHash, randomUUID } from 'node:crypto'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { AuthUser, UserRole } from '../../src/lib/auth.js'; import { newFixture, type Fixture } from '../helpers/fixture.js'; // Exercise real password verification without weakening production scrypt costs. vi.setConfig({ testTimeout: 20_000, hookTimeout: 20_000 }); const password = 'testpass'; const replacement = 'a replacement account passphrase'; const firstAddress = '192.0.2.1'; const limitedAddress = '192.0.2.2'; let passwordHash: string; let fixture: Fixture; let auth: typeof import('../../src/lib/server/auth/index.js'); let database: typeof import('../../src/lib/server/db/index.js'); beforeAll(async () => { const passwords = await import('../../src/lib/server/auth/passwords.js'); passwordHash = await passwords.hashPassword(password); }); beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); auth = await import('../../src/lib/server/auth/index.js'); database = await import('../../src/lib/server/db/index.js'); auth.setupRequired(); }); afterEach(async () => { vi.restoreAllMocks(); vi.unstubAllEnvs(); await fixture.cleanup(); }); function seedUser(username = 'viewer', role: UserRole = 'viewer', disabled = false): AuthUser { const id = randomUUID(); const now = Date.now(); database .db() .prepare( `INSERT INTO auth_users(id,username,display_name,password_hash,role,disabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)` ) .run(id, username, username, passwordHash, role, Number(disabled), now, now); return auth.getUser(id)!; } function exhaustPair(username: string, address = limitedAddress) { for (let attempt = 0; attempt < 10; attempt++) auth.consumeAuthAttempt(address, username); } function devices() { return database.db().prepare('SELECT * FROM auth_login_devices ORDER BY rowid').all(); } async function loginRoute( input: Record, options: { cookie?: string; address?: string; origin?: string } = {} ) { const { POST } = await import('../../src/routes/api/auth/login/+server.js'); const url = new URL('/api/auth/login', options.origin ?? 'https://ec.test'); const deviceName = url.protocol === 'https:' ? '__Host-ec_login_device' : 'ec_login_device_dev'; const cookies = { get: vi.fn((name: string) => (name === deviceName ? options.cookie : undefined)), set: vi.fn() }; const response = await POST({ request: new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }), cookies, url, getClientAddress: () => options.address ?? firstAddress } as unknown as Parameters[0]); return { response, cookies }; } describe('known login device proof', () => { it('creates an account-bound, hashed proof after password verification that cannot authenticate a session', async () => { const now = 2_000_000_000_000; vi.spyOn(Date, 'now').mockReturnValue(now); const user = seedUser(); const result = await auth.authenticatePassword( { username: user.username, password }, firstAddress ); const device = result.loginDevice!; expect(device.token).toMatch(/^[A-Za-z0-9_-]{43}$/); expect(device.token).not.toBe(result.session.token); expect(device.expiresAt).toBe(now + 30 * 24 * 60 * 60_000); expect(devices()).toEqual([ { token_hash: createHash('sha256').update(device.token).digest('hex'), user_id: user.id, created_at: now, expires_at: device.expiresAt } ]); expect(auth.readSession(device.token)).toBeNull(); expect(auth.readSession(result.session.token)?.user.id).toBe(user.id); const persisted = JSON.stringify(devices()) + JSON.stringify(auth.listAuthAudit()); expect(persisted).not.toContain(device.token); expect(persisted).not.toContain(result.session.token); expect(persisted).not.toContain(password); }); it('allows a proven account past an exhausted pair while still rejecting a wrong password', async () => { const user = seedUser(); const initial = await auth.authenticatePassword( { username: user.username, password }, firstAddress ); exhaustPair(user.username); await expect( auth.authenticatePassword({ username: user.username, password }, limitedAddress) ).rejects.toMatchObject({ status: 429 }); const original = devices(); await expect( auth.authenticatePassword( { username: user.username, password: 'wrong password' }, limitedAddress, initial.loginDevice!.token ) ).rejects.toMatchObject({ status: 401 }); expect(devices()).toEqual(original); const result = await auth.authenticatePassword( { username: user.username.toUpperCase(), password }, limitedAddress, initial.loginDevice!.token ); expect(result.user.id).toBe(user.id); expect(result.loginDevice).toEqual(initial.loginDevice); expect(devices()).toEqual(original); }); it.each(['other account', 'malformed', 'unknown', 'expired', 'session token'] as const)( 'does not bypass an exhausted pair with %s proof', async (kind) => { const user = seedUser(); const initial = await auth.authenticatePassword( { username: user.username, password }, firstAddress ); const target = kind === 'other account' ? seedUser('another-viewer') : user; let token = initial.loginDevice!.token; if (kind === 'malformed') token += '!'; if (kind === 'unknown') token = 'x'.repeat(43); if (kind === 'session token') token = initial.session.token; if (kind === 'expired') database.db().prepare('UPDATE auth_login_devices SET expires_at=?').run(Date.now()); exhaustPair(target.username); const original = devices(); await expect( auth.authenticatePassword( { username: target.username, password }, limitedAddress, token ) ).rejects.toMatchObject({ status: 429 }); expect(devices()).toEqual(original); } ); it('continues enforcing the shared IP ceiling for a valid device', async () => { const user = seedUser(); const initial = await auth.authenticatePassword( { username: user.username, password }, firstAddress ); for (let attempt = 0; attempt < 30; attempt++) auth.consumeAuthAttempt(limitedAddress, `missing-${attempt}`); await expect( auth.authenticatePassword( { username: user.username, password }, limitedAddress, initial.loginDevice!.token ) ).rejects.toMatchObject({ status: 429 }); expect(devices()).toHaveLength(1); }); it('accepts proof only from the request cookie and ignores body-only trust assertions', async () => { const user = seedUser(); const initial = await auth.authenticatePassword( { username: user.username, password }, firstAddress ); exhaustPair(user.username); const input = { username: user.username, password, loginDevice: initial.loginDevice!.token, trusted: true, knownDevice: true }; const rejected = await loginRoute(input, { address: limitedAddress }); expect(rejected.response.status).toBe(429); expect(rejected.cookies.set).not.toHaveBeenCalled(); const accepted = await loginRoute(input, { address: limitedAddress, cookie: initial.loginDevice!.token }); expect(accepted.response.status).toBe(200); expect(accepted.cookies.get).toHaveBeenCalledWith('__Host-ec_login_device'); expect(devices()).toHaveLength(1); }); it('retains the original absolute expiry when the database is reopened and the device logs in again', async () => { const now = 2_000_000_000_000; const clock = vi.spyOn(Date, 'now').mockReturnValue(now); const user = seedUser(); const initial = await auth.authenticatePassword( { username: user.username, password }, firstAddress ); const original = devices(); database.closeDb(); clock.mockReturnValue(now + 5 * 24 * 60 * 60_000); exhaustPair(user.username); const next = await auth.authenticatePassword( { username: user.username, password }, limitedAddress, initial.loginDevice!.token ); expect(next.loginDevice).toEqual(initial.loginDevice); expect(devices()).toEqual(original); clock.mockReturnValue(initial.loginDevice!.expiresAt); exhaustPair(user.username); await expect( auth.authenticatePassword( { username: user.username, password }, limitedAddress, initial.loginDevice!.token ) ).rejects.toMatchObject({ status: 429 }); }); it('does not issue device cookies or store proof for failed password logins', async () => { seedUser(); seedUser('disabled', 'viewer', true); for (const input of [ { username: 'viewer', password: 'wrong password' }, { username: 'missing', password }, { username: 'disabled', password } ]) { const result = await loginRoute(input); expect(result.response.status).toBe(401); expect(result.cookies.set).not.toHaveBeenCalled(); expect(devices()).toEqual([]); } }); }); describe('known login device lifecycle', () => { it('keeps device proof through logout while removing the session cookie and session', async () => { const user = seedUser(); const initial = await auth.authenticatePassword( { username: user.username, password }, firstAddress ); const original = devices(); const { POST } = await import('../../src/routes/api/auth/logout/+server.js'); const cookies = { get: vi.fn((name: string) => name === '__Host-ec_session' ? initial.session.token : initial.loginDevice!.token ), delete: vi.fn() }; const response = await POST({ cookies, url: new URL('https://ec.test/api/auth/logout'), locals: { user } } as unknown as Parameters[0]); expect(response.status).toBe(200); expect(cookies.delete.mock.calls).toEqual([['__Host-ec_session', { path: '/' }]]); expect(auth.readSession(initial.session.token)).toBeNull(); expect(devices()).toEqual(original); exhaustPair(user.username); await expect( auth.authenticatePassword( { username: user.username, password }, limitedAddress, initial.loginDevice!.token ) ).resolves.toMatchObject({ user: { id: user.id }, loginDevice: initial.loginDevice }); }); it.each(['role', 'disable', 'password reset', 'password change', 'revoke sessions'] as const)( 'revokes device proof after an account %s operation', async (operation) => { const admin = seedUser('administrator', 'admin'); const user = seedUser(); const initial = await auth.authenticatePassword( { username: user.username, password }, firstAddress ); if (operation === 'role') await auth.updateUser(admin.id, user.id, { role: 'operator' }); if (operation === 'disable') await auth.updateUser(admin.id, user.id, { disabled: true }); if (operation === 'password reset') await auth.updateUser(admin.id, user.id, { password: replacement }); if (operation === 'password change') await auth.changePassword( user.id, password, replacement, auth.readSession(initial.session.token)!.tokenHash ); if (operation === 'revoke sessions') auth.revokeUserSessions(admin.id, user.id); expect(devices()).toEqual([]); expect(auth.readSession(initial.session.token)).toBeNull(); exhaustPair(user.username); await expect( auth.authenticatePassword( { username: user.username, password: operation.startsWith('password') ? replacement : password }, limitedAddress, initial.loginDevice!.token ) ).rejects.toMatchObject({ status: 429 }); } ); it.each([ { origin: 'https://ec.test', rememberMe: false }, { origin: 'https://ec.test', rememberMe: true }, { origin: 'http://localhost', rememberMe: false } ])('sets a separate device cookie for $origin with rememberMe=$rememberMe', async (options) => { const now = 2_000_000_000_000; vi.spyOn(Date, 'now').mockReturnValue(now); const user = seedUser(); const { response, cookies } = await loginRoute( { username: user.username, password, rememberMe: options.rememberMe }, options ); expect(response.status).toBe(200); expect(cookies.set).toHaveBeenCalledTimes(2); const secure = options.origin.startsWith('https:'); const deviceName = secure ? '__Host-ec_login_device' : 'ec_login_device_dev'; const sessionName = secure ? '__Host-ec_session' : 'ec_session_dev'; const [, deviceToken, deviceOptions] = cookies.set.mock.calls.find( ([name]) => name === deviceName )!; const [, sessionToken, sessionOptions] = cookies.set.mock.calls.find( ([name]) => name === sessionName )!; expect(deviceOptions).toEqual({ path: '/', httpOnly: true, secure, sameSite: 'lax', expires: new Date(now + 30 * 24 * 60 * 60_000) }); expect(sessionOptions.expires).toEqual( new Date( now + (options.rememberMe ? auth.REMEMBERED_SESSION_LIFETIME_MS : auth.SESSION_LIFETIME_MS) ) ); expect(deviceToken).not.toBe(sessionToken); expect(auth.readSession(deviceToken)).toBeNull(); expect(auth.readSession(sessionToken)?.remembered).toBe(options.rememberMe); expect(response.headers.get('Cache-Control')).toBe('no-store'); expect(await response.json()).toEqual({ user: expect.objectContaining({ id: user.id }) }); }); });