DC-154: forward_auth enforcement endpoint (whois + access map + sensitive step-up) + tests

This commit is contained in:
Hermes committed 2026-10-03 22:30:23 -07:00
1 parent 88f7600693
commit aac2343df3
6 files changed
+789 -1

No files matched your search

@@ -0,0 +1,360 @@
/**
* forward_auth enforcement tests — DC-154 (spec §4 Layer 2).
*
* Hermetic: the app is a bare Express mount of routes/network-forward-auth
* with a fully mocked provider.whois, stores, sessionValidator, and auditLog.
* No disk, no network, no app context.
*/
const express = require('express');
const request = require('supertest');
const { createForwardAuthService } = require('../src/network/forward-auth');
const forwardAuthRoutes = require('../routes/network-forward-auth');
const { parseForwardedFor } = forwardAuthRoutes;
// ── fixtures ────────────────────────────────────────────────────────────────
const PERSON = 'person:sami';
const DEVICE = 'wgd:abc123';
const OTHER_PERSON = 'person:guest';
const RESOURCES = [
{ name: 'plex', displayName: 'Plex', sensitive: false },
{ name: 'vault', displayName: 'Vault', sensitive: true },
{ name: 'nas', displayName: 'NAS', sensitive: false },
];
function buildDeps(overrides = {}) {
const auditEntries = [];
const deps = {
provider: {
id: () => 'wireguard',
whois(ip) {
if (ip === '10.53.0.7') return { personId: PERSON, deviceId: DEVICE };
if (ip === '10.53.0.9') return { personId: OTHER_PERSON, deviceId: 'wgd:other' };
return null;
},
},
accessStore: {
effectiveAccess(principal) {
// person:sami is allowed plex+vault; person:guest only plex.
const map = {
[PERSON]: { plex: true, 'plex.internal': true, vault: true, nas: false },
[OTHER_PERSON]: { plex: true, vault: false, nas: false },
};
return { ok: true, resources: map[principal.id] || {} };
},
},
resourcesStore: {
listResources() { return Promise.resolve(RESOURCES.map(r => ({ ...r }))); },
},
peopleStore: {
getPerson(id) {
if (id === PERSON) return { id, groups: ['admins'] };
if (id === OTHER_PERSON) return { id, groups: ['guests'] };
return null;
},
},
sessionValidator: () => false,
logger: { info() {}, warn() {}, error() {} },
auditLog: {
logForwardAuth(entry) { auditEntries.push(entry); },
},
...overrides,
};
deps.auditEntries = auditEntries;
return deps;
}
function buildApp(deps) {
const app = express();
const service = createForwardAuthService(deps);
app.use('/network/forward-auth', forwardAuthRoutes({ service }));
return app;
}
// Convenience: one request with the usual headers.
function hit(app, { ip = '10.53.0.7', host = 'plex.sami', method = 'get', session = false } = {}) {
const req = request(app)[method]('/network/forward-auth/')
.set('X-Forwarded-For', ip)
.set('X-Forwarded-Host', host);
if (session) req.set('Cookie', 'dc_session=valid');
return req;
}
// ── service: decision matrix ────────────────────────────────────────────────
describe('DC-154 forward_auth service (createForwardAuthService.check)', () => {
test('unknown device → 401 { error: "unknown-device" } + WWW-Authenticate', async () => {
const svc = createForwardAuthService(buildDeps());
const d = await svc.check({ sourceIp: '10.99.99.99', host: 'plex.sami', requestPath: '/' });
expect(d.status).toBe(401);
expect(d.body).toEqual({ error: 'unknown-device' });
expect(d.headers['WWW-Authenticate']).toMatch(/ForwardAuth/);
});
test('whois throwing is treated as unknown device, not a 500', async () => {
const deps = buildDeps({
provider: { whois() { return Promise.reject(new Error('cli down')); } },
});
const svc = createForwardAuthService(deps);
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'plex.sami', requestPath: '/' });
expect(d.status).toBe(401);
expect(d.body).toEqual({ error: 'unknown-device' });
});
test('unmatched host → 404 { error: "unknown-resource" }', async () => {
const svc = createForwardAuthService(buildDeps());
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'nope.sami', requestPath: '/' });
expect(d.status).toBe(404);
expect(d.body).toEqual({ error: 'unknown-resource' });
});
test('denied by access map → 403 { error: "forbidden", resource, personId }', async () => {
const svc = createForwardAuthService(buildDeps());
// guest (10.53.0.9) is not granted vault
const d = await svc.check({ sourceIp: '10.53.0.9', host: 'vault.sami', requestPath: '/' });
expect(d.status).toBe(403);
expect(d.body).toEqual({ error: 'forbidden', resource: 'vault', personId: OTHER_PERSON });
});
test('allowed non-sensitive → 200 tailnet identity + X-Forwarded headers', async () => {
const svc = createForwardAuthService(buildDeps());
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'plex.sami', requestPath: '/' });
expect(d.status).toBe(200);
expect(d.body).toEqual({ authenticated: 'tailnet', personId: PERSON, deviceId: DEVICE });
expect(d.headers['X-Forwarded-User']).toBe(PERSON);
expect(d.headers['X-Forwarded-Person']).toBe(PERSON);
});
test('allowed sensitive without session → 401 { error: "step-up-required", loginUrl }', async () => {
const svc = createForwardAuthService(buildDeps());
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'vault.sami', requestPath: '/', req: {} });
expect(d.status).toBe(401);
expect(d.body).toEqual({ error: 'step-up-required', loginUrl: '/auth/login' });
});
test('allowed sensitive with valid session → 200 { authenticated: "step-up" } + headers', async () => {
const deps = buildDeps({ sessionValidator: () => true });
const svc = createForwardAuthService(deps);
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'vault.sami', requestPath: '/', req: {} });
expect(d.status).toBe(200);
expect(d.body).toEqual({ authenticated: 'step-up', personId: PERSON });
expect(d.headers['X-Forwarded-User']).toBe(PERSON);
});
test('multi-label resource names match by leading-subdomain prefix (plex.internal)', async () => {
const deps = buildDeps({
resourcesStore: {
listResources() {
return [
{ name: 'plex', sensitive: false },
{ name: 'plex.internal', sensitive: false },
];
},
},
});
const svc = createForwardAuthService(deps);
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'plex.internal.sami', requestPath: '/' });
expect(d.status).toBe(200);
// longest matching name wins — not the shorter 'plex'
const audit = deps.auditEntries.find(e => e.event === 'forward_auth_allow');
expect(audit.resource).toBe('plex.internal');
});
});
// ── service: audit entries ──────────────────────────────────────────────────
describe('DC-154 audit entries per outcome', () => {
test('allow → { event: "forward_auth_allow", personId, resource, host }', async () => {
const deps = buildDeps();
const svc = createForwardAuthService(deps);
await svc.check({ sourceIp: '10.53.0.7', host: 'plex.sami', requestPath: '/' });
expect(deps.auditEntries).toHaveLength(1);
expect(deps.auditEntries[0]).toEqual(expect.objectContaining({
event: 'forward_auth_allow',
personId: PERSON,
resource: 'plex',
host: 'plex.sami',
}));
});
test('deny (forbidden) → forward_auth_deny with resource + personId', async () => {
const deps = buildDeps();
const svc = createForwardAuthService(deps);
await svc.check({ sourceIp: '10.53.0.9', host: 'vault.sami', requestPath: '/' });
expect(deps.auditEntries[0]).toEqual(expect.objectContaining({
event: 'forward_auth_deny',
personId: OTHER_PERSON,
resource: 'vault',
host: 'vault.sami',
}));
});
test('deny (unknown resource) → forward_auth_deny with null resource', async () => {
const deps = buildDeps();
const svc = createForwardAuthService(deps);
await svc.check({ sourceIp: '10.53.0.7', host: 'nope.sami', requestPath: '/' });
expect(deps.auditEntries[0].event).toBe('forward_auth_deny');
expect(deps.auditEntries[0].resource).toBeNull();
expect(deps.auditEntries[0].personId).toBe(PERSON);
});
test('sensitive step-up both outcomes → forward_auth_step_up', async () => {
const deps = buildDeps({ sessionValidator: () => false });
const svc = createForwardAuthService(deps);
await svc.check({ sourceIp: '10.53.0.7', host: 'vault.sami', requestPath: '/', req: {} });
expect(deps.auditEntries[0].event).toBe('forward_auth_step_up');
expect(deps.auditEntries[0].outcome).toBe('required');
deps.auditEntries.length = 0;
const okDeps = buildDeps({ sessionValidator: () => true });
const svc2 = createForwardAuthService(okDeps);
await svc2.check({ sourceIp: '10.53.0.7', host: 'vault.sami', requestPath: '/', req: {} });
expect(okDeps.auditEntries[0]).toEqual(expect.objectContaining({
event: 'forward_auth_step_up',
outcome: 'granted',
}));
});
test('audit failures never break the auth decision', async () => {
const deps = buildDeps({
auditLog: { logForwardAuth() { throw new Error('audit sink down'); } },
});
const svc = createForwardAuthService(deps);
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'plex.sami', requestPath: '/' });
expect(d.status).toBe(200);
});
});
// ── route: HTTP surface ─────────────────────────────────────────────────────
describe('DC-154 route (GET/POST /network/forward-auth/)', () => {
test('GET unknown device → 401 + WWW-Authenticate header', async () => {
const app = buildApp(buildDeps());
const res = await hit(app, { ip: '10.99.99.99' });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'unknown-device' });
expect(res.headers['www-authenticate']).toBeDefined();
});
test('GET unknown resource → 404', async () => {
const app = buildApp(buildDeps());
const res = await hit(app, { host: 'nope.sami' });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'unknown-resource' });
});
test('GET denied → 403 with resource + personId', async () => {
const app = buildApp(buildDeps());
const res = await hit(app, { ip: '10.53.0.9', host: 'vault.sami' });
expect(res.status).toBe(403);
expect(res.body.error).toBe('forbidden');
expect(res.body.resource).toBe('vault');
expect(res.body.personId).toBe(OTHER_PERSON);
});
test('GET allowed non-sensitive → 200 tailnet + X-Forwarded-User/Person headers', async () => {
const app = buildApp(buildDeps());
const res = await hit(app, {});
expect(res.status).toBe(200);
expect(res.body).toEqual({ authenticated: 'tailnet', personId: PERSON, deviceId: DEVICE });
expect(res.headers['x-forwarded-user']).toBe(PERSON);
expect(res.headers['x-forwarded-person']).toBe(PERSON);
});
test('POST works identically (Caddy may use either verb)', async () => {
const app = buildApp(buildDeps());
const res = await hit(app, { method: 'post' });
expect(res.status).toBe(200);
expect(res.body.authenticated).toBe('tailnet');
});
test('sensitive without session → 401 step-up-required + loginUrl', async () => {
const app = buildApp(buildDeps());
const res = await hit(app, { host: 'vault.sami' });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'step-up-required', loginUrl: '/auth/login' });
});
test('sensitive with valid session → 200 step-up', async () => {
const app = buildApp(buildDeps({ sessionValidator: (req) => !!(req && req.headers) }));
const res = await hit(app, { host: 'vault.sami' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ authenticated: 'step-up', personId: PERSON });
});
test('Host header used when X-Forwarded-Host absent', async () => {
const app = buildApp(buildDeps());
const res = await request(app).get('/network/forward-auth/')
.set('X-Forwarded-For', '10.53.0.7')
.set('Host', 'plex.sami:8080'); // express maps Host → req.headers.host
expect(res.status).toBe(200);
expect(res.body.authenticated).toBe('tailnet');
});
test('falls back to req.ip when X-Forwarded-For absent', async () => {
const app = buildApp(buildDeps());
const res = await request(app).get('/network/forward-auth/')
.set('X-Forwarded-Host', 'plex.sami');
// supertest connects from 127.0.0.1 — an unknown device → 401, proving
// the fallback consulted req.ip rather than crashing on a missing header.
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'unknown-device' });
});
test('no provider active → 503 { error: "network provider not active" } (lazy mount semantics)', async () => {
// Mirror of the app.js p7ForwardAuthLazy stub: provider absent → 503
// before the router is ever built. Verified here by mounting the same
// lazy wrapper shape the app uses.
const app = express();
const lazy = (req, res, next) => {
const router = null; // registry returns no router when no provider active
if (!router) {
const prov = null; // registry returns null when no provider active
if (!prov) return res.status(503).json({ error: 'network provider not active' });
}
return router(req, res, next);
};
app.use('/network/forward-auth', lazy);
const res = await request(app).get('/network/forward-auth/')
.set('X-Forwarded-For', '10.53.0.7');
expect(res.status).toBe(503);
expect(res.body).toEqual({ error: 'network provider not active' });
});
});
// ── X-Forwarded-For parsing ─────────────────────────────────────────────────
describe('DC-154 parseForwardedFor', () => {
test('single entry', () => {
expect(parseForwardedFor('203.0.113.7')).toEqual(['203.0.113.7']);
});
test('chained entries (client first) preserve order', () => {
expect(parseForwardedFor('203.0.113.7, 10.53.0.1, 10.53.0.2'))
.toEqual(['203.0.113.7', '10.53.0.1', '10.53.0.2']);
});
test('missing header → []', () => {
expect(parseForwardedFor(undefined)).toEqual([]);
expect(parseForwardedFor('')).toEqual([]);
});
test('port-bearing and ipv6 entries are normalized', () => {
expect(parseForwardedFor('203.0.113.7:54321')).toEqual(['203.0.113.7']);
expect(parseForwardedFor('[fd00::1]:443')).toEqual(['fd00::1']);
});
test('route uses the FIRST chained entry as the client IP', async () => {
const deps = buildDeps();
const app = buildApp(deps);
// 10.53.0.7 = PERSON; middle hop 10.53.0.1 is unknown — if the route
// picked the wrong hop the whois would 401 instead of allowing.
const res = await request(app).get('/network/forward-auth/')
.set('X-Forwarded-For', '10.53.0.7, 10.53.0.1')
.set('X-Forwarded-Host', 'plex.sami');
expect(res.status).toBe(200);
expect(res.body.personId).toBe(PERSON);
});
});