diff --git a/github/README.md b/github/README.md index 360f3020..58e71c5a 100644 --- a/github/README.md +++ b/github/README.md @@ -99,10 +99,34 @@ 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 +EXTRA_ALLOWED_REDIRECT_HOSTS=[,] # Optional; extra OAuth redirect_uri hosts ``` `GITHUB_PRIVATE_KEY` accepts raw PEM, a single-line env value with `\n` escapes, or base64-encoded PEM. +### Allowing a self-hosted Studio as the OAuth `redirect_uri` + +GitHub delivers the authorization `code` to the `redirect_uri`, so this server +only hands GitHub a callback whose host matches +`ALLOWED_REDIRECT_HOST_SUFFIXES` (`decocms.com` and any subdomain — see +`server/constants.ts`). A self-hosted Mesh/Studio on its own domain needs its +host added in **two** places: + +1. **This deployment** — `EXTRA_ALLOWED_REDIRECT_HOSTS`, a comma-separated list + of host suffixes (a full callback URL is accepted and reduced to its host; + single-label values like `com` are rejected, since as a *suffix* they would + open a whole TLD). Set it with + `bunx wrangler secret put EXTRA_ALLOWED_REDIRECT_HOSTS`; unusable entries are + logged and skipped rather than breaking OAuth. Hosts stay in deployment + config on purpose, so no install-specific hostname lands in this repo. +2. **The GitHub App** — add the exact callback URL under *Settings → Developer + settings → GitHub Apps → → Callback URL* (GitHub Apps allow several). + GitHub rejects the authorize request otherwise, whatever this server allows. + +Each added host is one more origin that can receive a user's authorization +`code`, so add only hosts you control or trust to the same degree as +`decocms.com`. + `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 diff --git a/github/server/constants.test.ts b/github/server/constants.test.ts new file mode 100644 index 00000000..e27b786b --- /dev/null +++ b/github/server/constants.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { + normalizeRedirectHostSuffix, + resolveAllowedRedirectHosts, +} from "./constants.ts"; + +describe("normalizeRedirectHostSuffix", () => { + test("keeps a bare host", () => { + expect(normalizeRedirectHostSuffix("studio.example.com")).toBe( + "studio.example.com", + ); + }); + + test("reduces a full callback URL to its host", () => { + expect( + normalizeRedirectHostSuffix("https://studio.example.com/oauth/callback"), + ).toBe("studio.example.com"); + }); + + test("lowercases and trims", () => { + expect(normalizeRedirectHostSuffix(" Studio.Example.COM ")).toBe( + "studio.example.com", + ); + }); + + test("rejects single-label values that would open a whole TLD", () => { + expect(normalizeRedirectHostSuffix("com")).toBeNull(); + expect(normalizeRedirectHostSuffix("localhost")).toBeNull(); + }); + + test("rejects unparseable entries", () => { + expect(normalizeRedirectHostSuffix("")).toBeNull(); + expect(normalizeRedirectHostSuffix("http://")).toBeNull(); + }); +}); + +describe("resolveAllowedRedirectHosts", () => { + test("defaults to the built-in suffix when unset", () => { + expect(resolveAllowedRedirectHosts(undefined)).toEqual(["decocms.com"]); + expect(resolveAllowedRedirectHosts("")).toEqual(["decocms.com"]); + }); + + test("appends normalized extras", () => { + expect( + resolveAllowedRedirectHosts( + "https://a.example.com/oauth/callback, b.example.org", + ), + ).toEqual(["decocms.com", "a.example.com", "b.example.org"]); + }); + + test("drops bad entries but keeps the good ones", () => { + expect(resolveAllowedRedirectHosts("com, a.example.com")).toEqual([ + "decocms.com", + "a.example.com", + ]); + }); + + test("dedupes, including against the built-in", () => { + expect( + resolveAllowedRedirectHosts("decocms.com, a.example.com, a.example.com"), + ).toEqual(["decocms.com", "a.example.com"]); + }); +}); diff --git a/github/server/constants.ts b/github/server/constants.ts index 84e1cef7..a23d3fb9 100644 --- a/github/server/constants.ts +++ b/github/server/constants.ts @@ -33,3 +33,58 @@ export const REFRESH_TOKEN_PREFIX = "ghr_"; * hijacking — pinning it to decocms.com (the canonical origin lives at * `github-mcp.decocms.com`) closes that hole. */ export const ALLOWED_REDIRECT_HOST_SUFFIXES = ["decocms.com"] as const; + +/** Env var carrying extra allowed `redirect_uri` host suffixes, comma- + * separated, for self-hosted Mesh/Studio deployments that live outside + * decocms.com. Deployment config rather than source so the hostnames of + * specific installs never land in this repo. */ +export const EXTRA_ALLOWED_REDIRECT_HOSTS_VAR = "EXTRA_ALLOWED_REDIRECT_HOSTS"; + +/** + * Normalize one entry of EXTRA_ALLOWED_REDIRECT_HOSTS into a bare host + * suffix, or `null` if it can't be trusted as one. + * + * Accepts a bare host (`studio.example.com`) or a full URL pasted whole + * (`https://studio.example.com/oauth/callback`) — only the host survives, + * since the runtime matches on host, not path. Single-label values (`com`, + * `localhost`) are rejected: as a *suffix* they would open every domain + * under that TLD. + */ +export function normalizeRedirectHostSuffix(entry: string): string | null { + const trimmed = entry.trim(); + if (!trimmed) return null; + + const withScheme = trimmed.includes("://") ? trimmed : `https://${trimmed}`; + let host: string; + try { + host = new URL(withScheme).hostname.toLowerCase(); + } catch { + return null; + } + + if (!host.includes(".")) return null; + return host; +} + +/** + * Full set of host suffixes accepted as the OAuth `redirect_uri`: the + * built-in decocms.com plus whatever this deployment adds via + * EXTRA_ALLOWED_REDIRECT_HOSTS. Invalid entries are dropped with a warning + * rather than throwing, so one typo can't take OAuth down for everyone. + */ +export function resolveAllowedRedirectHosts(raw: string | undefined): string[] { + const extras: string[] = []; + for (const entry of (raw ?? "").split(",")) { + if (!entry.trim()) continue; + const host = normalizeRedirectHostSuffix(entry); + if (!host) { + console.warn( + `[oauth] ignoring unusable ${EXTRA_ALLOWED_REDIRECT_HOSTS_VAR} entry: ${entry.trim()}`, + ); + continue; + } + extras.push(host); + } + + return [...new Set([...ALLOWED_REDIRECT_HOST_SUFFIXES, ...extras])]; +} diff --git a/github/server/main.ts b/github/server/main.ts index 0a642c39..73f60f2a 100644 --- a/github/server/main.ts +++ b/github/server/main.ts @@ -26,9 +26,10 @@ import { } from "./lib/repo-grant.ts"; import { setRepoGrantKV } from "./lib/repo-grant-store.ts"; import { - ALLOWED_REDIRECT_HOST_SUFFIXES, + EXTRA_ALLOWED_REDIRECT_HOSTS_VAR, REPO_GRANT_REVOKE_PATH, REPO_GRANT_TOKEN_PATH, + resolveAllowedRedirectHosts, } from "./constants.ts"; import { setTriggerKV } from "./lib/trigger-store.ts"; import { getTools } from "./tools/index.ts"; @@ -89,7 +90,9 @@ async function getRuntime(): Promise { oauth: { mode: "PKCE", authorizationServer: "https://github.com", - allowedRedirectHosts: [...ALLOWED_REDIRECT_HOST_SUFFIXES], + allowedRedirectHosts: resolveAllowedRedirectHosts( + process.env[EXTRA_ALLOWED_REDIRECT_HOSTS_VAR], + ), stateSecret: getStateSecret(), authorizationUrl: (callbackUrl) => { diff --git a/github/server/types/env.ts b/github/server/types/env.ts index 43bf4571..6e14145a 100644 --- a/github/server/types/env.ts +++ b/github/server/types/env.ts @@ -45,6 +45,7 @@ export type Env = DefaultEnv & { GITHUB_CLIENT_SECRET?: string; GITHUB_WEBHOOK_SECRET?: string; PUBLIC_BASE_URL?: string; + EXTRA_ALLOWED_REDIRECT_HOSTS?: string; }; export type { Registry };