import { env } from "cloudflare:workers"; import { decryptSecret, encryptSecret, fromBase64, toBase64 } from "../crypto"; import { badRequest } from "../errors"; import { isPermanentFailure, isSuccess, outboundFetch } from "./outbound"; const GITHUB_API = "https://api.github.com"; const JWT_LIFETIME_SECONDS = 9 * 60; const JWT_CLOCK_SKEW_SECONDS = 60; const TOKEN_CACHE_SECONDS = 50 * 60; const MS_PER_SECOND = 1000; const DER_LONG_FORM = 0x80; const encoder = new TextEncoder(); function base64Url(bytes: Uint8Array): string { return toBase64(bytes).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); } function derLength(length: number): number[] { if (length < DER_LONG_FORM) return [length]; const bytes: number[] = []; for (let rest = length; rest > 0; rest >>= 8) bytes.unshift(rest & 0xff); return [DER_LONG_FORM | bytes.length, ...bytes]; } const RSA_ALGORITHM_IDENTIFIER = [ 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00, ]; /** GitHub hands out PKCS#1 keys; WebCrypto only imports PKCS#8, so wrap the RSAPrivateKey in a PrivateKeyInfo. */ export function pkcs1ToPkcs8(pkcs1: Uint8Array): Uint8Array { const octet = [0x04, ...derLength(pkcs1.length), ...pkcs1]; const body = [0x02, 0x01, 0x00, ...RSA_ALGORITHM_IDENTIFIER, ...octet]; return new Uint8Array([0x30, ...derLength(body.length), ...body]); } function pemBody(pem: string): Uint8Array { return fromBase64(pem.replace(/-----[A-Z ]+-----/g, "").replace(/\s+/g, "")); } async function appSigningKey(pem: string): Promise { const der = pem.includes("BEGIN RSA PRIVATE KEY") ? pkcs1ToPkcs8(pemBody(pem)) : pemBody(pem); return crypto.subtle.importKey("pkcs8", der, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"]); } export function githubAppConfigured(): boolean { return Boolean(env.GITHUB_APP_ID && env.GITHUB_APP_PRIVATE_KEY); } /** Short-lived RS256 JWT identifying the GitHub App itself. */ export async function githubAppJwt(nowMs = Date.now()): Promise { if (!env.GITHUB_APP_ID || !env.GITHUB_APP_PRIVATE_KEY) throw badRequest("The GitHub App is not configured"); const now = Math.floor(nowMs / MS_PER_SECOND); const header = base64Url(encoder.encode(JSON.stringify({ alg: "RS256", typ: "JWT" }))); const claims = base64Url( encoder.encode( JSON.stringify({ iat: now - JWT_CLOCK_SKEW_SECONDS, exp: now + JWT_LIFETIME_SECONDS, iss: env.GITHUB_APP_ID }), ), ); const key = await appSigningKey(env.GITHUB_APP_PRIVATE_KEY.replaceAll("\\n", "\n")); const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, encoder.encode(`${header}.${claims}`)); return `${header}.${claims}.${base64Url(new Uint8Array(signature))}`; } /** Installation access token, cached (encrypted) in KV for most of its one-hour lifetime. */ export async function installationToken(installationId: number): Promise { const cacheKey = `integrations:gh-token:${installationId}`; const cached = await env.KV.get<{ ciphertext: string; iv: string }>(cacheKey, "json"); if (cached) return decryptSecret(env.SECRETS_KEY, cached.ciphertext, cached.iv); const result = await outboundFetch(`${GITHUB_API}/app/installations/${installationId}/access_tokens`, { method: "POST", headers: { accept: "application/vnd.github+json", authorization: `Bearer ${await githubAppJwt()}`, "x-github-api-version": "2022-11-28", }, }); if (!isSuccess(result)) { const message = `GitHub refused an installation token (${result.status ?? result.error})`; throw isPermanentFailure(result) ? badRequest(message) : new Error(message); } const token = (JSON.parse(result.body) as { token?: string }).token; if (!token) throw new Error("GitHub returned no installation token"); await env.KV.put(cacheKey, JSON.stringify(await encryptSecret(env.SECRETS_KEY, token)), { expirationTtl: TOKEN_CACHE_SECONDS, }); return token; } export function gitAuthorization(token: string): string { return `Basic ${btoa(`x-access-token:${token}`)}`; } export async function githubApi(token: string, path: string, maxResponseBytes?: number): Promise { const result = await outboundFetch( `${GITHUB_API}${path}`, { method: "GET", headers: { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, "x-github-api-version": "2022-11-28", }, }, maxResponseBytes, ); if (!isSuccess(result)) throw new Error(`GitHub API ${path} failed (${result.status ?? result.error})`); return JSON.parse(result.body) as T; }