From 99abca9868a64967b3d48e3e6c67a0470f8ec7a7 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 5 Jun 2026 22:05:23 -0500 Subject: [PATCH] security: WebAuthn / FIDO2 hardware security key 2FA --- HISTORY.md | 6 + client/api.js | 8 ++ db/database.js | 42 +++++++ package.json | 4 +- routes/auth.js | 154 +++++++++++++++++++++++++ services/authService.js | 9 ++ services/webauthnService.js | 220 ++++++++++++++++++++++++++++++++++++ workers/dailyWorker.js | 2 + 8 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 services/webauthnService.js diff --git a/HISTORY.md b/HISTORY.md index 996d877..1f40599 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,11 @@ # Bill Tracker — Changelog +## v0.37.0 + +### 🔧 Changed + +- **WebAuthn / FIDO2 hardware security key 2FA** — Migration v0.92 adds `webauthn_enabled` and `webauthn_user_id` columns to `users`, a `webauthn_credentials` table (per-user, multiple keys supported — stores credential ID, CBOR public key as base64url, sign counter, transports, backup eligibility, friendly name, and AAGUID), and a `webauthn_challenges` table for short-lived registration, authentication, and login challenges. The new `webauthnService.js` handles the full lifecycle via `@simplewebauthn/server`: generating registration options (with `excludeCredentials` to prevent re-registering existing keys), verifying attestation responses, generating authentication options (passing allowed credentials and transports), verifying assertion responses (updating the sign counter on each use to detect cloned authenticators), and issuing/consuming login challenge tokens. The login flow mirrors TOTP exactly — after password verification succeeds, if `webauthn_enabled` is set, the server returns `requires_webauthn: true` alongside a `challenge_token` (a short-lived login challenge) and `webauthn_options` (the pre-generated assertion options); the client calls `startAuthentication()` from `@simplewebauthn/browser`, and `POST /api/auth/webauthn/challenge` verifies the assertion and creates a session. Six new endpoints added to `routes/auth.js`: `GET /webauthn/status` (enabled flag + credential count), `GET /webauthn/credentials` (list registered keys with name, AAGUID, backup flags, and timestamps), `GET /webauthn/setup` (begin registration — returns options + challengeId), `POST /webauthn/enable` (complete registration — verifies attestation, stores credential, sets `webauthn_enabled = 1`), `DELETE /webauthn/credentials/:credentialId` (remove one key — requires password confirmation; auto-disables WebAuthn when last key is removed), `POST /webauthn/disable` (remove all keys — requires password confirmation). RP ID and origin are configurable via `WEBAUTHN_RP_ID` and `WEBAUTHN_ORIGIN` env vars (default to `localhost` for dev). `publicUser()` in `authService.js` now includes `webauthn_enabled` so the frontend login flow knows to prompt for a security key tap. Expired WebAuthn challenges are pruned in the daily worker alongside expired sessions. OIDC and single-user mode are unaffected. `@simplewebauthn/server` and `@simplewebauthn/browser` v13 added to dependencies. + ## v0.36.0 ### 🔧 Changed diff --git a/client/api.js b/client/api.js index 1fab650..6abc49d 100644 --- a/client/api.js +++ b/client/api.js @@ -83,6 +83,14 @@ export const api = { totpDisable: (data) => post('/auth/totp/disable', data), totpChallenge: (data) => post('/auth/totp/challenge', data), + webauthnStatus: () => get('/auth/webauthn/status'), + webauthnSetup: () => get('/auth/webauthn/setup'), + webauthnEnable: (data) => post('/auth/webauthn/enable', data), + webauthnDisable: (data) => post('/auth/webauthn/disable', data), + webauthnCredentials: () => get('/auth/webauthn/credentials'), + webauthnDeleteCred: (id, data) => _fetch('DELETE', `/auth/webauthn/credentials/${encodeURIComponent(id)}`, data), + webauthnChallenge: (data) => post('/auth/webauthn/challenge', data), + // Admin hasUsers: () => get('/admin/has-users'), adminUsers: () => get('/admin/users'), diff --git a/db/database.js b/db/database.js index a9f037a..8766a1b 100644 --- a/db/database.js +++ b/db/database.js @@ -2942,6 +2942,48 @@ function runMigrations() { `); console.log('[v0.91] composite indexes created on categories, bills, payments'); } + }, + { + version: 'v0.92', + description: 'auth: WebAuthn/FIDO2 security key support — webauthn_credentials + webauthn_challenges tables', + dependsOn: ['v0.91'], + run: function() { + const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); + if (!cols.includes('webauthn_enabled')) + db.exec('ALTER TABLE users ADD COLUMN webauthn_enabled INTEGER NOT NULL DEFAULT 0'); + if (!cols.includes('webauthn_user_id')) + db.exec('ALTER TABLE users ADD COLUMN webauthn_user_id TEXT'); + + db.exec(` + CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + public_key TEXT NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + transports TEXT, + backup_eligible INTEGER NOT NULL DEFAULT 0, + backup_state INTEGER NOT NULL DEFAULT 0, + credential_name TEXT NOT NULL DEFAULT 'Security Key', + aaguid TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_webauthn_creds_user ON webauthn_credentials(user_id); + + CREATE TABLE IF NOT EXISTS webauthn_challenges ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + challenge_type TEXT NOT NULL CHECK(challenge_type IN ('registration','authentication','login')), + challenge TEXT NOT NULL DEFAULT '', + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user ON webauthn_challenges(user_id); + CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at); + `); + console.log('[v0.92] WebAuthn tables + users columns added'); + } } ]; diff --git a/package.json b/package.json index 0b30064..47a5850 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.36.0", + "version": "0.36.1", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { @@ -40,6 +40,8 @@ "node-cron": "^4.2.1", "nodemailer": "^8.0.9", "openid-client": "^5.7.1", + "@simplewebauthn/browser": "^13.0.0", + "@simplewebauthn/server": "^13.0.0", "otplib": "^13.4.1", "qrcode": "^1.5.4", "react": "^18.3.1", diff --git a/routes/auth.js b/routes/auth.js index a7566fc..8838ea1 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -424,4 +424,158 @@ router.post('/users', requireAuth, requireAdmin, async (req, res) => { } }); +// ── WebAuthn / FIDO2 security key ───────────────────────────────────────────── + +const { + createRegistrationChallenge, verifyRegistration, + createAuthenticationChallenge, verifyAuthentication, + consumeLoginChallenge, getCredentials, deleteCredential, +} = require('../services/webauthnService'); + +// GET /api/auth/webauthn/status +router.get('/webauthn/status', requireAuth, (req, res) => { + if (req.singleUserMode) return res.json({ enabled: false, credential_count: 0 }); + const db = getDb(); + const user = db.prepare('SELECT webauthn_enabled FROM users WHERE id = ?').get(req.user.id); + const { n } = db.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?').get(req.user.id); + res.json({ enabled: !!user?.webauthn_enabled, credential_count: n }); +}); + +// GET /api/auth/webauthn/credentials +router.get('/webauthn/credentials', requireAuth, (req, res) => { + if (req.singleUserMode) return res.json({ credentials: [] }); + res.json({ credentials: getCredentials(getDb(), req.user.id) }); +}); + +// GET /api/auth/webauthn/setup — begin registration +router.get('/webauthn/setup', requireAuth, async (req, res) => { + if (req.singleUserMode) + return res.status(400).json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR')); + try { + const { options, challengeId } = await createRegistrationChallenge(getDb(), req.user.id, req.user.username); + res.json({ options, challengeId }); + } catch (err) { + console.error('[webauthn/setup]', err); + res.status(500).json(standardizeError('Failed to generate setup options', 'SERVER_ERROR')); + } +}); + +// POST /api/auth/webauthn/enable — complete registration +router.post('/webauthn/enable', requireAuth, async (req, res) => { + if (req.singleUserMode) + return res.status(400).json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR')); + const { challengeId, response, credential_name } = req.body || {}; + if (!challengeId || !response) + return res.status(400).json(standardizeError('challengeId and response are required', 'VALIDATION_ERROR')); + + try { + const db = getDb(); + const result = await verifyRegistration(db, req.user.id, challengeId, response, credential_name); + if (!result.verified) + return res.status(400).json(standardizeError(result.error || 'Registration failed', 'VALIDATION_ERROR')); + + db.prepare("UPDATE users SET webauthn_enabled = 1, updated_at = datetime('now') WHERE id = ?").run(req.user.id); + + logAudit({ user_id: req.user.id, action: 'webauthn.credential_added', ip_address: req.ip, user_agent: req.get('user-agent') }); + res.json({ enabled: true, credential_id: result.credentialId }); + } catch (err) { + console.error('[webauthn/enable]', err); + res.status(500).json(standardizeError('Registration failed', 'SERVER_ERROR')); + } +}); + +// DELETE /api/auth/webauthn/credentials/:credentialId — remove one key +router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req, res) => { + const { password } = req.body || {}; + if (!password) + return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR')); + + const db = getDb(); + const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id); + try { + const bcrypt = require('bcryptjs'); + if (!await bcrypt.compare(password, user.password_hash)) + return res.status(401).json(standardizeError('Password is incorrect', 'AUTH_ERROR')); + + const result = deleteCredential(db, req.params.credentialId, req.user.id); + if (result.changes === 0) + return res.status(404).json(standardizeError('Credential not found', 'NOT_FOUND')); + + const { n } = db.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?').get(req.user.id); + if (n === 0) + db.prepare("UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?").run(req.user.id); + + logAudit({ user_id: req.user.id, action: 'webauthn.credential_removed', ip_address: req.ip, user_agent: req.get('user-agent') }); + res.json({ success: true, webauthn_enabled: n > 0 }); + } catch (err) { + console.error('[webauthn/credentials/delete]', err); + res.status(500).json(standardizeError('Failed to remove credential', 'SERVER_ERROR')); + } +}); + +// POST /api/auth/webauthn/disable — remove all keys, disable WebAuthn +router.post('/webauthn/disable', requireAuth, async (req, res) => { + const { password } = req.body || {}; + if (!password) + return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR')); + + const db = getDb(); + const user = db.prepare('SELECT password_hash, webauthn_enabled FROM users WHERE id = ?').get(req.user.id); + if (!user?.webauthn_enabled) + return res.status(400).json(standardizeError('WebAuthn is not enabled.', 'VALIDATION_ERROR')); + + try { + const bcrypt = require('bcryptjs'); + if (!await bcrypt.compare(password, user.password_hash)) + return res.status(401).json(standardizeError('Password is incorrect', 'AUTH_ERROR')); + + db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(req.user.id); + db.prepare("UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?").run(req.user.id); + + logAudit({ user_id: req.user.id, action: 'webauthn.disabled', ip_address: req.ip, user_agent: req.get('user-agent') }); + res.json({ enabled: false }); + } catch (err) { + console.error('[webauthn/disable]', err); + res.status(500).json(standardizeError('Failed to disable WebAuthn', 'SERVER_ERROR')); + } +}); + +// POST /api/auth/webauthn/challenge — second step of login when WebAuthn is enabled. +// Mirrors POST /totp/challenge exactly. +router.post('/webauthn/challenge', async (req, res) => { + req.csrfSkip = true; + const { challenge_token, response } = req.body || {}; + if (!challenge_token || !response) + return res.status(400).json(standardizeError('challenge_token and response are required', 'VALIDATION_ERROR')); + + const db = getDb(); + const session = consumeLoginChallenge(db, challenge_token); + if (!session) + return res.status(401).json(standardizeError('Challenge expired or invalid. Please sign in again.', 'AUTH_ERROR')); + + const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(session.userId); + if (!user) return res.status(401).json(standardizeError('User not found.', 'AUTH_ERROR')); + + try { + const result = await verifyAuthentication(db, session.userId, session.authChallengeId, response); + if (!result.verified) { + logAudit({ user_id: session.userId, action: 'webauthn.failure', ip_address: req.ip, user_agent: req.get('user-agent') }); + return res.status(401).json(standardizeError('Security key verification failed.', 'AUTH_ERROR')); + } + + const { createSession } = require('../services/authService'); + const s = await createSession(session.userId); + if (!s) return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR')); + + logAudit({ user_id: session.userId, action: 'login.success', details: { method: 'webauthn' }, ip_address: req.ip, user_agent: req.get('user-agent') }); + recordLogin(session.userId, req.ip, req.get('user-agent'), s.sessionId); + + res.cookie(COOKIE_NAME, s.sessionId, cookieOpts(req)); + res.json({ user: s.user }); + } catch (err) { + console.error('[webauthn/challenge]', err); + res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR')); + } +}); + module.exports = router; diff --git a/services/authService.js b/services/authService.js index e9d171d..541be6f 100644 --- a/services/authService.js +++ b/services/authService.js @@ -68,6 +68,14 @@ async function login(username, password) { return { requires_totp: true, challenge_token: challengeToken }; } + // WebAuthn is enabled — issue a WebAuthn authentication challenge instead + if (user.webauthn_enabled) { + const { createAuthenticationChallenge, createLoginChallenge } = require('./webauthnService'); + const { options, challengeId } = await createAuthenticationChallenge(getDb(), user.id); + const loginToken = createLoginChallenge(getDb(), user.id, challengeId); + return { requires_webauthn: true, challenge_token: loginToken, webauthn_options: options }; + } + // Clean up expired sessions for this user before creating new session try { db.prepare("DELETE FROM sessions WHERE user_id = ? AND expires_at < datetime('now')").run(user.id); @@ -179,6 +187,7 @@ function publicUser(u) { must_change_password: !!u.must_change_password, first_login: !!u.first_login, last_seen_version: u.last_seen_version || null, + webauthn_enabled: !!u.webauthn_enabled, }; } diff --git a/services/webauthnService.js b/services/webauthnService.js new file mode 100644 index 0000000..4d7f1d1 --- /dev/null +++ b/services/webauthnService.js @@ -0,0 +1,220 @@ +'use strict'; + +const crypto = require('crypto'); +const { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} = require('@simplewebauthn/server'); + +const APP_NAME = 'Bill Tracker'; +const CHALLENGE_TTL_MS = 15 * 60 * 1000; + +function getRpId() { + return process.env.WEBAUTHN_RP_ID || 'localhost'; +} + +function getOrigin() { + const o = process.env.WEBAUTHN_ORIGIN; + if (o) return o; + const port = process.env.PORT || 3000; + return `http://localhost:${port}`; +} + +// ── Registration ────────────────────────────────────────────────────────────── + +async function createRegistrationChallenge(db, userId, username) { + let { webauthn_user_id } = db.prepare('SELECT webauthn_user_id FROM users WHERE id = ?').get(userId) || {}; + + if (!webauthn_user_id) { + webauthn_user_id = crypto.randomBytes(32).toString('base64url'); + db.prepare("UPDATE users SET webauthn_user_id = ?, updated_at = datetime('now') WHERE id = ?") + .run(webauthn_user_id, userId); + } + + // Exclude already-registered credentials so the authenticator won't re-register them + const existing = db.prepare('SELECT credential_id FROM webauthn_credentials WHERE user_id = ?').all(userId); + + const options = await generateRegistrationOptions({ + rpName: APP_NAME, + rpID: getRpId(), + userID: Buffer.from(webauthn_user_id, 'base64url'), + userName: username, + userDisplayName: username, + attestationType: 'none', + excludeCredentials: existing.map(c => ({ id: c.credential_id })), + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred', + }, + supportedAlgorithmIDs: [-7, -257], + }); + + const challengeId = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS).toISOString().slice(0, 19).replace('T', ' '); + + db.prepare("DELETE FROM webauthn_challenges WHERE user_id = ? AND challenge_type = 'registration'").run(userId); + db.prepare('INSERT INTO webauthn_challenges (id, user_id, challenge_type, challenge, expires_at) VALUES (?, ?, ?, ?, ?)') + .run(challengeId, userId, 'registration', options.challenge, expiresAt); + + return { options, challengeId }; +} + +async function verifyRegistration(db, userId, challengeId, response, credentialName) { + const row = db.prepare( + "SELECT challenge FROM webauthn_challenges WHERE id = ? AND user_id = ? AND challenge_type = 'registration' AND expires_at > datetime('now')" + ).get(challengeId, userId); + if (!row) return { verified: false, error: 'Challenge expired or invalid' }; + + try { + const verification = await verifyRegistrationResponse({ + response, + expectedChallenge: row.challenge, + expectedOrigin: getOrigin(), + expectedRPID: getRpId(), + }); + + if (!verification.verified) return { verified: false, error: 'Registration verification failed' }; + + const { credential, aaguid, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; + + db.prepare(` + INSERT INTO webauthn_credentials + (user_id, credential_id, public_key, sign_count, transports, backup_eligible, backup_state, credential_name, aaguid) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + userId, + credential.id, + Buffer.from(credential.publicKey).toString('base64url'), + credential.counter, + credential.transports ? JSON.stringify(credential.transports) : null, + credentialDeviceType === 'multiDevice' ? 1 : 0, + credentialBackedUp ? 1 : 0, + credentialName || 'Security Key', + aaguid || null, + ); + + db.prepare('DELETE FROM webauthn_challenges WHERE id = ?').run(challengeId); + + return { verified: true, credentialId: credential.id }; + } catch (err) { + console.error('[webauthn] registration error:', err.message); + return { verified: false, error: err.message }; + } +} + +// ── Authentication ──────────────────────────────────────────────────────────── + +async function createAuthenticationChallenge(db, userId) { + const credentials = db.prepare('SELECT credential_id, transports FROM webauthn_credentials WHERE user_id = ?').all(userId); + if (!credentials.length) throw new Error('No registered WebAuthn credentials'); + + const options = await generateAuthenticationOptions({ + rpID: getRpId(), + allowCredentials: credentials.map(c => ({ + id: c.credential_id, + transports: c.transports ? JSON.parse(c.transports) : undefined, + })), + userVerification: 'preferred', + }); + + const challengeId = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS).toISOString().slice(0, 19).replace('T', ' '); + + db.prepare("DELETE FROM webauthn_challenges WHERE user_id = ? AND challenge_type = 'authentication'").run(userId); + db.prepare('INSERT INTO webauthn_challenges (id, user_id, challenge_type, challenge, expires_at) VALUES (?, ?, ?, ?, ?)') + .run(challengeId, userId, 'authentication', options.challenge, expiresAt); + + return { options, challengeId }; +} + +async function verifyAuthentication(db, userId, challengeId, response) { + const row = db.prepare( + "SELECT challenge FROM webauthn_challenges WHERE id = ? AND user_id = ? AND challenge_type = 'authentication' AND expires_at > datetime('now')" + ).get(challengeId, userId); + if (!row) return { verified: false, error: 'Challenge expired or invalid' }; + + const cred = db.prepare('SELECT credential_id, public_key, sign_count FROM webauthn_credentials WHERE user_id = ? AND credential_id = ?') + .get(userId, response.id); + if (!cred) return { verified: false, error: 'Credential not registered to this user' }; + + try { + const verification = await verifyAuthenticationResponse({ + response, + expectedChallenge: row.challenge, + expectedOrigin: getOrigin(), + expectedRPID: getRpId(), + credential: { + id: cred.credential_id, + publicKey: Buffer.from(cred.public_key, 'base64url'), + counter: cred.sign_count, + }, + }); + + if (!verification.verified) return { verified: false, error: 'Authentication failed' }; + + // Update sign count to detect cloned authenticators + db.prepare("UPDATE webauthn_credentials SET sign_count = ?, updated_at = datetime('now') WHERE user_id = ? AND credential_id = ?") + .run(verification.authenticationInfo.newCounter, userId, cred.credential_id); + + db.prepare('DELETE FROM webauthn_challenges WHERE id = ?').run(challengeId); + + return { verified: true }; + } catch (err) { + console.error('[webauthn] authentication error:', err.message); + return { verified: false, error: err.message }; + } +} + +// ── Login challenge (mirrors totpService.createChallenge / consumeChallenge) ── +// Issued after password passes; consumed when the WebAuthn assertion is verified. + +function createLoginChallenge(db, userId, webauthnChallengeId) { + const id = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS).toISOString().slice(0, 19).replace('T', ' '); + db.prepare("DELETE FROM webauthn_challenges WHERE user_id = ? AND challenge_type = 'login'").run(userId); + // Piggy-back the authentication challengeId in the challenge column for retrieval + db.prepare('INSERT INTO webauthn_challenges (id, user_id, challenge_type, challenge, expires_at) VALUES (?, ?, ?, ?, ?)') + .run(id, userId, 'login', webauthnChallengeId, expiresAt); + return id; +} + +function consumeLoginChallenge(db, loginChallengeId) { + const row = db.prepare( + "SELECT user_id, challenge FROM webauthn_challenges WHERE id = ? AND challenge_type = 'login' AND expires_at > datetime('now')" + ).get(loginChallengeId); + if (!row) return null; + db.prepare('DELETE FROM webauthn_challenges WHERE id = ?').run(loginChallengeId); + return { userId: row.user_id, authChallengeId: row.challenge }; +} + +// ── Credential management ───────────────────────────────────────────────────── + +function getCredentials(db, userId) { + return db.prepare( + 'SELECT id, credential_id, credential_name, aaguid, backup_eligible, backup_state, created_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC' + ).all(userId); +} + +function deleteCredential(db, credentialId, userId) { + return db.prepare('DELETE FROM webauthn_credentials WHERE credential_id = ? AND user_id = ?').run(credentialId, userId); +} + +// ── Cleanup ─────────────────────────────────────────────────────────────────── + +function pruneExpiredChallenges(db) { + db.prepare("DELETE FROM webauthn_challenges WHERE expires_at <= datetime('now')").run(); +} + +module.exports = { + createRegistrationChallenge, + verifyRegistration, + createAuthenticationChallenge, + verifyAuthentication, + createLoginChallenge, + consumeLoginChallenge, + getCredentials, + deleteCredential, + pruneExpiredChallenges, +}; diff --git a/workers/dailyWorker.js b/workers/dailyWorker.js index b562aef..7a8b063 100644 --- a/workers/dailyWorker.js +++ b/workers/dailyWorker.js @@ -4,6 +4,7 @@ const cron = require('node-cron'); const { getDb } = require('../db/database'); const { buildTrackerRow, getCycleRange } = require('../services/statusService'); const { pruneExpiredSessions } = require('../services/authService'); +const { pruneExpiredChallenges: pruneWebAuthnChallenges } = require('../services/webauthnService'); const { runNotifications, runDriftNotifications } = require('../services/notificationService'); const { runAllCleanup } = require('../services/cleanupService'); const { @@ -90,6 +91,7 @@ async function runDailyTasks() { } pruneExpiredSessions(); + pruneWebAuthnChallenges(db); await runNotifications().catch(err => { console.error('[worker] Notification error (non-fatal):', err.message);