diff --git a/github/README.md b/github/README.md index 6e4f0694..ab415150 100644 --- a/github/README.md +++ b/github/README.md @@ -20,11 +20,34 @@ The webhook handler receives events from GitHub and matches them against configu - `github.release.published` — Release published - And more (see `TRIGGER_LIST` tool) +## Repository-scoped tokens & synthetic refresh + +`MINT_REPO_TOKEN` mints a short-lived (~1h) GitHub App installation token scoped +to exactly one repository (least privilege), gated on the caller's own GitHub +entitlement. Alongside the `ghs_` token it issues a durable, revocable +**synthetic refresh token** (an MCP-issued repo grant — `ghr_.`, +NOT a GitHub refresh token) and returns its `tokenEndpoint` + `clientId`. + +Two unauthenticated OAuth-shaped endpoints redeem/revoke that grant using only +the GitHub App credentials (no user-to-server token at refresh time): + +- `POST /repo-grant/token` — `grant_type=refresh_token` → a fresh `ghs_` token + scoped to the same installation/repo/permissions. `400 invalid_grant` is + permanent (revoked/expired/unknown, or the App lost repo access); `503` is + transient (GitHub outage, rate limit, or server misconfig) and the grant is + kept. +- `POST /repo-grant/revoke` — RFC 7009 revocation (always `200`). + +Grants are stored in the `REPO_GRANTS` Cloudflare KV namespace (only the +SHA-256 of the secret is persisted; sliding 90-day TTL). + ## Architecture ``` Client → OAuth Proxy (this MCP) → api.githubcopilot.com/mcp/ GitHub Webhooks → /webhooks/github → Installation mapping → Trigger matching +MINT_REPO_TOKEN → mint ghs_ + issue grant (REPO_GRANTS KV) +POST /repo-grant/token|revoke → re-mint / revoke via GitHub App JWT ``` --- @@ -47,10 +70,13 @@ GITHUB_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= GITHUB_WEBHOOK_SECRET= # Required for webhook signature verification +PUBLIC_BASE_URL= # Optional; defaults to https://github-mcp.decocms.com ``` `GITHUB_PRIVATE_KEY` accepts raw PEM, a single-line env value with `\n` escapes, or base64-encoded PEM. +`PUBLIC_BASE_URL` is the origin used to build the absolute `tokenEndpoint` that `MINT_REPO_TOKEN` returns; it must point at this deployment. The synthetic refresh flow also needs the `REPO_GRANTS` KV namespace bound in `wrangler.toml` (create with `bunx wrangler kv namespace create REPO_GRANTS`). + ### Running locally ```bash diff --git a/github/server/.env.example b/github/server/.env.example index 578ab2ac..05506f35 100644 --- a/github/server/.env.example +++ b/github/server/.env.example @@ -8,5 +8,9 @@ GITHUB_CLIENT_SECRET= # Webhook signature verification (optional, recommended for production) GITHUB_WEBHOOK_SECRET= +# Public origin used to build the absolute refresh `tokenEndpoint` returned by +# MINT_REPO_TOKEN (optional; defaults to https://github-mcp.decocms.com) +PUBLIC_BASE_URL= + # Server port (optional, defaults to 8001) PORT=8001 diff --git a/github/server/constants.ts b/github/server/constants.ts new file mode 100644 index 00000000..d22ff3eb --- /dev/null +++ b/github/server/constants.ts @@ -0,0 +1,27 @@ +/** + * Shared constants for the GitHub MCP synthetic repo-grant refresh flow. + */ + +/** Public origin of this MCP (custom-domain route in wrangler.toml). Used to + * build the absolute `tokenEndpoint` returned by MINT_REPO_TOKEN. Overridable + * via the PUBLIC_BASE_URL env var. */ +export const DEFAULT_PUBLIC_BASE_URL = "https://github-mcp.decocms.com"; + +/** Sliding lifetime of a repo grant, in seconds (90 days). Each successful + * refresh extends expiry by this much; also used as the KV expirationTtl so + * orphaned grants self-expire. */ +export const GRANT_TTL_SECONDS = 90 * 24 * 60 * 60; + +/** Path of the synthetic OAuth refresh-token endpoint. Namespaced under + * /repo-grant/* (NOT /oauth/*) to avoid colliding with the deco runtime's own + * /oauth/start|callback|logout routes, which handle() intercepts before. */ +export const REPO_GRANT_TOKEN_PATH = "/repo-grant/token"; + +/** Path of the RFC 7009-style revoke endpoint. */ +export const REPO_GRANT_REVOKE_PATH = "/repo-grant/revoke"; + +/** KV key prefix for stored grants: `grant:`. */ +export const GRANT_KEY_PREFIX = "grant:"; + +/** Opaque refresh-token prefix: `ghr_.`. */ +export const REFRESH_TOKEN_PREFIX = "ghr_"; diff --git a/github/server/lib/installation-map.ts b/github/server/lib/installation-map.ts index 55c9aa2e..46eb51a0 100644 --- a/github/server/lib/installation-map.ts +++ b/github/server/lib/installation-map.ts @@ -8,7 +8,11 @@ interface KVNamespaceLike { get(key: string): Promise; - put(key: string, value: string): Promise; + put( + key: string, + value: string, + options?: { expirationTtl?: number }, + ): Promise; delete(key: string): Promise; list(options?: { prefix?: string; diff --git a/github/server/lib/repo-grant-store.test.ts b/github/server/lib/repo-grant-store.test.ts new file mode 100644 index 00000000..c685bc63 --- /dev/null +++ b/github/server/lib/repo-grant-store.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "bun:test"; +import { + formatRefreshToken, + generateGrantCredentials, + getRepoGrantStore, + hashSecret, + parseRefreshToken, + setRepoGrantKV, + verifySecret, + type RepoGrantMetadata, +} from "./repo-grant-store.ts"; + +describe("refresh token format", () => { + test("formats and round-trips a token", () => { + const token = formatRefreshToken("a".repeat(32), "secretvalue"); + expect(token).toBe(`ghr_${"a".repeat(32)}.secretvalue`); + const parsed = parseRefreshToken(token); + expect(parsed).toEqual({ grantId: "a".repeat(32), secret: "secretvalue" }); + }); + + test("rejects tokens without the ghr_ prefix", () => { + expect(parseRefreshToken(`${"a".repeat(32)}.secret`)).toBeNull(); + }); + + test("rejects tokens with a non-hex / wrong-length grantId", () => { + expect(parseRefreshToken("ghr_zzz.secret")).toBeNull(); + expect(parseRefreshToken("ghr_abc.secret")).toBeNull(); + }); + + test("rejects tokens missing the secret", () => { + expect(parseRefreshToken(`ghr_${"a".repeat(32)}.`)).toBeNull(); + expect(parseRefreshToken(`ghr_${"a".repeat(32)}`)).toBeNull(); + }); + + test("keeps a secret that itself contains base64url chars", () => { + const parsed = parseRefreshToken(`ghr_${"b".repeat(32)}.aB-_0.9`); + // split on the FIRST dot only + expect(parsed).toEqual({ grantId: "b".repeat(32), secret: "aB-_0.9" }); + }); +}); + +describe("secret hashing", () => { + test("hashSecret is deterministic and 64 hex chars", () => { + const h = hashSecret("hello"); + expect(h).toMatch(/^[0-9a-f]{64}$/); + expect(hashSecret("hello")).toBe(h); + }); + + test("verifySecret accepts the right secret and rejects others", () => { + const h = hashSecret("right"); + expect(verifySecret("right", h)).toBe(true); + expect(verifySecret("wrong", h)).toBe(false); + }); + + test("verifySecret is false on a malformed stored hash", () => { + expect(verifySecret("x", "not-hex")).toBe(false); + }); +}); + +describe("generateGrantCredentials", () => { + test("produces a parseable token whose secret matches its hash", () => { + const c = generateGrantCredentials(); + expect(c.grantId).toMatch(/^[0-9a-f]{32}$/); + const parsed = parseRefreshToken(c.refreshToken); + expect(parsed?.grantId).toBe(c.grantId); + expect(verifySecret(parsed!.secret, c.secretHash)).toBe(true); + }); + + test("produces unique grantIds across calls", () => { + expect(generateGrantCredentials().grantId).not.toBe( + generateGrantCredentials().grantId, + ); + }); +}); + +function fakeKV() { + const store = new Map(); + const ttls = new Map(); + return { + store, + ttls, + get: async (k: string) => store.get(k) ?? null, + put: async (k: string, v: string, o?: { expirationTtl?: number }) => { + store.set(k, v); + ttls.set(k, o?.expirationTtl); + }, + delete: async (k: string) => { + store.delete(k); + }, + }; +} + +const sampleMeta = ( + over: Partial = {}, +): RepoGrantMetadata => ({ + grantId: "f".repeat(32), + secretHash: hashSecret("s"), + installationId: 42, + repositoryId: 999, + owner: "acme", + repo: "web", + permissions: { contents: "write", metadata: "read" }, + createdAt: "2026-06-10T00:00:00.000Z", + expiresAt: "2026-09-08T00:00:00.000Z", + revokedAt: null, + createdByConnectionId: "conn-1", + clientId: "Iv1.abc", + ...over, +}); + +describe("Kv-backed grant store", () => { + test("create writes under grant: with the 90-day TTL", async () => { + const kv = fakeKV(); + const store = getRepoGrantStore(kv); + const meta = sampleMeta(); + await store.create(meta); + expect(kv.store.has(`grant:${meta.grantId}`)).toBe(true); + expect(kv.ttls.get(`grant:${meta.grantId}`)).toBe(90 * 24 * 60 * 60); + expect(await store.get(meta.grantId)).toEqual(meta); + }); + + test("get returns undefined for an unknown id and for corrupt JSON", async () => { + const kv = fakeKV(); + const store = getRepoGrantStore(kv); + expect(await store.get("0".repeat(32))).toBeUndefined(); + await kv.put("grant:bad", "{not-json"); + expect(await store.get("bad")).toBeUndefined(); + }); + + test("touch slides expiresAt and re-sets the TTL", async () => { + const kv = fakeKV(); + const store = getRepoGrantStore(kv); + const meta = sampleMeta(); + await store.create(meta); + await store.touch(meta.grantId, "2026-12-01T00:00:00.000Z"); + const updated = await store.get(meta.grantId); + expect(updated?.expiresAt).toBe("2026-12-01T00:00:00.000Z"); + expect(kv.ttls.get(`grant:${meta.grantId}`)).toBe(90 * 24 * 60 * 60); + }); + + test("touch on a missing grant is a no-op", async () => { + const kv = fakeKV(); + const store = getRepoGrantStore(kv); + await store.touch("0".repeat(32), "2026-12-01T00:00:00.000Z"); + expect(await store.get("0".repeat(32))).toBeUndefined(); + }); + + test("revoke deletes the grant", async () => { + const kv = fakeKV(); + const store = getRepoGrantStore(kv); + const meta = sampleMeta(); + await store.create(meta); + await store.revoke(meta.grantId); + expect(await store.get(meta.grantId)).toBeUndefined(); + }); +}); + +describe("store selection", () => { + test("falls back to a shared in-memory store when no KV is present", async () => { + setRepoGrantKV(undefined); + const store = getRepoGrantStore(); + const meta = sampleMeta({ grantId: "1".repeat(32) }); + await store.create(meta); + // Same module-level memory store is returned on the next call. + expect(await getRepoGrantStore().get(meta.grantId)).toEqual(meta); + }); + + test("uses the per-request KV singleton set via setRepoGrantKV", async () => { + const kv = fakeKV(); + setRepoGrantKV(kv); + const meta = sampleMeta({ grantId: "2".repeat(32) }); + await getRepoGrantStore().create(meta); + expect(kv.store.has(`grant:${meta.grantId}`)).toBe(true); + setRepoGrantKV(undefined); // reset for other tests + }); +}); diff --git a/github/server/lib/repo-grant-store.ts b/github/server/lib/repo-grant-store.ts new file mode 100644 index 00000000..7123726b --- /dev/null +++ b/github/server/lib/repo-grant-store.ts @@ -0,0 +1,179 @@ +/** + * Repo-grant storage + opaque refresh-token helpers. + * + * A synthetic refresh token is the stable opaque string `ghr_.`. + * Only the SHA-256 hash of is persisted; the plaintext is returned to + * the caller exactly once. Grants are keyed by in the REPO_GRANTS KV + * namespace and verified with a constant-time hash comparison. + */ + +import crypto from "node:crypto"; +import { + GRANT_KEY_PREFIX, + GRANT_TTL_SECONDS, + REFRESH_TOKEN_PREFIX, +} from "../constants.ts"; + +export interface RepoGrantMetadata { + grantId: string; + secretHash: string; + installationId: number; + repositoryId: number; + owner: string; + repo: string; + permissions: Record; + createdAt: string; + expiresAt: string | null; + revokedAt?: string | null; + createdByConnectionId?: string; + clientId: string; +} + +export interface NewGrantCredentials { + grantId: string; + secret: string; + secretHash: string; + refreshToken: string; +} + +/** Generate a fresh grant id + 256-bit secret, plus the secret's hash and the + * formatted opaque refresh token. */ +export function generateGrantCredentials(): NewGrantCredentials { + const grantId = crypto.randomBytes(16).toString("hex"); + const secret = crypto.randomBytes(32).toString("base64url"); + const secretHash = hashSecret(secret); + return { + grantId, + secret, + secretHash, + refreshToken: formatRefreshToken(grantId, secret), + }; +} + +/** SHA-256 of the secret, hex-encoded. */ +export function hashSecret(secret: string): string { + return crypto.createHash("sha256").update(secret).digest("hex"); +} + +/** Constant-time comparison of a presented secret against a stored hash. */ +export function verifySecret(secret: string, secretHash: string): boolean { + if (!/^[0-9a-f]{64}$/.test(secretHash)) return false; + const a = Buffer.from(hashSecret(secret), "hex"); + const b = Buffer.from(secretHash, "hex"); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +} + +/** Opaque refresh token: `ghr_.`. */ +export function formatRefreshToken(grantId: string, secret: string): string { + return `${REFRESH_TOKEN_PREFIX}${grantId}.${secret}`; +} + +/** Parse `ghr_.`; returns null if the shape is wrong. Splits + * on the FIRST dot, so a base64url secret containing dots is preserved. */ +export function parseRefreshToken( + token: string, +): { grantId: string; secret: string } | null { + if (!token.startsWith(REFRESH_TOKEN_PREFIX)) return null; + const body = token.slice(REFRESH_TOKEN_PREFIX.length); + const dot = body.indexOf("."); + if (dot <= 0 || dot === body.length - 1) return null; + const grantId = body.slice(0, dot); + const secret = body.slice(dot + 1); + if (!/^[0-9a-f]{32}$/.test(grantId)) return null; + if (secret.length === 0) return null; + return { grantId, secret }; +} + +// --- store interface + implementations --- + +interface KVNamespaceLike { + get(key: string): Promise; + put( + key: string, + value: string, + options?: { expirationTtl?: number }, + ): Promise; + delete(key: string): Promise; +} + +export interface RepoGrantStore { + create(meta: RepoGrantMetadata): Promise; + get(grantId: string): Promise; + /** Slide expiry forward and re-persist (resets the KV TTL). */ + touch(grantId: string, expiresAt: string): Promise; + /** Permanently remove a grant. */ + revoke(grantId: string): Promise; +} + +const keyOf = (grantId: string): string => `${GRANT_KEY_PREFIX}${grantId}`; + +class KvRepoGrantStore implements RepoGrantStore { + constructor(private kv: KVNamespaceLike) {} + + async create(meta: RepoGrantMetadata): Promise { + await this.kv.put(keyOf(meta.grantId), JSON.stringify(meta), { + expirationTtl: GRANT_TTL_SECONDS, + }); + } + + async get(grantId: string): Promise { + const raw = await this.kv.get(keyOf(grantId)); + if (!raw) return undefined; + try { + return JSON.parse(raw) as RepoGrantMetadata; + } catch { + return undefined; + } + } + + async touch(grantId: string, expiresAt: string): Promise { + const existing = await this.get(grantId); + if (!existing) return; + await this.kv.put( + keyOf(grantId), + JSON.stringify({ ...existing, expiresAt }), + { expirationTtl: GRANT_TTL_SECONDS }, + ); + } + + async revoke(grantId: string): Promise { + await this.kv.delete(keyOf(grantId)); + } +} + +class MemoryRepoGrantStore implements RepoGrantStore { + private map = new Map(); + async create(meta: RepoGrantMetadata): Promise { + this.map.set(meta.grantId, meta); + } + async get(grantId: string): Promise { + return this.map.get(grantId); + } + async touch(grantId: string, expiresAt: string): Promise { + const existing = this.map.get(grantId); + if (existing) this.map.set(grantId, { ...existing, expiresAt }); + } + async revoke(grantId: string): Promise { + this.map.delete(grantId); + } +} + +const memoryStore = new MemoryRepoGrantStore(); + +// Per-request KV binding, threaded from handle() the same way trigger-store +// does. The binding object is stable per isolate, so concurrent requests +// sharing it is safe. +let currentKV: KVNamespaceLike | undefined; + +export function setRepoGrantKV(kv: KVNamespaceLike | undefined): void { + currentKV = kv; +} + +/** Resolve a grant store. An explicit `kv` (e.g. from an HTTP handler that has + * `env`) wins; otherwise the per-request singleton; otherwise the dev memory + * store. */ +export function getRepoGrantStore(kv?: KVNamespaceLike): RepoGrantStore { + const ns = kv ?? currentKV; + return ns ? new KvRepoGrantStore(ns) : memoryStore; +} diff --git a/github/server/lib/repo-grant.test.ts b/github/server/lib/repo-grant.test.ts new file mode 100644 index 00000000..e60555f6 --- /dev/null +++ b/github/server/lib/repo-grant.test.ts @@ -0,0 +1,602 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + handleRepoGrantRevokeRequest, + handleRepoGrantTokenRequest, + issueRepoGrant, + mintRepoTokenWithGrant, + refreshRepoGrant, + revokeRepoGrant, +} from "./repo-grant.ts"; +import { + generateGrantCredentials, + getRepoGrantStore, + parseRefreshToken, + type RepoGrantMetadata, + verifySecret, +} from "./repo-grant-store.ts"; +import type { Env } from "../types/env.ts"; + +function fakeKV() { + const store = new Map(); + return { + store, + get: async (k: string) => store.get(k) ?? null, + put: async (k: string, v: string) => { + store.set(k, v); + }, + delete: async (k: string) => { + store.delete(k); + }, + }; +} + +describe("issueRepoGrant", () => { + test("creates a grant and returns refresh metadata", async () => { + const kv = fakeKV(); + const store = getRepoGrantStore(kv); + const now = Date.parse("2026-06-10T00:00:00.000Z"); + + const issued = await issueRepoGrant({ + store, + installationId: 42, + repositoryId: 999, + owner: "acme", + repo: "web", + permissions: { contents: "write", metadata: "read" }, + clientId: "Iv1.abc", + baseUrl: "https://github-mcp.decocms.com", + createdByConnectionId: "conn-1", + now, + }); + + expect(issued.tokenEndpoint).toBe( + "https://github-mcp.decocms.com/repo-grant/token", + ); + expect(issued.clientId).toBe("Iv1.abc"); + expect(issued.refreshTokenExpiresAt).toBe("2026-09-08T00:00:00.000Z"); + + // The returned token must resolve to a stored grant whose hash it matches. + const parsed = parseRefreshToken(issued.refreshToken); + expect(parsed).not.toBeNull(); + const stored = await store.get(parsed!.grantId); + expect(stored).toMatchObject({ + installationId: 42, + repositoryId: 999, + owner: "acme", + repo: "web", + permissions: { contents: "write", metadata: "read" }, + createdByConnectionId: "conn-1", + clientId: "Iv1.abc", + revokedAt: null, + }); + expect(verifySecret(parsed!.secret, stored!.secretHash)).toBe(true); + }); + + test("normalizes a baseUrl with a trailing slash (no double slash)", async () => { + const issued = await issueRepoGrant({ + store: getRepoGrantStore(fakeKV()), + installationId: 42, + repositoryId: 999, + owner: "acme", + repo: "web", + permissions: { contents: "write", metadata: "read" }, + clientId: "Iv1.abc", + baseUrl: "https://github-mcp.decocms.com/", + now: Date.parse("2026-06-10T00:00:00.000Z"), + }); + expect(issued.tokenEndpoint).toBe( + "https://github-mcp.decocms.com/repo-grant/token", + ); + }); +}); + +const realFetch = globalThis.fetch; +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +function setFetch(impl: (input: unknown, init?: unknown) => Promise) { + globalThis.fetch = impl as unknown as typeof globalThis.fetch; +} +const urlOf = (i: unknown) => + typeof i === "string" ? i : (i as { url: string }).url; + +// Restore the real fetch after every test so a monkeypatched mock can never +// leak into a later test in this file (which Tasks 7-8 also fetch-mock). +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("mintRepoTokenWithGrant", () => { + test("mints a token AND issues a grant with the full output shape", async () => { + setFetch(async (input) => { + const url = urlOf(input); + if (/\/user\/installations\/42\/repositories/.test(url)) { + return json({ + repositories: [{ id: 999, name: "web", owner: { login: "acme" } }], + }); + } + if (url.includes("/user/installations")) { + return json({ + installations: [{ id: 42, account: { login: "acme" } }], + }); + } + if (/\/app\/installations\/42\/access_tokens/.test(url)) { + return json( + { + token: "ghs_minted", + expires_at: "2026-06-10T01:00:00.000Z", + permissions: { + contents: "write", + metadata: "read", + pull_requests: "write", + }, + }, + 201, + ); + } + throw new Error(`unexpected url ${url}`); + }); + + const kv = fakeKV(); + const now = Date.parse("2026-06-10T00:00:00.000Z"); + const result = await mintRepoTokenWithGrant({ + callerToken: "ghu_x", + installationId: 42, + owner: "acme", + repo: "web", + clientId: "Iv1.abc", + baseUrl: "https://github-mcp.decocms.com", + store: getRepoGrantStore(kv), + createdByConnectionId: "conn-1", + jwt: "fake.jwt", + now, + }); + + expect(result.token).toBe("ghs_minted"); + expect(result.expiresAt).toBe("2026-06-10T01:00:00.000Z"); + expect(result.expiresIn).toBe(3600); + expect(result.tokenType).toBe("Bearer"); + expect(result.repository).toEqual({ id: 999, owner: "acme", name: "web" }); + expect(result.installationId).toBe(42); + expect(result.tokenEndpoint).toBe( + "https://github-mcp.decocms.com/repo-grant/token", + ); + expect(result.clientId).toBe("Iv1.abc"); + expect(result.refreshTokenExpiresAt).toBe("2026-09-08T00:00:00.000Z"); + expect(result.refreshToken.startsWith("ghr_")).toBe(true); + // The grant is persisted and redeemable. + const parsed = parseRefreshToken(result.refreshToken); + expect(await getRepoGrantStore(kv).get(parsed!.grantId)).toBeDefined(); + }); +}); + +async function seedGrant( + store: ReturnType, + over: Partial = {}, +) { + const creds = generateGrantCredentials(); + const meta: RepoGrantMetadata = { + grantId: creds.grantId, + secretHash: creds.secretHash, + installationId: 42, + repositoryId: 999, + owner: "acme", + repo: "web", + permissions: { contents: "write", metadata: "read" }, + createdAt: "2026-06-10T00:00:00.000Z", + expiresAt: "2026-09-08T00:00:00.000Z", + revokedAt: null, + clientId: "Iv1.abc", + ...over, + }; + await store.create(meta); + return { creds, meta }; +} + +describe("refreshRepoGrant — request validation", () => { + test("missing grant_type or refresh_token → 400 invalid_request", async () => { + const store = getRepoGrantStore(fakeKV()); + const r = await refreshRepoGrant({ + store, + grantType: null, + refreshToken: null, + clientId: null, + expectedClientId: "Iv1.abc", + }); + expect(r).toMatchObject({ + ok: false, + status: 400, + error: "invalid_request", + }); + }); + + test("unsupported grant_type → 400 unsupported_grant_type", async () => { + const store = getRepoGrantStore(fakeKV()); + const r = await refreshRepoGrant({ + store, + grantType: "authorization_code", + refreshToken: "ghr_x.y", + clientId: null, + expectedClientId: "Iv1.abc", + }); + expect(r).toMatchObject({ + ok: false, + status: 400, + error: "unsupported_grant_type", + }); + }); + + test("mismatched client_id → 400 invalid_client", async () => { + const store = getRepoGrantStore(fakeKV()); + const r = await refreshRepoGrant({ + store, + grantType: "refresh_token", + refreshToken: "ghr_x.y", + clientId: "WRONG", + expectedClientId: "Iv1.abc", + }); + expect(r).toMatchObject({ + ok: false, + status: 400, + error: "invalid_client", + }); + }); +}); + +describe("refreshRepoGrant — grant validity (permanent failures)", () => { + test("unparseable refresh_token → 400 invalid_grant", async () => { + const store = getRepoGrantStore(fakeKV()); + const r = await refreshRepoGrant({ + store, + grantType: "refresh_token", + refreshToken: "not-a-token", + clientId: "Iv1.abc", + expectedClientId: "Iv1.abc", + }); + expect(r).toMatchObject({ ok: false, status: 400, error: "invalid_grant" }); + }); + + test("unknown grant → 400 invalid_grant", async () => { + const store = getRepoGrantStore(fakeKV()); + const creds = generateGrantCredentials(); + const r = await refreshRepoGrant({ + store, + grantType: "refresh_token", + refreshToken: creds.refreshToken, + clientId: "Iv1.abc", + expectedClientId: "Iv1.abc", + }); + expect(r).toMatchObject({ ok: false, status: 400, error: "invalid_grant" }); + }); + + test("wrong secret → 400 invalid_grant", async () => { + const store = getRepoGrantStore(fakeKV()); + const { meta } = await seedGrant(store); + const r = await refreshRepoGrant({ + store, + grantType: "refresh_token", + refreshToken: `ghr_${meta.grantId}.WRONGSECRET`, + clientId: "Iv1.abc", + expectedClientId: "Iv1.abc", + }); + expect(r).toMatchObject({ ok: false, status: 400, error: "invalid_grant" }); + }); + + test("revoked grant → 400 invalid_grant", async () => { + const store = getRepoGrantStore(fakeKV()); + const { creds } = await seedGrant(store, { + revokedAt: "2026-06-11T00:00:00.000Z", + }); + const r = await refreshRepoGrant({ + store, + grantType: "refresh_token", + refreshToken: creds.refreshToken, + clientId: "Iv1.abc", + expectedClientId: "Iv1.abc", + }); + expect(r).toMatchObject({ ok: false, status: 400, error: "invalid_grant" }); + }); + + test("expired grant → 400 invalid_grant and the grant is deleted", async () => { + const kv = fakeKV(); + const store = getRepoGrantStore(kv); + const { creds, meta } = await seedGrant(store, { + expiresAt: "2026-06-09T00:00:00.000Z", + }); + const r = await refreshRepoGrant({ + store, + grantType: "refresh_token", + refreshToken: creds.refreshToken, + clientId: "Iv1.abc", + expectedClientId: "Iv1.abc", + now: Date.parse("2026-06-10T00:00:00.000Z"), + }); + expect(r).toMatchObject({ ok: false, status: 400, error: "invalid_grant" }); + expect(kv.store.has(`grant:${meta.grantId}`)).toBe(false); + }); +}); + +describe("refreshRepoGrant — minting", () => { + test("valid grant → 200 OAuth response, stable refresh_token, slid TTL", async () => { + const kv = fakeKV(); + const store = getRepoGrantStore(kv); + const now = Date.parse("2026-06-10T00:00:00.000Z"); + const { creds, meta } = await seedGrant(store); + + setFetch(async (input, init) => { + const url = urlOf(input); + if (/\/app\/installations\/42\/access_tokens/.test(url)) { + const body = JSON.parse((init as { body?: string }).body ?? "{}"); + expect(body.repository_ids).toEqual([999]); + expect(body.permissions).toEqual({ + contents: "write", + metadata: "read", + }); + return json( + { + token: "ghs_fresh", + expires_at: "2026-06-10T01:00:00.000Z", + permissions: body.permissions, + }, + 201, + ); + } + throw new Error(`unexpected url ${url}`); + }); + + const r = await refreshRepoGrant({ + store, + grantType: "refresh_token", + refreshToken: creds.refreshToken, + clientId: "Iv1.abc", + expectedClientId: "Iv1.abc", + now, + jwt: "fake.jwt", + }); + + expect(r.ok).toBe(true); + if (!r.ok) throw new Error("expected ok"); + expect(r.success).toEqual({ + access_token: "ghs_fresh", + token_type: "Bearer", + expires_in: 3600, + refresh_token: creds.refreshToken, + scope: "github-app-installation:42 repo:acme/web", + }); + // TTL slid forward 90 days from `now`. + const stored = await store.get(meta.grantId); + expect(stored?.expiresAt).toBe("2026-09-08T00:00:00.000Z"); + }); + + test("omitted client_id is allowed (public-client model)", async () => { + const store = getRepoGrantStore(fakeKV()); + const { creds } = await seedGrant(store); + setFetch(async () => + json( + { + token: "ghs_fresh", + expires_at: "2026-06-10T01:00:00.000Z", + permissions: { contents: "write", metadata: "read" }, + }, + 201, + ), + ); + const r = await refreshRepoGrant({ + store, + grantType: "refresh_token", + refreshToken: creds.refreshToken, + clientId: null, + expectedClientId: "Iv1.abc", + jwt: "fake.jwt", + }); + expect(r.ok).toBe(true); + }); + + test("a malformed GitHub expires_at yields expires_in 0 (not NaN)", async () => { + const store = getRepoGrantStore(fakeKV()); + const { creds } = await seedGrant(store); + setFetch(async () => + json( + { token: "ghs_fresh", expires_at: "not-a-date", permissions: {} }, + 201, + ), + ); + const r = await refreshRepoGrant({ + store, + grantType: "refresh_token", + refreshToken: creds.refreshToken, + clientId: "Iv1.abc", + expectedClientId: "Iv1.abc", + jwt: "fake.jwt", + }); + expect(r.ok).toBe(true); + if (!r.ok) throw new Error("expected ok"); + expect(r.success.expires_in).toBe(0); + }); + + test("GitHub 422 → 400 invalid_grant and grant deleted", async () => { + const kv = fakeKV(); + const store = getRepoGrantStore(kv); + const { creds, meta } = await seedGrant(store); + setFetch(async () => json({ message: "repo gone" }, 422)); + const r = await refreshRepoGrant({ + store, + grantType: "refresh_token", + refreshToken: creds.refreshToken, + clientId: "Iv1.abc", + expectedClientId: "Iv1.abc", + jwt: "fake.jwt", + }); + expect(r).toMatchObject({ ok: false, status: 400, error: "invalid_grant" }); + expect(kv.store.has(`grant:${meta.grantId}`)).toBe(false); + }); + + test("GitHub 404 (installation gone) → 400 invalid_grant and grant deleted", async () => { + const kv = fakeKV(); + const store = getRepoGrantStore(kv); + const { creds, meta } = await seedGrant(store); + setFetch(async () => json({ message: "not found" }, 404)); + const r = await refreshRepoGrant({ + store, + grantType: "refresh_token", + refreshToken: creds.refreshToken, + clientId: "Iv1.abc", + expectedClientId: "Iv1.abc", + jwt: "fake.jwt", + }); + expect(r).toMatchObject({ ok: false, status: 400, error: "invalid_grant" }); + expect(kv.store.has(`grant:${meta.grantId}`)).toBe(false); + }); + + test("GitHub 503 → 503 temporarily_unavailable and grant KEPT", async () => { + const kv = fakeKV(); + const store = getRepoGrantStore(kv); + const { creds, meta } = await seedGrant(store); + setFetch(async () => json({ message: "down" }, 503)); + const r = await refreshRepoGrant({ + store, + grantType: "refresh_token", + refreshToken: creds.refreshToken, + clientId: "Iv1.abc", + expectedClientId: "Iv1.abc", + jwt: "fake.jwt", + }); + expect(r).toMatchObject({ + ok: false, + status: 503, + error: "temporarily_unavailable", + }); + expect(kv.store.has(`grant:${meta.grantId}`)).toBe(true); + }); + + test("GitHub 401 (our App misconfig) → 503, NOT invalid_grant, grant KEPT", async () => { + const kv = fakeKV(); + const store = getRepoGrantStore(kv); + const { creds, meta } = await seedGrant(store); + setFetch(async () => json({ message: "bad jwt" }, 401)); + const r = await refreshRepoGrant({ + store, + grantType: "refresh_token", + refreshToken: creds.refreshToken, + clientId: "Iv1.abc", + expectedClientId: "Iv1.abc", + jwt: "fake.jwt", + }); + expect(r).toMatchObject({ + ok: false, + status: 503, + error: "temporarily_unavailable", + }); + expect(kv.store.has(`grant:${meta.grantId}`)).toBe(true); + }); +}); + +function formReq(path: string, params: Record): Request { + return new Request(`https://github-mcp.decocms.com${path}`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(params).toString(), + }); +} + +describe("revokeRepoGrant", () => { + test("revokes a known grant and returns 200", async () => { + const kv = fakeKV(); + const store = getRepoGrantStore(kv); + const { creds, meta } = await seedGrant(store); + const r = await revokeRepoGrant({ store, token: creds.refreshToken }); + expect(r.status).toBe(200); + expect(kv.store.has(`grant:${meta.grantId}`)).toBe(false); + }); + + test("returns 200 for an unknown / malformed / missing token", async () => { + const store = getRepoGrantStore(fakeKV()); + expect((await revokeRepoGrant({ store, token: null })).status).toBe(200); + expect((await revokeRepoGrant({ store, token: "garbage" })).status).toBe( + 200, + ); + }); +}); + +describe("HTTP adapters", () => { + test("token endpoint: invalid_request body + 400 + no-store header", async () => { + const env = { REPO_GRANTS: fakeKV() } as unknown as Env; + const res = await handleRepoGrantTokenRequest( + formReq("/repo-grant/token", { grant_type: "refresh_token" }), + env, + ); + expect(res.status).toBe(400); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + expect((await res.json()) as unknown).toEqual({ + error: "invalid_request", + error_description: "Both grant_type and refresh_token are required.", + }); + }); + + test("token endpoint: full success path through the adapter", async () => { + const kv = fakeKV(); + const store = getRepoGrantStore(kv); + const { creds } = await seedGrant(store); + const env = { + REPO_GRANTS: kv, + GITHUB_CLIENT_ID: "Iv1.abc", + } as unknown as Env; + + setFetch(async () => + json( + { + token: "ghs_fresh", + expires_at: "2026-06-10T01:00:00.000Z", + permissions: { contents: "write", metadata: "read" }, + }, + 201, + ), + ); + const res = await handleRepoGrantTokenRequest( + formReq("/repo-grant/token", { + grant_type: "refresh_token", + refresh_token: creds.refreshToken, + client_id: "Iv1.abc", + }), + env, + { jwt: "fake.jwt", now: Date.parse("2026-06-10T00:00:00.000Z") }, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + access_token: string; + token_type: string; + refresh_token: string; + }; + expect(body.access_token).toBe("ghs_fresh"); + expect(body.token_type).toBe("Bearer"); + // The stable refresh token is echoed back so the mesh can keep using it. + expect(body.refresh_token).toBe(creds.refreshToken); + }); + + test("token endpoint: rejects an over-sized body with 413", async () => { + const env = { REPO_GRANTS: fakeKV() } as unknown as Env; + const req = new Request("https://github-mcp.decocms.com/repo-grant/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Content-Length": "9000", + }, + body: `grant_type=refresh_token&refresh_token=${"a".repeat(8800)}`, + }); + const res = await handleRepoGrantTokenRequest(req, env); + expect(res.status).toBe(413); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + }); + + test("revoke endpoint: always 200", async () => { + const env = { REPO_GRANTS: fakeKV() } as unknown as Env; + const res = await handleRepoGrantRevokeRequest( + formReq("/repo-grant/revoke", { token: "garbage" }), + env, + ); + expect(res.status).toBe(200); + }); +}); diff --git a/github/server/lib/repo-grant.ts b/github/server/lib/repo-grant.ts new file mode 100644 index 00000000..1818dde3 --- /dev/null +++ b/github/server/lib/repo-grant.ts @@ -0,0 +1,454 @@ +/** + * Synthetic repo-grant OAuth flow. + * + * - issueRepoGrant: persist a durable grant from a freshly minted token and + * return the opaque refresh token + endpoint metadata (used by MINT_REPO_TOKEN). + * - mintRepoTokenWithGrant: the full MINT_REPO_TOKEN orchestration (Task 6). + * - refreshRepoGrant / revokeRepoGrant + HTTP adapters (Tasks 7-8). + * + * Refresh redeems a grant using ONLY GitHub App credentials — no user-to-server + * token. GitHub's own 422/404 means the grant is permanently invalid; outages + * and our own misconfiguration are transient and must NOT invalidate the grant. + */ + +import { + DEFAULT_PUBLIC_BASE_URL, + GRANT_TTL_SECONDS, + REPO_GRANT_TOKEN_PATH, +} from "../constants.ts"; +import { + createAppJWT, + GitHubAppApiError, + mintInstallationAccessToken, +} from "./github-app-auth.ts"; +import { + generateGrantCredentials, + getRepoGrantStore, + parseRefreshToken, + type RepoGrantMetadata, + type RepoGrantStore, + verifySecret, +} from "./repo-grant-store.ts"; +import { mintRepoScopedToken } from "./repo-token.ts"; +import type { Env } from "../types/env.ts"; + +export interface IssuedRepoGrant { + refreshToken: string; + tokenEndpoint: string; + clientId: string; + refreshTokenExpiresAt: string; +} + +/** Create and persist a grant, returning the opaque refresh token + endpoint + * metadata to embed in the MINT_REPO_TOKEN response. */ +export async function issueRepoGrant(opts: { + store: RepoGrantStore; + installationId: number; + repositoryId: number; + owner: string; + repo: string; + permissions: Record; + clientId: string; + baseUrl: string; + createdByConnectionId?: string; + now?: number; +}): Promise { + const now = opts.now ?? Date.now(); + const { grantId, secretHash, refreshToken } = generateGrantCredentials(); + const expiresAt = new Date(now + GRANT_TTL_SECONDS * 1000).toISOString(); + + const meta: RepoGrantMetadata = { + grantId, + secretHash, + installationId: opts.installationId, + repositoryId: opts.repositoryId, + owner: opts.owner, + repo: opts.repo, + permissions: opts.permissions, + createdAt: new Date(now).toISOString(), + expiresAt, + revokedAt: null, + createdByConnectionId: opts.createdByConnectionId, + clientId: opts.clientId, + }; + await opts.store.create(meta); + + return { + refreshToken, + // Strip a trailing slash so a misconfigured PUBLIC_BASE_URL can't yield a + // double-slash endpoint the mesh would fail to call. + tokenEndpoint: `${opts.baseUrl.replace(/\/+$/, "")}${REPO_GRANT_TOKEN_PATH}`, + clientId: opts.clientId, + refreshTokenExpiresAt: expiresAt, + }; +} + +/** Whole seconds until an ISO timestamp, floored at 0. Returns 0 (not NaN) for + * an unparseable value — `Math.max(0, NaN)` is NaN, which would serialize + * `expires_in` to null and break the OAuth numeric contract. */ +function secondsUntil(iso: string, now: number): number { + const secs = Math.floor((Date.parse(iso) - now) / 1000); + return Number.isFinite(secs) ? Math.max(0, secs) : 0; +} + +export interface MintRepoTokenWithGrantResult { + token: string; + expiresAt: string; + expiresIn: number; + tokenType: "Bearer"; + permissions: Record; + repository: { id: number; owner: string; name: string }; + installationId: number; + refreshToken: string; + tokenEndpoint: string; + clientId: string; + refreshTokenExpiresAt: string; +} + +/** Mint a short-lived repo-scoped token AND issue a durable refresh grant. + * This is the orchestration behind the MINT_REPO_TOKEN tool. */ +export async function mintRepoTokenWithGrant(opts: { + callerToken: string; + installationId: number; + owner: string; + repo: string; + permissions?: Record; + repositoryId?: number; + clientId: string; + baseUrl: string; + store: RepoGrantStore; + createdByConnectionId?: string; + jwt?: string; + now?: number; +}): Promise { + const now = opts.now ?? Date.now(); + + const minted = await mintRepoScopedToken({ + callerToken: opts.callerToken, + installationId: opts.installationId, + owner: opts.owner, + repo: opts.repo, + permissions: opts.permissions, + repositoryId: opts.repositoryId, + jwt: opts.jwt, + }); + + const issued = await issueRepoGrant({ + store: opts.store, + installationId: minted.installationId, + repositoryId: minted.repositoryId, + owner: minted.repository.owner, + repo: minted.repository.name, + permissions: minted.permissions, + clientId: opts.clientId, + baseUrl: opts.baseUrl, + createdByConnectionId: opts.createdByConnectionId, + now, + }); + + const expiresIn = secondsUntil(minted.expiresAt, now); + + return { + token: minted.token, + expiresAt: minted.expiresAt, + expiresIn, + tokenType: "Bearer", + permissions: minted.permissions, + repository: { + id: minted.repositoryId, + owner: minted.repository.owner, + name: minted.repository.name, + }, + installationId: minted.installationId, + refreshToken: issued.refreshToken, + tokenEndpoint: issued.tokenEndpoint, + clientId: issued.clientId, + refreshTokenExpiresAt: issued.refreshTokenExpiresAt, + }; +} + +export interface OAuthTokenSuccess { + access_token: string; + token_type: "Bearer"; + expires_in: number; + refresh_token: string; + scope: string; +} + +export type RefreshResult = + | { ok: true; success: OAuthTokenSuccess; newExpiresAt: string } + | { ok: false; status: number; error: string; error_description: string }; + +const INVALID_GRANT_MESSAGE = + "Repo grant is expired, revoked, unknown, or no longer valid."; + +function oauthError( + status: number, + error: string, + error_description: string, +): RefreshResult { + return { ok: false, status, error, error_description }; +} + +/** Map a mint failure to a transient-vs-permanent OAuth error. Permanent + * (422/404) means the grant can never work again; everything else (outage, + * rate limit, our own bad App key → 401/403) is transient and must NOT cause + * the mesh to discard a valid grant. */ +function mapRefreshMintError(err: unknown): RefreshResult { + if ( + err instanceof GitHubAppApiError && + (err.status === 422 || err.status === 404) + ) { + return oauthError(400, "invalid_grant", INVALID_GRANT_MESSAGE); + } + return oauthError( + 503, + "temporarily_unavailable", + "Token service is temporarily unavailable. Please retry.", + ); +} + +/** Redeem a synthetic refresh token for a fresh repo-scoped installation token. + * Uses ONLY GitHub App credentials — no user-to-server token. */ +export async function refreshRepoGrant(opts: { + store: RepoGrantStore; + grantType: string | null; + refreshToken: string | null; + clientId: string | null; + expectedClientId: string; + now?: number; + jwt?: string; +}): Promise { + const now = opts.now ?? Date.now(); + + // --- request validation (client errors; not grant invalidation) --- + if (!opts.grantType || !opts.refreshToken) { + return oauthError( + 400, + "invalid_request", + "Both grant_type and refresh_token are required.", + ); + } + if (opts.grantType !== "refresh_token") { + return oauthError( + 400, + "unsupported_grant_type", + `grant_type "${opts.grantType}" is not supported; use refresh_token.`, + ); + } + // Public-client model: the 256-bit grant secret is the real credential, so + // client_id is OPTIONAL. We reject only a client_id that is present AND wrong + // (a cheap consistency safeguard); an omitted client_id is allowed by design. + if ( + opts.clientId && + opts.expectedClientId && + opts.clientId !== opts.expectedClientId + ) { + return oauthError(400, "invalid_client", "Unknown client_id."); + } + + // --- grant lookup + constant-time secret verification (permanent) --- + const parsed = parseRefreshToken(opts.refreshToken); + if (!parsed) return oauthError(400, "invalid_grant", INVALID_GRANT_MESSAGE); + + let grant: RepoGrantMetadata | undefined; + try { + grant = await opts.store.get(parsed.grantId); + } catch { + return oauthError( + 503, + "temporarily_unavailable", + "Grant storage is temporarily unavailable. Please retry.", + ); + } + + if ( + !grant || + grant.revokedAt || + !verifySecret(parsed.secret, grant.secretHash) + ) { + return oauthError(400, "invalid_grant", INVALID_GRANT_MESSAGE); + } + if (grant.expiresAt && Date.parse(grant.expiresAt) <= now) { + try { + await opts.store.revoke(grant.grantId); + } catch { + // best-effort cleanup + } + return oauthError(400, "invalid_grant", INVALID_GRANT_MESSAGE); + } + + // --- re-mint --- + let jwt: string; + try { + jwt = opts.jwt ?? createAppJWT(); + } catch { + // App credentials misconfigured: our fault, not the grant's. Transient. + return oauthError( + 503, + "temporarily_unavailable", + "Token service is temporarily unavailable. Please retry.", + ); + } + + let minted; + try { + minted = await mintInstallationAccessToken( + grant.installationId, + { repository_ids: [grant.repositoryId], permissions: grant.permissions }, + jwt, + ); + } catch (err) { + const mapped = mapRefreshMintError(err); + // On a permanent (grant-invalidating) error, best-effort delete the grant. + if ( + !mapped.ok && + mapped.status === 400 && + mapped.error === "invalid_grant" + ) { + try { + await opts.store.revoke(grant.grantId); + } catch { + // best-effort + } + } + return mapped; + } + + // --- slide TTL and respond --- + const newExpiresAt = new Date(now + GRANT_TTL_SECONDS * 1000).toISOString(); + try { + await opts.store.touch(grant.grantId, newExpiresAt); + } catch { + // Non-fatal: the access token is already minted. + } + + const expiresIn = secondsUntil(minted.expires_at, now); + + return { + ok: true, + newExpiresAt, + success: { + access_token: minted.token, + token_type: "Bearer", + expires_in: expiresIn, + refresh_token: opts.refreshToken, + scope: `github-app-installation:${grant.installationId} repo:${grant.owner}/${grant.repo}`, + }, + }; +} + +/** RFC 7009-style revoke. Always 200 (even for unknown/malformed tokens) to + * avoid leaking token validity; only storage failure surfaces as 503. */ +export async function revokeRepoGrant(opts: { + store: RepoGrantStore; + token: string | null; +}): Promise<{ status: number; body?: { error: string } }> { + if (!opts.token) return { status: 200 }; + const parsed = parseRefreshToken(opts.token); + if (!parsed) return { status: 200 }; + try { + await opts.store.revoke(parsed.grantId); + } catch { + return { status: 503, body: { error: "temporarily_unavailable" } }; + } + return { status: 200 }; +} + +const NO_STORE: Record = { + "Cache-Control": "no-store", + Pragma: "no-cache", +}; +const JSON_NO_STORE: Record = { + ...NO_STORE, + "Content-Type": "application/json", +}; + +/** These endpoints are public/unauthenticated and only ever receive a few + * small form fields. Reject an over-sized body via Content-Length before + * buffering it, so a hostile caller can't amplify memory/CPU per request. */ +const MAX_FORM_BYTES = 8192; + +function bodyTooLarge(req: Request): boolean { + const len = Number(req.headers.get("content-length") ?? "0"); + return Number.isFinite(len) && len > MAX_FORM_BYTES; +} + +async function readForm(req: Request): Promise { + return new URLSearchParams(await req.text()); +} + +function clientIdOf(env: Env): string { + return env.GITHUB_CLIENT_ID || process.env.GITHUB_CLIENT_ID || ""; +} + +function baseUrlOf(env: Env): string { + return ( + env.PUBLIC_BASE_URL || + process.env.PUBLIC_BASE_URL || + DEFAULT_PUBLIC_BASE_URL + ); +} + +/** Re-export so MINT_REPO_TOKEN can resolve the public base URL the same way. */ +export { baseUrlOf as repoGrantBaseUrl, clientIdOf as repoGrantClientId }; + +/** POST /repo-grant/token — OAuth refresh_token grant. */ +export async function handleRepoGrantTokenRequest( + req: Request, + env: Env, + deps: { jwt?: string; now?: number } = {}, +): Promise { + if (bodyTooLarge(req)) { + return new Response( + JSON.stringify({ + error: "invalid_request", + error_description: "Request body too large.", + }), + { status: 413, headers: JSON_NO_STORE }, + ); + } + const form = await readForm(req); + const result = await refreshRepoGrant({ + store: getRepoGrantStore(env.REPO_GRANTS), + grantType: form.get("grant_type"), + refreshToken: form.get("refresh_token"), + clientId: form.get("client_id"), + expectedClientId: clientIdOf(env), + jwt: deps.jwt, + now: deps.now, + }); + + if (result.ok) { + return new Response(JSON.stringify(result.success), { + status: 200, + headers: JSON_NO_STORE, + }); + } + return new Response( + JSON.stringify({ + error: result.error, + error_description: result.error_description, + }), + { status: result.status, headers: JSON_NO_STORE }, + ); +} + +/** POST /repo-grant/revoke — RFC 7009 token revocation. */ +export async function handleRepoGrantRevokeRequest( + req: Request, + env: Env, +): Promise { + if (bodyTooLarge(req)) { + return new Response(null, { status: 413, headers: NO_STORE }); + } + const form = await readForm(req); + const result = await revokeRepoGrant({ + store: getRepoGrantStore(env.REPO_GRANTS), + token: form.get("token"), + }); + return new Response(result.body ? JSON.stringify(result.body) : null, { + status: result.status, + headers: result.body ? JSON_NO_STORE : NO_STORE, + }); +} diff --git a/github/server/lib/repo-token.test.ts b/github/server/lib/repo-token.test.ts index d01770d9..dbfc0bdb 100644 --- a/github/server/lib/repo-token.test.ts +++ b/github/server/lib/repo-token.test.ts @@ -464,6 +464,7 @@ describe("mintRepoScopedToken", () => { }, repository: { owner: "acme", name: "web" }, installationId: 42, + repositoryId: 999, }); }); @@ -538,4 +539,78 @@ describe("mintRepoScopedToken", () => { ); expect(mintCalled).toBe(false); }); + + test("accepts a matching repositoryId and mints with it", async () => { + setFetch(async (input, init) => { + const url = urlOf(input); + if (/\/user\/installations\/42\/repositories/.test(url)) { + return json({ + repositories: [{ id: 999, name: "web", owner: { login: "acme" } }], + }); + } + if (url.includes("/user/installations")) { + return json({ + installations: [{ id: 42, account: { login: "acme" } }], + }); + } + if (/access_tokens/.test(url)) { + // The resolved id — NOT the caller-supplied param — must reach GitHub. + const body = JSON.parse((init as { body?: string }).body ?? "{}"); + expect(body.repository_ids).toEqual([999]); + return json( + { + token: "ghs_ok", + expires_at: "2026-06-05T12:00:00Z", + permissions: {}, + }, + 201, + ); + } + throw new Error(`unexpected url ${url}`); + }); + const result = await mintRepoScopedToken({ + callerToken: "ghu_x", + installationId: 42, + owner: "acme", + repo: "web", + repositoryId: 999, + jwt: "fake.jwt", + }); + expect(result.repositoryId).toBe(999); + }); + + test("rejects a repositoryId that does not match the resolved repo", async () => { + let mintCalled = false; + setFetch(async (input) => { + const url = urlOf(input); + if (/\/user\/installations\/42\/repositories/.test(url)) { + return json({ + repositories: [{ id: 999, name: "web", owner: { login: "acme" } }], + }); + } + if (url.includes("/user/installations")) { + return json({ + installations: [{ id: 42, account: { login: "acme" } }], + }); + } + if (/access_tokens/.test(url)) { + mintCalled = true; + return json({ token: "ghs_nope" }, 201); + } + throw new Error(`unexpected url ${url}`); + }); + await expectRejectCode( + () => + mintRepoScopedToken({ + callerToken: "ghu_x", + installationId: 42, + owner: "acme", + repo: "web", + repositoryId: 5, + jwt: "fake.jwt", + }), + "invalid_input", + ); + expect(mintCalled).toBe(false); + }); }); diff --git a/github/server/lib/repo-token.ts b/github/server/lib/repo-token.ts index f659ca67..e207b96e 100644 --- a/github/server/lib/repo-token.ts +++ b/github/server/lib/repo-token.ts @@ -333,6 +333,7 @@ export interface RepoTokenResult { permissions: Record; repository: { owner: string; name: string }; installationId: number; + repositoryId: number; } /** @@ -348,6 +349,7 @@ export async function mintRepoScopedToken(params: { owner: string; repo: string; permissions?: Record; + repositoryId?: number; jwt?: string; }): Promise { const { callerToken, installationId, owner, repo, permissions, jwt } = params; @@ -366,19 +368,37 @@ export async function mintRepoScopedToken(params: { // rejected without touching GitHub. const cappedPermissions = capPermissions(permissions); - // Security gate — mints nothing if the caller is not entitled. - const repositoryId = await authorizeAndResolveRepoId({ + // Security gate — mints nothing if the caller is not entitled. This resolves + // the authoritative numeric repo id from the caller's own installation view. + const resolvedRepositoryId = await authorizeAndResolveRepoId({ callerToken, installationId, owner, repo, }); + // If the caller asserted a repositoryId, it must match what they are entitled + // to. The resolved id stays authoritative (rename-proof) and is what we mint + // and store. + if ( + params.repositoryId !== undefined && + params.repositoryId !== resolvedRepositoryId + ) { + throw new RepoTokenError( + "invalid_input", + `Provided repositoryId ${params.repositoryId} does not match repository ` + + `"${owner}/${repo}".`, + ); + } + let minted; try { minted = await mintInstallationAccessToken( installationId, - { repository_ids: [repositoryId], permissions: cappedPermissions }, + { + repository_ids: [resolvedRepositoryId], + permissions: cappedPermissions, + }, jwt ?? createAppJWT(), ); } catch (err) { @@ -391,5 +411,6 @@ export async function mintRepoScopedToken(params: { permissions: minted.permissions, repository: { owner, name: repo }, installationId, + repositoryId: resolvedRepositoryId, }; } diff --git a/github/server/lib/trigger-store.ts b/github/server/lib/trigger-store.ts index 8868d90c..39465b40 100644 --- a/github/server/lib/trigger-store.ts +++ b/github/server/lib/trigger-store.ts @@ -3,7 +3,11 @@ import { z } from "zod"; interface KVNamespaceLike { get(key: string): Promise; - put(key: string, value: string): Promise; + put( + key: string, + value: string, + options?: { expirationTtl?: number }, + ): Promise; delete(key: string): Promise; } diff --git a/github/server/main.ts b/github/server/main.ts index f38efbc0..1b3075d0 100644 --- a/github/server/main.ts +++ b/github/server/main.ts @@ -20,6 +20,12 @@ import { getInstallationStore, } from "./lib/installation-map.ts"; import { handleProxiedRequest } from "./lib/mcp-proxy.ts"; +import { + handleRepoGrantRevokeRequest, + handleRepoGrantTokenRequest, +} from "./lib/repo-grant.ts"; +import { setRepoGrantKV } from "./lib/repo-grant-store.ts"; +import { REPO_GRANT_REVOKE_PATH, REPO_GRANT_TOKEN_PATH } from "./constants.ts"; import { setTriggerKV } from "./lib/trigger-store.ts"; import { getTools } from "./tools/index.ts"; import { type Env, StateSchema } from "./types/env.ts"; @@ -148,6 +154,10 @@ async function getRuntime(): Promise { return runtimePromise; } +// Per-isolate latch so the missing-REPO_GRANTS warning logs at most once per +// cold start instead of on every request. +let warnedMissingRepoGrants = false; + /** * Intercept webhook and MCP resource requests before they reach runtime.fetch. * The Deco runtime doesn't support resources natively, so we proxy them upstream. @@ -161,6 +171,18 @@ async function handle( // storage for this request. setTriggerKV(env.INSTALLATIONS); + // Make the REPO_GRANTS KV binding visible to the grant store's module-level + // singleton for this request (used by the MINT_REPO_TOKEN tool). + setRepoGrantKV(env.REPO_GRANTS); + if (!env.REPO_GRANTS && !warnedMissingRepoGrants) { + warnedMissingRepoGrants = true; + console.warn( + "[repo-grant] REPO_GRANTS KV binding is not configured; synthetic " + + "refresh grants will not persist and token refresh will fail. Add the " + + "REPO_GRANTS namespace in wrangler.toml.", + ); + } + const url = new URL(req.url); // GitHub webhook endpoint (unauthenticated — signature-verified instead) @@ -168,6 +190,16 @@ async function handle( return handleGitHubWebhook(req, env, ctx); } + // Synthetic repo-grant OAuth endpoints (unauthenticated — the opaque refresh + // token is the credential). Namespaced under /repo-grant/* to stay clear of + // the runtime's /oauth/* routes. + if (req.method === "POST" && url.pathname === REPO_GRANT_TOKEN_PATH) { + return handleRepoGrantTokenRequest(req, env); + } + if (req.method === "POST" && url.pathname === REPO_GRANT_REVOKE_PATH) { + return handleRepoGrantRevokeRequest(req, env); + } + // Proxy MCP resource requests to upstream const authHeader = req.headers.get("authorization"); const token = authHeader?.startsWith("Bearer ") diff --git a/github/server/tools/mint-repo-token.ts b/github/server/tools/mint-repo-token.ts index ebc09d11..a821dae8 100644 --- a/github/server/tools/mint-repo-token.ts +++ b/github/server/tools/mint-repo-token.ts @@ -1,21 +1,24 @@ /** * MINT_REPO_TOKEN — mint a GitHub App installation access token scoped to - * exactly one repository, with least-privilege permissions. + * exactly one repository, AND issue a durable synthetic refresh token (an + * MCP-issued repo grant — NOT a GitHub refresh token). * - * Used by Deco Studio to give an imported agent a token that can touch ONLY - * its own repo (baked into the sandbox clone URL). Tokens are short-lived - * (~1h) with no refresh token — Studio calls this again to refresh. + * The short-lived (~1h) `ghs_` token is unchanged. The refresh token is the + * opaque `ghr_.` string; redeeming it at `tokenEndpoint` + * re-mints a fresh `ghs_` token using only the GitHub App credentials. * - * `createPrivateTool` ensures the request is authenticated before executing; - * the heavy lifting (caller authorization, permission capping, minting) lives - * in `../lib/repo-token.ts`. Env (and thus the caller's GitHub token) is read - * from `runtimeContext.env` at execution time — on Workers there is no env at - * tool-build time. + * `createPrivateTool` ensures the caller is authenticated; caller authorization, + * permission capping, minting and grant issuance live in ../lib/*. */ import { createPrivateTool } from "@decocms/runtime/tools"; import { z } from "zod"; -import { mintRepoScopedToken } from "../lib/repo-token.ts"; +import { + mintRepoTokenWithGrant, + repoGrantBaseUrl, + repoGrantClientId, +} from "../lib/repo-grant.ts"; +import { getRepoGrantStore } from "../lib/repo-grant-store.ts"; import type { Env } from "../types/env.ts"; export function createMintRepoTokenTool() { @@ -26,8 +29,10 @@ export function createMintRepoTokenTool() { "with least-privilege permissions, using the GitHub App. The authenticated " + "caller must already be entitled to the installation and repository — the " + "tool verifies this against the caller's own GitHub context before minting. " + - "The token grants only repo-content / pull-request / issue access. Tokens " + - "are not cached and have no refresh token; call again to refresh.", + "The token grants only repo-content / pull-request / issue access. Also " + + "returns a durable refresh token (refreshToken) plus tokenEndpoint and " + + "clientId: POST grant_type=refresh_token to tokenEndpoint to mint a fresh " + + "token later without the caller's GitHub login.", inputSchema: z.object({ installationId: z .number() @@ -41,6 +46,15 @@ export function createMintRepoTokenTool() { repo: z .string() .describe('The repository NAME only, e.g. "web" (NOT "acme/web").'), + repositoryId: z + .number() + .int() + .optional() + .describe( + "Optional numeric repository id. When provided it is cross-checked " + + "against the repo the caller is entitled to; the resolved id is " + + "authoritative (rename-proof).", + ), permissions: z .record(z.string(), z.string()) .optional() @@ -58,25 +72,52 @@ export function createMintRepoTokenTool() { expiresAt: z .string() .describe("ISO8601 expiry (~1h from now; issued by GitHub)."), + expiresIn: z + .number() + .optional() + .describe("Seconds until the access token expires (usually <= 3600)."), + tokenType: z.literal("Bearer").optional(), permissions: z .record(z.string(), z.string()) .describe("The permissions actually granted, echoed from GitHub."), repository: z.object({ + id: z.number().optional(), owner: z.string(), name: z.string(), }), installationId: z.number(), + refreshToken: z + .string() + .describe( + "Opaque MCP-issued repo grant (ghr_...). NOT a GitHub token.", + ), + tokenEndpoint: z + .string() + .describe("Absolute HTTPS endpoint accepting a refresh_token grant."), + clientId: z + .string() + .describe("Stable client id expected by tokenEndpoint."), + refreshTokenExpiresAt: z + .string() + .nullable() + .optional() + .describe("ISO8601 expiry of the refresh grant (sliding 90 days)."), }), execute: async ({ context, runtimeContext }) => { const env = runtimeContext.env as unknown as Env; const callerToken = env.MESH_REQUEST_CONTEXT?.authorization ?? ""; - return await mintRepoScopedToken({ + return await mintRepoTokenWithGrant({ callerToken, installationId: context.installationId, owner: context.owner, repo: context.repo, + repositoryId: context.repositoryId, permissions: context.permissions, + clientId: repoGrantClientId(env), + baseUrl: repoGrantBaseUrl(env), + store: getRepoGrantStore(), + createdByConnectionId: env.MESH_REQUEST_CONTEXT?.connectionId, }); }, }); diff --git a/github/server/types/env.ts b/github/server/types/env.ts index b33443e2..43bf4571 100644 --- a/github/server/types/env.ts +++ b/github/server/types/env.ts @@ -14,7 +14,11 @@ export const StateSchema = z.object({}); interface KVNamespace { get(key: string): Promise; - put(key: string, value: string): Promise; + put( + key: string, + value: string, + options?: { expirationTtl?: number }, + ): Promise; delete(key: string): Promise; list(options?: { prefix?: string; cursor?: string }): Promise<{ keys: Array<{ name: string }>; @@ -34,11 +38,13 @@ interface KVNamespace { */ export type Env = DefaultEnv & { INSTALLATIONS?: KVNamespace; + REPO_GRANTS?: KVNamespace; GITHUB_APP_ID?: string; GITHUB_PRIVATE_KEY?: string; GITHUB_CLIENT_ID?: string; GITHUB_CLIENT_SECRET?: string; GITHUB_WEBHOOK_SECRET?: string; + PUBLIC_BASE_URL?: string; }; export type { Registry }; diff --git a/github/wrangler.toml b/github/wrangler.toml index 3f4688e7..c8b6c041 100644 --- a/github/wrangler.toml +++ b/github/wrangler.toml @@ -23,3 +23,12 @@ invocation_logs = true [[kv_namespaces]] binding = "INSTALLATIONS" id = "c81656fe0e4347d39205c0f2103ca5c9" + +# Durable storage for synthetic repo-grant refresh tokens (prefix `grant:`). +# Each grant stores hashed-at-rest metadata with a sliding 90-day TTL. +# +# Create with: bunx wrangler kv namespace create REPO_GRANTS +# Then replace the id below with the returned id BEFORE deploying. +[[kv_namespaces]] +binding = "REPO_GRANTS" +id = "REPLACE_WITH_REPO_GRANTS_KV_ID"