Files
dsh-remote/lib/edge-auth.mjs
T
lyuksovannyy 78fce8e1ce 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
2026-10-11 12:19:36 +00:00

183 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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()] } },
}
}