/** * forward_auth enforcement service — DC-154 (spec §4 Layer 2). * * Caddy's `forward_auth` directive points every protected request at this * module before it reaches the upstream app. The check is fully stateless: * the connecting IP's identity IS the credential. * * Flow (spec §4 "Layer 2: app (forward_auth)"): * 1. provider.whois(sourceIp) → { personId, deviceId }. Identity follows * the DEVICE (shared-device semantics): whoever owns the connecting * device gets that person's access. Unknown device → 401 * { error: 'unknown-device' } + WWW-Authenticate header. * 2. Host → resource: match the leading subdomain of the request host * against resource names via resourcesStore. Unmatched → 404 * { error: 'unknown-resource' }. * 3. accessStore.effectiveAccess({ type:'person', id, groups }) → * person overrides beat group union. Denied → 403 * { error:'forbidden', resource, personId }. * 4. Allowed + resource.sensitive → step-up: a valid DashCaddy session * (TOTP login) via sessionValidator. Valid → 200 * { authenticated:'step-up', personId }; missing/invalid → 401 * { error:'step-up-required', loginUrl:'/auth/login' }. * 5. Allowed + non-sensitive → 200 { authenticated:'tailnet', personId, * deviceId } — tailnet users get in with no separate login. * * Allowed responses carry X-Forwarded-User + X-Forwarded-Person — the * headers Caddy's forward_auth copies to the upstream request. * * Every outcome is audited: * { event:'forward_auth_allow'|'forward_auth_deny'|'forward_auth_step_up', * personId, resource, host } * 'forward_auth_step_up' is used for BOTH the 200 step-up success and the * 401 step-up-required challenge (outcome field distinguishes them). * * Group membership for effectiveAccess comes from the people store — * supplied by the caller as peopleStore so this module stays store-agnostic * (the route factory wires the real one; tests inject a stub). */ 'use strict'; const DEFAULT_LOGIN_URL = '/auth/login'; /** Audit one outcome. Failures are swallowed — audit must never break authz. */ function audit(auditLog, entry) { if (!auditLog || typeof auditLog.logForwardAuth !== 'function') return; try { const maybePromise = auditLog.logForwardAuth(entry); if (maybePromise && typeof maybePromise.catch === 'function') { maybePromise.catch(() => {}); } } catch (_) { /* non-fatal by contract */ } } /** * Build the forward_auth check service. * * @param {object} deps * @param {object} deps.provider - NetworkProvider with whois(ip) * @param {object} deps.accessStore - network-access-store (effectiveAccess) * @param {object} deps.resourcesStore - network-resources (listResources) * @param {object} [deps.peopleStore] - network-people-store (getPerson) — * supplies the person's groups for effectiveAccess. Optional; without it * group grants are simply not consulted (person overrides still apply). * @param {Function} [deps.sessionValidator] - async (req) => boolean for the * sensitive step-up (prod wiring reuses middleware's isSessionValid). * Default: always false (no session can be valid). * @param {object} [deps.logger] - { info, warn, error } no-op default. * @param {object} [deps.auditLog] - audit sink; when present must expose * logForwardAuth(entry). Default: no-op. */ function createForwardAuthService({ provider, accessStore, resourcesStore, peopleStore = null, sessionValidator = () => Promise.resolve(false), logger = { info() {}, warn() {}, error() {} }, auditLog = null, } = {}) { if (!provider || typeof provider.whois !== 'function') { throw new Error('forward-auth service requires a provider with whois()'); } if (!accessStore || typeof accessStore.effectiveAccess !== 'function') { throw new Error('forward-auth service requires an accessStore with effectiveAccess()'); } if (!resourcesStore || typeof resourcesStore.listResources !== 'function') { throw new Error('forward-auth service requires a resourcesStore with listResources()'); } /** Map a request host to a resource by leading subdomain. * `plex.internal` (resource name) ← host `plex.internal.sami` or * `plex.sami` / bare `plex` — the first label(s) up to the private-zone * suffix. Implemented as: longest resource name whose labels are a * prefix of the host's labels. */ async function resolveResource(host) { const resources = await resourcesStore.listResources(); if (!Array.isArray(resources) || resources.length === 0) return null; const labels = String(host || '') .toLowerCase() .replace(/^[.]+/, '') .replace(/[.]+$/, '') .split('.'); if (!labels[0]) return null; let best = null; for (const r of resources) { if (!r || typeof r.name !== 'string' || !r.name) continue; const nameLabels = r.name.toLowerCase().split('.'); // resource name labels must be a leading prefix of the host labels if (nameLabels.length > labels.length) continue; let match = true; for (let i = 0; i < nameLabels.length; i += 1) { if (nameLabels[i] !== labels[i]) { match = false; break; } } if (match && (!best || r.name.length > best.name.length)) best = r; } return best; } /** whois + normalize; any failure/unknown resolves to null identity. */ async function whoisIdentity(sourceIp) { let who = null; try { who = await provider.whois(sourceIp); } catch (err) { logger.warn && logger.warn('network:forward-auth', 'whois threw', { code: (err && err.code) || 'unknown', }); who = null; } return { personId: who && typeof who.personId === 'string' && who.personId ? who.personId : null, deviceId: who && typeof who.deviceId === 'string' && who.deviceId ? who.deviceId : null, }; } /** Sensitive-resource step-up (spec §4): sessionValidator decides. */ async function stepUpDecision(req, personId, resource, hostName) { let sessionValid = false; try { sessionValid = !!(await sessionValidator(req)); } catch (err) { logger.warn && logger.warn('network:forward-auth', 'sessionValidator threw', { err: err && err.message, }); sessionValid = false; } if (sessionValid) { audit(auditLog, { event: 'forward_auth_step_up', personId, resource: resource.name, host: hostName, outcome: 'granted', }); return { status: 200, headers: { 'X-Forwarded-User': personId, 'X-Forwarded-Person': personId, }, body: { authenticated: 'step-up', personId }, }; } audit(auditLog, { event: 'forward_auth_step_up', personId, resource: resource.name, host: hostName, outcome: 'required', }); return { status: 401, headers: {}, body: { error: 'step-up-required', loginUrl: DEFAULT_LOGIN_URL }, }; } /** * The check. Returns a plain decision object; the route layer maps it to * HTTP (status + headers + body). Kept transport-free for unit tests. * * @param {object} input { sourceIp, host, requestPath, req? } * `req` (optional, raw Express request) is forwarded to * sessionValidator so it can read cookies off the real request. */ async function check({ sourceIp, host, requestPath: _requestPath, req = null }) { // 1. Identity follows the device: whois the connecting IP. const identity = await whoisIdentity(sourceIp); if (!identity.personId) { audit(auditLog, { event: 'forward_auth_deny', personId: null, resource: null, host: String(host || ''), outcome: 'unknown-device', }); return { status: 401, headers: { 'WWW-Authenticate': 'ForwardAuth realm="dashcaddy-private-network"' }, body: { error: 'unknown-device' }, }; } const { personId, deviceId } = identity; // 2. Host → resource (leading-subdomain match). const hostName = String(host || '').split(':')[0].trim(); const resource = await resolveResource(hostName); if (!resource) { audit(auditLog, { event: 'forward_auth_deny', personId, resource: null, host: hostName, outcome: 'unknown-resource', }); return { status: 404, headers: {}, body: { error: 'unknown-resource' } }; } // 3. Access map: person overrides over their groups' union. let groups = []; if (peopleStore && typeof peopleStore.getPerson === 'function') { const person = await peopleStore.getPerson(personId); if (person && Array.isArray(person.groups)) groups = person.groups; } const eff = await accessStore.effectiveAccess({ type: 'person', id: personId, groups }); const allowed = !!(eff && eff.ok && eff.resources && eff.resources[resource.name] === true); if (!allowed) { audit(auditLog, { event: 'forward_auth_deny', personId, resource: resource.name, host: hostName, outcome: 'forbidden', }); return { status: 403, headers: {}, body: { error: 'forbidden', resource: resource.name, personId }, }; } // 4. Sensitive resources: DashCaddy login + TOTP step-up (spec §4). if (resource.sensitive === true) { return stepUpDecision(req, personId, resource, hostName); } // 5. Non-sensitive allowed: tailnet identity, no separate login. audit(auditLog, { event: 'forward_auth_allow', personId, resource: resource.name, host: hostName, outcome: 'granted', }); return { status: 200, headers: { 'X-Forwarded-User': personId, 'X-Forwarded-Person': personId, }, body: { authenticated: 'tailnet', personId, deviceId }, }; } return { check, resolveResource }; } module.exports = { createForwardAuthService, DEFAULT_LOGIN_URL };