// @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' }); });