From 96978ebc919e7f4a8c7879595c14b344864645e3 Mon Sep 17 00:00:00 2001 From: Codex_Lin_Lay Date: Wed, 30 Sep 2026 18:31:42 +0900 Subject: [PATCH] fix(oauth): recover saved credentials after delayed authorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 共有 token を毎回読み直し、401/refresh 失敗で拒否された値を再採用しない。 認可結果を保存後に成功表示し、遅延完了と失敗を後続の呼び出しへ伝える。 検証: bridge 33 件、Worker 382 件、型チェック、schema drift、dry-run build。 --- docs/0-requirements.ja.md | 15 ++ docs/0-requirements.md | 15 ++ mcp-server/README.md | 25 ++- mcp-server/package.json | 2 +- mcp-server/server/index.js | 317 +--------------------------------- mcp-server/server/oauth.js | 289 +++++++++++++++++++++++++++++++ mcp-server/test/oauth.test.js | 300 ++++++++++++++++++++++++++++++++ 7 files changed, 645 insertions(+), 318 deletions(-) create mode 100644 mcp-server/server/oauth.js create mode 100644 mcp-server/test/oauth.test.js diff --git a/docs/0-requirements.ja.md b/docs/0-requirements.ja.md index de05cce..71d20f8 100644 --- a/docs/0-requirements.ja.md +++ b/docs/0-requirements.ja.md @@ -594,6 +594,21 @@ Requirements: OAuth flow で要求する localhost callback URI を全て含む場合だけ再利用し、 含まない場合は authorization 前に client registration を置き換える +### Bridge OAuth recovery(issue #250) + +ローカル bridge は credential を取得するたびに共有 token ファイルを読み、別の +bridge process が保存した更新も取り込む。401 または refresh 失敗で無効と判明した +token を、その process がメモリや disk からそのまま再採用してはならない。 +有効な既存 credential は引き続き利用する。同時呼び出しは一つの authorization +attempt を共有し、pending の呼び出しは速やかに戻る。後続の呼び出しは完了・失敗・ +5 分の timeout を観測でき、未処理 Promise rejection を発生させない。 +callback page の成功表示は token 交換と保存の成功後に出す。error と log は +token・code・state・verifier・authorization URL を含めない。 +MCP protocol と検索の意味は変更しない。 +新しい token record は発行時の client ID を保持し、別 process が共有 client +registration を置き換えてもその ID で refresh する。旧 record は共有 registration +を fallback として使う。token と registration は完成したファイルを atomic に置換する。 + ## Storage Rules ### Canonical memory surfaces diff --git a/docs/0-requirements.md b/docs/0-requirements.md index 8ce3cd8..18150be 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -600,6 +600,21 @@ Requirements: URI set covers every localhost callback URI requested for the current OAuth flow; otherwise register a replacement client before authorization +### Bridge OAuth recovery (issue #250) + +The local bridge reads shared token storage on each credential request, including +updates made by another bridge process. A token rejected with 401 or a failed +refresh must not be reused unchanged from memory or disk in that process. Valid +existing credentials remain usable. Concurrent calls share one authorization +attempt; a pending call returns promptly and a later call observes completion, +failure, or the five-minute timeout without an unhandled promise rejection. +The callback page reports success only after token exchange and storage succeed. +Errors and logs must not expose tokens, codes, state, verifiers, or authorization +URLs. This changes neither MCP protocols nor search semantics. +New token records retain their issuing client ID for refresh even when another +process replaces shared client registration; legacy records use that registration +as a fallback. Token and registration writes replace complete files atomically. + ## Storage Rules ### Canonical memory surfaces diff --git a/mcp-server/README.md b/mcp-server/README.md index 65f03b2..f79a91b 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -120,21 +120,36 @@ The `type` filter accepts: `issue`, `pull_request`, `release`, `doc`, `wiki_doc` is cached for the current localhost callback URIs. 3. It starts a one-shot localhost HTTP listener on a random port and opens the browser to the Worker's authorization endpoint. 4. After you approve, the Worker redirects to `http://127.0.0.1:/callback` with an authorization code. -5. The proxy exchanges the code for tokens (PKCE S256) and saves them. -6. Subsequent calls reuse the access token and silently refresh when it nears expiry. On `401` from the Worker, the proxy invalidates its cached tokens and re-authenticates. +5. The proxy exchanges the code for tokens (PKCE S256) and saves them before the + browser shows success. Exchange or storage failures show an authorization failure. +6. Subsequent calls read shared token storage, including another process's updates, + and silently refresh when the token nears expiry. A `401` rejects the bearer + actually sent, including the transport's final retry. Rejected access tokens and + failed refresh credentials are excluded in that process; a fresh token from disk + can still be used. New records retain the issuing client ID for refresh; older + records use the shared client registration as a fallback. + +While authorization is pending, tool calls return an authentication requirement +after a short wait. Retry after completing the browser flow: the running proxy +uses the saved tokens. A failed flow or five-minute timeout is reported on a later +call; retry again to start a new flow. Concurrent calls share one browser attempt. The browser callback never leaves your machine; the authorization code is delivered directly to the local listener. ## Troubleshooting -- **Browser does not open.** The proxy logs the authorization URL to stderr; copy it into a browser manually. +- **Browser does not open.** Check that your system has a working default browser. + The proxy does not log authorization URLs or credentials. - **`redirect_uri is not associated with this application`.** Upgrade the proxy. Current versions replace cached client registrations whose redirect URI set does not cover the callback port selected for this authorization. -- **`OAuth callback timed out after 5 minutes`.** Re-invoke any tool to restart the flow. +- **`OAuth callback timed out after 5 minutes`.** Retry the tool to start a new flow. - **`Failed to reach worker`.** Check that `RAG_WORKER_URL` is correct and reachable from your machine. - **`Unsupported protocol version`, or a tool that answers with a protocol error instead of results.** The proxy predates the 2026-07-28 flip. Quit Claude Desktop fully and reopen so `npx` fetches the current version; if your config pins a version, move the pin forward first. -- **Stale credentials.** Remove `~/.github-rag-mcp/oauth-tokens.json` (and optionally `oauth-client.json`) and retry. +- **Authentication required after authorizing.** Retry the tool after the browser + confirms success. The running proxy reads the newly saved token; restarting or + deleting credentials is not required for this recovery. A browser failure page + means exchange or storage did not complete successfully. ## Links diff --git a/mcp-server/package.json b/mcp-server/package.json index cdce7bd..cf75d58 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -14,7 +14,7 @@ ], "scripts": { "start": "node server/index.js", - "test": "node --check server/index.js && node --check server/tools.js && node --test test/oauth-client-registration.test.js test/search-tool-schema.test.js", + "test": "node --check server/index.js && node --check server/oauth.js && node --check server/tools.js && node --test test/*.test.js", "pack:mcpb": "mcpb pack" }, "dependencies": { diff --git a/mcp-server/server/index.js b/mcp-server/server/index.js index 2e0e772..8e0f151 100644 --- a/mcp-server/server/index.js +++ b/mcp-server/server/index.js @@ -29,14 +29,8 @@ import { ListToolsRequestSchema, CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; -import { createServer } from "node:http"; -import { randomBytes, createHash } from "node:crypto"; -import { readFile, writeFile, mkdir } from "node:fs/promises"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { exec } from "node:child_process"; import { createRequire } from "node:module"; -import { supportsRedirectUris } from "./oauth-client-registration.js"; +import { createOAuthProvider, OAuthPendingError } from "./oauth.js"; import { createRemoteClient } from "./remote-client.js"; import { TOOLS } from "./tools.js"; @@ -47,300 +41,7 @@ const WORKER_URL = process.env.RAG_WORKER_URL || "https://github-rag-mcp.liplus.workers.dev"; -// ── OAuth Token Storage ────────────────────────────────────────────────────── - -const TOKEN_DIR = join(homedir(), ".github-rag-mcp"); -const TOKEN_FILE = join(TOKEN_DIR, "oauth-tokens.json"); -const CLIENT_REG_FILE = join(TOKEN_DIR, "oauth-client.json"); - -async function loadTokens() { - try { - const data = await readFile(TOKEN_FILE, "utf-8"); - return JSON.parse(data); - } catch { - return null; - } -} - -async function saveTokens(tokens) { - await mkdir(TOKEN_DIR, { recursive: true }); - await writeFile(TOKEN_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 }); -} - -let _cachedTokens = null; - -// ── PKCE Utilities ─────────────────────────────────────────────────────────── - -function generateCodeVerifier() { - return randomBytes(32).toString("base64url"); -} - -function generateCodeChallenge(verifier) { - return createHash("sha256").update(verifier).digest("base64url"); -} - -// ── OAuth Discovery & Registration ─────────────────────────────────────────── - -async function discoverOAuthMetadata() { - const res = await fetch(`${WORKER_URL}/.well-known/oauth-authorization-server`); - if (!res.ok) { - throw new Error(`OAuth discovery failed: ${res.status}`); - } - return await res.json(); -} - -async function loadClientRegistration() { - try { - const data = await readFile(CLIENT_REG_FILE, "utf-8"); - return JSON.parse(data); - } catch { - return null; - } -} - -async function saveClientRegistration(reg) { - await mkdir(TOKEN_DIR, { recursive: true }); - await writeFile(CLIENT_REG_FILE, JSON.stringify(reg, null, 2), { mode: 0o600 }); -} - -async function ensureClientRegistration(metadata, redirectUris) { - const existing = await loadClientRegistration(); - if (supportsRedirectUris(existing, redirectUris)) return existing; - - if (!metadata.registration_endpoint) { - throw new Error("OAuth server does not support dynamic client registration"); - } - - const res = await fetch(metadata.registration_endpoint, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - client_name: "github-rag-mcp-cli", - redirect_uris: redirectUris, - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - token_endpoint_auth_method: "none", - }), - }); - - if (!res.ok) { - throw new Error(`Client registration failed: ${res.status} ${await res.text()}`); - } - - const reg = await res.json(); - await saveClientRegistration(reg); - return reg; -} - -// ── OAuth Localhost Callback Flow ──────────────────────────────────────────── - -let _pendingOAuth = null; - -class OAuthPendingError extends Error { - constructor(authUrl) { - super("OAuth authentication required"); - this.authUrl = authUrl; - } -} - -function openBrowser(url) { - if (process.platform === "win32") { - exec(`start "" "${url}"`); - } else { - const openCmd = process.platform === "darwin" ? "open" : "xdg-open"; - exec(`${openCmd} "${url}"`); - } -} - -async function startOAuthFlow() { - const metadata = await discoverOAuthMetadata(); - - const callbackServer = createServer(); - await new Promise((resolve) => { - callbackServer.listen(0, "127.0.0.1", () => resolve()); - }); - const port = callbackServer.address().port; - const redirectUri = `http://127.0.0.1:${port}/callback`; - - const client = await ensureClientRegistration(metadata, [ - redirectUri, - `http://localhost:${port}/callback`, - ]); - - const codeVerifier = generateCodeVerifier(); - const codeChallenge = generateCodeChallenge(codeVerifier); - const state = randomBytes(16).toString("hex"); - - 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"); - - const tokenPromise = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - callbackServer.close(); - _pendingOAuth = null; - reject(new Error("OAuth callback timed out after 5 minutes")); - }, 5 * 60 * 1000); - - callbackServer.on("request", async (req, res) => { - const url = new URL(req.url || "/", `http://127.0.0.1:${port}`); - if (url.pathname !== "/callback") { - res.writeHead(404); - res.end("Not found"); - return; - } - - 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(); - _pendingOAuth = null; - reject(new Error(`OAuth authorization failed: ${error}`)); - return; - } - - if (!code || returnedState !== state) { - res.writeHead(400, { "Content-Type": "text/html" }); - res.end("

Invalid callback

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

Authorization successful

You can close this tab.

"); - clearTimeout(timeout); - callbackServer.close(); - - try { - const tokenRes = await fetch(metadata.token_endpoint, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: redirectUri, - client_id: client.client_id, - code_verifier: codeVerifier, - }), - }); - - if (!tokenRes.ok) { - _pendingOAuth = null; - reject(new Error(`Token exchange failed: ${tokenRes.status} ${await tokenRes.text()}`)); - return; - } - - const tokenData = await tokenRes.json(); - const tokens = { - 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); - _pendingOAuth = null; - resolve(tokens); - } catch (err) { - _pendingOAuth = null; - reject(err); - } - }); - }); - - openBrowser(authUrl.toString()); - process.stderr.write( - `\n[github-rag-mcp] Opening browser for authentication...\n`, - ); - - _pendingOAuth = { authUrl: authUrl.toString(), tokenPromise }; - return _pendingOAuth; -} - -async function performOAuthFlow() { - if (_pendingOAuth) { - const result = await Promise.race([ - _pendingOAuth.tokenPromise, - new Promise((resolve) => setTimeout(() => resolve(null), 2000)), - ]); - if (result && result.access_token) return result; - throw new OAuthPendingError(_pendingOAuth.authUrl); - } - - const pending = await startOAuthFlow(); - - const result = await Promise.race([ - pending.tokenPromise, - new Promise((resolve) => setTimeout(() => resolve(null), 3000)), - ]); - if (result && result.access_token) return result; - - throw new OAuthPendingError(pending.authUrl); -} - -async function refreshAccessToken(refreshToken) { - const metadata = await discoverOAuthMetadata(); - const client = await loadClientRegistration(); - if (!client) throw new Error("No client registration found"); - - const res = await fetch(metadata.token_endpoint, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: client.client_id, - }), - }); - - if (!res.ok) { - throw new Error(`Token refresh failed: ${res.status}`); - } - - const data = await res.json(); - - const tokens = { - access_token: data.access_token, - refresh_token: data.refresh_token || refreshToken, - expires_at: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined, - }; - - await saveTokens(tokens); - return tokens; -} - -async function getAccessToken() { - if (!_cachedTokens) { - _cachedTokens = await loadTokens(); - } - - if (_cachedTokens) { - if (!_cachedTokens.expires_at || _cachedTokens.expires_at > Date.now() + 60_000) { - return _cachedTokens.access_token; - } - - if (_cachedTokens.refresh_token) { - try { - _cachedTokens = await refreshAccessToken(_cachedTokens.refresh_token); - return _cachedTokens.access_token; - } catch { - // Refresh failed, fall through to full OAuth flow - } - } - } - - _cachedTokens = await performOAuthFlow(); - return _cachedTokens.access_token; -} +const oauth = createOAuthProvider({ workerUrl: WORKER_URL }); // ── Remote MCP Client (lazy, reused) ───────────────────────────────────────── // Construction and caching live in ./remote-client.js so they can be tested @@ -349,23 +50,15 @@ async function getAccessToken() { const remote = createRemoteClient({ workerUrl: WORKER_URL, clientVersion: PACKAGE_VERSION, - // The OAuth flow above stays the source of tokens; this only hands the - // current one over, and clears the cache when the Worker says it is stale so - // the next `token()` re-mints. - authProvider: { - token: () => getAccessToken(), - onUnauthorized: async () => { - _cachedTokens = null; - await getAccessToken(); - }, - }, + authProvider: oauth, + fetch: oauth.fetch, }); async function callRemoteTool(name, args) { // Resolve credentials first so an interactive-auth requirement surfaces as // OAuthPendingError from here, where the caller already handles it, rather // than from inside the transport wrapped as a network failure. - await getAccessToken(); + await oauth.token(); return await remote.callTool(name, args); } diff --git a/mcp-server/server/oauth.js b/mcp-server/server/oauth.js new file mode 100644 index 0000000..665eaa1 --- /dev/null +++ b/mcp-server/server/oauth.js @@ -0,0 +1,289 @@ +import { createServer } from "node:http"; +import { randomBytes, createHash } from "node:crypto"; +import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { exec } from "node:child_process"; +import { supportsRedirectUris } from "./oauth-client-registration.js"; + +export class OAuthPendingError extends Error { + constructor() { + super("OAuth authentication required"); + } +} + +function openBrowser(url) { + const command = process.platform === "win32" ? 'start ""' : + process.platform === "darwin" ? "open" : "xdg-open"; + exec(`${command} "${url}"`); + process.stderr.write("\n[github-rag-mcp] Opening browser for authentication...\n"); +} + +// Injected paths and browser/fetch functions keep tests away from real credentials. +export function createOAuthProvider({ + workerUrl, + tokenDir = join(homedir(), ".github-rag-mcp"), + fetch: request = globalThis.fetch, + openBrowser: launchBrowser = openBrowser, + createCallbackServer = createServer, + now = Date.now, + callbackTimeoutMs = 5 * 60 * 1000, + initialWaitMs = 3000, + pendingWaitMs = 2000, +}) { + const tokenFile = join(tokenDir, "oauth-tokens.json"); + const clientFile = join(tokenDir, "oauth-client.json"); + const rejectedAccess = new Set(); + const rejectedRefresh = new Set(); + let pending = null; + let starting = null; + let acquiring = null; + let lastIssued = null; + + async function readJson(path) { + try { return JSON.parse(await readFile(path, "utf8")); } + catch { return null; } + } + + async function saveJson(path, data) { + const temporary = `${path}.${randomBytes(12).toString("hex")}.tmp`; + try { + await mkdir(tokenDir, { recursive: true }); + await writeFile(temporary, JSON.stringify(data, null, 2), { mode: 0o600 }); + await rename(temporary, path); + } catch { + throw new Error("OAuth credential storage failed"); + } finally { + await unlink(temporary).catch(() => {}); + } + } + + async function discover() { + try { + const res = await request(`${workerUrl}/.well-known/oauth-authorization-server`); + if (!res.ok) throw new Error(); + return await res.json(); + } catch { throw new Error("OAuth discovery failed"); } + } + + async function register(metadata, redirectUris) { + const existing = await readJson(clientFile); + if (supportsRedirectUris(existing, redirectUris)) return existing; + if (!metadata.registration_endpoint) { + throw new Error("OAuth server does not support dynamic client registration"); + } + let registration; + try { + const res = await request(metadata.registration_endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "github-rag-mcp-cli", redirect_uris: redirectUris, + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], token_endpoint_auth_method: "none", + }), + }); + if (!res.ok) throw new Error(); + registration = await res.json(); + if (!registration.client_id) throw new Error(); + } catch { throw new Error("OAuth client registration failed"); } + await saveJson(clientFile, registration); + return registration; + } + + function tokensFrom(data, refreshToken, clientId) { + if (typeof data.access_token !== "string" || !data.access_token) { + throw new Error("OAuth token response has no access token"); + } + return { + access_token: data.access_token, + refresh_token: data.refresh_token || refreshToken, + client_id: clientId, + expires_at: data.expires_in ? now() + data.expires_in * 1000 : undefined, + }; + } + + async function startFlow() { + const metadata = await discover(); + const server = createCallbackServer(); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const port = server.address().port; + const redirectUri = `http://127.0.0.1:${port}/callback`; + const client = await register(metadata, [redirectUri, `http://localhost:${port}/callback`]); + const verifier = randomBytes(32).toString("base64url"); + const state = randomBytes(16).toString("hex"); + const authUrl = new URL(metadata.authorization_endpoint); + for (const [key, value] of Object.entries({ + response_type: "code", client_id: client.client_id, redirect_uri: redirectUri, + state, code_challenge: createHash("sha256").update(verifier).digest("base64url"), + code_challenge_method: "S256", + })) authUrl.searchParams.set(key, value); + + let settle; + const flow = { result: null, promise: new Promise(resolve => { settle = resolve; }) }; + pending = flow; + const controller = new AbortController(); + let processing = false; + let response = null; + function page(res, status, title) { + res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" }); + res.end(`

${title}

You can close this tab.

`); + } + function finish(result) { + if (flow.result) return; + flow.result = result; + clearTimeout(timeout); + controller.abort(); + server.close(); + settle(result); // Outcomes resolve: late failure cannot become an unhandled rejection. + } + const timeout = setTimeout(() => { + if (response) page(response, 504, "Authorization timed out"); + finish({ error: new Error("OAuth callback timed out after 5 minutes") }); + }, callbackTimeoutMs); + server.on("error", () => { + if (response && !flow.result) page(response, 500, "Authorization failed"); + finish({ error: new Error("OAuth callback listener failed") }); + }); + server.on("request", async (req, res) => { + let url; + try { url = new URL(req.url || "/", redirectUri); } + catch { page(res, 400, "Invalid callback"); return; } + if (url.pathname !== "/callback") { res.writeHead(404); res.end("Not found"); return; } + if (url.searchParams.get("state") !== state) { page(res, 400, "Invalid callback"); return; } + if (flow.result || processing) { page(res, 409, "Authorization already handled"); return; } + if (url.searchParams.has("error")) { + page(res, 400, "Authorization failed"); + finish({ error: new Error("OAuth authorization failed") }); + return; + } + const code = url.searchParams.get("code"); + if (!code) { page(res, 400, "Invalid callback"); return; } + processing = true; + response = res; + try { + let tokens; + try { + const tokenRes = await request(metadata.token_endpoint, { + method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ grant_type: "authorization_code", code, + redirect_uri: redirectUri, client_id: client.client_id, code_verifier: verifier }), + signal: controller.signal, + }); + if (!tokenRes.ok) throw new Error(); + tokens = tokensFrom(await tokenRes.json(), undefined, client.client_id); + } catch { throw new Error("OAuth token exchange failed"); } + if (flow.result) return; + await saveJson(tokenFile, tokens); + if (flow.result) return; + page(res, 200, "Authorization successful"); + finish({ tokens }); + } catch (error) { + if (flow.result) return; + page(res, 500, "Authorization failed"); + finish({ error }); + } + }); + try { launchBrowser(authUrl.toString()); } + catch { + finish({ error: new Error("OAuth browser launch failed") }); + } + return flow; + } catch { + server.close(); + throw new Error("OAuth authorization setup failed"); + } + } + + async function authorize() { + const alreadyPending = Boolean(pending || starting); + if (!pending) { + starting ??= startFlow(); + try { await starting; } finally { starting = null; } + } + const flow = pending; + let timer; + const outcome = await Promise.race([ + flow.promise, + new Promise(resolve => { timer = setTimeout(() => resolve(null), alreadyPending ? pendingWaitMs : initialWaitMs); }), + ]); + clearTimeout(timer); + if (!outcome) throw new OAuthPendingError(); + if (pending === flow) pending = null; + if (outcome.error) throw outcome.error; + return outcome.tokens; + } + + function usable(tokens) { + return tokens && typeof tokens.access_token === "string" && tokens.access_token && + !rejectedAccess.has(tokens.access_token) && + (!tokens.expires_at || tokens.expires_at > now() + 60_000); + } + + async function acquire() { + let tokens = await readJson(tokenFile); + if (usable(tokens)) { + // A successful callback already persisted its result. Forget its completed wait. + if (pending?.result?.tokens) pending = null; + return tokens.access_token; + } + if (tokens?.refresh_token && !rejectedRefresh.has(tokens.refresh_token)) { + const stale = tokens; + try { + const metadata = await discover(); + const clientId = stale.client_id || (await readJson(clientFile))?.client_id; + if (!clientId) throw new Error(); + const res = await request(metadata.token_endpoint, { + method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: stale.refresh_token, client_id: clientId }), + }); + if (!res.ok) throw new Error(); + tokens = tokensFrom(await res.json(), stale.refresh_token, clientId); + const latest = await readJson(tokenFile); + if (latest?.access_token !== stale.access_token && usable(latest)) return latest.access_token; + if (!usable(tokens)) throw new Error(); + await saveJson(tokenFile, tokens); + return tokens.access_token; + } catch { + rejectedAccess.add(stale.access_token); + rejectedRefresh.add(stale.refresh_token); + const latest = await readJson(tokenFile); + if (usable(latest)) return latest.access_token; + } + } + if (tokens?.access_token) rejectedAccess.add(tokens.access_token); + const authorized = await authorize(); + if (!usable(authorized)) throw new Error("OAuth authorization returned an unusable token"); + return authorized.access_token; + } + + async function token() { + acquiring ??= acquire(); + const acquisition = acquiring; + try { lastIssued = await acquisition; return lastIssued; } + finally { if (acquiring === acquisition) acquiring = null; } + } + + async function onUnauthorized(context) { + // The transport fetch records the actual request's rejected bearer, including + // the SDK's final retry. A newer concurrently issued token must stay usable. + if (!context && lastIssued) rejectedAccess.add(lastIssued); + await token(); + } + + async function authenticatedFetch(input, init) { + const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); + const bearer = headers.get("authorization"); + const response = await request(input, init); + if (response.status === 401 && bearer?.startsWith("Bearer ")) { + rejectedAccess.add(bearer.slice(7)); + } + return response; + } + + return { token, onUnauthorized, fetch: authenticatedFetch }; +} diff --git a/mcp-server/test/oauth.test.js b/mcp-server/test/oauth.test.js new file mode 100644 index 0000000..0b65cef --- /dev/null +++ b/mcp-server/test/oauth.test.js @@ -0,0 +1,300 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createServer } from "node:http"; +import { mkdtemp, readFile, writeFile, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createOAuthProvider, OAuthPendingError } from "../server/oauth.js"; + +function deferred() { + let resolve; + const promise = new Promise(r => { resolve = r; }); + return { promise, resolve }; +} + +async function harness(t, options = {}) { + const tokenDir = await mkdtemp(join(tmpdir(), "rag-oauth-test-")); + const servers = []; + const browsers = []; + let refreshes = 0; + let exchanges = 0; + const metadata = { + authorization_endpoint: "https://oauth.test/authorize", + token_endpoint: "https://oauth.test/token", + registration_endpoint: "https://oauth.test/register", + }; + const provider = createOAuthProvider({ + workerUrl: "https://oauth.test", tokenDir, + initialWaitMs: 5, pendingWaitMs: 5, callbackTimeoutMs: 2000, + createCallbackServer: () => { + const server = createServer(); servers.push(server); return server; + }, + openBrowser: url => { browsers.push(new URL(url)); }, + fetch: async (url, init) => { + if (options.remote && (url instanceof Request ? url.url : String(url)).endsWith("/mcp")) return options.remote(url, init); + if (url.endsWith("/.well-known/oauth-authorization-server")) return Response.json(metadata); + if (url === metadata.registration_endpoint) { + if (options.registrationFails) return new Response("sensitive registration detail", { status: 400 }); + return Response.json({ client_id: "synthetic-client", redirect_uris: JSON.parse(init.body).redirect_uris }); + } + assert.equal(url, metadata.token_endpoint); + if (init.body.get("grant_type") === "refresh_token") { + refreshes++; + return options.refresh ? await options.refresh(init) : new Response("sensitive refresh detail", { status: 400 }); + } + exchanges++; + return options.exchange ? await options.exchange(init) : Response.json({ access_token: "synthetic-new", refresh_token: "synthetic-refresh", expires_in: 3600 }); + }, + ...options.provider, + }); + t.after(async () => { + for (const server of servers) { + server.emit("error", new Error("test cleanup")); + server.closeAllConnections(); + server.close(); + } + await rm(tokenDir, { recursive: true, force: true }); + }); + const tokenFile = join(tokenDir, "oauth-tokens.json"); + async function save(tokens) { await writeFile(tokenFile, JSON.stringify(tokens)); } + async function callback(params = {}, browser = browsers.at(-1)) { + const url = new URL(browser.searchParams.get("redirect_uri")); + url.searchParams.set("state", browser.searchParams.get("state")); + url.searchParams.set("code", "synthetic-code"); + for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value); + return await fetch(url); + } + return { provider, browsers, servers, tokenDir, tokenFile, save, callback, + counts: () => ({ refreshes, exchanges }) }; +} + +test("failed refresh followed by delayed authorization uses saved token on the next call", async t => { + const h = await harness(t); + await h.save({ access_token: "synthetic-old", refresh_token: "synthetic-stale-refresh", expires_at: 1 }); + await writeFile(join(h.tokenDir, "oauth-client.json"), JSON.stringify({ client_id: "synthetic-client" })); + await assert.rejects(h.provider.token(), OAuthPendingError); + await assert.rejects(h.provider.token(), OAuthPendingError); + assert.equal(h.browsers.length, 1); + const response = await h.callback(); + assert.equal(response.status, 200); + assert.match(await response.text(), /Authorization successful/); + assert.equal(await h.provider.token(), "synthetic-new"); + assert.equal(h.browsers.length, 1); + assert.deepEqual(h.counts(), { refreshes: 1, exchanges: 1 }); +}); + +test("valid existing credentials and another process's file update are used without a browser", async t => { + const h = await harness(t); + await h.save({ access_token: "synthetic-existing" }); + assert.equal(await h.provider.token(), "synthetic-existing"); + await h.save({ access_token: "synthetic-other-process" }); + assert.equal(await h.provider.token(), "synthetic-other-process"); + assert.equal(h.browsers.length, 0); +}); + +test("401 excludes the same disk token and accepts another process's replacement while pending", async t => { + const h = await harness(t); + await h.save({ access_token: "synthetic-rejected" }); + await h.provider.token(); + await assert.rejects(h.provider.onUnauthorized(), OAuthPendingError); + await assert.rejects(h.provider.token(), OAuthPendingError); + assert.equal(h.browsers.length, 1); + await h.save({ access_token: "synthetic-replacement" }); + assert.equal(await h.provider.token(), "synthetic-replacement"); + assert.equal(h.browsers.length, 1); +}); + +test("401 silently refreshes instead of reusing the rejected access token", async t => { + const h = await harness(t, { refresh: () => Response.json({ access_token: "synthetic-refreshed", expires_in: 3600 }) }); + await h.save({ access_token: "synthetic-rejected", refresh_token: "synthetic-refresh" }); + await writeFile(join(h.tokenDir, "oauth-client.json"), JSON.stringify({ client_id: "synthetic-client" })); + await h.provider.token(); + await h.provider.onUnauthorized(); + assert.equal(await h.provider.token(), "synthetic-refreshed"); + assert.equal(h.browsers.length, 0); + assert.equal(h.counts().refreshes, 1); +}); + +test("refresh uses the token's issuing client even after another process changes registration", async t => { + let usedClient; + const h = await harness(t, { refresh: init => { + usedClient = init.body.get("client_id"); + return Response.json({ access_token: "synthetic-refreshed", expires_in: 3600 }); + } }); + await h.save({ access_token: "synthetic-expired", refresh_token: "synthetic-refresh", client_id: "synthetic-issuing-client", expires_at: 1 }); + await writeFile(join(h.tokenDir, "oauth-client.json"), JSON.stringify({ client_id: "synthetic-other-client" })); + assert.equal(await h.provider.token(), "synthetic-refreshed"); + assert.equal(usedClient, "synthetic-issuing-client"); + assert.equal(JSON.parse(await readFile(h.tokenFile)).client_id, "synthetic-issuing-client"); +}); + +test("a rejected access token returned again by refresh is not adopted", async t => { + const h = await harness(t, { refresh: () => Response.json({ access_token: "synthetic-rejected", expires_in: 3600 }) }); + await h.save({ access_token: "synthetic-rejected", refresh_token: "synthetic-refresh", client_id: "synthetic-client" }); + await h.provider.token(); + await assert.rejects(h.provider.onUnauthorized(), OAuthPendingError); + await assert.rejects(h.provider.token(), OAuthPendingError); + assert.equal(h.counts().refreshes, 1); + assert.equal(h.browsers.length, 1); +}); + +test("a 401 invalidates the bearer actually sent, including a final retry, without rejecting a newer token", async t => { + const h = await harness(t, { remote: () => new Response(null, { status: 401 }) }); + await h.save({ access_token: "synthetic-request-old" }); + await h.provider.token(); + await h.save({ access_token: "synthetic-concurrent-new" }); + await h.provider.token(); + await h.provider.fetch("https://oauth.test/mcp", { headers: { Authorization: "Bearer synthetic-request-old" } }); + await h.provider.onUnauthorized({}); + assert.equal(await h.provider.token(), "synthetic-concurrent-new"); + await h.provider.fetch(new Request("https://oauth.test/mcp", { headers: { Authorization: "Bearer synthetic-concurrent-new" } })); + await assert.rejects(h.provider.token(), OAuthPendingError); + assert.equal(h.browsers.length, 1); +}); + +test("an external replacement during refresh failure is used without opening a browser", async t => { + const h = await harness(t, { refresh: async () => { + await h.save({ access_token: "synthetic-external" }); + return new Response("failure", { status: 400 }); + } }); + await h.save({ access_token: "synthetic-expired", refresh_token: "synthetic-refresh", expires_at: 1 }); + await writeFile(join(h.tokenDir, "oauth-client.json"), JSON.stringify({ client_id: "synthetic-client" })); + assert.equal(await h.provider.token(), "synthetic-external"); + assert.equal(h.browsers.length, 0); +}); + +test("refresh does not overwrite an external replacement saved while the request was in flight", async t => { + const h = await harness(t, { refresh: async () => { + await h.save({ access_token: "synthetic-external" }); + return Response.json({ access_token: "synthetic-refreshed", expires_in: 3600 }); + } }); + await h.save({ access_token: "synthetic-expired", refresh_token: "synthetic-refresh", expires_at: 1 }); + await writeFile(join(h.tokenDir, "oauth-client.json"), JSON.stringify({ client_id: "synthetic-client" })); + assert.equal(await h.provider.token(), "synthetic-external"); + assert.equal(JSON.parse(await readFile(h.tokenFile)).access_token, "synthetic-external"); +}); + +test("concurrent calls start one browser flow and keep polling that same flow", async t => { + const h = await harness(t); + const results = await Promise.allSettled([h.provider.token(), h.provider.token(), h.provider.token()]); + assert.ok(results.every(result => result.status === "rejected" && result.reason instanceof OAuthPendingError)); + assert.equal(h.browsers.length, 1); + await h.callback(); + assert.deepEqual(await Promise.all([h.provider.token(), h.provider.token()]), ["synthetic-new", "synthetic-new"]); +}); + +test("browser success waits for token exchange and persisted credentials", async t => { + const exchangeStarted = deferred(); + const exchangeResult = deferred(); + const h = await harness(t, { exchange: () => { exchangeStarted.resolve(); return exchangeResult.promise; } }); + await assert.rejects(h.provider.token(), OAuthPendingError); + let responded = false; + const callback = h.callback().then(res => { responded = true; return res; }); + await exchangeStarted.promise; + assert.equal(responded, false); + exchangeResult.resolve(Response.json({ access_token: "synthetic-new" })); + const response = await callback; + assert.equal(response.status, 200); + assert.match(await response.text(), /Authorization successful/); + assert.equal(JSON.parse(await readFile(h.tokenFile)).access_token, "synthetic-new"); +}); + +test("late token exchange failure shows failure and is observed on the next call", async t => { + const h = await harness(t, { exchange: () => new Response("synthetic-secret-code-state", { status: 400 }) }); + await assert.rejects(h.provider.token(), OAuthPendingError); + const response = await h.callback(); + assert.equal(response.status, 500); + const body = await response.text(); + assert.match(body, /Authorization failed/); + assert.doesNotMatch(body, /successful|synthetic-secret/); + await assert.rejects(h.provider.token(), { message: "OAuth token exchange failed" }); + assert.equal(h.browsers.length, 1); + await assert.rejects(h.provider.token(), OAuthPendingError); + assert.equal(h.browsers.length, 2); +}); + +test("storage failure does not claim success and safely reaches the next caller", async t => { + const h = await harness(t); + await assert.rejects(h.provider.token(), OAuthPendingError); + await mkdir(h.tokenFile); + const response = await h.callback(); + assert.equal(response.status, 500); + assert.doesNotMatch(await response.text(), /successful/); + await assert.rejects(h.provider.token(), { message: "OAuth credential storage failed" }); +}); + +test("a timeout after the pending caller returned is retained until observed, then retry starts a new flow", async t => { + const h = await harness(t, { provider: { callbackTimeoutMs: 30 } }); + await assert.rejects(h.provider.token(), OAuthPendingError); + await new Promise(resolve => setTimeout(resolve, 45)); + await assert.rejects(h.provider.token(), { message: "OAuth callback timed out after 5 minutes" }); + assert.equal(h.servers[0].listening, false); + await assert.rejects(h.provider.token(), OAuthPendingError); + assert.equal(h.browsers.length, 2); +}); + +test("timeout during token exchange responds with failure and discards a late exchange result", async t => { + const exchangeStarted = deferred(); + const exchangeResult = deferred(); + const h = await harness(t, { provider: { callbackTimeoutMs: 80 }, exchange: () => { + exchangeStarted.resolve(); return exchangeResult.promise; + } }); + await assert.rejects(h.provider.token(), OAuthPendingError); + const callback = h.callback(); + await exchangeStarted.promise; + const response = await callback; + assert.equal(response.status, 504); + assert.doesNotMatch(await response.text(), /successful/); + exchangeResult.resolve(Response.json({ access_token: "synthetic-late" })); + await new Promise(resolve => setImmediate(resolve)); + await assert.rejects(readFile(h.tokenFile), { code: "ENOENT" }); + await assert.rejects(h.provider.token(), /timed out/); +}); + +test("invalid state cannot complete a flow, and authorization errors are sanitized", async t => { + const h = await harness(t); + await assert.rejects(h.provider.token(), OAuthPendingError); + const invalid = await h.callback({ state: "synthetic-invalid-state" }); + assert.equal(invalid.status, 400); + await assert.rejects(h.provider.token(), OAuthPendingError); + const denied = await h.callback({ error: "synthetic-sensitive-error" }); + assert.equal(denied.status, 400); + assert.doesNotMatch(await denied.text(), /synthetic-sensitive/); + await assert.rejects(h.provider.token(), { message: "OAuth authorization failed" }); +}); + +test("registration setup failure closes the callback listener and allows retry", async t => { + const h = await harness(t, { registrationFails: true }); + await assert.rejects(h.provider.token(), { message: "OAuth authorization setup failed" }); + assert.equal(h.servers[0].listening, false); + assert.equal(h.browsers.length, 0); + await assert.rejects(h.provider.token(), /setup failed/); + assert.equal(h.servers[1].listening, false); +}); + +test("a pending listener error is retained for the next call", async t => { + const h = await harness(t); + await assert.rejects(h.provider.token(), OAuthPendingError); + h.servers[0].emit("error", new Error("synthetic sensitive listener detail")); + await assert.rejects(h.provider.token(), { message: "OAuth callback listener failed" }); + assert.equal(h.servers[0].listening, false); +}); + +test("a malformed token exchange cannot display or persist success", async t => { + const h = await harness(t, { exchange: () => Response.json({ refresh_token: "synthetic-no-access" }) }); + await assert.rejects(h.provider.token(), OAuthPendingError); + const response = await h.callback(); + assert.equal(response.status, 500); + assert.doesNotMatch(await response.text(), /successful/); + await assert.rejects(h.provider.token(), { message: "OAuth token exchange failed" }); + await assert.rejects(readFile(h.tokenFile), { code: "ENOENT" }); +}); + +test("listener startup failure rejects promptly without an unhandled server error", async t => { + const h = await harness(t, { provider: { createCallbackServer: () => { + const server = createServer(); + server.listen = () => { queueMicrotask(() => server.emit("error", new Error("synthetic bind failure"))); return server; }; + return server; + } } }); + await assert.rejects(h.provider.token(), { message: "OAuth authorization setup failed" }); +});