Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions github/server/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -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=
47 changes: 46 additions & 1 deletion github/server/lib/github-client.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<string, unknown> | null = null;

mockFetch(async (_input, init) => {
const headers = new Headers(init?.headers);
authHeader = headers.get("Authorization");
requestBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
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],
});
});
});
100 changes: 91 additions & 9 deletions github/server/lib/github-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>,
): Promise<GitHubTokenResponse> {
Expand Down Expand Up @@ -63,20 +71,30 @@ 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<GitHubTokenResponse> {
const body: Record<string, string> = {
client_id: clientId,
client_secret: clientSecret,
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: repository_id is sent to /login/oauth/access_token, but this parameter is not documented for code exchange/refresh and may be ignored, so per-repo scoping may not actually apply during token mint/refresh.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At github/server/lib/github-client.ts, line 97:

<comment>`repository_id` is sent to `/login/oauth/access_token`, but this parameter is not documented for code exchange/refresh and may be ignored, so per-repo scoping may not actually apply during token mint/refresh.</comment>

<file context>
@@ -63,20 +71,30 @@ async function postToGitHub(
+  }
+
+  if (options?.repositoryId !== undefined) {
+    body.repository_id = String(options.repositoryId);
   }
 
</file context>

}

return postToGitHub(body);
Expand All @@ -96,20 +114,27 @@ export async function refreshAccessToken(
refreshToken: string,
clientId: string,
clientSecret: string,
options?: Pick<GitHubOAuthOptions, "repositoryId">,
): Promise<GitHubTokenResponse> {
const payload: Record<string, string> = {
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: {
Accept: "application/json",
"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
Expand Down Expand Up @@ -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<ScopedUserTokenResponse> {
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 };
}
44 changes: 44 additions & 0 deletions github/server/lib/oauth-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Parse Mesh-encoded OAuth client `state` for GitHub-specific parameters.
*
* Studio sends `mesh:<base64url(json)>` 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}`;
}
18 changes: 16 additions & 2 deletions github/server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -80,14 +81,15 @@ async function getRuntime(): Promise<Runtime> {
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 {
Expand Down Expand Up @@ -152,6 +154,13 @@ async function getRuntime(): Promise<Runtime> {
* 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,
Expand All @@ -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") {
Expand Down
7 changes: 6 additions & 1 deletion github/server/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@

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
* upstream discovery succeeds (caching happens inside getUpstreamToolDefs).
*/
export async function getTools() {
const toolDefs = await getUpstreamToolDefs();
return [...buildUpstreamTools(toolDefs), ...triggers.tools()];
return [
...buildUpstreamTools(toolDefs),
GITHUB_SCOPE_TOKEN,
...triggers.tools(),
];
}
68 changes: 68 additions & 0 deletions github/server/tools/scope-token.ts
Original file line number Diff line number Diff line change
@@ -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 } {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This adds duplicated OAuth credential-loading logic; extract to a shared helper to avoid drift between auth flows.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At github/server/tools/scope-token.ts, line 14:

<comment>This adds duplicated OAuth credential-loading logic; extract to a shared helper to avoid drift between auth flows.</comment>

<file context>
@@ -0,0 +1,68 @@
+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 || "";
</file context>

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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Trim target before .min(1) so whitespace-only owner logins are rejected at validation time.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At github/server/tools/scope-token.ts, line 38:

<comment>Trim `target` before `.min(1)` so whitespace-only owner logins are rejected at validation time.</comment>

<file context>
@@ -0,0 +1,68 @@
+      .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)",
</file context>

.describe(
"GitHub user or organization login that owns the repository (owner login)",
),
}),
execute: async ({ context }, ctx) => {
const env = (ctx as unknown as AppContext<Env>).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,
};
},
});
Loading