// Passkeys (WebAuthn sign-in with Face ID, a fingerprint or a security key) and the list of devices signed in. // A passkey is a second factor in itself (the device checks it is really you), so signing in with one skips the // authenticator-code step. Passwords and 2FA keep working as before. import { createHash } from 'crypto'; import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server'; import { addPasskey, deletePasskey, destroyAllSessionsForUser, destroySession, getPasskey, getUserById, listPasskeys, listUserSessions, renamePasskey, setLoginAlerts, touchPasskey, } from './db.js'; import { logEvent } from './health.js'; import { sendNtfy } from './reminders.js'; const MONTH = 30 * 24 * 60 * 60 * 1000; const APP_NAME = 'Bible Study'; // "Chrome on macOS" from a User-Agent string; good enough to recognise your own devices. export function describeDevice(ua = '') { const browser = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\/|CriOS\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : null; const os = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Windows/.test(ua) ? 'Windows' : /Mac OS X|Macintosh/.test(ua) ? 'macOS' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : null; if (browser && os) return `${browser} on ${os}`; return browser ?? os ?? 'Unknown device'; } export const sessionKey = (sid) => createHash('sha256').update(sid).digest('hex').slice(0, 16); // Where the browser says it is, for checking passkeys: APP_URL when set, else the address this request came to. export function relyingParty(req, env = process.env) { if (env.APP_URL) { try { const u = new URL(env.APP_URL); return { rpID: u.hostname, origin: u.origin }; } catch { /* fall through */ } } return { rpID: req.hostname, origin: `${req.protocol}://${req.get('host')}` }; } /** Called right after a session gets its userId: remembers the device and (if asked) sends a sign-in alert. */ export function noteSignIn(req, user, method) { const label = describeDevice(req.get('user-agent') ?? ''); req.session.device = { label, ip: req.ip, at: Date.now(), method }; if (user.loginAlerts && user.ntfyTopic) { const how = { password: 'password', passkey: 'passkey', mfa: 'password and authenticator code', register: 'a new account' }[method] ?? method; sendNtfy(user, { title: 'New sign-in', body: `${label} signed in to your Bible Study account with ${how}. If that wasn’t you, change your password and sign out other devices.`, tags: 'warning' }) .catch((err) => { console.error('Sign-in alert failed:', err.message); logEvent('notify', `Sign-in alert: ${err.message}`, { userId: user.id }); }); } } const publicPasskey = (p) => ({ id: p.id, name: p.name, createdAt: p.createdAt, lastUsedAt: p.lastUsedAt }); export function registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany }) { const fail = (res, status, error) => res.status(status).json({ error }); // ---- Signing in with a passkey (no email needed: the passkey says who you are) ---- app.post('/api/auth/passkey/options', async (req, res) => { try { if (isLimited(`ip:${req.ip}`)) return res.status(429).json(tooMany); const { rpID } = relyingParty(req); const options = await generateAuthenticationOptions({ rpID, userVerification: 'required' }); req.session.passkeyChallenge = options.challenge; res.json(options); } catch (err) { console.error('POST /api/auth/passkey/options error:', err); fail(res, 500, 'Could not start passkey sign-in.'); } }); app.post('/api/auth/passkey/verify', async (req, res) => { const keys = [`ip:${req.ip}`]; try { if (isLimited(...keys)) return res.status(429).json(tooMany); const challenge = req.session?.passkeyChallenge; const response = req.body; const passkey = typeof response?.id === 'string' ? getPasskey(response.id) : null; const failed = () => { recordFailure(...keys); logEvent('signin', `Passkey sign-in failed from ${req.ip}`); return fail(res, 401, 'That passkey didn’t work. Try again, or use your password.'); }; if (!challenge || !passkey) return failed(); const { rpID, origin } = relyingParty(req); let result; try { result = await verifyAuthenticationResponse({ response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true, credential: { id: passkey.id, publicKey: Buffer.from(passkey.publicKey, 'base64url'), counter: passkey.counter, transports: passkey.transports }, }); } catch { return failed(); } if (!result.verified) return failed(); const user = getUserById(passkey.userId); if (!user) return failed(); touchPasskey(passkey.id, result.authenticationInfo.newCounter); clearFailures(...keys); req.session.regenerate((err) => { if (err) return fail(res, 500, 'Could not create session.'); req.session.userId = user.id; noteSignIn(req, user, 'passkey'); res.json(userPayload(user)); }); } catch (err) { console.error('POST /api/auth/passkey/verify error:', err); fail(res, 500, 'Could not check that passkey.'); } }); // ---- Managing passkeys (signed in) ---- app.get('/api/passkeys', requireAuth, (req, res) => res.json(listPasskeys(req.session.userId).map(publicPasskey))); app.post('/api/passkeys/options', requireAuth, async (req, res) => { try { const user = getUserById(req.session.userId); const { rpID } = relyingParty(req); const options = await generateRegistrationOptions({ rpName: APP_NAME, rpID, userName: user.email, userID: new TextEncoder().encode(user.id), attestationType: 'none', excludeCredentials: listPasskeys(user.id).map((p) => ({ id: p.id, transports: p.transports })), authenticatorSelection: { residentKey: 'required', userVerification: 'required' }, }); req.session.passkeyChallenge = options.challenge; res.json(options); } catch (err) { console.error('POST /api/passkeys/options error:', err); fail(res, 500, 'Could not start adding a passkey.'); } }); app.post('/api/passkeys', requireAuth, async (req, res) => { try { const challenge = req.session.passkeyChallenge; if (!challenge) return fail(res, 400, 'Start again: that request expired.'); const { rpID, origin } = relyingParty(req); let result; try { result = await verifyRegistrationResponse({ response: req.body?.response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true }); } catch (err) { return fail(res, 400, `That passkey couldn’t be added (${err.message}).`); } if (!result.verified) return fail(res, 400, 'That passkey couldn’t be added.'); delete req.session.passkeyChallenge; const { credential } = result.registrationInfo; if (getPasskey(credential.id)) return fail(res, 409, 'That passkey is already registered.'); const name = String(req.body?.name ?? '').trim().slice(0, 60) || describeDevice(req.get('user-agent') ?? ''); addPasskey({ id: credential.id, userId: req.session.userId, publicKey: Buffer.from(credential.publicKey).toString('base64url'), counter: credential.counter, transports: credential.transports, name }); res.json(listPasskeys(req.session.userId).map(publicPasskey)); } catch (err) { console.error('POST /api/passkeys error:', err); fail(res, 500, 'Could not save that passkey.'); } }); app.patch('/api/passkeys/:id', requireAuth, (req, res) => { const name = String(req.body?.name ?? '').trim().slice(0, 60); if (!name) return fail(res, 400, 'Give it a name.'); if (!renamePasskey(req.params.id, req.session.userId, name)) return fail(res, 404, 'No such passkey.'); res.json(listPasskeys(req.session.userId).map(publicPasskey)); }); app.delete('/api/passkeys/:id', requireAuth, (req, res) => { if (!deletePasskey(req.params.id, req.session.userId)) return fail(res, 404, 'No such passkey.'); res.json(listPasskeys(req.session.userId).map(publicPasskey)); }); // ---- Where you are signed in ---- app.get('/api/sessions', requireAuth, (req, res) => { const rows = listUserSessions(req.session.userId).map(({ sid, sess, expires }) => ({ id: sessionKey(sid), current: sid === req.sessionID, label: sess.device?.label ?? 'Signed in before this list existed', ip: sess.device?.ip ?? null, method: sess.device?.method ?? null, signedInAt: sess.device?.at ?? null, lastSeen: expires - MONTH, // sessions are renewed on each request, so this is when it was last used })).sort((a, b) => Number(b.current) - Number(a.current) || b.lastSeen - a.lastSeen); res.json(rows); }); app.delete('/api/sessions/:id', requireAuth, (req, res) => { const found = listUserSessions(req.session.userId).find((s) => sessionKey(s.sid) === req.params.id); if (!found) return fail(res, 404, 'That device is already signed out.'); if (found.sid === req.sessionID) return fail(res, 400, 'Use Sign out for this device.'); destroySession(found.sid); res.json({ ok: true }); }); app.post('/api/sessions/sign-out-others', requireAuth, (req, res) => { destroyAllSessionsForUser(req.session.userId, req.sessionID); res.json({ ok: true }); }); app.put('/api/auth/login-alerts', requireAuth, (req, res) => { const user = getUserById(req.session.userId); const enabled = !!req.body?.enabled; if (enabled && !user.ntfyTopic) return fail(res, 400, 'Set up a notification topic first (under Notifications).'); setLoginAlerts(user.id, enabled); res.json({ loginAlerts: enabled }); }); }