-
Notifications
You must be signed in to change notification settings - Fork 3
feat(github): repository-scoped user tokens for per-repo imports #446
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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= |
| 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}`; | ||
| } |
| 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 } { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Trim Prompt for AI agents |
||
| .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, | ||
| }; | ||
| }, | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2:
repository_idis 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