dsh-remote 0.4.0: edge auth – slaves log in to an auth proxy in front of the master
- lib/edge-auth.mjs: basic / bearer / oauth2 client_credentials (Authentik M2M) / custom headers (Cloudflare Access), proxy session cookie jar, retry with fresh token, clear edge-refusal vs DSH-refusal errors - link + enroll go through the edge; master.edgeAuth stored at pair time, secrets as DSH credential refs only; config edgeAuth override - /dsh-remote/edge-auth: show/change/probe; Settings UI section + pair form - pair.mjs / install-mac.sh: --edge … flags, secrets prompted via /dev/tty - tests: fake Authentik-like proxy for all modes + WebSocket through the edge
This commit is contained in:
1 parent
5dff41a3d0
commit
78fce8e1ce
12 files changed
+833
-40
No files matched your search
@@ -0,0 +1,163 @@
|
||||
// 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$/)
|
||||
})
|
||||
Reference in new issue
Block a user