From 1cbdf7ed4926ee6e8702c13bc7ef4d2699804384 Mon Sep 17 00:00:00 2001 From: guitavano Date: Tue, 26 May 2026 20:22:41 -0300 Subject: [PATCH] feat(github): repository-scoped user tokens for per-repo imports Add GITHUB_SCOPE_TOKEN, repository_id on OAuth exchange/refresh, and Basic auth for GitHub scoped token API. Alias /api/mcp to /mcp for local wrangler. Co-authored-by: Cursor --- github/server/.dev.vars.example | 8 ++ github/server/lib/github-client.test.ts | 47 ++++++++++- github/server/lib/github-client.ts | 100 +++++++++++++++++++++--- github/server/lib/oauth-state.ts | 44 +++++++++++ github/server/main.ts | 18 ++++- github/server/tools/index.ts | 7 +- github/server/tools/scope-token.ts | 68 ++++++++++++++++ 7 files changed, 279 insertions(+), 13 deletions(-) create mode 100644 github/server/.dev.vars.example create mode 100644 github/server/lib/oauth-state.ts create mode 100644 github/server/tools/scope-token.ts diff --git a/github/server/.dev.vars.example b/github/server/.dev.vars.example new file mode 100644 index 00000000..c5efd936 --- /dev/null +++ b/github/server/.dev.vars.example @@ -0,0 +1,8 @@ +# Copy to server/.dev.vars for `bun run dev` (wrangler dev) +# See server/.env.example for field descriptions. + +GITHUB_APP_ID= +GITHUB_PRIVATE_KEY= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_WEBHOOK_SECRET= diff --git a/github/server/lib/github-client.test.ts b/github/server/lib/github-client.test.ts index 9d97b060..87f1c1b9 100644 --- a/github/server/lib/github-client.test.ts +++ b/github/server/lib/github-client.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { OAuthInvalidGrantError } from "@decocms/runtime"; -import { refreshAccessToken } from "./github-client.ts"; +import { + refreshAccessToken, + scopeUserAccessTokenToRepository, +} from "./github-client.ts"; const realFetch = globalThis.fetch; @@ -149,3 +152,45 @@ describe("refreshAccessToken", () => { expect(result.scope).toBe("repo"); }); }); + +describe("scopeUserAccessTokenToRepository", () => { + beforeEach(() => { + globalThis.fetch = realFetch; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + test("uses GitHub App basic auth for /token/scoped", async () => { + let authHeader: string | null = null; + let requestBody: Record | null = null; + + mockFetch(async (_input, init) => { + const headers = new Headers(init?.headers); + authHeader = headers.get("Authorization"); + requestBody = JSON.parse(String(init?.body)) as Record; + return new Response( + JSON.stringify({ token: "scoped-token", expires_in: 28800 }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + + const result = await scopeUserAccessTokenToRepository( + "ghu_user_token", + "Iv1.client", + "client_secret", + { repositoryId: 1016852701, target: "deco" }, + ); + + expect(result.token).toBe("scoped-token"); + expect(authHeader).toBe( + `Basic ${Buffer.from("Iv1.client:client_secret").toString("base64")}`, + ); + expect(requestBody).toEqual({ + access_token: "ghu_user_token", + target: "deco", + repository_ids: [1016852701], + }); + }); +}); diff --git a/github/server/lib/github-client.ts b/github/server/lib/github-client.ts index 036e173f..b286fbc8 100644 --- a/github/server/lib/github-client.ts +++ b/github/server/lib/github-client.ts @@ -20,6 +20,14 @@ interface RawGitHubTokenResponse extends GitHubTokenResponse { const GITHUB_TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token"; +function githubAppBasicAuthHeader( + clientId: string, + clientSecret: string, +): string { + const encoded = Buffer.from(`${clientId}:${clientSecret}`).toString("base64"); + return `Basic ${encoded}`; +} + async function postToGitHub( body: Record, ): Promise { @@ -63,11 +71,17 @@ async function postToGitHub( * `refresh_token`, `expires_in`, and `refresh_token_expires_in` alongside * the `access_token`; all fields are forwarded unchanged. */ +export interface GitHubOAuthOptions { + redirectUri?: string; + /** GitHub App user token: limit access to a single repository. */ + repositoryId?: number; +} + export function exchangeCodeForToken( code: string, clientId: string, clientSecret: string, - redirectUri?: string, + options?: GitHubOAuthOptions, ): Promise { const body: Record = { client_id: clientId, @@ -75,8 +89,12 @@ export function exchangeCodeForToken( code, }; - if (redirectUri) { - body.redirect_uri = redirectUri; + if (options?.redirectUri) { + body.redirect_uri = options.redirectUri; + } + + if (options?.repositoryId !== undefined) { + body.repository_id = String(options.repositoryId); } return postToGitHub(body); @@ -96,7 +114,19 @@ export async function refreshAccessToken( refreshToken: string, clientId: string, clientSecret: string, + options?: Pick, ): Promise { + const payload: Record = { + client_id: clientId, + client_secret: clientSecret, + grant_type: "refresh_token", + refresh_token: refreshToken, + }; + + if (options?.repositoryId !== undefined) { + payload.repository_id = String(options.repositoryId); + } + const response = await fetch(GITHUB_TOKEN_ENDPOINT, { method: "POST", headers: { @@ -104,12 +134,7 @@ export async function refreshAccessToken( "Content-Type": "application/json", "User-Agent": "deco-cms-github-mcp", }, - body: JSON.stringify({ - client_id: clientId, - client_secret: clientSecret, - grant_type: "refresh_token", - refresh_token: refreshToken, - }), + body: JSON.stringify(payload), }); // Per RFC 6749 §5.2 the canonical signal is the body's `error` field, not @@ -145,3 +170,60 @@ export async function refreshAccessToken( scope: data.scope, }; } + +export interface ScopedUserTokenResponse { + token: string; + expires_in?: number; +} + +/** + * Mint a repository-scoped user access token from an existing user token. + * @see https://docs.github.com/en/rest/apps/apps#create-a-scoped-access-token + */ +export async function scopeUserAccessTokenToRepository( + accessToken: string, + clientId: string, + clientSecret: string, + options: { + repositoryId: number; + /** GitHub user or organization login that owns the repository. */ + target: string; + }, +): Promise { + const response = await fetch( + `https://api.github.com/applications/${clientId}/token/scoped`, + { + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: githubAppBasicAuthHeader(clientId, clientSecret), + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "deco-cms-github-mcp", + }, + body: JSON.stringify({ + access_token: accessToken, + target: options.target, + repository_ids: [options.repositoryId], + }), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `GitHub scoped token failed: ${response.status} - ${errorText}`, + ); + } + + const data = (await response.json()) as { + token: string; + expires_in?: number; + }; + + if (!data.token) { + throw new Error("GitHub scoped token response missing token"); + } + + return { token: data.token, expires_in: data.expires_in }; +} diff --git a/github/server/lib/oauth-state.ts b/github/server/lib/oauth-state.ts new file mode 100644 index 00000000..23dd9cf5 --- /dev/null +++ b/github/server/lib/oauth-state.ts @@ -0,0 +1,44 @@ +/** + * Parse Mesh-encoded OAuth client `state` for GitHub-specific parameters. + * + * Studio sends `mesh:` as the MCP OAuth client state when it + * needs repository-scoped tokens (`repositoryId`). + */ + +export interface MeshOAuthClientState { + repositoryId?: number; +} + +export function parseMeshOAuthClientState( + clientState?: string | null, +): MeshOAuthClientState { + if (!clientState?.startsWith("mesh:")) return {}; + + try { + const encoded = clientState.slice("mesh:".length); + const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); + const parsed = JSON.parse(atob(padded)) as { repositoryId?: unknown }; + if ( + typeof parsed.repositoryId === "number" && + Number.isFinite(parsed.repositoryId) + ) { + return { repositoryId: parsed.repositoryId }; + } + } catch { + // Ignore malformed state — treat as unscoped OAuth. + } + + return {}; +} + +export function encodeMeshOAuthClientState( + state: MeshOAuthClientState, +): string { + const json = JSON.stringify(state); + const base64 = btoa(json) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + return `mesh:${base64}`; +} diff --git a/github/server/main.ts b/github/server/main.ts index f38efbc0..580f209e 100644 --- a/github/server/main.ts +++ b/github/server/main.ts @@ -19,6 +19,7 @@ import { ensureInstallationMappings, getInstallationStore, } from "./lib/installation-map.ts"; +import { parseMeshOAuthClientState } from "./lib/oauth-state.ts"; import { handleProxiedRequest } from "./lib/mcp-proxy.ts"; import { setTriggerKV } from "./lib/trigger-store.ts"; import { getTools } from "./tools/index.ts"; @@ -80,14 +81,15 @@ async function getRuntime(): Promise { return url.toString(); }, - exchangeCode: async ({ code, redirect_uri }) => { + exchangeCode: async ({ code, redirect_uri, state }) => { const { clientId, clientSecret } = getOAuthCredentials(); + const { repositoryId } = parseMeshOAuthClientState(state); const tokenResponse = await exchangeCodeForToken( code, clientId, clientSecret, - redirect_uri, + { redirectUri: redirect_uri, repositoryId }, ); return { @@ -152,6 +154,13 @@ async function getRuntime(): Promise { * Intercept webhook and MCP resource requests before they reach runtime.fetch. * The Deco runtime doesn't support resources natively, so we proxy them upstream. */ +function normalizeMcpPathname(pathname: string): string { + if (pathname === "/api/mcp" || pathname === "/api/mcp/") { + return "/mcp"; + } + return pathname; +} + async function handle( req: Request, env: Env, @@ -162,6 +171,11 @@ async function handle( setTriggerKV(env.INSTALLATIONS); const url = new URL(req.url); + const normalizedPathname = normalizeMcpPathname(url.pathname); + if (normalizedPathname !== url.pathname) { + url.pathname = normalizedPathname; + req = new Request(url, req); + } // GitHub webhook endpoint (unauthenticated — signature-verified instead) if (req.method === "POST" && url.pathname === "/webhooks/github") { diff --git a/github/server/tools/index.ts b/github/server/tools/index.ts index 86e9531f..44b22cee 100644 --- a/github/server/tools/index.ts +++ b/github/server/tools/index.ts @@ -8,6 +8,7 @@ import { buildUpstreamTools, getUpstreamToolDefs } from "../lib/mcp-proxy.ts"; import { triggers } from "../lib/trigger-store.ts"; +import { GITHUB_SCOPE_TOKEN } from "./scope-token.ts"; /** * Resolve the full tool set. Cached for the isolate's lifetime once @@ -15,5 +16,9 @@ import { triggers } from "../lib/trigger-store.ts"; */ export async function getTools() { const toolDefs = await getUpstreamToolDefs(); - return [...buildUpstreamTools(toolDefs), ...triggers.tools()]; + return [ + ...buildUpstreamTools(toolDefs), + GITHUB_SCOPE_TOKEN, + ...triggers.tools(), + ]; } diff --git a/github/server/tools/scope-token.ts b/github/server/tools/scope-token.ts new file mode 100644 index 00000000..a7640c2e --- /dev/null +++ b/github/server/tools/scope-token.ts @@ -0,0 +1,68 @@ +/** + * Scope the current user token to a single repository. + * + * Called from Studio after the user picks a repo during import. The + * connection keeps the original refresh token; subsequent refreshes pass + * repository_id from connection metadata via the mesh OAuth proxy. + */ + +import { createTool, type AppContext } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { scopeUserAccessTokenToRepository } from "../lib/github-client.ts"; +import type { Env } from "../types/env.ts"; + +function getOAuthCredentials(): { clientId: string; clientSecret: string } { + const clientId = process.env.GITHUB_CLIENT_ID || ""; + const clientSecret = process.env.GITHUB_CLIENT_SECRET || ""; + if (!clientId || !clientSecret) { + throw new Error( + "GitHub OAuth credentials not configured. " + + "Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET environment variables.", + ); + } + return { clientId, clientSecret }; +} + +export const GITHUB_SCOPE_TOKEN = createTool({ + id: "GITHUB_SCOPE_TOKEN", + description: + "Restrict the authenticated GitHub user token to a single repository.", + inputSchema: z.object({ + repository_id: z + .number() + .int() + .positive() + .describe("GitHub repository ID to scope the token to"), + target: z + .string() + .min(1) + .describe( + "GitHub user or organization login that owns the repository (owner login)", + ), + }), + execute: async ({ context }, ctx) => { + const env = (ctx as unknown as AppContext).env; + const accessToken = env.MESH_REQUEST_CONTEXT?.authorization; + if (!accessToken) { + throw new Error("GitHub authorization token not found"); + } + + const { clientId, clientSecret } = getOAuthCredentials(); + const scoped = await scopeUserAccessTokenToRepository( + accessToken, + clientId, + clientSecret, + { + repositoryId: context.repository_id, + target: context.target, + }, + ); + + return { + access_token: scoped.token, + token_type: "Bearer", + expires_in: scoped.expires_in, + repository_id: context.repository_id, + }; + }, +});