From 97edd085c770c05309a19a7df9d51bc485783954 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 16:50:20 +0000 Subject: [PATCH] Add self-service connector offboarding + approved-email-domain allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offboarding was manual DBA work (README pointed at a raw DELETE), and the only access gate was GITHUB_ALLOWED_USERS — a per-login list, so admitting a whole company meant enumerating its people one at a time. Access control: - GITHUB_APPROVED_EMAIL_DOMAINS admits any user with a *verified* GitHub email on an approved domain, subdomains included. Unverified addresses never count; anyone can type someone@your-company.com into a GitHub profile. - OR'd with GITHUB_ALLOWED_USERS: named individuals via one list, whole companies via the other, both empty = open as before. - Fails closed when a domain gate is configured but /user/emails is unreadable, with an error telling the user to re-authorize. - Adds user:email to the default scopes and stores the admitting email on github_users, preserved across token refreshes. Offboarding — users turn their own connector off, on any approved domain: - GET /disconnect → POST /disconnect/start → GitHub → callback, reusing the existing redirect via a pending-state `purpose` column. - POST /disconnect for clients holding a bearer token. - Both delete every opaque token, in-flight auth code, the encrypted GitHub credentials and the tenants row in one transaction, then revoke the OAuth App grant on GitHub so the connector is off rather than merely forgotten. - audit_log rows are retained; they identify users only by a salted hash. - Deliberately not domain-gated: gating a privilege reduction would strand users whose domain was later removed from the allowlist. Verified against a scratch Postgres 16: migrations idempotent across reruns, 22 DB-layer assertions (including that a token refresh can't blank a stored email and that offboarding never touches a second user), the 15-case allowlist decision matrix, and the HTTP surface end to end. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HxTDjBKzBnaAvzMEY4DpQi --- .env.example | 15 ++- CLAUDE.md | 9 +- README.md | 44 ++++++- migrations/003_offboarding.sql | 20 +++ src/db.ts | 109 +++++++++++++--- src/github-oauth.ts | 210 +++++++++++++++++++++++++++--- src/http.ts | 226 +++++++++++++++++++++++++++++++-- src/oauth.ts | 3 +- 8 files changed, 575 insertions(+), 61 deletions(-) create mode 100644 migrations/003_offboarding.sql 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)); },