Skip to content
Merged
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
24 changes: 24 additions & 0 deletions github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,34 @@ 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
EXTRA_ALLOWED_REDIRECT_HOSTS=<host>[,<host>] # 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 → <App> → 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
Expand Down
63 changes: 63 additions & 0 deletions github/server/constants.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
55 changes: 55 additions & 0 deletions github/server/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])];
}
7 changes: 5 additions & 2 deletions github/server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -89,7 +90,9 @@ async function getRuntime(): Promise<Runtime> {
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) => {
Expand Down
1 change: 1 addition & 0 deletions github/server/types/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export type Env = DefaultEnv<typeof StateSchema, Registry> & {
GITHUB_CLIENT_SECRET?: string;
GITHUB_WEBHOOK_SECRET?: string;
PUBLIC_BASE_URL?: string;
EXTRA_ALLOWED_REDIRECT_HOSTS?: string;
};

export type { Registry };
Loading