CF Access multi-aud: CF_ACCESS_AUD now accepts a comma-separated allow-list so requests through either the void.hynesy.com or void2-app.hynesy.com CF Access app are honoured as owner. Fails closed; unlisted auds rejected. Adds multi-aud test. Void 1 (CT 301) becomes legacy but stays running untouched as an instant rollback. -alpha tag kept pending owner sign-off. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
90 lines
4.1 KiB
JavaScript
90 lines
4.1 KiB
JavaScript
// 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=<application audience tag>
|
|
// 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];
|
|
// `aud` may be a single value or a comma-separated list (multiple CF Access
|
|
// apps front the same origin — e.g. void.hynesy.com and void2-app.hynesy.com
|
|
// during the 8b cutover). Accept the token if it carries ANY allowed aud.
|
|
const wanted = (Array.isArray(aud) ? aud : String(aud).split(','))
|
|
.map((a) => a.trim()).filter(Boolean);
|
|
if (!auds.some((a) => wanted.includes(a))) 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;
|
|
}
|
|
}
|