From bbb1b4043ee1a90cbb51f4b2de2f2665cbaf50a4 Mon Sep 17 00:00:00 2001 From: yao <63141491+yaonyan@users.noreply.github.com> Date: Fri, 22 May 2026 17:58:32 +0800 Subject: [PATCH 1/4] feat(act): add OAuth 2.1 PKCE support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `src/oauth.ts`: full PKCE login flow with local callback server, cross-platform browser opener, headless stdin fallback, auto token refresh via discoverOAuthServerInfo + refreshAuthorization, and disk-backed state in ~/.config/one/oauth-state.json - `daemon.ts`: add `auth?: "oauth"` to OneActMcpServerConfig; strip it in normalizeMcpServersForRuntime so it never reaches the transport layer - `act.ts`: - `injectOAuthHeaders()` fetches/refreshes tokens and injects Authorization: Bearer before any server connection (CLI + programmatic act() and createActSession()) - `act oauth login|logout|status` subcommand - `act config` wizard: HTTP transport now asks url → headers → OAuth? → daemon (only when OAuth is not selected; they are mutually exclusive) - Help text and examples updated to use GitHub Copilot MCP as reference Co-Authored-By: Claude Sonnet 4.6 --- packages/one-act/src/act.ts | 174 ++++++++++++-- packages/one-act/src/daemon.ts | 4 +- packages/one-act/src/oauth.ts | 404 +++++++++++++++++++++++++++++++++ 3 files changed, 568 insertions(+), 14 deletions(-) create mode 100644 packages/one-act/src/oauth.ts diff --git a/packages/one-act/src/act.ts b/packages/one-act/src/act.ts index a54945b..a5c4ae7 100644 --- a/packages/one-act/src/act.ts +++ b/packages/one-act/src/act.ts @@ -16,6 +16,7 @@ import { import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, extname } from "node:path"; import { getOneConfigPath } from "./config-path.js"; +import { clearOAuthState, ensureOAuthToken, listOAuthStates, runOAuthLogin } from "./oauth.js"; import { computeDaemonConfigHash, ensureActDaemonClient, @@ -52,6 +53,7 @@ const HELP_MCP_FORMAT = [ 'stdio: {"transportType":"stdio","command":"npx","args":[...],"env":{"KEY":"VALUE"}}', 'http: {"transportType":"streamable-http"|"sse","url":"https://...","headers":{"Authorization":"Bearer ..."}}', 'one-act extension: {"daemon":true} keeps that server resident in the daemon; other servers stay on demand', + 'oauth: add {"auth":"oauth"} to a streamable-http/sse server to use OAuth 2.1 PKCE; run: act oauth login ', ]; const HELP_DAEMON = [ "Per-server `daemon: true` keeps that MCP server resident in the background", @@ -64,6 +66,10 @@ const HELP_EXAMPLES = [ 'act chrome-devtools_new_page \'{"url":"https://example.com"}\'', "ONE_ACT_MCP_SERVERS=" + '\'{"chrome-devtools":{"transportType":"stdio","command":"npx","args":["-y","chrome-devtools-mcp@latest","--autoConnect"]}}\' act --manual', + "act oauth login github", + "act oauth status", + "ONE_ACT_MCP_SERVERS=" + + '\'{"github":{"transportType":"streamable-http","url":"https://api.githubcopilot.com/mcp/","auth":"oauth"}}\' act --manual', ]; export type { McpServersConfig, OneActMcpServerConfig } from "./daemon.js"; @@ -152,7 +158,9 @@ export async function act( args: unknown, options?: ActOptions, ): Promise { - const mcpServers = options?.mcpServers ?? readConfiguredMcpServers(readActConfig()); + const mcpServers = await injectOAuthHeaders( + options?.mcpServers ?? readConfiguredMcpServers(readActConfig()), + ); if (!mcpServers || Object.keys(mcpServers).length === 0) { throw new Error( "No MCP server configuration found. Provide options.mcpServers, set ONE_ACT_MCP_SERVERS, or configure mcpServers in ~/.config/one/act.json.", @@ -263,7 +271,9 @@ export async function act( * ``` */ export async function createActSession(options?: ActOptions): Promise { - const mcpServers = options?.mcpServers ?? readConfiguredMcpServers(readActConfig()); + const mcpServers = await injectOAuthHeaders( + options?.mcpServers ?? readConfiguredMcpServers(readActConfig()), + ); if (!mcpServers || Object.keys(mcpServers).length === 0) { throw new Error( "No MCP server configuration found. Provide options.mcpServers, set ONE_ACT_MCP_SERVERS, or configure mcpServers in ~/.config/one/act.json.", @@ -614,16 +624,18 @@ async function runActConfigCli() { } } - const daemonMode = await confirm({ - message: "keep this MCP server running in one-act daemon?", - initialValue: transportType === "stdio", - }); - if (isCancel(daemonMode)) { - cancel("Operation cancelled."); - return; - } - if (daemonMode) { - serverConfig.daemon = true; + if (transportType === "stdio") { + const daemonMode = await confirm({ + message: "keep this MCP server running in one-act daemon?", + initialValue: true, + }); + if (isCancel(daemonMode)) { + cancel("Operation cancelled."); + return; + } + if (daemonMode) { + serverConfig.daemon = true; + } } if (transportType === "streamable-http" || transportType === "sse") { @@ -644,6 +656,30 @@ async function runActConfigCli() { if (headersRaw) { serverConfig.headers = parseEnvInput(headersRaw); } + + const useOAuth = await confirm({ + message: "enable OAuth 2.1 PKCE auth? (run `act oauth login ` after setup)", + initialValue: false, + }); + if (isCancel(useOAuth)) { + cancel("Operation cancelled."); + return; + } + if (useOAuth) { + serverConfig.auth = "oauth"; + } else { + const daemonMode = await confirm({ + message: "keep this MCP server running in one-act daemon?", + initialValue: false, + }); + if (isCancel(daemonMode)) { + cancel("Operation cancelled."); + return; + } + if (daemonMode) { + serverConfig.daemon = true; + } + } } const nextConfig = readActConfig(); @@ -1342,13 +1378,119 @@ async function runDaemonCommand( } } +/** + * For every on-demand server that has `auth: "oauth"`, fetch (or refresh) its + * access token and inject it as an `Authorization: Bearer` header. Daemon + * servers are intentionally skipped — they are long-lived processes that + * cannot reliably track token expiry. + */ +async function injectOAuthHeaders( + mcpServers: McpServersConfig | null, +): Promise { + if (!mcpServers) return null; + + const result: McpServersConfig = {}; + + for (const [name, config] of Object.entries(mcpServers)) { + const configRecord = config as unknown as Record; + + if ( + config?.auth === "oauth" && + config.daemon !== true && + typeof configRecord.url === "string" + ) { + const token = await ensureOAuthToken(name, configRecord.url); + if (token) { + result[name] = { + ...config, + headers: { + ...((configRecord.headers as Record | undefined) ?? {}), + Authorization: `Bearer ${token}`, + }, + } as McpServersConfig[string]; + continue; + } + + process.stderr.write( + `Warning: no valid OAuth token for server "${name}". Run: act oauth login ${name}\n`, + ); + } + + result[name] = config; + } + + return result; +} + +async function runOAuthCommand( + subcommand: string | undefined, + serverName: string | undefined, + mcpServers: McpServersConfig | null, +): Promise { + switch (subcommand) { + case "login": { + if (!serverName) throw new Error("Usage: act oauth login "); + + const serverConfig = mcpServers?.[serverName]; + const serverRecord = serverConfig as Record | undefined; + if (!serverRecord || typeof serverRecord.url !== "string") { + throw new Error(`Server "${serverName}" not found or has no URL in the current config`); + } + + process.stderr.write(`Logging in to "${serverName}"...\n`); + await runOAuthLogin(serverName, serverRecord.url); + process.stderr.write(`Successfully logged in to "${serverName}".\n`); + return; + } + + case "logout": { + if (!serverName) throw new Error("Usage: act oauth logout "); + clearOAuthState(serverName); + process.stderr.write(`Logged out of "${serverName}".\n`); + return; + } + + case "status": { + const states = listOAuthStates(); + if (Object.keys(states).length === 0) { + process.stdout.write("No OAuth sessions stored.\n"); + return; + } + for (const [name, info] of Object.entries(states)) { + const tokenStatus = !info.hasToken + ? "no token" + : info.expiresAt && Date.now() > info.expiresAt + ? "expired" + : "active"; + const expiry = info.expiresAt ? new Date(info.expiresAt).toLocaleString() : "unknown"; + const expiryStr = info.hasToken && info.expiresAt ? ` (expires ${expiry})` : ""; + process.stdout.write(` ${name}: ${tokenStatus}${expiryStr}\n`); + } + return; + } + + default: + throw new Error("Unknown oauth command. Use: login | logout | status"); + } +} + export async function runActCli(options?: { getServer?: GetServerFn; argv?: string[] }) { const args = options?.argv ?? process.argv.slice(2); if (args[0] === "auth") { throw new Error("'act auth' was removed. Use 'act config' instead."); } - const configuredMcpServers = readConfiguredMcpServers(readActConfig()); + // Read raw config first so the oauth command can look up server URLs before + // any token injection takes place. + const _rawMcpServers = readConfiguredMcpServers(readActConfig()); + + if (args[0] === "oauth" && !args.includes("--help") && !args.includes("-h")) { + await runOAuthCommand(args[1], args[2], _rawMcpServers); + return; + } + + // Inject OAuth tokens into headers for on-demand servers that have auth:"oauth". + const configuredMcpServers = await injectOAuthHeaders(_rawMcpServers); const _allDaemonServers = configuredMcpServers ? selectDaemonMcpServers(configuredMcpServers) : null; @@ -1423,6 +1565,12 @@ export async function runActCli(options?: { getServer?: GetServerFn; argv?: stri ); }); + cli + .command("oauth [server]", "Manage OAuth sessions: login | logout | status") + .action((action: string, server: string | undefined) => { + pending = runOAuthCommand(action, server, _rawMcpServers); + }); + cli .command("[tool] [toolArgs]", "Deterministic MCP tool invocation") .option("--name ", "Tool name (equivalent to positional )") diff --git a/packages/one-act/src/daemon.ts b/packages/one-act/src/daemon.ts index 5943021..b955ab7 100644 --- a/packages/one-act/src/daemon.ts +++ b/packages/one-act/src/daemon.ts @@ -58,6 +58,8 @@ type ActDaemonInvokePayload = export type OneActMcpServerConfig = McpServerConfig & { daemon?: boolean; + /** Set to "oauth" to enable OAuth 2.1 PKCE authentication for this server. */ + auth?: "oauth"; }; export type McpServersConfig = Record; @@ -267,7 +269,7 @@ function spawnActDaemonProcess(options: ActDaemonSpawnOptions) { export function normalizeMcpServersForRuntime(mcpServers: McpServersConfig): PlainMcpServersConfig { return Object.fromEntries( Object.entries(selectEnabledMcpServers(mcpServers)).map(([name, config]) => { - const { daemon: _daemon, ...runtimeConfig } = config; + const { daemon: _daemon, auth: _auth, ...runtimeConfig } = config; return [name, runtimeConfig as McpServerConfig]; }), ); diff --git a/packages/one-act/src/oauth.ts b/packages/one-act/src/oauth.ts new file mode 100644 index 0000000..b765e5f --- /dev/null +++ b/packages/one-act/src/oauth.ts @@ -0,0 +1,404 @@ +import type { + OAuthClientProvider, + OAuthDiscoveryState, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import { + auth, + discoverOAuthServerInfo, + refreshAuthorization, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import type { + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { createInterface } from "node:readline"; +import { dirname } from "node:path"; +import { getOneConfigPath } from "./config-path.js"; + +const OAUTH_STATE_PATH = getOneConfigPath("oauth-state.json"); + +// --------------------------------------------------------------------------- +// State file types +// --------------------------------------------------------------------------- + +type PerServerState = { + tokens?: OAuthTokens; + /** Absolute ms timestamp after which access_token should be considered expired. */ + expiresAt?: number; + clientInfo?: OAuthClientInformationMixed; + codeVerifier?: string; + discoveryState?: OAuthDiscoveryState; +}; + +type OAuthStateFile = Record; + +// --------------------------------------------------------------------------- +// State file helpers +// --------------------------------------------------------------------------- + +function readOAuthStateFile(): OAuthStateFile { + if (!existsSync(OAUTH_STATE_PATH)) return {}; + try { + const parsed = JSON.parse(readFileSync(OAUTH_STATE_PATH, "utf-8")); + return typeof parsed === "object" && parsed !== null ? (parsed as OAuthStateFile) : {}; + } catch { + return {}; + } +} + +function writeOAuthStateFile(file: OAuthStateFile) { + mkdirSync(dirname(OAUTH_STATE_PATH), { recursive: true }); + writeFileSync(OAUTH_STATE_PATH, `${JSON.stringify(file, null, 2)}\n`, "utf-8"); +} + +function readServerState(serverName: string): PerServerState | null { + return readOAuthStateFile()[serverName] ?? null; +} + +function writeServerState(serverName: string, state: PerServerState) { + const file = readOAuthStateFile(); + file[serverName] = state; + writeOAuthStateFile(file); +} + +function deleteServerState(serverName: string) { + const file = readOAuthStateFile(); + delete file[serverName]; + writeOAuthStateFile(file); +} + +// --------------------------------------------------------------------------- +// OAuthClientProvider implementation (disk-backed, browser-opening) +// --------------------------------------------------------------------------- + +class ActOAuthProvider implements OAuthClientProvider { + readonly #serverName: string; + readonly #redirectUrl: string; + #browserOpened = false; + + constructor(serverName: string, redirectUrl: string) { + this.#serverName = serverName; + this.#redirectUrl = redirectUrl; + } + + get redirectUrl(): string { + return this.#redirectUrl; + } + + get browserOpened(): boolean { + return this.#browserOpened; + } + + get clientMetadata(): OAuthClientMetadata { + return { + client_name: "one-act", + redirect_uris: [this.#redirectUrl], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }; + } + + clientInformation(): OAuthClientInformationMixed | undefined { + return readServerState(this.#serverName)?.clientInfo ?? undefined; + } + + saveClientInformation(clientInfo: OAuthClientInformationMixed): void { + const state = readServerState(this.#serverName) ?? {}; + writeServerState(this.#serverName, { ...state, clientInfo }); + } + + tokens(): OAuthTokens | undefined { + return readServerState(this.#serverName)?.tokens ?? undefined; + } + + saveTokens(tokens: OAuthTokens): void { + const state = readServerState(this.#serverName) ?? {}; + const expiresAt = + typeof tokens.expires_in === "number" ? Date.now() + tokens.expires_in * 1000 : undefined; + writeServerState(this.#serverName, { ...state, tokens, expiresAt }); + } + + redirectToAuthorization(url: URL): void { + this.#browserOpened = openBrowser(url.toString()); + const urlStr = url.toString(); + if (this.#browserOpened) { + process.stderr.write(`\nOpening browser for OAuth authorization:\n${urlStr}\n\n`); + } else { + process.stderr.write( + `\nCould not open browser. Open this URL manually to authorize:\n${urlStr}\n\n`, + ); + } + } + + saveCodeVerifier(codeVerifier: string): void { + const state = readServerState(this.#serverName) ?? {}; + writeServerState(this.#serverName, { ...state, codeVerifier }); + } + + codeVerifier(): string { + const cv = readServerState(this.#serverName)?.codeVerifier; + if (!cv) throw new Error(`No code verifier saved for server "${this.#serverName}"`); + return cv; + } + + invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): void { + const state = readServerState(this.#serverName); + if (!state) return; + + if (scope === "all") { + deleteServerState(this.#serverName); + return; + } + + const updated = { ...state }; + if (scope === "client") delete updated.clientInfo; + if (scope === "tokens") { + delete updated.tokens; + delete updated.expiresAt; + } + if (scope === "verifier") delete updated.codeVerifier; + if (scope === "discovery") delete updated.discoveryState; + writeServerState(this.#serverName, updated); + } + + saveDiscoveryState(discoveryState: OAuthDiscoveryState): void { + const state = readServerState(this.#serverName) ?? {}; + writeServerState(this.#serverName, { ...state, discoveryState }); + } + + discoveryState(): OAuthDiscoveryState | undefined { + return readServerState(this.#serverName)?.discoveryState ?? undefined; + } +} + +// --------------------------------------------------------------------------- +// OAuth callback HTTP server +// --------------------------------------------------------------------------- + +async function runOAuthCallbackServer(): Promise<{ + port: number; + waitForCode(): Promise; + close(): void; +}> { + let resolveCode: ((code: string) => void) | null = null; + let rejectCode: ((err: Error) => void) | null = null; + + const codePromise = new Promise((resolve, reject) => { + resolveCode = resolve; + rejectCode = reject; + }); + + const server = createServer((req, res) => { + try { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + const code = url.searchParams.get("code"); + const error = url.searchParams.get("error"); + + if (error) { + res.writeHead(400, { "content-type": "text/html; charset=utf-8" }); + res.end( + `

Authorization failed: ${error}

You may close this tab.

`, + ); + rejectCode?.(new Error(`OAuth error: ${error}`)); + return; + } + + if (code) { + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end( + `

Authorization successful!

You may close this tab.

`, + ); + resolveCode?.(code); + return; + } + + res.writeHead(400, { "content-type": "text/html; charset=utf-8" }); + res.end(`

Bad request

`); + } catch { + res.writeHead(500, { "content-type": "text/plain" }); + res.end("Internal server error"); + } + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Failed to start OAuth callback server"); + } + + return { + port: address.port, + waitForCode: () => codePromise, + close: () => { + server.close(); + }, + }; +} + +// --------------------------------------------------------------------------- +// Cross-platform browser opener +// --------------------------------------------------------------------------- + +function openBrowser(url: string): boolean { + const cmd = + process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; + try { + const child = spawn(cmd, [url], { detached: true, stdio: "ignore" }); + child.unref(); + return true; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Stdin fallback: wait for user to paste redirect URL or bare code +// --------------------------------------------------------------------------- + +function waitForCodeFromStdin(prompt: string): Promise { + return new Promise((resolve, reject) => { + process.stderr.write(prompt); + + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + terminal: false, + }); + + rl.once("line", (line) => { + rl.close(); + const trimmed = line.trim(); + if (!trimmed) { + reject(new Error("No input provided")); + return; + } + // Accept full redirect URL (http://127.0.0.1:PORT/callback?code=...) or bare code + try { + const parsed = new URL(trimmed); + const code = parsed.searchParams.get("code"); + if (code) { + resolve(code); + return; + } + } catch { + // Not a URL — treat as bare code + } + resolve(trimmed); + }); + + rl.once("close", () => { + reject(new Error("stdin closed without input")); + }); + }); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Run a full interactive OAuth 2.1 PKCE login flow for the given server. + * Persists tokens to disk when complete. + */ +export async function runOAuthLogin(serverName: string, serverUrl: string): Promise { + const callbackServer = await runOAuthCallbackServer(); + const redirectUrl = `http://127.0.0.1:${callbackServer.port}/callback`; + const provider = new ActOAuthProvider(serverName, redirectUrl); + + let code: string; + try { + const result = await auth(provider, { serverUrl }); + if (result === "AUTHORIZED") { + process.stderr.write("Already authorized.\n"); + return; + } + + // result === "REDIRECT": race callback server vs. manual paste (headless fallback). + // If stdin closes (non-interactive), gracefully fall back to callback-only. + const stdinPrompt = provider.browserOpened + ? `If the browser didn't redirect automatically, paste the full redirect URL here and press Enter:\n> ` + : `Paste the full redirect URL (or just the code=… value) after authorizing, then press Enter:\n> `; + + const stdinRace = waitForCodeFromStdin(stdinPrompt).catch(() => new Promise(() => {})); + code = await Promise.race([callbackServer.waitForCode(), stdinRace]); + } finally { + callbackServer.close(); + } + + const finalResult = await auth(provider, { serverUrl, authorizationCode: code }); + if (finalResult !== "AUTHORIZED") { + throw new Error("OAuth authorization did not complete successfully"); + } +} + +/** + * Return a valid access token for the given server, refreshing automatically + * if the stored token is within 60 seconds of expiry. Returns null when no + * token is stored or refresh fails — caller should prompt the user to re-login. + */ +export async function ensureOAuthToken( + serverName: string, + serverUrl: string, +): Promise { + const state = readServerState(serverName); + if (!state?.tokens?.access_token) return null; + + // No expiry info — assume still valid + if (!state.expiresAt) return state.tokens.access_token; + + // Still valid with 60 s buffer + if (Date.now() < state.expiresAt - 60_000) return state.tokens.access_token; + + // Expired — try to refresh + if (!state.tokens.refresh_token || !state.clientInfo) return null; + + try { + const { authorizationServerUrl, authorizationServerMetadata } = + await discoverOAuthServerInfo(serverUrl); + + const newTokens = await refreshAuthorization(authorizationServerUrl, { + metadata: authorizationServerMetadata, + clientInformation: state.clientInfo, + refreshToken: state.tokens.refresh_token, + }); + + const expiresAt = + typeof newTokens.expires_in === "number" + ? Date.now() + newTokens.expires_in * 1000 + : undefined; + + writeServerState(serverName, { ...state, tokens: newTokens, expiresAt }); + return newTokens.access_token; + } catch { + return null; + } +} + +/** Remove all stored OAuth state for the given server. */ +export function clearOAuthState(serverName: string): void { + deleteServerState(serverName); +} + +/** Return a summary of every server that has stored OAuth state. */ +export function listOAuthStates(): Record { + const file = readOAuthStateFile(); + return Object.fromEntries( + Object.entries(file).map(([name, state]) => [ + name, + { + hasToken: Boolean(state.tokens?.access_token), + expiresAt: state.expiresAt, + }, + ]), + ); +} From b5aa6fa52adc9112f493b11017c5b53229e936df Mon Sep 17 00:00:00 2001 From: yao <63141491+yaonyan@users.noreply.github.com> Date: Fri, 22 May 2026 18:12:48 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(one-act):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20security,=20Windows=20compat,=20resource=20leak,=20?= =?UTF-8?q?warn=20callback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Escape HTML in OAuth callback error page (prevent reflected injection) - Fix Windows browser opener: use `cmd /c start "" ` instead of bare `start` - Suppress async spawn errors via child.on("error", () => {}) - Add AbortSignal to waitForCodeFromStdin; abort controller in runOAuthLogin so the readline interface is cleaned up when the callback server wins the race - Make injectOAuthHeaders warn callback optional; only pass process.stderr from the CLI path so programmatic act()/createActSession() stay side-effect-free Co-Authored-By: Claude Sonnet 4.6 --- packages/one-act/src/act.ts | 7 ++-- packages/one-act/src/oauth.ts | 67 +++++++++++++++++++++++++++++------ 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/packages/one-act/src/act.ts b/packages/one-act/src/act.ts index a5c4ae7..94320e3 100644 --- a/packages/one-act/src/act.ts +++ b/packages/one-act/src/act.ts @@ -1386,6 +1386,7 @@ async function runDaemonCommand( */ async function injectOAuthHeaders( mcpServers: McpServersConfig | null, + warn?: (msg: string) => void, ): Promise { if (!mcpServers) return null; @@ -1411,7 +1412,7 @@ async function injectOAuthHeaders( continue; } - process.stderr.write( + warn?.( `Warning: no valid OAuth token for server "${name}". Run: act oauth login ${name}\n`, ); } @@ -1490,7 +1491,9 @@ export async function runActCli(options?: { getServer?: GetServerFn; argv?: stri } // Inject OAuth tokens into headers for on-demand servers that have auth:"oauth". - const configuredMcpServers = await injectOAuthHeaders(_rawMcpServers); + const configuredMcpServers = await injectOAuthHeaders(_rawMcpServers, (msg) => + process.stderr.write(msg), + ); const _allDaemonServers = configuredMcpServers ? selectDaemonMcpServers(configuredMcpServers) : null; diff --git a/packages/one-act/src/oauth.ts b/packages/one-act/src/oauth.ts index b765e5f..d3b4117 100644 --- a/packages/one-act/src/oauth.ts +++ b/packages/one-act/src/oauth.ts @@ -51,8 +51,11 @@ function readOAuthStateFile(): OAuthStateFile { } function writeOAuthStateFile(file: OAuthStateFile) { - mkdirSync(dirname(OAUTH_STATE_PATH), { recursive: true }); - writeFileSync(OAUTH_STATE_PATH, `${JSON.stringify(file, null, 2)}\n`, "utf-8"); + mkdirSync(dirname(OAUTH_STATE_PATH), { recursive: true, mode: 0o700 }); + writeFileSync(OAUTH_STATE_PATH, `${JSON.stringify(file, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + }); } function readServerState(serverName: string): PerServerState | null { @@ -176,6 +179,19 @@ class ActOAuthProvider implements OAuthClientProvider { } } +// --------------------------------------------------------------------------- +// HTML helpers +// --------------------------------------------------------------------------- + +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + // --------------------------------------------------------------------------- // OAuth callback HTTP server // --------------------------------------------------------------------------- @@ -202,7 +218,7 @@ async function runOAuthCallbackServer(): Promise<{ if (error) { res.writeHead(400, { "content-type": "text/html; charset=utf-8" }); res.end( - `

Authorization failed: ${error}

You may close this tab.

`, + `

Authorization failed: ${escapeHtml(error)}

You may close this tab.

`, ); rejectCode?.(new Error(`OAuth error: ${error}`)); return; @@ -250,10 +266,16 @@ async function runOAuthCallbackServer(): Promise<{ // --------------------------------------------------------------------------- function openBrowser(url: string): boolean { - const cmd = - process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; try { - const child = spawn(cmd, [url], { detached: true, stdio: "ignore" }); + let child: ReturnType; + if (process.platform === "win32") { + // "start" is a cmd.exe built-in, not a standalone binary + child = spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }); + } else { + const cmd = process.platform === "darwin" ? "open" : "xdg-open"; + child = spawn(cmd, [url], { detached: true, stdio: "ignore" }); + } + child.on("error", () => {}); // suppress unhandled async spawn errors child.unref(); return true; } catch { @@ -265,8 +287,13 @@ function openBrowser(url: string): boolean { // Stdin fallback: wait for user to paste redirect URL or bare code // --------------------------------------------------------------------------- -function waitForCodeFromStdin(prompt: string): Promise { +function waitForCodeFromStdin(prompt: string, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Aborted")); + return; + } + process.stderr.write(prompt); const rl = createInterface({ @@ -275,8 +302,20 @@ function waitForCodeFromStdin(prompt: string): Promise { terminal: false, }); - rl.once("line", (line) => { + const cleanup = () => { + signal?.removeEventListener("abort", onAbort); rl.close(); + }; + + const onAbort = () => { + cleanup(); + reject(new Error("Aborted")); + }; + + if (signal) signal.addEventListener("abort", onAbort); + + rl.once("line", (line) => { + cleanup(); const trimmed = line.trim(); if (!trimmed) { reject(new Error("No input provided")); @@ -297,6 +336,7 @@ function waitForCodeFromStdin(prompt: string): Promise { }); rl.once("close", () => { + signal?.removeEventListener("abort", onAbort); reject(new Error("stdin closed without input")); }); }); @@ -329,8 +369,15 @@ export async function runOAuthLogin(serverName: string, serverUrl: string): Prom ? `If the browser didn't redirect automatically, paste the full redirect URL here and press Enter:\n> ` : `Paste the full redirect URL (or just the code=… value) after authorizing, then press Enter:\n> `; - const stdinRace = waitForCodeFromStdin(stdinPrompt).catch(() => new Promise(() => {})); - code = await Promise.race([callbackServer.waitForCode(), stdinRace]); + const abortController = new AbortController(); + const stdinRace = waitForCodeFromStdin(stdinPrompt, abortController.signal).catch( + () => new Promise(() => {}), + ); + try { + code = await Promise.race([callbackServer.waitForCode(), stdinRace]); + } finally { + abortController.abort(); // close readline if callback server won the race + } } finally { callbackServer.close(); } From 0f771874da4ff0c8062a4d3a2a19c9410220726a Mon Sep 17 00:00:00 2001 From: yao <63141491+yaonyan@users.noreply.github.com> Date: Fri, 22 May 2026 18:58:15 +0800 Subject: [PATCH 3/4] feat(one-act): built-in client_id for GitHub Copilot MCP, skip DCR for known servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub's OAuth server does not support Dynamic Client Registration. Add a KNOWN_CLIENT_IDS map (keyed by hostname) with a pre-registered client_id for api.githubcopilot.com so `act oauth login github` works out of the box without any extra config. - ActOAuthProvider: accept optional clientId; return it from clientInformation() when no saved state, causing the SDK to skip DCR entirely - runOAuthLogin: resolve clientId from arg → KNOWN_CLIENT_IDS → DCR; persist clientInfo after success so token refresh works later - ensureOAuthToken: fall back to KNOWN_CLIENT_IDS when clientInfo not saved - OneActMcpServerConfig: add optional clientId field for custom servers - normalizeMcpServersForRuntime: strip clientId before passing to MCP runtime Co-Authored-By: Claude Sonnet 4.6 --- packages/one-act/src/act.ts | 2 +- packages/one-act/src/daemon.ts | 9 +++++- packages/one-act/src/oauth.ts | 52 ++++++++++++++++++++++++++++++---- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/packages/one-act/src/act.ts b/packages/one-act/src/act.ts index 94320e3..ba87562 100644 --- a/packages/one-act/src/act.ts +++ b/packages/one-act/src/act.ts @@ -1439,7 +1439,7 @@ async function runOAuthCommand( } process.stderr.write(`Logging in to "${serverName}"...\n`); - await runOAuthLogin(serverName, serverRecord.url); + await runOAuthLogin(serverName, serverRecord.url, serverConfig?.clientId); process.stderr.write(`Successfully logged in to "${serverName}".\n`); return; } diff --git a/packages/one-act/src/daemon.ts b/packages/one-act/src/daemon.ts index b955ab7..ca39a0d 100644 --- a/packages/one-act/src/daemon.ts +++ b/packages/one-act/src/daemon.ts @@ -60,6 +60,13 @@ export type OneActMcpServerConfig = McpServerConfig & { daemon?: boolean; /** Set to "oauth" to enable OAuth 2.1 PKCE authentication for this server. */ auth?: "oauth"; + /** + * Pre-registered OAuth client ID. When provided, Dynamic Client Registration + * is skipped and this client_id is used directly. Required for servers like + * GitHub that do not support DCR. Built-in values exist for known servers + * (e.g. api.githubcopilot.com), so this is only needed for custom servers. + */ + clientId?: string; }; export type McpServersConfig = Record; @@ -269,7 +276,7 @@ function spawnActDaemonProcess(options: ActDaemonSpawnOptions) { export function normalizeMcpServersForRuntime(mcpServers: McpServersConfig): PlainMcpServersConfig { return Object.fromEntries( Object.entries(selectEnabledMcpServers(mcpServers)).map(([name, config]) => { - const { daemon: _daemon, auth: _auth, ...runtimeConfig } = config; + const { daemon: _daemon, auth: _auth, clientId: _clientId, ...runtimeConfig } = config; return [name, runtimeConfig as McpServerConfig]; }), ); diff --git a/packages/one-act/src/oauth.ts b/packages/one-act/src/oauth.ts index d3b4117..60cb769 100644 --- a/packages/one-act/src/oauth.ts +++ b/packages/one-act/src/oauth.ts @@ -21,6 +21,24 @@ import { getOneConfigPath } from "./config-path.js"; const OAUTH_STATE_PATH = getOneConfigPath("oauth-state.json"); +// --------------------------------------------------------------------------- +// Built-in client IDs for known servers (keyed by hostname). +// These servers do not support Dynamic Client Registration, so one-act ships +// a pre-registered client_id for each of them. +// --------------------------------------------------------------------------- + +const KNOWN_CLIENT_IDS: Record = { + "api.githubcopilot.com": "Ov23li0hx3Ph6WI4G1nt", +}; + +export function getKnownClientId(serverUrl: string): string | undefined { + try { + return KNOWN_CLIENT_IDS[new URL(serverUrl).hostname]; + } catch { + return undefined; + } +} + // --------------------------------------------------------------------------- // State file types // --------------------------------------------------------------------------- @@ -81,11 +99,13 @@ function deleteServerState(serverName: string) { class ActOAuthProvider implements OAuthClientProvider { readonly #serverName: string; readonly #redirectUrl: string; + readonly #clientId: string | undefined; #browserOpened = false; - constructor(serverName: string, redirectUrl: string) { + constructor(serverName: string, redirectUrl: string, clientId?: string) { this.#serverName = serverName; this.#redirectUrl = redirectUrl; + this.#clientId = clientId; } get redirectUrl(): string { @@ -107,7 +127,11 @@ class ActOAuthProvider implements OAuthClientProvider { } clientInformation(): OAuthClientInformationMixed | undefined { - return readServerState(this.#serverName)?.clientInfo ?? undefined; + const saved = readServerState(this.#serverName)?.clientInfo; + if (saved) return saved; + // Pre-configured client_id: returned directly so DCR is skipped entirely. + if (this.#clientId) return { client_id: this.#clientId }; + return undefined; } saveClientInformation(clientInfo: OAuthClientInformationMixed): void { @@ -350,10 +374,16 @@ function waitForCodeFromStdin(prompt: string, signal?: AbortSignal): Promise { +export async function runOAuthLogin( + serverName: string, + serverUrl: string, + clientId?: string, +): Promise { const callbackServer = await runOAuthCallbackServer(); const redirectUrl = `http://127.0.0.1:${callbackServer.port}/callback`; - const provider = new ActOAuthProvider(serverName, redirectUrl); + // Resolve: explicit clientId arg → built-in known client → DCR + const effectiveClientId = clientId ?? getKnownClientId(serverUrl); + const provider = new ActOAuthProvider(serverName, redirectUrl, effectiveClientId); let code: string; try { @@ -386,6 +416,13 @@ export async function runOAuthLogin(serverName: string, serverUrl: string): Prom if (finalResult !== "AUTHORIZED") { throw new Error("OAuth authorization did not complete successfully"); } + + // When DCR was skipped (pre-configured clientId), saveClientInformation is never + // called by the SDK. Persist clientInfo manually so token refresh works later. + if (effectiveClientId && !readServerState(serverName)?.clientInfo) { + const state = readServerState(serverName) ?? {}; + writeServerState(serverName, { ...state, clientInfo: { client_id: effectiveClientId } }); + } } /** @@ -407,7 +444,10 @@ export async function ensureOAuthToken( if (Date.now() < state.expiresAt - 60_000) return state.tokens.access_token; // Expired — try to refresh - if (!state.tokens.refresh_token || !state.clientInfo) return null; + const clientInfo = state.clientInfo ?? + // Fallback: built-in client_id for servers that don't support DCR + (getKnownClientId(serverUrl) ? { client_id: getKnownClientId(serverUrl)! } : undefined); + if (!state.tokens.refresh_token || !clientInfo) return null; try { const { authorizationServerUrl, authorizationServerMetadata } = @@ -415,7 +455,7 @@ export async function ensureOAuthToken( const newTokens = await refreshAuthorization(authorizationServerUrl, { metadata: authorizationServerMetadata, - clientInformation: state.clientInfo, + clientInformation: clientInfo, refreshToken: state.tokens.refresh_token, }); From 347a62bd2c0a3e32824b918bb784575f31aaf615 Mon Sep 17 00:00:00 2001 From: yao <63141491+yaonyan@users.noreply.github.com> Date: Fri, 22 May 2026 19:43:29 +0800 Subject: [PATCH 4/4] fix(one-act): use GitHub Device Flow for Copilot MCP OAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub OAuth Apps require client_secret in the PKCE authorization_code token exchange even when using PKCE — they return incorrect_client_credentials without it. Device Flow explicitly supports no-secret public clients and is the correct flow for CLI tools. - Add DEVICE_FLOW_HOSTS set (api.githubcopilot.com) to route servers that need Device Flow instead of the standard PKCE web callback - Implement runGitHubDeviceFlow: discovers scopes from Protected Resource Metadata, requests device code, polls token endpoint until user authorizes - Clear actionable error when "Enable Device Flow" is not checked in the OAuth App settings - Remove debug fetch wrapper from runOAuthLogin Co-Authored-By: Claude Sonnet 4.6 --- packages/one-act/src/act.ts | 4 +- packages/one-act/src/oauth.ts | 172 ++++++++++++++++++++++++++++++++-- 2 files changed, 167 insertions(+), 9 deletions(-) diff --git a/packages/one-act/src/act.ts b/packages/one-act/src/act.ts index ba87562..9986ae3 100644 --- a/packages/one-act/src/act.ts +++ b/packages/one-act/src/act.ts @@ -1412,9 +1412,7 @@ async function injectOAuthHeaders( continue; } - warn?.( - `Warning: no valid OAuth token for server "${name}". Run: act oauth login ${name}\n`, - ); + warn?.(`Warning: no valid OAuth token for server "${name}". Run: act oauth login ${name}\n`); } result[name] = config; diff --git a/packages/one-act/src/oauth.ts b/packages/one-act/src/oauth.ts index 60cb769..3fbda8f 100644 --- a/packages/one-act/src/oauth.ts +++ b/packages/one-act/src/oauth.ts @@ -19,6 +19,26 @@ import { createInterface } from "node:readline"; import { dirname } from "node:path"; import { getOneConfigPath } from "./config-path.js"; +// --------------------------------------------------------------------------- +// Servers that use GitHub Device Flow instead of PKCE web flow. +// GitHub OAuth Apps require client_secret in the PKCE token exchange, but +// the Device Flow works without one. We key by hostname. +// --------------------------------------------------------------------------- + +const DEVICE_FLOW_HOSTS = new Set(["api.githubcopilot.com"]); + +function isDeviceFlowServer(serverUrl: string): boolean { + try { + return DEVICE_FLOW_HOSTS.has(new URL(serverUrl).hostname); + } catch { + return false; + } +} + +// GitHub Device Flow endpoint (not in their OAuth server metadata). +const GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"; +const GITHUB_DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; + const OAUTH_STATE_PATH = getOneConfigPath("oauth-state.json"); // --------------------------------------------------------------------------- @@ -366,12 +386,146 @@ function waitForCodeFromStdin(prompt: string, signal?: AbortSignal): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function runGitHubDeviceFlow( + serverName: string, + serverUrl: string, + clientId: string, +): Promise { + // Fetch scopes from Protected Resource Metadata. + let scope = "repo read:org read:user user:email"; // safe default + try { + const prMetaUrl = new URL("/.well-known/oauth-protected-resource", serverUrl).href; + const prResp = await fetch(prMetaUrl, { headers: { accept: "application/json" } }); + if (prResp.ok) { + const prMeta = (await prResp.json()) as { scopes_supported?: string[] }; + if (Array.isArray(prMeta.scopes_supported) && prMeta.scopes_supported.length > 0) { + scope = prMeta.scopes_supported.join(" "); + } + } + } catch { + // ignore — use default scope + } + + // Step 1: request device code. + const deviceResp = await fetch(GITHUB_DEVICE_CODE_URL, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ client_id: clientId, scope }).toString(), + }); + + const deviceData = (await deviceResp.json()) as { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; + error?: string; + error_description?: string; + }; + + if (!deviceResp.ok || deviceData.error) { + const msg = deviceData.error_description ?? deviceData.error ?? `HTTP ${deviceResp.status}`; + const hint = + deviceData.error === "device_flow_disabled" + ? '\n→ Open your GitHub OAuth App settings and check "Enable Device Flow":\n https://github.com/settings/developers' + : ""; + throw new Error(`GitHub device flow error: ${msg}${hint}`); + } + + // Step 2: show code to user and try to open browser. + const verifyUrl = deviceData.verification_uri; + openBrowser(verifyUrl); + process.stderr.write( + `\nOpen: ${verifyUrl}\nEnter code: ${deviceData.user_code}\n\n(Waiting for authorization…)\n`, + ); + + // Step 3: poll for token. + const { authorizationServerMetadata } = await discoverOAuthServerInfo(serverUrl).catch(() => ({ + authorizationServerMetadata: undefined, + authorizationServerUrl: new URL("https://github.com"), + })); + const tokenEndpoint = + authorizationServerMetadata?.token_endpoint ?? "https://github.com/login/oauth/access_token"; + + const deadline = Date.now() + deviceData.expires_in * 1000; + const pollMs = Math.max((deviceData.interval + 1) * 1000, 5_000); + + while (Date.now() < deadline) { + await delay(pollMs); + + const tokenResp = await fetch(tokenEndpoint, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + device_code: deviceData.device_code, + grant_type: GITHUB_DEVICE_GRANT, + }).toString(), + }); + + const tokenData = (await tokenResp.json()) as { + access_token?: string; + token_type?: string; + scope?: string; + refresh_token?: string; + expires_in?: number; + error?: string; + error_description?: string; + }; + + if (tokenData.access_token) { + const expiresAt = + typeof tokenData.expires_in === "number" + ? Date.now() + tokenData.expires_in * 1000 + : undefined; + const tokens: OAuthTokens = { + access_token: tokenData.access_token, + token_type: tokenData.token_type ?? "bearer", + ...(tokenData.scope !== undefined ? { scope: tokenData.scope } : {}), + ...(tokenData.refresh_token !== undefined + ? { refresh_token: tokenData.refresh_token } + : {}), + ...(tokenData.expires_in !== undefined ? { expires_in: tokenData.expires_in } : {}), + }; + writeServerState(serverName, { + tokens, + expiresAt, + clientInfo: { client_id: clientId }, + }); + process.stderr.write("\nSuccessfully authorized!\n"); + return; + } + + if (tokenData.error === "authorization_pending") continue; + if (tokenData.error === "slow_down") { + await delay(5_000); + continue; + } + + throw new Error( + `GitHub authorization failed: ${tokenData.error_description ?? tokenData.error ?? "unknown error"}`, + ); + } + + throw new Error("GitHub device flow authorization timed out. Please try again."); +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** - * Run a full interactive OAuth 2.1 PKCE login flow for the given server. + * Run a full interactive OAuth login flow for the given server. + * Uses GitHub Device Flow for GitHub Copilot (no client_secret needed). + * Falls back to OAuth 2.1 PKCE web flow for other servers. * Persists tokens to disk when complete. */ export async function runOAuthLogin( @@ -379,10 +533,16 @@ export async function runOAuthLogin( serverUrl: string, clientId?: string, ): Promise { + const effectiveClientId = clientId ?? getKnownClientId(serverUrl); + + // GitHub OAuth Apps require client_secret in the PKCE token exchange but + // NOT in the Device Flow — use Device Flow for GitHub servers. + if (effectiveClientId && isDeviceFlowServer(serverUrl)) { + return runGitHubDeviceFlow(serverName, serverUrl, effectiveClientId); + } + const callbackServer = await runOAuthCallbackServer(); const redirectUrl = `http://127.0.0.1:${callbackServer.port}/callback`; - // Resolve: explicit clientId arg → built-in known client → DCR - const effectiveClientId = clientId ?? getKnownClientId(serverUrl); const provider = new ActOAuthProvider(serverName, redirectUrl, effectiveClientId); let code: string; @@ -394,7 +554,6 @@ export async function runOAuthLogin( } // result === "REDIRECT": race callback server vs. manual paste (headless fallback). - // If stdin closes (non-interactive), gracefully fall back to callback-only. const stdinPrompt = provider.browserOpened ? `If the browser didn't redirect automatically, paste the full redirect URL here and press Enter:\n> ` : `Paste the full redirect URL (or just the code=… value) after authorizing, then press Enter:\n> `; @@ -406,7 +565,7 @@ export async function runOAuthLogin( try { code = await Promise.race([callbackServer.waitForCode(), stdinRace]); } finally { - abortController.abort(); // close readline if callback server won the race + abortController.abort(); } } finally { callbackServer.close(); @@ -444,7 +603,8 @@ export async function ensureOAuthToken( if (Date.now() < state.expiresAt - 60_000) return state.tokens.access_token; // Expired — try to refresh - const clientInfo = state.clientInfo ?? + const clientInfo = + state.clientInfo ?? // Fallback: built-in client_id for servers that don't support DCR (getKnownClientId(serverUrl) ? { client_id: getKnownClientId(serverUrl)! } : undefined); if (!state.tokens.refresh_token || !clientInfo) return null;