From a4719c13bebe3be292458511b1261bcfa67265fb Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Tue, 21 Apr 2026 00:01:13 +0900 Subject: [PATCH] refactor(oauth): replace device flow with Worker-hosted web OAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.11.0 で導入した device authorization grant (RFC 8628) を撤去し、 Worker がホストする web OAuth flow に戻す。GitHub 標準のログイン + 2FA の UX に回帰しつつ、v0.10.x で慢性的に発生していた auth loop の 2 つの root cause を構造的に解消する。 RC1 (refresh rotation desync): MCP bridge 側で refresh が invalid_grant を返したとき、直ちに re-auth に移行せず tokens file を再読み込みする。別プロセスが rotation 済みなら最新 refresh_token を採用して再試行。file lock は導入しない。 RC2 (localhost ephemeral port): Worker 側で `redirect_uri` を `https:///oauth/callback` に固定し、localhost listener 依存 を完全に消した。GitHub の callback は常に Worker に戻り、tenant は GitHub user_id で分離する既存の TenantRegistry DO に委ねる。 - Worker: /oauth/authorize で state を発行して GitHub に 302、 /oauth/callback で confidential client として code→token 交換、 /oauth/token に web_authorization_poll grant_type を追加。 OAUTH_KV schema に web_auth_state レコードを追加、device:/user_code: キーは撤去。 - mcp-server (JS) / local-mcp (TS) bridges: device flow 関連関数 (performOAuthFlow / requestDeviceAuthorization / pollForDeviceToken / AuthRequiredError / formatAuthRequiredResponse / getAccessTokenForToolCall の device-flow ブランチ) を全削除し、web flow poll に書き直し。 openBrowser は web flow の authorize URL を開く用途で流用。 tokens file の flow marker は "device" → "web" に更新。 - docs: F7 を全面書き直し、architecture 図更新、installation.ja.md / installation.md の初回認証節と GitHub App 設定節を web flow 用に 書き直し。mcp-server/README.md の該当節も更新。 - tests: worker/test/oauth.test.ts を web flow 用に書き直し、 mcp-server/test/auth-required.test.mjs を削除し新しく web-auth-required.test.mjs を追加、migration.test.mjs は flow="web" ベースに更新。 version は 0.11.1 を維持(β方針で device flow UX patch のマージ履歴 と revert を同じ v0.11.1 タグに載せる)。 GitHub App `liplus-webhook-mcp` に `redirect_uri=https://github-webhook.smgjp.com/oauth/callback` の 登録が必要(Master 側の手動作業)。 Closes #195 Refs #209 --- docs/0-requirements.ja.md | 47 +- docs/0-requirements.md | 43 +- docs/installation.ja.md | 71 ++- docs/installation.md | 71 ++- local-mcp/src/index.ts | 439 ++++++++----------- mcp-server/README.md | 44 +- mcp-server/package-lock.json | 4 +- mcp-server/server/index.js | 402 ++++++++--------- mcp-server/test/auth-required.test.mjs | 146 ------- mcp-server/test/migration.test.mjs | 137 ++++-- mcp-server/test/web-auth-required.test.mjs | 111 +++++ worker/src/index.ts | 19 +- worker/src/oauth-store.ts | 94 ++-- worker/src/oauth.ts | 481 +++++++++++--------- worker/test/oauth.test.ts | 482 +++++++++++---------- 15 files changed, 1278 insertions(+), 1313 deletions(-) delete mode 100644 mcp-server/test/auth-required.test.mjs create mode 100644 mcp-server/test/web-auth-required.test.mjs diff --git a/docs/0-requirements.ja.md b/docs/0-requirements.ja.md index 3c81f02..1377dc3 100644 --- a/docs/0-requirements.ja.md +++ b/docs/0-requirements.ja.md @@ -21,14 +21,18 @@ GitHub --POST--> Cloudflare Worker --> TenantRegistry DO | v | WebhookStore DO (SQLite) [per-tenant] | | - +-- /mcp (Streamable HTTP) +-- SSE real-time stream + +-- /mcp (Streamable HTTP) +-- WebSocket / SSE real-time stream | WebhookMcpAgent DO +-- REST endpoints | [per-tenant] /pending-status | +-- tools -> WebhookStore /pending-events | /webhook-events - +-- /events (SSE) /event + +-- /events (WebSocket/SSE) /event | +-- WebhookStore DO /mark-processed | + +-- /oauth/authorize --> github.com/login/oauth/authorize + +-- /oauth/callback <-- github.com redirect_uri (Worker-hosted) + +-- /oauth/token (web_authorization_poll, refresh_token) + | +-- /webhooks/github (POST) +-- TenantRegistry -> WebhookStore DO /ingest @@ -36,7 +40,9 @@ GitHub --POST--> Cloudflare Worker --> TenantRegistry DO | Local MCP Bridge (.mcpb) | | stdio <- Claude Desktop/CLI | | -> proxy tool calls to /mcp | - | -> SSE listener -> channel | + | -> WebSocket listener -> channel | + | -> browser: /oauth/authorize | + | -> poll: /oauth/token | +-----------------------------+ ``` @@ -144,27 +150,28 @@ WebhookMcpAgent DO が以下のツールセットを提供する。ローカル | 3 | フルペイロードが必要なイベントのみ `get_event(event_id)` で取得 | | 4 | 処理完了後 `mark_processed(event_id)` でマーク | -### F7. OAuth 認証(Device Authorization Grant, RFC 8628) +### F7. OAuth 認証(Worker-hosted web OAuth) -Worker は OAuth 2.1 Device Authorization Grant を自前実装する(`@cloudflare/workers-oauth-provider` は v0.11.0 で撤去)。GitHub App の device flow を upstream として利用し、localhost callback に依存しない。 +Worker は GitHub の web OAuth flow をホストする独自実装を備える(v0.11.0 の device authorization grant は v0.11.1 で撤去。GitHub 標準のログイン + 2FA UX に回帰しつつ、v0.10.x の chronic auth loop 原因である localhost callback 依存と refresh rotation desync を構造的に解消する)。 | ID | 要件 | |----|------| -| F7.1 | `GET /.well-known/oauth-authorization-server` で RFC 8414 メタデータを返す | +| F7.1 | `GET /.well-known/oauth-authorization-server` で RFC 8414 メタデータを返す(`authorization_endpoint` / `token_endpoint` / `grant_types_supported=[urn:ietf:params:oauth:grant-type:web_authorization_poll, refresh_token]`) | | F7.2 | `POST /oauth/register` で RFC 7591 dynamic client registration を行う(public client、secret 発行なし) | -| F7.3 | `POST /oauth/device_authorization` で GitHub に device code を要求し、RFC 8628 §3.2 形式の JSON(device_code / user_code / verification_uri / verification_uri_complete / expires_in / interval)を返す | -| F7.4 | `POST /oauth/token` で `grant_type=urn:ietf:params:oauth:grant-type:device_code` を処理し、GitHub への polling 結果に応じて `authorization_pending` / `slow_down` / `access_denied` / `expired_token` を RFC 8628 §3.5 準拠で返す | -| F7.5 | `POST /oauth/token` で `grant_type=refresh_token` を処理し、access token と refresh token を rotate する | -| F7.6 | 旧 `GET /oauth/authorize` および `GET /oauth/callback` は **HTTP 410 Gone** を返す(localhost callback flow は v0.11.0 で廃止) | +| F7.3 | `GET /oauth/authorize?client_id=&state=[&scope=...]` で `web_auth_state:{state}` レコードを `pending` として作成し、`redirect_uri=https:///oauth/callback` を固定して `https://github.com/login/oauth/authorize` に 302 リダイレクトする | +| F7.4 | `GET /oauth/callback?code=&state=` で GitHub authorization code を confidential client として access token に交換し、`fetchGitHubProps()` で user profile + installations を取得、Worker 独自 bearer token pair を発行して `web_auth_state` を `approved` に遷移させる。ユーザにはタブを閉じるよう案内する HTML を返す | +| F7.5 | `POST /oauth/token` で `grant_type=urn:ietf:params:oauth:grant-type:web_authorization_poll` を処理する。`pending` → `400 authorization_pending`、`approved` → `200` で bearer pair を返し state レコードを消費、`denied` → `400 access_denied`、期限切れ → `400 expired_token`(RFC 8628 §3.5 のエラー形式を再利用) | +| F7.6 | `POST /oauth/token` で `grant_type=refresh_token` を処理し、access token と refresh token を rotate する(ブリッジ側 RC1 修正と組み合わせて desync を解消) | | F7.7 | 保護対象 API ルート(`/mcp`, `/events`)は `Authorization: Bearer ` ヘッダによる独自 token 検証 middleware で認可する | -| F7.8 | KV schema は自前設計: `client:{client_id}` / `device:{device_code}` / `user_code:{user_code}` / `token:{access_token}` / `refresh:{refresh_token}` / `grant:{grant_id}` | -| F7.9 | ローカルブリッジは device authorization 応答受信直後に `verification_uri_complete`(なければ `verification_uri`)を platform 既定のブラウザで自動オープンする。Windows は `cmd /c start`、macOS は `open`、Linux は `xdg-open` を使う。オープン失敗は fatal にしない(stderr に警告を残し、URL は応答と stderr で伝える) | -| F7.10 | ローカルブリッジは初回ツール呼び出しで device flow が完了していない場合、polling をバックグラウンドに維持したまま、`user_code` / `verification_uri_complete` / `verification_uri` / 残り有効秒数を本文に含む `isError: true` の構造化ツール応答を即座に返す。2 回目以降の同一ツール呼び出しは、承認完了なら通常処理、未完了なら同じ auth-required 応答を返す(ポーリングは 1 本に serialize) | +| F7.8 | KV schema は自前設計: `client:{client_id}` / `web_auth_state:{state}` / `token:{access_token}` / `refresh:{refresh_token}` / `grant:{grant_id}`。device flow 時代の `device:` / `user_code:` キーは撤去 | +| F7.9 | ローカルブリッジは authorize URL を platform 既定のブラウザで自動オープンする。Windows は `cmd /c start`、macOS は `open`、Linux は `xdg-open` を使う。オープン失敗は fatal にしない(stderr に警告を残し、URL は応答と stderr で伝える) | +| F7.10 | ローカルブリッジは初回ツール呼び出しで web flow が完了していない場合、polling をバックグラウンドに維持したまま、authorize URL と残り有効秒数を本文に含む `isError: true` の構造化ツール応答を即座に返す。2 回目以降の同一ツール呼び出しは、承認完了なら通常処理、未完了なら同じ auth-required 応答を返す(ポーリングは 1 本に serialize) | +| F7.11 | ローカルブリッジは refresh 時に `invalid_grant` を受けた場合、直ちに全面 re-auth に遷移せず tokens file を再読み込みする。別プロセスが既に rotation を完了していれば、その最新 refresh_token を採用して再試行する(RC1: refresh desync の最小 fix。file lock は導入しない) | **GitHub App 前提条件:** -- GitHub App の設定で **"Enable Device Flow"** を有効化する必要がある(未有効時は `device_flow_disabled` が返る) -- 使用する upstream endpoint: `POST https://github.com/login/device/code`, `POST https://github.com/login/oauth/access_token` +- 使用する upstream endpoint: `https://github.com/login/oauth/authorize`(web), `POST https://github.com/login/oauth/access_token` +- GitHub App の設定で `redirect_uri = https:///oauth/callback` を登録する必要がある(smgjp.com プレビュー + self-host 例示) ## 非機能要件 @@ -189,7 +196,7 @@ Worker は OAuth 2.1 Device Authorization Grant を自前実装する(`@cloudf | N2.2 | シークレット | Cloudflare Secret `GITHUB_WEBHOOK_SECRET` | なし(検証スキップ) | | N2.3 | チャンネル通知の有効/無効 | `WEBHOOK_CHANNEL` | 有効(`0` で無効) | | N2.4 | カスタムドメイン | `github-webhook.smgjp.com` | Cloudflare Worker のカスタムドメインとして設定済み | -| N2.5 | 認証方式 | Worker 自前認証 | Cloudflare Access は使用しない。Worker が webhook secret + OAuth Device Authorization Grant (RFC 8628) で認証を処理する | +| N2.5 | 認証方式 | Worker 自前認証 | Cloudflare Access は使用しない。Worker が webhook secret + Worker-hosted web OAuth で認証を処理する | | N2.6 | プレビューインスタンス | `preview` 環境 | 本番と同一構成の検証用インスタンス | ### N3. 制約 @@ -199,7 +206,7 @@ Worker は OAuth 2.1 Device Authorization Grant を自前実装する(`@cloudf | N3.1 | WebhookStore / McpAgent DO はテナント別インスタンス(`idFromName("store-{accountId}")` / `getAgentByName("tenant-{accountId}")`)で動作する。TenantRegistry DO は単一インスタンスで全テナントの installation-account マッピングを管理する | | N3.2 | SSE 接続は DO のメモリ内で管理される(DO eviction 時に切断) | | N3.3 | ローカルブリッジはツール呼び出しごとに Worker セッションを再利用する(セッション失効時は自動リトライ) | -| N3.4 | Device flow 完了時に `GET /user/installations` で取得した accessible_account_ids(ユーザー + org)を GitHubUserProps に保存し、McpAgent が複数 store を並列クエリして結果をマージする。これにより org インストールのイベントもメンバーの MCP セッションから参照できる | +| N3.4 | Web OAuth callback 処理時に `GET /user/installations` で取得した accessible_account_ids(ユーザー + org)を GitHubUserProps に保存し、McpAgent が複数 store を並列クエリして結果をマージする。これにより org インストールのイベントもメンバーの MCP セッションから参照できる | ## CI/CD @@ -233,7 +240,7 @@ Worker は OAuth 2.1 Device Authorization Grant を自前実装する(`@cloudf | @modelcontextprotocol/sdk | MCP SDK | | zod | スキーマバリデーション | -OAuth 実装は自前(`worker/src/oauth.ts` + `worker/src/oauth-store.ts`)。`@cloudflare/workers-oauth-provider` は v0.11.0 で撤去済み(device flow 非対応のため)。 +OAuth 実装は自前(`worker/src/oauth.ts` + `worker/src/oauth-store.ts`)。`@cloudflare/workers-oauth-provider` は v0.11.0 で撤去済み。v0.11.1 で Worker-hosted web OAuth に切り替え(device authorization grant は撤去)。 ### ローカルブリッジ (mcp-server/) @@ -252,8 +259,8 @@ Node.js >= 18.0.0 が必要。 | `worker/src/agent.ts` | WebhookMcpAgent DO(MCP ツール定義、テナント別インスタンス) | | `worker/src/store.ts` | WebhookStore DO(SQLite + SSE、テナント別インスタンス) | | `worker/src/tenant.ts` | TenantRegistry DO(installation-account マッピング、クォータ管理) | -| `worker/src/oauth.ts` | OAuth Device Authorization Grant (RFC 8628) 自前実装(metadata / register / device_authorization / token / 独自 token 検証 middleware) | -| `worker/src/oauth-store.ts` | OAuth KV schema helper(client / device / user_code / token / refresh / grant レコード操作) | +| `worker/src/oauth.ts` | Worker-hosted web OAuth 自前実装(metadata / register / authorize / callback / token / 独自 token 検証 middleware) | +| `worker/src/oauth-store.ts` | OAuth KV schema helper(client / web_auth_state / token / refresh / grant レコード操作) | | `worker/wrangler.toml` | Worker デプロイ設定 | | `shared/src/types.ts` | 共有型定義 | | `shared/src/summarize.ts` | イベントサマリー生成 | diff --git a/docs/0-requirements.md b/docs/0-requirements.md index 04a82ef..dfad7b9 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -21,7 +21,7 @@ GitHub ──POST──▶ Cloudflare Worker ──▶ TenantRegistry DO │ ▼ │ WebhookStore DO (SQLite) [per-tenant] │ │ - ├── /mcp (Streamable HTTP) ├── WebSocket real-time stream + ├── /mcp (Streamable HTTP) ├── WebSocket / SSE real-time stream │ WebhookMcpAgent DO └── REST endpoints │ [per-tenant] /pending-status │ └── tools → WebhookStore /pending-events @@ -29,6 +29,10 @@ GitHub ──POST──▶ Cloudflare Worker ──▶ TenantRegistry DO ├── /events (WebSocket/SSE) /event │ └── WebhookStore DO /mark-processed │ + ├── /oauth/authorize ──▶ github.com/login/oauth/authorize + ├── /oauth/callback ◀── github.com redirect_uri (Worker-hosted) + ├── /oauth/token (web_authorization_poll, refresh_token) + │ └── /webhooks/github (POST) └── TenantRegistry → WebhookStore DO /ingest @@ -37,6 +41,8 @@ GitHub ──POST──▶ Cloudflare Worker ──▶ TenantRegistry DO │ stdio ← Claude Desktop/CLI │ │ → proxy tool calls to /mcp │ │ → WebSocket listener → channel │ + │ → browser: /oauth/authorize │ + │ → poll: /oauth/token │ └─────────────────────────────┘ ``` @@ -144,27 +150,28 @@ WebhookMcpAgent DO が以下のツールセットを提供する。ローカル | 3 | フルペイロードが必要なイベントのみ `get_event(event_id)` で取得 | | 4 | 処理完了後 `mark_processed(event_id)` でマーク | -### F7. OAuth 認証(Device Authorization Grant, RFC 8628) +### F7. OAuth 認証(Worker-hosted web OAuth) -Worker は OAuth 2.1 Device Authorization Grant を自前実装する(`@cloudflare/workers-oauth-provider` は v0.11.0 で撤去)。GitHub App の device flow を upstream として利用し、localhost callback に依存しない。 +Worker は GitHub の web OAuth flow をホストする独自実装を備える(v0.11.0 の device authorization grant は v0.11.1 で撤去。GitHub 標準のログイン + 2FA UX に回帰しつつ、v0.10.x の chronic auth loop 原因である localhost callback 依存と refresh rotation desync を構造的に解消する)。 | ID | 要件 | |----|------| -| F7.1 | `GET /.well-known/oauth-authorization-server` で RFC 8414 メタデータを返す | +| F7.1 | `GET /.well-known/oauth-authorization-server` で RFC 8414 メタデータを返す(`authorization_endpoint` / `token_endpoint` / `grant_types_supported=[urn:ietf:params:oauth:grant-type:web_authorization_poll, refresh_token]`) | | F7.2 | `POST /oauth/register` で RFC 7591 dynamic client registration を行う(public client、secret 発行なし) | -| F7.3 | `POST /oauth/device_authorization` で GitHub に device code を要求し、RFC 8628 §3.2 形式の JSON(device_code / user_code / verification_uri / verification_uri_complete / expires_in / interval)を返す | -| F7.4 | `POST /oauth/token` で `grant_type=urn:ietf:params:oauth:grant-type:device_code` を処理し、GitHub への polling 結果に応じて `authorization_pending` / `slow_down` / `access_denied` / `expired_token` を RFC 8628 §3.5 準拠で返す | -| F7.5 | `POST /oauth/token` で `grant_type=refresh_token` を処理し、access token と refresh token を rotate する | -| F7.6 | 旧 `GET /oauth/authorize` および `GET /oauth/callback` は **HTTP 410 Gone** を返す(localhost callback flow は v0.11.0 で廃止) | +| F7.3 | `GET /oauth/authorize?client_id=&state=[&scope=...]` で `web_auth_state:{state}` レコードを `pending` として作成し、`redirect_uri=https:///oauth/callback` を固定して `https://github.com/login/oauth/authorize` に 302 リダイレクトする | +| F7.4 | `GET /oauth/callback?code=&state=` で GitHub authorization code を confidential client として access token に交換し、`fetchGitHubProps()` で user profile + installations を取得、Worker 独自 bearer token pair を発行して `web_auth_state` を `approved` に遷移させる。ユーザにはタブを閉じるよう案内する HTML を返す | +| F7.5 | `POST /oauth/token` で `grant_type=urn:ietf:params:oauth:grant-type:web_authorization_poll` を処理する。`pending` → `400 authorization_pending`、`approved` → `200` で bearer pair を返し state レコードを消費、`denied` → `400 access_denied`、期限切れ → `400 expired_token`(RFC 8628 §3.5 のエラー形式を再利用) | +| F7.6 | `POST /oauth/token` で `grant_type=refresh_token` を処理し、access token と refresh token を rotate する(ブリッジ側 RC1 修正と組み合わせて desync を解消) | | F7.7 | 保護対象 API ルート(`/mcp`, `/events`)は `Authorization: Bearer ` ヘッダによる独自 token 検証 middleware で認可する | -| F7.8 | KV schema は自前設計: `client:{client_id}` / `device:{device_code}` / `user_code:{user_code}` / `token:{access_token}` / `refresh:{refresh_token}` / `grant:{grant_id}` | -| F7.9 | ローカルブリッジは device authorization 応答受信直後に `verification_uri_complete`(なければ `verification_uri`)を platform 既定のブラウザで自動オープンする。Windows は `cmd /c start`、macOS は `open`、Linux は `xdg-open` を使う。オープン失敗は fatal にしない(stderr に警告を残し、URL は応答と stderr で伝える) | -| F7.10 | ローカルブリッジは初回ツール呼び出しで device flow が完了していない場合、polling をバックグラウンドに維持したまま、`user_code` / `verification_uri_complete` / `verification_uri` / 残り有効秒数を本文に含む `isError: true` の構造化ツール応答を即座に返す。2 回目以降の同一ツール呼び出しは、承認完了なら通常処理、未完了なら同じ auth-required 応答を返す(ポーリングは 1 本に serialize) | +| F7.8 | KV schema は自前設計: `client:{client_id}` / `web_auth_state:{state}` / `token:{access_token}` / `refresh:{refresh_token}` / `grant:{grant_id}`。device flow 時代の `device:` / `user_code:` キーは撤去 | +| F7.9 | ローカルブリッジは authorize URL を platform 既定のブラウザで自動オープンする。Windows は `cmd /c start`、macOS は `open`、Linux は `xdg-open` を使う。オープン失敗は fatal にしない(stderr に警告を残し、URL は応答と stderr で伝える) | +| F7.10 | ローカルブリッジは初回ツール呼び出しで web flow が完了していない場合、polling をバックグラウンドに維持したまま、authorize URL と残り有効秒数を本文に含む `isError: true` の構造化ツール応答を即座に返す。2 回目以降の同一ツール呼び出しは、承認完了なら通常処理、未完了なら同じ auth-required 応答を返す(ポーリングは 1 本に serialize) | +| F7.11 | ローカルブリッジは refresh 時に `invalid_grant` を受けた場合、直ちに全面 re-auth に遷移せず tokens file を再読み込みする。別プロセスが既に rotation を完了していれば、その最新 refresh_token を採用して再試行する(RC1: refresh desync の最小 fix。file lock は導入しない) | **GitHub App 前提条件:** -- GitHub App の設定で **"Enable Device Flow"** を有効化する必要がある(未有効時は `device_flow_disabled` が返る) -- 使用する upstream endpoint: `POST https://github.com/login/device/code`, `POST https://github.com/login/oauth/access_token` +- 使用する upstream endpoint: `https://github.com/login/oauth/authorize`(web), `POST https://github.com/login/oauth/access_token` +- GitHub App の設定で `redirect_uri = https:///oauth/callback` を登録する必要がある(smgjp.com プレビュー + self-host 例示) ## Non-Functional Requirements @@ -189,7 +196,7 @@ Worker は OAuth 2.1 Device Authorization Grant を自前実装する(`@cloudf | N2.2 | シークレット | Cloudflare Secret `GITHUB_WEBHOOK_SECRET` | なし(検証スキップ) | | N2.3 | チャンネル通知の有効/無効 | `WEBHOOK_CHANNEL` | 有効(`0` で無効) | | N2.4 | カスタムドメイン | `github-webhook.smgjp.com` | Cloudflare Worker のカスタムドメインとして設定済み | -| N2.5 | 認証方式 | Worker 自前認証 | Cloudflare Access は使用しない。Worker が webhook secret + OAuth Device Authorization Grant (RFC 8628) で認証を処理する | +| N2.5 | 認証方式 | Worker 自前認証 | Cloudflare Access は使用しない。Worker が webhook secret + Worker-hosted web OAuth で認証を処理する | | N2.6 | プレビューインスタンス | `preview` 環境 | 本番と同一構成の検証用インスタンス | **GitHub Webhook 購読イベント:** @@ -212,7 +219,7 @@ Worker は OAuth 2.1 Device Authorization Grant を自前実装する(`@cloudf | ID | 制約 | |----|------| | N3.1 | WebhookStore / McpAgent DO はテナント別インスタンス(`idFromName("store-{accountId}")` / `getAgentByName("tenant-{accountId}")`)で動作する。TenantRegistry DO は単一インスタンスで全テナントの installation-account マッピングを管理する | -| N3.4 | Device flow 完了時に `GET /user/installations` で取得した accessible_account_ids(ユーザー + org)を GitHubUserProps に保存し、McpAgent が複数 store を並列クエリして結果をマージする。これにより org インストールのイベントもメンバーの MCP セッションから参照できる | +| N3.4 | Web OAuth callback 処理時に `GET /user/installations` で取得した accessible_account_ids(ユーザー + org)を GitHubUserProps に保存し、McpAgent が複数 store を並列クエリして結果をマージする。これにより org インストールのイベントもメンバーの MCP セッションから参照できる | | N3.2 | WebSocket / SSE 接続は DO のメモリ内で管理される(DO eviction 時に切断) | | N3.3 | ローカルブリッジはツール呼び出しごとに Worker セッションを再利用する(セッション失効時は自動リトライ) | @@ -226,7 +233,7 @@ Worker は OAuth 2.1 Device Authorization Grant を自前実装する(`@cloudf | @modelcontextprotocol/sdk | MCP SDK | | zod | スキーマバリデーション | -OAuth 実装は自前(`worker/src/oauth.ts` + `worker/src/oauth-store.ts`)。`@cloudflare/workers-oauth-provider` は v0.11.0 で撤去済み(device flow 非対応のため)。 +OAuth 実装は自前(`worker/src/oauth.ts` + `worker/src/oauth-store.ts`)。`@cloudflare/workers-oauth-provider` は v0.11.0 で撤去済み。v0.11.1 で Worker-hosted web OAuth に切り替え(device authorization grant は撤去)。 ### ローカルブリッジ (mcp-server/) @@ -296,8 +303,8 @@ manifest.json のバージョンも一致させる。 | `worker/src/agent.ts` | WebhookMcpAgent DO(MCP ツール定義、テナント別インスタンス) | | `worker/src/store.ts` | WebhookStore DO(SQLite + SSE、テナント別インスタンス) | | `worker/src/tenant.ts` | TenantRegistry DO(installation-account マッピング、クォータ管理) | -| `worker/src/oauth.ts` | OAuth Device Authorization Grant (RFC 8628) 自前実装(metadata / register / device_authorization / token / 独自 token 検証 middleware) | -| `worker/src/oauth-store.ts` | OAuth KV schema helper(client / device / user_code / token / refresh / grant レコード操作) | +| `worker/src/oauth.ts` | Worker-hosted web OAuth 自前実装(metadata / register / authorize / callback / token / 独自 token 検証 middleware) | +| `worker/src/oauth-store.ts` | OAuth KV schema helper(client / web_auth_state / token / refresh / grant レコード操作) | | `worker/wrangler.toml` | Worker デプロイ設定 | | `shared/src/types.ts` | 共有型定義 | | `shared/src/summarize.ts` | イベントサマリー生成 | diff --git a/docs/installation.ja.md b/docs/installation.ja.md index 67f45df..c3aa9f4 100644 --- a/docs/installation.ja.md +++ b/docs/installation.ja.md @@ -15,44 +15,38 @@ > **前提:** Node.js 18+ が必要です(ローカル MCP ブリッジの実行に使用)。 -### 初回認証(OAuth Device Flow) +### 初回認証(Web OAuth) -v0.11.0 以降、MCP クライアントは **OAuth 2.1 Device Authorization Grant (RFC 8628)** で認証します。v0.11.1 以降は初回のツール呼び出し時に以下が同時に起きます: +v0.11.1 以降、MCP クライアントは **Worker-hosted web OAuth flow** で認証します。GitHub 標準のログイン + 2FA(Google Authenticator など見慣れた UX)がそのまま使えます。初回のツール呼び出し時に以下が同時に起きます: -1. **ブラウザが自動で開きます**(`verification_uri_complete`、コード事前入力済み URL) -2. **ツール呼び出しの応答として `user_code` と URL が即座に返ります**(600 秒待たされません) +1. **ブラウザが自動で開きます**(`https:///oauth/authorize?client_id=...&state=...` → GitHub のログイン画面に 302 リダイレクト) +2. **ツール呼び出しの応答として authorize URL が即座に返ります**(ポーリングの完了を待たず、すぐ retry できる) Claude Code / Claude Desktop のチャット上には、おおよそ次のような応答が表示されます: ``` -OAuth device authorization required. +OAuth authorization required. -Open (code pre-filled): https://github.com/login/device?user_code=WDJB-MJHT +Open this URL in your browser: https://github-webhook.smgjp.com/oauth/authorize?client_id=abc&state=xyz -Or visit https://github.com/login/device and enter the code: - WDJB-MJHT - -Code expires in about 10 minutes. -A browser window should have opened automatically. Retry the same tool call -after approving — subsequent calls will succeed once authorization completes. +This link is valid for about 10 minutes. +A browser window should have opened automatically. Sign in on GitHub, then retry the same tool call — subsequent calls will succeed once authorization completes. ``` -ブラウザで承認するとバックグラウンドのポーリングが完了し、`~/.github-webhook-mcp/oauth-tokens.json` にトークンが保存されます。その後同じツールを呼び直すと通常どおり結果が返ります。以降の起動では保存済みトークンが再利用され、期限切れ前に自動でリフレッシュされます。 +ブラウザで GitHub にサインインして承認すると、Worker が `/oauth/callback` を受けて「Authorization complete」ページを返します(タブは閉じて構いません)。ローカルブリッジのバックグラウンドポーリングがトークンを受け取り、`~/.github-webhook-mcp/oauth-tokens.json` に保存します。その後同じツールを呼び直すと通常どおり結果が返ります。以降の起動では保存済みトークンが再利用され、期限切れ前に自動でリフレッシュされます。 並行して、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] Opening browser for authentication... -[github-webhook-mcp] Waiting for approval (expires in 600s)... +[github-webhook-mcp] OAuth authorization required. +[github-webhook-mcp] Opening: https://github-webhook.smgjp.com/oauth/authorize?client_id=abc&state=xyz +[github-webhook-mcp] Approve in the browser window; the tab can be closed when done. +[github-webhook-mcp] Waiting for approval (state expires in 600s)... ``` > **ブラウザ自動オープンが失敗した場合:** 応答と stderr ログに URL がそのまま残るので、手動でコピーしてブラウザに貼り付けてください。Windows では `start`、macOS では `open`、Linux では `xdg-open` を使用します。 -> **旧バージョンからの移行:** v0.10.x 以前の localhost callback flow を使っていた場合、初回起動時に旧トークンファイルが自動削除され、migration 通知が stderr に出力されます。表示される device code を入力して一度だけ再認証してください。 +> **旧バージョンからの移行:** v0.11.0 以前(localhost callback flow / device flow どちらも)の `~/.github-webhook-mcp/oauth-tokens.json` は flow marker が一致しないため自動で無視され、初回ツール呼び出し時に新しい web OAuth flow で再認証が走ります。特別な手作業は不要です。 ### Claude Desktop — デスクトップ拡張 (.mcpb) @@ -181,16 +175,17 @@ id = "<ここに KV Namespace ID を貼り付け>" #### OAuth 設定(MCP リモート接続に必要) -v0.11.0 以降、OAuth は **Device Authorization Grant (RFC 8628)** で動作します。ローカル MCP クライアントは localhost callback ポートに依存しません。 +v0.11.1 以降、OAuth は **Worker-hosted web flow** で動作します。ローカル MCP クライアントは localhost callback ポートに依存せず、GitHub の redirect_uri は Worker 自身に固定されます。 -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 を発行済みの場合はそのままでも問題ありません(未使用のまま) +1. **Callback URL** に `https:///oauth/callback` を登録してください(必須) + - 例: `https://github-webhook-mcp.example.workers.dev/oauth/callback` + - カスタムドメインを使う場合はそのドメインの `/oauth/callback` を登録 + - 複数 URL に対応している GitHub App 設定(Multiple callback URLs 等)を利用する場合は、利用者が実際にアクセスする Worker URL をすべて列挙 +2. **"Enable Device Flow"** は **OFF のままで構いません**(v0.11.1 では使用しません) +3. Client ID と Client Secret を生成・メモしてください(ステップ 5 で使用) +4. **Client secret は必須です**(Worker が confidential client として GitHub に code→token 交換を行うため) -> **重要:** v0.11.0 より前の localhost callback flow から移行するユーザーは、初回接続時に自動的に device flow で再認証が要求されます(`~/.github-webhook-mcp/oauth-tokens.json` の旧ファイルは自動で破棄され、stderr に移行通知が出力されます)。Claude Code のログで `https://github.com/login/device` の案内と `user_code` を確認して入力してください。 +> **重要:** v0.10.x 以前(localhost callback flow)や v0.11.0(device flow)から移行するユーザーは、初回接続時に自動的に web flow で再認証が要求されます(`~/.github-webhook-mcp/oauth-tokens.json` の旧ファイルは flow marker が一致しないため無視されます)。Claude Code のチャット応答または stderr に表示される authorize URL を開いて GitHub にサインインしてください。 #### パーミッション @@ -228,17 +223,16 @@ v0.11.0 以降、OAuth は **Device Authorization Grant (RFC 8628)** で動作 # GitHub App の Webhook secret(必須) npx wrangler secret put GITHUB_WEBHOOK_SECRET -# GitHub App の Client ID(device flow で必須) +# GitHub App の Client ID(web OAuth で必須) npx wrangler secret put GITHUB_CLIENT_ID -# GitHub App の Client Secret(device flow では未使用だが、upstream API が -# 将来 confidential client に変わった場合に備えて設定を残してあります) +# GitHub App の Client Secret(web OAuth で必須 — Worker が confidential client として code→token 交換を行う) npx wrangler secret put GITHUB_CLIENT_SECRET ``` 各コマンドでプロンプトが表示されるので、対応する値を入力してください。 -> **注意:** device flow の public client として動作するため `GITHUB_CLIENT_SECRET` は実際の認証には使用されません。未登録でも認証は成立しますが、互換性のためダミー値(例: `unused`)を入れておくことを推奨します。 +> **注意:** `GITHUB_CLIENT_SECRET` は Worker の `/oauth/callback` が GitHub に authorization code を提示する際に使用されます。空欄・未登録だと callback 交換が失敗するので必ず設定してください。 ### 6. カスタムドメイン(オプション) @@ -246,8 +240,9 @@ npx wrangler secret put GITHUB_CLIENT_SECRET 1. Cloudflare ダッシュボード → **Workers & Pages** → Worker → **Settings** → **Domains & Routes** 2. カスタムドメインを追加(例: `github-webhook.example.com`) -3. GitHub App の Webhook URL をカスタムドメインに更新(device flow は Callback URL 不要) -4. MCP クライアント設定の `WEBHOOK_WORKER_URL` を更新 +3. GitHub App の Webhook URL をカスタムドメインに更新 +4. GitHub App の Callback URL をカスタムドメインの `/oauth/callback` に更新(例: `https://github-webhook.example.com/oauth/callback`) +5. MCP クライアント設定の `WEBHOOK_WORKER_URL` を更新 ### 7. WAF ルール(推奨) @@ -292,9 +287,9 @@ claude --dangerously-load-development-channels server:github-webhook-mcp |------|-----------| | Webhook が 403 を返す | `GITHUB_WEBHOOK_SECRET` が GitHub App の設定と一致していない。両方の値を確認 | | Webhook が 429 を返す | テナントクォータ(デフォルト 10,000 イベント)を超過。古いイベントを `mark_processed` で処理 | -| `/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 で再認証してください | +| `/oauth/callback` で「Authorization failed」が出る | GitHub App の Callback URL に `https:///oauth/callback` が登録されていない、または `GITHUB_CLIENT_SECRET` が未設定 / 不一致。ステップ 4 と 5 を確認 | +| ブラウザで GitHub のログイン画面が表示されない | Worker の `/oauth/authorize` にアクセスできているか確認(`https:///oauth/authorize?client_id=...&state=...` を直接開くと 302 redirect で `github.com/login/oauth/authorize` に飛ぶはず) | +| Claude Code のログに authorize URL が表示されない | stderr の出力を確認。`[github-webhook-mcp] OAuth authorization required.` のセクションに `Opening: https:///oauth/authorize?...` が出力されているはず | +| `~/.github-webhook-mcp/oauth-tokens.json` が無視される | v0.11.1 に更新した際、flow marker が `web` でない旧ファイル(localhost flow / device flow)は自動で無視されます。ブラウザで authorize URL を開いて再認証してください | | KV エラーが出る | `wrangler.toml` の KV ID が `wrangler kv namespace create` の出力と一致しているか確認 | | MCP ツールが応答しない | Worker がデプロイされているか `wrangler tail` でログを確認 | diff --git a/docs/installation.md b/docs/installation.md index b33d5b3..6329636 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -15,44 +15,38 @@ > **前提:** Node.js 18+ が必要です(ローカル MCP ブリッジの実行に使用)。 -### 初回認証(OAuth Device Flow) +### 初回認証(Web OAuth) -v0.11.0 以降、MCP クライアントは **OAuth 2.1 Device Authorization Grant (RFC 8628)** で認証します。v0.11.1 以降は初回のツール呼び出し時に以下が同時に起きます: +v0.11.1 以降、MCP クライアントは **Worker-hosted web OAuth flow** で認証します。GitHub 標準のログイン + 2FA(Google Authenticator など見慣れた UX)がそのまま使えます。初回のツール呼び出し時に以下が同時に起きます: -1. **ブラウザが自動で開きます**(`verification_uri_complete`、コード事前入力済み URL) -2. **ツール呼び出しの応答として `user_code` と URL が即座に返ります**(600 秒待たされません) +1. **ブラウザが自動で開きます**(`https:///oauth/authorize?client_id=...&state=...` → GitHub のログイン画面に 302 リダイレクト) +2. **ツール呼び出しの応答として authorize URL が即座に返ります**(ポーリングの完了を待たず、すぐ retry できる) Claude Code / Claude Desktop のチャット上には、おおよそ次のような応答が表示されます: ``` -OAuth device authorization required. +OAuth authorization required. -Open (code pre-filled): https://github.com/login/device?user_code=WDJB-MJHT +Open this URL in your browser: https://github-webhook.smgjp.com/oauth/authorize?client_id=abc&state=xyz -Or visit https://github.com/login/device and enter the code: - WDJB-MJHT - -Code expires in about 10 minutes. -A browser window should have opened automatically. Retry the same tool call -after approving — subsequent calls will succeed once authorization completes. +This link is valid for about 10 minutes. +A browser window should have opened automatically. Sign in on GitHub, then retry the same tool call — subsequent calls will succeed once authorization completes. ``` -ブラウザで承認するとバックグラウンドのポーリングが完了し、`~/.github-webhook-mcp/oauth-tokens.json` にトークンが保存されます。その後同じツールを呼び直すと通常どおり結果が返ります。以降の起動では保存済みトークンが再利用され、期限切れ前に自動でリフレッシュされます。 +ブラウザで GitHub にサインインして承認すると、Worker が `/oauth/callback` を受けて「Authorization complete」ページを返します(タブは閉じて構いません)。ローカルブリッジのバックグラウンドポーリングがトークンを受け取り、`~/.github-webhook-mcp/oauth-tokens.json` に保存します。その後同じツールを呼び直すと通常どおり結果が返ります。以降の起動では保存済みトークンが再利用され、期限切れ前に自動でリフレッシュされます。 並行して、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] Opening browser for authentication... -[github-webhook-mcp] Waiting for approval (expires in 600s)... +[github-webhook-mcp] OAuth authorization required. +[github-webhook-mcp] Opening: https://github-webhook.smgjp.com/oauth/authorize?client_id=abc&state=xyz +[github-webhook-mcp] Approve in the browser window; the tab can be closed when done. +[github-webhook-mcp] Waiting for approval (state expires in 600s)... ``` > **ブラウザ自動オープンが失敗した場合:** 応答と stderr ログに URL がそのまま残るので、手動でコピーしてブラウザに貼り付けてください。Windows では `start`、macOS では `open`、Linux では `xdg-open` を使用します。 -> **旧バージョンからの移行:** v0.10.x 以前の localhost callback flow を使っていた場合、初回起動時に旧トークンファイルが自動削除され、migration 通知が stderr に出力されます。表示される device code を入力して一度だけ再認証してください。 +> **旧バージョンからの移行:** v0.11.0 以前(localhost callback flow / device flow どちらも)の `~/.github-webhook-mcp/oauth-tokens.json` は flow marker が一致しないため自動で無視され、初回ツール呼び出し時に新しい web OAuth flow で再認証が走ります。特別な手作業は不要です。 ### Claude Desktop — デスクトップ拡張 (.mcpb) @@ -181,16 +175,17 @@ id = "<ここに KV Namespace ID を貼り付け>" #### OAuth 設定(MCP リモート接続に必要) -v0.11.0 以降、OAuth は **Device Authorization Grant (RFC 8628)** で動作します。ローカル MCP クライアントは localhost callback ポートに依存しません。 +v0.11.1 以降、OAuth は **Worker-hosted web flow** で動作します。ローカル MCP クライアントは localhost callback ポートに依存せず、GitHub の redirect_uri は Worker 自身に固定されます。 -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 を発行済みの場合はそのままでも問題ありません(未使用のまま) +1. **Callback URL** に `https:///oauth/callback` を登録してください(必須) + - 例: `https://github-webhook-mcp.example.workers.dev/oauth/callback` + - カスタムドメインを使う場合はそのドメインの `/oauth/callback` を登録 + - 複数 URL に対応している GitHub App 設定(Multiple callback URLs 等)を利用する場合は、利用者が実際にアクセスする Worker URL をすべて列挙 +2. **"Enable Device Flow"** は **OFF のままで構いません**(v0.11.1 では使用しません) +3. Client ID と Client Secret を生成・メモしてください(ステップ 5 で使用) +4. **Client secret は必須です**(Worker が confidential client として GitHub に code→token 交換を行うため) -> **重要:** v0.11.0 より前の localhost callback flow から移行するユーザーは、初回接続時に自動的に device flow で再認証が要求されます(`~/.github-webhook-mcp/oauth-tokens.json` の旧ファイルは自動で破棄され、stderr に移行通知が出力されます)。Claude Code のログで `https://github.com/login/device` の案内と `user_code` を確認して入力してください。 +> **重要:** v0.10.x 以前(localhost callback flow)や v0.11.0(device flow)から移行するユーザーは、初回接続時に自動的に web flow で再認証が要求されます(`~/.github-webhook-mcp/oauth-tokens.json` の旧ファイルは flow marker が一致しないため無視されます)。Claude Code のチャット応答または stderr に表示される authorize URL を開いて GitHub にサインインしてください。 #### パーミッション @@ -228,17 +223,16 @@ v0.11.0 以降、OAuth は **Device Authorization Grant (RFC 8628)** で動作 # GitHub App の Webhook secret(必須) npx wrangler secret put GITHUB_WEBHOOK_SECRET -# GitHub App の Client ID(device flow で必須) +# GitHub App の Client ID(web OAuth で必須) npx wrangler secret put GITHUB_CLIENT_ID -# GitHub App の Client Secret(device flow では未使用だが、upstream API が -# 将来 confidential client に変わった場合に備えて設定を残してあります) +# GitHub App の Client Secret(web OAuth で必須 — Worker が confidential client として code→token 交換を行う) npx wrangler secret put GITHUB_CLIENT_SECRET ``` 各コマンドでプロンプトが表示されるので、対応する値を入力してください。 -> **注意:** device flow の public client として動作するため `GITHUB_CLIENT_SECRET` は実際の認証には使用されません。未登録でも認証は成立しますが、互換性のためダミー値(例: `unused`)を入れておくことを推奨します。 +> **注意:** `GITHUB_CLIENT_SECRET` は Worker の `/oauth/callback` が GitHub に authorization code を提示する際に使用されます。空欄・未登録だと callback 交換が失敗するので必ず設定してください。 ### 6. カスタムドメイン(オプション) @@ -246,8 +240,9 @@ npx wrangler secret put GITHUB_CLIENT_SECRET 1. Cloudflare ダッシュボード → **Workers & Pages** → Worker → **Settings** → **Domains & Routes** 2. カスタムドメインを追加(例: `github-webhook.example.com`) -3. GitHub App の Webhook URL をカスタムドメインに更新(device flow は Callback URL 不要) -4. MCP クライアント設定の `WEBHOOK_WORKER_URL` を更新 +3. GitHub App の Webhook URL をカスタムドメインに更新 +4. GitHub App の Callback URL をカスタムドメインの `/oauth/callback` に更新(例: `https://github-webhook.example.com/oauth/callback`) +5. MCP クライアント設定の `WEBHOOK_WORKER_URL` を更新 ### 7. WAF ルール(推奨) @@ -292,9 +287,9 @@ claude --dangerously-load-development-channels server:github-webhook-mcp |------|-----------| | Webhook が 403 を返す | `GITHUB_WEBHOOK_SECRET` が GitHub App の設定と一致していない。両方の値を確認 | | Webhook が 429 を返す | テナントクォータ(デフォルト 10,000 イベント)を超過。古いイベントを `mark_processed` で処理 | -| `/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 で再認証してください | +| `/oauth/callback` で「Authorization failed」が出る | GitHub App の Callback URL に `https:///oauth/callback` が登録されていない、または `GITHUB_CLIENT_SECRET` が未設定 / 不一致。ステップ 4 と 5 を確認 | +| ブラウザで GitHub のログイン画面が表示されない | Worker の `/oauth/authorize` にアクセスできているか確認(`https:///oauth/authorize?client_id=...&state=...` を直接開くと 302 redirect で `github.com/login/oauth/authorize` に飛ぶはず) | +| Claude Code のログに authorize URL が表示されない | stderr の出力を確認。`[github-webhook-mcp] OAuth authorization required.` のセクションに `Opening: https:///oauth/authorize?...` が出力されているはず | +| `~/.github-webhook-mcp/oauth-tokens.json` が無視される | v0.11.1 に更新した際、flow marker が `web` でない旧ファイル(localhost flow / device flow)は自動で無視されます。ブラウザで authorize URL を開いて再認証してください | | KV エラーが出る | `wrangler.toml` の KV ID が `wrangler kv namespace create` の出力と一致しているか確認 | | MCP ツールが応答しない | Worker がデプロイされているか `wrangler tail` でログを確認 | diff --git a/local-mcp/src/index.ts b/local-mcp/src/index.ts index 4f6ed62..5ca51f7 100644 --- a/local-mcp/src/index.ts +++ b/local-mcp/src/index.ts @@ -5,9 +5,17 @@ * - Connects to Cloudflare Worker's /events WebSocket endpoint * - Forwards new events as Claude Code channel notifications * - Proxies MCP tool calls to the remote Worker (reuses a single session) - * - Authenticates via OAuth 2.1 Device Authorization Grant (RFC 8628). - * user_code + verification URI are surfaced on stderr because stdio MCP - * clients have no UI to drive an interactive browser flow. + * - v0.11.1: authenticates via a Worker-hosted web OAuth flow. + * 1. Bridge generates a random `state` and opens + * `https:///oauth/authorize?client_id=&state=` in + * the local browser. The Worker redirects to GitHub's standard web OAuth + * (familiar login + 2FA UX). + * 2. Bridge polls `https:///oauth/token` with + * `grant_type=urn:ietf:params:oauth:grant-type:web_authorization_poll` + * against the same state. + * 3. Refresh `invalid_grant` triggers a tokens-file re-read before fallback: + * a sibling Claude Code process may have refreshed already, so we adopt + * its rotation rather than starting a fresh web flow. * * Discord MCP pattern: data lives in the cloud, local MCP is a thin bridge. */ @@ -18,10 +26,11 @@ import { CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import WebSocket from "ws"; -import { readFile, writeFile, mkdir, unlink } from "node:fs/promises"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; import { homedir, platform as osPlatform } from "node:os"; import { join } from "node:path"; import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; const WORKER_URL = process.env.WEBHOOK_WORKER_URL || "https://github-webhook.smgjp.com"; const CHANNEL_ENABLED = process.env.WEBHOOK_CHANNEL !== "0"; @@ -29,7 +38,7 @@ const CHANNEL_ENABLED = process.env.WEBHOOK_CHANNEL !== "0"; // ── OAuth Token Storage ────────────────────────────────────────────────────── interface TokenData { - /** Flow marker for files produced by this client (v0.11.0+). */ + /** Flow marker for files produced by this client (v0.11.1+). */ flow?: string; access_token: string; refresh_token?: string; @@ -41,20 +50,16 @@ const TOKEN_FILE = join(TOKEN_DIR, "oauth-tokens.json"); const CLIENT_REG_FILE = join(TOKEN_DIR, "oauth-client.json"); /** - * Marker written on every tokens file produced by this client (v0.11.0+). - * Legacy files from the localhost-callback flow don't have it, which is how - * we detect a first-run migration scenario and surface the one-time notice. + * Marker written on every tokens file produced by this client (v0.11.1+). + * Legacy files from earlier flows lack it; loadTokens() ignores them so + * startup doesn't adopt stale state and the next tool call re-authenticates. */ -const TOKENS_FLOW_MARKER = "device"; +const TOKENS_FLOW_MARKER = "web"; async function loadTokens(): Promise { try { const data = await readFile(TOKEN_FILE, "utf-8"); const parsed = JSON.parse(data) as TokenData | null; - // Legacy files (pre-v0.11.0) lack the flow marker and carry tokens the - // new Worker cannot honor. Ignore them here so startup doesn't adopt - // stale state; performOAuthFlow() will surface the migration notice and - // remove the file the first time it runs. if (!parsed || parsed.flow !== TOKENS_FLOW_MARKER) { return null; } @@ -71,23 +76,20 @@ async function saveTokens(tokens: TokenData): Promise { let _cachedTokens: TokenData | null = null; let _refreshLock: Promise | null = null; -let _deviceFlowLock: Promise | null = null; -let _legacyMigrationNotified = false; +let _webAuthLock: Promise | null = null; /** - * Tracks the in-flight device authorization so tool calls can return an + * Tracks the in-flight web authorization so tool calls can return an * auth-required response immediately (instead of blocking for ~600s) while * polling continues in the background. Cleared on success or failure so the - * next tool call after expiry starts a fresh device code. + * next tool call after expiry starts a fresh authorize URL. */ -interface PendingDeviceAuth { - user_code: string; - verification_uri: string; - verification_uri_complete: string | null; +interface PendingWebAuth { + authorize_url: string; expires_at: number | undefined; } -let _pendingDeviceAuth: PendingDeviceAuth | null = null; -let _pendingDeviceAuthError: string | null = null; +let _pendingWebAuth: PendingWebAuth | null = null; +let _pendingWebAuthError: string | null = null; // ── OAuth Discovery & Registration ─────────────────────────────────────────── @@ -95,9 +97,11 @@ interface OAuthMetadata { authorization_endpoint?: string; token_endpoint: string; registration_endpoint?: string; - device_authorization_endpoint?: string; + grant_types_supported?: string[]; } +const WEB_AUTH_POLL_GRANT = "urn:ietf:params:oauth:grant-type:web_authorization_poll"; + async function discoverOAuthMetadata(): Promise { const res = await fetch(`${WORKER_URL}/.well-known/oauth-authorization-server`); if (!res.ok) { @@ -127,17 +131,15 @@ async function saveClientRegistration(reg: ClientRegistration): Promise { await writeFile(CLIENT_REG_FILE, JSON.stringify(reg, null, 2), { mode: 0o600 }); } -const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; - async function ensureClientRegistration( metadata: OAuthMetadata, ): Promise { const existing = await loadClientRegistration(); - // Legacy registrations were created for authorization_code + refresh_token. - // Re-register if the existing one is missing the device_code grant type so - // the Worker recognizes us as a device-flow client. + // Accept any client registration that already lists our web-auth poll grant. + // Legacy device-flow or authorization-code clients get re-registered so the + // Worker recognizes us as a v0.11.1 web-flow client. if (existing && Array.isArray(existing.grant_types) && - existing.grant_types.includes(DEVICE_CODE_GRANT)) { + existing.grant_types.includes(WEB_AUTH_POLL_GRANT)) { return existing; } @@ -150,9 +152,8 @@ async function ensureClientRegistration( headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_name: "github-webhook-mcp-cli", - // Device flow does not use redirect_uris; leave empty for RFC 8628. redirect_uris: [], - grant_types: [DEVICE_CODE_GRANT, "refresh_token"], + grant_types: [WEB_AUTH_POLL_GRANT, "refresh_token"], token_endpoint_auth_method: "none", }), }); @@ -166,76 +167,20 @@ async function ensureClientRegistration( return reg; } -// ── OAuth Device Authorization Grant (RFC 8628) ───────────────────────────── - -interface DeviceAuthorizationResponse { - device_code: string; - user_code: string; - verification_uri: string; - verification_uri_complete?: string; - expires_in?: number; - interval?: number; -} - -interface TokenPollingResponse { - access_token?: string; - refresh_token?: string; - expires_in?: number; - error?: string; - error_description?: string; -} - -/** - * Detect a pre-v0.11.0 tokens file and surface a one-time migration notice - * on stderr. Legacy files were written by the localhost-callback flow and - * carry tokens the new Worker will reject, so we discard them and let the - * device flow re-establish authentication from scratch. - */ -async function checkLegacyTokensMigration(): Promise { - let raw: string; - try { - raw = await readFile(TOKEN_FILE, "utf-8"); - } catch { - return; // No tokens file at all — not a migration case. - } - - let parsed: TokenData | null; - try { - parsed = JSON.parse(raw) as TokenData; - } catch { - // Corrupt file — treat as legacy/unusable and remove. - parsed = null; - } - - if (parsed && parsed.flow === TOKENS_FLOW_MARKER) { - return; // Already a device-flow tokens file — no migration needed. - } - - if (_legacyMigrationNotified) return; - _legacyMigrationNotified = true; - - process.stderr.write( - "[github-webhook-mcp] Detected legacy OAuth tokens from pre-v0.11.0 " + - "(localhost callback flow). This client now uses the Device " + - "Authorization Grant (RFC 8628). One-time re-authentication is " + - "required; follow the device-code prompt below.\n", - ); - - try { - await unlink(TOKEN_FILE); - } catch { - // Non-fatal: saveTokens() will overwrite it on success anyway. - } -} +// ── OAuth Web Flow ────────────────────────────────────────────────────────── function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function generateState(): string { + return randomBytes(32).toString("base64url"); +} + /** - * Best-effort platform-native browser launcher for the device-flow - * verification URL. Failures are non-fatal: we still surface the URL on - * stderr / in the tool response so the user can open it manually. + * Best-effort platform-native browser launcher for the web-flow authorize URL. + * Failures are non-fatal: we still surface the URL on stderr / in the tool + * response so the user can open it manually. */ function openBrowser(url: string): void { if (!url || typeof url !== "string") return; @@ -275,49 +220,28 @@ function openBrowser(url: string): void { } } -async function requestDeviceAuthorization( - metadata: OAuthMetadata, - client: ClientRegistration, -): Promise { - const endpoint = - metadata.device_authorization_endpoint || - `${WORKER_URL}/oauth/device_authorization`; - - const res = await fetch(endpoint, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ client_id: client.client_id }), - }); - - if (!res.ok) { - const body = await res.text().catch(() => ""); - throw new Error( - `Device authorization request failed: ${res.status} ${res.statusText}${body ? ` — ${body.slice(0, 200)}` : ""}`, - ); - } - - const data = await res.json() as DeviceAuthorizationResponse; - if (!data.device_code || !data.user_code || !data.verification_uri) { - throw new Error( - `Device authorization response missing required fields: ${JSON.stringify(data).slice(0, 200)}`, - ); - } - return data; +interface TokenPollingResponse { + access_token?: string; + refresh_token?: string; + expires_in?: number; + error?: string; + error_description?: string; } /** - * Poll the Worker's /oauth/token endpoint until the user approves, denies, - * or the device_code expires. Interval comes from the server; `slow_down` - * replies bump it by 5s per RFC 8628 §3.5. + * Poll the Worker's /oauth/token endpoint until the user approves, denies, or + * the state expires. The Worker mirrors RFC 8628's polling error shape so we + * can surface `authorization_pending` without exposing the poll to the user. */ -async function pollForDeviceToken( +async function pollForWebAuthToken( metadata: OAuthMetadata, client: ClientRegistration, - deviceAuth: DeviceAuthorizationResponse, + state: string, + expiresInSec: number, ): Promise { const endpoint = metadata.token_endpoint || `${WORKER_URL}/oauth/token`; - let interval = Math.max(1, Number(deviceAuth.interval) || 5); - const deadline = Date.now() + (Number(deviceAuth.expires_in) || 600) * 1000; + const interval = 2; // Worker issues Bearer tokens quickly; 2s poll is plenty. + const deadline = Date.now() + (expiresInSec || 600) * 1000; while (Date.now() < deadline) { await sleep(interval * 1000); @@ -328,8 +252,8 @@ async function pollForDeviceToken( method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ - grant_type: DEVICE_CODE_GRANT, - device_code: deviceAuth.device_code, + grant_type: WEB_AUTH_POLL_GRANT, + state, client_id: client.client_id, }), }); @@ -354,16 +278,12 @@ async function pollForDeviceToken( if (err === "authorization_pending") { continue; } - if (err === "slow_down") { - interval += 5; - continue; - } if (err === "access_denied") { throw new Error("OAuth authorization denied by user"); } if (err === "expired_token") { throw new Error( - "OAuth device code expired before approval. Re-run the client to retry.", + "OAuth state expired before approval. Re-run the client to retry.", ); } @@ -375,90 +295,78 @@ async function pollForDeviceToken( } throw new Error( - "OAuth device code expired before approval. Re-run the client to retry.", + "OAuth state expired before approval. Re-run the client to retry.", ); } /** - * Start a device authorization flow: obtain device_code/user_code, surface the - * verification URL (stderr + auto-open browser), and kick off a background - * poll. Callers that await the returned promise will block until the user - * approves — this is what the WebSocket bootstrap does. Callers that only - * need the deviceAuth metadata (for a non-blocking tool response) can read - * `_pendingDeviceAuth` as soon as this function resolves past the phase-1 - * await; see `getAccessTokenForToolCall()`. + * Start a Worker-hosted web OAuth flow: mint a state, open the authorize URL + * in the local browser, and kick off a background poll. Callers that await + * the returned promise will block until the user approves. Callers that only + * need the pending metadata (for a non-blocking tool response) can read + * `_pendingWebAuth` as soon as phase 1 resolves; see + * `getAccessTokenForToolCall()`. * - * Serialization: _deviceFlowLock ensures that concurrent callers (WebSocket - * bootstrap racing the first tool call) share a single device code rather - * than each launching their own approval prompt. + * Serialization: _webAuthLock ensures that concurrent callers (WebSocket + * bootstrap racing the first tool call) share a single authorize URL rather + * than each launching their own browser window. */ async function performOAuthFlow(): Promise { - if (_deviceFlowLock) { - return await _deviceFlowLock; + if (_webAuthLock) { + return await _webAuthLock; } - // Phase 1: obtain the device code. This is fast (one HTTP round-trip) and - // we surface the verification URL as soon as it returns. + // Phase 1: mint state + open the authorize URL. This is local-only (no HTTP + // round-trip) so it returns almost immediately. const startPromise: Promise<{ metadata: OAuthMetadata; client: ClientRegistration; - deviceAuth: DeviceAuthorizationResponse; + state: string; + expiresInSec: number; }> = (async () => { - await checkLegacyTokensMigration(); - const metadata = await discoverOAuthMetadata(); const client = await ensureClientRegistration(metadata); - const deviceAuth = await requestDeviceAuthorization(metadata, client); - const complete = deviceAuth.verification_uri_complete; - const browserUrl = complete || deviceAuth.verification_uri; + const state = generateState(); + const authorizeEndpoint = metadata.authorization_endpoint || `${WORKER_URL}/oauth/authorize`; + const authorizeUrl = + `${authorizeEndpoint}?client_id=${encodeURIComponent(client.client_id)}` + + `&state=${encodeURIComponent(state)}`; + + // Client-side state validity window — keep aligned with Worker's WEB_AUTH_STATE_TTL. + const expiresInSec = 600; + const expiresAt = Date.now() + expiresInSec * 1000; - // stdio MCP clients have no UI surface of their own, so we publish the - // user_code and verification URI on stderr where the host logs land. The - // auth-required tool response (below) is the primary channel for Claude - // Code / Desktop; stderr is the fallback surface. const lines: string[] = [ "", - "[github-webhook-mcp] OAuth device authorization required.", - `[github-webhook-mcp] Visit: ${deviceAuth.verification_uri}`, - `[github-webhook-mcp] Enter code: ${deviceAuth.user_code}`, - ]; - if (complete && complete !== deviceAuth.verification_uri) { - lines.push(`[github-webhook-mcp] Or open directly: ${complete}`); - } - lines.push( - `[github-webhook-mcp] Opening browser for authentication...`, - `[github-webhook-mcp] Waiting for approval (expires in ${deviceAuth.expires_in ?? "?"}s)...`, + "[github-webhook-mcp] OAuth authorization required.", + `[github-webhook-mcp] Opening: ${authorizeUrl}`, + `[github-webhook-mcp] Approve in the browser window; the tab can be closed when done.`, + `[github-webhook-mcp] Waiting for approval (state expires in ${expiresInSec}s)...`, "", - ); + ]; process.stderr.write(lines.join("\n")); // Best-effort browser auto-open. Failures are logged to stderr but do // not abort the flow — the URL is still available in the tool response. - openBrowser(browserUrl); - - const expiresAt = deviceAuth.expires_in - ? Date.now() + Number(deviceAuth.expires_in) * 1000 - : undefined; + openBrowser(authorizeUrl); - _pendingDeviceAuth = { - user_code: deviceAuth.user_code, - verification_uri: deviceAuth.verification_uri, - verification_uri_complete: complete ?? null, + _pendingWebAuth = { + authorize_url: authorizeUrl, expires_at: expiresAt, }; - _pendingDeviceAuthError = null; + _pendingWebAuthError = null; - return { metadata, client, deviceAuth }; + return { metadata, client, state, expiresInSec }; })(); // Phase 2: poll in the background (still inside the same lock promise so // that simultaneous callers await one shared flow). Errors are recorded // and re-thrown so awaiting callers see them. - _deviceFlowLock = (async (): Promise => { + _webAuthLock = (async (): Promise => { try { - const { metadata, client, deviceAuth } = await startPromise; - const tokenData = await pollForDeviceToken(metadata, client, deviceAuth); + const { metadata, client, state, expiresInSec } = await startPromise; + const tokenData = await pollForWebAuthToken(metadata, client, state, expiresInSec); if (!tokenData.access_token) { throw new Error( @@ -477,26 +385,24 @@ async function performOAuthFlow(): Promise { await saveTokens(tokens); _cachedTokens = tokens; - _pendingDeviceAuth = null; - _pendingDeviceAuthError = null; - process.stderr.write("[github-webhook-mcp] OAuth device authorization complete.\n"); + _pendingWebAuth = null; + _pendingWebAuthError = null; + process.stderr.write("[github-webhook-mcp] OAuth authorization complete.\n"); return tokens; } catch (err) { - _pendingDeviceAuth = null; - _pendingDeviceAuthError = err instanceof Error ? err.message : String(err); + _pendingWebAuth = null; + _pendingWebAuthError = err instanceof Error ? err.message : String(err); throw err; } })(); - const lockPromise = _deviceFlowLock; + const lockPromise = _webAuthLock; lockPromise.finally(() => { - if (_deviceFlowLock === lockPromise) { - _deviceFlowLock = null; + if (_webAuthLock === lockPromise) { + _webAuthLock = null; } }); - // Wait for phase 1 so the caller sees _pendingDeviceAuth populated (or the - // startPromise's error) before we return the outer promise. await startPromise; return lockPromise; } @@ -504,6 +410,11 @@ async function performOAuthFlow(): Promise { /** * Refresh the access token using the refresh token. */ +interface RefreshError extends Error { + status?: number; + bodyText?: string; +} + async function refreshAccessToken(refreshToken: string): Promise { const metadata = await discoverOAuthMetadata(); const client = await loadClientRegistration(); @@ -521,9 +432,11 @@ async function refreshAccessToken(refreshToken: string): Promise { if (!res.ok) { const body = await res.text().catch(() => ""); - const err = new Error( + const err: RefreshError = new Error( `Token refresh failed: ${res.status} ${res.statusText}${body ? ` — ${body.slice(0, 200)}` : ""}`, ); + err.status = res.status; + err.bodyText = body; console.error("[oauth] refresh failed:", err.message); throw err; } @@ -554,6 +467,40 @@ async function refreshAccessToken(refreshToken: string): Promise { return tokens; } +/** + * Detect the `invalid_grant` error shape on a refresh failure. When that hits, + * a sibling Claude Code process may have refreshed first — re-read the tokens + * file and see whether a newer rotation landed on disk. + */ +function isInvalidGrantError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const e = err as RefreshError; + if (e.status !== 400) return false; + const body = typeof e.bodyText === "string" ? e.bodyText : ""; + return body.includes("invalid_grant"); +} + +/** + * RC1 fix: before giving up on a stale refresh_token, re-read the tokens file + * to see whether a concurrent process already rotated it. If the on-disk + * refresh_token differs from the one that just failed, adopt it and retry. + */ +async function tryRefreshViaDiskReread(previousRefreshToken: string): Promise { + const fresh = await loadTokens(); + if (!fresh || !fresh.refresh_token) return null; + if (fresh.refresh_token === previousRefreshToken) return null; + try { + const tokens = await refreshAccessToken(fresh.refresh_token); + return tokens; + } catch (err) { + console.error( + "[oauth] disk-reread refresh also failed:", + err instanceof Error ? err.message : err, + ); + return null; + } +} + /** * Get a valid access token, refreshing or re-authenticating as needed. */ @@ -570,18 +517,21 @@ async function getAccessToken(): Promise { } if (_cachedTokens.refresh_token) { - // Serialize concurrent refresh attempts to prevent race conditions. - // Without this lock, the WebSocket startup and the first tool call can - // both trigger refreshAccessToken() with the same refresh token - // simultaneously, causing the token file to end up with an orphaned - // refresh token that the Worker no longer recognizes. + const previousRefresh = _cachedTokens.refresh_token; if (!_refreshLock) { - _refreshLock = refreshAccessToken(_cachedTokens.refresh_token); + _refreshLock = refreshAccessToken(previousRefresh); } try { _cachedTokens = await _refreshLock; return _cachedTokens.access_token; } catch (err) { + if (isInvalidGrantError(err)) { + const reread = await tryRefreshViaDiskReread(previousRefresh); + if (reread) { + _cachedTokens = reread; + return _cachedTokens.access_token; + } + } console.error("[oauth] refresh failed, falling back to full OAuth flow:", (err as Error).message || err); } finally { _refreshLock = null; @@ -597,25 +547,24 @@ async function getAccessToken(): Promise { } /** - * Sentinel thrown by `getAccessTokenForToolCall()` when the device flow is - * still pending approval. The tool handler catches this and returns a - * structured auth-required response to the MCP client without blocking on - * the poll loop. + * Sentinel thrown by `getAccessTokenForToolCall()` when the web flow is still + * pending approval. The tool handler catches this and returns a structured + * auth-required response to the MCP client without blocking on the poll loop. */ class AuthRequiredError extends Error { - pending: PendingDeviceAuth; - constructor(pending: PendingDeviceAuth, note?: string) { - super(note || "OAuth device authorization required"); + pending: PendingWebAuth; + constructor(pending: PendingWebAuth, note?: string) { + super(note || "OAuth authorization required"); this.name = "AuthRequiredError"; this.pending = pending; } } /** - * Like getAccessToken(), but never blocks on the device-flow poll. If no - * tokens are available, it starts the flow (if not already running) and - * throws an AuthRequiredError carrying the current device-code details so - * the caller can surface them in the tool response immediately. + * Like getAccessToken(), but never blocks on the web-flow poll. If no tokens + * are available, it starts the flow (if not already running) and throws an + * AuthRequiredError carrying the current authorize URL so the caller can + * surface it in the tool response immediately. */ async function getAccessTokenForToolCall(): Promise { if (!_cachedTokens) { @@ -629,16 +578,23 @@ async function getAccessTokenForToolCall(): Promise { } if (_cachedTokens.refresh_token) { - // Refresh is a single round-trip; blocking a tool call here is fine. + const previousRefresh = _cachedTokens.refresh_token; if (!_refreshLock) { - _refreshLock = refreshAccessToken(_cachedTokens.refresh_token); + _refreshLock = refreshAccessToken(previousRefresh); } try { _cachedTokens = await _refreshLock; return _cachedTokens.access_token; } catch (err) { + if (isInvalidGrantError(err)) { + const reread = await tryRefreshViaDiskReread(previousRefresh); + if (reread) { + _cachedTokens = reread; + return _cachedTokens.access_token; + } + } console.error( - "[oauth] refresh failed, starting device flow in background:", + "[oauth] refresh failed, starting web flow in background:", (err as Error).message || err, ); } finally { @@ -647,36 +603,33 @@ async function getAccessTokenForToolCall(): Promise { } } - // No usable tokens. Start the device flow if it isn't already running; - // either way, hand the caller the current pending device_code details. - if (!_pendingDeviceAuth && !_deviceFlowLock) { - // Swallow the outer promise; the background poll settles via - // _deviceFlowLock. Errors during phase 1 (e.g. network failure to - // /oauth/device_authorization) propagate via _pendingDeviceAuthError. + // No usable tokens. Start the web flow if it isn't already running; + // either way, hand the caller the current pending authorize URL. + if (!_pendingWebAuth && !_webAuthLock) { void performOAuthFlow().catch((err) => { console.error( - "[oauth] device flow background poll ended with error:", + "[oauth] web flow background poll ended with error:", (err as Error).message || err, ); }); } - // Give phase 1 a little time to finish. Device authorization is one HTTP - // round-trip; normally sub-second, so 15s is a generous ceiling. + // Give phase 1 a little time to finish. Phase 1 now includes a discovery + // fetch + (possibly) a register fetch, so allow up to 15s before giving up. const phase1Deadline = Date.now() + 15_000; - while (!_pendingDeviceAuth && !_pendingDeviceAuthError && Date.now() < phase1Deadline) { + while (!_pendingWebAuth && !_pendingWebAuthError && Date.now() < phase1Deadline) { await sleep(100); } - if (_pendingDeviceAuthError && !_pendingDeviceAuth) { - throw new Error(`OAuth device flow failed: ${_pendingDeviceAuthError}`); + if (_pendingWebAuthError && !_pendingWebAuth) { + throw new Error(`OAuth web flow failed: ${_pendingWebAuthError}`); } - if (_pendingDeviceAuth) { - throw new AuthRequiredError(_pendingDeviceAuth); + if (_pendingWebAuth) { + throw new AuthRequiredError(_pendingWebAuth); } - throw new Error("OAuth device flow did not produce a verification URL in time."); + throw new Error("OAuth web flow did not produce an authorize URL in time."); } async function buildAuthHeaders( @@ -845,30 +798,22 @@ const TOOLS = [ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); -function formatAuthRequiredResponse(pending: PendingDeviceAuth): string { +function formatAuthRequiredResponse(pending: PendingWebAuth): string { const parts: string[] = []; - parts.push("OAuth device authorization required."); + parts.push("OAuth authorization required."); parts.push(""); - if (pending.verification_uri_complete) { - parts.push(`Open (code pre-filled): ${pending.verification_uri_complete}`); - parts.push(""); - parts.push(`Or visit ${pending.verification_uri} and enter the code:`); - } else { - parts.push(`Visit: ${pending.verification_uri}`); - parts.push("Enter the code:"); - } - parts.push(` ${pending.user_code}`); + parts.push(`Open this URL in your browser: ${pending.authorize_url}`); parts.push(""); if (pending.expires_at) { const remainingMs = pending.expires_at - Date.now(); if (remainingMs > 0) { const mins = Math.max(1, Math.round(remainingMs / 60_000)); - parts.push(`Code expires in about ${mins} minute${mins === 1 ? "" : "s"}.`); + parts.push(`This link is valid for about ${mins} minute${mins === 1 ? "" : "s"}.`); } } parts.push( "A browser window should have opened automatically. " + - "Retry the same tool call after approving — subsequent calls will succeed once authorization completes.", + "Sign in on GitHub, then retry the same tool call — subsequent calls will succeed once authorization completes.", ); return parts.join("\n"); } diff --git a/mcp-server/README.md b/mcp-server/README.md index 389aaa8..d252ec0 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 **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. +- Handles **Worker-hosted web OAuth** against the Worker and Dynamic Client Registration (RFC 7591). No localhost callback port is used: GitHub's `redirect_uri` is pinned to the Worker itself, 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. On `invalid_grant` during refresh, the proxy re-reads the tokens file to adopt any rotation performed by a sibling process before falling back to a full re-authorization. ## 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 (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 web browser (used once to sign in on GitHub via the authorize URL the proxy prints — 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,21 +36,20 @@ npm install -g github-webhook-mcp github-webhook-mcp ``` -On first run the proxy prints a GitHub device code and verification URL to stderr: +On first run the proxy prints an authorize URL to stderr and tries to open it in your default browser: ``` -[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)... +[github-webhook-mcp] OAuth authorization required. +[github-webhook-mcp] Opening: https://github-webhook.smgjp.com/oauth/authorize?client_id=abc&state=xyz +[github-webhook-mcp] Approve in the browser window; the tab can be closed when done. +[github-webhook-mcp] Waiting for approval (state 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. +Sign in on GitHub (2FA works as usual), approve access, and close the tab when the "Authorization complete" page appears. 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. +> **Migrating from v0.10.x / v0.11.0.** v0.10.x used a browser-based localhost callback flow; v0.11.0 used a GitHub device code flow. On first run with v0.11.1+, the proxy treats any tokens file whose flow marker does not match the new web flow as inactive and transparently starts the new authorization. One-time re-authentication is required; the legacy file is left in place but ignored. -> **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. +> **Configure the Callback URL on self-hosted GitHub Apps.** If you self-host the Worker with your own GitHub App, register `https:///oauth/callback` as the **Callback URL**. Without it, the Worker's `/oauth/callback` step fails with "Authorization failed" because GitHub will reject the redirect. **Device Flow** is not used and can stay off. See the [self-hosting guide](https://github.com/Liplus-Project/github-webhook-mcp/blob/main/docs/installation.md) for step-by-step instructions. ## Client configuration @@ -149,24 +148,23 @@ 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, 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). +2. It performs Dynamic Client Registration (RFC 7591) if no client is cached, declaring support for the `urn:ietf:params:oauth:grant-type:web_authorization_poll` and `refresh_token` grant types (public client, no secret — the Worker itself uses its own `GITHUB_CLIENT_SECRET` to talk to GitHub). +3. It generates a random `state` and opens `${WEBHOOK_WORKER_URL}/oauth/authorize?client_id=&state=` in your default browser. The Worker stores a pending state record and 302-redirects to GitHub's standard `https://github.com/login/oauth/authorize`, with `redirect_uri` pinned to the Worker's own `/oauth/callback` (no localhost). +4. You sign in on GitHub and approve. GitHub redirects back to `${WEBHOOK_WORKER_URL}/oauth/callback?code=&state=`. The Worker exchanges the code for a GitHub access token (confidential client), fetches your GitHub profile + installations, and issues its own opaque access/refresh token pair bound to that grant. The browser tab shows "Authorization complete". +5. Meanwhile the proxy polls `${WEBHOOK_WORKER_URL}/oauth/token` with `grant_type=urn:ietf:params:oauth:grant-type:web_authorization_poll` against the same state. It receives `authorization_pending` until the callback completes, then receives the Worker-issued token pair on the next poll. +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 or `invalid_grant` during refresh, the proxy first re-reads its tokens file (in case a sibling process has already rotated) and only falls back to a fresh web flow when no newer refresh token is on disk. 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 -- **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. +- **Authorize URL 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 authorization required.` block. +- **`OAuth state expired before approval. Re-run the client to retry.`** The state token expires after ~10 minutes. Trigger any tool call again to restart the flow. +- **Browser lands on "Authorization failed" (Worker 502).** The Worker rejected the GitHub code exchange. On self-hosts this usually means the GitHub App's **Callback URL** does not include `https:///oauth/callback`, or `GITHUB_CLIENT_SECRET` is missing / wrong. - **`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. +- **Upgrading from v0.10.x / v0.11.0.** Existing tokens files are ignored (flow marker mismatch) and a fresh web-flow authorize URL is emitted on the next tool call. No manual cleanup is required. - **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-lock.json b/mcp-server/package-lock.json index 65ba474..9e2556b 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -1,12 +1,12 @@ { "name": "github-webhook-mcp", - "version": "0.8.2", + "version": "0.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-webhook-mcp", - "version": "0.8.2", + "version": "0.11.1", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/mcp-server/server/index.js b/mcp-server/server/index.js index bf659ee..bdc573c 100644 --- a/mcp-server/server/index.js +++ b/mcp-server/server/index.js @@ -6,9 +6,18 @@ * Cloudflare Worker + Durable Object backend via Streamable HTTP. * Optionally listens via WebSocket for real-time channel notifications * (enables DO hibernation on the Worker side). - * Authenticates via OAuth 2.1 Device Authorization Grant (RFC 8628); - * user_code + verification URI are surfaced on stderr because stdio MCP - * clients have no UI to drive an interactive browser flow. + * + * v0.11.1: authenticates via a Worker-hosted web OAuth flow. + * 1. Bridge generates a random `state` and opens + * `https:///oauth/authorize?client_id=&state=` in + * the local browser. The Worker redirects to GitHub's standard web OAuth + * (familiar login + 2FA UX). + * 2. Bridge polls `https:///oauth/token` with + * `grant_type=urn:ietf:params:oauth:grant-type:web_authorization_poll` + * against the same state. + * 3. Refresh `invalid_grant` triggers a tokens-file re-read before fallback: + * a sibling Claude Code process may have refreshed already, so we adopt + * its rotation rather than starting a fresh web flow. * * Discord MCP pattern: data lives in the cloud, local MCP is a thin bridge. */ @@ -18,11 +27,12 @@ import { ListToolsRequestSchema, CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; -import { readFile, writeFile, mkdir, unlink } from "node:fs/promises"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; import { homedir, platform as osPlatform } from "node:os"; import { join } from "node:path"; import { createRequire } from "node:module"; import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; import WebSocketClient from "ws"; const require = createRequire(import.meta.url); @@ -40,20 +50,16 @@ const TOKEN_FILE = join(TOKEN_DIR, "oauth-tokens.json"); const CLIENT_REG_FILE = join(TOKEN_DIR, "oauth-client.json"); /** - * Marker written on every tokens file produced by this client (v0.11.0+). - * Legacy files from the localhost-callback flow don't have it, which is how - * we detect a first-run migration scenario and surface the one-time notice. + * Marker written on every tokens file produced by this client (v0.11.1+). + * Legacy files from earlier flows lack it; loadTokens() ignores them so + * startup doesn't adopt stale state and the next tool call re-authenticates. */ -const TOKENS_FLOW_MARKER = "device"; +const TOKENS_FLOW_MARKER = "web"; async function loadTokens() { try { const data = await readFile(TOKEN_FILE, "utf-8"); const parsed = JSON.parse(data); - // Legacy files (pre-v0.11.0) lack the flow marker and carry tokens the - // new Worker cannot honor. Ignore them here so startup doesn't adopt - // stale state; performOAuthFlow() will surface the migration notice and - // remove the file the first time it runs. if (!parsed || parsed.flow !== TOKENS_FLOW_MARKER) { return null; } @@ -70,20 +76,21 @@ async function saveTokens(tokens) { let _cachedTokens = null; let _refreshLock = null; -let _deviceFlowLock = null; -let _legacyMigrationNotified = false; +let _webAuthLock = null; /** - * Tracks the in-flight device authorization so tool calls can return an + * Tracks the in-flight web authorization so tool calls can return an * auth-required response immediately (instead of blocking for ~600s) while * polling continues in the background. Cleared on success or failure so the - * next tool call after expiry starts a fresh device code. + * next tool call after expiry starts a fresh authorize URL. */ -let _pendingDeviceAuth = null; -let _pendingDeviceAuthError = null; +let _pendingWebAuth = null; +let _pendingWebAuthError = null; // ── OAuth Discovery & Registration ─────────────────────────────────────────── +const WEB_AUTH_POLL_GRANT = "urn:ietf:params:oauth:grant-type:web_authorization_poll"; + async function discoverOAuthMetadata() { const res = await fetch(`${WORKER_URL}/.well-known/oauth-authorization-server`); if (!res.ok) { @@ -106,15 +113,13 @@ async function saveClientRegistration(reg) { await writeFile(CLIENT_REG_FILE, JSON.stringify(reg, null, 2), { mode: 0o600 }); } -const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; - async function ensureClientRegistration(metadata) { const existing = await loadClientRegistration(); - // Legacy registrations were created for authorization_code + refresh_token. - // Re-register if the existing one is missing the device_code grant type so - // the Worker recognizes us as a device-flow client. + // Accept any client registration that already lists our web-auth poll grant. + // Legacy device-flow or authorization-code clients get re-registered so the + // Worker recognizes us as a v0.11.1 web-flow client. if (existing && Array.isArray(existing.grant_types) && - existing.grant_types.includes(DEVICE_CODE_GRANT)) { + existing.grant_types.includes(WEB_AUTH_POLL_GRANT)) { return existing; } @@ -127,9 +132,8 @@ async function ensureClientRegistration(metadata) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_name: "github-webhook-mcp-cli", - // Device flow does not use redirect_uris; leave empty for RFC 8628. redirect_uris: [], - grant_types: [DEVICE_CODE_GRANT, "refresh_token"], + grant_types: [WEB_AUTH_POLL_GRANT, "refresh_token"], token_endpoint_auth_method: "none", }), }); @@ -143,59 +147,20 @@ async function ensureClientRegistration(metadata) { return reg; } -// ── OAuth Device Authorization Grant (RFC 8628) ───────────────────────────── - -/** - * Detect a pre-v0.11.0 tokens file and surface a one-time migration notice - * on stderr. Legacy files were written by the localhost-callback flow and - * carry tokens the new Worker will reject, so we discard them and let the - * device flow re-establish authentication from scratch. - */ -async function checkLegacyTokensMigration() { - let raw; - try { - raw = await readFile(TOKEN_FILE, "utf-8"); - } catch { - return; // No tokens file at all — not a migration case. - } - - let parsed; - try { - parsed = JSON.parse(raw); - } catch { - // Corrupt file — treat as legacy/unusable and remove. - parsed = null; - } - - if (parsed && parsed.flow === TOKENS_FLOW_MARKER) { - return; // Already a device-flow tokens file — no migration needed. - } - - if (_legacyMigrationNotified) return; - _legacyMigrationNotified = true; - - process.stderr.write( - "[github-webhook-mcp] Detected legacy OAuth tokens from pre-v0.11.0 " + - "(localhost callback flow). This client now uses the Device " + - "Authorization Grant (RFC 8628). One-time re-authentication is " + - "required; follow the device-code prompt below.\n", - ); - - try { - await unlink(TOKEN_FILE); - } catch { - // Non-fatal: saveTokens() will overwrite it on success anyway. - } -} +// ── OAuth Web Flow ────────────────────────────────────────────────────────── function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function generateState() { + return randomBytes(32).toString("base64url"); +} + /** - * Best-effort platform-native browser launcher for the device-flow - * verification URL. Failures are non-fatal: we still surface the URL on - * stderr / in the tool response so the user can open it manually. + * Best-effort platform-native browser launcher for the web-flow authorize URL. + * Failures are non-fatal: we still surface the URL on stderr / in the tool + * response so the user can open it manually. */ function openBrowser(url) { if (!url || typeof url !== "string") return; @@ -235,42 +200,15 @@ function openBrowser(url) { } } -async function requestDeviceAuthorization(metadata, client) { - const endpoint = - metadata.device_authorization_endpoint || - `${WORKER_URL}/oauth/device_authorization`; - - const res = await fetch(endpoint, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ client_id: client.client_id }), - }); - - if (!res.ok) { - const body = await res.text().catch(() => ""); - throw new Error( - `Device authorization request failed: ${res.status} ${res.statusText}${body ? ` — ${body.slice(0, 200)}` : ""}`, - ); - } - - const data = await res.json(); - if (!data.device_code || !data.user_code || !data.verification_uri) { - throw new Error( - `Device authorization response missing required fields: ${JSON.stringify(data).slice(0, 200)}`, - ); - } - return data; -} - /** - * Poll the Worker's /oauth/token endpoint until the user approves, denies, - * or the device_code expires. Interval comes from the server; `slow_down` - * replies bump it by 5s per RFC 8628 §3.5. + * Poll the Worker's /oauth/token endpoint until the user approves, denies, or + * the state expires. The Worker mirrors RFC 8628's polling error shape so we + * can surface `authorization_pending` without exposing the poll to the user. */ -async function pollForDeviceToken(metadata, client, deviceAuth) { +async function pollForWebAuthToken(metadata, client, state, expiresInSec) { const endpoint = metadata.token_endpoint || `${WORKER_URL}/oauth/token`; - let interval = Math.max(1, Number(deviceAuth.interval) || 5); - const deadline = Date.now() + (Number(deviceAuth.expires_in) || 600) * 1000; + const interval = 2; // Worker issues Bearer tokens quickly; 2s poll is plenty. + const deadline = Date.now() + (expiresInSec || 600) * 1000; while (Date.now() < deadline) { await sleep(interval * 1000); @@ -281,8 +219,8 @@ async function pollForDeviceToken(metadata, client, deviceAuth) { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ - grant_type: DEVICE_CODE_GRANT, - device_code: deviceAuth.device_code, + grant_type: WEB_AUTH_POLL_GRANT, + state, client_id: client.client_id, }), }); @@ -307,16 +245,12 @@ async function pollForDeviceToken(metadata, client, deviceAuth) { if (err === "authorization_pending") { continue; } - if (err === "slow_down") { - interval += 5; - continue; - } if (err === "access_denied") { throw new Error("OAuth authorization denied by user"); } if (err === "expired_token") { throw new Error( - "OAuth device code expired before approval. Re-run the client to retry.", + "OAuth state expired before approval. Re-run the client to retry.", ); } @@ -328,86 +262,73 @@ async function pollForDeviceToken(metadata, client, deviceAuth) { } throw new Error( - "OAuth device code expired before approval. Re-run the client to retry.", + "OAuth state expired before approval. Re-run the client to retry.", ); } /** - * Start a device authorization flow: obtain device_code/user_code, surface the - * verification URL (stderr + auto-open browser), and kick off a background - * poll. Callers that await the returned promise will block until the user - * approves — this is what the WebSocket bootstrap does. Callers that only - * need the deviceAuth metadata (for a non-blocking tool response) can read - * `_pendingDeviceAuth` as soon as this function resolves past the await - * below; see `getAccessTokenOrPendingAuth()`. + * Start a Worker-hosted web OAuth flow: mint a state, open the authorize URL + * in the local browser, and kick off a background poll. Callers that await + * the returned promise will block until the user approves. Callers that only + * need the pending metadata (for a non-blocking tool response) can read + * `_pendingWebAuth` as soon as phase 1 resolves; see + * `getAccessTokenForToolCall()`. * - * Serialization: _deviceFlowLock ensures that concurrent callers (WebSocket - * bootstrap racing the first tool call) share a single device code rather - * than each launching their own approval prompt. + * Serialization: _webAuthLock ensures that concurrent callers (WebSocket + * bootstrap racing the first tool call) share a single authorize URL rather + * than each launching their own browser window. */ async function performOAuthFlow() { - if (_deviceFlowLock) { - return await _deviceFlowLock; + if (_webAuthLock) { + return await _webAuthLock; } - // Phase 1: obtain the device code. This is fast (one HTTP round-trip) and - // we surface the verification URL as soon as it returns. + // Phase 1: mint state + open the authorize URL. This is local-only (no HTTP + // round-trip) so it returns almost immediately. const startPromise = (async () => { - await checkLegacyTokensMigration(); - const metadata = await discoverOAuthMetadata(); const client = await ensureClientRegistration(metadata); - const deviceAuth = await requestDeviceAuthorization(metadata, client); - const complete = deviceAuth.verification_uri_complete; - const browserUrl = complete || deviceAuth.verification_uri; + const state = generateState(); + const authorizeEndpoint = metadata.authorization_endpoint || `${WORKER_URL}/oauth/authorize`; + const authorizeUrl = + `${authorizeEndpoint}?client_id=${encodeURIComponent(client.client_id)}` + + `&state=${encodeURIComponent(state)}`; + + // Client-side state validity window — keep aligned with Worker's WEB_AUTH_STATE_TTL. + const expiresInSec = 600; + const expiresAt = Date.now() + expiresInSec * 1000; - // stdio MCP clients have no UI surface of their own, so we publish the - // user_code and verification URI on stderr where the host logs land. The - // auth-required tool response (below) is the primary channel for Claude - // Code / Desktop; stderr is the fallback surface. const lines = [ "", - "[github-webhook-mcp] OAuth device authorization required.", - `[github-webhook-mcp] Visit: ${deviceAuth.verification_uri}`, - `[github-webhook-mcp] Enter code: ${deviceAuth.user_code}`, - ]; - if (complete && complete !== deviceAuth.verification_uri) { - lines.push(`[github-webhook-mcp] Or open directly: ${complete}`); - } - lines.push( - `[github-webhook-mcp] Opening browser for authentication...`, - `[github-webhook-mcp] Waiting for approval (expires in ${deviceAuth.expires_in || "?"}s)...`, + "[github-webhook-mcp] OAuth authorization required.", + `[github-webhook-mcp] Opening: ${authorizeUrl}`, + `[github-webhook-mcp] Approve in the browser window; the tab can be closed when done.`, + `[github-webhook-mcp] Waiting for approval (state expires in ${expiresInSec}s)...`, "", - ); + ]; process.stderr.write(lines.join("\n")); // Best-effort browser auto-open. Failures are logged to stderr but do // not abort the flow — the URL is still available in the tool response. - openBrowser(browserUrl); - - const expiresAt = deviceAuth.expires_in - ? Date.now() + Number(deviceAuth.expires_in) * 1000 - : undefined; + openBrowser(authorizeUrl); - _pendingDeviceAuth = { - user_code: deviceAuth.user_code, - verification_uri: deviceAuth.verification_uri, - verification_uri_complete: complete || null, + _pendingWebAuth = { + authorize_url: authorizeUrl, expires_at: expiresAt, }; - _pendingDeviceAuthError = null; + _pendingWebAuthError = null; - return { metadata, client, deviceAuth }; + return { metadata, client, state, expiresInSec }; })(); // Phase 2: poll in the background (still inside the same lock promise so // that simultaneous callers await one shared flow). Errors are recorded // and re-thrown so awaiting callers see them. - _deviceFlowLock = (async () => { + _webAuthLock = (async () => { try { - const { metadata, client, deviceAuth } = await startPromise; - const tokenData = await pollForDeviceToken(metadata, client, deviceAuth); + const { metadata, client, state, expiresInSec } = await startPromise; + const tokenData = await pollForWebAuthToken(metadata, client, state, expiresInSec); if (!tokenData.access_token) { throw new Error( @@ -426,28 +347,25 @@ async function performOAuthFlow() { await saveTokens(tokens); _cachedTokens = tokens; - _pendingDeviceAuth = null; - _pendingDeviceAuthError = null; - process.stderr.write("[github-webhook-mcp] OAuth device authorization complete.\n"); + _pendingWebAuth = null; + _pendingWebAuthError = null; + process.stderr.write("[github-webhook-mcp] OAuth authorization complete.\n"); return tokens; } catch (err) { - _pendingDeviceAuth = null; - _pendingDeviceAuthError = err && err.message ? err.message : String(err); + _pendingWebAuth = null; + _pendingWebAuthError = err && err.message ? err.message : String(err); throw err; } })(); - // Make sure the caller-visible promise settles deterministically when the - // background poll finishes (or errors). Don't await inside the lock itself; - // other callers may want to see _pendingDeviceAuth without blocking. - const lockPromise = _deviceFlowLock; + const lockPromise = _webAuthLock; lockPromise.finally(() => { - if (_deviceFlowLock === lockPromise) { - _deviceFlowLock = null; + if (_webAuthLock === lockPromise) { + _webAuthLock = null; } }); - // Wait for phase 1 so the caller sees _pendingDeviceAuth populated (or the + // Wait for phase 1 so the caller sees _pendingWebAuth populated (or the // startPromise's error) before we return the outer promise. await startPromise; return lockPromise; @@ -473,6 +391,8 @@ async function refreshAccessToken(refreshToken) { const err = new Error( `Token refresh failed: ${res.status} ${res.statusText}${body ? ` — ${body.slice(0, 200)}` : ""}`, ); + err.status = res.status; + err.bodyText = body; console.error("[oauth] refresh failed:", err.message); throw err; } @@ -499,6 +419,39 @@ async function refreshAccessToken(refreshToken) { return tokens; } +/** + * Detect the `invalid_grant` error shape on a refresh failure. When that hits, + * a sibling Claude Code process may have refreshed first — re-read the tokens + * file and see whether a newer rotation landed on disk. + */ +function isInvalidGrantError(err) { + if (!err) return false; + if (err.status !== 400) return false; + const body = typeof err.bodyText === "string" ? err.bodyText : ""; + return body.includes("invalid_grant"); +} + +/** + * RC1 fix: before giving up on a stale refresh_token, re-read the tokens file + * to see whether a concurrent process already rotated it. If the on-disk + * refresh_token differs from the one that just failed, adopt it and retry. + */ +async function tryRefreshViaDiskReread(previousRefreshToken) { + const fresh = await loadTokens(); + if (!fresh || !fresh.refresh_token) return null; + if (fresh.refresh_token === previousRefreshToken) return null; + try { + const tokens = await refreshAccessToken(fresh.refresh_token); + return tokens; + } catch (err) { + console.error( + "[oauth] disk-reread refresh also failed:", + err && err.message ? err.message : err, + ); + return null; + } +} + async function getAccessToken() { if (!_cachedTokens) { _cachedTokens = await loadTokens(); @@ -512,11 +465,6 @@ async function getAccessToken() { } if (_cachedTokens.refresh_token) { - // Serialize concurrent refresh attempts to prevent race conditions. - // Without this lock, the WebSocket startup and the first tool call can - // both trigger refreshAccessToken() with the same refresh token - // simultaneously, causing the token file to end up with an orphaned - // refresh token that the Worker no longer recognizes. if (!_refreshLock) { _refreshLock = refreshAccessToken(_cachedTokens.refresh_token); } @@ -524,6 +472,15 @@ async function getAccessToken() { _cachedTokens = await _refreshLock; return _cachedTokens.access_token; } catch (err) { + // RC1: maybe a sibling process already rotated. Re-read and retry once + // before falling through to a full web-flow restart. + if (isInvalidGrantError(err)) { + const reread = await tryRefreshViaDiskReread(_cachedTokens.refresh_token); + if (reread) { + _cachedTokens = reread; + return _cachedTokens.access_token; + } + } console.error("[oauth] refresh failed, falling back to full OAuth flow:", err.message || err); } finally { _refreshLock = null; @@ -538,24 +495,23 @@ async function getAccessToken() { } /** - * Sentinel thrown by `getAccessTokenForToolCall()` when the device flow is - * still pending approval. The tool handler catches this and returns a - * structured auth-required response to the MCP client without blocking on - * the poll loop. + * Sentinel thrown by `getAccessTokenForToolCall()` when the web flow is still + * pending approval. The tool handler catches this and returns a structured + * auth-required response to the MCP client without blocking on the poll loop. */ class AuthRequiredError extends Error { constructor(pending, note) { - super(note || "OAuth device authorization required"); + super(note || "OAuth authorization required"); this.name = "AuthRequiredError"; this.pending = pending; } } /** - * Like getAccessToken(), but never blocks on the device-flow poll. If no - * tokens are available, it starts the flow (if not already running) and - * throws an AuthRequiredError carrying the current device-code details so - * the caller can surface them in the tool response immediately. + * Like getAccessToken(), but never blocks on the web-flow poll. If no tokens + * are available, it starts the flow (if not already running) and throws an + * AuthRequiredError carrying the current authorize URL so the caller can + * surface it in the tool response immediately. */ async function getAccessTokenForToolCall() { if (!_cachedTokens) { @@ -569,8 +525,6 @@ async function getAccessTokenForToolCall() { } if (_cachedTokens.refresh_token) { - // Refresh synchronously — refresh calls are one round-trip, unlike - // the full device flow, so blocking a tool call here is fine. if (!_refreshLock) { _refreshLock = refreshAccessToken(_cachedTokens.refresh_token); } @@ -578,8 +532,15 @@ async function getAccessTokenForToolCall() { _cachedTokens = await _refreshLock; return _cachedTokens.access_token; } catch (err) { + if (isInvalidGrantError(err)) { + const reread = await tryRefreshViaDiskReread(_cachedTokens.refresh_token); + if (reread) { + _cachedTokens = reread; + return _cachedTokens.access_token; + } + } console.error( - "[oauth] refresh failed, starting device flow in background:", + "[oauth] refresh failed, starting web flow in background:", err.message || err, ); } finally { @@ -588,31 +549,18 @@ async function getAccessTokenForToolCall() { } } - // No usable tokens. Start the device flow if it isn't already running; - // either way, hand the caller the current pending device_code details. - if (!_pendingDeviceAuth && !_deviceFlowLock) { - // Swallow the outer promise; the background poll settles via - // _deviceFlowLock. Errors during phase 1 (e.g. network failure to - // /oauth/device_authorization) propagate synchronously below. + // No usable tokens. Start the web flow if it isn't already running; + // either way, hand the caller the current pending authorize URL. + if (!_pendingWebAuth && !_webAuthLock) { performOAuthFlow().catch((err) => { - // Already captured into _pendingDeviceAuthError; log for operators. - console.error("[oauth] device flow background poll ended with error:", err.message || err); + console.error("[oauth] web flow background poll ended with error:", err.message || err); }); - // Wait briefly (phase 1 is a single HTTP round-trip) so _pendingDeviceAuth - // is populated before we throw — but never block for the full poll. - // performOAuthFlow() only returns once phase 1 has resolved, so we can - // await the lock promise safely. The lock is created synchronously inside - // performOAuthFlow(), so it's guaranteed to be non-null here. - if (_deviceFlowLock) { + if (_webAuthLock) { try { - // Awaiting the lock resolves when polling finishes. We instead await - // via a tick to give startPromise time to populate _pendingDeviceAuth. await Promise.race([ - // Let phase 1 populate _pendingDeviceAuth. new Promise((resolve) => setImmediate(resolve)), - // If phase 1 fails fast, the lock rejects and we re-throw. - _deviceFlowLock.then(() => undefined, (err) => { throw err; }), + _webAuthLock.then(() => undefined, (err) => { throw err; }), ]); } catch (err) { throw err; @@ -620,24 +568,22 @@ async function getAccessTokenForToolCall() { } } - // Give phase 1 a little more time to finish (device_authorization request - // is a single round-trip — normally sub-second). Poll the state briefly - // so we return a populated auth-required response instead of an empty one. + // Give phase 1 a little more time to finish. Phase 1 now includes a discovery + // fetch + (possibly) a register fetch, so allow up to 15s before giving up. const phase1Deadline = Date.now() + 15_000; - while (!_pendingDeviceAuth && !_pendingDeviceAuthError && Date.now() < phase1Deadline) { + while (!_pendingWebAuth && !_pendingWebAuthError && Date.now() < phase1Deadline) { await sleep(100); } - if (_pendingDeviceAuthError && !_pendingDeviceAuth) { - throw new Error(`OAuth device flow failed: ${_pendingDeviceAuthError}`); + if (_pendingWebAuthError && !_pendingWebAuth) { + throw new Error(`OAuth web flow failed: ${_pendingWebAuthError}`); } - if (_pendingDeviceAuth) { - throw new AuthRequiredError(_pendingDeviceAuth); + if (_pendingWebAuth) { + throw new AuthRequiredError(_pendingWebAuth); } - // Phase 1 timed out — surface as a regular error. - throw new Error("OAuth device flow did not produce a verification URL in time."); + throw new Error("OAuth web flow did not produce an authorize URL in time."); } async function buildAuthHeaders(token, extra) { @@ -827,28 +773,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })) function formatAuthRequiredResponse(pending) { const parts = []; - parts.push("OAuth device authorization required."); + parts.push("OAuth authorization required."); parts.push(""); - if (pending.verification_uri_complete) { - parts.push(`Open (code pre-filled): ${pending.verification_uri_complete}`); - parts.push(""); - parts.push(`Or visit ${pending.verification_uri} and enter the code:`); - } else { - parts.push(`Visit: ${pending.verification_uri}`); - parts.push("Enter the code:"); - } - parts.push(` ${pending.user_code}`); + parts.push(`Open this URL in your browser: ${pending.authorize_url}`); parts.push(""); if (pending.expires_at) { const remainingMs = pending.expires_at - Date.now(); if (remainingMs > 0) { const mins = Math.max(1, Math.round(remainingMs / 60_000)); - parts.push(`Code expires in about ${mins} minute${mins === 1 ? "" : "s"}.`); + parts.push(`This link is valid for about ${mins} minute${mins === 1 ? "" : "s"}.`); } } parts.push( "A browser window should have opened automatically. " + - "Retry the same tool call after approving — subsequent calls will succeed once authorization completes.", + "Sign in on GitHub, then retry the same tool call — subsequent calls will succeed once authorization completes.", ); return parts.join("\n"); } diff --git a/mcp-server/test/auth-required.test.mjs b/mcp-server/test/auth-required.test.mjs deleted file mode 100644 index e95a452..0000000 --- a/mcp-server/test/auth-required.test.mjs +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Black-box contract tests for the v0.11.1 device-flow UX response shape. - * - * The behaviour exercised here is the "auth-required" tool result that the - * MCP client returns when a tool call arrives before the user has approved - * the device code (see mcp-server/server/index.js :: formatAuthRequiredResponse - * and its TypeScript twin in local-mcp/src/index.ts). - * - * We re-implement the user-visible contract inline because the module cannot - * be imported without starting an MCP server (top-level await on mcp.connect). - * The assertions cover the invariants that the Claude Code / Desktop UX - * depends on: - * - the response surfaces a clickable URL (verification_uri_complete - * preferred over verification_uri) - * - the user_code is always included, even when the complete URL is present - * - the response is marked isError=true so hosts render it as a failed tool - * call the user can retry - */ -import { test } from "node:test"; -import assert from "node:assert/strict"; - -/** Mirrors formatAuthRequiredResponse() in server/index.js. */ -function formatAuthRequiredResponse(pending) { - const parts = []; - parts.push("OAuth device authorization required."); - parts.push(""); - if (pending.verification_uri_complete) { - parts.push(`Open (code pre-filled): ${pending.verification_uri_complete}`); - parts.push(""); - parts.push(`Or visit ${pending.verification_uri} and enter the code:`); - } else { - parts.push(`Visit: ${pending.verification_uri}`); - parts.push("Enter the code:"); - } - parts.push(` ${pending.user_code}`); - parts.push(""); - if (pending.expires_at) { - const remainingMs = pending.expires_at - Date.now(); - if (remainingMs > 0) { - const mins = Math.max(1, Math.round(remainingMs / 60_000)); - parts.push(`Code expires in about ${mins} minute${mins === 1 ? "" : "s"}.`); - } - } - parts.push( - "A browser window should have opened automatically. " + - "Retry the same tool call after approving — subsequent calls will succeed once authorization completes.", - ); - return parts.join("\n"); -} - -/** Shape the tool handler returns when the device flow is pending. */ -function buildAuthRequiredToolResult(pending) { - return { - content: [{ type: "text", text: formatAuthRequiredResponse(pending) }], - isError: true, - }; -} - -test("auth-required response prefers verification_uri_complete and still includes the raw code", () => { - const pending = { - user_code: "WDJB-MJHT", - verification_uri: "https://github.com/login/device", - verification_uri_complete: - "https://github.com/login/device?user_code=WDJB-MJHT", - expires_at: Date.now() + 600_000, - }; - const result = buildAuthRequiredToolResult(pending); - - assert.equal(result.isError, true); - assert.equal(result.content.length, 1); - const text = result.content[0].text; - - // Both surfaces must be present: the preferred pre-filled URL AND the raw - // code (some hosts strip hyperlinks or trim long URLs). - assert.ok( - text.includes(pending.verification_uri_complete), - "expected response to include verification_uri_complete", - ); - assert.ok(text.includes(pending.user_code), "expected response to include user_code"); - assert.ok( - text.includes("OAuth device authorization required"), - "expected response to start with the OAuth required header", - ); -}); - -test("auth-required response falls back to verification_uri when complete URL is absent", () => { - const pending = { - user_code: "WDJB-MJHT", - verification_uri: "https://github.com/login/device", - verification_uri_complete: null, - expires_at: Date.now() + 600_000, - }; - const result = buildAuthRequiredToolResult(pending); - const text = result.content[0].text; - - assert.ok(text.includes(pending.verification_uri), "expected verification_uri"); - assert.ok(text.includes(pending.user_code), "expected user_code"); - // Without the complete URL we should NOT pretend there is a pre-filled link. - assert.ok( - !text.includes("code pre-filled"), - "expected no 'code pre-filled' hint when verification_uri_complete is null", - ); -}); - -test("auth-required response omits expiry hint when expires_at is missing", () => { - const pending = { - user_code: "WDJB-MJHT", - verification_uri: "https://github.com/login/device", - verification_uri_complete: null, - expires_at: undefined, - }; - const result = buildAuthRequiredToolResult(pending); - const text = result.content[0].text; - - assert.ok(!/expires in/.test(text), "expected no expiry hint when expires_at is undefined"); -}); - -test("auth-required response tells the user to retry after approval", () => { - const pending = { - user_code: "WDJB-MJHT", - verification_uri: "https://github.com/login/device", - verification_uri_complete: - "https://github.com/login/device?user_code=WDJB-MJHT", - expires_at: Date.now() + 600_000, - }; - const result = buildAuthRequiredToolResult(pending); - const text = result.content[0].text; - - // The retry hint is what keeps subsequent tool calls from blocking a second - // time — users must understand the workflow. - assert.ok( - /Retry the same tool call after approving/i.test(text), - "expected retry-after-approval hint in response", - ); -}); - -test("response is marked isError so hosts render it as a failed tool call", () => { - const pending = { - user_code: "X", - verification_uri: "https://example.com", - verification_uri_complete: null, - expires_at: undefined, - }; - const result = buildAuthRequiredToolResult(pending); - assert.equal(result.isError, true); -}); diff --git a/mcp-server/test/migration.test.mjs b/mcp-server/test/migration.test.mjs index 81a10f7..98e4c6f 100644 --- a/mcp-server/test/migration.test.mjs +++ b/mcp-server/test/migration.test.mjs @@ -2,22 +2,22 @@ * 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. + * that were not issued by the current flow. Clients signal "this file was written + * by the v0.11.1+ web OAuth flow" with a `flow: "web"` marker on the tokens + * file. Older files — whether from the v0.10.x localhost-callback flow, the v0.11.0 + * device flow, or any other prior iteration — lack that marker and must be + * treated as stale on load so the client can re-authenticate through the + * current flow. * * 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 + * - loadTokens() returns null when `flow !== "web"` + * - saveTokens() always writes `flow: "web"` 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. + * here and verify it against sample payloads representative of each flow. */ import { test } from "node:test"; import assert from "node:assert/strict"; @@ -25,7 +25,8 @@ import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -const TOKENS_FLOW_MARKER = "device"; +const TOKENS_FLOW_MARKER = "web"; +const WEB_AUTH_POLL_GRANT = "urn:ietf:params:oauth:grant-type:web_authorization_poll"; /** Predicate mirrored from mcp-server/server/index.js :: loadTokens(). */ function isActiveTokensFile(parsed) { @@ -33,27 +34,39 @@ function isActiveTokensFile(parsed) { return parsed.flow === TOKENS_FLOW_MARKER; } -/** Shape a legacy (pre-v0.11.0, localhost-callback) tokens file would have on disk. */ -const LEGACY_TOKENS = { +/** Shape a v0.10.x (localhost-callback) tokens file would have on disk. */ +const LEGACY_LOCALHOST_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 = { +/** Shape a v0.11.0 (device-flow) tokens file would have on disk. */ +const LEGACY_DEVICE_TOKENS = { + flow: "device", + access_token: "device_access_token_value", + refresh_token: "device_refresh_token_value", + expires_at: Date.now() + 3600_000, +}; + +/** Shape a v0.11.1+ (Worker-hosted web flow) tokens file has on disk. */ +const WEB_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("v0.10.x localhost-flow tokens file (no flow marker) is rejected", () => { + assert.equal(isActiveTokensFile(LEGACY_LOCALHOST_TOKENS), false); +}); + +test("v0.11.0 device-flow tokens file (flow=device) is rejected", () => { + assert.equal(isActiveTokensFile(LEGACY_DEVICE_TOKENS), false); }); -test("device-flow tokens file (flow=device) is accepted", () => { - assert.equal(isActiveTokensFile(DEVICE_TOKENS), true); +test("v0.11.1+ web-flow tokens file (flow=web) is accepted", () => { + assert.equal(isActiveTokensFile(WEB_TOKENS), true); }); test("malformed files are treated as inactive", () => { @@ -64,53 +77,85 @@ test("malformed files are treated as inactive", () => { assert.equal(isActiveTokensFile({ access_token: "x" }), false); // no flow at all }); -test("round-trip: legacy file replaced by device-flow file on disk", async () => { +test("round-trip: legacy file replaced by web-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"`. + // 1. A pre-v0.11.1 client wrote a tokens file to ~/.github-webhook-mcp/oauth-tokens.json + // (either localhost-flow or device-flow shape). + // 2. The user upgrades to v0.11.1; on first run the client overwrites the + // existing file with a web-flow payload. + // The key invariant: after migration, the file on disk parses as `flow === "web"`. 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); + for (const legacy of [LEGACY_LOCALHOST_TOKENS, LEGACY_DEVICE_TOKENS]) { + await writeFile(tokenFile, JSON.stringify(legacy, null, 2), { mode: 0o600 }); + const before = JSON.parse(await readFile(tokenFile, "utf-8")); + assert.equal(isActiveTokensFile(before), false); + + // Simulate post-migration overwrite with web-flow payload + await writeFile(tokenFile, JSON.stringify(WEB_TOKENS, null, 2), { mode: 0o600 }); + const after = JSON.parse(await readFile(tokenFile, "utf-8")); + assert.equal(isActiveTokensFile(after), true); + assert.equal(after.access_token, WEB_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. +test("client registration file shape: web-auth poll grant + refresh_token", () => { + // The client re-registers when existing registration lacks the web-auth poll grant. // This test asserts the shape the Worker will accept (see worker/src/oauth.ts handleRegister). - const deviceFlowClient = { + const webFlowClient = { client_id: "abc123", client_name: "github-webhook-mcp-cli", redirect_uris: [], - grant_types: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"], + grant_types: [WEB_AUTH_POLL_GRANT, "refresh_token"], token_endpoint_auth_method: "none", }; - const legacyClient = { + const legacyDeviceClient = { client_id: "legacy456", + grant_types: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"], + }; + const legacyAuthCodeClient = { + client_id: "legacy789", 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)); + const hasWebAuthGrant = (reg) => + Boolean(reg && Array.isArray(reg.grant_types) && reg.grant_types.includes(WEB_AUTH_POLL_GRANT)); + + assert.equal(hasWebAuthGrant(webFlowClient), true); + assert.equal(hasWebAuthGrant(legacyDeviceClient), false); + assert.equal(hasWebAuthGrant(legacyAuthCodeClient), false); + assert.equal(hasWebAuthGrant(null), false); + assert.equal(hasWebAuthGrant({ grant_types: "not-an-array" }), false); +}); + +/** + * RC1 regression guard: when refresh returns invalid_grant, the bridge should + * re-read the tokens file and adopt any newer refresh_token before falling + * back to a full web flow. This test covers the detection predicate only; the + * full retry sequence is exercised manually on a real Worker. + */ +test("invalid_grant detection recognizes 400 responses with invalid_grant body", () => { + function isInvalidGrantError(err) { + if (!err) return false; + if (err.status !== 400) return false; + const body = typeof err.bodyText === "string" ? err.bodyText : ""; + return body.includes("invalid_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); + assert.equal(isInvalidGrantError(null), false); + assert.equal(isInvalidGrantError({ status: 500, bodyText: "invalid_grant" }), false); + assert.equal(isInvalidGrantError({ status: 400, bodyText: "something else" }), false); + assert.equal( + isInvalidGrantError({ + status: 400, + bodyText: '{"error":"invalid_grant","error_description":"rotated"}', + }), + true, + ); }); diff --git a/mcp-server/test/web-auth-required.test.mjs b/mcp-server/test/web-auth-required.test.mjs new file mode 100644 index 0000000..ec101c2 --- /dev/null +++ b/mcp-server/test/web-auth-required.test.mjs @@ -0,0 +1,111 @@ +/** + * Black-box contract tests for the v0.11.1 Worker-hosted web OAuth UX. + * + * The behaviour exercised here is the "auth-required" tool result that the + * MCP client returns when a tool call arrives before the user has approved + * the web OAuth flow (see mcp-server/server/index.js :: formatAuthRequiredResponse + * and its TypeScript twin in local-mcp/src/index.ts). + * + * We re-implement the user-visible contract inline because the module cannot + * be imported without starting an MCP server (top-level await on mcp.connect). + * The assertions cover the invariants that the Claude Code / Desktop UX + * depends on: + * - the response surfaces a clickable authorize URL pointing at the Worker + * - the response is marked isError=true so hosts render it as a failed tool + * call the user can retry + * - the response tells the user to retry after signing in on GitHub + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +/** Mirrors formatAuthRequiredResponse() in server/index.js. */ +function formatAuthRequiredResponse(pending) { + const parts = []; + parts.push("OAuth authorization required."); + parts.push(""); + parts.push(`Open this URL in your browser: ${pending.authorize_url}`); + parts.push(""); + if (pending.expires_at) { + const remainingMs = pending.expires_at - Date.now(); + if (remainingMs > 0) { + const mins = Math.max(1, Math.round(remainingMs / 60_000)); + parts.push(`This link is valid for about ${mins} minute${mins === 1 ? "" : "s"}.`); + } + } + parts.push( + "A browser window should have opened automatically. " + + "Sign in on GitHub, then retry the same tool call — subsequent calls will succeed once authorization completes.", + ); + return parts.join("\n"); +} + +/** Shape the tool handler returns when the web flow is pending. */ +function buildAuthRequiredToolResult(pending) { + return { + content: [{ type: "text", text: formatAuthRequiredResponse(pending) }], + isError: true, + }; +} + +test("auth-required response surfaces the Worker authorize URL verbatim", () => { + const pending = { + authorize_url: + "https://github-webhook.smgjp.com/oauth/authorize?client_id=abc&state=xyz", + expires_at: Date.now() + 600_000, + }; + const result = buildAuthRequiredToolResult(pending); + + assert.equal(result.isError, true); + assert.equal(result.content.length, 1); + const text = result.content[0].text; + + assert.ok( + text.includes(pending.authorize_url), + "expected response to include authorize_url", + ); + assert.ok( + text.includes("OAuth authorization required"), + "expected response to start with the OAuth required header", + ); +}); + +test("auth-required response omits expiry hint when expires_at is missing", () => { + const pending = { + authorize_url: "https://example.com/oauth/authorize?client_id=c&state=s", + expires_at: undefined, + }; + const result = buildAuthRequiredToolResult(pending); + const text = result.content[0].text; + + assert.ok(!/valid for about/.test(text), "expected no expiry hint when expires_at is undefined"); +}); + +test("auth-required response tells the user to retry after GitHub sign-in", () => { + const pending = { + authorize_url: + "https://github-webhook.smgjp.com/oauth/authorize?client_id=abc&state=xyz", + expires_at: Date.now() + 600_000, + }; + const result = buildAuthRequiredToolResult(pending); + const text = result.content[0].text; + + // The retry hint is what keeps subsequent tool calls from blocking a second + // time — users must understand the workflow. + assert.ok( + /retry the same tool call/i.test(text), + "expected retry-after-approval hint in response", + ); + assert.ok( + /Sign in on GitHub/i.test(text), + "expected explicit mention of GitHub sign-in", + ); +}); + +test("response is marked isError so hosts render it as a failed tool call", () => { + const pending = { + authorize_url: "https://example.com/oauth/authorize?client_id=c&state=s", + expires_at: undefined, + }; + const result = buildAuthRequiredToolResult(pending); + assert.equal(result.isError, true); +}); diff --git a/worker/src/index.ts b/worker/src/index.ts index 1db2d73..790439a 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -1,19 +1,18 @@ /** * Cloudflare Worker entrypoint for github-webhook-mcp. * - * OAuth model: bespoke Device Authorization Grant (RFC 8628) implementation. - * See worker/src/oauth.ts. The legacy @cloudflare/workers-oauth-provider - * wrapper and its /oauth/authorize + /oauth/callback localhost flow were - * removed in v0.11.0 (see #198) because they caused a chronic auth loop - * (#195) across process restarts. + * OAuth model: bespoke Worker-hosted web OAuth flow. See worker/src/oauth.ts. + * v0.11.1 reverts the device-authorization-grant iteration (#203, v0.11.0) + * back to a standard web OAuth UX. The redirect_uri is pinned to + * `https:///oauth/callback` so the approval step never returns to + * the client's localhost (fix for #195's chronic auth loop). * * Routes: * GET /.well-known/oauth-authorization-server RFC 8414 metadata (oauth.ts) - * POST /oauth/register RFC 7591 (oauth.ts) - * POST /oauth/device_authorization RFC 8628 §3.1 (oauth.ts) - * POST /oauth/token RFC 8628 §3.4 + refresh (oauth.ts) - * GET /oauth/device Device approval redirect (oauth.ts) - * GET /oauth/authorize, /oauth/callback 410 Gone (oauth.ts) + * POST /oauth/register RFC 7591 dynamic registration (oauth.ts) + * GET /oauth/authorize State issuance + GitHub redirect (oauth.ts) + * GET /oauth/callback GitHub code → bearer token exchange (oauth.ts) + * POST /oauth/token Web-auth polling + refresh_token (oauth.ts) * * POST /webhooks/github Webhook ingest (no auth) * POST /mcp MCP protocol (Bearer token) diff --git a/worker/src/oauth-store.ts b/worker/src/oauth-store.ts index 75a8bb6..41f332c 100644 --- a/worker/src/oauth-store.ts +++ b/worker/src/oauth-store.ts @@ -1,14 +1,15 @@ /** - * OAuth KV store — bespoke schema for device authorization grant flow (RFC 8628). + * OAuth KV store — bespoke schema for Worker-hosted web OAuth flow. * - * Replaces the opaque key format used by @cloudflare/workers-oauth-provider v0.3.1. - * All records are JSON-encoded with explicit expiresAt timestamps so we can - * detect expiry without relying solely on KV TTL. + * v0.11.1 reverts the device-authorization-grant layout (#203) in favour of a + * Worker-hosted web OAuth flow. The Worker acts as the redirect_uri target + * (`https:///oauth/callback`) so the client never needs a localhost + * listener. Issued bearer tokens and refresh rotation semantics stay identical + * to v0.11.0 — only the approval path is new. * * Key layout: * client:{client_id} → ClientRecord (dynamic client registration) - * device:{device_code} → DeviceRecord (device flow polling state) - * user_code:{user_code} → {device_code} (user-code → device-code index) + * web_auth_state:{state} → WebAuthStateRecord (web OAuth polling state) * token:{access_token} → TokenRecord (access token → grant ref) * refresh:{refresh_token} → RefreshRecord (refresh token → grant ref) * grant:{grant_id} → GrantRecord (GitHub props + issued tokens) @@ -21,37 +22,44 @@ import type { GitHubUserProps } from "./oauth.js"; export interface ClientRecord { client_id: string; - /** Optional for public clients using device flow. */ + /** Optional for public clients. */ client_secret?: string; client_name?: string; redirect_uris: string[]; - /** Always includes "urn:ietf:params:oauth:grant-type:device_code" + "refresh_token". */ + /** Always includes "authorization_code" + "refresh_token". */ grant_types: string[]; token_endpoint_auth_method: "none" | "client_secret_post" | "client_secret_basic"; created_at: string; } -export interface DeviceRecord { - device_code: string; - user_code: string; +/** + * Web OAuth polling state. + * + * The MCP bridge obtains a `state` by calling GET /oauth/authorize, opens the + * browser to let the user complete GitHub's web OAuth, and polls + * POST /oauth/token with grant_type=urn:ietf:params:oauth:grant-type:web_authorization_poll + * against that `state`. The callback handler flips the record to `approved` and + * attaches the freshly-issued access/refresh tokens; the next poll consumes + * them and the record is deleted. + */ +export interface WebAuthStateRecord { + state: string; client_id: string; /** Scope requested by the client, space-separated. May be empty. */ scope: string; /** Absolute expiry time (epoch seconds). */ expires_at: number; - /** Minimum polling interval in seconds. Bumped on slow_down. */ - interval: number; - /** Next allowed poll time (epoch seconds). Enforces interval + slow_down. */ - next_poll_at: number; /** * Approval state: * pending — user has not completed authorization yet - * approved — user authorized; props populated; ready for token exchange + * approved — user authorized; access_token / refresh_token populated * denied — user denied authorization */ status: "pending" | "approved" | "denied"; - /** Populated once the user completes GitHub authorization. */ - props?: GitHubUserProps; + /** Populated once the callback handler completes the GitHub exchange. */ + access_token?: string; + /** Populated alongside access_token. */ + refresh_token?: string; } export interface TokenRecord { @@ -85,10 +93,10 @@ export interface GrantRecord { /** Token lifetimes (seconds). */ export const ACCESS_TOKEN_TTL = 3600; // 1 hour export const REFRESH_TOKEN_TTL = 30 * 24 * 3600; // 30 days -/** Device code lifetime (seconds). RFC 8628 §3.2 recommends ~600s. */ -export const DEVICE_CODE_TTL = 600; -/** Default polling interval (seconds). RFC 8628 §3.5. */ -export const DEVICE_POLL_INTERVAL = 5; +/** Web auth state lifetime (seconds). */ +export const WEB_AUTH_STATE_TTL = 600; +/** Default client poll interval (seconds). */ +export const WEB_AUTH_POLL_INTERVAL = 2; const nowSec = () => Math.floor(Date.now() / 1000); @@ -124,24 +132,28 @@ export async function getClient(kv: KVNamespace, clientId: string): Promise(kv, `client:${clientId}`); } -// ── Device records (RFC 8628 polling state) ─────────────────────── +// ── Web auth state records (Worker-hosted web OAuth polling) ────── -export async function putDevice(kv: KVNamespace, record: DeviceRecord): Promise { +export async function putWebAuthState( + kv: KVNamespace, + record: WebAuthStateRecord, +): Promise { const remaining = record.expires_at - nowSec(); - await putJson(kv, `device:${record.device_code}`, record, remaining); - // user_code index for approval landing pages / lookups. - await kv.put(`user_code:${record.user_code}`, record.device_code, { - expirationTtl: Math.max(60, remaining), - }); + await putJson(kv, `web_auth_state:${record.state}`, record, remaining); } -export async function getDevice(kv: KVNamespace, deviceCode: string): Promise { - return getJson(kv, `device:${deviceCode}`); +export async function getWebAuthState( + kv: KVNamespace, + state: string, +): Promise { + return getJson(kv, `web_auth_state:${state}`); } -export async function deleteDevice(kv: KVNamespace, record: DeviceRecord): Promise { - await kv.delete(`device:${record.device_code}`); - await kv.delete(`user_code:${record.user_code}`); +export async function deleteWebAuthState( + kv: KVNamespace, + state: string, +): Promise { + await kv.delete(`web_auth_state:${state}`); } // ── Grant + token records ───────────────────────────────────────── @@ -193,20 +205,6 @@ export function randomToken(bytes = 32): string { return base64UrlEncode(buf); } -/** Generate an RFC 8628 user_code: 8 uppercase alphanumeric chars grouped XXXX-XXXX. */ -export function randomUserCode(): string { - // Exclude ambiguous chars (0/O, 1/I) per RFC 8628 §6.1 guidance. - const alphabet = "BCDFGHJKLMNPQRSTVWXZ23456789"; - const buf = new Uint8Array(8); - crypto.getRandomValues(buf); - let out = ""; - for (let i = 0; i < 8; i++) { - out += alphabet[buf[i] % alphabet.length]; - if (i === 3) out += "-"; - } - return out; -} - export function grantIdFor(userId: string): string { return `${userId}:${randomToken(12)}`; } diff --git a/worker/src/oauth.ts b/worker/src/oauth.ts index d9cdaf4..ebadab4 100644 --- a/worker/src/oauth.ts +++ b/worker/src/oauth.ts @@ -1,58 +1,62 @@ /** * Bespoke OAuth implementation for github-webhook-mcp. * - * Implements OAuth 2.1 Device Authorization Grant (RFC 8628) with GitHub as the - * upstream identity provider (also via device flow). Replaces the previous - * @cloudflare/workers-oauth-provider integration, which did not support device - * flow and relied on an ephemeral localhost callback that failed across process - * restarts (see #195 / #198). + * v0.11.1 reverts to a Worker-hosted web OAuth flow (standard GitHub login + + * 2FA UX) after the v0.11.0 device-authorization-grant iteration proved + * awkward for end users (#209). Both root causes that drove the original + * localhost-callback auth loop (#195) are still avoided: + * + * RC1: refresh-rotation desync — resolved client-side via tokens-file + * re-read on invalid_grant (see mcp-server / local-mcp). + * RC2: localhost ephemeral-port unreachability — resolved here by pinning + * the GitHub `redirect_uri` to `https:///oauth/callback` so the + * approval step never returns to the client's machine. * * Endpoints served: * GET /.well-known/oauth-authorization-server RFC 8414 metadata * POST /oauth/register RFC 7591 dynamic registration - * POST /oauth/device_authorization RFC 8628 §3.1 - * POST /oauth/token RFC 8628 §3.4 + refresh_token - * GET /oauth/device Human-friendly approval redirect - * GET /oauth/authorize 410 Gone (legacy endpoint) - * GET /oauth/callback 410 Gone (legacy endpoint) + * GET /oauth/authorize State issuance + GitHub redirect + * GET /oauth/callback GitHub code → bearer token exchange + * POST /oauth/token Web auth polling + refresh_token * - * GitHub upstream (device flow): - * POST https://github.com/login/device/code + * GitHub upstream (web flow): + * https://github.com/login/oauth/authorize * POST https://github.com/login/oauth/access_token */ import { ACCESS_TOKEN_TTL, - DEVICE_CODE_TTL, - DEVICE_POLL_INTERVAL, REFRESH_TOKEN_TTL, + WEB_AUTH_STATE_TTL, + WEB_AUTH_POLL_INTERVAL, deleteAccessToken, - deleteDevice, deleteGrant, deleteRefreshToken, + deleteWebAuthState, getAccessToken, getClient, - getDevice, getGrant, getRefreshToken, + getWebAuthState, grantIdFor, putAccessToken, putClient, - putDevice, putGrant, putRefreshToken, + putWebAuthState, randomToken, - randomUserCode, type ClientRecord, - type DeviceRecord, type GrantRecord, + type WebAuthStateRecord, } from "./oauth-store.js"; -/** GitHub device flow endpoints. */ -const GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"; +/** GitHub web OAuth endpoints. */ +const GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"; const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"; const GITHUB_USER_API = "https://api.github.com/user"; -const GITHUB_VERIFICATION_URI = "https://github.com/login/device"; + +/** Custom grant type for the bridge-polling half of the web OAuth flow. */ +const WEB_AUTH_POLL_GRANT = "urn:ietf:params:oauth:grant-type:web_authorization_poll"; /** * Props stored with each OAuth grant. @@ -121,29 +125,15 @@ export async function handleOAuthRequest( if (path === "/oauth/register" && request.method === "POST") { return handleRegister(request, env); } - if (path === "/oauth/device_authorization" && request.method === "POST") { - return handleDeviceAuthorization(request, env); + if (path === "/oauth/authorize" && request.method === "GET") { + return handleAuthorize(request, env); + } + if (path === "/oauth/callback" && request.method === "GET") { + return handleCallback(request, env); } if (path === "/oauth/token" && request.method === "POST") { return handleToken(request, env); } - if (path === "/oauth/device" && request.method === "GET") { - // Human-friendly landing page: forward the user to GitHub's device page. - // The actual approval is driven by GitHub's device flow — the user enters - // the code at https://github.com/login/device directly. This route exists - // mainly so verification_uri_complete can include a user_code query param - // and land somewhere informative. - return handleDeviceLanding(request); - } - - // Legacy endpoints removed in v0.11.0 (see #198). Return 410 Gone so old - // clients fail loudly rather than silently fall into an auth loop. - if (path === "/oauth/authorize" || path === "/oauth/callback") { - return new Response( - "This endpoint was removed in v0.11.0. Update your MCP client to use the device authorization grant (RFC 8628).", - { status: 410, headers: { "Content-Type": "text/plain" } }, - ); - } return null; } @@ -155,10 +145,10 @@ function handleMetadata(request: Request): Response { return jsonResponse({ issuer: origin, registration_endpoint: `${origin}/oauth/register`, - device_authorization_endpoint: `${origin}/oauth/device_authorization`, + authorization_endpoint: `${origin}/oauth/authorize`, token_endpoint: `${origin}/oauth/token`, grant_types_supported: [ - "urn:ietf:params:oauth:grant-type:device_code", + WEB_AUTH_POLL_GRANT, "refresh_token", ], response_types_supported: [], @@ -186,10 +176,11 @@ async function handleRegister(request: Request, env: OAuthEnv): Promise { + const url = new URL(request.url); + const clientId = url.searchParams.get("client_id"); + const state = url.searchParams.get("state"); + const scope = url.searchParams.get("scope") ?? ""; -async function handleDeviceAuthorization(request: Request, env: OAuthEnv): Promise { - const form = await readForm(request); - const clientId = form.get("client_id"); if (!clientId) { return oauthError("invalid_request", "client_id is required", 400); } + if (!state || state.length < 8) { + return oauthError("invalid_request", "state parameter is required (min 8 chars)", 400); + } const client = await getClient(env.OAUTH_KV, clientId); if (!client) { return oauthError("invalid_client", "Unknown client_id", 401); } - const scope = form.get("scope") ?? ""; + const now = nowSec(); + const record: WebAuthStateRecord = { + state, + client_id: clientId, + scope, + expires_at: now + WEB_AUTH_STATE_TTL, + status: "pending", + }; + await putWebAuthState(env.OAUTH_KV, record); + + // Always redirect to our own /oauth/callback so the approval step returns to + // the Worker rather than the client's localhost. This is the fix for RC2 + // (#195): no ephemeral port to bind or re-bind. + const redirectUri = `${url.origin}/oauth/callback`; + const params = new URLSearchParams({ + client_id: env.GITHUB_CLIENT_ID, + redirect_uri: redirectUri, + state, + }); + if (scope) params.set("scope", scope); - // Request a device code from GitHub first so that our device_code and the - // upstream device_code line up. The user types the GitHub user_code into - // https://github.com/login/device directly; our own user_code is informational. - const githubDeviceRes = await fetch(GITHUB_DEVICE_CODE_URL, { + return Response.redirect(`${GITHUB_AUTHORIZE_URL}?${params.toString()}`, 302); +} + +// ── /oauth/callback ─────────────────────────────────────────────── + +/** + * Complete the GitHub exchange after the user approves. Flip the web_auth_state + * record to `approved` and attach the Worker-issued bearer pair so the next + * client poll can consume it. The browser tab shows a "close this window" + * confirmation — no information that needs to be copied back to the client. + */ +async function handleCallback(request: Request, env: OAuthEnv): Promise { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const ghError = url.searchParams.get("error"); + + if (!state) { + return htmlResponse( + "Authorization failed", + "The callback is missing the state parameter. Return to your MCP client and try again.", + 400, + ); + } + + const record = await getWebAuthState(env.OAUTH_KV, state); + if (!record) { + return htmlResponse( + "Authorization expired", + "This authorization request has expired or was never initiated. Return to your MCP client and start a new login.", + 400, + ); + } + + // User denied authorization on the GitHub consent screen. + if (ghError === "access_denied" || !code) { + record.status = "denied"; + await putWebAuthState(env.OAUTH_KV, record); + return htmlResponse( + "Authorization denied", + "You declined to authorize the app. You can close this window.", + 200, + ); + } + + // Exchange the GitHub authorization code for upstream tokens as a + // confidential client (requires GITHUB_CLIENT_SECRET, which lives in the + // Worker environment and is never exposed to the MCP bridge). + const redirectUri = `${url.origin}/oauth/callback`; + const ghRes = await fetch(GITHUB_TOKEN_URL, { method: "POST", headers: { "Accept": "application/json", @@ -236,89 +304,145 @@ async function handleDeviceAuthorization(request: Request, env: OAuthEnv): Promi }, body: new URLSearchParams({ client_id: env.GITHUB_CLIENT_ID, + client_secret: env.GITHUB_CLIENT_SECRET, + code, + redirect_uri: redirectUri, + state, }).toString(), }); - if (!githubDeviceRes.ok) { - const text = await githubDeviceRes.text(); - console.log(`[oauth] github device_code failed status=${githubDeviceRes.status} body=${text.slice(0, 200)}`); - return oauthError("server_error", "Upstream device authorization failed", 502); - } - - type GitHubDeviceResponse = { - device_code?: string; - user_code?: string; - verification_uri?: string; + type GitHubTokenResponse = { + access_token?: string; + refresh_token?: string; + token_type?: string; expires_in?: number; - interval?: number; + refresh_token_expires_in?: number; error?: string; error_description?: string; }; - const gh = await githubDeviceRes.json() as GitHubDeviceResponse; - - if (gh.error || !gh.device_code || !gh.user_code) { - console.log(`[oauth] github device_code error=${gh.error} desc=${gh.error_description}`); - return oauthError( - gh.error === "device_flow_disabled" ? "server_error" : (gh.error ?? "server_error"), - gh.error_description ?? "Upstream device authorization failed", - gh.error === "device_flow_disabled" ? 503 : 400, + + if (!ghRes.ok) { + console.log(`[oauth] github code exchange non-2xx status=${ghRes.status}`); + return htmlResponse( + "Authorization failed", + "GitHub rejected the authorization code. Return to your MCP client and try again.", + 502, ); } - // Use the GitHub device_code as our own device_code. The record on our side - // carries polling state and, once approved, the issued grant props. - const expiresIn = gh.expires_in ?? DEVICE_CODE_TTL; - const interval = gh.interval ?? DEVICE_POLL_INTERVAL; - const now = nowSec(); + const gh = await ghRes.json() as GitHubTokenResponse; + if (gh.error || !gh.access_token) { + console.log(`[oauth] github code exchange error=${gh.error} desc=${gh.error_description}`); + return htmlResponse( + "Authorization failed", + gh.error_description ?? "GitHub did not return an access token. Return to your MCP client and try again.", + 502, + ); + } - const record: DeviceRecord = { - device_code: gh.device_code, - user_code: randomUserCode(), - client_id: clientId, - scope, - expires_at: now + expiresIn, - interval, - next_poll_at: now + interval, - status: "pending", - }; + const props = await fetchGitHubProps(gh.access_token, gh.refresh_token ?? null); + if (!props) { + return htmlResponse( + "Authorization failed", + "Could not fetch the GitHub user profile. Return to your MCP client and try again.", + 502, + ); + } - await putDevice(env.OAUTH_KV, record); + const issued = await issueTokensForNewGrant(env, record.client_id, record.scope, props); - // verification_uri_complete uses GitHub's user_code so the user lands on a - // page with the code already filled in. The local user_code we generated is - // not used by GitHub but kept for log correlation. - const verificationUriComplete = `${GITHUB_VERIFICATION_URI}?user_code=${encodeURIComponent(gh.user_code)}`; + record.status = "approved"; + record.access_token = issued.access_token; + record.refresh_token = issued.refresh_token; + await putWebAuthState(env.OAUTH_KV, record); - return jsonResponse({ - device_code: record.device_code, - user_code: gh.user_code, - verification_uri: gh.verification_uri ?? GITHUB_VERIFICATION_URI, - verification_uri_complete: verificationUriComplete, - expires_in: expiresIn, - interval, + console.log(`[oauth] web grant approved user=${props.githubLogin} (${props.githubUserId})`); + + return htmlResponse( + "Authorization complete", + "You can close this window and return to your MCP client. The next tool call will succeed automatically.", + 200, + ); +} + +function htmlResponse(title: string, body: string, status: number): Response { + const html = ` + + + + + ${escapeHtml(title)} + + + +
+

${escapeHtml(title)}

+

${escapeHtml(body)}

+
+ + +`; + return new Response(html, { + status, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }, }); } +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + // ── /oauth/token ────────────────────────────────────────────────── async function handleToken(request: Request, env: OAuthEnv): Promise { const form = await readForm(request); const grantType = form.get("grant_type"); - if (grantType === "urn:ietf:params:oauth:grant-type:device_code") { - return handleTokenDeviceCode(form, env); + if (grantType === WEB_AUTH_POLL_GRANT) { + return handleTokenWebAuthPoll(form, env); } if (grantType === "refresh_token") { return handleTokenRefresh(form, env); } - return oauthError("unsupported_grant_type", `grant_type=${grantType ?? "(missing)"} is not supported`, 400); + return oauthError( + "unsupported_grant_type", + `grant_type=${grantType ?? "(missing)"} is not supported`, + 400, + ); } -async function handleTokenDeviceCode(form: URLSearchParams, env: OAuthEnv): Promise { - const deviceCode = form.get("device_code"); +/** + * Client-side poll for the web OAuth flow. Mirrors RFC 8628's polling error + * shape so the bridge error-handling code stays straightforward: + * pending → 400 authorization_pending + * approved → 200 token pair (record is deleted) + * expired → 400 expired_token + * denied → 400 access_denied + */ +async function handleTokenWebAuthPoll( + form: URLSearchParams, + env: OAuthEnv, +): Promise { + const state = form.get("state"); const clientId = form.get("client_id"); - if (!deviceCode || !clientId) { - return oauthError("invalid_request", "device_code and client_id are required", 400); + if (!state || !clientId) { + return oauthError("invalid_request", "state and client_id are required", 400); } const client = await getClient(env.OAUTH_KV, clientId); @@ -326,114 +450,56 @@ async function handleTokenDeviceCode(form: URLSearchParams, env: OAuthEnv): Prom return oauthError("invalid_client", "Unknown client_id", 401); } - const record = await getDevice(env.OAUTH_KV, deviceCode); + const record = await getWebAuthState(env.OAUTH_KV, state); if (!record || record.client_id !== clientId) { - return oauthError("expired_token", "Device code is invalid or expired", 400); + return oauthError("expired_token", "state is invalid or expired", 400); } const now = nowSec(); if (now >= record.expires_at) { - await deleteDevice(env.OAUTH_KV, record); - return oauthError("expired_token", "Device code has expired", 400); + await deleteWebAuthState(env.OAUTH_KV, state); + return oauthError("expired_token", "state has expired", 400); } if (record.status === "denied") { - await deleteDevice(env.OAUTH_KV, record); + await deleteWebAuthState(env.OAUTH_KV, state); return oauthError("access_denied", "User denied authorization", 400); } - // Poll GitHub for the user's approval. GitHub enforces its own interval; - // we also enforce our own to protect against clients ignoring `interval`. if (record.status === "pending") { - if (now < record.next_poll_at) { - // Client polled faster than allowed — per RFC 8628 §3.5, bump interval - // by 5s and return slow_down. - record.interval += 5; - record.next_poll_at = now + record.interval; - await putDevice(env.OAUTH_KV, record); - return oauthError("slow_down", "Polling too fast; increase interval", 400); - } - - const ghRes = await fetch(GITHUB_TOKEN_URL, { - method: "POST", - headers: { - "Accept": "application/json", - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "github-webhook-mcp", - }, - body: new URLSearchParams({ - client_id: env.GITHUB_CLIENT_ID, - device_code: deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }).toString(), - }); - - type GitHubTokenResponse = { - access_token?: string; - refresh_token?: string; - token_type?: string; - expires_in?: number; - refresh_token_expires_in?: number; - error?: string; - error_description?: string; - interval?: number; - }; - const gh = await ghRes.json() as GitHubTokenResponse; - - if (gh.error === "authorization_pending") { - record.next_poll_at = now + record.interval; - await putDevice(env.OAUTH_KV, record); - return oauthError("authorization_pending", "User has not yet approved", 400); - } - if (gh.error === "slow_down") { - record.interval += 5; - record.next_poll_at = now + record.interval; - await putDevice(env.OAUTH_KV, record); - return oauthError("slow_down", "GitHub requested slower polling", 400); - } - if (gh.error === "expired_token" || gh.error === "device_flow_disabled") { - await deleteDevice(env.OAUTH_KV, record); - return oauthError("expired_token", gh.error_description ?? "Device code expired", 400); - } - if (gh.error === "access_denied") { - await deleteDevice(env.OAUTH_KV, record); - return oauthError("access_denied", "User denied authorization", 400); - } - if (gh.error || !gh.access_token) { - console.log(`[oauth] github token error=${gh.error} desc=${gh.error_description}`); - return oauthError("server_error", gh.error_description ?? "Upstream token exchange failed", 502); - } + return oauthError("authorization_pending", "User has not yet approved", 400); + } - // Approved — fetch GitHub user profile and installations. - const props = await fetchGitHubProps(gh.access_token, gh.refresh_token ?? null); - if (!props) { - return oauthError("server_error", "Failed to fetch GitHub user profile", 502); + if (record.status === "approved" && record.access_token && record.refresh_token) { + const accessRecord = await getAccessToken(env.OAUTH_KV, record.access_token); + const refreshRecord = await getRefreshToken(env.OAUTH_KV, record.refresh_token); + if (!accessRecord || !refreshRecord) { + await deleteWebAuthState(env.OAUTH_KV, state); + return oauthError("expired_token", "issued tokens are no longer available", 400); } - record.status = "approved"; - record.props = props; - // Keep the device record around briefly so the client's next poll - // (which this response already satisfies) doesn't race with deletion. - await putDevice(env.OAUTH_KV, record); - - return await issueTokensForNewGrant(env, record, props); - } + const body = jsonResponse({ + access_token: record.access_token, + token_type: "Bearer", + expires_in: Math.max(0, accessRecord.expires_at - now), + refresh_token: record.refresh_token, + scope: record.scope, + }); - // status === "approved" — this shouldn't normally happen because we delete - // the record on first successful exchange. Defensive: re-issue only if the - // stored props still exist. - if (record.status === "approved" && record.props) { - return await issueTokensForNewGrant(env, record, record.props); + // Consume the state record — subsequent polls will return expired_token. + await deleteWebAuthState(env.OAUTH_KV, state); + return body; } - return oauthError("expired_token", "Device code is in an unexpected state", 400); + return oauthError("expired_token", "state is in an unexpected state", 400); } async function issueTokensForNewGrant( env: OAuthEnv, - device: DeviceRecord, + clientId: string, + scope: string, props: GitHubUserProps, -): Promise { +): Promise<{ access_token: string; refresh_token: string }> { const now = nowSec(); const grantId = grantIdFor(String(props.githubUserId)); const accessToken = randomToken(32); @@ -441,9 +507,9 @@ async function issueTokensForNewGrant( const grant: GrantRecord = { grant_id: grantId, - client_id: device.client_id, + client_id: clientId, user_id: String(props.githubUserId), - scope: device.scope, + scope, props, access_token: accessToken, refresh_token: refreshToken, @@ -463,18 +529,9 @@ async function issueTokensForNewGrant( expires_at: now + REFRESH_TOKEN_TTL, }); - // Consume the device record so subsequent polls return expired_token. - await deleteDevice(env.OAUTH_KV, device); - console.log(`[oauth] grant issued user=${props.githubLogin} (${props.githubUserId}) grant=${grantId}`); - return jsonResponse({ - access_token: accessToken, - token_type: "Bearer", - expires_in: ACCESS_TOKEN_TTL, - refresh_token: refreshToken, - scope: device.scope, - }); + return { access_token: accessToken, refresh_token: refreshToken }; } async function handleTokenRefresh(form: URLSearchParams, env: OAuthEnv): Promise { @@ -536,17 +593,6 @@ async function handleTokenRefresh(form: URLSearchParams, env: OAuthEnv): Promise }); } -// ── /oauth/device landing page (informational) ──────────────────── - -function handleDeviceLanding(request: Request): Response { - const url = new URL(request.url); - const userCode = url.searchParams.get("user_code") ?? ""; - const target = userCode - ? `${GITHUB_VERIFICATION_URI}?user_code=${encodeURIComponent(userCode)}` - : GITHUB_VERIFICATION_URI; - return Response.redirect(target, 302); -} - // ── GitHub profile + installations fetch ────────────────────────── async function fetchGitHubProps( @@ -671,6 +717,11 @@ async function readForm(request: Request): Promise { return new URLSearchParams(text); } +/** Default polling interval for the web OAuth flow (seconds). */ +export { WEB_AUTH_POLL_INTERVAL }; +/** Custom grant type used by the bridge-polling half of the web OAuth flow. */ +export { WEB_AUTH_POLL_GRANT }; + /** * Revoke a grant (and all its tokens). Exposed for future /oauth/revoke or * admin operations; currently unused from the HTTP surface. diff --git a/worker/test/oauth.test.ts b/worker/test/oauth.test.ts index 5bfc52b..8985a4a 100644 --- a/worker/test/oauth.test.ts +++ b/worker/test/oauth.test.ts @@ -1,25 +1,34 @@ /** - * Integration tests for the Worker's bespoke OAuth device-flow implementation. + * Integration tests for the Worker-hosted web OAuth implementation (v0.11.1). * - * 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) + * v0.11.1 replaces the device-authorization-grant iteration with a Worker- + * hosted web OAuth flow: the bridge opens `/oauth/authorize?state=` in + * a browser, the Worker is the redirect_uri target (`/oauth/callback`), and + * the bridge polls `/oauth/token` with `grant_type=…web_authorization_poll` + * against the same state. * - * 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. + * Scenarios covered deterministically in CI: + * - metadata advertises authorization_code + refresh_token + * - dynamic client registration issues a public client + * - authorize redirects to GitHub with redirect_uri pinned to the Worker + * - polling returns authorization_pending while state is pending + * - callback → pending poll turns into an approved token pair on next poll + * - refresh_token rotation invalidates the previous token pair + * - access_token validates via the middleware across simulated restarts + * - user-denied callback surfaces as access_denied on the next poll * - * 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. + * GitHub upstream is stubbed by swapping globalThis.fetch. The KV namespace + * is a Map-backed mock that matches the subset of the KVNamespace API that + * oauth-store.ts uses. */ import { test, before, after, beforeEach } from "node:test"; import assert from "node:assert/strict"; -import { handleOAuthRequest, authenticateApiRequest } from "../src/oauth.js"; +import { + authenticateApiRequest, + handleOAuthRequest, + WEB_AUTH_POLL_GRANT, +} from "../src/oauth.js"; import type { OAuthEnv } from "../src/oauth.js"; // ── In-memory KV mock ──────────────────────────────────────────────── @@ -98,7 +107,7 @@ async function registerClient(env: OAuthEnv): Promise { 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"], + grant_types: [WEB_AUTH_POLL_GRANT, "refresh_token"], token_endpoint_auth_method: "none", }), env, @@ -109,31 +118,95 @@ async function registerClient(env: OAuthEnv): Promise { return body.client_id; } -// ── Legacy endpoints (existing-user migration) ─────────────────────── +/** + * Drive an authorize → callback → poll sequence end-to-end, stubbing GitHub's + * web OAuth endpoints. Returns the issued access / refresh token pair. + */ +async function onboardUser( + env: OAuthEnv, + clientId: string, + state: string, + user: { id: number; login: string }, + installations: number[], +): Promise<{ access_token: string; refresh_token: string }> { + // Stub GitHub's token exchange + user profile fetch. + fetchHandler = async (req) => { + if (req.url === "https://github.com/login/oauth/access_token") { + return new Response( + JSON.stringify({ + access_token: `ghu_${user.login}`, + refresh_token: `ghr_${user.login}`, + 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: user.id, login: user.login }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (req.url.startsWith("https://api.github.com/user/installations")) { + return new Response( + JSON.stringify({ + installations: installations.map((id) => ({ account: { id } })), + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + throw new Error(`Unexpected upstream fetch: ${req.url}`); + }; -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"), + // /oauth/authorize issues the pending state record and 302s to GitHub. + const authRes = await handleOAuthRequest( + new Request( + `https://worker.example.com/oauth/authorize?client_id=${encodeURIComponent(clientId)}&state=${encodeURIComponent(state)}`, + ), env, ); - assert.ok(res); - assert.equal(res.status, 410); -}); + assert.ok(authRes, "authorize must return a response"); + assert.equal(authRes.status, 302); + + // Simulate GitHub redirecting back to the Worker's callback with a code. + const cbRes = await handleOAuthRequest( + new Request( + `https://worker.example.com/oauth/callback?code=dummy-code&state=${encodeURIComponent(state)}`, + ), + env, + ); + assert.ok(cbRes); + assert.equal(cbRes.status, 200); + assert.match(cbRes.headers.get("Content-Type") ?? "", /text\/html/); -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"), + // Poll consumes the approved state and returns the token pair. + const pollRes = await handleOAuthRequest( + formRequest("https://worker.example.com/oauth/token", { + grant_type: WEB_AUTH_POLL_GRANT, + state, + client_id: clientId, + }), env, ); - assert.ok(res); - assert.equal(res.status, 410); -}); + assert.ok(pollRes); + assert.equal(pollRes.status, 200); + const tokens = await pollRes.json() as { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; + }; + assert.equal(tokens.token_type, "Bearer"); + assert.ok(tokens.access_token); + assert.ok(tokens.refresh_token); + return { access_token: tokens.access_token, refresh_token: tokens.refresh_token }; +} // ── Metadata (RFC 8414) ────────────────────────────────────────────── -test("metadata advertises device_code + refresh_token, no authorization_code", async () => { +test("metadata advertises authorization_endpoint + web-auth poll + refresh_token", async () => { const env = makeEnv(); const res = await handleOAuthRequest( new Request("https://worker.example.com/.well-known/oauth-authorization-server"), @@ -141,16 +214,16 @@ test("metadata advertises device_code + refresh_token, no authorization_code", a ); assert.ok(res); const meta = await res.json() as { - device_authorization_endpoint: string; + authorization_endpoint: string; token_endpoint: string; grant_types_supported: string[]; }; - assert.ok(meta.device_authorization_endpoint.endsWith("/oauth/device_authorization")); + assert.ok(meta.authorization_endpoint.endsWith("/oauth/authorize")); 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", - ]); + WEB_AUTH_POLL_GRANT, + ].sort()); }); // ── Dynamic client registration (RFC 7591) ─────────────────────────── @@ -161,135 +234,166 @@ test("POST /oauth/register issues a public client (no secret)", async () => { assert.match(clientId, /^[A-Za-z0-9_-]+$/); }); -// ── New-user onboarding: device flow happy path ────────────────────── +// ── Authorize redirect pins redirect_uri to the Worker (RC2 fix) ───── -test("new-user onboarding: device_authorization + token(device_code) issues tokens", async () => { +test("GET /oauth/authorize redirects to GitHub with Worker-pinned redirect_uri", 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}`); - }; + const state = "state-worker-redirect-check"; + const res = await handleOAuthRequest( + new Request( + `https://worker.example.com/oauth/authorize?client_id=${encodeURIComponent(clientId)}&state=${encodeURIComponent(state)}&scope=read:user`, + ), + env, + ); + assert.ok(res); + assert.equal(res.status, 302); + + const location = res.headers.get("Location") ?? ""; + assert.ok( + location.startsWith("https://github.com/login/oauth/authorize"), + "authorize must redirect to GitHub's web OAuth", + ); + const target = new URL(location); + // RC2 fix: redirect_uri is pinned to the Worker, never to the client's host. + assert.equal( + target.searchParams.get("redirect_uri"), + "https://worker.example.com/oauth/callback", + ); + assert.equal(target.searchParams.get("state"), state); + assert.equal(target.searchParams.get("client_id"), "test-github-client-id"); +}); - // Device authorization - const daRes = await handleOAuthRequest( - formRequest("https://worker.example.com/oauth/device_authorization", { client_id: clientId }), +test("GET /oauth/authorize rejects missing state", async () => { + const env = makeEnv(); + const clientId = await registerClient(env); + const res = await handleOAuthRequest( + new Request(`https://worker.example.com/oauth/authorize?client_id=${encodeURIComponent(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")); + assert.ok(res); + assert.equal(res.status, 400); +}); - // 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, - }), +// ── Polling: pending state ─────────────────────────────────────────── + +test("POST /oauth/token(web_authorization_poll) returns authorization_pending while state is pending", async () => { + const env = makeEnv(); + const clientId = await registerClient(env); + const state = "state-pending-1234"; + + const authRes = await handleOAuthRequest( + new Request( + `https://worker.example.com/oauth/authorize?client_id=${encodeURIComponent(clientId)}&state=${encodeURIComponent(state)}`, + ), env, ); - assert.ok(pendingRes); - assert.equal(pendingRes.status, 400); - const pendingBody = await pendingRes.json() as { error: string }; - assert.equal(pendingBody.error, "authorization_pending"); + assert.equal(authRes!.status, 302); - // Second poll: approval → token pair issued - const okRes = await handleOAuthRequest( + const pollRes = await handleOAuthRequest( formRequest("https://worker.example.com/oauth/token", { - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - device_code: daBody.device_code, + grant_type: WEB_AUTH_POLL_GRANT, + state, 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); + assert.ok(pollRes); + assert.equal(pollRes.status, 400); + const body = await pollRes.json() as { error: string }; + assert.equal(body.error, "authorization_pending"); +}); + +// ── Happy path: authorize → callback → poll → token pair ───────────── + +test("new-user onboarding: authorize + callback + poll issues tokens", async () => { + const env = makeEnv(); + const clientId = await registerClient(env); + + const { access_token, refresh_token } = await onboardUser( + env, + clientId, + "state-new-user-abcdef", + { id: 42, login: "octocat" }, + [42, 4242], + ); // 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}` }, + headers: { Authorization: `Bearer ${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], ); + + // Refresh token is also persisted and usable. + assert.ok(refresh_token); +}); + +test("polling the same state twice returns expired_token after the first consume", async () => { + const env = makeEnv(); + const clientId = await registerClient(env); + const state = "state-consumed-once-xyz"; + await onboardUser(env, clientId, state, { id: 7, login: "consumer" }, []); + + // Second poll against the same state must fail — the record was consumed. + const secondPoll = await handleOAuthRequest( + formRequest("https://worker.example.com/oauth/token", { + grant_type: WEB_AUTH_POLL_GRANT, + state, + client_id: clientId, + }), + env, + ); + assert.equal(secondPoll!.status, 400); + const body = await secondPoll!.json() as { error: string }; + assert.equal(body.error, "expired_token"); +}); + +// ── User-denied callback ───────────────────────────────────────────── + +test("GitHub access_denied on /oauth/callback surfaces as access_denied on next poll", async () => { + const env = makeEnv(); + const clientId = await registerClient(env); + const state = "state-denied-qrst"; + + // Start the authorize state. + await handleOAuthRequest( + new Request( + `https://worker.example.com/oauth/authorize?client_id=${encodeURIComponent(clientId)}&state=${encodeURIComponent(state)}`, + ), + env, + ); + + // User declines on the GitHub consent screen. No upstream fetch is needed + // because the callback short-circuits on the error parameter. + const cbRes = await handleOAuthRequest( + new Request( + `https://worker.example.com/oauth/callback?error=access_denied&state=${encodeURIComponent(state)}`, + ), + env, + ); + assert.ok(cbRes); + assert.equal(cbRes.status, 200); + + // Next poll returns access_denied so the bridge can stop and surface a clear error. + const pollRes = await handleOAuthRequest( + formRequest("https://worker.example.com/oauth/token", { + grant_type: WEB_AUTH_POLL_GRANT, + state, + client_id: clientId, + }), + env, + ); + assert.equal(pollRes!.status, 400); + const body = await pollRes!.json() as { error: string }; + assert.equal(body.error, "access_denied"); }); // ── Unknown / missing Bearer ───────────────────────────────────────── @@ -316,50 +420,12 @@ test("authenticateApiRequest rejects unknown token", async () => { assert.equal(res.response.status, 401); }); -// ── Refresh rotation (concurrent-instance scenario) ────────────────── +// ── Refresh rotation ───────────────────────────────────────────────── 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 }; + const first = await onboardUser(env, clientId, "state-rotation-aaaa", { id: 7, login: "rotuser" }, []); // Rotate via refresh_token. const rotRes = await handleOAuthRequest( @@ -413,44 +479,7 @@ test("refresh_token rotation invalidates the previous access and refresh tokens" 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 }; + const issued = await onboardUser(env, clientId, "state-persist-uvwx", { id: 99, login: "persist" }, []); // Simulate a process restart: drop the fetch stub (nothing should be called), // then validate the same access_token again via a brand-new Request. @@ -465,26 +494,19 @@ test("process-restart: tokens stored in KV remain valid across fresh authenticat assert.equal(reopened.auth.props.githubLogin, "persist"); }); -// ── GitHub 'device_flow_disabled' returns 503 so clients can surface a clear message +// ── Unsupported grant_type guard ───────────────────────────────────── -test("GitHub device_flow_disabled surfaces as 503 (not a silent auth loop)", async () => { +test("unsupported grant_type returns 400 unsupported_grant_type", 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 }), + formRequest("https://worker.example.com/oauth/token", { + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: clientId, + }), env, ); - assert.ok(res); - assert.equal(res.status, 503); + assert.equal(res!.status, 400); + const body = await res!.json() as { error: string }; + assert.equal(body.error, "unsupported_grant_type"); });