// 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 // 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()] } }, } }