Files
dashcaddy/dashcaddy-api/src/auth/providers/email-tokens-store.js
T
Hermes Agent c619d3a36b
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-046 DC-047 pluggable auth providers + email magic link
Pluggable AuthProvider framework for any future auth method (OIDC, SAML,
passkeys) to plug in without touching the auth path again. Two
implementations ship:

  * TOTP — refactored from routes/auth/totp.js into src/auth/providers/totp.js
    as one AuthProvider impl. Legacy /api/v1/totp/* routes stay mounted for
    back-compat; new /api/v1/auth/login/totp/* routes use the new shape.

  * EmailMagicLink — src/auth/providers/email.js. Initiate issues a 32-byte
    base64url token, stores its SHA-256 hash in data/email-tokens.json
    (atomic lockfile-based mutation, automatic TTL cleanup), and delivers via
    nodemailer if providers.email.{host,port,username,password} is set OR
    falls back to log.info('auth', 'email magic link issued', ...) for dev.
    Verify accepts the token, marks it used, creates the same DashCaddy
    session cookie that TOTP uses (single global cookie model).

createAuthProviderRegistry() composes both implementations and exposes
them via /api/v1/auth/login/{methods, :provider/initiate, :provider/verify,
recovery-info} and /api/v1/auth/disable/:provider. PUBLIC_ROUTES + CSRF
exemptions updated to use :provider placeholder (parameterized for future
providers).

Test fix: src/utilities/middleware.js PUBLIC_ROUTES and csrf-protection.js
both switched to the :provider form because the prior literal 'totp'
wouldn't match the parameterized mount path Express 4.22 produces.

Test fix: __tests__/public-routes-drift.test.js extractMountPath() was
broken for Express 4.22's new ^/path/?(?=/|$) source format (no \?\?
terminator in the literal-mount case). Rewrote the parser to normalize
escaped slashes + trailing lookaheads instead of relying on regex
matching against the raw source.

New: __tests__/auth-provider-registry.test.js — 9 tests covering registry
composition, getProvider round-trip, listEnabled no-secrets-leak guarantee,
enabled-flag respect, listAll vs listEnabled distinction, email provider
dev-console fallback (token written to JSON store + log.info with
deliveredVia: 'dev-console' + response masked), verify rejects unknown
tokens via AuthenticationError.

Tests: 1241/1241 passing across 46 suites (was 1232; +9 new).

DNS2 deploy: this commit + bump VERSION to 1.16.0 + publish tarball to
get.dashcaddy.net + docker build + bash start.sh. The image-layer migration
from DC-050 also runs on first container recreate post-merge.
2026-07-20 01:40:33 -07:00

261 lines
8.3 KiB
JavaScript

/**
* 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": {
* "<sha256-hex>": {
* "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 };