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
93 changes: 93 additions & 0 deletions .github/workflows/access-site.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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 != '' }}
HAS_GITHUB_SECRET: ${{ secrets.ACCESS_SITE_GITHUB_CLIENT_SECRET != '' }}
run: |
if [ "$HAS_TOKEN" = true ] && [ "$HAS_SIGNING_KEY" = true ] && [ "$HAS_CLIENT_SECRET" = true ] && [ "$HAS_GITHUB_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_ROTATION_JWKS: ${{ secrets.ACCESS_SITE_OIDC_ROTATION_JWKS }}
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
102 changes: 102 additions & 0 deletions access-site/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# 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. A member in `users.ts` can sign in as `<googleEmailPrefix>@modelcontextprotocol.io` when the entry declares an existing Workspace account or qualifies for Pulumi provisioning, and the GitHub login and verified numeric `githubUserId` both match. The Worker bundles `src/config` and redeploys on config changes, so removing a member from `users.ts` stops new sign-ins 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: Verify login and ID against 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 who cannot sign in get a page explaining the next step.

## Security

- **Accounts come from `users.ts`**, which only core maintainers can approve (CODEOWNERS). Treat changes to `githubUserId` or `googleEmailPrefix` like credential changes.
- **GitHub accounts are verified before sign-in.** The numeric ID must already be in `users.ts`. A recycled GitHub login cannot claim the original member’s Workspace address, even on its first sign-in.
- **Codes and requests are single-use and short-lived.** Pending requests last 10 minutes and authorization codes last 2 minutes. D1 stores them hashed and consumes them with `DELETE ... RETURNING`. GitHub OAuth state and S256 PKCE are bound to the same host-only pending request cookie. The Google flow enforces PKCE when sent and rejects a verifier without a challenge (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
- Leave repository, organization, and account permissions unset. The Worker reads only the authenticated GitHub user’s basic profile.
- 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. Follow the rollout steps below before assigning it more widely. Super admins always keep Google sign-in, which is the break-glass path.

## Operations

- **Pilot rollout.** `PILOT_GITHUB_LOGINS` in `wrangler.toml` limits sign-in to the listed logins (initially `sambhav`). Before adding anyone, confirm the GitHub account with the maintainer and check its numeric ID against a trusted GitHub record. Add `githubUserId` to their `users.ts` entry in a reviewed PR. Keep the Google profile scoped to users whose IDs are verified and whose GitHub sign-in is enabled. Before removing the pilot allowlist and assigning the profile to the entire **Model Context Protocol** OU, verify IDs and account mappings for everyone affected, including existing Workspace accounts.
- **GitHub renames.** Confirm the new login against the existing ID, then update the login in `users.ts` and in the pilot list if applicable. A recreated GitHub account has a different ID and requires fresh verification.
- **Rotate the signing key.** `ACCESS_SITE_OIDC_ROTATION_JWKS` holds a JWK Set (`{"keys": [...]}`) of public keys that `/jwks` publishes alongside the active key.
1. Generate a new private key as in setup step 2 and keep it offline until the cutover.
2. Set `ACCESS_SITE_OIDC_ROTATION_JWKS` to the new public key and re-run the workflow. Check that `/jwks` lists both keys, then wait at least five minutes for caches to refresh.
3. Set `ACCESS_SITE_OIDC_ROTATION_JWKS` to the old public key, replace `ACCESS_SITE_OIDC_SIGNING_KEY` with the new private key, and re-run the workflow. Check that sign-in works and `/jwks` still lists both keys.
4. After a day, set `ACCESS_SITE_OIDC_ROTATION_JWKS` to `{}` and re-run the workflow.

Each `kid` must be the key's RFC 7638 thumbprint, as the Worker computes for the active key, so the old key's entry can be copied from `/jwks` as is.

- **Logs.** `signin.denied` with `reason: identity_mismatch` means the returned GitHub ID differed from the configured ID. `identity_unverified` means the account still needs a reviewed ID.

## Development

```sh
npm ci
npm test # vitest, Node 24+ (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.
21 changes: 21 additions & 0 deletions access-site/migrations/0001_init.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- 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
);
Loading
Loading