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:
lyuksovannyy committed 2026-10-11 12:19:36 +00:00
1 parent 5dff41a3d0
commit 78fce8e1ce
12 files changed
+833 -40

No files matched your search

+182
View File
@@ -0,0 +1,182 @@
// Edge (auth proxy) login for the master link.
//
// A master published behind an identity-aware proxy (Authentik, oauth2-proxy, Cloudflare
// Access, plain HTTP Basic, …) redirects every request it can't attribute to a user. A slave's
// DSH has no browser, so it authenticates to the proxy itself and replays what it gets
// (cookies, headers) on /dsh-remote/enroll and the /dsh-remote/link WebSocket.
//
// Modes (config `edgeAuth` on the slave, or passed to pair):
// none no proxy
// basic { username, passwordRef } Authorization: Basic … (Authentik: user + app
// password; "goauthentik.io/token" + token works too)
// bearer { tokenRef } Authorization: Bearer <static token>
// oauth2 { tokenUrl, clientId, username?, passwordRef?, clientSecretRef?, scope? }
// client_credentials grant → Bearer JWT, refreshed
// before expiry (Authentik machine-to-machine:
// username = service account, password = app password)
// headers { headers: { "CF-Access-Client-Id": "…", "CF-Access-Client-Secret": "ref:NAME" } }
// static extra headers (Cloudflare Access service
// tokens, custom gateways); "ref:NAME" values resolve
// through the secret resolver
//
// Every mode keeps a small cookie jar: proxies such as Authentik answer an authenticated
// request with a session cookie, and replaying it avoids re-authenticating each call.
// Secrets are never stored in config: *Ref fields name a DSH credential ref (or env var).
const SKEW_MS = 60_000
export class EdgeAuthError extends Error {
constructor(message, { status, hint } = {}) {
super(message)
this.status = status
this.hint = hint
}
}
/** Minimal cookie jar scoped to one origin (enough for proxy session cookies). */
export class CookieJar {
constructor() { this.cookies = new Map() }
/** Absorb Set-Cookie headers from a fetch Response or node IncomingMessage. */
absorb(headers) {
let list = []
if (typeof headers?.getSetCookie === 'function') list = headers.getSetCookie()
else if (Array.isArray(headers?.['set-cookie'])) list = headers['set-cookie']
else if (typeof headers?.['set-cookie'] === 'string') list = [headers['set-cookie']]
for (const raw of list) {
const [pair, ...attrs] = String(raw).split(';')
const eq = pair.indexOf('=')
if (eq <= 0) continue
const name = pair.slice(0, eq).trim()
// Never keep the master DSH's own browser-session cookie: the slave authenticates to
// DSH with its device key, the jar is only for the auth proxy's session.
if (/^dsh-auth-/.test(name)) continue
const value = pair.slice(eq + 1).trim()
let expires = null
for (const a of attrs) {
const [k, v] = a.split('=').map((s) => s?.trim())
if (/^max-age$/i.test(k)) expires = Date.now() + Number(v) * 1000
else if (/^expires$/i.test(k) && !expires) { const t = Date.parse(v); if (!Number.isNaN(t)) expires = t }
}
if (!value || (expires && expires <= Date.now())) this.cookies.delete(name)
else this.cookies.set(name, { value, expires })
}
}
header() {
const now = Date.now()
const parts = []
for (const [k, c] of this.cookies) {
if (c.expires && c.expires <= now) { this.cookies.delete(k); continue }
parts.push(`${k}=${c.value}`)
}
return parts.join('; ')
}
clear() { this.cookies.clear() }
}
/**
* Build an edge-auth client.
* @param cfg edgeAuth config ({mode, ...})
* @param resolveSecret async (refName) => string|undefined (DSH credentials, then env)
*/
export function createEdgeAuth(cfg, resolveSecret) {
const mode = cfg?.mode || 'none'
const jar = new CookieJar()
let token = null // { value, expiresAt }
const secret = async (ref, what) => {
if (!ref) throw new EdgeAuthError(`edgeAuth: ${what} is not configured`)
const v = await resolveSecret(ref)
if (!v) throw new EdgeAuthError(`edgeAuth: secret "${ref}" (${what}) is not set on this device`, { hint: `store it with: dsh credentials or the ${ref} env var` })
return v
}
async function oauthToken(force = false) {
if (!force && token && token.expiresAt - SKEW_MS > Date.now()) return token.value
if (!cfg.tokenUrl || !cfg.clientId) throw new EdgeAuthError('edgeAuth oauth2: tokenUrl and clientId are required')
const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: cfg.clientId })
if (cfg.scope) body.set('scope', cfg.scope)
if (cfg.username) {
// Authentik M2M: username + app password (or a JWT from another provider as password).
body.set('username', cfg.username)
body.set('password', await secret(cfg.passwordRef, 'password / app password'))
}
if (cfg.clientSecretRef) body.set('client_secret', await secret(cfg.clientSecretRef, 'client secret'))
const r = await fetch(cfg.tokenUrl, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body })
let j = {}
try { j = await r.json() } catch { j = {} }
if (!r.ok || !j.access_token) {
throw new EdgeAuthError(`edgeAuth oauth2: token request failed (HTTP ${r.status}${j.error ? `: ${j.error}${j.error_description ? ` – ${j.error_description}` : ''}` : ''})`, { status: r.status })
}
token = { value: j.access_token, expiresAt: Date.now() + (Number(j.expires_in) || 300) * 1000 }
return token.value
}
/** Headers to add to a request through the edge (auth + jar cookies). */
async function headers({ forceRefresh = false } = {}) {
const h = {}
if (mode === 'basic') {
const pass = await secret(cfg.passwordRef, 'password / app password')
h.authorization = `Basic ${Buffer.from(`${cfg.username || ''}:${pass}`).toString('base64')}`
} else if (mode === 'bearer') {
h.authorization = `Bearer ${await secret(cfg.tokenRef, 'token')}`
} else if (mode === 'oauth2') {
h.authorization = `Bearer ${await oauthToken(forceRefresh)}`
} else if (mode === 'headers') {
for (const [k, v] of Object.entries(cfg.headers || {})) {
h[k.toLowerCase()] = typeof v === 'string' && v.startsWith('ref:') ? await secret(v.slice(4), k) : String(v)
}
} else if (mode !== 'none') {
throw new EdgeAuthError(`edgeAuth: unknown mode "${mode}"`)
}
const c = jar.header()
if (c) h.cookie = c
return h
}
/** Classify a response that came back from the edge instead of DSH. */
function edgeBlocked(status, location) {
if (status >= 300 && status < 400 && location && !/\/dsh-remote\//.test(location)) return `redirected to ${String(location).slice(0, 90)}`
if (status === 401 || status === 403) return `HTTP ${status}`
return null
}
/**
* fetch() through the edge: adds auth, keeps cookies, never follows redirects (a redirect
* to a login page means the edge rejected us). Retries once with a fresh token / cleared
* jar when the edge refuses.
*/
async function edgeFetch(url, init = {}) {
for (let attempt = 0; attempt < 2; attempt++) {
const h = { ...(init.headers || {}), ...(await headers({ forceRefresh: attempt > 0 })) }
const r = await fetch(url, { ...init, headers: h, redirect: 'manual' })
jar.absorb(r.headers)
const blocked = edgeBlocked(r.status, r.headers.get('location'))
if (!blocked) return r
// A DSH JSON error (e.g. invalid pairing code → 403) is not an edge refusal.
if ((r.status === 401 || r.status === 403) && /json/.test(r.headers.get('content-type') || '')) return r
if (attempt === 0 && mode !== 'none') { jar.clear(); token = null; continue }
throw new EdgeAuthError(`the auth proxy in front of ${new URL(url).host} refused the request (${blocked})`, {
status: r.status,
hint: mode === 'none'
? 'configure edgeAuth (basic / bearer / oauth2 / headers) for this master, or exempt /dsh-remote/link and /dsh-remote/enroll on the proxy'
: 'check the edgeAuth credentials and that this identity may access the application',
})
}
throw new EdgeAuthError('unreachable')
}
return {
mode,
jar,
headers,
edgeFetch,
edgeBlocked,
/** Feed upgrade-response cookies back (WebSocket handshake). */
absorb: (h) => jar.absorb(h),
reset() { jar.clear(); token = null },
describe() { return { mode, ...(mode === 'basic' ? { username: cfg.username } : {}), ...(mode === 'oauth2' ? { tokenUrl: cfg.tokenUrl, clientId: cfg.clientId, username: cfg.username, tokenValidUntil: token ? new Date(token.expiresAt).toISOString() : null } : {}), cookies: [...jar.cookies.keys()] } },
}
}
+5 -2
View File
@@ -167,7 +167,7 @@ export function rejectUpgrade(socket, status, message = '') {
}
/** Client connect: resolves a WsConn, rejects with {status, body} on refusal. */
export function connect(url, { headers = {}, timeoutMs = 15000, rejectUnauthorized = true } = {}) {
export function connect(url, { headers = {}, timeoutMs = 15000, rejectUnauthorized = true, onHeaders } = {}) {
return new Promise((resolve, reject) => {
const u = new URL(url)
const secure = u.protocol === 'wss:' || u.protocol === 'https:'
@@ -192,12 +192,15 @@ export function connect(url, { headers = {}, timeoutMs = 15000, rejectUnauthoriz
req.on('timeout', () => { req.destroy(new Error(`connect timeout after ${timeoutMs} ms`)) })
req.on('error', reject)
req.on('response', (res) => {
onHeaders?.(res.headers)
let body = ''
res.setEncoding('utf8')
res.on('data', (d) => { if (body.length < 4096) body += d })
res.on('end', () => {
const err = new Error(`upgrade refused: HTTP ${res.statusCode} ${body.trim()}`)
const loc = res.headers.location ? ` → ${String(res.headers.location).slice(0, 100)}` : ''
const err = new Error(`upgrade refused: HTTP ${res.statusCode}${loc} ${res.headers.location ? '' : body.trim().slice(0, 200)}`.trim())
err.status = res.statusCode
err.location = res.headers.location
err.body = body
reject(err)
})