Add optional 30-day remembered sessions
CI / checks (push) Successful in 6m29s

This commit is contained in:
2026-10-13 14:42:53 -04:00
parent e85dc58130
commit 82e07ea33e
13 changed files with 677 additions and 72 deletions
+119
View File
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import { writable } from 'svelte/store';
import LoginPage from '../../src/routes/login/+page.svelte';
import UsersPage from '../../src/routes/settings/users/+page.svelte';
const page = writable({
url: new URL('https://ec.test/login?next=/hosts'),
data: {
user: { id: 'admin', username: 'administrator', role: 'admin' },
rememberedSession: false,
setupRequired: false
}
});
vi.mock('$app/stores', () => ({
page: { subscribe: (run: Parameters<typeof page.subscribe>[0]) => page.subscribe(run) }
}));
vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn() }));
let component: ReturnType<typeof mount> | undefined;
beforeEach(() => {
page.update((value) => ({ ...value, data: { ...value.data, rememberedSession: false } }));
});
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function enter(selector: string, value: string) {
const input = document.querySelector<HTMLInputElement>(selector)!;
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
}
it.each([false, true])(
'leaves remembering unchecked by default and submits the selected choice: %s',
async (rememberMe) => {
const fetch = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ error: 'Try again' }), { status: 401 })
);
vi.stubGlobal('fetch', fetch);
component = mount(LoginPage, { target: document.body });
flushSync();
const checkbox = document.querySelector<HTMLInputElement>('input[name="rememberMe"]')!;
expect(checkbox.checked).toBe(false);
expect(document.body.textContent).toContain('30 days');
enter('#username', 'administrator');
enter('#password', 'my current password');
checkbox.checked = rememberMe;
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
document
.querySelector('form')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
username: 'administrator',
password: 'my current password',
rememberMe
});
await vi.waitFor(() => expect(document.body.textContent).toContain('Try again'));
expect(document.querySelector<HTMLInputElement>('#password')!.value).toBe('');
expect(checkbox.checked).toBe(rememberMe);
}
);
it.each([false, true])(
'asks remembered administrators for their own password before creating an account: %s',
async (rememberedSession) => {
page.update((value) => ({ ...value, data: { ...value.data, rememberedSession } }));
const fetch = vi.fn(async (path: string, options?: RequestInit) => {
if (options?.method === 'POST')
return new Response(JSON.stringify({ error: 'Could not create user' }), {
status: 403
});
return new Response(
JSON.stringify(path.endsWith('/audit') ? { events: [] } : { users: [] })
);
});
vi.stubGlobal('fetch', fetch);
component = mount(UsersPage, { target: document.body });
flushSync();
const add = Array.from(document.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Add user'
)!;
add.click();
flushSync();
const confirmation = document.querySelector<HTMLInputElement>('#create-current-password');
expect(!!confirmation).toBe(rememberedSession);
if (rememberedSession) {
expect(confirmation!.required).toBe(true);
enter('#create-current-password', 'admin secret');
}
enter('#new-username', 'viewer');
enter('#new-name', 'Viewer');
enter('#temporary-password', 'temporary secret');
document
.querySelector('form')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
await vi.waitFor(() =>
expect(fetch.mock.calls.some(([, options]) => options?.method === 'POST')).toBe(true)
);
const [, options] = fetch.mock.calls.find(([, options]) => options?.method === 'POST')!;
expect(JSON.parse(String(options!.body))).toMatchObject({
password: 'temporary secret',
currentPassword: rememberedSession ? 'admin secret' : ''
});
await vi.waitFor(() =>
expect(document.body.textContent).toContain('Could not create user')
);
if (confirmation) expect(confirmation.value).toBe('');
}
);
+38 -2
View File
@@ -1,6 +1,9 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { migrateSuperViewerRole } from '../../src/lib/server/auth/schema.js';
import {
migrateRememberedSessions,
migrateSuperViewerRole
} from '../../src/lib/server/auth/schema.js';
import { runMigrations } from '../../src/lib/server/db/migration-runner.js';
import { migrations } from '../../src/lib/server/db/migrations.js';
@@ -29,7 +32,10 @@ beforeEach(() => {
afterEach(() => handle.close());
function rows(table: string) {
return handle.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all();
// Compare the original session fields across migrations that add policy columns.
const columns =
table === 'auth_sessions' ? 'token_hash,user_id,created_at,expires_at,last_seen_at' : '*';
return handle.prepare(`SELECT ${columns} FROM ${table} ORDER BY rowid`).all();
}
describe('super viewer role migration', () => {
@@ -117,3 +123,33 @@ describe('super viewer role migration', () => {
expect(handle.pragma('foreign_key_check')).toEqual([]);
});
});
describe('remembered session migration', () => {
it('preserves existing sessions as short sessions and safely replays without clearing remembered devices', () => {
const upgrade = migrations.find((entry) => entry.name === 'remembered browser sessions')!;
runMigrations(handle, migrations.slice(0, upgrade.version - 1));
const sessions = rows('auth_sessions');
const users = rows('auth_users');
runMigrations(handle, migrations);
expect(rows('auth_sessions')).toEqual(sessions);
expect(rows('auth_users')).toEqual(users);
expect(handle.prepare('SELECT remembered FROM auth_sessions').all()).toEqual([
{ remembered: 0 },
{ remembered: 0 }
]);
expect(() => handle.exec('UPDATE auth_sessions SET remembered=2')).toThrow(
'CHECK constraint failed'
);
handle.exec("UPDATE auth_sessions SET remembered=1 WHERE user_id='admin'");
const remembered = handle.prepare('SELECT * FROM auth_sessions').all();
const schemaVersion = handle.pragma('schema_version', { simple: true });
migrateRememberedSessions(handle);
handle.pragma(`user_version = ${upgrade.version - 1}`);
runMigrations(handle, migrations);
expect(handle.prepare('SELECT * FROM auth_sessions').all()).toEqual(remembered);
expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion);
expect(handle.pragma('foreign_key_check')).toEqual([]);
});
});
+322 -42
View File
@@ -347,51 +347,150 @@ describe('administrator user management', () => {
expect(auth.getUser(second.id)?.role).toBe('admin');
});
it('revokes every target session on role, enabled-state and password changes while keeping other users signed in', async () => {
const admin = seedUser(),
target = seedUser('target', 'viewer');
const administratorSession = auth.createSession(admin.id);
const first = auth.createSession(target.id),
second = auth.createSession(target.id);
await auth.updateUser(admin.id, target.id, { role: 'super_viewer' });
expect(auth.getUser(target.id)?.role).toBe('super_viewer');
expect(auth.readSession(first.token)).toBeNull();
expect(auth.readSession(second.token)).toBeNull();
const third = auth.createSession(target.id);
await auth.updateUser(admin.id, target.id, { disabled: true });
expect(auth.readSession(third.token)).toBeNull();
expect(() => auth.createSession(target.id)).toThrow('unavailable');
await auth.updateUser(admin.id, target.id, { disabled: false });
expect(auth.readSession(third.token)).toBeNull();
const fourth = auth.createSession(target.id);
const reset = await auth.updateUser(admin.id, target.id, { password: replacement });
expect(reset.mustChangePassword).toBe(true);
expect(auth.readSession(fourth.token)).toBeNull();
expect(auth.readSession(administratorSession.token)?.user.id).toBe(admin.id);
expect(storedUser(target.id).password_hash).not.toBe(passwordHash);
await expect(
auth.updateUser(admin.id, admin.id, { password: replacement })
).rejects.toThrow('current password');
});
it.each([false, true])(
'revokes every target session on role, enabled-state and password changes (remembered=%s)',
async (remembered) => {
const admin = seedUser(),
target = seedUser('target', 'viewer');
const administratorSession = auth.createSession(admin.id);
const first = auth.createSession(target.id, remembered),
second = auth.createSession(target.id);
await auth.updateUser(admin.id, target.id, { role: 'super_viewer' });
expect(auth.getUser(target.id)?.role).toBe('super_viewer');
expect(auth.readSession(first.token)).toBeNull();
expect(auth.readSession(second.token)).toBeNull();
const third = auth.createSession(target.id, remembered);
await auth.updateUser(admin.id, target.id, { disabled: true });
expect(auth.readSession(third.token)).toBeNull();
expect(() => auth.createSession(target.id)).toThrow('unavailable');
await auth.updateUser(admin.id, target.id, { disabled: false });
expect(auth.readSession(third.token)).toBeNull();
const fourth = auth.createSession(target.id, remembered);
const reset = await auth.updateUser(admin.id, target.id, { password: replacement });
expect(reset.mustChangePassword).toBe(true);
expect(auth.readSession(fourth.token)).toBeNull();
expect(auth.readSession(administratorSession.token)?.user.id).toBe(admin.id);
expect(storedUser(target.id).password_hash).not.toBe(passwordHash);
await expect(
auth.updateUser(admin.id, admin.id, { password: replacement })
).rejects.toThrow('current password');
}
);
it('keeps sessions for display-only edits and supports explicit per-user and individual revocation', async () => {
const admin = seedUser(),
target = seedUser('target', 'viewer');
const one = auth.createSession(target.id),
two = auth.createSession(target.id),
adminSession = auth.createSession(admin.id);
await auth.updateUser(admin.id, target.id, { displayName: 'New display name' });
expect(auth.readSession(one.token)?.user.displayName).toBe('New display name');
auth.revokeSession(one.token);
expect(auth.readSession(one.token)).toBeNull();
expect(auth.readSession(two.token)).not.toBeNull();
auth.revokeUserSessions(admin.id, target.id);
expect(auth.readSession(two.token)).toBeNull();
expect(auth.readSession(adminSession.token)).not.toBeNull();
});
it.each([false, true])(
'keeps sessions for display-only edits and supports per-user and individual revocation (remembered=%s)',
async (remembered) => {
const admin = seedUser(),
target = seedUser('target', 'viewer');
const one = auth.createSession(target.id, remembered),
two = auth.createSession(target.id, remembered),
adminSession = auth.createSession(admin.id);
await auth.updateUser(admin.id, target.id, { displayName: 'New display name' });
expect(auth.readSession(one.token)?.user.displayName).toBe('New display name');
auth.revokeSession(one.token);
expect(auth.readSession(one.token)).toBeNull();
expect(auth.readSession(two.token)).not.toBeNull();
auth.revokeUserSessions(admin.id, target.id);
expect(auth.readSession(two.token)).toBeNull();
expect(auth.readSession(adminSession.token)).not.toBeNull();
}
);
});
describe('session secrecy and expiration', () => {
it('signs out a remembered device by deleting its cookie and server session', async () => {
const user = seedUser();
const session = auth.createSession(user.id, true);
const otherDevice = auth.createSession(user.id, true);
const { POST } = await import('../../src/routes/api/auth/logout/+server.js');
const url = new URL('https://ec.test/api/auth/logout');
const cookies = { get: vi.fn(() => session.token), delete: vi.fn() };
const response = await POST({ cookies, url, locals: { user } } as unknown as Parameters<
typeof POST
>[0]);
expect(response.status).toBe(200);
expect(cookies.delete).toHaveBeenCalledWith('__Host-ec_session', { path: '/' });
expect(auth.readSession(session.token)).toBeNull();
expect(auth.readSession(otherDevice.token)).not.toBeNull();
});
it.each([undefined, false, true])(
'requires explicit opt-in at login and sets the cookie lifetime (rememberMe=%s)',
async (rememberMe) => {
const now = 2_000_000_000_000;
vi.spyOn(Date, 'now').mockReturnValue(now);
const user = seedUser();
const previous = auth.createSession(user.id, true);
const { POST } = await import('../../src/routes/api/auth/login/+server.js');
const url = new URL('https://ec.test/api/auth/login');
const cookies = { get: vi.fn(() => previous.token), set: vi.fn() };
const response = await POST({
request: new Request(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user.username, password, rememberMe })
}),
cookies,
url,
getClientAddress: () => '192.0.2.60'
} as unknown as Parameters<typeof POST>[0]);
expect(response.status).toBe(200);
const [name, token, options] = cookies.set.mock.calls[0];
const lifetime = rememberMe ? 30 * 24 * 60 * 60_000 : 12 * 60 * 60_000;
expect(name).toBe('__Host-ec_session');
expect(options).toEqual({
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
expires: new Date(now + lifetime)
});
expect(auth.readSession(token)).toMatchObject({
remembered: rememberMe === true,
expiresAt: now + lifetime
});
expect(auth.readSession(previous.token)).toBeNull();
}
);
it.each(['true', 'false', 1, null, {}])(
'rejects non-boolean remember-device choices: %j',
async (rememberMe) => {
const user = seedUser();
await expect(
auth.authenticatePassword(
{ username: user.username, password, rememberMe },
'192.0.2.60'
)
).rejects.toMatchObject({ status: 400 });
expect(database.db().prepare('SELECT * FROM auth_sessions').all()).toEqual([]);
}
);
it('keeps remembered sessions across inactivity, other logins and restarts, but never beyond 30 days', () => {
let now = 2_000_000_000_000;
vi.spyOn(Date, 'now').mockImplementation(() => now);
const user = seedUser();
const remembered = auth.createSession(user.id, true);
const short = auth.createSession(user.id);
now += 2 * 24 * 60 * 60_000;
auth.createSession(user.id); // Expiry cleanup must retain inactive remembered devices.
database.closeDb();
expect(auth.readSession(short.token)).toBeNull();
expect(auth.readSession(remembered.token)).toMatchObject({
user: { id: user.id },
remembered: true
});
now = remembered.expiresAt - 1;
expect(auth.readSession(remembered.token)?.expiresAt).toBe(remembered.expiresAt);
now++;
expect(auth.readSession(remembered.token)).toBeNull();
auth.createSession(user.id);
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({
count: 1
});
});
it('stores only opaque session hashes, never raw cookies or password hashes in public identities', () => {
const user = seedUser();
const first = auth.createSession(user.id),
@@ -449,6 +548,52 @@ describe('session secrecy and expiration', () => {
});
describe('forced password changes and asynchronous authorization races', () => {
it.each([false, true])(
'preserves this device choice through a required password change and revokes all old sessions (remembered=%s)',
async (remembered) => {
let now = 2_000_000_000_000;
vi.spyOn(Date, 'now').mockImplementation(() => now);
const user = seedUser('newuser', 'viewer', { mustChangePassword: true });
const login = await auth.authenticatePassword(
{ username: user.username, password, rememberMe: remembered },
'192.0.2.61'
);
const other = auth.createSession(user.id, true);
const tokenHash = auth.readSession(login.session.token)!.tokenHash;
const { POST } = await import('../../src/routes/api/auth/password/+server.js');
const url = new URL('https://ec.test/api/auth/password');
const cookies = { set: vi.fn() };
const response = await POST({
request: new Request(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// The server must preserve the stored policy, ignoring a forged client choice.
body: JSON.stringify({
currentPassword: password,
newPassword: replacement,
rememberMe: !remembered
})
}),
cookies,
url,
locals: { user, sessionHash: tokenHash },
getClientAddress: () => '192.0.2.61'
} as unknown as Parameters<typeof POST>[0]);
expect(response.status).toBe(200);
const [, token, options] = cookies.set.mock.calls[0];
const lifetime = remembered ? 30 * 24 * 60 * 60_000 : 12 * 60 * 60_000;
expect(options.expires).toEqual(new Date(now + lifetime));
expect(auth.readSession(login.session.token)).toBeNull();
expect(auth.readSession(other.token)).toBeNull();
expect(auth.readSession(token)).toMatchObject({
remembered,
user: { mustChangePassword: false }
});
now += 24 * 60 * 60_000;
expect(!!auth.readSession(token)).toBe(remembered);
}
);
it('requires the current password, replaces old sessions atomically and clears the forced-change state', async () => {
const user = seedUser('newuser', 'viewer', { mustChangePassword: true });
const login = await auth.authenticatePassword(
@@ -577,6 +722,138 @@ describe('forced password changes and asynchronous authorization races', () => {
});
});
describe('remembered administrator account changes', () => {
it.each([false, true])(
'enforces password confirmation in account creation and update endpoints (remembered=%s)',
async (remembered) => {
const admin = seedUser();
const login = auth.createSession(admin.id, remembered);
const sessionHash = auth.readSession(login.token)!.tokenHash;
const { POST } = await import('../../src/routes/api/users/+server.js');
const { PATCH } = await import('../../src/routes/api/users/[id]/+server.js');
function event(method: string, body: Record<string, unknown>, id = '') {
const url = new URL(`https://ec.test/api/users/${id}`);
return {
url,
request: new Request(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}),
locals: { user: admin, sessionHash },
params: { id },
getClientAddress: () => '192.0.2.62'
};
}
const input = { username: 'newviewer', role: 'viewer', password: replacement };
if (remembered) {
for (const currentPassword of [undefined, replacement]) {
const response = await POST(
event('POST', { ...input, currentPassword }) as unknown as Parameters<
typeof POST
>[0]
);
expect(response.status).toBe(403);
expect(auth.listUsers()).toHaveLength(1);
}
}
const response = await POST(
event('POST', {
...input,
...(remembered ? { currentPassword: password } : {})
}) as unknown as Parameters<typeof POST>[0]
);
expect(response.status).toBe(201);
const { user } = await response.json();
const targetSession = auth.createSession(user.id, true);
const changes = { role: 'operator', password };
if (remembered) {
for (const currentPassword of [undefined, replacement]) {
const response = await PATCH(
event(
'PATCH',
{ ...changes, currentPassword },
user.id
) as unknown as Parameters<typeof PATCH>[0]
);
expect(response.status).toBe(403);
expect(auth.getUser(user.id)?.role).toBe('viewer');
expect(auth.readSession(targetSession.token)).not.toBeNull();
}
}
const updated = await PATCH(
event(
'PATCH',
{
...changes,
...(remembered ? { currentPassword: password } : {})
},
user.id
) as unknown as Parameters<typeof PATCH>[0]
);
expect(updated.status).toBe(200);
expect(auth.getUser(user.id)?.role).toBe('operator');
expect(auth.readSession(targetSession.token)).toBeNull();
const audit = JSON.stringify(auth.listAuthAudit());
expect(audit).not.toContain(replacement);
expect(audit).not.toContain('currentPassword');
}
);
it('rejects an account change if the remembered session is revoked while confirming the password', async () => {
const admin = seedUser(),
target = seedUser('target', 'viewer');
const session = auth.createSession(admin.id, true);
const tokenHash = auth.readSession(session.token)!.tokenHash;
const passwords = await import('../../src/lib/server/auth/passwords.js');
const verifying = gate<boolean>();
vi.spyOn(passwords, 'verifyPassword').mockImplementationOnce(() => verifying.promise);
const pending = auth.updateUser(
admin.id,
target.id,
{ role: 'admin', currentPassword: password },
{
tokenHash,
clientAddress: '192.0.2.62'
}
);
auth.revokeSession(session.token);
verifying.release(true);
await expect(pending).rejects.toMatchObject({ status: 401 });
expect(auth.getUser(target.id)?.role).toBe('viewer');
});
it('rechecks the session after hashing a new user password', async () => {
const admin = seedUser();
const session = auth.createSession(admin.id, true);
const tokenHash = auth.readSession(session.token)!.tokenHash;
const passwords = await import('../../src/lib/server/auth/passwords.js');
vi.spyOn(passwords, 'verifyPassword').mockResolvedValueOnce(true);
const hashing = gate<string>();
const hash = vi
.spyOn(passwords, 'hashPassword')
.mockImplementationOnce(() => hashing.promise);
const pending = auth.createUser(
admin.id,
{
username: 'newviewer',
role: 'viewer',
password: replacement,
currentPassword: password
},
{
tokenHash,
clientAddress: '192.0.2.62'
}
);
await vi.waitFor(() => expect(hash).toHaveBeenCalled());
auth.revokeSession(session.token);
hashing.release(replacementHash);
await expect(pending).rejects.toMatchObject({ status: 401 });
expect(auth.listUsers()).toHaveLength(1);
});
});
describe('persistent authentication throttling', () => {
it.each(['admin', 'super_viewer'] as const)(
'does not consume login or password-change limits for an enabled %s',
@@ -638,7 +915,10 @@ describe('persistent authentication throttling', () => {
body: JSON.stringify({ currentPassword: password, newPassword: replacement })
}),
cookies: { set: vi.fn() },
locals: { user: login.user },
locals: {
user: login.user,
sessionHash: auth.readSession(login.session.token)!.tokenHash
},
url,
getClientAddress: () => address
} as unknown as Parameters<typeof POST>[0]);