diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a4a219..fe12dac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,15 @@ jobs: with: node-version: "22" - run: node --check mcp-server/server/index.js - - run: cd worker && npm ci && npx wrangler deploy --dry-run --outdir dist + - name: mcp-server client migration tests + run: node --test test/*.test.mjs + working-directory: mcp-server + - name: worker install + dry-run deploy + OAuth device-flow tests + run: | + npm ci + npx wrangler deploy --dry-run --outdir dist + npm test + working-directory: worker # Gate job: single Required status check for branch protection. # Add new jobs to `needs` when CI grows. No Settings change needed. diff --git a/.gitignore b/.gitignore index 101f760..08e0245 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ mcp-server/node_modules/ mcp-server/*.mcpb worker/node_modules/ worker/.wrangler/ +worker/dist/ local-mcp/node_modules/ diff --git a/docs/installation.ja.md b/docs/installation.ja.md index 4268b3a..4207bb4 100644 --- a/docs/installation.ja.md +++ b/docs/installation.ja.md @@ -15,6 +15,22 @@ > **前提:** Node.js 18+ が必要です(ローカル MCP ブリッジの実行に使用)。 +### 初回認証(OAuth Device Flow) + +v0.11.0 以降、MCP クライアントは **OAuth 2.1 Device Authorization Grant (RFC 8628)** で認証します。初回接続時に以下のメッセージが Claude Code の stderr ログに出力されます: + +``` +[github-webhook-mcp] OAuth device authorization required. +[github-webhook-mcp] Visit: https://github.com/login/device +[github-webhook-mcp] Enter code: WDJB-MJHT +[github-webhook-mcp] Or open directly: https://github.com/login/device?user_code=WDJB-MJHT +[github-webhook-mcp] Waiting for approval (expires in 600s)... +``` + +ブラウザで `https://github.com/login/device` を開き、表示された 8 文字の `user_code` を入力してください。承認後、自動的にトークンが発行され、`~/.github-webhook-mcp/oauth-tokens.json` に保存されます。以降の起動では保存済みトークンが再利用され、期限切れ前に自動でリフレッシュされます。 + +> **旧バージョンからの移行:** v0.10.x 以前の localhost callback flow を使っていた場合、初回起動時に旧トークンファイルが自動削除され、migration 通知が stderr に出力されます。表示される device code を入力して一度だけ再認証してください。 + ### Claude Desktop — デスクトップ拡張 (.mcpb) [Releases](https://github.com/Liplus-Project/github-webhook-mcp/releases) から `github-webhook-mcp.mcpb` をダウンロード: @@ -142,11 +158,16 @@ id = "<ここに KV Namespace ID を貼り付け>" #### OAuth 設定(MCP リモート接続に必要) -| 項目 | 値 | -|------|-----| -| **Callback URL** | `https:///oauth/callback` | +v0.11.0 以降、OAuth は **Device Authorization Grant (RFC 8628)** で動作します。ローカル MCP クライアントは localhost callback ポートに依存しません。 -Client ID と Client secret を生成・メモしてください(ステップ 5 で使用)。 +1. **Identifying and authorizing users** セクションで **"Enable Device Flow"** チェックボックスを **ON** にする(必須) + - 未有効の場合、Worker の `/oauth/device_authorization` が `503 device_flow_disabled` を返し、MCP クライアントが認証できません +2. **Callback URL** は **空欄のままで構いません**(device flow では使用されない。旧フローとの後方互換のため任意のダミー URL を入れてもよい) +3. Client ID を生成・メモしてください(ステップ 5 で使用) +4. **Client secret は不要です**(device flow の public client として動作するため) + - 既存 App で secret を発行済みの場合はそのままでも問題ありません(未使用のまま) + +> **重要:** v0.11.0 より前の localhost callback flow から移行するユーザーは、初回接続時に自動的に device flow で再認証が要求されます(`~/.github-webhook-mcp/oauth-tokens.json` の旧ファイルは自動で破棄され、stderr に移行通知が出力されます)。Claude Code のログで `https://github.com/login/device` の案内と `user_code` を確認して入力してください。 #### パーミッション @@ -178,28 +199,31 @@ Client ID と Client secret を生成・メモしてください(ステップ ### 5. シークレットの設定 -3 つのシークレットを Cloudflare に登録。ダッシュボードの **Workers & Pages** → Worker → **Settings** → **Variables and Secrets** から設定するか、wrangler CLI で設定します: +必要なシークレットを Cloudflare に登録。ダッシュボードの **Workers & Pages** → Worker → **Settings** → **Variables and Secrets** から設定するか、wrangler CLI で設定します: ```bash -# GitHub App の Webhook secret +# GitHub App の Webhook secret(必須) npx wrangler secret put GITHUB_WEBHOOK_SECRET -# GitHub App の OAuth Client ID +# GitHub App の Client ID(device flow で必須) npx wrangler secret put GITHUB_CLIENT_ID -# GitHub App の OAuth Client Secret +# GitHub App の Client Secret(device flow では未使用だが、upstream API が +# 将来 confidential client に変わった場合に備えて設定を残してあります) npx wrangler secret put GITHUB_CLIENT_SECRET ``` 各コマンドでプロンプトが表示されるので、対応する値を入力してください。 +> **注意:** device flow の public client として動作するため `GITHUB_CLIENT_SECRET` は実際の認証には使用されません。未登録でも認証は成立しますが、互換性のためダミー値(例: `unused`)を入れておくことを推奨します。 + ### 6. カスタムドメイン(オプション) デフォルトの `*.workers.dev` URL の代わりにカスタムドメインを使用するには: 1. Cloudflare ダッシュボード → **Workers & Pages** → Worker → **Settings** → **Domains & Routes** 2. カスタムドメインを追加(例: `github-webhook.example.com`) -3. GitHub App の Webhook URL と Callback URL をカスタムドメインに更新 +3. GitHub App の Webhook URL をカスタムドメインに更新(device flow は Callback URL 不要) 4. MCP クライアント設定の `WEBHOOK_WORKER_URL` を更新 ### 7. WAF ルール(推奨) @@ -245,6 +269,9 @@ claude --dangerously-load-development-channels server:github-webhook-mcp |------|-----------| | Webhook が 403 を返す | `GITHUB_WEBHOOK_SECRET` が GitHub App の設定と一致していない。両方の値を確認 | | Webhook が 429 を返す | テナントクォータ(デフォルト 10,000 イベント)を超過。古いイベントを `mark_processed` で処理 | -| OAuth ログインが失敗する | `GITHUB_CLIENT_ID` と `GITHUB_CLIENT_SECRET` が正しいか確認。Callback URL が一致しているか確認 | +| `/oauth/device_authorization` が 503 を返す | GitHub App で **"Enable Device Flow"** が有効になっていない。ステップ 4 の OAuth 設定で再確認 | +| `/oauth/authorize` / `/oauth/callback` が 410 Gone を返す | v0.10.x 以前のクライアントが使っていた旧エンドポイント。MCP クライアントを最新版に更新してください(新クライアントは自動的に device flow にフォールバック) | +| Claude Code のログに device code が表示されない | stderr の出力を確認。`[github-webhook-mcp] OAuth device authorization required.` のセクションに `user_code` と `https://github.com/login/device` が出力されているはず | +| `~/.github-webhook-mcp/oauth-tokens.json` が消えた | v0.11.0 移行時に旧フローのトークンが自動削除された正常動作。device flow で再認証してください | | KV エラーが出る | `wrangler.toml` の KV ID が `wrangler kv namespace create` の出力と一致しているか確認 | | MCP ツールが応答しない | Worker がデプロイされているか `wrangler tail` でログを確認 | diff --git a/docs/installation.md b/docs/installation.md index bccf36a..3a1c501 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -15,6 +15,22 @@ > **前提:** Node.js 18+ が必要です(ローカル MCP ブリッジの実行に使用)。 +### 初回認証(OAuth Device Flow) + +v0.11.0 以降、MCP クライアントは **OAuth 2.1 Device Authorization Grant (RFC 8628)** で認証します。初回接続時に以下のメッセージが Claude Code の stderr ログに出力されます: + +``` +[github-webhook-mcp] OAuth device authorization required. +[github-webhook-mcp] Visit: https://github.com/login/device +[github-webhook-mcp] Enter code: WDJB-MJHT +[github-webhook-mcp] Or open directly: https://github.com/login/device?user_code=WDJB-MJHT +[github-webhook-mcp] Waiting for approval (expires in 600s)... +``` + +ブラウザで `https://github.com/login/device` を開き、表示された 8 文字の `user_code` を入力してください。承認後、自動的にトークンが発行され、`~/.github-webhook-mcp/oauth-tokens.json` に保存されます。以降の起動では保存済みトークンが再利用され、期限切れ前に自動でリフレッシュされます。 + +> **旧バージョンからの移行:** v0.10.x 以前の localhost callback flow を使っていた場合、初回起動時に旧トークンファイルが自動削除され、migration 通知が stderr に出力されます。表示される device code を入力して一度だけ再認証してください。 + ### Claude Desktop — デスクトップ拡張 (.mcpb) [Releases](https://github.com/Liplus-Project/github-webhook-mcp/releases) から `mcp-server.mcpb` をダウンロード: @@ -142,11 +158,16 @@ id = "<ここに KV Namespace ID を貼り付け>" #### OAuth 設定(MCP リモート接続に必要) -| 項目 | 値 | -|------|-----| -| **Callback URL** | `https:///oauth/callback` | +v0.11.0 以降、OAuth は **Device Authorization Grant (RFC 8628)** で動作します。ローカル MCP クライアントは localhost callback ポートに依存しません。 -Client ID と Client secret を生成・メモしてください(ステップ 5 で使用)。 +1. **Identifying and authorizing users** セクションで **"Enable Device Flow"** チェックボックスを **ON** にする(必須) + - 未有効の場合、Worker の `/oauth/device_authorization` が `503 device_flow_disabled` を返し、MCP クライアントが認証できません +2. **Callback URL** は **空欄のままで構いません**(device flow では使用されない。旧フローとの後方互換のため任意のダミー URL を入れてもよい) +3. Client ID を生成・メモしてください(ステップ 5 で使用) +4. **Client secret は不要です**(device flow の public client として動作するため) + - 既存 App で secret を発行済みの場合はそのままでも問題ありません(未使用のまま) + +> **重要:** v0.11.0 より前の localhost callback flow から移行するユーザーは、初回接続時に自動的に device flow で再認証が要求されます(`~/.github-webhook-mcp/oauth-tokens.json` の旧ファイルは自動で破棄され、stderr に移行通知が出力されます)。Claude Code のログで `https://github.com/login/device` の案内と `user_code` を確認して入力してください。 #### パーミッション @@ -178,28 +199,31 @@ Client ID と Client secret を生成・メモしてください(ステップ ### 5. シークレットの設定 -3 つのシークレットを Cloudflare に登録。ダッシュボードの **Workers & Pages** → Worker → **Settings** → **Variables and Secrets** から設定するか、wrangler CLI で設定します: +必要なシークレットを Cloudflare に登録。ダッシュボードの **Workers & Pages** → Worker → **Settings** → **Variables and Secrets** から設定するか、wrangler CLI で設定します: ```bash -# GitHub App の Webhook secret +# GitHub App の Webhook secret(必須) npx wrangler secret put GITHUB_WEBHOOK_SECRET -# GitHub App の OAuth Client ID +# GitHub App の Client ID(device flow で必須) npx wrangler secret put GITHUB_CLIENT_ID -# GitHub App の OAuth Client Secret +# GitHub App の Client Secret(device flow では未使用だが、upstream API が +# 将来 confidential client に変わった場合に備えて設定を残してあります) npx wrangler secret put GITHUB_CLIENT_SECRET ``` 各コマンドでプロンプトが表示されるので、対応する値を入力してください。 +> **注意:** device flow の public client として動作するため `GITHUB_CLIENT_SECRET` は実際の認証には使用されません。未登録でも認証は成立しますが、互換性のためダミー値(例: `unused`)を入れておくことを推奨します。 + ### 6. カスタムドメイン(オプション) デフォルトの `*.workers.dev` URL の代わりにカスタムドメインを使用するには: 1. Cloudflare ダッシュボード → **Workers & Pages** → Worker → **Settings** → **Domains & Routes** 2. カスタムドメインを追加(例: `github-webhook.example.com`) -3. GitHub App の Webhook URL と Callback URL をカスタムドメインに更新 +3. GitHub App の Webhook URL をカスタムドメインに更新(device flow は Callback URL 不要) 4. MCP クライアント設定の `WEBHOOK_WORKER_URL` を更新 ### 7. WAF ルール(推奨) @@ -245,6 +269,9 @@ claude --dangerously-load-development-channels server:github-webhook-mcp |------|-----------| | Webhook が 403 を返す | `GITHUB_WEBHOOK_SECRET` が GitHub App の設定と一致していない。両方の値を確認 | | Webhook が 429 を返す | テナントクォータ(デフォルト 10,000 イベント)を超過。古いイベントを `mark_processed` で処理 | -| OAuth ログインが失敗する | `GITHUB_CLIENT_ID` と `GITHUB_CLIENT_SECRET` が正しいか確認。Callback URL が一致しているか確認 | +| `/oauth/device_authorization` が 503 を返す | GitHub App で **"Enable Device Flow"** が有効になっていない。ステップ 4 の OAuth 設定で再確認 | +| `/oauth/authorize` / `/oauth/callback` が 410 Gone を返す | v0.10.x 以前のクライアントが使っていた旧エンドポイント。MCP クライアントを最新版に更新してください(新クライアントは自動的に device flow にフォールバック) | +| Claude Code のログに device code が表示されない | stderr の出力を確認。`[github-webhook-mcp] OAuth device authorization required.` のセクションに `user_code` と `https://github.com/login/device` が出力されているはず | +| `~/.github-webhook-mcp/oauth-tokens.json` が消えた | v0.11.0 移行時に旧フローのトークンが自動削除された正常動作。device flow で再認証してください | | KV エラーが出る | `wrangler.toml` の KV ID が `wrangler kv namespace create` の出力と一致しているか確認 | | MCP ツールが応答しない | Worker がデプロイされているか `wrangler tail` でログを確認 | diff --git a/mcp-server/README.md b/mcp-server/README.md index 1095d07..389aaa8 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -9,14 +9,14 @@ This package is the **client-side proxy only**. Webhook ingestion, tenant routin - Speaks stdio MCP locally to your client. - Forwards `tools/call` to the Worker's Streamable HTTP MCP endpoint (`/mcp`). - Optionally maintains a WebSocket connection to the Worker's `/events` endpoint and re-emits incoming webhook events as Claude Code `claude/channel` notifications (real-time push, no polling). -- Handles OAuth 2.1 with PKCE against the Worker (browser-based localhost callback) and Dynamic Client Registration (RFC 7591). +- Handles OAuth 2.1 **Device Authorization Grant (RFC 8628)** against the Worker and Dynamic Client Registration (RFC 7591). No localhost callback port is used, so the flow works reliably across process restarts and concurrent client instances. - Caches access and refresh tokens under `~/.github-webhook-mcp/` (mode `0600`) and refreshes them silently before expiry. ## Requirements - Node.js >= 18 - A reachable github-webhook-mcp Worker (the public preview default is `https://github-webhook.smgjp.com`; you can also point at your own deployment) -- A web browser on the same machine (used once for OAuth authorization) +- A web browser (used once to approve the device code at `https://github.com/login/device` — can be on a different machine from where the MCP client runs) - A GitHub App installed on the accounts/organizations whose events you want to receive (the Worker resolves your accessible installations automatically after OAuth) ## Install / Run @@ -36,7 +36,21 @@ npm install -g github-webhook-mcp github-webhook-mcp ``` -The first run opens a browser window to complete OAuth against the Worker. After authorization, tokens are stored under `~/.github-webhook-mcp/` and refreshed automatically. +On first run the proxy prints a GitHub device code and verification URL to stderr: + +``` +[github-webhook-mcp] OAuth device authorization required. +[github-webhook-mcp] Visit: https://github.com/login/device +[github-webhook-mcp] Enter code: WDJB-MJHT +[github-webhook-mcp] Or open directly: https://github.com/login/device?user_code=WDJB-MJHT +[github-webhook-mcp] Waiting for approval (expires in 600s)... +``` + +Open the verification URL in any browser, enter the 8-character code, and authorize the GitHub App. Tokens are stored under `~/.github-webhook-mcp/` and refreshed automatically before expiry. + +> **Migrating from v0.10.x or earlier.** The previous versions used a browser-based localhost callback flow. On first run with v0.11.0+, the proxy detects the legacy tokens file, removes it, prints a migration notice to stderr, and starts the device flow. One-time re-authentication is required. + +> **Enable Device Flow on self-hosted GitHub Apps.** If you self-host the Worker with your own GitHub App, toggle **"Enable Device Flow"** in the App settings (under *Identifying and authorizing users*). Without it the Worker returns `503 device_flow_disabled`. See the [self-hosting guide](https://github.com/Liplus-Project/github-webhook-mcp/blob/main/docs/installation.md#4-github-app-の作成と設定) for step-by-step instructions. ## Client configuration @@ -135,21 +149,24 @@ If real-time channel notifications are enabled (Claude Code), step 1 can be skip ## Authentication flow 1. On first tool call (or on startup if cached tokens exist), the proxy discovers OAuth metadata at `${WEBHOOK_WORKER_URL}/.well-known/oauth-authorization-server`. -2. It performs Dynamic Client Registration (RFC 7591) if no client is cached. -3. It starts a one-shot localhost HTTP listener on a random port and opens the browser to the Worker's authorization endpoint. -4. After you approve, the Worker redirects to `http://127.0.0.1:/callback` with an authorization code. -5. The proxy exchanges the code for tokens (PKCE S256) and saves them. -6. The Worker resolves your accessible GitHub installations (your user account plus any organizations where the GitHub App is installed) and binds them to the OAuth session, so events from any of those tenants surface through the same MCP session. -7. Subsequent calls reuse the access token and silently refresh it five minutes before expiry. On `401` from the Worker, the proxy invalidates its cached tokens and re-authenticates automatically. +2. It performs Dynamic Client Registration (RFC 7591) if no client is cached, declaring support for the `urn:ietf:params:oauth:grant-type:device_code` and `refresh_token` grant types (public client, no secret). +3. It requests a device code from the Worker's `/oauth/device_authorization` endpoint. The Worker proxies that request to GitHub's `POST https://github.com/login/device/code` and returns an RFC 8628 §3.2 response. +4. The proxy prints the `user_code` and `verification_uri` to stderr. You open the URL in any browser and enter the code. +5. The proxy polls `/oauth/token` with `grant_type=urn:ietf:params:oauth:grant-type:device_code` at the interval the server specifies. `authorization_pending` and `slow_down` responses are handled per RFC 8628 §3.5. +6. When you approve, the Worker exchanges the GitHub device code for a GitHub access token, fetches your GitHub profile + installations, and issues its own opaque access/refresh token pair bound to that grant. +7. The Worker resolves your accessible GitHub installations (your user account plus any organizations where the GitHub App is installed) and binds them to the OAuth session, so events from any of those tenants surface through the same MCP session. +8. Subsequent calls reuse the access token and silently refresh it five minutes before expiry. On `401` from the Worker, the proxy invalidates its cached tokens and re-authenticates automatically (falling back to the device flow if the refresh token is also rejected). -The authorization code is delivered directly to the local listener; it never leaves your machine. +No localhost port is listened on at any point. The flow works the same way on headless hosts and across concurrent MCP client instances. ## Troubleshooting -- **Browser does not open.** The proxy logs the authorization URL to stderr; copy it into a browser manually. -- **`OAuth callback timed out after 5 minutes`.** Re-invoke any tool to restart the flow. +- **Device code never appears in the log.** Check the stderr stream of the MCP process (Claude Code surfaces it as the server's log). Look for the `[github-webhook-mcp] OAuth device authorization required.` block. +- **`OAuth device code expired before approval. Re-run the client to retry.`** The code expires after ~10 minutes. Trigger any tool call again to restart the flow. +- **`/oauth/device_authorization` returns 503.** The upstream GitHub App does not have **Enable Device Flow** turned on. Self-hosters must enable it in the GitHub App settings. - **`Failed to reach worker`.** Check that `WEBHOOK_WORKER_URL` is correct and reachable from your machine. - **`Authentication failed after retry`.** Cached tokens were rejected and re-authentication did not succeed. Remove `~/.github-webhook-mcp/oauth-tokens.json` and retry. +- **Legacy tokens migration.** If you see `Detected legacy OAuth tokens from pre-v0.11.0`, this is normal on the first run after upgrading. The old file has been removed and a device-code prompt follows. Complete it once. - **No events arriving.** Confirm that the GitHub App is installed on the target account/organization and that webhook deliveries are succeeding on the GitHub App's *Advanced* → *Recent Deliveries* page. The Worker only sees events for installations linked to your authenticated account. - **`429` from the Worker.** The per-tenant event quota (default 10,000) has been exceeded. Process the backlog with `mark_processed` to free space. - **Real-time notifications not showing in Claude Code.** Make sure `WEBHOOK_CHANNEL` is not set to `0` and that Claude Code was launched with `--dangerously-load-development-channels server:github-webhook-mcp`. diff --git a/mcp-server/package.json b/mcp-server/package.json index b6ed0fe..faf89d3 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -14,7 +14,7 @@ ], "scripts": { "start": "node server/index.js", - "test": "node --check server/index.js", + "test": "node --check server/index.js && node --test test/*.test.mjs", "pack:mcpb": "mcpb pack" }, "dependencies": { diff --git a/mcp-server/test/migration.test.mjs b/mcp-server/test/migration.test.mjs new file mode 100644 index 0000000..81a10f7 --- /dev/null +++ b/mcp-server/test/migration.test.mjs @@ -0,0 +1,116 @@ +/** + * Client-side migration contract tests for github-webhook-mcp (npm package). + * + * The Worker-side bespoke OAuth implementation (worker/src/oauth.ts) rejects tokens + * that do not originate from a device-flow client. Clients signal "this file was + * written by the v0.11.0+ device-flow flow" with a `flow: "device"` marker on the + * tokens file. Legacy files (pre-v0.11.0, localhost-callback flow) lack that marker + * and must be treated as stale on load so the client can surface a one-time + * migration notice and re-authenticate. + * + * The semantics implemented in mcp-server/server/index.js (and its TypeScript twin + * local-mcp/src/index.ts) are: + * - loadTokens() returns null when `flow !== "device"` + * - checkLegacyTokensMigration() unlinks the legacy file and prints a stderr notice + * - saveTokens() always writes `flow: "device"` on the way back out + * + * These tests are intentionally black-box over the JSON contract rather than + * importing index.js directly — the module has top-level `await mcp.connect(...)` + * that would start an MCP server on import. We re-implement the minimum predicate + * here and verify it against sample payloads representative of both flows. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const TOKENS_FLOW_MARKER = "device"; + +/** Predicate mirrored from mcp-server/server/index.js :: loadTokens(). */ +function isActiveTokensFile(parsed) { + if (!parsed || typeof parsed !== "object") return false; + return parsed.flow === TOKENS_FLOW_MARKER; +} + +/** Shape a legacy (pre-v0.11.0, localhost-callback) tokens file would have on disk. */ +const LEGACY_TOKENS = { + access_token: "gho_legacylocalhostflow", + refresh_token: "ghr_legacylocalhostflow", + expires_at: Date.now() + 3600_000, +}; + +/** Shape a v0.11.0+ device-flow tokens file has on disk. */ +const DEVICE_TOKENS = { + flow: TOKENS_FLOW_MARKER, + access_token: "bespoke_access_token_value", + refresh_token: "bespoke_refresh_token_value", + expires_at: Date.now() + 3600_000, +}; + +test("legacy tokens file (no flow marker) is rejected by the active-file predicate", () => { + assert.equal(isActiveTokensFile(LEGACY_TOKENS), false); +}); + +test("device-flow tokens file (flow=device) is accepted", () => { + assert.equal(isActiveTokensFile(DEVICE_TOKENS), true); +}); + +test("malformed files are treated as inactive", () => { + assert.equal(isActiveTokensFile(null), false); + assert.equal(isActiveTokensFile({}), false); + assert.equal(isActiveTokensFile({ flow: "" }), false); + assert.equal(isActiveTokensFile({ flow: "authorization_code" }), false); + assert.equal(isActiveTokensFile({ access_token: "x" }), false); // no flow at all +}); + +test("round-trip: legacy file replaced by device-flow file on disk", async () => { + // Simulate the sequence: + // 1. A pre-v0.11.0 client wrote a localhost-flow tokens file to ~/.github-webhook-mcp/oauth-tokens.json. + // 2. The user upgrades to v0.11.0; on first run the client unlinks the legacy file + // and writes a device-flow file in its place. + // The key invariant: after migration, the file on disk parses as `flow === "device"`. + const dir = await mkdtemp(join(tmpdir(), "github-webhook-mcp-migration-")); + try { + const tokenFile = join(dir, "oauth-tokens.json"); + + // Write legacy file + await writeFile(tokenFile, JSON.stringify(LEGACY_TOKENS, null, 2), { mode: 0o600 }); + const before = JSON.parse(await readFile(tokenFile, "utf-8")); + assert.equal(isActiveTokensFile(before), false); + + // Simulate post-migration overwrite with device-flow payload + await writeFile(tokenFile, JSON.stringify(DEVICE_TOKENS, null, 2), { mode: 0o600 }); + const after = JSON.parse(await readFile(tokenFile, "utf-8")); + assert.equal(isActiveTokensFile(after), true); + assert.equal(after.access_token, DEVICE_TOKENS.access_token); + assert.equal(after.flow, TOKENS_FLOW_MARKER); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("client registration file shape: device_code + refresh_token grant types", () => { + // The client re-registers when existing registration lacks the device_code grant type. + // This test asserts the shape the Worker will accept (see worker/src/oauth.ts handleRegister). + const deviceFlowClient = { + client_id: "abc123", + client_name: "github-webhook-mcp-cli", + redirect_uris: [], + grant_types: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"], + token_endpoint_auth_method: "none", + }; + const legacyClient = { + client_id: "legacy456", + grant_types: ["authorization_code", "refresh_token"], + }; + + const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; + const hasDeviceGrant = (reg) => + Boolean(reg && Array.isArray(reg.grant_types) && reg.grant_types.includes(DEVICE_CODE_GRANT)); + + assert.equal(hasDeviceGrant(deviceFlowClient), true); + assert.equal(hasDeviceGrant(legacyClient), false); + assert.equal(hasDeviceGrant(null), false); + assert.equal(hasDeviceGrant({ grant_types: "not-an-array" }), false); +}); diff --git a/worker/package-lock.json b/worker/package-lock.json index fba6660..9262e82 100644 --- a/worker/package-lock.json +++ b/worker/package-lock.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.0.0", + "tsx": "^4.21.0", "typescript": "^5.5.0", "wrangler": "^4.0.0" } @@ -2149,6 +2150,19 @@ "node": ">= 0.4" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2739,6 +2753,16 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -3050,6 +3074,26 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", diff --git a/worker/package.json b/worker/package.json index 666b7a1..66791eb 100644 --- a/worker/package.json +++ b/worker/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "wrangler dev", - "deploy": "wrangler deploy" + "deploy": "wrangler deploy", + "test": "tsx --test test/*.test.ts" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", @@ -14,6 +15,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.0.0", + "tsx": "^4.21.0", "typescript": "^5.5.0", "wrangler": "^4.0.0" } diff --git a/worker/test/oauth.test.ts b/worker/test/oauth.test.ts new file mode 100644 index 0000000..5bfc52b --- /dev/null +++ b/worker/test/oauth.test.ts @@ -0,0 +1,490 @@ +/** + * Integration tests for the Worker's bespoke OAuth device-flow implementation. + * + * Covers the Step 4 (#202) scenarios that can run deterministically in CI: + * - existing-user migration: legacy /oauth/authorize and /oauth/callback return 410 + * - new-user onboarding: /.well-known + /oauth/register + /oauth/device_authorization + * + /oauth/token (device_code) → access_token + refresh_token + * - concurrent-instance: refresh_token rotation invalidates the previous token + * - process-restart: access_token validates against the same KV after simulated + * restart (new AuthContext via authenticateApiRequest) + * + * The GitHub upstream is stubbed by swapping globalThis.fetch. The KV namespace is a + * Map-backed mock that matches the subset of the KVNamespace API oauth-store.ts uses. + * + * End-to-end scenarios that depend on a real user visiting https://github.com/login/device + * are NOT covered here — those remain on the manual verification checklist documented + * in docs/installation.md and in the PR body. + */ +import { test, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +import { handleOAuthRequest, authenticateApiRequest } from "../src/oauth.js"; +import type { OAuthEnv } from "../src/oauth.js"; + +// ── In-memory KV mock ──────────────────────────────────────────────── + +class MockKV { + private store = new Map(); + async get(key: string): Promise { + return this.store.has(key) ? this.store.get(key)! : null; + } + async put(key: string, value: string, _opts?: unknown): Promise { + this.store.set(key, value); + } + async delete(key: string): Promise { + this.store.delete(key); + } + list() { + return Array.from(this.store.keys()); + } + raw(key: string) { + return this.store.get(key) ?? null; + } +} + +// ── GitHub upstream fetch stub ─────────────────────────────────────── + +type FetchHandler = (req: Request) => Promise | Response; +let fetchHandler: FetchHandler | null = null; +const realFetch = globalThis.fetch; + +before(() => { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(input, init); + if (!fetchHandler) { + throw new Error(`Unhandled fetch in test: ${req.method} ${req.url}`); + } + return fetchHandler(req); + }) as typeof fetch; +}); + +after(() => { + globalThis.fetch = realFetch; +}); + +beforeEach(() => { + fetchHandler = null; +}); + +// ── Helpers ────────────────────────────────────────────────────────── + +function makeEnv(): OAuthEnv { + return { + OAUTH_KV: new MockKV() as unknown as KVNamespace, + GITHUB_CLIENT_ID: "test-github-client-id", + GITHUB_CLIENT_SECRET: "test-github-client-secret", + }; +} + +function formRequest(url: string, body: Record): Request { + return new Request(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(body).toString(), + }); +} + +function jsonRequest(url: string, body: unknown): Request { + return new Request(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function registerClient(env: OAuthEnv): Promise { + const res = await handleOAuthRequest( + jsonRequest("https://worker.example.com/oauth/register", { + client_name: "test-client", + redirect_uris: [], + grant_types: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"], + token_endpoint_auth_method: "none", + }), + env, + ); + assert.ok(res, "register must return a response"); + assert.equal(res.status, 201); + const body = await res.json() as { client_id: string }; + return body.client_id; +} + +// ── Legacy endpoints (existing-user migration) ─────────────────────── + +test("legacy /oauth/authorize returns 410 Gone so old clients fail loudly", async () => { + const env = makeEnv(); + const res = await handleOAuthRequest( + new Request("https://worker.example.com/oauth/authorize?client_id=x&response_type=code"), + env, + ); + assert.ok(res); + assert.equal(res.status, 410); +}); + +test("legacy /oauth/callback returns 410 Gone", async () => { + const env = makeEnv(); + const res = await handleOAuthRequest( + new Request("https://worker.example.com/oauth/callback?code=dummy"), + env, + ); + assert.ok(res); + assert.equal(res.status, 410); +}); + +// ── Metadata (RFC 8414) ────────────────────────────────────────────── + +test("metadata advertises device_code + refresh_token, no authorization_code", async () => { + const env = makeEnv(); + const res = await handleOAuthRequest( + new Request("https://worker.example.com/.well-known/oauth-authorization-server"), + env, + ); + assert.ok(res); + const meta = await res.json() as { + device_authorization_endpoint: string; + token_endpoint: string; + grant_types_supported: string[]; + }; + assert.ok(meta.device_authorization_endpoint.endsWith("/oauth/device_authorization")); + assert.ok(meta.token_endpoint.endsWith("/oauth/token")); + assert.deepEqual(meta.grant_types_supported.sort(), [ + "refresh_token", + "urn:ietf:params:oauth:grant-type:device_code", + ]); +}); + +// ── Dynamic client registration (RFC 7591) ─────────────────────────── + +test("POST /oauth/register issues a public client (no secret)", async () => { + const env = makeEnv(); + const clientId = await registerClient(env); + assert.match(clientId, /^[A-Za-z0-9_-]+$/); +}); + +// ── New-user onboarding: device flow happy path ────────────────────── + +test("new-user onboarding: device_authorization + token(device_code) issues tokens", async () => { + const env = makeEnv(); + const clientId = await registerClient(env); + + // Stub GitHub device code + token endpoints + user/installations. + let pollCount = 0; + fetchHandler = async (req) => { + if (req.url === "https://github.com/login/device/code") { + return new Response( + JSON.stringify({ + device_code: "gh-device-code-abc", + user_code: "WDJB-MJHT", + verification_uri: "https://github.com/login/device", + expires_in: 600, + interval: 0, // keep test fast; we also override next_poll_at directly + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (req.url === "https://github.com/login/oauth/access_token") { + pollCount++; + if (pollCount === 1) { + return new Response( + JSON.stringify({ error: "authorization_pending" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response( + JSON.stringify({ + access_token: "ghu_live-access", + refresh_token: "ghr_live-refresh", + token_type: "bearer", + expires_in: 28800, + refresh_token_expires_in: 15897600, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (req.url === "https://api.github.com/user") { + return new Response( + JSON.stringify({ id: 42, login: "octocat" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (req.url.startsWith("https://api.github.com/user/installations")) { + return new Response( + JSON.stringify({ + installations: [ + { account: { id: 42 } }, + { account: { id: 4242 } }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + throw new Error(`Unexpected upstream fetch: ${req.url}`); + }; + + // Device authorization + const daRes = await handleOAuthRequest( + formRequest("https://worker.example.com/oauth/device_authorization", { client_id: clientId }), + env, + ); + assert.ok(daRes); + assert.equal(daRes.status, 200); + const daBody = await daRes.json() as { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; + }; + assert.equal(daBody.device_code, "gh-device-code-abc"); + assert.equal(daBody.user_code, "WDJB-MJHT"); + assert.equal(daBody.verification_uri, "https://github.com/login/device"); + assert.ok(daBody.verification_uri_complete.includes("user_code=WDJB-MJHT")); + + // First poll: authorization_pending + const pendingRes = await handleOAuthRequest( + formRequest("https://worker.example.com/oauth/token", { + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: daBody.device_code, + client_id: clientId, + }), + env, + ); + assert.ok(pendingRes); + assert.equal(pendingRes.status, 400); + const pendingBody = await pendingRes.json() as { error: string }; + assert.equal(pendingBody.error, "authorization_pending"); + + // Second poll: approval → token pair issued + const okRes = await handleOAuthRequest( + formRequest("https://worker.example.com/oauth/token", { + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: daBody.device_code, + client_id: clientId, + }), + env, + ); + assert.ok(okRes); + assert.equal(okRes.status, 200); + const tokenBody = await okRes.json() as { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; + }; + assert.equal(tokenBody.token_type, "Bearer"); + assert.ok(tokenBody.access_token); + assert.ok(tokenBody.refresh_token); + assert.ok(tokenBody.expires_in > 0); + + // The issued access_token validates against the middleware and carries props. + const authReq = new Request("https://worker.example.com/mcp", { + headers: { Authorization: `Bearer ${tokenBody.access_token}` }, + }); + const authResult = await authenticateApiRequest(authReq, env); + assert.ok("auth" in authResult, "Bearer must validate"); + assert.equal(authResult.auth.props.githubLogin, "octocat"); + assert.equal(authResult.auth.props.githubUserId, 42); + // accessibleAccountIds must include user + org installation ids, de-duplicated. + assert.deepEqual( + [...authResult.auth.props.accessibleAccountIds].sort((a, b) => a - b), + [42, 4242], + ); +}); + +// ── Unknown / missing Bearer ───────────────────────────────────────── + +test("authenticateApiRequest rejects missing Bearer", async () => { + const env = makeEnv(); + const res = await authenticateApiRequest( + new Request("https://worker.example.com/mcp"), + env, + ); + assert.ok("response" in res); + assert.equal(res.response.status, 401); +}); + +test("authenticateApiRequest rejects unknown token", async () => { + const env = makeEnv(); + const res = await authenticateApiRequest( + new Request("https://worker.example.com/mcp", { + headers: { Authorization: "Bearer totally-not-a-real-token" }, + }), + env, + ); + assert.ok("response" in res); + assert.equal(res.response.status, 401); +}); + +// ── Refresh rotation (concurrent-instance scenario) ────────────────── + +test("refresh_token rotation invalidates the previous access and refresh tokens", async () => { + const env = makeEnv(); + const clientId = await registerClient(env); + + // Onboard first so we have a real token pair to rotate. + fetchHandler = async (req) => { + if (req.url === "https://github.com/login/device/code") { + return new Response(JSON.stringify({ + device_code: "dc-rot", user_code: "AAAA-BBBB", + verification_uri: "https://github.com/login/device", + expires_in: 600, interval: 0, + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (req.url === "https://github.com/login/oauth/access_token") { + return new Response(JSON.stringify({ + access_token: "ghu_x", refresh_token: "ghr_x", + token_type: "bearer", expires_in: 28800, + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (req.url === "https://api.github.com/user") { + return new Response(JSON.stringify({ id: 7, login: "rotuser" }), + { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (req.url.startsWith("https://api.github.com/user/installations")) { + return new Response(JSON.stringify({ installations: [] }), + { status: 200, headers: { "Content-Type": "application/json" } }); + } + throw new Error(`Unexpected: ${req.url}`); + }; + + await handleOAuthRequest( + formRequest("https://worker.example.com/oauth/device_authorization", { client_id: clientId }), + env, + ); + const issueRes = await handleOAuthRequest( + formRequest("https://worker.example.com/oauth/token", { + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: "dc-rot", client_id: clientId, + }), + env, + ); + const first = await issueRes!.json() as { access_token: string; refresh_token: string }; + + // Rotate via refresh_token. + const rotRes = await handleOAuthRequest( + formRequest("https://worker.example.com/oauth/token", { + grant_type: "refresh_token", + refresh_token: first.refresh_token, + client_id: clientId, + }), + env, + ); + assert.equal(rotRes!.status, 200); + const rotated = await rotRes!.json() as { access_token: string; refresh_token: string }; + assert.notEqual(rotated.access_token, first.access_token); + assert.notEqual(rotated.refresh_token, first.refresh_token); + + // Old access_token must now fail validation (invalidated by rotation). + const stale = await authenticateApiRequest( + new Request("https://worker.example.com/mcp", { + headers: { Authorization: `Bearer ${first.access_token}` }, + }), + env, + ); + assert.ok("response" in stale); + assert.equal(stale.response.status, 401); + + // Old refresh_token must fail as well. + const staleRef = await handleOAuthRequest( + formRequest("https://worker.example.com/oauth/token", { + grant_type: "refresh_token", + refresh_token: first.refresh_token, + client_id: clientId, + }), + env, + ); + assert.equal(staleRef!.status, 400); + const staleBody = await staleRef!.json() as { error: string }; + assert.equal(staleBody.error, "invalid_grant"); + + // New access_token still works. + const fresh = await authenticateApiRequest( + new Request("https://worker.example.com/mcp", { + headers: { Authorization: `Bearer ${rotated.access_token}` }, + }), + env, + ); + assert.ok("auth" in fresh); +}); + +// ── Process-restart token persistence ──────────────────────────────── + +test("process-restart: tokens stored in KV remain valid across fresh authenticator invocations", async () => { + const env = makeEnv(); + const clientId = await registerClient(env); + + fetchHandler = async (req) => { + if (req.url === "https://github.com/login/device/code") { + return new Response(JSON.stringify({ + device_code: "dc-persist", user_code: "CCCC-DDDD", + verification_uri: "https://github.com/login/device", + expires_in: 600, interval: 0, + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (req.url === "https://github.com/login/oauth/access_token") { + return new Response(JSON.stringify({ + access_token: "ghu_persist", refresh_token: "ghr_persist", + token_type: "bearer", expires_in: 28800, + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (req.url === "https://api.github.com/user") { + return new Response(JSON.stringify({ id: 99, login: "persist" }), + { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (req.url.startsWith("https://api.github.com/user/installations")) { + return new Response(JSON.stringify({ installations: [] }), + { status: 200, headers: { "Content-Type": "application/json" } }); + } + throw new Error(`Unexpected: ${req.url}`); + }; + + await handleOAuthRequest( + formRequest("https://worker.example.com/oauth/device_authorization", { client_id: clientId }), + env, + ); + const tokRes = await handleOAuthRequest( + formRequest("https://worker.example.com/oauth/token", { + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: "dc-persist", client_id: clientId, + }), + env, + ); + const issued = await tokRes!.json() as { access_token: string }; + + // Simulate a process restart: drop the fetch stub (nothing should be called), + // then validate the same access_token again via a brand-new Request. + fetchHandler = null; + const reopened = await authenticateApiRequest( + new Request("https://worker.example.com/mcp", { + headers: { Authorization: `Bearer ${issued.access_token}` }, + }), + env, + ); + assert.ok("auth" in reopened, "persisted token must still validate after restart"); + assert.equal(reopened.auth.props.githubLogin, "persist"); +}); + +// ── GitHub 'device_flow_disabled' returns 503 so clients can surface a clear message + +test("GitHub device_flow_disabled surfaces as 503 (not a silent auth loop)", async () => { + const env = makeEnv(); + const clientId = await registerClient(env); + + fetchHandler = async (req) => { + if (req.url === "https://github.com/login/device/code") { + return new Response(JSON.stringify({ + error: "device_flow_disabled", + error_description: "Device flow is not enabled on this GitHub App.", + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + throw new Error(`Unexpected: ${req.url}`); + }; + + const res = await handleOAuthRequest( + formRequest("https://worker.example.com/oauth/device_authorization", { client_id: clientId }), + env, + ); + assert.ok(res); + assert.equal(res.status, 503); +});