- 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
193 lines
10 KiB
JavaScript
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$/)
|
|
})
|