Add cross-reference map, admin health page, passkeys and device list, settings tabs
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
- 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>
This commit is contained in:
1 parent
207bfcb919
commit
bdb04cdedc
35 files changed
+1860
-22
No files matched your search
@@ -0,0 +1,171 @@
|
||||
// @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' });
|
||||
});
|
||||
Reference in new issue
Block a user