| 1 | import { b64url, fromB64url } from "./util.js"; |
| 2 | |
| 3 | interface Jwk extends JsonWebKey { |
| 4 | kid?: string; |
| 5 | } |
| 6 | |
| 7 | const certCache = new Map<string, { keys: Jwk[]; at: number }>(); |
| 8 | |
| 9 | /** Verify a Cloudflare Access JWT (RS256) and return its claims. */ |
| 10 | export async function verifyAccessJwt(token: string, teamDomain: string, aud: string): Promise<Record<string, unknown>> { |
| 11 | const [h, p, s] = token.split("."); |
| 12 | if (!h || !p || !s) throw new Error("malformed JWT"); |
| 13 | const header = JSON.parse(new TextDecoder().decode(fromB64url(h))) as { kid?: string; alg?: string }; |
| 14 | if (header.alg !== "RS256") throw new Error("unexpected alg"); |
| 15 | const certsUrl = `https://${teamDomain}/cdn-cgi/access/certs`; |
| 16 | let cached = certCache.get(certsUrl); |
| 17 | if (!cached || Date.now() - cached.at > 10 * 60_000 || !cached.keys.some((k) => k.kid === header.kid)) { |
| 18 | const res = await fetch(certsUrl); |
| 19 | if (!res.ok) throw new Error(`failed to fetch Access certs: ${res.status}`); |
| 20 | cached = { keys: ((await res.json()) as { keys: Jwk[] }).keys, at: Date.now() }; |
| 21 | certCache.set(certsUrl, cached); |
| 22 | } |
| 23 | const jwk = cached.keys.find((k) => k.kid === header.kid); |
| 24 | if (!jwk) throw new Error("unknown signing key"); |
| 25 | const key = await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]); |
| 26 | const ok = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, fromB64url(s) as BufferSource, new TextEncoder().encode(`${h}.${p}`)); |
| 27 | if (!ok) throw new Error("bad signature"); |
| 28 | const claims = JSON.parse(new TextDecoder().decode(fromB64url(p))) as Record<string, unknown>; |
| 29 | const now = Math.floor(Date.now() / 1000); |
| 30 | if (typeof claims.exp === "number" && claims.exp < now) throw new Error("token expired"); |
| 31 | const auds = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; |
| 32 | if (!auds.includes(aud)) throw new Error("audience mismatch"); |
| 33 | if (claims.iss !== `https://${teamDomain}`) throw new Error("issuer mismatch"); |
| 34 | return claims; |
| 35 | } |
| 36 | |
| 37 | /** Sign an ES256 JWT with a private JWK (used for job OIDC tokens). */ |
| 38 | export async function signEs256(privateJwk: Jwk, claims: Record<string, unknown>): Promise<string> { |
| 39 | const key = await crypto.subtle.importKey("jwk", privateJwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]); |
| 40 | const enc = (o: unknown) => b64url(new TextEncoder().encode(JSON.stringify(o))); |
| 41 | const head = enc({ alg: "ES256", typ: "JWT", kid: privateJwk.kid ?? "diffinery-1" }); |
| 42 | const body = enc(claims); |
| 43 | const sig = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, new TextEncoder().encode(`${head}.${body}`))); |
| 44 | return `${head}.${body}.${b64url(sig)}`; |
| 45 | } |
| 46 | |
| 47 | export function publicJwk(privateJwk: Jwk): Jwk { |
| 48 | const { d: _d, key_ops: _ops, ...pub } = privateJwk; |
| 49 | return { ...pub, use: "sig", alg: "ES256", kid: privateJwk.kid ?? "diffinery-1" }; |
| 50 | } |