diff --git a/migrations/0081_orb_enrollment_token_cache.sql b/migrations/0081_orb_enrollment_token_cache.sql new file mode 100644 index 0000000000..d4253302b0 --- /dev/null +++ b/migrations/0081_orb_enrollment_token_cache.sql @@ -0,0 +1,7 @@ +-- Cache the brokered GitHub installation token on the enrollment row so POST /v1/orb/token mints from GitHub at +-- most once per install per ~hour instead of on EVERY call. Minting on every call throttles GitHub's +-- installation-token endpoint (observed 16-20s responses), which exceeds the engine's broker timeout and surfaces +-- as orb_broker_unavailable / orb_broker_degraded_serving_cached_token. The value is a JSON blob holding the +-- AES-256-GCM ciphertext/iv/salt (encrypted with TOKEN_ENCRYPTION_SECRET, same scheme as the relay secret) plus the +-- token's expiry; it is NULL until the first mint and re-minted once under the safety margin. +ALTER TABLE orb_enrollments ADD COLUMN cached_token_json TEXT; diff --git a/src/orb/app-auth.ts b/src/orb/app-auth.ts index 1f6d3dcbbf..9d2b2af32b 100644 --- a/src/orb/app-auth.ts +++ b/src/orb/app-auth.ts @@ -55,13 +55,18 @@ export async function listOrbAppInstallations(env: Env): Promise { const jwt = await createOrbAppJwt(env); const response = await timeoutFetch(`https://api.github.com/app/installations/${installationId}/access_tokens`, { method: "POST", headers: orbHeaders(jwt), + signal: AbortSignal.timeout(ORB_TOKEN_MINT_TIMEOUT_MS), }); if (!response.ok) { const body = await response.text(); diff --git a/src/orb/broker-client.ts b/src/orb/broker-client.ts index a35e116032..7f250a768e 100644 --- a/src/orb/broker-client.ts +++ b/src/orb/broker-client.ts @@ -9,7 +9,9 @@ /** The Orb's hosted broker base; override (ORB_BROKER_URL) only to point at a private gittensory deployment. */ const DEFAULT_BROKER_URL = "https://gittensory-api.aethereal.dev"; -const BROKER_TIMEOUT_MS = 10_000; +// The broker's cold token mint can take many seconds when GitHub is throttling the App; allow headroom so the one +// uncached mint completes and populates the broker-side cache (steady-state cache hits return in well under a second). +const BROKER_TIMEOUT_MS = 25_000; function isLocalBrokerHost(hostname: string): boolean { return hostname === "localhost" || hostname === "127.0.0.1" || (hostname === "::1" || hostname === "[::1]"); diff --git a/src/orb/broker.ts b/src/orb/broker.ts index 3891861b47..b23c12d8ab 100644 --- a/src/orb/broker.ts +++ b/src/orb/broker.ts @@ -12,8 +12,13 @@ // the installation's account server-side before issuing — both bind installation_id at issue time, so the OAuth // privilege-escalation surface the red-team flagged stays closed. import { createOpaqueToken, hashToken } from "../auth/security"; +import { decryptSecret, encryptSecret } from "../utils/crypto"; import { createOrbInstallationToken } from "./app-auth"; +// A minted GitHub installation token lasts ~1h; re-mint only once it's under this margin so a near-expiry entry is +// never handed out (covers clock skew + the engine's own ~5m cache margin). +const ORB_TOKEN_CACHE_MIN_REMAINING_MS = 10 * 60_000; + export function isOrbBrokerEnabled(env: Env): boolean { return /^(1|true|yes|on)$/i.test(String(env.ORB_BROKER_ENABLED ?? "").trim()); } @@ -50,16 +55,57 @@ export type BrokerResult = { token: string; installationId: number; expiresAt: s * registered=1 and neither suspended nor removed at mint time (the gate is re-checked, not trusted from issue). */ export async function brokerOrbToken(env: Env, secret: string): Promise { const row = await env.DB - .prepare("SELECT enroll_id, installation_id, state, revoked_at FROM orb_enrollments WHERE secret_hash = ?") + .prepare("SELECT enroll_id, installation_id, state, revoked_at, cached_token_json FROM orb_enrollments WHERE secret_hash = ?") .bind(await hashToken(secret)) - .first<{ enroll_id: string; installation_id: number; state: string; revoked_at: string | null }>(); + .first<{ enroll_id: string; installation_id: number; state: string; revoked_at: string | null; cached_token_json: string | null }>(); if (!row || row.state !== "enrolled" || row.revoked_at !== null) return { error: "invalid_enrollment" }; const install = await env.DB .prepare("SELECT registered, suspended_at, removed_at FROM orb_github_installations WHERE installation_id = ?") .bind(row.installation_id) .first<{ registered: number; suspended_at: string | null; removed_at: string | null }>(); if (!install || install.registered !== 1 || install.suspended_at !== null || install.removed_at !== null) return { error: "installation_not_eligible" }; + // Serve a still-fresh cached token instead of re-minting. GitHub installation tokens last ~1h, and minting on + // EVERY broker call throttles GitHub's token endpoint (16-20s responses → engine timeouts → orb_broker_unavailable). + // The token is cached encrypted-at-rest (AES-256-GCM via TOKEN_ENCRYPTION_SECRET); with no key set the cache is + // skipped and we mint every call exactly as before. + const cached = await readCachedOrbToken(env, row.cached_token_json); + if (cached) { + await touchLastToken(env, row.enroll_id); + return { token: cached.token, installationId: row.installation_id, expiresAt: cached.expiresAt }; + } const minted = await createOrbInstallationToken(env, row.installation_id); - await env.DB.prepare("UPDATE orb_enrollments SET last_token_at = CURRENT_TIMESTAMP WHERE enroll_id = ?").bind(row.enroll_id).run(); + await cacheOrbToken(env, row.enroll_id, minted); + await touchLastToken(env, row.enroll_id); return { token: minted.token, installationId: row.installation_id, expiresAt: minted.expiresAt }; } + +async function touchLastToken(env: Env, enrollId: string): Promise { + await env.DB.prepare("UPDATE orb_enrollments SET last_token_at = CURRENT_TIMESTAMP WHERE enroll_id = ?").bind(enrollId).run(); +} + +/** Decrypt + return the cached installation token when present and still safely before expiry; null (→ re-mint) on + * no key, no cache, an expired/unparseable entry, or any decrypt failure (e.g. a rotated encryption key). */ +async function readCachedOrbToken(env: Env, cachedJson: string | null): Promise<{ token: string; expiresAt: string } | null> { + if (!env.TOKEN_ENCRYPTION_SECRET || !cachedJson) return null; + try { + const entry = JSON.parse(cachedJson) as { ciphertext: string; iv: string; salt: string | null; expiresAt: string }; + if (!(Date.parse(entry.expiresAt) - Date.now() >= ORB_TOKEN_CACHE_MIN_REMAINING_MS)) return null; + const token = await decryptSecret(entry.ciphertext, entry.iv, env.TOKEN_ENCRYPTION_SECRET, entry.salt); + return { token, expiresAt: entry.expiresAt }; + } catch { + return null; + } +} + +/** Cache the freshly-minted token (encrypted) on the enrollment row. Best-effort + fail-safe: a cache-write error + * must never fail a valid token exchange — the next call simply re-mints. No-op without an encryption key. */ +async function cacheOrbToken(env: Env, enrollId: string, minted: { token: string; expiresAt: string }): Promise { + if (!env.TOKEN_ENCRYPTION_SECRET) return; + try { + const enc = await encryptSecret(minted.token, env.TOKEN_ENCRYPTION_SECRET); + const json = JSON.stringify({ ciphertext: enc.ciphertext, iv: enc.iv, salt: enc.salt, expiresAt: minted.expiresAt }); + await env.DB.prepare("UPDATE orb_enrollments SET cached_token_json = ? WHERE enroll_id = ?").bind(json, enrollId).run(); + } catch (error) { + console.warn(JSON.stringify({ level: "warn", event: "orb_token_cache_write_failed", enrollId, message: String(error).slice(0, 120) })); + } +} diff --git a/test/integration/orb-broker.test.ts b/test/integration/orb-broker.test.ts index 33128be800..5bb47ee1e8 100644 --- a/test/integration/orb-broker.test.ts +++ b/test/integration/orb-broker.test.ts @@ -68,6 +68,65 @@ describe("brokerOrbToken", () => { await db(e).prepare("UPDATE orb_github_installations SET suspended_at=CURRENT_TIMESTAMP WHERE installation_id=302").run(); expect(await brokerOrbToken(e, secret)).toEqual({ error: "installation_not_eligible" }); }); + + it("caches the minted token (encrypted) and serves it WITHOUT re-minting on the next exchange (#12)", async () => { + const e = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "test-encryption-key-material-0001" }); + await seedInstall(e, 310, { registered: 1 }); + const { secret } = (await issueOrbEnrollment(e, 310)) as { secret: string }; + let mints = 0; + vi.stubGlobal("fetch", async () => { + mints += 1; + return Response.json({ token: `ghs_${mints}`, expires_at: new Date(Date.now() + 60 * 60_000).toISOString() }); + }); + const first = await brokerOrbToken(e, secret); + const second = await brokerOrbToken(e, secret); + expect(first).toMatchObject({ token: "ghs_1", installationId: 310 }); + expect(second).toMatchObject({ token: "ghs_1" }); // served from the cache, NOT re-minted + expect(mints).toBe(1); // GitHub's token endpoint was hit ONCE across two exchanges (no throttling) + const cached = (await db(e).prepare("SELECT cached_token_json FROM orb_enrollments WHERE installation_id=310").first<{ cached_token_json: string }>())?.cached_token_json ?? ""; + expect(cached).not.toContain("ghs_1"); // stored encrypted, never plaintext + expect(cached).toContain("ciphertext"); + }); + + it("re-mints when the cached token is within the expiry margin (never serves a near-expired token)", async () => { + const e = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "test-encryption-key-material-0001" }); + await seedInstall(e, 311, { registered: 1 }); + const { secret } = (await issueOrbEnrollment(e, 311)) as { secret: string }; + let mints = 0; + vi.stubGlobal("fetch", async () => { + mints += 1; + return Response.json({ token: `ghs_${mints}`, expires_at: new Date(Date.now() + 60 * 60_000).toISOString() }); + }); + // Seed a cache entry only ~5m from expiry (inside the 10m re-mint margin) — read returns before decrypting. + await db(e).prepare("UPDATE orb_enrollments SET cached_token_json = ? WHERE installation_id=311").bind(JSON.stringify({ ciphertext: "x", iv: "y", salt: null, expiresAt: new Date(Date.now() + 5 * 60_000).toISOString() })).run(); + expect(await brokerOrbToken(e, secret)).toMatchObject({ token: "ghs_1" }); // a fresh mint, not the near-expired entry + expect(mints).toBe(1); + }); + + it("re-mints when the cached entry is unparseable (JSON/decrypt failure falls through)", async () => { + const e = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "test-encryption-key-material-0001" }); + await seedInstall(e, 312, { registered: 1 }); + const { secret } = (await issueOrbEnrollment(e, 312)) as { secret: string }; + await db(e).prepare("UPDATE orb_enrollments SET cached_token_json = 'not-json' WHERE installation_id=312").run(); + vi.stubGlobal("fetch", async () => Response.json({ token: "ghs_fresh", expires_at: new Date(Date.now() + 60 * 60_000).toISOString() })); + expect(await brokerOrbToken(e, secret)).toMatchObject({ token: "ghs_fresh" }); // malformed cache ignored, re-minted + }); + + it("swallows a cache-write failure — a valid token exchange never fails on a cache hiccup", async () => { + const e = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "test-encryption-key-material-0001" }); + await seedInstall(e, 313, { registered: 1 }); + const { secret } = (await issueOrbEnrollment(e, 313)) as { secret: string }; + vi.stubGlobal("fetch", async () => Response.json({ token: "ghs_ok", expires_at: new Date(Date.now() + 60 * 60_000).toISOString() })); + const real = e.DB; + (e as { DB: unknown }).DB = { + prepare: (sql: string) => + sql.includes("SET cached_token_json") ? { bind: () => ({ run: () => Promise.reject(new Error("cache write boom")) }) } : real.prepare(sql), + }; + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + expect(await brokerOrbToken(e, secret)).toMatchObject({ token: "ghs_ok" }); // mint succeeded despite the cache write failing + expect(warn.mock.calls.some(([l]) => String(l).includes("orb_token_cache_write_failed"))).toBe(true); + warn.mockRestore(); + }); }); describe("broker endpoints", () => {