- Settings → Remote devices → Node: type master|slave (stored in state, beats config role; Restart DSH button under systemd/launchd), origin (read-only: own origin / paired master), 'Master has an auth middleware' toggle - middleware on: a non-DSH answer on the link (login redirect, 401/403, inline HTML) raises auth-required; the UI opens <master>/dsh-remote/signin in a popup, the user signs in to the proxy, one click hands the proxy session cookie back (postMessage, origin-checked, trusted origins only), link retries - edge mode 'cookie': pasted authentik_proxy_… (or a whole Cookie header), rotated proxy cookies persisted back; pair.mjs/install-mac --edge cookie - connectMaster serialised; a failed attempt never clears a finished link - tests: cookie seed/mode, HTML login wall; e2e-signin against a fake proxy
225 lines
11 KiB
JavaScript
225 lines
11 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() }
|
||
|
||
/**
|
||
* Seed from a pasted cookie string: "a=1; b=2", a "Cookie: a=1; b=2" header line, or one
|
||
* "name=value" per line (as copied from browser dev tools). The master DSH's own
|
||
* `dsh-auth-*` cookie is dropped (the slave authenticates to DSH with its device key).
|
||
*/
|
||
seed(text) {
|
||
const body = String(text || '').trim().replace(/^cookie\s*:\s*/i, '')
|
||
for (const part of body.split(/;|\r?\n/)) {
|
||
const p = part.trim()
|
||
const eq = p.indexOf('=')
|
||
if (eq <= 0) continue
|
||
const name = p.slice(0, eq).trim()
|
||
if (/^dsh-auth-/.test(name) || /\s/.test(name)) continue
|
||
this.cookies.set(name, { value: p.slice(eq + 1).trim().replace(/^"|"$/g, ''), expires: null })
|
||
}
|
||
return this.cookies.size
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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, { persistCookie } = {}) {
|
||
const mode = cfg?.mode || 'none'
|
||
const jar = new CookieJar()
|
||
let token = null // { value, expiresAt }
|
||
let persisted = ''
|
||
/** Cookie mode: write rotated proxy cookies back so the session keeps extending itself. */
|
||
function persist() {
|
||
if (mode !== 'cookie' || !persistCookie) return
|
||
const h = jar.header()
|
||
if (h && h !== persisted) { persisted = h; Promise.resolve(persistCookie(h)).catch(() => {}) }
|
||
}
|
||
|
||
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 === 'cookie') {
|
||
// A session cookie copied from a browser that already logged in through the proxy
|
||
// (e.g. Authentik's `authentik_proxy_<hash>`). Seeded once; cookies the proxy rotates
|
||
// afterwards replace it in the jar for the life of this process.
|
||
if (!jar.cookies.size) jar.seed(await secret(cfg.cookieRef, 'proxy session cookie'))
|
||
} 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, contentType = '') {
|
||
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}`
|
||
// Some proxies answer with the login page inline (HTTP 200 text/html); DSH's own
|
||
// endpoints answer JSON / plain text, never HTML.
|
||
if (status === 200 && /text\/html/i.test(contentType)) return 'answered with an HTML page (sign-in page?)'
|
||
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)
|
||
persist()
|
||
const ct = r.headers.get('content-type') || ''
|
||
const blocked = edgeBlocked(r.status, r.headers.get('location'), ct)
|
||
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(ct)) 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'
|
||
? 'sign in through the auth proxy (Settings → Remote devices), configure edgeAuth, or exempt /dsh-remote/link and /dsh-remote/enroll on the proxy'
|
||
: 'the proxy rejected the stored credentials or cookie; sign in again or check the edgeAuth settings',
|
||
})
|
||
}
|
||
throw new EdgeAuthError('unreachable')
|
||
}
|
||
|
||
return {
|
||
mode,
|
||
jar,
|
||
headers,
|
||
edgeFetch,
|
||
edgeBlocked,
|
||
/** Feed upgrade-response cookies back (WebSocket handshake). */
|
||
absorb: (h) => { jar.absorb(h); persist() },
|
||
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()],
|
||
}
|
||
},
|
||
}
|
||
}
|