Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b6e2f5a
docs(github): spec for synthetic OAuth refresh flow on MINT_REPO_TOKEN
tlgimenes Jun 10, 2026
98dac73
docs(github): implementation plan for synthetic OAuth refresh flow
tlgimenes Jun 10, 2026
7aab1a0
feat(github): add repo-grant constants and REPO_GRANTS env binding
tlgimenes Jun 10, 2026
2d5ba64
refactor(github): sync KVNamespaceLike put signature with options arg
tlgimenes Jun 10, 2026
b2e7509
feat(github): add repo-grant token format + hashing helpers
tlgimenes Jun 10, 2026
cb67f1c
feat(github): add KV-backed repo-grant store with sliding TTL
tlgimenes Jun 10, 2026
ea3c411
feat(github): mintRepoScopedToken returns + cross-checks repositoryId
tlgimenes Jun 10, 2026
3d648ec
fix(github): sync MINT_REPO_TOKEN outputSchema with repositoryId + as…
tlgimenes Jun 10, 2026
20e4d93
feat(github): add issueRepoGrant to persist synthetic repo grants
tlgimenes Jun 10, 2026
d8f3fa5
fix(github): normalize trailing slash in repo-grant tokenEndpoint
tlgimenes Jun 10, 2026
1767f56
feat(github): add mintRepoTokenWithGrant orchestration
tlgimenes Jun 10, 2026
fb67990
test(github): restore fetch via afterEach in repo-grant tests
tlgimenes Jun 10, 2026
ec9a3ea
feat(github): add refreshRepoGrant with permanent/transient error map…
tlgimenes Jun 10, 2026
890b503
refactor(github): document public-client model + collapse mint-error …
tlgimenes Jun 10, 2026
1fbc08c
feat(github): add repo-grant revoke + OAuth HTTP adapters
tlgimenes Jun 10, 2026
a2bec2f
feat(github): guard repo-grant endpoints against oversized bodies (413)
tlgimenes Jun 10, 2026
c20b958
feat(github): MINT_REPO_TOKEN issues a synthetic refresh grant
tlgimenes Jun 10, 2026
153b26d
feat(github): route /repo-grant/{token,revoke} + REPO_GRANTS KV
tlgimenes Jun 10, 2026
e93e4bc
refactor(github): tidy repo-grant warn-flag placement + comment
tlgimenes Jun 10, 2026
75a2603
fix(github): floor expires_in at 0 on unparseable timestamp; document…
tlgimenes Jun 10, 2026
4770d2e
chore(github): drop internal spec/plan docs from PR
tlgimenes Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<grantId>.<secret>`,
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
```

---
Expand All @@ -47,10 +70,13 @@ GITHUB_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
GITHUB_CLIENT_ID=<github-app-client-id>
GITHUB_CLIENT_SECRET=<github-app-client-secret>
GITHUB_WEBHOOK_SECRET=<webhook-secret> # Required for webhook signature verification
PUBLIC_BASE_URL=<public-origin> # 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
Expand Down
4 changes: 4 additions & 0 deletions github/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 27 additions & 0 deletions github/server/constants.ts
Original file line number Diff line number Diff line change
@@ -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:<grantId>`. */
export const GRANT_KEY_PREFIX = "grant:";

/** Opaque refresh-token prefix: `ghr_<grantId>.<secret>`. */
export const REFRESH_TOKEN_PREFIX = "ghr_";
6 changes: 5 additions & 1 deletion github/server/lib/installation-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@

interface KVNamespaceLike {
get(key: string): Promise<string | null>;
put(key: string, value: string): Promise<void>;
put(
key: string,
value: string,
options?: { expirationTtl?: number },
): Promise<void>;
delete(key: string): Promise<void>;
list(options?: {
prefix?: string;
Expand Down
176 changes: 176 additions & 0 deletions github/server/lib/repo-grant-store.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
const ttls = new Map<string, number | undefined>();
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> = {},
): 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:<id> 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
});
});
Loading
Loading