// Edge auth against a fake identity-aware proxy that behaves like Authentik's outpost: // unauthenticated → 302 to a login page; valid credential → session cookie + pass-through; // valid session cookie → pass-through; invalid credential → 401. import test from 'node:test' import assert from 'node:assert/strict' import http from 'node:http' import { createEdgeAuth, CookieJar } from '../lib/edge-auth.mjs' import { acceptUpgrade, connect } from '../lib/ws.mjs' import { setRefIn } from '../deploy/pair-lib.mjs' const listen = (srv) => new Promise((r) => srv.listen(0, '127.0.0.1', () => r(srv.address().port))) function fakeProxy() { const sessions = new Set() const issuedTokens = new Set() const stats = { credentialLogins: 0, cookieHits: 0, tokenRequests: 0 } const authOk = (req) => { const cookie = /edge_session=([a-z0-9]+)/.exec(req.headers.cookie || '')?.[1] if (cookie && sessions.has(cookie)) { stats.cookieHits++; return 'cookie' } const a = req.headers.authorization || '' if (a === `Basic ${Buffer.from('svc:app-pass').toString('base64')}`) return 'cred' if (a === 'Bearer static-token') return 'cred' if (a.startsWith('Bearer jwt-') && issuedTokens.has(a.slice(7))) return 'cred' if (req.headers['cf-access-client-id'] === 'id1' && req.headers['cf-access-client-secret'] === 'sec1') return 'cred' if (a) return 'bad' return null } const srv = http.createServer((req, res) => { if (req.url === '/token' && req.method === 'POST') { let body = '' req.on('data', (d) => { body += d }) req.on('end', () => { stats.tokenRequests++ const p = new URLSearchParams(body) if (p.get('grant_type') !== 'client_credentials' || p.get('client_id') !== 'cid' || p.get('username') !== 'svc' || p.get('password') !== 'app-pass') { res.writeHead(400, { 'content-type': 'application/json' }); return res.end('{"error":"invalid_grant"}') } const t = `jwt-${Math.random().toString(36).slice(2)}` issuedTokens.add(t) res.writeHead(200, { 'content-type': 'application/json' }) res.end(JSON.stringify({ access_token: t, token_type: 'bearer', expires_in: 3600 })) }) return } const a = authOk(req) if (a === 'bad') { res.writeHead(401); return res.end('bad credentials') } if (!a) { res.writeHead(302, { location: 'https://auth.example/application/o/authorize/?x=1' }); return res.end() } const headers = { 'content-type': 'application/json' } if (a === 'cred') { stats.credentialLogins++ const sid = Math.random().toString(36).slice(2) sessions.add(sid) headers['set-cookie'] = `edge_session=${sid}; Path=/; HttpOnly; Max-Age=3600` } if (req.url === '/dsh-remote/enroll') { res.writeHead(req.headers['x-bad-code'] ? 403 : 200, headers) return res.end(JSON.stringify(req.headers['x-bad-code'] ? { error: 'invalid or expired pairing code' } : { deviceId: 'mac', key: 'k', master: { id: 'm', name: 'M' } })) } res.writeHead(404); res.end() }) srv.on('upgrade', (req, socket, head) => { const a = authOk(req) if (a !== 'cred' && a !== 'cookie') { socket.end(`HTTP/1.1 302 Found\r\nLocation: https://auth.example/login\r\nContent-Length: 0\r\n\r\n`) return } const ws = acceptUpgrade(req, socket, head) ws.on('error', () => {}) ws.on('message', (m) => ws.send(`echo:${m}`)) }) return { srv, stats, sessions } } const secrets = { PW: 'app-pass', TOK: 'static-token', H1: 'id1', H2: 'sec1' } const resolve = async (ref) => secrets[ref] test('mode none: a login redirect becomes a clear edge error with a hint', async () => { const { srv } = fakeProxy() const port = await listen(srv) const e = createEdgeAuth({ mode: 'none' }, resolve) await assert.rejects(e.edgeFetch(`http://127.0.0.1:${port}/dsh-remote/enroll`, { method: 'POST' }), (err) => /auth proxy/.test(err.message) && /configure edgeAuth/.test(err.hint)) srv.close() }) for (const [mode, cfg] of [ ['basic', { mode: 'basic', username: 'svc', passwordRef: 'PW' }], ['bearer', { mode: 'bearer', tokenRef: 'TOK' }], ['headers', { mode: 'headers', headers: { 'CF-Access-Client-Id': 'ref:H1', 'CF-Access-Client-Secret': 'ref:H2' } }], ]) { test(`mode ${mode}: enroll passes, session cookie is kept and reused`, async () => { const { srv, stats } = fakeProxy() const port = await listen(srv) const e = createEdgeAuth(cfg, resolve) const r = await e.edgeFetch(`http://127.0.0.1:${port}/dsh-remote/enroll`, { method: 'POST' }) assert.equal(r.status, 200) assert.equal((await r.json()).deviceId, 'mac') assert.deepEqual(e.describe().cookies, ['edge_session']) // DSH-level JSON 403 (bad pairing code) is passed through, not treated as an edge refusal. const bad = await e.edgeFetch(`http://127.0.0.1:${port}/dsh-remote/enroll`, { method: 'POST', headers: { 'x-bad-code': '1' } }) assert.equal(bad.status, 403) assert.ok(stats.cookieHits >= 1, 'second request rode on the proxy session cookie') srv.close() }) } test('mode oauth2: client_credentials token, used as Bearer, cached', async () => { const { srv, stats } = fakeProxy() const port = await listen(srv) const e = createEdgeAuth({ mode: 'oauth2', tokenUrl: `http://127.0.0.1:${port}/token`, clientId: 'cid', username: 'svc', passwordRef: 'PW' }, resolve) const r1 = await e.edgeFetch(`http://127.0.0.1:${port}/dsh-remote/enroll`, { method: 'POST' }) assert.equal(r1.status, 200) e.jar.clear() // force credential use again, token must be reused (not re-requested) const r2 = await e.edgeFetch(`http://127.0.0.1:${port}/dsh-remote/enroll`, { method: 'POST' }) assert.equal(r2.status, 200) assert.equal(stats.tokenRequests, 1) assert.ok(e.describe().tokenValidUntil) srv.close() }) test('oauth2 bad credentials and basic wrong password surface clear errors', async () => { const { srv } = fakeProxy() const port = await listen(srv) const o = createEdgeAuth({ mode: 'oauth2', tokenUrl: `http://127.0.0.1:${port}/token`, clientId: 'cid', username: 'svc', passwordRef: 'NOPE' }, async (r) => (r === 'NOPE' ? 'wrong' : undefined)) await assert.rejects(o.edgeFetch(`http://127.0.0.1:${port}/x`), /token request failed \(HTTP 400: invalid_grant/) const b = createEdgeAuth({ mode: 'basic', username: 'svc', passwordRef: 'NOPE' }, async () => 'wrong') await assert.rejects(b.edgeFetch(`http://127.0.0.1:${port}/x`), (err) => /refused the request \(HTTP 401\)/.test(err.message) && /check the edgeAuth credentials/.test(err.hint)) const missing = createEdgeAuth({ mode: 'basic', username: 'svc', passwordRef: 'UNSET' }, async () => undefined) await assert.rejects(missing.edgeFetch(`http://127.0.0.1:${port}/x`), /secret "UNSET"/) srv.close() }) test('WebSocket link through the edge: refused without auth, passes with basic + cookie', async () => { const { srv, stats } = fakeProxy() const port = await listen(srv) await assert.rejects(connect(`ws://127.0.0.1:${port}/dsh-remote/link`), (err) => err.status === 302 && /auth\.example/.test(err.location)) const e = createEdgeAuth({ mode: 'basic', username: 'svc', passwordRef: 'PW' }, resolve) // First request gets the session cookie (like the slave's enroll / probe)… await e.edgeFetch(`http://127.0.0.1:${port}/dsh-remote/enroll`, { method: 'POST' }) const ws = await connect(`ws://127.0.0.1:${port}/dsh-remote/link`, { headers: await e.headers(), onHeaders: (h) => e.absorb(h) }) const reply = await new Promise((r) => { ws.on('message', (m) => r(String(m))); ws.send('hi') }) assert.equal(reply, 'echo:hi') ws.close() assert.ok(stats.cookieHits >= 1) srv.close() }) test('cookie jar: expiry and deletion', () => { const j = new CookieJar() j.absorb({ 'set-cookie': ['a=1; Max-Age=3600', 'b=2; Max-Age=0', 'c=3'] }) assert.equal(j.header(), 'a=1; c=3') j.absorb({ 'set-cookie': 'a=; Max-Age=0' }) assert.equal(j.header(), 'c=3') }) test('pair.mjs credentials.yaml ref writer keeps existing records and refs', () => { const before = 'version: 1\nrecords:\n client-connection/browser-session:\n kind: grant\nrefs:\n OMNI_API_KEY: sk-1\n' const after = setRefIn(before, 'DSH_REMOTE_EDGE_PASSWORD', 'p"w') assert.match(after, /records:\n client-connection\/browser-session:\n kind: grant\n/) assert.match(after, /refs:\n OMNI_API_KEY: sk-1\n DSH_REMOTE_EDGE_PASSWORD: "p\\"w"\n/) const again = setRefIn(after, 'DSH_REMOTE_EDGE_PASSWORD', 'new') assert.equal((again.match(/DSH_REMOTE_EDGE_PASSWORD/g) || []).length, 1) assert.match(setRefIn('', 'X_Y', 'v'), /^version: 1\nrefs:\n X_Y: "v"\n$/) })