Files
electric-cherry/tests/unit/auth-login-devices.test.ts
T
2026-10-17 10:55:28 -04:00

387 lines
15 KiB
TypeScript

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<string, unknown>,
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<typeof POST>[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<typeof POST>[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 }) });
});
});