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,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()] } },
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user