diff --git a/local-mcp/package-lock.json b/local-mcp/package-lock.json index e45c33f..9d141dc 100644 --- a/local-mcp/package-lock.json +++ b/local-mcp/package-lock.json @@ -15,6 +15,8 @@ "github-webhook-bridge": "src/index.ts" }, "devDependencies": { + "@types/node": "^22.19.17", + "@types/ws": "^8.18.1", "tsx": "^4.0.0", "typescript": "^5.5.0" } @@ -525,6 +527,26 @@ "node": ">=18.0.0" } }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1641,6 +1663,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/local-mcp/package.json b/local-mcp/package.json index f12c85c..70f4892 100644 --- a/local-mcp/package.json +++ b/local-mcp/package.json @@ -15,7 +15,9 @@ "ws": "^8.18.0" }, "devDependencies": { - "typescript": "^5.5.0", - "tsx": "^4.0.0" + "@types/node": "^22.19.17", + "@types/ws": "^8.18.1", + "tsx": "^4.0.0", + "typescript": "^5.5.0" } } diff --git a/local-mcp/src/index.ts b/local-mcp/src/index.ts index 87ee08a..5a6f457 100644 --- a/local-mcp/src/index.ts +++ b/local-mcp/src/index.ts @@ -5,7 +5,9 @@ * - 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 with PKCE (localhost callback) + * - 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. * * Discord MCP pattern: data lives in the cloud, local MCP is a thin bridge. */ @@ -16,9 +18,7 @@ import { CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import WebSocket from "ws"; -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { randomBytes, createHash } from "node:crypto"; -import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { readFile, writeFile, mkdir, unlink } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -28,6 +28,8 @@ 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?: string; access_token: string; refresh_token?: string; expires_at?: number; // Unix timestamp in ms @@ -35,11 +37,27 @@ interface TokenData { const TOKEN_DIR = join(homedir(), ".github-webhook-mcp"); 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. + */ +const TOKENS_FLOW_MARKER = "device"; async function loadTokens(): Promise { try { const data = await readFile(TOKEN_FILE, "utf-8"); - return JSON.parse(data) as TokenData; + 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; + } + return parsed; } catch { return null; } @@ -51,23 +69,17 @@ async function saveTokens(tokens: TokenData): Promise { } let _cachedTokens: TokenData | null = null; - -// ── PKCE Utilities ─────────────────────────────────────────────────────────── - -function generateCodeVerifier(): string { - return randomBytes(32).toString("base64url"); -} - -function generateCodeChallenge(verifier: string): string { - return createHash("sha256").update(verifier).digest("base64url"); -} +let _refreshLock: Promise | null = null; +let _deviceFlowLock: Promise | null = null; +let _legacyMigrationNotified = false; // ── OAuth Discovery & Registration ─────────────────────────────────────────── interface OAuthMetadata { - authorization_endpoint: string; + authorization_endpoint?: string; token_endpoint: string; registration_endpoint?: string; + device_authorization_endpoint?: string; } async function discoverOAuthMetadata(): Promise { @@ -81,11 +93,10 @@ async function discoverOAuthMetadata(): Promise { interface ClientRegistration { client_id: string; client_secret?: string; - redirect_uris: string[]; + redirect_uris?: string[]; + grant_types?: string[]; } -const CLIENT_REG_FILE = join(TOKEN_DIR, "oauth-client.json"); - async function loadClientRegistration(): Promise { try { const data = await readFile(CLIENT_REG_FILE, "utf-8"); @@ -100,12 +111,19 @@ 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, - redirectUris: string[], ): Promise { const existing = await loadClientRegistration(); - if (existing) return existing; + // 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. + if (existing && Array.isArray(existing.grant_types) && + existing.grant_types.includes(DEVICE_CODE_GRANT)) { + return existing; + } if (!metadata.registration_endpoint) { throw new Error("OAuth server does not support dynamic client registration"); @@ -116,10 +134,10 @@ async function ensureClientRegistration( headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_name: "github-webhook-mcp-cli", - redirect_uris: redirectUris, - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - token_endpoint_auth_method: "none", // public client + // Device flow does not use redirect_uris; leave empty for RFC 8628. + redirect_uris: [], + grant_types: [DEVICE_CODE_GRANT, "refresh_token"], + token_endpoint_auth_method: "none", }), }); @@ -132,130 +150,236 @@ async function ensureClientRegistration( return reg; } -// ── OAuth Localhost Callback Flow ──────────────────────────────────────────── +// ── 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; +} /** - * Start a temporary localhost HTTP server to receive the OAuth callback. - * Opens the browser for user authorization, waits for the callback, - * exchanges the auth code for tokens, and returns them. + * 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 performOAuthFlow(): Promise { - const metadata = await discoverOAuthMetadata(); +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. + } - // Find a free port for the callback server - const callbackServer = createServer(); - await new Promise((resolve) => { - callbackServer.listen(0, "127.0.0.1", () => resolve()); - }); - const port = (callbackServer.address() as { port: number }).port; - const redirectUri = `http://127.0.0.1:${port}/callback`; - - // Register client with both localhost variants - const client = await ensureClientRegistration(metadata, [ - redirectUri, - `http://localhost:${port}/callback`, - ]); - - // Generate PKCE pair - const codeVerifier = generateCodeVerifier(); - const codeChallenge = generateCodeChallenge(codeVerifier); - const state = randomBytes(16).toString("hex"); - - // Build authorization URL - const authUrl = new URL(metadata.authorization_endpoint); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("client_id", client.client_id); - authUrl.searchParams.set("redirect_uri", redirectUri); - authUrl.searchParams.set("state", state); - authUrl.searchParams.set("code_challenge", codeChallenge); - authUrl.searchParams.set("code_challenge_method", "S256"); - - // Wait for the callback with the authorization code - const authCode = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - callbackServer.close(); - reject(new Error("OAuth callback timed out after 5 minutes")); - }, 5 * 60 * 1000); - - callbackServer.on("request", (req: IncomingMessage, res: ServerResponse) => { - const url = new URL(req.url || "/", `http://127.0.0.1:${port}`); - if (url.pathname !== "/callback") { - res.writeHead(404); - res.end("Not found"); - return; - } + let parsed: TokenData | null; + try { + parsed = JSON.parse(raw) as TokenData; + } catch { + // Corrupt file — treat as legacy/unusable and remove. + parsed = null; + } - const code = url.searchParams.get("code"); - const returnedState = url.searchParams.get("state"); - const error = url.searchParams.get("error"); - - if (error) { - res.writeHead(200, { "Content-Type": "text/html" }); - res.end("

Authorization failed

You can close this tab.

"); - clearTimeout(timeout); - callbackServer.close(); - reject(new Error(`OAuth authorization failed: ${error}`)); - return; - } + if (parsed && parsed.flow === TOKENS_FLOW_MARKER) { + return; // Already a device-flow tokens file — no migration needed. + } - if (!code || returnedState !== state) { - res.writeHead(400, { "Content-Type": "text/html" }); - res.end("

Invalid callback

"); - return; - } + if (_legacyMigrationNotified) return; + _legacyMigrationNotified = true; - res.writeHead(200, { "Content-Type": "text/html" }); - res.end("

Authorization successful

You can close this tab.

"); - clearTimeout(timeout); - callbackServer.close(); - resolve(code); - }); + 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", + ); - // Open the browser - const openCmd = process.platform === "win32" ? "start" : - process.platform === "darwin" ? "open" : "xdg-open"; - import("node:child_process").then(({ exec }) => { - exec(`${openCmd} "${authUrl.toString()}"`); - }); + try { + await unlink(TOKEN_FILE); + } catch { + // Non-fatal: saveTokens() will overwrite it on success anyway. + } +} - process.stderr.write( - `\n[github-webhook-mcp] Opening browser for authentication...\n`, - ); - }); +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} - // Exchange auth code for tokens - const tokenRes = await fetch(metadata.token_endpoint, { +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({ - grant_type: "authorization_code", - code: authCode, - redirect_uri: redirectUri, - client_id: client.client_id, - code_verifier: codeVerifier, - }), + body: new URLSearchParams({ client_id: client.client_id }), }); - if (!tokenRes.ok) { - throw new Error(`Token exchange failed: ${tokenRes.status} ${await tokenRes.text()}`); + 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 tokenData = await tokenRes.json() as { - access_token: string; - refresh_token?: string; - expires_in?: number; - }; + 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; +} - const tokens: TokenData = { - access_token: tokenData.access_token, - refresh_token: tokenData.refresh_token, - expires_at: tokenData.expires_in - ? Date.now() + tokenData.expires_in * 1000 - : undefined, - }; +/** + * 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. + */ +async function pollForDeviceToken( + metadata: OAuthMetadata, + client: ClientRegistration, + deviceAuth: DeviceAuthorizationResponse, +): 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; - await saveTokens(tokens); - return tokens; + while (Date.now() < deadline) { + await sleep(interval * 1000); + + let res: Response; + try { + res = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: DEVICE_CODE_GRANT, + device_code: deviceAuth.device_code, + client_id: client.client_id, + }), + }); + } catch (err) { + // Transient network error — keep polling. + console.error("[oauth] token poll network error:", (err as Error).message || err); + continue; + } + + if (res.ok) { + return await res.json() as TokenPollingResponse; + } + + let body: TokenPollingResponse | null = null; + try { + body = await res.json() as TokenPollingResponse; + } catch { + body = null; + } + const err = body && typeof body.error === "string" ? body.error : null; + + 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.", + ); + } + + // Unexpected error — surface and stop polling. + throw new Error( + `Token exchange failed: ${res.status} ${res.statusText}` + + (body ? ` — ${JSON.stringify(body).slice(0, 200)}` : ""), + ); + } + + throw new Error( + "OAuth device code expired before approval. Re-run the client to retry.", + ); +} + +async function performOAuthFlow(): Promise { + // Serialize concurrent device-flow starts (e.g. WebSocket boot racing the + // first tool call). Whichever caller wins runs the flow; others await. + if (_deviceFlowLock) { + return await _deviceFlowLock; + } + + _deviceFlowLock = (async (): Promise => { + await checkLegacyTokensMigration(); + + const metadata = await discoverOAuthMetadata(); + const client = await ensureClientRegistration(metadata); + + const deviceAuth = await requestDeviceAuthorization(metadata, client); + + // stdio MCP clients have no UI surface, so we publish the user_code and + // verification URI on stderr where Claude Code surfaces the log. + const complete = deviceAuth.verification_uri_complete; + 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] Waiting for approval (expires in ${deviceAuth.expires_in ?? "?"}s)...`, + "", + ); + process.stderr.write(lines.join("\n")); + + const tokenData = await pollForDeviceToken(metadata, client, deviceAuth); + + if (!tokenData.access_token) { + throw new Error( + `Token response missing access_token: ${JSON.stringify(tokenData).slice(0, 200)}`, + ); + } + + const tokens: TokenData = { + flow: TOKENS_FLOW_MARKER, + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token, + expires_at: tokenData.expires_in + ? Date.now() + tokenData.expires_in * 1000 + : undefined, + }; + + await saveTokens(tokens); + process.stderr.write("[github-webhook-mcp] OAuth device authorization complete.\n"); + return tokens; + })(); + + try { + return await _deviceFlowLock; + } finally { + _deviceFlowLock = null; + } } /** @@ -300,6 +424,7 @@ async function refreshAccessToken(refreshToken: string): Promise { } const tokens: TokenData = { + flow: TOKENS_FLOW_MARKER, access_token: data.access_token, refresh_token: data.refresh_token || refreshToken, expires_at: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined, @@ -325,13 +450,22 @@ async function getAccessToken(): Promise { return _cachedTokens.access_token; } - // Try refresh 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); + } try { - _cachedTokens = await refreshAccessToken(_cachedTokens.refresh_token); + _cachedTokens = await _refreshLock; return _cachedTokens.access_token; } catch (err) { console.error("[oauth] refresh failed, falling back to full OAuth flow:", (err as Error).message || err); + } finally { + _refreshLock = null; } } else { console.error("[oauth] no refresh_token available, requiring full OAuth flow");