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>
172 lines
11 KiB
JavaScript
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' });
|
|
});
|