/** * 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, opts = {}) { const app = express(); const service = createForwardAuthService(deps); app.use('/network/forward-auth', forwardAuthRoutes({ service, // supertest connects over loopback: that IS the trusted reverse proxy // in the test harness (mirrors Caddy on-host in production). trustedProxies: opts.trustedProxies !== undefined ? opts.trustedProxies : ['127.0.0.1', '::1'], })); 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 trust boundary (forwarded headers only from trusted proxy)', () => { test('DIRECT request with spoofed X-Forwarded-For (no trusted proxy) → identity NOT inherited → 401 unknown-device', async () => { const app = buildApp(buildDeps(), { trustedProxies: [] }); // trust disabled const res = await request(app).get('/network/forward-auth/') .set('X-Forwarded-For', '10.53.0.7') // spoofed: a KNOWN allowed device .set('X-Forwarded-Host', 'plex.sami'); expect(res.status).toBe(401); expect(res.body).toEqual({ error: 'unknown-device' }); }); test('Spoofed X-Forwarded-Host from an untrusted peer is ignored (socket identity used instead)', async () => { const app = buildApp(buildDeps(), { trustedProxies: [] }); const res = await request(app).get('/network/forward-auth/') .set('X-Forwarded-For', '10.53.0.7') .set('X-Forwarded-Host', 'vault.sami'); // socket addr (127.0.0.1) is not a known device → unknown-device, NOT // a vault authorization decision based on the spoofed host+ip pair. expect(res.status).toBe(401); expect(res.body).toEqual({ error: 'unknown-device' }); }); test('Trusted proxy present → headers honored as before (regression)', async () => { const app = buildApp(buildDeps()); // loopback trusted const res = await request(app).get('/network/forward-auth/') .set('X-Forwarded-For', '10.53.0.7, 127.0.0.1') .set('X-Forwarded-Host', 'plex.sami'); expect(res.status).toBe(200); expect(res.body.personId).toBe(PERSON); }); test('XFF chain from trusted proxy picks the first entry (original client)', async () => { const app = buildApp(buildDeps()); const res = await request(app).get('/network/forward-auth/') .set('X-Forwarded-For', '10.53.0.9, 127.0.0.1') // first = other person's device .set('X-Forwarded-Host', 'plex.sami'); expect(res.status).toBe(200); expect(res.body.personId).toBe(OTHER_PERSON); }); }); 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); }); });