Files
dsh-remote/lib/edge-auth.mjs
T
lyuksovannyy bfbc3c66e4 dsh-remote 0.5.0: node settings, session-cookie edge auth, sign-in popup
- 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
2026-10-12 09:37:21 +00:00

225 lines
11 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() }
/**
* 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()],
}
},
}
}