// Trust a verified Cloudflare Access identity as the Void owner, so browser // requests that came through the CF tunnel don't need the owner token copied // onto every device. SECURITY: we verify the Access JWT (`Cf-Access-Jwt-Assertion`) // cryptographically — signature against the team's JWKS, audience = our app's AUD, // and email in the owner allow-list. The plain `Cf-Access-Authenticated-User-Email` // header is NEVER trusted on its own (it would be spoofable on the LAN-direct path). // Everything fails CLOSED: any miss/error returns null → falls back to token auth. // // Opt-in via env (absent → feature disabled, behaviour unchanged): // CF_ACCESS_TEAM_DOMAIN=hynesy.cloudflareaccess.com // CF_ACCESS_AUD= // CF_ACCESS_OWNER_EMAILS=mrhynesy@gmail.com[,other@…] import crypto from 'node:crypto'; const CERTS_TTL_MS = 60 * 60 * 1000; let jwks = { at: 0, keys: {} }; // kid -> KeyObject export function _resetJwksCache() { jwks = { at: 0, keys: {} }; } function b64urlBuf(s) { return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); } function b64urlJson(s) { return JSON.parse(b64urlBuf(s).toString('utf8')); } async function getKeys(teamDomain, fetchImpl) { if (Date.now() - jwks.at < CERTS_TTL_MS && Object.keys(jwks.keys).length) return jwks.keys; const res = await fetchImpl(`https://${teamDomain}/cdn-cgi/access/certs`, { signal: AbortSignal.timeout(5000) }); if (!res.ok) throw new Error(`jwks fetch ${res.status}`); const { keys } = await res.json(); const map = {}; for (const jwk of keys || []) map[jwk.kid] = crypto.createPublicKey({ key: jwk, format: 'jwk' }); jwks = { at: Date.now(), keys: map }; return map; } /** * Verify a Cloudflare Access JWT. Returns the claims if valid; throws otherwise. */ export async function verifyAccessJwt(jwt, { teamDomain, aud, fetchImpl = fetch, now = Date.now } = {}) { const [h, p, s] = String(jwt).split('.'); if (!h || !p || !s) throw new Error('malformed jwt'); const header = b64urlJson(h); if (header.alg !== 'RS256') throw new Error(`unexpected alg ${header.alg}`); let keys = await getKeys(teamDomain, fetchImpl); let key = keys[header.kid]; if (!key) { jwks.at = 0; keys = await getKeys(teamDomain, fetchImpl); key = keys[header.kid]; } // key rotation if (!key) throw new Error('unknown kid'); const ok = crypto.verify('RSA-SHA256', Buffer.from(`${h}.${p}`), key, b64urlBuf(s)); if (!ok) throw new Error('bad signature'); const claims = b64urlJson(p); const auds = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; if (!auds.includes(aud)) throw new Error('aud mismatch'); const t = Math.floor(now() / 1000); if (claims.exp && t > claims.exp) throw new Error('expired'); if (claims.nbf && t < claims.nbf - 60) throw new Error('not yet valid'); return claims; } export function config(env = process.env) { const { CF_ACCESS_TEAM_DOMAIN, CF_ACCESS_AUD, CF_ACCESS_OWNER_EMAILS } = env; if (!CF_ACCESS_TEAM_DOMAIN || !CF_ACCESS_AUD || !CF_ACCESS_OWNER_EMAILS) return null; return { teamDomain: CF_ACCESS_TEAM_DOMAIN, aud: CF_ACCESS_AUD, ownerEmails: CF_ACCESS_OWNER_EMAILS.split(',').map(e => e.trim().toLowerCase()).filter(Boolean) }; } /** * If the request carries a valid Access JWT for an allow-listed owner email, * return that email; otherwise null. Never throws (fails closed). */ export async function accessOwnerEmail(req, cfg = config(), opts = {}) { if (!cfg) return null; const jwt = req.headers['cf-access-jwt-assertion']; if (!jwt) return null; try { const claims = await verifyAccessJwt(jwt, { teamDomain: cfg.teamDomain, aud: cfg.aud, ...opts }); const email = String(claims.email || claims.identity || '').toLowerCase(); return cfg.ownerEmails.includes(email) ? email : null; } catch { return null; } }