var test = require('node:test'); var assert = require('node:assert/strict'); var EventEmitter = require('node:events'); var fixture = require('./helpers/account-auth-fixture'); var authenticator = require('./helpers/passkey-authenticator').createAuthenticator; var passwords = require('../lib/account-passwords'); var phrase = 'synthetic long account passphrase'; var origin = 'https://clay.example.test'; function request(server, method, url, data, cookie, headers) { return new Promise(function (resolve) { var req = new EventEmitter(); req.method = method; req.url = url; req.headers = Object.assign({ host: 'clay.example.test', 'content-type': 'application/json', origin: origin, cookie: cookie || '' }, headers); req.socket = { remoteAddress: '127.0.0.1', encrypted: true }; var result = {}; var res = { writeHead: function (code, values) { result.code = code; result.headers = values || {}; }, end: function (body) { result.body = JSON.parse(body); resolve(result); } }; assert.equal(server.handleRequest(req, res, url), true); req.emit('data', JSON.stringify(data || {})); req.emit('end'); }); } function cookies(response) { var value = response.headers['Set-Cookie']; return (Array.isArray(value) ? value : value ? [value] : []).map(function (entry) { return entry.split(';')[0]; }).join('; '); } async function setup(t) { var h = fixture.isolated(t); var hash = await passwords.hash(phrase); fixture.seed(h, null, { passwordHash: hash }); var state = h.users.loadUsers(); state.accountAuthMethod = 'password'; h.users.saveUsers(state); var server = h.server(); var cookie = server.createMultiUserSession('owner', hash).cookie; var result = await request(server, 'PUT', '/api/passkey-policy', { origin: origin, currentPassword: phrase }, cookie); assert.equal(result.code, 200, JSON.stringify(result.body)); return { h: h, server: server, cookie: cookie, key: authenticator() }; } async function register(f, overrides, key) { key = key || f.key; var options = await request(f.server, 'POST', '/api/passkeys/register/options', { name: 'Personal laptop', currentPassword: phrase }, f.cookie); assert.equal(options.code, 200, JSON.stringify(options.body)); assert.equal(options.body.options.authenticatorSelection.userVerification, 'required'); assert.equal(options.body.options.authenticatorSelection.residentKey, 'required'); var proof = cookies(options); var response = key.register(options.body.options, origin, overrides); var result = await request(f.server, 'POST', '/api/passkeys/register/verify', { response: response }, f.cookie + '; ' + proof); return { result: result, response: response, proof: proof }; } async function login(f, overrides, key) { var options = await request(f.server, 'POST', '/auth/passkeys/login/options', {}); assert.equal(options.code, 200, JSON.stringify(options.body)); assert.equal(options.body.options.userVerification, 'required'); var proof = cookies(options); var response = (key || f.key).authenticate(options.body.options, origin, overrides); var result = await request(f.server, 'POST', '/auth/passkeys/login/verify', { response: response }, proof); return { result: result, proof: proof, response: response }; } function authed(server, cookie) { return server.isRequestAuthed({ socket: { encrypted: true, remoteAddress: '127.0.0.1' }, headers: { host: 'clay.example.test', cookie: cookie } }); } test('real WebAuthn registration and signed login persist, expose only safe metadata and support multiple named passkeys', async function (t) { var f = await setup(t); var registered = await register(f); assert.equal(registered.result.code, 200, JSON.stringify(registered.result.body)); assert.equal(registered.result.body.passkeys[0].publicKey, undefined); assert.equal(authed(f.server, registered.proof), false); var second = authenticator(); assert.equal((await register(f, {}, second)).result.code, 200); var loginResult = await login(f); assert.equal(loginResult.result.code, 200, JSON.stringify(loginResult.result.body)); assert.match(loginResult.result.headers['Set-Cookie'][0], /; Secure/); assert.equal(authed(f.server, cookies(loginResult.result)), true); assert.equal(authed(f.h.server(), cookies(loginResult.result)), true); var user = f.h.users.findUserById('owner'); assert.equal(user.passkeys[0].counter, 1); assert.ok(user.passkeys[0].lastUsedAt); assert.ok(user.passwordHash); assert.equal(user.passkeys.length, 2); var renamed = await request(f.server, 'POST', '/api/passkeys/rename', { id: second.id, name: 'Backup key', currentPassword: phrase }, f.cookie); assert.equal(renamed.code, 200); assert.equal(renamed.body.passkeys[1].name, 'Backup key'); assert.equal((await login(f, {}, second)).result.code, 200); }); test('fresh password and authenticated ownership protect registration, naming, removal and policy changes', async function (t) { var f = await setup(t); assert.equal((await request(f.server, 'POST', '/api/passkeys/register/options', { name: 'No session', currentPassword: phrase })).code, 400); assert.equal((await request(f.server, 'POST', '/api/passkeys/register/options', { name: 'Wrong password', currentPassword: 'wrong' }, f.cookie)).code, 400); assert.equal((await request(f.server, 'PUT', '/api/passkey-policy', { origin: origin, currentPassword: 'wrong' }, f.cookie)).code, 400); assert.equal((await register(f)).result.code, 200); var hash = await passwords.hash(phrase); var state = f.h.users.loadUsers(); state.users.push({ id: 'other', username: 'Other', role: 'user', passwordHash: hash }); f.h.users.saveUsers(state); var otherCookie = f.server.createMultiUserSession('other', hash).cookie; assert.equal((await request(f.server, 'PUT', '/api/passkey-policy', { origin: origin, currentPassword: phrase }, otherCookie)).code, 403); assert.equal((await request(f.server, 'POST', '/api/passkeys/remove', { id: f.key.id, currentPassword: phrase }, otherCookie)).code, 400); assert.equal((await request(f.server, 'POST', '/api/passkeys/rename', { id: f.key.id, name: 'Changed', currentPassword: 'wrong' }, f.cookie)).code, 400); assert.equal(f.h.users.findUserById('owner').passkeys[0].name, 'Personal laptop'); }); test('exact configured origin and RP ID are independent of request host or network allowlists', async function (t) { var f = await setup(t); for (var value of ['http://public.example.test', 'https://127.0.0.1', 'https://clay.example.test/path', 'https://*.example.test']) { assert.equal((await request(f.server, 'PUT', '/api/passkey-policy', { origin: value, currentPassword: phrase }, f.cookie)).code, 400); } assert.equal((await request(f.server, 'POST', '/auth/passkeys/login/options', {}, '', { origin: 'https://evil.example.test', host: 'evil.example.test' })).code, 403); assert.equal((await request(f.server, 'POST', '/auth/passkeys/login/options', {}, '', { origin: undefined })).code, 403); var options = await request(f.server, 'POST', '/auth/passkeys/login/options', {}, '', { host: 'clay.example.test', 'x-forwarded-host': 'untrusted.example.test' }); assert.equal(options.body.options.rpId, 'clay.example.test'); assert.equal((await register(f, { rpID: 'wrong.example.test' })).result.code, 400); assert.equal((await register(f, { client: { origin: 'https://evil.example.test' } })).result.code, 400); assert.equal((await register(f)).result.code, 200); assert.equal((await request(f.server, 'PUT', '/api/passkey-policy', { origin: 'https://new.example.test', currentPassword: phrase }, f.cookie)).code, 400); }); test('user presence, verification, signatures, user handles and counters are enforced', async function (t) { var f = await setup(t); assert.equal((await register(f, { flags: 0x41 })).result.code, 400); assert.equal((await register(f, { flags: 0x44 })).result.code, 400); assert.equal((await register(f, { client: { crossOrigin: true } })).result.code, 400); assert.equal((await register(f)).result.code, 200); for (var overrides of [{ flags: 1 }, { flags: 4 }, { badSignature: true }, { handle: 'wrong' }, { handle: null }, { rpID: 'wrong.example.test' }, { client: { origin: 'https://evil.example.test' } }]) { assert.equal((await login(f, overrides)).result.code, 400, JSON.stringify(overrides)); } assert.equal((await login(f)).result.code, 200); assert.equal((await login(f)).result.code, 400); assert.equal((await login(f, { counter: 2 })).result.code, 200); }); test('challenges are browser and session bound, single-use, expiring and cancelled by logout', async function (t) { var f = await setup(t); var options = await request(f.server, 'POST', '/api/passkeys/register/options', { name: 'Laptop', currentPassword: phrase }, f.cookie); var response = f.key.register(options.body.options, origin); var otherSession = f.server.createMultiUserSession('owner', f.h.users.findUserById('owner').passwordHash).cookie; assert.equal((await request(f.server, 'POST', '/api/passkeys/register/verify', { response: response }, otherSession + '; ' + cookies(options))).code, 400); var registered = await register(f); assert.equal(registered.result.code, 200); assert.equal((await request(f.server, 'POST', '/api/passkeys/register/verify', { response: registered.response }, f.cookie + '; ' + registered.proof)).code, 400); options = await request(f.server, 'POST', '/auth/passkeys/login/options', {}); response = f.key.authenticate(options.body.options, origin); assert.equal((await request(f.server, 'POST', '/auth/passkeys/login/verify', { response: response })).code, 400); var first = await request(f.server, 'POST', '/auth/passkeys/login/verify', { response: response }, cookies(options)); assert.equal(first.code, 200); assert.equal((await request(f.server, 'POST', '/auth/passkeys/login/verify', { response: response }, cookies(options))).code, 400); options = await request(f.server, 'POST', '/auth/passkeys/login/options', {}); await request(f.server, 'POST', '/auth/logout', {}, cookies(options)); assert.equal((await request(f.server, 'POST', '/auth/passkeys/login/verify', { response: response }, cookies(options))).code, 400); options = await request(f.server, 'POST', '/auth/passkeys/login/options', {}); t.mock.timers.enable({ apis: ['Date'], now: Date.now() }); t.mock.timers.tick(300001); assert.equal((await request(f.server, 'POST', '/auth/passkeys/login/verify', { response: f.key.authenticate(options.body.options, origin, { counter: 2 }) }, cookies(options))).code, 400); }); test('failed persistence grants no access and does not remove password fallback', async function (t) { var f = await setup(t); f.h.state.failUsers = true; assert.equal((await register(f)).result.code, 400); assert.equal(f.h.users.findUserById('owner').passkeys, undefined); f.h.state.failUsers = false; assert.equal((await register(f)).result.code, 200); f.h.state.failUsers = true; var failed = await login(f); assert.equal(failed.result.code, 400); assert.equal(failed.result.headers['Set-Cookie'], undefined); assert.equal(f.h.users.findUserById('owner').passkeys[0].counter, 0); f.h.state.failUsers = false; assert.equal((await login(f)).result.code, 200); assert.equal((await request(f.server, 'POST', '/auth/login', { username: 'Owner', password: phrase })).code, 200); }); test('removal revokes passkey sessions, password changes revoke sessions and recovery clears all passkeys', async function (t) { var f = await setup(t); assert.equal((await register(f)).result.code, 200); var signedIn = await login(f); var session = cookies(signedIn.result); var removed = await request(f.server, 'POST', '/api/passkeys/remove', { id: f.key.id, currentPassword: phrase }, f.cookie); assert.equal(removed.code, 200); assert.equal(authed(f.server, session), false); assert.equal(authed(f.h.server(), session), false); assert.equal(authed(f.server, cookies(removed)), true); assert.equal((await login(f, { counter: 2 })).result.code, 400); assert.equal(authed(f.server, f.cookie), false); f.cookie = cookies(removed); f.key = authenticator(); assert.equal((await register(f)).result.code, 200); session = cookies((await login(f)).result); var change = await request(f.server, 'POST', '/auth/password-change', { currentPassword: phrase, password: 'another long passphrase', confirmPassword: 'another long passphrase' }, f.cookie); assert.equal(change.code, 200); assert.equal(authed(f.server, session), false); f.h.users.updateUserPin('owner', '123456'); assert.equal(f.h.users.findUserById('owner').passkeys, undefined); assert.equal((await login(f, { counter: 2 })).result.code, 400); }); test('concurrent verification consumes a challenge once and invalid requests are bounded', async function (t) { var f = await setup(t); assert.equal((await register(f)).result.code, 200); var options = await request(f.server, 'POST', '/auth/passkeys/login/options', {}); var response = f.key.authenticate(options.body.options, origin); var results = await Promise.all([request(f.server, 'POST', '/auth/passkeys/login/verify', { response: response }, cookies(options)), request(f.server, 'POST', '/auth/passkeys/login/verify', { response: response }, cookies(options))]); assert.deepEqual(results.map(function (result) { return result.code; }).sort(), [200, 400]); assert.equal((await request(f.server, 'POST', '/auth/passkeys/login/options', { large: 'x'.repeat(33000) })).code, 413); assert.equal((await request(f.server, 'POST', '/auth/passkeys/login/options', {}, '', { 'content-type': 'text/plain' })).code, 415); for (var i = 0; i < 10; i++) await request(f.server, 'POST', '/auth/passkeys/login/options', {}); assert.match((await request(f.server, 'POST', '/auth/passkeys/login/options', {})).body.error, /Too many attempts/); }); test('solo and account PIN modes cannot enable passkeys; localhost is accepted explicitly', async function (t) { var h = fixture.isolated(t); fixture.seed(h, null); var server = h.server(); assert.equal((await request(server, 'GET', '/api/passkey-policy')).body.available, false); assert.equal((await request(server, 'POST', '/auth/passkeys/login/options', {})).code, 409); assert.equal((await request(server, 'PUT', '/api/passkey-policy', { origin: origin, currentPassword: phrase })).code, 400); var parser = require('../lib/account-passkey-policy'); assert.equal(parser.parseOrigin('http://localhost:3000').rpID, 'localhost'); }); test('synced passkeys can use zero counters but cannot change their backup eligibility', async function (t) { var f = await setup(t); assert.equal((await register(f, { flags: 0x5d })).result.code, 200); assert.equal((await login(f, { flags: 0x1d, counter: 0 })).result.code, 200); assert.equal((await login(f, { flags: 0x1d, counter: 0 })).result.code, 200); assert.equal((await login(f, { flags: 5, counter: 0 })).result.code, 400); }); test('credential changes and policy changes invalidate pending registration', async function (t) { var f = await setup(t); var options = await request(f.server, 'POST', '/api/passkeys/register/options', { name: 'Pending', currentPassword: phrase }, f.cookie); var response = f.key.register(options.body.options, origin); await request(f.server, 'PUT', '/api/passkey-policy', { origin: 'https://different.example.test', currentPassword: phrase }, f.cookie); assert.equal((await request(f.server, 'POST', '/api/passkeys/register/verify', { response: response }, f.cookie + '; ' + cookies(options), { origin: 'https://different.example.test', host: 'different.example.test' })).code, 400); await request(f.server, 'PUT', '/api/passkey-policy', { origin: origin, currentPassword: phrase }, f.cookie); options = await request(f.server, 'POST', '/api/passkeys/register/options', { name: 'Pending', currentPassword: phrase }, f.cookie); response = f.key.register(options.body.options, origin); f.h.users.updateUserPin('owner', '123456'); assert.equal((await request(f.server, 'POST', '/api/passkeys/register/verify', { response: response }, f.cookie + '; ' + cookies(options))).code, 400); assert.equal(f.h.users.findUserById('owner').passkeys, undefined); }); test('session persistence failure does not issue a passkey login cookie', async function (t) { var f = await setup(t); assert.equal((await register(f)).result.code, 200); f.h.state.failTokens = true; var result = await login(f); assert.equal(result.result.code, 400); assert.equal(result.result.headers['Set-Cookie'], undefined); f.h.state.failTokens = false; assert.equal((await login(f, { counter: 2 })).result.code, 200); });