- 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
183 lines
8.8 KiB
JavaScript
183 lines
8.8 KiB
JavaScript
// 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()] } },
|
||
}
|
||
}
|