From 4e943ada126d18e059197c6750419cf62bd42342 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 3 Jun 2026 10:25:58 +1000 Subject: [PATCH] =?UTF-8?q?feat(auth):=202.0.0-alpha.10=20=E2=80=94=20Clou?= =?UTF-8?q?dflare=20Access=20SSO=20as=20owner=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cryptographically-verified CF Access JWT (signature vs team JWKS + audience + email allow-list) now counts as the owner, so browser requests through the CF tunnel don't need the owner token copied onto each device. Fails closed → owner token remains the fallback (LAN-direct + dev/tests unaffected). Opt-in via CF_ACCESS_TEAM_DOMAIN / CF_ACCESS_AUD / CF_ACCESS_OWNER_EMAILS. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 ++ lib/api/middleware/agent_auth.js | 9 ++++ lib/auth/cf_access.js | 84 ++++++++++++++++++++++++++++++++ package.json | 2 +- server.js | 2 +- tests/auth/cf_access.test.js | 72 +++++++++++++++++++++++++++ 6 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 lib/auth/cf_access.js create mode 100644 tests/auth/cf_access.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 908c25e..f13b784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ All notable changes to Void 2.0 are documented here. Format: [Keep a Changelog](https://keepachangelog.com). +## 2.0.0-alpha.10 — Cloudflare Access SSO as owner auth +- Browser requests through the CF tunnel no longer need the owner token copied onto each device: a cryptographically-verified Cloudflare Access JWT (`Cf-Access-Jwt-Assertion`) for an allow-listed email now counts as the owner (`lib/auth/cf_access.js`, wired into `agentOrOwner`). +- Security: verifies signature against the team JWKS + audience (app AUD) + email allow-list; the plain email header is never trusted alone. Fails closed → falls back to the owner token (LAN-direct `:3000` path and dev/tests unaffected). +- Opt-in via env: `CF_ACCESS_TEAM_DOMAIN`, `CF_ACCESS_AUD`, `CF_ACCESS_OWNER_EMAILS` (absent → feature disabled). + ## 2.0.0-alpha.9 — Hardening pass (Void 3.0 quick wins) - Security: prod `void` DB role revoked SUPERUSER (CT 310; `vector` marked trusted so the test harness still creates it as non-superuser). An app-process compromise no longer escalates to full-cluster compromise. - Security: the `claude` companion subprocess now gets an explicit env allow-list (`buildChildEnv`) instead of the full `process.env` — `OWNER_TOKEN`/`DATABASE_URL`/Karakeep/ANTHROPIC secrets no longer reach the CLI. MCP tools are unaffected (they get DB env via the explicit `--mcp-config`). diff --git a/lib/api/middleware/agent_auth.js b/lib/api/middleware/agent_auth.js index a8b6941..565df93 100644 --- a/lib/api/middleware/agent_auth.js +++ b/lib/api/middleware/agent_auth.js @@ -1,5 +1,6 @@ import * as agents from '../../db/repos/agents.js'; import { timingSafeStrEqual } from '../../auth/safe_compare.js'; +import { accessOwnerEmail } from '../../auth/cf_access.js'; export async function agentOrOwner(req, res, next) { const expectedOwner = process.env.OWNER_TOKEN; @@ -8,6 +9,14 @@ export async function agentOrOwner(req, res, next) { error: { code: 'no_owner_token', message: 'OWNER_TOKEN not configured' } }); } + // A cryptographically-verified Cloudflare Access identity counts as the owner, + // so requests through the CF tunnel don't need the owner token. Fails closed + // (null on any miss/error) → falls through to bearer-token auth below. + const cfEmail = await accessOwnerEmail(req); + if (cfEmail) { + req.actor = { kind: 'user', id: null }; + return next(); + } const auth = req.headers.authorization || ''; const [scheme, token] = auth.split(' '); if (scheme !== 'Bearer' || !token) { diff --git a/lib/auth/cf_access.js b/lib/auth/cf_access.js new file mode 100644 index 0000000..54dbf5d --- /dev/null +++ b/lib/auth/cf_access.js @@ -0,0 +1,84 @@ +// 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; + } +} diff --git a/package.json b/package.json index d5a192b..51434f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void-server", - "version": "2.0.0-alpha.9", + "version": "2.0.0-alpha.10", "type": "module", "private": true, "scripts": { diff --git a/server.js b/server.js index f14331b..1071c8a 100644 --- a/server.js +++ b/server.js @@ -9,7 +9,7 @@ import { router as ingestRouter } from './lib/api/routes/ingest.js'; import { router as iconsRouter } from './lib/api/routes/icons.js'; import { startCron } from './lib/cron/index.js'; -const VERSION = '2.0.0-alpha.9'; +const VERSION = '2.0.0-alpha.10'; export function createApp() { const app = express(); diff --git a/tests/auth/cf_access.test.js b/tests/auth/cf_access.test.js new file mode 100644 index 0000000..9877553 --- /dev/null +++ b/tests/auth/cf_access.test.js @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import crypto from 'node:crypto'; +import { verifyAccessJwt, accessOwnerEmail, config, _resetJwksCache } from '../../lib/auth/cf_access.js'; + +const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); +const KID = 'test-kid-1'; +const JWK = { ...publicKey.export({ format: 'jwk' }), kid: KID, alg: 'RS256', use: 'sig' }; + +const AUD = 'aud-123'; +const TEAM = 'team.cloudflareaccess.com'; +const EMAIL = 'owner@example.com'; +const NOW = () => 1_780_000_000_000; // fixed clock (ms) +const sec = Math.floor(NOW() / 1000); + +function b64url(buf) { + return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} +function mintJwt(claims, { kid = KID, key = privateKey } = {}) { + const h = b64url(JSON.stringify({ alg: 'RS256', kid, typ: 'JWT' })); + const p = b64url(JSON.stringify(claims)); + const s = b64url(crypto.sign('RSA-SHA256', Buffer.from(`${h}.${p}`), key)); + return `${h}.${p}.${s}`; +} +const fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ keys: [JWK] }) }); +const valid = { aud: AUD, email: EMAIL, exp: sec + 300, iat: sec, nbf: sec - 5 }; +const opts = { teamDomain: TEAM, aud: AUD, fetchImpl, now: NOW }; + +beforeEach(() => _resetJwksCache()); + +describe('verifyAccessJwt', () => { + it('accepts a correctly-signed token for our audience', async () => { + const c = await verifyAccessJwt(mintJwt(valid), opts); + expect(c.email).toBe(EMAIL); + }); + it('rejects a token signed by a different key', async () => { + const { privateKey: other } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + await expect(verifyAccessJwt(mintJwt(valid, { key: other }), opts)).rejects.toThrow(); + }); + it('rejects a wrong audience (another app cannot grant access)', async () => { + await expect(verifyAccessJwt(mintJwt({ ...valid, aud: 'someone-else' }), opts)).rejects.toThrow(/aud/); + }); + it('rejects an expired token', async () => { + await expect(verifyAccessJwt(mintJwt({ ...valid, exp: sec - 10 }), opts)).rejects.toThrow(/expired/); + }); + it('rejects a malformed token', async () => { + await expect(verifyAccessJwt('not.a.jwt', opts)).rejects.toThrow(); + }); +}); + +describe('accessOwnerEmail (fails closed)', () => { + const cfg = { teamDomain: TEAM, aud: AUD, ownerEmails: [EMAIL] }; + const reqWith = (jwt) => ({ headers: jwt ? { 'cf-access-jwt-assertion': jwt } : {} }); + + it('returns the email for a valid owner JWT', async () => { + expect(await accessOwnerEmail(reqWith(mintJwt(valid)), cfg, { fetchImpl, now: NOW })).toBe(EMAIL); + }); + it('returns null when the JWT email is not an allow-listed owner', async () => { + const jwt = mintJwt({ ...valid, email: 'intruder@example.com' }); + expect(await accessOwnerEmail(reqWith(jwt), cfg, { fetchImpl, now: NOW })).toBeNull(); + }); + it('returns null when no Access header is present (LAN-direct path)', async () => { + expect(await accessOwnerEmail(reqWith(null), cfg, { fetchImpl, now: NOW })).toBeNull(); + }); + it('returns null for a forged/bad JWT', async () => { + const { privateKey: other } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + expect(await accessOwnerEmail(reqWith(mintJwt(valid, { key: other })), cfg, { fetchImpl, now: NOW })).toBeNull(); + }); + it('is disabled (null) when CF_ACCESS env is unset', async () => { + expect(config({})).toBeNull(); + expect(await accessOwnerEmail(reqWith(mintJwt(valid)), config({}), { fetchImpl, now: NOW })).toBeNull(); + }); +});