Files
dsh-remote/test/edge-auth.test.mjs
T
lyuksovannyy bfbc3c66e4 dsh-remote 0.5.0: node settings, session-cookie edge auth, sign-in popup
- Settings → Remote devices → Node: type master|slave (stored in state, beats
  config role; Restart DSH button under systemd/launchd), origin (read-only:
  own origin / paired master), 'Master has an auth middleware' toggle
- middleware on: a non-DSH answer on the link (login redirect, 401/403, inline
  HTML) raises auth-required; the UI opens <master>/dsh-remote/signin in a popup,
  the user signs in to the proxy, one click hands the proxy session cookie back
  (postMessage, origin-checked, trusted origins only), link retries
- edge mode 'cookie': pasted authentik_proxy_… (or a whole Cookie header),
  rotated proxy cookies persisted back; pair.mjs/install-mac --edge cookie
- connectMaster serialised; a failed attempt never clears a finished link
- tests: cookie seed/mode, HTML login wall; e2e-signin against a fake proxy
2026-10-12 09:37:21 +00:00

193 lines
10 KiB
JavaScript

// 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) && /sign in through the auth proxy/.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) && /rejected the stored 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('cookie jar seed: accepts name=value, a Cookie: header and multi-line paste; drops dsh-auth-*', () => {
const j = new CookieJar()
assert.equal(j.seed('Cookie: authentik_proxy_ab12=XYZ; dsh-auth-foo=bar; other="q"'), 2)
assert.equal(j.header(), 'authentik_proxy_ab12=XYZ; other=q')
j.clear()
j.seed('authentik_proxy_ab12=XYZ\nextra=1\n')
assert.equal(j.header(), 'authentik_proxy_ab12=XYZ; extra=1')
})
test('mode cookie: pasted cookie passes the proxy, rotated cookie is persisted, HTML page counts as a login wall', async () => {
const { srv, sessions } = fakeProxy()
const port = await listen(srv)
sessions.add('abc123')
const saved = []
const e = createEdgeAuth({ mode: 'cookie', cookieRef: 'COOK' }, async () => 'Cookie: edge_session=abc123; dsh-auth-x=ignored', { persistCookie: (c) => saved.push(c) })
const r = await e.edgeFetch(`http://127.0.0.1:${port}/dsh-remote/enroll`, { method: 'POST' })
assert.equal(r.status, 200)
assert.deepEqual(e.describe().cookies, ['edge_session'])
// A stale cookie → the proxy redirects → clear error with a sign-in hint.
const stale = createEdgeAuth({ mode: 'cookie', cookieRef: 'COOK' }, async () => 'edge_session=nope')
await assert.rejects(stale.edgeFetch(`http://127.0.0.1:${port}/dsh-remote/enroll`, { method: 'POST' }), (err) => /refused the request/.test(err.message) && /sign in again/.test(err.hint))
// An inline HTML sign-in page (200) is not DSH either.
const htmlSrv = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end('<html>Sign in</html>') })
const hp = await listen(htmlSrv)
const none = createEdgeAuth({ mode: 'none' }, resolve)
await assert.rejects(none.edgeFetch(`http://127.0.0.1:${hp}/dsh-remote/enroll`, { method: 'POST' }), /HTML page/)
srv.close(); htmlSrv.close()
})
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$/)
})