access.ts
Language TypeScript7.4 KB206 lines
import { errorMessage } from '@meridian/shared'/*** Verifying who Cloudflare Access says is asking.** The JWT and not the email header. Access sets `Cf-Access-Authenticated-User-Email`, and it is tempting to read* it and move on. That header is only trustworthy if every request reaches this* Worker through Access, and the Worker is published on a `workers.dev`* hostname, which is publicly routable. Access sits in front of it by* configuration, not by physics. A misconfigured application, a preview URL left* uncovered, or a direct request would all deliver a header this Worker cannot* distinguish from the real thing, and the header is trivially settable by* whoever sends the request.** The executive briefing has exactly one reader, enforced rather than* preferred, and trusting a spoofable header would make it a preference. So* the signed assertion is verified instead: `Cf-Access-Jwt-Assertion` is an RS256 JWT signed* by the Access team's keys, and only Access can mint one.** Fail closed, always. Every failure path here returns null and the caller must* refuse the request. That includes the case where Access has not been configured* yet: a freshly deployed Worker with no application in front of it serves* nothing at all rather than serving everything to everyone. That is the correct* order: the board carries client names and order numbers.*/export interface AccessIdentity {email: string/** Subject claim, for logging a denial without logging an address. */sub: string}interface Jwk {kid: stringkty: stringn: stringe: stringalg?: string}const base64UrlToBytes = (s: string): Uint8Array => {const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4))const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/') + pad)const out = new Uint8Array(bin.length)for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)return out}const decodeJson = (s: string): Record<string, unknown> =>JSON.parse(new TextDecoder().decode(base64UrlToBytes(s))) as Record<string, unknown>/*** Access's signing keys, cached for a few minutes.** Cached because a key fetch on every request would put a network round trip on* the read path; kept short because key rotation must take effect without a* deploy. A stale key set fails closed; the signature simply will not verify.*/let keyCache: { at: number; keys: Map<string, CryptoKey> } | null = nullconst KEY_TTL_MS = 5 * 60_000/*** Drop the cached signing keys. For tests only. Production rotation is handled* by the short TTL above, and nothing in the request path should need this.*/export function __resetKeyCache(): void {keyCache = null}async function signingKeys(teamDomain: string): Promise<Map<string, CryptoKey>> {const now = Date.now()if (keyCache && now - keyCache.at < KEY_TTL_MS) return keyCache.keysconst res = await fetch(`https://${teamDomain}/cdn-cgi/access/certs`)if (!res.ok) throw new Error(`Access certs returned ${res.status}`)const body = (await res.json()) as { keys?: Jwk[] }const keys = new Map<string, CryptoKey>()for (const jwk of body.keys ?? []) {if (jwk.kty !== 'RSA') continuekeys.set(jwk.kid,await crypto.subtle.importKey('jwk',{ kty: jwk.kty, n: jwk.n, e: jwk.e, alg: 'RS256', ext: true },{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },false,['verify'],),)}keyCache = { at: now, keys }return keys}/*** The verified identity, or null.** Null is the only failure signal and it never carries a reason to the caller on* purpose: a request that fails verification gets one answer, so nothing about* why can be probed from outside. The reason is logged, not returned.*/export async function identify(req: Request,teamDomain: string | undefined,audience: string | undefined,log: (why: string) => void = () => {},): Promise<AccessIdentity | null> {if (!teamDomain || !audience) {log('ACCESS_TEAM_DOMAIN or ACCESS_AUD is not set on the Worker')return null}const token = req.headers.get('Cf-Access-Jwt-Assertion')if (!token) {log('no Cf-Access-Jwt-Assertion header; request did not come through Access')return null}const parts = token.split('.')if (parts.length !== 3) {log('assertion is not a three-part JWT')return null}const [rawHeader, rawPayload, rawSig] = parts as [string, string, string]let header: Record<string, unknown>let claims: Record<string, unknown>try {header = decodeJson(rawHeader)claims = decodeJson(rawPayload)} catch {log('assertion header or payload is not valid base64url JSON')return null}// Algorithm is pinned. Accepting whatever `alg` claims is the classic JWT// failure: "none" verifies trivially, and an HMAC alg would let the public key// be used as a shared secret.if (header.alg !== 'RS256') {log(`assertion alg is ${String(header.alg)}, not RS256`)return null}// A key fetch that fails is a denial, not an error. Letting this throw would// surface as a 500: still closed, but it reads as the board being broken and// invites someone to "fix" it by loosening the check. Refusing cleanly says// what it is: we cannot confirm who you are, so we will not answer.let keys: Map<string, CryptoKey>try {keys = await signingKeys(teamDomain)} catch (err) {log(`could not fetch Access signing keys: ${errorMessage(err)}`)return null}const key = keys.get(String(header.kid))if (!key) {log('assertion kid matches none of the current Access signing keys')return null}const ok = await crypto.subtle.verify({ name: 'RSASSA-PKCS1-v1_5' },key,base64UrlToBytes(rawSig),new TextEncoder().encode(`${rawHeader}.${rawPayload}`),)if (!ok) {log('assertion signature does not verify')return null}// Audience. Without this, a token minted by the same Access team for a// different application would be accepted here. Access teams commonly protect// several apps, and a token is only meant for the one it was issued for.const aud = claims.audconst audList = Array.isArray(aud) ? aud.map(String) : [String(aud)]if (!audList.includes(audience)) {log('assertion audience is for a different Access application')return null}const now = Math.floor(Date.now() / 1000)if (typeof claims.exp !== 'number' || claims.exp < now) {log('assertion is expired')return null}if (typeof claims.nbf === 'number' && claims.nbf > now) {log('assertion is not valid yet')return null}// Exact, not `includes`. A substring test is satisfied by any issuer merely// containing the team domain: `https://attacker.example/your-team.cloudflareaccess.com`// would pass. The signature still has to verify against keys fetched from// `teamDomain` itself, so a forged token cannot be signed either way. But a check that only holds because a different check is// doing the work is not a check, and this one is meant to be defence in depth.if (typeof claims.iss === 'string' && claims.iss !== `https://${teamDomain}`) {log('assertion issuer is not exactly this Access team')return null}const email = typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : ''if (!email) {log('assertion carries no email claim')return null}return { email, sub: typeof claims.sub === 'string' ? claims.sub : '' }}