/** * EmailMagicLink tokens store. * * Stores SHA-256-hashed tokens in a JSON file. The raw token NEVER lives on * disk — only its hash. This means a read-only disk compromise cannot be * used to forge login links. * * Schema (tokens file): * { * "byHash": { * "": { * "email": "user@example.com", * "expiresAt": 1721322000000, * "issuedAt": 1721321100000, * "usedAt": null, * "ip": "10.0.0.1", * "userAgent": "Mozilla/5.0 ..." * }, * ... * } * } * * Concurrency: writes go through a single in-flight queue. The store never * loses tokens due to interleaved read-modify-write cycles. Reads are * unlocked and may see slightly stale data (acceptable — token TTL is 15min * so a stale read at worst surfaces an expired token that the next request * will catch). * * Garbage collection: expired-and-used tokens are pruned every PRUNE_INTERVAL * via `startPruneTimer()` (auto-started by `createStore()`). Tests that want * deterministic behavior can call `prune()` directly and skip the timer. */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const TOKEN_TTL_MS = 15 * 60 * 1000; // 15 minutes const PRUNE_INTERVAL_MS = 60 * 60 * 1000; // hourly prune of used+expired const MAX_TOKENS = 10000; // hard cap; protect the file /** * Token-store factory. Captures the file path so callers don't have to * thread it through every method. * * @param {string} filePath Absolute path to email-tokens.json * @returns {Object} Token-store instance (see JSDoc below) */ function createStore(filePath) { if (typeof filePath !== 'string' || !filePath) { throw new Error('email-tokens-store: filePath required'); } let writeQueue = Promise.resolve(); let pruneTimer = null; function _readSync() { try { if (!fs.existsSync(filePath)) { return { byHash: {} }; } const raw = fs.readFileSync(filePath, 'utf8'); if (!raw.trim()) return { byHash: {} }; const parsed = JSON.parse(raw); // Defensive: tolerate older shapes ({tokens: [...]}, flat object, etc). if (parsed && typeof parsed === 'object' && parsed.byHash && typeof parsed.byHash === 'object') { return parsed; } return { byHash: {} }; } catch { // Treat unparseable file as empty — don't block login on a corrupt store. return { byHash: {} }; } } function _writeSync(state) { const dir = path.dirname(filePath); try { fs.mkdirSync(dir, { recursive: true }); } catch { /* ignore */ } // Atomic write: temp file + rename, so a crash mid-write doesn't corrupt. const tmp = filePath + '.tmp.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(state)); fs.renameSync(tmp, filePath); } function _enqueueWrite(mutator) { writeQueue = writeQueue.then(async () => { const state = _readSync(); const result = await mutator(state); // Cap-store at MAX_TOKENS (drop oldest expired-then-recent ones first, // then oldest used if we still exceed). User-visible as "can't request // more links until old ones are cleaned up" — pathological case only. if (Object.keys(state.byHash).length > MAX_TOKENS) { _capStore(state); } _writeSync(state); return result; }); return writeQueue; } function _capStore(state) { const entries = Object.entries(state.byHash); entries.sort((a, b) => (a[1].issuedAt || 0) - (b[1].issuedAt || 0)); while (entries.length > MAX_TOKENS) { const [hash] = entries.shift(); delete state.byHash[hash]; } } /** * Issue a new token. * * @param {Object} meta { email, ip, userAgent } * @returns {{ token: string, hash: string, expiresAt: number }} */ function issue(meta) { const email = (meta && meta.email || '').toLowerCase().trim(); const ip = (meta && meta.ip) || ''; const userAgent = (meta && meta.userAgent) || ''; const raw = crypto.randomBytes(32).toString('base64url'); const hash = _hashToken(raw); const now = Date.now(); const expiresAt = now + TOKEN_TTL_MS; const record = { email, issuedAt: now, expiresAt, usedAt: null, ip, userAgent, }; // Issue is synchronous w.r.t. the in-memory state — the write happens // before `issue` resolves, so a follow-up `lookup` is guaranteed to see // the new token. The returned token is the only copy of the secret; // the caller MUST display/em它 inside an email body and never persist it. writeQueue = writeQueue.then(() => { const state = _readSync(); state.byHash[hash] = record; if (Object.keys(state.byHash).length > MAX_TOKENS) { _capStore(state); } _writeSync(state); }); // Block on the write so the caller can immediately `lookup` the token. // Each call returns a copy of `writeQueue` chained with our new write. return writeQueue.then(() => ({ token: raw, hash, expiresAt, email })); } /** * Look up a token record by raw token (not hash — caller passes what * arrived in the URL, we hash it for lookup). Does NOT mutate. * * @param {string} rawToken * @returns {Object|null} Token record or null if not found / expired / invalid */ function lookup(rawToken) { if (typeof rawToken !== 'string' || !rawToken) return null; const hash = _hashToken(rawToken); const state = _readSync(); const record = state.byHash[hash]; if (!record) return null; if (record.usedAt) return null; // single-use if (Date.now() > record.expiresAt) return null; return { hash, ...record }; } /** * Mark a token as used. Idempotent — second call is a no-op. * * @param {string} hash Hex SHA-256 of the token * @param {number} at Timestamp (default: now) */ function markUsed(hash, at) { return _enqueueWrite(async (state) => { const record = state.byHash[hash]; if (!record) return false; if (record.usedAt) return false; record.usedAt = at || Date.now(); return true; }); } /** * Count tokens issued to `email` within the last `windowMs` (default 1h). * Used for the per-email request-link rate limit. * * @param {string} email * @param {number} windowMs * @returns {number} */ function countRecentForEmail(email, windowMs = 60 * 60 * 1000) { if (!email) return 0; const target = email.toLowerCase().trim(); const since = Date.now() - windowMs; const state = _readSync(); let n = 0; for (const record of Object.values(state.byHash)) { if (record.email === target && (record.issuedAt || 0) >= since) n++; } return n; } /** * Delete expired-and-used tokens (and very-old ones that somehow weren't * marked used). Safe to call any time; idempotent. */ function prune() { return _enqueueWrite(async (state) => { const now = Date.now(); for (const [hash, record] of Object.entries(state.byHash)) { const isUsed = !!record.usedAt; const isExpired = now > (record.expiresAt || 0); const isAncient = (record.issuedAt || 0) < (now - 7 * 24 * 60 * 60 * 1000); if ((isUsed && isExpired) || isAncient) delete state.byHash[hash]; } }); } function startPruneTimer() { if (pruneTimer) return; pruneTimer = setInterval(() => { prune().catch(() => { /* swallow — prune is best-effort */ }); }, PRUNE_INTERVAL_MS); // Don't keep the event loop alive for this timer alone. if (typeof pruneTimer.unref === 'function') pruneTimer.unref(); } function stopPruneTimer() { if (pruneTimer) { clearInterval(pruneTimer); pruneTimer = null; } } /** Test-only helper. Wipes the in-memory state and the file. */ function _resetSync() { writeQueue = Promise.resolve(); try { fs.unlinkSync(filePath); } catch { /* ignore */ } } return { issue, lookup, markUsed, countRecentForEmail, prune, startPruneTimer, stopPruneTimer, _resetSync, // test-only get TOKEN_TTL_MS() { return TOKEN_TTL_MS; }, get MAX_TOKENS() { return MAX_TOKENS; }, }; } /** Hash a raw token to its storage key. SHA-256 hex. */ function _hashToken(raw) { return crypto.createHash('sha256').update(raw, 'utf8').digest('hex'); } module.exports = { createStore, _hashToken };