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
15 changes: 12 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------------------------------------------------------------

Expand All @@ -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=
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <user's GitHub token>` 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 <user's GitHub token>` 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
Expand All @@ -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).
44 changes: 37 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <access_token>'
```

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.
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions migrations/003_offboarding.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading