Skip to content
Open
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
92 changes: 92 additions & 0 deletions .github/workflows/access-site.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
name: access-site

# Tests, provisions and deploys access.modelcontextprotocol.io (access-site/).
# The Worker bundles src/config, so config changes redeploy it too.
on:
pull_request:
paths:
- 'access-site/**'
- 'src/config/**'
- '.github/workflows/access-site.yml'
push:
branches:
- main
paths:
- 'access-site/**'
- 'src/config/**'
- '.github/workflows/access-site.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
test:
name: Typecheck and test
runs-on: ubuntu-latest
defaults:
run:
working-directory: access-site
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: access-site/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm test

deploy:
name: Provision and deploy
needs: test
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
concurrency:
group: deploy-access-site
cancel-in-progress: false
defaults:
run:
working-directory: access-site
# Secrets are exposed only to the steps that need them, never to `npm ci`
# (package install scripts run there).
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- name: Check setup
id: setup
env:
HAS_TOKEN: ${{ secrets.CLOUDFLARE_ACCESS_SITE_TOKEN != '' }}
HAS_SIGNING_KEY: ${{ secrets.ACCESS_SITE_OIDC_SIGNING_KEY != '' }}
HAS_CLIENT_SECRET: ${{ secrets.ACCESS_SITE_OIDC_CLIENT_SECRET != '' }}
run: |
if [ "$HAS_TOKEN" = true ] && [ "$HAS_SIGNING_KEY" = true ] && [ "$HAS_CLIENT_SECRET" = true ]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "::notice::access-site is not set up yet (see access-site/README.md); skipping deploy."
echo "ready=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
if: steps.setup.outputs.ready == 'true'
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: access-site/package-lock.json
- if: steps.setup.outputs.ready == 'true'
run: npm ci
- name: Provision and deploy
if: steps.setup.outputs.ready == 'true'
env:
# Token scoped to: Workers Scripts edit, D1 edit, and Workers Routes /
# DNS edit on the modelcontextprotocol.io zone (for the custom domain).
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_ACCESS_SITE_TOKEN }}
OIDC_SIGNING_KEY: ${{ secrets.ACCESS_SITE_OIDC_SIGNING_KEY }}
OIDC_CLIENT_SECRET: ${{ secrets.ACCESS_SITE_OIDC_CLIENT_SECRET }}
GITHUB_CLIENT_SECRET: ${{ secrets.ACCESS_SITE_GITHUB_CLIENT_SECRET }}
OIDC_PREVIOUS_JWK: ${{ secrets.ACCESS_SITE_OIDC_PREVIOUS_JWK }}
run: ./scripts/deploy.sh
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Infrastructure as Code for managing access to MCP community resources using Pulu
- **Google Workspace Groups**: Automatically syncs group memberships for @modelcontextprotocol.io email accounts
- **Email Groups**: Groups with `isEmailGroup: true` accept emails from anyone (including external users) and notify all members. External posts are moderated for security.
- **Google Workspace User Accounts**: Provisions @modelcontextprotocol.io accounts for members of roles with `provisionUser: true` (directly, or via a role nested under one through `github.parent` — e.g. SDK teams under `sdk-maintainers`, working groups under `working-groups`)
- **GitHub sign-in for Google Workspace** ([`access-site/`](access-site/README.md)): [access.modelcontextprotocol.io](https://access.modelcontextprotocol.io) is the OIDC identity provider for Google Workspace, so maintainers sign in to their account with GitHub. Who can sign in, and as which address, comes from `users.ts`.
- **Discord**: Syncs the Discord roles declared in [`src/config/roles.ts`](src/config/roles.ts) to the members' Discord IDs in `users.ts`, and manages the settings of the channels declared in [`src/config/channels.ts`](src/config/channels.ts), including thread-only forum/media channels. See [Discord channels](#discord-channels) below.
- **Cloudflare Access (security-room)**: Syncs the Cloudflare Zero Trust Access policy that decides who can sign in to `securityroom.modelcontextprotocol.io` from the roles declared in [`src/config/accessPolicies.ts`](src/config/accessPolicies.ts). See [Cloudflare Access (security-room)](#cloudflare-access-security-room) below.
- **npm & PyPI Package Publishing Access** (declared, not applied): Expected registry access is declared in [`src/config/packageAccess.ts`](src/config/packageAccess.ts) and drift against the live npm registry is detected by CI — but changes are applied manually by a maintainer. See [npm & PyPI Package Publishing Access](#npm--pypi-package-publishing-access) below for why and how.
Expand Down
95 changes: 95 additions & 0 deletions access-site/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# access.modelcontextprotocol.io

Sign in to MCP Google Workspace with GitHub.

A small Cloudflare Worker (Hono, jose, D1) that is the OpenID Connect provider for Google Workspace's custom OIDC SSO profile. Maintainers sign in to Google with their GitHub account; there are no Google passwords.

Accounts stay infrastructure as code. Who has an account, and which address, is `users.ts`: a GitHub user can sign in as `<googleEmailPrefix>@modelcontextprotocol.io` only if their entry has a `googleEmailPrefix`, isn't opted out, and is in a role with `provisionUser`, the same rule Pulumi uses to create the account. The Worker bundles `src/config` and is redeployed on every config change, so removing someone from `users.ts` stops their sign-in on the next deploy.

The Worker holds no Google credentials and creates nothing in Google.

## How sign-in works

```mermaid
sequenceDiagram
actor M as Maintainer
participant G as Google
participant W as access.modelcontextprotocol.io
participant GH as GitHub
M->>G: Open Gmail
G->>W: /authorize (OIDC)
W->>GH: GitHub App sign-in
GH-->>W: user ID, login
W->>W: login -> address in users.ts
W-->>G: code
G->>W: /token
W-->>G: ID token: sub=<GitHub ID>, email=<prefix>@modelcontextprotocol.io
G-->>M: Signed in
```

GitHub users with no account in `users.ts` get a page explaining how to request one.

## Security

- **Accounts come from `users.ts`**, which only core maintainers can approve (CODEOWNERS). Anyone who can merge a `googleEmailPrefix` change can map a GitHub account to that Workspace address, so treat those changes like credential changes.
- **GitHub logins are pinned to GitHub user IDs** on first sign-in (`github_identities` in D1). GitHub frees a login when its owner renames, so if someone else registers that name, sign-in is refused until `users.ts` is updated and the pin is cleared (see Operations).
- **Codes and requests are single-use and short-lived**: pending requests last 10 minutes and authorization codes 2 minutes, stored hashed and consumed with `DELETE ... RETURNING`. PKCE is enforced when Google sends it, and a verifier without a challenge is rejected (RFC 9700). Redirect URIs must match exactly; client credentials are form-decoded (RFC 6749 2.3.1) and compared in constant time.
- **Rate limited**: `/authorize` and `/login` allow 30 requests per minute per IP, so anonymous traffic can't exhaust D1 writes.
- **Minimal GitHub access**: the GitHub App has no repository or organization permissions, and its user token is revoked right after the user is read.
- **No Google credentials** in the Worker; it cannot create, change or read Google accounts.
- **CI**: actions are pinned to commit SHAs, Git credentials aren't persisted, and deploy secrets are only exposed to the deploy step, never to `npm ci`.
- **Break-glass**: super admins are exempt from the SSO profile. The GitHub org requires 2FA, which covers Google skipping its own 2-step verification for SSO users.

Sign-ins and refusals are logged as JSON lines in Workers Logs.

## Setup

Cloudflare provisioning is done by CI: on every merge to `main`, [`scripts/deploy.sh`](scripts/deploy.sh) finds or creates the `access-site` D1 database, applies migrations, and deploys the Worker with its secrets. Until the secrets below exist, the deploy job is skipped with a notice, so merging early is safe. Non-secret settings live in `wrangler.toml` and change by PR.

One-time steps, in order. Secrets go straight from the command line into GitHub secrets (needs `openssl` and `gh`), so they never pass between people.

1. **Cloudflare API token** (Cloudflare admin): create a token on the MCP Domain Account scoped to Workers Scripts edit, D1 edit, and Workers Routes and DNS edit on the `modelcontextprotocol.io` zone. Store it as `CLOUDFLARE_ACCESS_SITE_TOKEN` in the `production` environment.
2. **OIDC signing key** (anyone with access to the repo's secrets):
```sh
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
| gh secret set ACCESS_SITE_OIDC_SIGNING_KEY --env production -R modelcontextprotocol/access
```
3. **GitHub App** (org owner): create it at <https://github.com/organizations/modelcontextprotocol/settings/apps/new>.
- Homepage URL `https://access.modelcontextprotocol.io`, callback URL `https://access.modelcontextprotocol.io/login`; leave token expiry on; no device flow; webhook off
- Permissions: Account → Email addresses: read-only (the sign-in library reads it; it is not used). Nothing else.
- Installable only on this account; no need to install it
- Generate a client secret and store it: `gh secret set ACCESS_SITE_GITHUB_CLIENT_SECRET --env production -R modelcontextprotocol/access` (paste when prompted)
- Put the client ID (not secret) in `GITHUB_CLIENT_ID` in `wrangler.toml` by PR
4. **OIDC client secret** (whoever does step 6, so it stays with one person):
```sh
openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\n' > oidc-client-secret.txt
gh secret set ACCESS_SITE_OIDC_CLIENT_SECRET --env production -R modelcontextprotocol/access < oidc-client-secret.txt
```
Keep `oidc-client-secret.txt` for step 6, then delete it.
5. **Deploy**: merge (or re-run the `access-site` workflow) and check `https://access.modelcontextprotocol.io/.well-known/openid-configuration`.
6. **Google SSO profile** (super admin): in the [Admin console](https://admin.google.com), Security → Authentication → SSO with third-party IdP → add a custom **OIDC** profile:
- Issuer URL `https://access.modelcontextprotocol.io`, client ID `google-workspace`, client secret from step 4
- Change password URL `https://github.com/settings/security` (passwords now live on GitHub)
- Copy the **Redirect URI** Google shows into `OIDC_REDIRECT_URIS` in `wrangler.toml` by PR
- Assign the profile to a pilot sub-OU holding only the pilot users first, then to **Model Context Protocol**. Super admins always keep Google sign-in, which is the break-glass path.

## Operations

- **Pilot**: `PILOT_GITHUB_LOGINS` in `wrangler.toml` limits sign-in to the listed GitHub logins (initially `sambhav`); a test checks each one maps to an account. Add logins to widen the pilot, and remove the line by PR to open sign-in to everyone in `users.ts`.
- **Rotate the signing key**: copy the current key object (`keys[0]`) from `https://access.modelcontextprotocol.io/jwks` into the `ACCESS_SITE_OIDC_PREVIOUS_JWK` secret, replace `ACCESS_SITE_OIDC_SIGNING_KEY` as in setup step 2, and re-run the workflow. A day later set `ACCESS_SITE_OIDC_PREVIOUS_JWK` to `{}` and re-run.
- **Logs**: sign-ins and refusals are JSON lines in Workers Logs (dashboard, or `npx wrangler tail`). `signin.denied` with `reason: identity_mismatch` means a pinned login came from a different GitHub account.
- **Clear an identity pin** (after confirming the person, e.g. they recreated their GitHub account): `npx wrangler d1 execute access-site --remote --command "DELETE FROM github_identities WHERE login = '<login>'"`.

## Development

```sh
npm ci
npm test # vitest, Node 22+ (uses node:sqlite in place of D1)
npm run typecheck
npm run dev # wrangler dev
```

## Google behaviour

- The redirect URI is specific to each SSO profile and only shown once the profile exists, so it is set by PR after setup step 6.
- Google doesn't document whether it sends PKCE; both cases are supported.
30 changes: 30 additions & 0 deletions access-site/migrations/0001_init.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- Sign-in requests from Google, held while the user is at GitHub.
CREATE TABLE pending_authorizations (
id_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
state TEXT,
nonce TEXT,
code_challenge TEXT,
expires_at INTEGER NOT NULL
);

-- Single-use authorization codes (stored hashed; consumed with DELETE ... RETURNING).
CREATE TABLE authorization_codes (
code_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
nonce TEXT,
code_challenge TEXT,
claims TEXT NOT NULL,
expires_at INTEGER NOT NULL
);

-- First GitHub user ID seen signing in with each login (trust on first use).
-- A different ID for the same login means the login was freed by a rename
-- and registered by someone else, so sign-in is refused.
CREATE TABLE github_identities (
login TEXT PRIMARY KEY,
github_id TEXT NOT NULL,
first_seen INTEGER NOT NULL
);
Loading
Loading