diff --git a/.env.example b/.env.example index b4c9258..4fc248c 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,9 @@ GITHUB_CLIENT_SECRET=CHANGE_ME # Comma-separated GitHub OAuth scopes. Conservative default; bump as needed. # `workflow` is required for creating/updating files under .github/workflows/*. -GITHUB_SCOPES=repo,read:org,read:user,read:project,workflow +# `user:email` is required to read verified emails for +# GITHUB_APPROVED_EMAIL_DOMAINS below — drop it only if you run no domain gate. +GITHUB_SCOPES=repo,read:org,read:user,user:email,read:project,workflow # --- Optional ---------------------------------------------------------------- @@ -32,7 +34,14 @@ BASE_URL=https://github.nlma.io # Authorization header before forwarding here. UPSTREAM_MCP_URL=http://127.0.0.1:3060 -# OPTIONAL: an allowlist of GitHub usernames (comma-separated). If set, only -# these users can complete the OAuth flow. Empty/unset = no allowlist. +# OPTIONAL: an allowlist of GitHub usernames (comma-separated). If set, these +# users can complete the OAuth flow. Empty/unset = no login gate. # Useful while in dev / before opening to the wider team. GITHUB_ALLOWED_USERS= + +# OPTIONAL: approved email domains (comma-separated). If set, a user is admitted +# when one of their VERIFIED GitHub emails is on one of these domains; +# subdomains count, so nlma.io also admits me@mail.nlma.io. +# OR'd with GITHUB_ALLOWED_USERS above — either list admits a user. +# Empty/unset = no domain gate. Requires the user:email scope. +GITHUB_APPROVED_EMAIL_DOMAINS= diff --git a/CLAUDE.md b/CLAUDE.md index 1089a79..6479e3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,14 +33,17 @@ This is an **OAuth 2.1 PKCE+DCR gateway** that sits in front of the official [`g 2. **`/authorize` hijack** ([src/oauth.ts:109](src/oauth.ts#L109)) — instead of rendering a UI, we persist the claude.ai PKCE challenge in `oauth_pending_state` keyed by a random `state_token`, then 302 the browser to `github.com/login/oauth/authorize` with that token as GitHub's `state`. 3. **GitHub callback** ([src/http.ts:40](src/http.ts#L40)) — `/oauth/github/callback` looks up the pending state, runs `completeGithubLogin` ([src/github-oauth.ts:129](src/github-oauth.ts#L129)) to exchange GitHub's code → token → user, upserts the encrypted token into `github_users`, mints **our** auth code, and 302s back to claude.ai's `redirect_uri` carrying the original `state`. 4. **`/token` exchange** ([src/oauth.ts:128](src/oauth.ts#L128)) — claude.ai gets an opaque UUID access token + refresh token; both rows in `oauth_access_tokens` / `oauth_refresh_tokens` point at a `github_user_id`. -5. **`/mcp` proxy** ([src/auth.ts](src/auth.ts) → [src/proxy.ts](src/proxy.ts)) — `bearerAuth` validates the opaque token, calls `getValidAccessTokenFor` ([src/github-oauth.ts:161](src/github-oauth.ts#L161)) to refresh the GitHub token if expiring, attaches `req.tenant`. `buildMcpProxy` then rewrites `Authorization: Bearer ` on the way to `127.0.0.1:3060`. +5. **`/mcp` proxy** ([src/auth.ts](src/auth.ts) → [src/proxy.ts](src/proxy.ts)) — `bearerAuth` validates the opaque token, calls `getValidAccessTokenFor` to refresh the GitHub token if expiring, attaches `req.tenant`. `buildMcpProxy` then rewrites `Authorization: Bearer ` on the way to `127.0.0.1:3060`. +6. **Offboarding** ([src/http.ts](src/http.ts)) — `GET /disconnect` → `POST /disconnect/start` stores a pending-state row with `purpose='disconnect'` and reuses the *same* GitHub redirect; `/oauth/github/callback` branches on `purpose` and calls `identifyGithubUser` (exchange + fetch, **persists nothing**) then `offboardGithubUser`. `POST /disconnect` is the bearer-authenticated equivalent. ### Key invariants - **Two distinct opaque-token namespaces**: tokens we issue to claude.ai (UUIDs in `oauth_access_tokens`) are completely separate from GitHub access tokens (in `github_users.access_ciphertext`). The bridge is `github_user_id`. - **All GitHub tokens are AES-256-GCM at rest** ([src/crypto.ts](src/crypto.ts)). The key is HKDF-derived from `API_KEY_HASH_SALT` with the info label `"github-mcp-auth oauth token encryption v1"` — do not change this label or all stored tokens decrypt-fail. The same env var (with different HKDF info — actually just SHA-256 with salt) drives the `tenant_id_hash` derivation in [src/auth.ts:26](src/auth.ts#L26). - **Migrations auto-run on startup** ([src/db.ts:19](src/db.ts#L19)) — every `.sql` in `migrations/` is re-executed in lexical order each boot. They must therefore be idempotent (`CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, additive `ALTER`s only). -- **`GITHUB_ALLOWED_USERS`** (CSV of GitHub logins, [src/github-oauth.ts:113](src/github-oauth.ts#L113)) is the deny-by-default switch. Empty = anyone with a GitHub account can authorize. +- **Two allowlists, OR'd** (`decideAccess` in [src/github-oauth.ts](src/github-oauth.ts)): `GITHUB_ALLOWED_USERS` (CSV of GitHub logins) and `GITHUB_APPROVED_EMAIL_DOMAINS` (CSV of email domains, subdomains included). Either one admits a user; both empty = anyone with a GitHub account. Only **verified** GitHub emails satisfy the domain gate, and when a domain gate is configured but `/user/emails` is unreadable (no `user:email` scope) it **fails closed** — don't "fix" that by defaulting to allow. +- **Offboarding is not domain-gated.** `/disconnect` requires only proof of the GitHub account, on purpose: gating a privilege *reduction* on the approved-domain allowlist would strand users whose domain was later removed. Don't add the gate there. +- **`identifyGithubUser` must never persist.** It backs the disconnect flow, whose whole job is deleting the row a normal login would write. - **Token TTLs**: claude.ai access tokens 1h, refresh tokens 30d, auth codes + pending-state rows 10m (constants at top of [src/oauth.ts](src/oauth.ts)). The GitHub access token's own expiry is independent and handled by `getValidAccessTokenFor` with a 30s skew. ### Pattern this codebase follows @@ -55,4 +58,4 @@ All required env vars are validated at startup in [src/index.ts](src/index.ts): - `API_KEY_HASH_SALT` — ≥32 chars random; drives both the token-encryption HKDF key and the tenant-id-hash salt. **Rotating this orphans every stored GitHub token**. - `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` — from the GitHub OAuth App. - `BASE_URL` — `https://github.nlma.io` in prod. Used for OAuth metadata issuer and the GitHub callback URL. -- `GITHUB_SCOPES` (default `repo,read:org,read:user,read:project,workflow`), `UPSTREAM_MCP_URL` (default `http://127.0.0.1:3060`), `GITHUB_ALLOWED_USERS` (optional CSV allowlist). +- `GITHUB_SCOPES` (default `repo,read:org,read:user,user:email,read:project,workflow` — `user:email` backs the domain allowlist), `UPSTREAM_MCP_URL` (default `http://127.0.0.1:3060`), `GITHUB_ALLOWED_USERS` (optional CSV of logins), `GITHUB_APPROVED_EMAIL_DOMAINS` (optional CSV of approved email domains). diff --git a/README.md b/README.md index c85d27c..8572f8a 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,21 @@ claude.ai ──OAuth 2.1 (PKCE, DCR)──> https://github.nlma.io (nginx) `github-mcp-server` itself is unchanged; it just sees a normal authenticated request with a per-user GitHub token. +## Turning a connector off + +Users offboard themselves — no admin and no SQL. Two entry points, both proving control of the GitHub account whose credentials get deleted: + +- **Browser** — `GET /disconnect` explains what will be deleted; the button posts to `/disconnect/start`, which sends the user through GitHub and back to `/oauth/github/callback`. Linked from the splash page. +- **API** — `POST /disconnect` with the bearer token the MCP client already holds: + + ```bash + curl -X POST https://github.nlma.io/disconnect -H 'Authorization: Bearer ' + ``` + +Either path deletes every opaque access/refresh token issued for that user, any in-flight auth code, their encrypted GitHub credentials, and their `tenants` row — then revokes the OAuth App grant on GitHub's side so the connector is genuinely off rather than merely forgotten locally (best-effort; the response reports whether GitHub confirmed). `audit_log` rows are kept: they identify the user only by a salted hash, and an audit trail the product can erase isn't one. + +Offboarding deliberately does **not** re-check `GITHUB_APPROVED_EMAIL_DOMAINS`. That gate decides who may *connect*; applying it to disconnection would mean dropping a domain from the allowlist strands its users with a connector they can no longer turn off. Any GitHub account that has connected here can disconnect itself — and only itself. + ## Why this pattern (and not JWT RS256 / Authentik) - **Opaque tokens, not JWT.** Matches the existing `mcpAuthRouter` pattern in `hospitable-mcp` and `skillbuilder-mcp` on this VPS. Simpler revocation, no JWKS to publish or rotate. @@ -40,9 +55,16 @@ Copy `.env.example` to `.env` and fill in. Required: | `GITHUB_CLIENT_ID` | From the GitHub OAuth App you register (see below). | | `GITHUB_CLIENT_SECRET` | Same. Treat as secret. `.env` should be `chmod 600`. | | `BASE_URL` | `https://github.nlma.io` | -| `GITHUB_SCOPES` | Default `repo,read:org,read:user,read:project,workflow`. `workflow` is required to create/update `.github/workflows/*` files. Bump if a tool needs more. | +| `GITHUB_SCOPES` | Default `repo,read:org,read:user,user:email,read:project,workflow`. `workflow` is required to create/update `.github/workflows/*` files; `user:email` is required to read verified emails for `GITHUB_APPROVED_EMAIL_DOMAINS`. Bump if a tool needs more. | | `UPSTREAM_MCP_URL` | Default `http://127.0.0.1:3060` — the github-mcp-server docker container. | -| `GITHUB_ALLOWED_USERS` | Optional CSV allowlist of GitHub logins. Empty = anyone with a GitHub account. | +| `GITHUB_ALLOWED_USERS` | Optional CSV allowlist of GitHub logins. Empty = no login gate. | +| `GITHUB_APPROVED_EMAIL_DOMAINS` | Optional CSV of approved email domains, e.g. `nlma.io,fidumcompany.com,fsbt.io`. A user is admitted when one of their **verified** GitHub emails is on an approved domain (subdomains count: `nlma.io` admits `me@mail.nlma.io`). Empty = no domain gate. | + +### How the two allowlists compose + +Either one admits a user — they're OR'd, not AND'd. `GITHUB_ALLOWED_USERS` is for named individuals (contractors, a break-glass account); `GITHUB_APPROVED_EMAIL_DOMAINS` is for "everyone at these companies". With both empty, anyone with a GitHub account can authorize, as before. + +Only **verified** GitHub emails count toward a domain match — an unverified address proves nothing, since anyone can type `someone@your-company.com` into their GitHub profile. If a domain gate is configured and the grant can't read email addresses at all (missing `user:email`), authorization **fails closed** and tells the user to re-authorize. ## Deployment @@ -112,23 +134,31 @@ curl -s https://github.nlma.io/health # Without a bearer, /mcp must 401 with a WWW-Authenticate header curl -i https://github.nlma.io/mcp -X POST -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' + +# Offboarding: the page renders, and the form target 302s to github.com +curl -s https://github.nlma.io/disconnect | grep -o '/disconnect/start' +curl -si -X POST https://github.nlma.io/disconnect/start | grep -i '^location' + +# Without a bearer, POST /disconnect must 401 (never a silent no-op) +curl -si -X POST https://github.nlma.io/disconnect | head -1 ``` ## Schema -See `migrations/001_initial.sql` + `migrations/002_oauth.sql`. Notable tables: +See `migrations/001_initial.sql`, `002_oauth.sql`, `003_offboarding.sql`. Notable tables: -- `github_users` — one row per GitHub user we've ever authenticated. Holds the encrypted access (and refresh) tokens. +- `github_users` — one row per GitHub user we've ever authenticated. Holds the encrypted access (and refresh) tokens, plus the verified `email` that admitted them. - `oauth_access_tokens` — opaque tokens issued to claude.ai. Points at `github_users.github_user_id`. -- `oauth_pending_state` — short-lived rows for in-flight GitHub OAuth dances; carries the claude.ai PKCE challenge across the GitHub redirect. +- `oauth_pending_state` — short-lived rows for in-flight GitHub OAuth dances; carries the claude.ai PKCE challenge across the GitHub redirect. `purpose` is `authorize` or `disconnect`; a `disconnect` row has no client/PKCE columns because there's no MCP client on the other side. ## Security notes - GitHub tokens are AES-256-GCM-encrypted at rest. Key is HKDF-derived from `API_KEY_HASH_SALT` with a distinct info label. - Tokens issued to claude.ai are opaque UUIDs; nothing about the GitHub user is recoverable from them without the database. - `tenants.tenant_id_hash` is a salted SHA-256 of the GitHub user id, so audit logs don't directly expose user ids. -- `GITHUB_ALLOWED_USERS` provides a deny-by-default mode while testing. -- Revoke a user: `DELETE FROM github_users WHERE github_login = '...'` cascades effectively (their opaque tokens won't resolve, and proxy requests will 401). +- `GITHUB_ALLOWED_USERS` and `GITHUB_APPROVED_EMAIL_DOMAINS` provide deny-by-default modes; only verified GitHub emails satisfy the domain gate, and a configured domain gate fails closed when emails can't be read. +- Users can revoke themselves — see [Turning a connector off](#turning-a-connector-off). +- Revoke a user as admin: `DELETE FROM github_users WHERE github_login = '...'` cascades effectively (their opaque tokens won't resolve, and proxy requests will 401). Unlike `/disconnect`, this leaves the OAuth App grant in place on GitHub's side. ## License Copyright © 2026 Next Level Management Advisors, LLC. diff --git a/migrations/003_offboarding.sql b/migrations/003_offboarding.sql new file mode 100644 index 0000000..83f51c3 --- /dev/null +++ b/migrations/003_offboarding.sql @@ -0,0 +1,20 @@ +-- Self-service connector offboarding + the approved-email-domain allowlist. +-- +-- Migrations re-run on every boot (src/db.ts), so everything here is idempotent. + +-- The verified GitHub email that admitted this user. Retained so the +-- offboarding flow can say *whose* connector it is about to turn off without +-- another GitHub API round trip. +ALTER TABLE github_users ADD COLUMN IF NOT EXISTS email TEXT; + +CREATE INDEX IF NOT EXISTS github_users_email_idx ON github_users (LOWER(email)); + +-- An in-flight GitHub redirect is now either an authorization ('authorize') or +-- a self-service disconnect ('disconnect'); /oauth/github/callback branches on +-- this. A disconnect dance has no claude.ai client on the other side, so the +-- three PKCE/client columns become nullable for those rows. +ALTER TABLE oauth_pending_state ADD COLUMN IF NOT EXISTS purpose TEXT NOT NULL DEFAULT 'authorize'; + +ALTER TABLE oauth_pending_state ALTER COLUMN client_id DROP NOT NULL; +ALTER TABLE oauth_pending_state ALTER COLUMN redirect_uri DROP NOT NULL; +ALTER TABLE oauth_pending_state ALTER COLUMN code_challenge DROP NOT NULL; diff --git a/src/db.ts b/src/db.ts index 3f9bed7..fb6694b 100644 --- a/src/db.ts +++ b/src/db.ts @@ -69,6 +69,7 @@ export interface GithubUserTokens { refreshExpiresAt: Date | null; scopes: string[]; githubLogin: string; + email: string | null; } export async function upsertGithubUser( @@ -78,7 +79,8 @@ export async function upsertGithubUser( accessExpiresAt: Date | null, refreshToken: string | null, refreshExpiresAt: Date | null, - scopes: string[] + scopes: string[], + email: string | null ): Promise { const enc = encryptToken(accessToken); const refEnc = refreshToken ? encryptToken(refreshToken) : null; @@ -87,8 +89,8 @@ export async function upsertGithubUser( (github_user_id, github_login, access_ciphertext, access_iv, access_tag, access_expires_at, refresh_ciphertext, refresh_iv, refresh_tag, refresh_expires_at, - scopes, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11, NOW()) + scopes, email, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, NOW()) ON CONFLICT (github_user_id) DO UPDATE SET github_login = EXCLUDED.github_login, access_ciphertext = EXCLUDED.access_ciphertext, @@ -100,6 +102,8 @@ export async function upsertGithubUser( refresh_tag = EXCLUDED.refresh_tag, refresh_expires_at = EXCLUDED.refresh_expires_at, scopes = EXCLUDED.scopes, + -- A token refresh doesn't look the email up, so never let it blank one out. + email = COALESCE(EXCLUDED.email, github_users.email), updated_at = NOW()`, [ githubUserId, @@ -107,6 +111,7 @@ export async function upsertGithubUser( enc.ciphertext, enc.iv, enc.tag, accessExpiresAt, refEnc?.ciphertext ?? null, refEnc?.iv ?? null, refEnc?.tag ?? null, refreshExpiresAt, scopes, + email, ] ); } @@ -122,6 +127,7 @@ interface GhUserRow { refresh_tag: Buffer | null; refresh_expires_at: Date | null; scopes: string[]; + email: string | null; } export async function loadGithubUser(githubUserId: number): Promise { @@ -129,7 +135,7 @@ export async function loadGithubUser(githubUserId: number): Promise { + const client = await getPool().connect(); + try { + await client.query("BEGIN"); + const access = await client.query( + `DELETE FROM oauth_access_tokens WHERE github_user_id = $1`, + [githubUserId] + ); + const refresh = await client.query( + `DELETE FROM oauth_refresh_tokens WHERE github_user_id = $1`, + [githubUserId] + ); + const codes = await client.query(`DELETE FROM oauth_auth_codes WHERE github_user_id = $1`, [ + githubUserId, + ]); + const user = await client.query(`DELETE FROM github_users WHERE github_user_id = $1`, [ + githubUserId, + ]); + await client.query(`DELETE FROM tenants WHERE github_user_id = $1`, [githubUserId]); + await client.query("COMMIT"); + return { + hadStoredConnection: (user.rowCount ?? 0) > 0, + accessTokensDeleted: access.rowCount ?? 0, + refreshTokensDeleted: refresh.rowCount ?? 0, + authCodesDeleted: codes.rowCount ?? 0, + }; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} + // ─── oauth_pending_state ──────────────────────────────────────────────────── +/** What the GitHub round trip this row guards is for. */ +export type PendingPurpose = "authorize" | "disconnect"; + +export interface PendingState { + purpose: PendingPurpose; + /** Null on a "disconnect" dance — there is no claude.ai client involved. */ + clientId: string | null; + redirectUri: string | null; + codeChallenge: string | null; + claudeState: string | null; +} + export async function storePendingState( stateToken: string, - clientId: string, - redirectUri: string, - codeChallenge: string, + clientId: string | null, + redirectUri: string | null, + codeChallenge: string | null, claudeState: string | undefined, - expiresAtMs: number + expiresAtMs: number, + purpose: PendingPurpose ): Promise { await getPool().query( - `INSERT INTO oauth_pending_state (state_token, client_id, redirect_uri, code_challenge, claude_state, expires_at) - VALUES ($1, $2, $3, $4, $5, to_timestamp($6 / 1000.0))`, - [stateToken, clientId, redirectUri, codeChallenge, claudeState ?? null, expiresAtMs] + `INSERT INTO oauth_pending_state + (state_token, client_id, redirect_uri, code_challenge, claude_state, expires_at, purpose) + VALUES ($1, $2, $3, $4, $5, to_timestamp($6 / 1000.0), $7)`, + [stateToken, clientId, redirectUri, codeChallenge, claudeState ?? null, expiresAtMs, purpose] ); } -export async function takePendingState(stateToken: string): Promise<{ - clientId: string; - redirectUri: string; - codeChallenge: string; - claudeState: string | null; -} | null> { +export async function takePendingState(stateToken: string): Promise { const r = await getPool().query<{ - client_id: string; - redirect_uri: string; - code_challenge: string; + client_id: string | null; + redirect_uri: string | null; + code_challenge: string | null; claude_state: string | null; + purpose: string; }>( `DELETE FROM oauth_pending_state WHERE state_token = $1 AND expires_at > NOW() - RETURNING client_id, redirect_uri, code_challenge, claude_state`, + RETURNING client_id, redirect_uri, code_challenge, claude_state, purpose`, [stateToken] ); if (r.rowCount === 0) return null; const row = r.rows[0]; return { + purpose: row.purpose === "disconnect" ? "disconnect" : "authorize", clientId: row.client_id, redirectUri: row.redirect_uri, codeChallenge: row.code_challenge, diff --git a/src/github-oauth.ts b/src/github-oauth.ts index 0d1bd60..8111d05 100644 --- a/src/github-oauth.ts +++ b/src/github-oauth.ts @@ -6,6 +6,7 @@ import { upsertGithubUser, loadGithubUser } from "./db.js"; const GH_AUTH_URL = "https://github.com/login/oauth/authorize"; const GH_TOKEN_URL = "https://github.com/login/oauth/access_token"; const GH_USER_URL = "https://api.github.com/user"; +const GH_USER_EMAILS_URL = "https://api.github.com/user/emails"; function getEnv(name: string): string { const v = process.env[name]; @@ -14,7 +15,8 @@ function getEnv(name: string): string { } export function getGithubScopes(): string[] { - const raw = process.env.GITHUB_SCOPES ?? "repo,read:org,read:user,read:project,workflow"; + const raw = + process.env.GITHUB_SCOPES ?? "repo,read:org,read:user,user:email,read:project,workflow"; return raw .split(",") .map((s) => s.trim()) @@ -87,6 +89,58 @@ export async function fetchGithubUser(accessToken: string): Promise { + const res = await fetch(GH_USER_EMAILS_URL, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "github-mcp-auth", + Authorization: `Bearer ${accessToken}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (res.status === 403 || res.status === 404) { + throw new GithubEmailScopeError( + "this authorization cannot read your email addresses — it is missing the user:email scope" + ); + } + if (!res.ok) { + const body = await res.text(); + throw new Error(`GitHub /user/emails lookup failed: HTTP ${res.status}: ${body}`); + } + return (await res.json()) as GithubEmail[]; +} + +/** + * Revoke the OAuth App grant on GitHub's side so a disconnected connector is + * genuinely off rather than merely forgotten locally. Best-effort: callers + * still delete local state when this returns false. + */ +export async function revokeGithubGrant(accessToken: string): Promise { + const clientId = getEnv("GITHUB_CLIENT_ID"); + const basic = Buffer.from(`${clientId}:${getEnv("GITHUB_CLIENT_SECRET")}`).toString("base64"); + const res = await fetch(`https://api.github.com/applications/${clientId}/grant`, { + method: "DELETE", + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "github-mcp-auth", + Authorization: `Basic ${basic}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ access_token: accessToken }), + }); + return res.status === 204; +} + export async function refreshGithubToken(refreshToken: string): Promise { const res = await fetch(GH_TOKEN_URL, { method: "POST", @@ -110,15 +164,139 @@ export async function refreshGithubToken(refreshToken: string): Promise s.trim().toLowerCase()) .filter((s) => s.length > 0); - if (allow.length === 0) return true; - return allow.includes(login.toLowerCase()); +} + +/** `@Nlma.io`, ` nlma.io. ` and `nlma.io` all normalize to `nlma.io`. */ +function normalizeDomain(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/^@+/, "") + .replace(/^\.+/, "") + .replace(/\.+$/, ""); +} + +/** CSV of email domains that may use this connector. Empty = no domain gate. */ +export function getApprovedEmailDomains(): string[] { + return (process.env.GITHUB_APPROVED_EMAIL_DOMAINS ?? "") + .split(",") + .map(normalizeDomain) + .filter((s) => s.length > 0); +} + +/** An address is approved when its domain matches an entry or is a subdomain of one. */ +export function isEmailDomainApproved(email: string, approved: string[]): boolean { + const at = email.lastIndexOf("@"); + if (at < 0) return false; + const domain = normalizeDomain(email.slice(at + 1)); + if (!domain) return false; + return approved.some((d) => domain === d || domain.endsWith(`.${d}`)); +} + +/** + * Only *verified* addresses count. An unverified one proves nothing — anyone + * could add `someone@your-company.com` to their GitHub account and walk in. + * Primary first, so the stored email is the user's own idea of their identity. + */ +function verifiedEmails(emails: GithubEmail[]): string[] { + const usable = emails.filter((e) => e.verified && e.email); + return [...usable.filter((e) => e.primary), ...usable.filter((e) => !e.primary)].map( + (e) => e.email + ); +} + +interface AccessDecision { + allowed: boolean; + /** Verified email to store on the user row — the approving one when there is one. */ + email: string | null; + /** Human-readable justification, surfaced to the user on denial. */ + reason: string; +} + +/** + * Two independent allowlists, either of which admits a user: the per-login + * `GITHUB_ALLOWED_USERS` and the per-domain `GITHUB_APPROVED_EMAIL_DOMAINS`. + * Both empty = anyone with a GitHub account, as before. + */ +async function decideAccess(accessToken: string, login: string): Promise { + const logins = getAllowedLogins(); + const domains = getApprovedEmailDomains(); + const onLoginList = logins.includes(login.toLowerCase()); + + // Fetched even with no domain gate configured: the email is stored on the + // user row and shown back during offboarding. + let emails: GithubEmail[] | null = null; + let emailError: string | null = null; + try { + emails = await fetchGithubEmails(accessToken); + } catch (err) { + emailError = err instanceof Error ? err.message : "email lookup failed"; + } + const verified = emails ? verifiedEmails(emails) : []; + const approvingEmail = verified.find((e) => isEmailDomainApproved(e, domains)) ?? null; + const primaryEmail = verified[0] ?? null; + + if (logins.length === 0 && domains.length === 0) { + return { allowed: true, email: primaryEmail, reason: "no allowlist configured" }; + } + if (onLoginList) { + return { allowed: true, email: approvingEmail ?? primaryEmail, reason: "GITHUB_ALLOWED_USERS" }; + } + if (domains.length === 0) { + return { allowed: false, email: primaryEmail, reason: "not on GITHUB_ALLOWED_USERS" }; + } + if (approvingEmail) { + return { allowed: true, email: approvingEmail, reason: "approved email domain" }; + } + // A domain gate is configured, so an unreadable email list must fail closed. + if (emailError) { + return { + allowed: false, + email: null, + reason: `${emailError} — re-authorize to grant it`, + }; + } + return { + allowed: false, + email: primaryEmail, + reason: `no verified GitHub email on an approved domain (${domains.join(", ")})`, + }; +} + +export interface GithubIdentity { + githubUserId: number; + githubLogin: string; + /** Verified email we know them by, when GitHub let us read one. */ + email: string | null; +} + +/** + * Exchange a GitHub `code` for an identity *without* persisting anything. + * The offboarding flow needs to prove who is asking, but must not store + * credentials for an account whose row it is about to delete. + */ +export async function identifyGithubUser( + code: string +): Promise { + const tok = await exchangeCodeForToken(code); + const user = await fetchGithubUser(tok.access_token); + let email: string | null = null; + try { + email = verifiedEmails(await fetchGithubEmails(tok.access_token))[0] ?? null; + } catch { + // Identity is the login; the email is only used to label the page. + } + return { + githubUserId: user.id, + githubLogin: user.login, + email, + accessToken: tok.access_token, + }; } /** @@ -126,14 +304,12 @@ function isUserAllowed(login: string): boolean { * token, fetch the user, persist the encrypted token, and return the * github_user_id we'll use to look it up at proxy time. */ -export async function completeGithubLogin(code: string): Promise<{ - githubUserId: number; - githubLogin: string; -}> { +export async function completeGithubLogin(code: string): Promise { const tok = await exchangeCodeForToken(code); const user = await fetchGithubUser(tok.access_token); - if (!isUserAllowed(user.login)) { - throw new Error(`GitHub user ${user.login} is not on the allowlist`); + const decision = await decideAccess(tok.access_token, user.login); + if (!decision.allowed) { + throw new Error(`GitHub user ${user.login} is not allowed to use this connector: ${decision.reason}`); } const now = Date.now(); const accessExpiresAt = tok.expires_in ? new Date(now + tok.expires_in * 1000) : null; @@ -148,9 +324,10 @@ export async function completeGithubLogin(code: string): Promise<{ accessExpiresAt, tok.refresh_token ?? null, refreshExpiresAt, - scopes + scopes, + decision.email ); - return { githubUserId: user.id, githubLogin: user.login }; + return { githubUserId: user.id, githubLogin: user.login, email: decision.email }; } /** @@ -188,7 +365,8 @@ export async function getValidAccessTokenFor(githubUserId: number): Promise<{ accessExpiresAt, tok.refresh_token ?? u.refreshToken, refreshExpiresAt, - scopes + scopes, + u.email ); return { accessToken: tok.access_token, githubLogin: u.githubLogin }; } catch (err) { diff --git a/src/http.ts b/src/http.ts index 2058f47..7f73339 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,14 +1,23 @@ +import { randomUUID } from "node:crypto"; import express from "express"; import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js"; import { bearerAuth } from "./auth.js"; import { oauthProvider, mintAuthCode } from "./oauth.js"; -import { completeGithubLogin } from "./github-oauth.js"; -import { takePendingState } from "./db.js"; +import { + buildGithubAuthorizeUrl, + completeGithubLogin, + getApprovedEmailDomains, + identifyGithubUser, + revokeGithubGrant, +} from "./github-oauth.js"; +import { offboardGithubUser, storePendingState, takePendingState } from "./db.js"; +import type { OffboardResult } from "./db.js"; import { buildMcpProxy } from "./proxy.js"; const PORT = parseInt(process.env.PORT ?? "3061", 10); const HOST = process.env.HOST ?? "127.0.0.1"; const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`; +const DISCONNECT_STATE_TTL_MS = 10 * 60 * 1000; // 10m, same as an authorize dance export function buildApp(): express.Express { const app = express(); @@ -49,6 +58,18 @@ export function buildApp(): express.Express { res.status(400).type("text/plain").send("Unknown or expired state — please retry authorization."); return; } + + // The same callback serves both directions: connecting, and turning it off. + if (pending.purpose === "disconnect") { + await finishBrowserDisconnect(code, res); + return; + } + + if (!pending.clientId || !pending.redirectUri || !pending.codeChallenge) { + res.status(400).type("text/plain").send("Malformed authorization state — please retry authorization."); + return; + } + let user: { githubUserId: number; githubLogin: string }; try { user = await completeGithubLogin(code); @@ -73,6 +94,54 @@ export function buildApp(): express.Express { res.redirect(target.toString()); }); + // ─── Self-service offboarding ───────────────────────────────────────────── + // + // Two ways in, both proving the same thing — that you control the GitHub + // account whose credentials are about to be deleted: + // + // • browser: GET /disconnect → POST /disconnect/start → GitHub → callback + // • API: POST /disconnect with the connector's own bearer token + // + // Neither re-checks the approved-domain allowlist. That gate decides who may + // *connect*; making it also guard disconnection would mean dropping a domain + // from the allowlist strands its users with a connector they can't turn off. + + app.get("/disconnect", (_req, res) => { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.send(disconnectPage()); + }); + + app.post("/disconnect/start", async (_req, res) => { + const stateToken = randomUUID(); + await storePendingState( + stateToken, + null, + null, + null, + undefined, + Date.now() + DISCONNECT_STATE_TTL_MS, + "disconnect" + ); + res.redirect(buildGithubAuthorizeUrl(stateToken)); + }); + + app.post("/disconnect", bearerAuth, async (req, res) => { + const tenant = req.tenant; + if (!tenant) { + res.status(401).json({ error: "unauthorized" }); + return; + } + const result = await offboardGithubUser(tenant.githubUserId); + const grantRevoked = await tryRevokeGrant(tenant.githubAccessToken); + console.log(`Offboarded GitHub user ${tenant.githubLogin} via bearer token`); + res.json({ + status: "disconnected", + github_login: tenant.githubLogin, + github_grant_revoked: grantRevoked, + ...result, + }); + }); + // MCP endpoints — bearer-authenticated then proxied upstream with the // user's GitHub token swapped in. const proxy = buildMcpProxy(); @@ -85,14 +154,65 @@ export function buildApp(): express.Express { return app; } -function splashPage(): string { - const issuer = BASE_URL; +/** Revoking is best-effort: local state is gone either way. */ +async function tryRevokeGrant(accessToken: string): Promise { + try { + return await revokeGithubGrant(accessToken); + } catch (err) { + console.error("GitHub grant revocation failed:", err); + return false; + } +} + +async function finishBrowserDisconnect(code: string, res: express.Response): Promise { + let identity: Awaited>; + try { + identity = await identifyGithubUser(code); + } catch (err) { + console.error("Disconnect identification failed:", err); + res + .status(401) + .type("text/html") + .send( + page( + "Disconnect failed", + `
+

We couldn't confirm who you are

+

${escapeHtml(err instanceof Error ? err.message : "unknown error")}

+

Try again

+
` + ) + ); + return; + } + + const result = await offboardGithubUser(identity.githubUserId); + const grantRevoked = await tryRevokeGrant(identity.accessToken); + console.log( + `Offboarded GitHub user ${identity.githubLogin} via browser (grant revoked: ${grantRevoked})` + ); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.send(disconnectedPage(identity.githubLogin, identity.email, result, grantRevoked)); +} + +// ─── Pages ─────────────────────────────────────────────────────────────────── + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function page(title: string, body: string): string { return ` - GitHub MCP — Multi-tenant OAuth Gateway + ${escapeHtml(title)} -

GitHub MCP

+${body} + +`; +} + +function splashPage(): string { + const issuer = BASE_URL; + const domains = getApprovedEmailDomains(); + const who = + domains.length > 0 + ? `

Open to GitHub accounts with a verified email on ${domains + .map((d) => `${escapeHtml(d)}`) + .join(", ")} (subdomains included).

` + : ""; + return page( + "GitHub MCP — Multi-tenant OAuth Gateway", + `

GitHub MCP

Multi-tenant OAuth gateway for the official github-mcp-server. Each user authorizes with their own GitHub account and acts as themselves on the GitHub API.

Connect from claude.ai

Add this connector URL in claude.ai → Settings → Connectors → Add custom connector:

-
${issuer}/mcp
-

You'll be redirected to GitHub to authorize. Subsequent sessions reuse your stored token; revoke at any time from github.com/settings/connections/applications.

+
${escapeHtml(issuer)}/mcp
+

You'll be redirected to GitHub to authorize. Subsequent sessions reuse your stored token.

+${who}
+ +
+

Turn your connector off

+

Disconnect this connector — deletes your stored GitHub credentials here and revokes the app's access to your GitHub account, whichever approved domain you signed in from.

@@ -121,9 +265,71 @@ function splashPage(): string {
  • /.well-known/oauth-authorization-server
  • /health
  • +
    ` + ); +} + +function disconnectPage(): string { + return page( + "Disconnect GitHub MCP", + `

    Disconnect this connector

    +

    Sign in with GitHub to confirm it's your connector, then we turn it off.

    + +
    +

    What this deletes

    +
      +
    • Every access and refresh token this gateway issued to your MCP clients
    • +
    • Your encrypted GitHub access and refresh tokens stored here
    • +
    • Your tenant record
    • +
    +

    It also revokes this app's authorization on your GitHub account, so nothing here can act as you again until you reconnect.

    +

    Any GitHub account that signed in here can turn its own connector off — it isn't limited to one email domain. Activity records in the audit log are kept; they identify you only by a salted hash.

    +
    + +
    - -`; + +
    +

    Prefer the API?

    +

    With a bearer token your client already holds:

    +
    curl -X POST ${escapeHtml(BASE_URL)}/disconnect \\
    +     -H 'Authorization: Bearer <access_token>'
    +
    ` + ); +} + +function disconnectedPage( + login: string, + email: string | null, + result: OffboardResult, + grantRevoked: boolean +): string { + const who = email + ? `${escapeHtml(login)} (${escapeHtml(email)})` + : `${escapeHtml(login)}`; + const headline = result.hadStoredConnection + ? `Disconnected ${who}.` + : `Nothing was stored here for ${who} — there was no connector left to turn off.`; + const grantNote = grantRevoked + ? "
  • This app's authorization on your GitHub account was revoked.
  • " + : `
  • We couldn't revoke this app's GitHub authorization automatically — remove it at github.com/settings/connections/applications.
  • `; + return page( + "Connector disconnected", + `

    Connector off

    +

    ${headline}

    + +
    +

    What happened

    +
      +
    • Access tokens deleted: ${result.accessTokensDeleted}
    • +
    • Refresh tokens deleted: ${result.refreshTokensDeleted}
    • +
    • Pending authorization codes deleted: ${result.authCodesDeleted}
    • +
    • Stored GitHub credentials: ${result.hadStoredConnection ? "deleted" : "none found"}
    • + ${grantNote} +
    +

    Remove the connector in claude.ai → Settings → Connectors too, so it stops trying to reach a connection that no longer exists. You can reconnect any time.

    +
    ` + ); } export function listen(): void { diff --git a/src/oauth.ts b/src/oauth.ts index 042b0cf..3d4b997 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -114,7 +114,8 @@ export const oauthProvider: OAuthServerProvider = { params.redirectUri, params.codeChallenge, params.state, - Date.now() + PENDING_STATE_TTL_MS + Date.now() + PENDING_STATE_TTL_MS, + "authorize" ); res.redirect(buildGithubAuthorizeUrl(stateToken)); },