270 lines
9.9 KiB
JavaScript
270 lines
9.9 KiB
JavaScript
/**
|
|
* 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 };
|