Files
nmemmertandClaude Sonnet 5.5 bdb04cdedc
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
Add cross-reference map, admin health page, passkeys and device list, settings tabs
- Reader: chapter cross-reference map (M) from IQ Bible's reference graph
- Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs
- Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts
- Settings split into Account, Preferences, Notifications, Security, Your data tabs
- ntfy server field: catch a topic typed into the server box

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 12:52:49 -04:00

172 lines
11 KiB
JavaScript

// @vitest-environment node
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { createHash, generateKeyPairSync, randomBytes, sign } from 'crypto';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import express from 'express';
import session from 'express-session';
const b64 = (b) => Buffer.from(b).toString('base64url');
const sha = (b) => createHash('sha256').update(b).digest();
// A tiny CBOR writer, just enough for the attestation a passkey device would send.
const head = (major, n) => (n < 24 ? Buffer.from([(major << 5) | n]) : n < 256 ? Buffer.from([(major << 5) | 24, n]) : Buffer.from([(major << 5) | 25, n >> 8, n & 255]));
const cbor = (v) => {
if (Buffer.isBuffer(v)) return Buffer.concat([head(2, v.length), v]);
if (typeof v === 'string') return Buffer.concat([head(3, Buffer.byteLength(v)), Buffer.from(v)]);
if (typeof v === 'number') return v >= 0 ? head(0, v) : head(1, -1 - v);
const entries = v instanceof Map ? [...v] : Object.entries(v);
return Buffer.concat([head(5, entries.length), ...entries.flatMap(([k, x]) => [cbor(k), cbor(x)])]);
};
// A software "phone": makes passkeys and answers sign-in challenges, like Face ID would.
function authenticator(rpID, origin) {
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const jwk = publicKey.export({ format: 'jwk' });
const credId = randomBytes(16);
let counter = 0;
const rpHash = sha(rpID);
const client = (type, challenge, o = origin) => Buffer.from(JSON.stringify({ type, challenge, origin: o, crossOrigin: false }));
return {
id: b64(credId),
create(challenge, o) {
const cose = cbor(new Map([[1, 2], [3, -7], [-1, 1], [-2, Buffer.from(jwk.x, 'base64url')], [-3, Buffer.from(jwk.y, 'base64url')]]));
const len = Buffer.alloc(2); len.writeUInt16BE(credId.length);
const cnt = Buffer.alloc(4);
const authData = Buffer.concat([rpHash, Buffer.from([0x45]), cnt, Buffer.alloc(16), len, credId, cose]);
return { id: b64(credId), rawId: b64(credId), type: 'public-key', clientExtensionResults: {}, response: { clientDataJSON: b64(client('webauthn.create', challenge, o)), attestationObject: b64(cbor({ fmt: 'none', attStmt: {}, authData })), transports: ['internal'] } };
},
get(challenge, o) {
counter += 1;
const cnt = Buffer.alloc(4); cnt.writeUInt32BE(counter);
const authData = Buffer.concat([rpHash, Buffer.from([0x05]), cnt]);
const clientData = client('webauthn.get', challenge, o);
const signature = sign('sha256', Buffer.concat([authData, sha(clientData)]), privateKey);
return { id: b64(credId), rawId: b64(credId), type: 'public-key', clientExtensionResults: {}, response: { clientDataJSON: b64(clientData), authenticatorData: b64(authData), signature: b64(signature), userHandle: null } };
},
};
}
let dir; let server; let base; let db;
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'devices-'));
process.env.DATA_DIR = dir; delete process.env.APP_URL;
vi.resetModules();
db = await import('./db.js'); db.initDb();
const { registerDeviceRoutes } = await import('./devices.js');
const { requireAuth } = await import('./auth.js');
const app = express();
app.use(express.json());
app.use(session({ secret: 'test', resave: false, saveUninitialized: false, store: new (await import('./sessionStore.js')).SqliteSessionStore(), cookie: { maxAge: 30 * 24 * 3600 * 1000 } }));
const { noteSignIn } = await import('./devices.js');
// stand-in for the password sign-in route
app.post('/test/login', (req, res) => { const u = db.getUserByEmail(req.body.email); req.session.regenerate(() => { req.session.userId = u.id; noteSignIn(req, u, 'password'); res.json({ ok: true }); }); });
registerDeviceRoutes(app, { requireAuth, userPayload: (u) => ({ id: u.id, email: u.email }), isLimited: () => false, recordFailure() {}, clearFailures() {}, tooMany: { error: 'slow down' } });
await new Promise((r) => { server = app.listen(0, r); });
base = `http://localhost:${server.address().port}`;
db.createUser({ id: 'u1', email: 'a@x.test', passwordHash: 'h' });
});
afterEach(async () => { await new Promise((r) => server.close(r)); rmSync(dir, { recursive: true, force: true }); delete process.env.DATA_DIR; });
// A browser: keeps its own cookie
function browser(ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17 Safari/605.1.15') {
let cookie = '';
const call = async (method, path, body) => {
const res = await fetch(base + path, { method, headers: { 'Content-Type': 'application/json', 'User-Agent': ua, ...(cookie ? { Cookie: cookie } : {}) }, body: body === undefined ? undefined : JSON.stringify(body) });
const set = res.headers.get('set-cookie');
if (set) cookie = set.split(';')[0];
return { status: res.status, body: await res.json().catch(() => null) };
};
return { call, login: () => call('POST', '/test/login', { email: 'a@x.test' }) };
}
const originOf = () => base;
test('add a passkey, then sign in with it from another device', async () => {
const phone = authenticator('localhost', originOf());
const laptop = browser(); await laptop.login();
const { body: opts } = await laptop.call('POST', '/api/passkeys/options');
expect(opts.authenticatorSelection.userVerification).toBe('required');
const added = await laptop.call('POST', '/api/passkeys', { response: phone.create(opts.challenge), name: 'My phone' });
expect(added.status).toBe(200);
expect(added.body).toMatchObject([{ id: phone.id, name: 'My phone' }]);
const other = browser('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1');
expect((await other.call('GET', '/api/passkeys')).status).toBe(401); // not signed in yet
const { body: ask } = await other.call('POST', '/api/auth/passkey/options');
const ok = await other.call('POST', '/api/auth/passkey/verify', phone.get(ask.challenge));
expect(ok.status).toBe(200);
expect(ok.body.email).toBe('a@x.test');
expect((await other.call('GET', '/api/passkeys')).body[0].lastUsedAt).toBeGreaterThan(0);
const devices = (await other.call('GET', '/api/sessions')).body;
expect(devices.map((d) => d.label)).toEqual(['Safari on iPhone', 'Safari on macOS']);
expect(devices[0]).toMatchObject({ current: true, method: 'passkey' });
});
test('a passkey from the wrong site, an unknown one, or a replay is refused', async () => {
const phone = authenticator('localhost', originOf());
const laptop = browser(); await laptop.login();
const { body: opts } = await laptop.call('POST', '/api/passkeys/options');
const bad = await laptop.call('POST', '/api/passkeys', { response: phone.create(opts.challenge, 'https://evil.example') });
expect(bad.status).toBe(400);
await laptop.call('POST', '/api/passkeys', { response: phone.create(opts.challenge), name: 'ok' });
const stranger = browser();
const { body: ask } = await stranger.call('POST', '/api/auth/passkey/options');
expect((await stranger.call('POST', '/api/auth/passkey/verify', authenticator('localhost', originOf()).get(ask.challenge))).status).toBe(401); // never registered
expect((await stranger.call('POST', '/api/auth/passkey/verify', phone.get('wrong-challenge'))).status).toBe(401);
const good = phone.get(ask.challenge);
expect((await stranger.call('POST', '/api/auth/passkey/verify', good)).status).toBe(200);
const replay = browser();
await replay.call('POST', '/api/auth/passkey/options');
expect((await replay.call('POST', '/api/auth/passkey/verify', good)).status).toBe(401); // that answer was for another challenge
});
test('passkeys can be renamed and removed, and only by their owner', async () => {
const phone = authenticator('localhost', originOf());
const me = browser(); await me.login();
const { body: opts } = await me.call('POST', '/api/passkeys/options');
await me.call('POST', '/api/passkeys', { response: phone.create(opts.challenge) });
expect((await me.call('PATCH', `/api/passkeys/${phone.id}`, { name: 'Pixel' })).body[0].name).toBe('Pixel');
expect((await me.call('PATCH', `/api/passkeys/${phone.id}`, { name: ' ' })).status).toBe(400);
db.createUser({ id: 'u2', email: 'b@x.test', passwordHash: 'h' });
expect(db.deletePasskey(phone.id, 'u2')).toBe(false);
expect((await me.call('DELETE', `/api/passkeys/${phone.id}`)).body).toEqual([]);
const { body: ask } = await browser().call('POST', '/api/auth/passkey/options');
expect(ask.challenge).toBeTruthy();
});
test('devices can be signed out one by one or all at once', async () => {
const a = browser(); const b = browser('Mozilla/5.0 (Windows NT 10.0) Chrome/120 Safari/537'); const c = browser('Mozilla/5.0 (Linux; Android 14) Chrome/120 Mobile Safari/537');
await a.login(); await b.login(); await c.login();
const list = (await a.call('GET', '/api/sessions')).body;
expect(list).toHaveLength(3);
expect(list[0].current).toBe(true);
expect((await a.call('DELETE', `/api/sessions/${list[0].id}`)).status).toBe(400); // not your own
const bId = list.find((d) => d.label === 'Chrome on Windows').id;
expect((await a.call('DELETE', `/api/sessions/${bId}`)).status).toBe(200);
expect((await b.call('GET', '/api/sessions')).status).toBe(401);
expect((await a.call('DELETE', `/api/sessions/${bId}`)).status).toBe(404);
await a.call('POST', '/api/sessions/sign-out-others');
expect((await c.call('GET', '/api/sessions')).status).toBe(401);
expect((await a.call('GET', '/api/sessions')).body).toHaveLength(1);
});
test('sign-in alerts need a notification topic', async () => {
const a = browser(); await a.login();
expect((await a.call('PUT', '/api/auth/login-alerts', { enabled: true })).status).toBe(400);
db.setReminderSettings('u1', { ntfyUrl: null, ntfyTopic: 'my-topic', reminderTime: '08:00', reminderEnabled: false, reminderTz: 'UTC' });
expect((await a.call('PUT', '/api/auth/login-alerts', { enabled: true })).body).toEqual({ loginAlerts: true });
expect(db.getUserById('u1').loginAlerts).toBe(true);
});
test('devices are described in plain words', async () => {
const { describeDevice, relyingParty } = await import('./devices.js');
expect(describeDevice('Mozilla/5.0 (Windows NT 10.0) Chrome/120 Edg/120')).toBe('Edge on Windows');
expect(describeDevice('Mozilla/5.0 (X11; Linux) Firefox/120')).toBe('Firefox on Linux');
expect(describeDevice('')).toBe('Unknown device');
const req = { hostname: 'study.example', protocol: 'https', get: () => 'study.example:8443' };
expect(relyingParty(req, {})).toEqual({ rpID: 'study.example', origin: 'https://study.example:8443' });
expect(relyingParty(req, { APP_URL: 'https://study.example/app' })).toEqual({ rpID: 'study.example', origin: 'https://study.example' });
});