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>
79 lines
3.9 KiB
JavaScript
79 lines
3.9 KiB
JavaScript
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('accepts any aud from a comma-separated allow-list (8b cutover: two CF apps front one origin)', async () => {
|
|
const multi = { ...opts, aud: `void-app-aud, ${AUD}` };
|
|
const c = await verifyAccessJwt(mintJwt(valid), multi); // token carries AUD (the 2nd allowed)
|
|
expect(c.email).toBe(EMAIL);
|
|
await expect(verifyAccessJwt(mintJwt({ ...valid, aud: 'unlisted' }), multi)).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();
|
|
});
|
|
});
|