diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md index 1b14fa4a5ca1..e81133c8f147 100644 --- a/docs/cli/mcp.md +++ b/docs/cli/mcp.md @@ -423,7 +423,7 @@ Notes: - `set` expects one JSON object value on the command line. - `configure` updates enablement, tool filters, timeouts, OAuth, TLS, and parallel-tool-call hints without replacing the whole server definition. Add `--probe` to verify the updated server before saving. - `tools` updates per-server tool filters. Include/exclude entries are MCP tool names and simple `*` globs. -- `login` runs the OAuth flow for HTTP servers configured with `auth: "oauth"`. It listens for the registered loopback redirect and completes the exchange automatically; `--code` remains the manual fallback. +- `login` runs the OAuth flow for HTTP servers configured with `auth: "oauth"`. For a loopback redirect, OpenClaw listens for the browser callback and completes login automatically. The printed `--code` command remains the fallback for remote, headless, or unreachable callbacks. - `logout` clears stored OAuth credentials for the named server without removing the saved server definition. - `reload` disposes cached in-process MCP runtimes for the current CLI process only. Gateway or agent processes in another process still need their own reload or restart path. - Use `transport: "streamable-http"` for Streamable HTTP MCP servers. `openclaw mcp set` also normalizes CLI-native `type: "http"` to the same canonical config shape for compatibility. @@ -731,11 +731,11 @@ When a remote MCP service is already backed by a separate OpenClaw refresh-capab openclaw mcp login docs ``` - OpenClaw starts the registered loopback callback listener before printing the authorization URL. After browser approval, it validates the returned state, exchanges the code, saves the credentials, and closes the listener automatically. + OpenClaw starts the registered loopback callback, prints the authorization URL, and stores temporary OAuth verifier state in shared SQLite. Approve the request in the browser and return to the terminal; token exchange completes automatically after the callback arrives. - The login output also prints a manual command for headless, remote, busy-port, or timed-out callback flows. After browser approval, copy the returned code into that command. + If the browser runs on another machine or cannot reach the printed loopback address, copy the returned code and pass it back to OpenClaw. ```bash openclaw mcp login docs --code abc123 diff --git a/src/agents/mcp-oauth.test.ts b/src/agents/mcp-oauth.test.ts index f6eef308fdd0..56a1a4f00cc5 100644 --- a/src/agents/mcp-oauth.test.ts +++ b/src/agents/mcp-oauth.test.ts @@ -707,147 +707,6 @@ describe("MCP OAuth provider", () => { ]); }); - it("exchanges a captured loopback code under the same OAuth lease", async () => { - await withTempHome( - async () => { - const authorizationUrl = new URL("https://auth.example.com/authorize"); - authorizationUrl.searchParams.set("state", "state-1"); - authorizationUrl.searchParams.set("redirect_uri", "http://127.0.0.1:43123/oauth/callback"); - authMock.mockReset(); - authMock - .mockImplementationOnce(async (provider) => { - await provider.saveCodeVerifier?.("verifier"); - await provider.redirectToAuthorization?.(authorizationUrl); - return "REDIRECT"; - }) - .mockImplementationOnce(async (provider, options) => { - expect(options.authorizationCode).toBe("captured-code"); - expect(provider.redirectUrl).toBe("http://127.0.0.1:43123/oauth/callback"); - const leaseCount = openOpenClawStateDatabase() - .db.prepare("SELECT COUNT(*) AS count FROM state_leases WHERE scope = ?") - .get("core:mcp-oauth") as { count: number }; - expect(leaseCount.count).toBe(1); - await provider.saveTokens({ access_token: "access", token_type: "Bearer" }); - return "AUTHORIZED"; - }); - - await expect( - runMcpOAuthLogin({ - serverName: "Remote Docs", - serverUrl: "https://mcp.example.com/mcp", - config: { redirectUrl: "http://127.0.0.1:43123/oauth/callback" }, - onAuthorizationUrl: async (url) => { - expect(url).toEqual(authorizationUrl); - return "captured-code"; - }, - }), - ).resolves.toBe("authorized"); - expect(authMock).toHaveBeenCalledTimes(2); - }, - { - prefix: "openclaw-mcp-oauth-loopback-code-", - skipSessionCleanup: true, - env: { OPENCLAW_CONFIG_PATH: undefined, OPENCLAW_STATE_DIR: undefined }, - }, - ); - }); - - it("persists the localhost registration fallback before exchanging a captured code", async () => { - await withTempHome( - async () => { - const serverName = "Calendly"; - const serverUrl = "https://mcp.calendly.com/"; - const storeKey = resolveMcpOAuthStoreKey(serverName, serverUrl); - const authorizationUrl = new URL("https://auth.example.com/authorize"); - authorizationUrl.searchParams.set("state", "state-1"); - authorizationUrl.searchParams.set("redirect_uri", "http://localhost:8989/oauth/callback"); - authMock.mockReset(); - authMock - .mockRejectedValueOnce(new Error("invalid_client_metadata: redirect_uri rejected")) - .mockImplementationOnce(async (provider) => { - expect(provider.redirectUrl).toBe("http://localhost:8989/oauth/callback"); - await provider.saveCodeVerifier?.("verifier"); - await provider.redirectToAuthorization?.(authorizationUrl); - return "REDIRECT"; - }) - .mockImplementationOnce(async (provider, options) => { - expect(options.authorizationCode).toBe("captured-code"); - expect(provider.redirectUrl).toBe("http://localhost:8989/oauth/callback"); - expect(readMcpOAuthStore(storeKey).redirectUrl).toBe( - "http://localhost:8989/oauth/callback", - ); - return "AUTHORIZED"; - }); - - await expect( - runMcpOAuthLogin({ - serverName, - serverUrl, - onAuthorizationUrl: () => "captured-code", - }), - ).resolves.toBe("authorized"); - expect(authMock).toHaveBeenCalledTimes(3); - }, - { - prefix: "openclaw-mcp-oauth-loopback-fallback-", - skipSessionCleanup: true, - env: { OPENCLAW_CONFIG_PATH: undefined, OPENCLAW_STATE_DIR: undefined }, - }, - ); - }); - - it.each([ - ["busy listener", Object.assign(new Error("listen EADDRINUSE"), { code: "EADDRINUSE" })], - ["callback timeout", new Error("OAuth callback timeout")], - ])("persists localhost before a failed %s can fall back to --code", async (_label, error) => { - await withTempHome( - async () => { - const serverName = "Calendly"; - const serverUrl = "https://mcp.calendly.com/"; - const storeKey = resolveMcpOAuthStoreKey(serverName, serverUrl); - const authorizationUrl = new URL("https://auth.example.com/authorize"); - authorizationUrl.searchParams.set("state", "state-1"); - authorizationUrl.searchParams.set("redirect_uri", "http://localhost:8989/oauth/callback"); - authMock.mockReset(); - authMock - .mockRejectedValueOnce(new Error("invalid_client_metadata: redirect_uri rejected")) - .mockImplementationOnce(async (provider) => { - await provider.saveCodeVerifier?.("verifier"); - await provider.redirectToAuthorization?.(authorizationUrl); - return "REDIRECT"; - }); - - await expect( - runMcpOAuthLogin({ - serverName, - serverUrl, - onAuthorizationUrl: () => { - throw error; - }, - }), - ).rejects.toBe(error); - expect(readMcpOAuthStore(storeKey).redirectUrl).toBe( - "http://localhost:8989/oauth/callback", - ); - - authMock.mockReset(); - authMock.mockImplementationOnce(async (provider, options) => { - expect(options.authorizationCode).toBe("manual-code"); - expect(provider.redirectUrl).toBe("http://localhost:8989/oauth/callback"); - return "AUTHORIZED"; - }); - await expect( - runMcpOAuthLogin({ serverName, serverUrl, authorizationCode: "manual-code" }), - ).resolves.toBe("authorized"); - }, - { - prefix: "openclaw-mcp-oauth-loopback-recovery-", - skipSessionCleanup: true, - env: { OPENCLAW_CONFIG_PATH: undefined, OPENCLAW_STATE_DIR: undefined }, - }, - ); - }); - it("does not retry a code exchange redirect mismatch", async () => { authMock.mockReset(); authMock.mockRejectedValueOnce(new Error("invalid_grant: redirect_uri mismatch")); @@ -895,11 +754,16 @@ describe("MCP OAuth provider", () => { it("persists localhost redirect for a later code exchange login", async () => { await withTempHome( async () => { + let finalAuthorizationUrl: URL | undefined; authMock.mockReset(); authMock .mockRejectedValueOnce(new Error("invalid_client_metadata: redirect_uri rejected")) .mockImplementationOnce(async (provider) => { await provider.saveCodeVerifier?.("verifier"); + const authorizationUrl = new URL("https://auth.example.com/authorize"); + authorizationUrl.searchParams.set("redirect_uri", String(provider.redirectUrl)); + authorizationUrl.searchParams.set("state", "state-1234567890"); + await provider.redirectToAuthorization?.(authorizationUrl); return "REDIRECT"; }); @@ -907,10 +771,16 @@ describe("MCP OAuth provider", () => { runMcpOAuthLogin({ serverName: "Calendly", serverUrl: "https://mcp.calendly.com/", - onAuthorizationUrl: () => {}, + onAuthorizationUrl: (url) => { + finalAuthorizationUrl = url; + }, }), ).resolves.toBe("redirect"); + expect(finalAuthorizationUrl?.searchParams.get("redirect_uri")).toBe( + "http://localhost:8989/oauth/callback", + ); + const store = readMcpOAuthStore( resolveMcpOAuthStoreKey("Calendly", "https://mcp.calendly.com/"), ); @@ -918,7 +788,12 @@ describe("MCP OAuth provider", () => { expect(store.codeVerifier).toBe("verifier"); authMock.mockReset(); - authMock.mockResolvedValueOnce("AUTHORIZED"); + authMock.mockImplementationOnce(async (provider, options) => { + expect(options.authorizationCode).toBe("code-123"); + expect(provider.redirectUrl).toBe("http://localhost:8989/oauth/callback"); + expect(await provider.codeVerifier?.()).toBe("verifier"); + return "AUTHORIZED"; + }); await runMcpOAuthLogin({ serverName: "Calendly", serverUrl: "https://mcp.calendly.com/", @@ -939,6 +814,74 @@ describe("MCP OAuth provider", () => { ); }); + it("keeps a captured verifier bound to its login when another login overlaps", async () => { + await withTempHome( + async () => { + let firstSession: { codeVerifier: string; redirectUrl: string } | undefined; + authMock.mockReset(); + authMock.mockImplementationOnce(async (provider) => { + await provider.saveCodeVerifier?.("verifier-first"); + return "REDIRECT"; + }); + + await runMcpOAuthLogin({ + serverName: "Calendly", + serverUrl: "https://mcp.calendly.com/", + onAuthorizationUrl: () => {}, + onAuthorizationSession: (session) => { + firstSession = session; + }, + }); + expect(firstSession).toEqual({ + codeVerifier: "verifier-first", + redirectUrl: "http://127.0.0.1:8989/oauth/callback", + }); + + authMock.mockImplementationOnce(async (provider) => { + await provider.saveCodeVerifier?.("verifier-second"); + return "REDIRECT"; + }); + await runMcpOAuthLogin({ + serverName: "Calendly", + serverUrl: "https://mcp.calendly.com/", + onAuthorizationUrl: () => {}, + }); + expect( + readMcpOAuthStore(resolveMcpOAuthStoreKey("Calendly", "https://mcp.calendly.com/")) + .codeVerifier, + ).toBe("verifier-second"); + + const captured = firstSession; + if (!captured) { + throw new Error("first login did not capture its authorization session"); + } + authMock.mockImplementationOnce(async (provider, options) => { + expect(options.authorizationCode).toBe("code-first"); + expect(await provider.codeVerifier()).toBe("verifier-first"); + expect(provider.redirectUrl).toBe("http://127.0.0.1:8989/oauth/callback"); + return "AUTHORIZED"; + }); + await expect( + runMcpOAuthLogin({ + serverName: "Calendly", + serverUrl: "https://mcp.calendly.com/", + config: { redirectUrl: captured.redirectUrl }, + authorizationCode: "code-first", + codeVerifier: captured.codeVerifier, + }), + ).resolves.toBe("authorized"); + }, + { + prefix: "openclaw-mcp-oauth-overlap-", + skipSessionCleanup: true, + env: { + OPENCLAW_CONFIG_PATH: undefined, + OPENCLAW_STATE_DIR: undefined, + }, + }, + ); + }); + it("does not start hidden authorization flows without an authorization callback", async () => { // Normal agent/tool execution must not open browser auth flows implicitly; // operators use the explicit mcp login command instead. diff --git a/src/agents/mcp-oauth.ts b/src/agents/mcp-oauth.ts index 3e8fcd82e6d2..1307270949c4 100644 --- a/src/agents/mcp-oauth.ts +++ b/src/agents/mcp-oauth.ts @@ -33,6 +33,12 @@ export type McpOAuthCredentialsStatus = { hasLastAuthorizationUrl: boolean; }; +/** Attempt-scoped PKCE facts captured before the login lease is released. */ +export type McpOAuthAuthorizationSession = { + codeVerifier: string; + redirectUrl: string; +}; + const LOCALHOST_REDIRECT_URL = "http://localhost:8989/oauth/callback"; const TOKEN_EXPIRY_SKEW_MS = 30_000; const MCP_OAUTH_LEASE_MS = 60_000; @@ -292,59 +298,45 @@ async function runMcpOAuthLoginAttempt( serverUrl: string; config?: McpOAuthConfig; authorizationCode?: string; + codeVerifier?: string; fetchFn?: FetchLike; - onAuthorizationUrl?: (url: URL) => string | void | Promise; + onAuthorizationUrl?: (url: URL) => void | Promise; + onAuthorizationSession?: (session: McpOAuthAuthorizationSession) => void; resourceMetadataUrl?: URL; scope?: string; forceAuthorization?: boolean; }, lease: OpenClawStateLeaseContext, -): Promise<{ authorizationCode?: string; result: "authorized" | "redirect" }> { - let authorizationCode: string | undefined; - const result = await auth( - createMcpOAuthClientProvider({ - ...params, - onAuthorizationUrl: params.onAuthorizationUrl - ? async (url) => { - authorizationCode = normalizeOptionalString(await params.onAuthorizationUrl?.(url)); - } - : undefined, - allowAuthorizationRedirect: true, - suppressStoredTokens: params.forceAuthorization, - lease, - }), - { - serverUrl: params.serverUrl, - authorizationCode: normalizeOptionalString(params.authorizationCode), - resourceMetadataUrl: params.resourceMetadataUrl, - scope: normalizeOptionalString(params.scope) ?? normalizeOptionalString(params.config?.scope), - fetchFn: withMcpOAuthLeaseSignal(params.fetchFn, lease.signal), - }, - ); - lease.assertOwned(); - return { - ...(authorizationCode ? { authorizationCode } : {}), - result: result === "AUTHORIZED" ? "authorized" : "redirect", - }; -} - -async function exchangeCapturedMcpOAuthCode( - params: Parameters[0], - attempt: Awaited>, - lease: OpenClawStateLeaseContext, ): Promise<"authorized" | "redirect"> { - if (attempt.result !== "redirect" || !attempt.authorizationCode) { - return attempt.result; - } - const exchanged = await runMcpOAuthLoginAttempt( - { - ...params, - authorizationCode: attempt.authorizationCode, - onAuthorizationUrl: undefined, - }, + const provider = createMcpOAuthClientProvider({ + ...params, + allowAuthorizationRedirect: true, + suppressStoredTokens: params.forceAuthorization, lease, - ); - return exchanged.result; + }); + if (params.codeVerifier) { + const codeVerifier = params.codeVerifier; + provider.codeVerifier = () => codeVerifier; + } + const result = await auth(provider, { + serverUrl: params.serverUrl, + authorizationCode: normalizeOptionalString(params.authorizationCode), + resourceMetadataUrl: params.resourceMetadataUrl, + scope: normalizeOptionalString(params.scope) ?? normalizeOptionalString(params.config?.scope), + fetchFn: withMcpOAuthLeaseSignal(params.fetchFn, lease.signal), + }); + lease.assertOwned(); + if (result === "REDIRECT" && params.onAuthorizationSession) { + const redirectUrl = provider.redirectUrl; + if (!redirectUrl) { + throw new Error("Missing MCP OAuth redirect URL after authorization started."); + } + params.onAuthorizationSession({ + codeVerifier: await provider.codeVerifier(), + redirectUrl: String(redirectUrl), + }); + } + return result === "AUTHORIZED" ? "authorized" : "redirect"; } /** Runs both redirect-registration attempts under one OAuth session lease. */ @@ -353,8 +345,10 @@ export async function runMcpOAuthLogin(params: { serverUrl: string; config?: McpOAuthConfig; authorizationCode?: string; + codeVerifier?: string; fetchFn?: FetchLike; - onAuthorizationUrl?: (url: URL) => string | void | Promise; + onAuthorizationUrl?: (url: URL) => void | Promise; + onAuthorizationSession?: (session: McpOAuthAuthorizationSession) => void; }): Promise<"authorized" | "redirect"> { const storeKey = resolveMcpOAuthStoreKey(params.serverName, params.serverUrl); return await withMcpOAuthLease(storeKey, async (lease) => { @@ -372,47 +366,29 @@ export async function runMcpOAuthLogin(params: { scope: normalizeOptionalString(pendingChallenge?.scope), forceAuthorization: pendingChallenge?.requiresAuthorization === true, }; - let effectiveParams = loginParams; - let attempt: Awaited>; try { - attempt = await runMcpOAuthLoginAttempt(loginParams, lease); + return await runMcpOAuthLoginAttempt(loginParams, lease); } catch (error) { if ( !normalizeOptionalString(params.authorizationCode) && !normalizeOptionalString(params.config?.redirectUrl) && isMcpOAuthRedirectRegistrationError(error) ) { - let fallbackRedirectPersisted = false; - const persistFallbackRedirect = () => { - if (fallbackRedirectPersisted) { - return; - } - updateMcpOAuthStore( - storeKey, - (current) => ({ ...current, redirectUrl: LOCALHOST_REDIRECT_URL }), - bindMcpOAuthLeaseAssertion(lease), - ); - fallbackRedirectPersisted = true; - }; - const onAuthorizationUrl = loginParams.onAuthorizationUrl; - effectiveParams = { - ...loginParams, - config: { ...params.config, redirectUrl: LOCALHOST_REDIRECT_URL }, - onAuthorizationUrl: onAuthorizationUrl - ? async (url: URL) => { - // DCR succeeded with localhost. Persist that fact before the - // callback wait so --code recovery uses the same redirect. - persistFallbackRedirect(); - return await onAuthorizationUrl(url); - } - : undefined, - }; - attempt = await runMcpOAuthLoginAttempt(effectiveParams, lease); - persistFallbackRedirect(); - } else { - throw error; + const result = await runMcpOAuthLoginAttempt( + { + ...loginParams, + config: { ...params.config, redirectUrl: LOCALHOST_REDIRECT_URL }, + }, + lease, + ); + updateMcpOAuthStore( + storeKey, + (current) => ({ ...current, redirectUrl: LOCALHOST_REDIRECT_URL }), + bindMcpOAuthLeaseAssertion(lease), + ); + return result; } + throw error; } - return await exchangeCapturedMcpOAuthCode(effectiveParams, attempt, lease); }); } diff --git a/src/cli/mcp-cli.login-loopback.test.ts b/src/cli/mcp-cli.login-loopback.test.ts new file mode 100644 index 000000000000..86704acc546f --- /dev/null +++ b/src/cli/mcp-cli.login-loopback.test.ts @@ -0,0 +1,173 @@ +import fs from "node:fs/promises"; +import { createServer } from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempHome } from "../config/home-env.test-harness.js"; +import { getFreePort } from "../test-utils/ports.js"; +import { registerMcpCli } from "./mcp-cli.js"; + +type CreateSessionMcpRuntime = + typeof import("../agents/agent-bundle-mcp-runtime.js").createSessionMcpRuntime; + +const mocks = vi.hoisted(() => { + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`__exit__:${code}`); + }), + writeJson: vi.fn(), + }; + return { + runtime, + runMcpOAuthLogin: vi.fn(), + readMcpOAuthCredentialsStatus: vi.fn(), + createSessionMcpRuntimeOverride: undefined as CreateSessionMcpRuntime | undefined, + }; +}); + +vi.mock("../runtime.js", () => ({ defaultRuntime: mocks.runtime })); +vi.mock("../mcp/channel-server.js", () => ({ serveOpenClawChannelMcp: vi.fn() })); +vi.mock("../agents/mcp-oauth.js", () => ({ + clearMcpOAuthCredentials: vi.fn(), + readMcpOAuthCredentialsStatus: mocks.readMcpOAuthCredentialsStatus, + runMcpOAuthLogin: mocks.runMcpOAuthLogin, +})); +vi.mock("../agents/agent-bundle-mcp-runtime.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createSessionMcpRuntime: (params: Parameters[0]) => + mocks.createSessionMcpRuntimeOverride?.(params) ?? actual.createSessionMcpRuntime(params), + }; +}); + +const tempDirs: string[] = []; +let program: Command; + +async function createWorkspace(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-mcp-loopback-")); + tempDirs.push(dir); + return dir; +} + +async function waitForLog(text: string): Promise { + await vi.waitFor(() => { + expect(mocks.runtime.log.mock.calls.some(([line]) => String(line).includes(text))).toBe(true); + }); +} + +async function configureServer(): Promise { + vi.spyOn(process, "cwd").mockReturnValue(await createWorkspace()); + await program.parseAsync( + [ + "mcp", + "set", + "docs", + '{"url":"https://mcp.example.com","transport":"streamable-http","auth":"oauth"}', + ], + { from: "user" }, + ); + mocks.runtime.log.mockClear(); +} + +function mockRedirectFlow(redirectUrl: string): void { + mocks.runMcpOAuthLogin.mockImplementation( + async (params: { + authorizationCode?: string; + onAuthorizationUrl?: (url: URL) => void | Promise; + onAuthorizationSession?: (session: { codeVerifier: string; redirectUrl: string }) => void; + }) => { + if (params.authorizationCode) { + return "authorized"; + } + const authorizationUrl = new URL("https://auth.example.com/authorize"); + authorizationUrl.searchParams.set("redirect_uri", redirectUrl); + authorizationUrl.searchParams.set("state", "state-1234567890"); + await params.onAuthorizationUrl?.(authorizationUrl); + params.onAuthorizationSession?.({ codeVerifier: "verifier-123", redirectUrl }); + return "redirect"; + }, + ); +} + +describe("mcp login loopback callback", () => { + beforeEach(() => { + vi.clearAllMocks(); + program = new Command().exitOverride(); + registerMcpCli(program); + mocks.readMcpOAuthCredentialsStatus.mockResolvedValue({ + hasTokens: false, + requiresAuthorization: false, + hasClientInformation: false, + hasCodeVerifier: false, + hasDiscoveryState: false, + hasLastAuthorizationUrl: false, + }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all( + tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + + it("binds the final redirect before printing it and exchanges the captured code", async () => { + await withTempHome("openclaw-cli-mcp-loopback-home-", async () => { + await configureServer(); + const port = await getFreePort(); + const redirectUrl = `http://127.0.0.1:${port}/oauth/callback`; + mockRedirectFlow(redirectUrl); + + const login = program.parseAsync(["mcp", "login", "docs"], { from: "user" }); + await waitForLog("Waiting for the browser"); + const printedUrlIndex = mocks.runtime.log.mock.calls.findIndex(([line]) => + String(line).startsWith("https://auth.example.com/authorize"), + ); + expect(printedUrlIndex).toBeGreaterThanOrEqual(0); + + const wrong = await fetch(`${redirectUrl}?code=wrong&state=wrong`); + expect(wrong.status).toBe(400); + expect(mocks.runMcpOAuthLogin).toHaveBeenCalledOnce(); + + const response = await fetch(`${redirectUrl}?code=right&state=state-1234567890`); + expect(response.status).toBe(200); + await expect(response.text()).resolves.toContain("Authorization received"); + await login; + + expect(mocks.runMcpOAuthLogin).toHaveBeenCalledTimes(2); + expect(mocks.runMcpOAuthLogin).toHaveBeenLastCalledWith( + expect.objectContaining({ authorizationCode: "right" }), + ); + expect(mocks.runtime.log).toHaveBeenCalledWith('MCP OAuth credentials saved for "docs".'); + }); + }); + + it("falls back immediately to the printed manual command when binding fails", async () => { + await withTempHome("openclaw-cli-mcp-loopback-home-", async () => { + await configureServer(); + const blocker = createServer(); + await new Promise((resolve) => { + blocker.listen(0, "127.0.0.1", resolve); + }); + const address = blocker.address(); + const port = typeof address === "object" && address ? address.port : 0; + mockRedirectFlow(`http://127.0.0.1:${port}/oauth/callback`); + + await program.parseAsync(["mcp", "login", "docs"], { from: "user" }); + expect( + mocks.runtime.log.mock.calls.some(([line]) => String(line).includes("Could not start")), + ).toBe(true); + expect(mocks.runtime.log.mock.calls.some(([line]) => String(line).includes("--code"))).toBe( + true, + ); + expect(mocks.runMcpOAuthLogin).toHaveBeenCalledOnce(); + await new Promise((resolve) => { + blocker.close(() => resolve()); + }); + }); + }); +}); diff --git a/src/cli/mcp-cli.oauth-integration.test.ts b/src/cli/mcp-cli.oauth-integration.test.ts new file mode 100644 index 000000000000..ea38024fd836 --- /dev/null +++ b/src/cli/mcp-cli.oauth-integration.test.ts @@ -0,0 +1,183 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { createServer } from "node:http"; +import { Command } from "commander"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readMcpOAuthCredentialsStatus } from "../agents/mcp-oauth.js"; +import { withTempHome } from "../config/home-env.test-harness.js"; +import { defaultRuntime } from "../runtime.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { getFreePort } from "../test-utils/ports.js"; +import { registerMcpCli } from "./mcp-cli.js"; + +function sendJson(response: ServerResponse, body: unknown, status = 200): void { + response.writeHead(status, { "Content-Type": "application/json" }); + response.end(JSON.stringify(body)); +} + +async function readBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} + +async function startOAuthFixture(port: number) { + const issuer = `http://127.0.0.1:${port}`; + let codeChallenge: string | undefined; + let tokenRedirectUri: string | undefined; + let tokenVerifier: string | undefined; + const handleRequest = async (request: IncomingMessage, response: ServerResponse) => { + const url = new URL(request.url ?? "/", issuer); + if (url.pathname.startsWith("/.well-known/oauth-protected-resource")) { + sendJson(response, { resource: `${issuer}/mcp`, authorization_servers: [issuer] }); + return; + } + if (url.pathname === "/.well-known/oauth-authorization-server") { + sendJson(response, { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + registration_endpoint: `${issuer}/register`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + token_endpoint_auth_methods_supported: ["none"], + code_challenge_methods_supported: ["S256"], + }); + return; + } + if (url.pathname === "/register" && request.method === "POST") { + const metadata = JSON.parse(await readBody(request)) as { redirect_uris?: string[] }; + sendJson( + response, + { + ...metadata, + client_id: "fixture-client", + client_id_issued_at: Math.floor(Date.now() / 1000), + }, + 201, + ); + return; + } + if (url.pathname === "/authorize") { + const redirectUri = url.searchParams.get("redirect_uri"); + const state = url.searchParams.get("state"); + codeChallenge = url.searchParams.get("code_challenge") ?? undefined; + if (!redirectUri || !state || !codeChallenge) { + sendJson(response, { error: "invalid_request" }, 400); + return; + } + const callback = new URL(redirectUri); + callback.searchParams.set("code", "fixture-code"); + callback.searchParams.set("state", state); + response.writeHead(302, { Location: callback.toString() }); + response.end(); + return; + } + if (url.pathname === "/token" && request.method === "POST") { + const form = new URLSearchParams(await readBody(request)); + tokenRedirectUri = form.get("redirect_uri") ?? undefined; + tokenVerifier = form.get("code_verifier") ?? undefined; + const challenge = tokenVerifier + ? createHash("sha256").update(tokenVerifier).digest("base64url") + : undefined; + if (form.get("code") !== "fixture-code" || challenge !== codeChallenge) { + sendJson(response, { error: "invalid_grant" }, 400); + return; + } + sendJson(response, { + access_token: "fixture-access-token", + refresh_token: "fixture-refresh-token", + token_type: "Bearer", + expires_in: 3600, + }); + return; + } + response.writeHead(404).end(); + }; + const server = createServer((request, response) => { + void handleRequest(request, response).catch((error: unknown) => { + response.destroy(error instanceof Error ? error : new Error("OAuth fixture failed")); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", resolve); + }); + return { + issuer, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + exchange: () => ({ tokenRedirectUri, tokenVerifier }), + }; +} + +afterEach(() => { + vi.restoreAllMocks(); + closeOpenClawStateDatabaseForTest(); +}); + +describe("mcp login OAuth integration", () => { + it("captures the browser callback, persists tokens, and closes the port", async () => { + await withTempHome(`openclaw-mcp-login-${randomUUID()}-`, async () => { + const oauthPort = await getFreePort(); + const callbackPort = await getFreePort(); + const fixture = await startOAuthFixture(oauthPort); + const redirectUrl = `http://127.0.0.1:${callbackPort}/oauth/callback`; + const logs: string[] = []; + vi.spyOn(defaultRuntime, "log").mockImplementation((line) => logs.push(String(line))); + const program = new Command().exitOverride(); + registerMcpCli(program); + try { + await program.parseAsync( + [ + "mcp", + "set", + "fixture", + JSON.stringify({ + url: `${fixture.issuer}/mcp`, + transport: "streamable-http", + auth: "oauth", + oauth: { redirectUrl }, + }), + ], + { from: "user" }, + ); + logs.length = 0; + + const login = program.parseAsync(["mcp", "login", "fixture"], { from: "user" }); + await vi.waitFor(() => { + expect(logs.some((line) => line.includes("Waiting for the browser"))).toBe(true); + }); + const authorizationUrl = logs.find((line) => + line.startsWith(`${fixture.issuer}/authorize`), + ); + expect(authorizationUrl).toBeDefined(); + const browserResponse = await fetch(authorizationUrl!); + expect(browserResponse.status).toBe(200); + await expect(browserResponse.text()).resolves.toContain("Authorization received"); + await login; + + await expect( + readMcpOAuthCredentialsStatus({ + serverName: "fixture", + serverUrl: `${fixture.issuer}/mcp`, + }), + ).resolves.toMatchObject({ hasTokens: true, hasCodeVerifier: true }); + expect(fixture.exchange()).toMatchObject({ + tokenRedirectUri: redirectUrl, + tokenVerifier: expect.any(String), + }); + expect(logs).toContain('MCP OAuth credentials saved for "fixture".'); + await vi.waitFor(async () => { + await expect(fetch(redirectUrl)).rejects.toThrow(); + }); + } finally { + await fixture.close(); + } + }); + }); +}); diff --git a/src/cli/mcp-cli.oauth.test.ts b/src/cli/mcp-cli.oauth.test.ts index 843fe5393442..f0a37fdb4288 100644 --- a/src/cli/mcp-cli.oauth.test.ts +++ b/src/cli/mcp-cli.oauth.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as mcpHttpFetch from "../agents/mcp-http-fetch.js"; import { withTempHome } from "../config/home-env.test-harness.js"; -import { getFreePort } from "../test-utils/ports.js"; import { cleanupMcpCliTestState, clearMcpOAuthCredentials, @@ -15,8 +14,6 @@ import { runMcpOAuthLogin, } from "./mcp-cli.test-harness.js"; -type RunMcpOAuthLogin = typeof import("../agents/mcp-oauth.js").runMcpOAuthLogin; - describe("mcp cli OAuth", () => { beforeEach(() => { resetMcpCliTestState(); @@ -150,7 +147,6 @@ describe("mcp cli OAuth", () => { config: undefined, fetchFn: expect.any(Function), authorizationCode: "abc123", - onAuthorizationUrl: expect.any(Function), }); mockLog.mockClear(); @@ -165,50 +161,6 @@ describe("mcp cli OAuth", () => { }); }); - it("captures the browser redirect before completing OAuth login", async () => { - await withTempHome("openclaw-cli-mcp-home-", async () => { - const workspaceDir = await createWorkspace(); - const port = await getFreePort(); - const redirectUrl = `http://127.0.0.1:${port}/oauth/callback`; - const authUrl = new URL("https://auth.example.com/authorize"); - authUrl.searchParams.set("state", "state-1"); - authUrl.searchParams.set("redirect_uri", redirectUrl); - vi.spyOn(process, "cwd").mockReturnValue(workspaceDir); - - await runMcpCommand([ - "mcp", - "set", - "docs", - JSON.stringify({ - url: "https://mcp.example.com", - transport: "streamable-http", - auth: "oauth", - oauth: { redirectUrl }, - }), - ]); - mockLog.mockClear(); - let capturedCode: string | undefined; - runMcpOAuthLogin.mockImplementationOnce(async (params: Parameters[0]) => { - const code = await params.onAuthorizationUrl?.(authUrl); - capturedCode = typeof code === "string" ? code : undefined; - return "authorized"; - }); - - const login = runMcpCommand(["mcp", "login", "docs"]); - await vi.waitFor(() => expect(mockLog).toHaveBeenCalledWith(authUrl.toString())); - const response = await fetch(`${redirectUrl}?code=browser-code&state=state-1`); - expect(response.status).toBe(200); - await expect(response.text()).resolves.toContain("MCP OAuth complete"); - await login; - - expect(capturedCode).toBe("browser-code"); - expect(lastLogLine()).toBe('MCP OAuth credentials saved for "docs".'); - expect(mockLog).toHaveBeenCalledWith( - "If the browser redirect cannot reach this machine, stop this command and run openclaw mcp login docs --code .", - ); - }); - }); - it("clears stored OAuth credentials on logout", async () => { await withTempHome("openclaw-cli-mcp-home-", async () => { const workspaceDir = await createWorkspace(); diff --git a/src/cli/mcp-cli.ts b/src/cli/mcp-cli.ts index abe274b50a2b..9cf793f21310 100644 --- a/src/cli/mcp-cli.ts +++ b/src/cli/mcp-cli.ts @@ -21,6 +21,7 @@ import { clearMcpOAuthCredentials, readMcpOAuthCredentialsStatus, runMcpOAuthLogin, + type McpOAuthAuthorizationSession, type McpOAuthCredentialsStatus, } from "../agents/mcp-oauth.js"; import { resolveMcpTransportConfig } from "../agents/mcp-transport-config.js"; @@ -34,12 +35,15 @@ import { } from "../config/mcp-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { + startOAuthLoopbackCallbackServer, + type OAuthLoopbackCallbackServer, +} from "../infra/oauth-loopback-callback.js"; import { serveOpenClawChannelMcp } from "../mcp/channel-server.js"; import { defaultRuntime } from "../runtime.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; import { formatCliCommand } from "./command-format.js"; import { resolveGatewayAuthOptions } from "./gateway-secret-options.js"; -import { waitForMcpOAuthAuthorizationCode } from "./mcp-oauth-loopback.js"; import { requestExitAfterOneShotOutput } from "./one-shot-exit.js"; import { applyParentDefaultHelpAction } from "./program/parent-default-help.js"; @@ -53,6 +57,8 @@ function printJson(value: unknown): void { defaultRuntime.writeJson(value); } +const MCP_OAUTH_CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; + function parseCsvList(value: string | undefined): string[] | undefined { if (!value) { return undefined; @@ -1333,11 +1339,10 @@ export function registerMcpCli(program: Command) { if (!resolved || resolved.kind !== "http") { fail(`MCP server "${name}" needs a valid HTTP transport for OAuth login.`); } - const result = await runMcpOAuthLogin({ + const loginParams = { serverName: name, serverUrl: resolved.url, config: server.oauth as Record | undefined, - authorizationCode: opts.code, fetchFn: withSameOriginMcpHttpHeaders({ fetchFn: buildMcpHttpFetch({ sslVerify: resolved.sslVerify, @@ -1349,25 +1354,88 @@ export function registerMcpCli(program: Command) { headers: withoutMcpAuthorizationHeader(resolved.headers), resourceUrl: resolved.url, }), - onAuthorizationUrl: async (url) => { - const manualFallbackCommand = formatCliCommand( - `openclaw mcp login ${name} --code `, - ); - return await waitForMcpOAuthAuthorizationCode({ - authorizationUrl: url, - manualFallbackCommand, - onReady: () => { - defaultRuntime.log(`Open this URL to authorize "${name}":`); - defaultRuntime.log(url.toString()); + }; + if (opts.code) { + const result = await runMcpOAuthLogin({ + ...loginParams, + authorizationCode: opts.code, + }); + if (result === "authorized") { + defaultRuntime.log(`MCP OAuth credentials saved for "${name}".`); + } + return; + } + + let callbackServer: OAuthLoopbackCallbackServer | undefined; + let authorizationSession: McpOAuthAuthorizationSession | undefined; + const manualCommand = formatCliCommand(`openclaw mcp login ${name} --code `); + try { + const result = await runMcpOAuthLogin({ + ...loginParams, + onAuthorizationSession: (session) => { + authorizationSession = session; + }, + onAuthorizationUrl: async (url) => { + const redirectValue = url.searchParams.get("redirect_uri"); + const expectedState = url.searchParams.get("state"); + if (redirectValue && expectedState && expectedState.length >= 16) { + try { + callbackServer = await startOAuthLoopbackCallbackServer({ + redirectUrl: redirectValue, + expectedState, + timeoutMs: MCP_OAUTH_CALLBACK_TIMEOUT_MS, + }); + } catch (error) { + defaultRuntime.log( + `Could not start the local OAuth callback (${formatErrorMessage(error)}).`, + ); + } + } + defaultRuntime.log(`Open this URL to authorize "${name}":`); + defaultRuntime.log(url.toString()); + if (callbackServer) { + defaultRuntime.log("Waiting for the browser to return to OpenClaw..."); defaultRuntime.log( - `If the browser redirect cannot reach this machine, stop this command and run ${manualFallbackCommand}.`, + `If the callback cannot reach this terminal, run ${manualCommand}.`, ); - }, - }); - }, - }); - if (result === "authorized") { + } else { + defaultRuntime.log(`After approval, run ${manualCommand}.`); + } + }, + }); + if (result === "authorized") { + defaultRuntime.log(`MCP OAuth credentials saved for "${name}".`); + return; + } + if (!callbackServer) { + return; + } + + let callback; + try { + callback = await callbackServer.waitForCallback(); + } catch (error) { + fail(`${formatErrorMessage(error)}. Complete login manually with ${manualCommand}.`); + } + if (callback.type === "oauth_error") { + fail(`OAuth authorization did not complete. Retry login or use ${manualCommand}.`); + } + const session = authorizationSession; + if (!session) { + fail(`OAuth login state was not preserved. Retry login or use ${manualCommand}.`); + } + const exchangeResult = await runMcpOAuthLogin({ + ...loginParams, + config: { ...loginParams.config, redirectUrl: session.redirectUrl }, + authorizationCode: callback.code, + codeVerifier: session.codeVerifier, + }); + if (exchangeResult !== "authorized") { + fail(`OAuth login did not complete. Retry login or use ${manualCommand}.`); + } defaultRuntime.log(`MCP OAuth credentials saved for "${name}".`); + } finally { + await callbackServer?.close(); } }); diff --git a/src/cli/mcp-oauth-loopback.test.ts b/src/cli/mcp-oauth-loopback.test.ts deleted file mode 100644 index 85853986ad86..000000000000 --- a/src/cli/mcp-oauth-loopback.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; -import { describe, expect, it, vi } from "vitest"; -import { getFreePort, isPortFree } from "../test-utils/ports.js"; -import { waitForMcpOAuthAuthorizationCode } from "./mcp-oauth-loopback.js"; - -function authorizationUrl(params: { port: number; state?: string; redirectHost?: string }): URL { - const url = new URL("https://auth.example.com/authorize"); - url.searchParams.set("state", params.state ?? "state-1"); - url.searchParams.set( - "redirect_uri", - `http://${params.redirectHost ?? "127.0.0.1"}:${params.port}/oauth/callback`, - ); - return url; -} - -async function getFreeIpv6Port(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "::1", resolve); - }); - const port = (server.address() as AddressInfo).port; - await new Promise((resolve) => { - server.close(() => resolve()); - }); - return port; -} - -describe("MCP OAuth loopback callback", () => { - it("listens before announcing the URL and captures a real callback", async () => { - const port = await getFreePort(); - let announceReady: (() => void) | undefined; - const ready = new Promise((resolve) => { - announceReady = resolve; - }); - const onReady = vi.fn(() => announceReady?.()); - const callback = waitForMcpOAuthAuthorizationCode({ - authorizationUrl: authorizationUrl({ port }), - manualFallbackCommand: "openclaw mcp login docs --code ", - onReady, - timeoutMs: 5_000, - }); - await ready; - expect(onReady).toHaveBeenCalledOnce(); - - const deniedPreflight = await fetch(`http://127.0.0.1:${port}/oauth/callback`, { - method: "OPTIONS", - headers: { - Origin: "https://attacker.example", - "Access-Control-Request-Method": "GET", - }, - }); - expect(deniedPreflight.status).toBe(204); - expect(deniedPreflight.headers.get("access-control-allow-origin")).toBeNull(); - - const allowedPreflight = await fetch(`http://127.0.0.1:${port}/oauth/callback`, { - method: "OPTIONS", - headers: { - Origin: "https://auth.example.com", - "Access-Control-Request-Method": "GET", - }, - }); - expect(allowedPreflight.headers.get("access-control-allow-origin")).toBe( - "https://auth.example.com", - ); - - const response = await fetch( - `http://127.0.0.1:${port}/oauth/callback?code=captured-code&state=state-1`, - ); - expect(response.status).toBe(200); - expect(response.headers.get("connection")).toBe("close"); - await expect(response.text()).resolves.toContain("MCP OAuth complete"); - await expect(callback).resolves.toBe("captured-code"); - await vi.waitFor(async () => expect(await isPortFree(port)).toBe(true)); - }); - - it("rejects a state mismatch before accepting an OAuth error", async () => { - const port = await getFreePort(); - let announceReady: (() => void) | undefined; - const ready = new Promise((resolve) => { - announceReady = resolve; - }); - const callback = waitForMcpOAuthAuthorizationCode({ - authorizationUrl: authorizationUrl({ port }), - manualFallbackCommand: "openclaw mcp login docs --code ", - onReady: () => announceReady?.(), - timeoutMs: 5_000, - }); - const callbackRejection = expect(callback).rejects.toThrow("state did not match"); - - await ready; - const response = await fetch( - `http://127.0.0.1:${port}/oauth/callback?error=access_denied&state=wrong-state`, - ); - expect(response.status).toBe(400); - await expect(response.text()).resolves.toBe("Invalid state"); - await callbackRejection; - await vi.waitFor(async () => expect(await isPortFree(port)).toBe(true)); - }); - - it("captures callbacks on a bracketed IPv6 loopback redirect", async () => { - const port = await getFreeIpv6Port(); - let announceReady: (() => void) | undefined; - const ready = new Promise((resolve) => { - announceReady = resolve; - }); - const callback = waitForMcpOAuthAuthorizationCode({ - authorizationUrl: authorizationUrl({ port, redirectHost: "[::1]" }), - manualFallbackCommand: "openclaw mcp login docs --code ", - onReady: () => announceReady?.(), - timeoutMs: 5_000, - }); - - await ready; - const response = await fetch( - `http://[::1]:${port}/oauth/callback?code=ipv6-code&state=state-1`, - ); - expect(response.status).toBe(200); - await expect(callback).resolves.toBe("ipv6-code"); - }); - - it("names a busy callback port and preserves the manual fallback", async () => { - const blocker = createServer((_request, response) => response.end("busy")); - await new Promise((resolve, reject) => { - blocker.once("error", reject); - blocker.listen(0, "127.0.0.1", resolve); - }); - const port = (blocker.address() as AddressInfo).port; - const onReady = vi.fn(); - try { - await expect( - waitForMcpOAuthAuthorizationCode({ - authorizationUrl: authorizationUrl({ port }), - manualFallbackCommand: "openclaw mcp login docs --code ", - onReady, - timeoutMs: 5_000, - }), - ).rejects.toThrow( - `MCP OAuth callback port ${port} is already in use. Complete approval in the browser, then run openclaw mcp login docs --code .`, - ); - expect(onReady).toHaveBeenCalledOnce(); - } finally { - await new Promise((resolve) => { - blocker.close(() => resolve()); - }); - } - }); - - it("times out and releases the callback port", async () => { - const port = await getFreePort(); - const onReady = vi.fn(); - await expect( - waitForMcpOAuthAuthorizationCode({ - authorizationUrl: authorizationUrl({ port }), - manualFallbackCommand: "openclaw mcp login docs --code ", - onReady, - timeoutMs: 20, - }), - ).rejects.toThrow( - `Timed out waiting for the MCP OAuth redirect on port ${port}. Complete approval in the browser, then run openclaw mcp login docs --code .`, - ); - expect(onReady).toHaveBeenCalledOnce(); - await vi.waitFor(async () => expect(await isPortFree(port)).toBe(true)); - }); - - it("leaves non-loopback redirects on the manual code path", async () => { - const url = new URL("https://auth.example.com/authorize"); - url.searchParams.set("state", "state-1"); - url.searchParams.set("redirect_uri", "https://app.example.com/oauth/callback"); - const onReady = vi.fn(); - - await expect( - waitForMcpOAuthAuthorizationCode({ - authorizationUrl: url, - manualFallbackCommand: "openclaw mcp login docs --code ", - onReady, - }), - ).resolves.toBeUndefined(); - expect(onReady).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/cli/mcp-oauth-loopback.ts b/src/cli/mcp-oauth-loopback.ts deleted file mode 100644 index fd2080664abc..000000000000 --- a/src/cli/mcp-oauth-loopback.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Local callback capture for the interactive MCP OAuth CLI flow. -import { waitForLocalOAuthCallback } from "../plugin-sdk/provider-auth-runtime.js"; - -const MCP_OAUTH_CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; -const MCP_OAUTH_LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); - -type McpOAuthLoopbackTarget = { - callbackPath: string; - hostname: string; - port: number; - redirectUri: string; - state: string; -}; - -function resolveMcpOAuthLoopbackTarget(authorizationUrl: URL): McpOAuthLoopbackTarget | undefined { - const state = authorizationUrl.searchParams.get("state")?.trim(); - const redirectUri = authorizationUrl.searchParams.get("redirect_uri")?.trim(); - if (!state || !redirectUri) { - return undefined; - } - - let redirect: URL; - try { - redirect = new URL(redirectUri); - } catch { - return undefined; - } - const hostname = redirect.hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase(); - if ( - redirect.protocol !== "http:" || - redirect.username || - redirect.password || - !MCP_OAUTH_LOOPBACK_HOSTS.has(hostname) - ) { - return undefined; - } - const port = redirect.port ? Number(redirect.port) : 80; - if (!Number.isInteger(port) || port <= 0 || port > 65_535) { - return undefined; - } - return { - callbackPath: redirect.pathname || "/", - hostname, - port, - redirectUri: redirect.toString(), - state, - }; -} - -function formatMcpOAuthCallbackError( - error: unknown, - target: McpOAuthLoopbackTarget, - manualFallbackCommand: string, -): Error { - const code = (error as NodeJS.ErrnoException | undefined)?.code; - const message = error instanceof Error ? error.message : String(error); - if (code === "EADDRINUSE") { - return new Error( - `MCP OAuth callback port ${target.port} is already in use. Complete approval in the browser, then run ${manualFallbackCommand}.`, - { cause: error }, - ); - } - if (/timeout/i.test(message)) { - return new Error( - `Timed out waiting for the MCP OAuth redirect on port ${target.port}. Complete approval in the browser, then run ${manualFallbackCommand}.`, - { cause: error }, - ); - } - if (/state mismatch/i.test(message)) { - return new Error( - `Rejected the MCP OAuth redirect because its state did not match. Restart login, or complete approval and run ${manualFallbackCommand}.`, - { cause: error }, - ); - } - return new Error( - `MCP OAuth callback failed on port ${target.port}: ${message}. Complete approval in the browser, then run ${manualFallbackCommand}.`, - { cause: error }, - ); -} - -/** Capture a loopback OAuth callback, or leave custom redirects on the manual path. */ -export async function waitForMcpOAuthAuthorizationCode(params: { - authorizationUrl: URL; - manualFallbackCommand: string; - onReady: () => void; - timeoutMs?: number; -}): Promise { - const target = resolveMcpOAuthLoopbackTarget(params.authorizationUrl); - if (!target) { - params.onReady(); - return undefined; - } - - const controller = new AbortController(); - let markListening: (() => void) | undefined; - const listening = new Promise((resolve) => { - markListening = resolve; - }); - const callback = waitForLocalOAuthCallback({ - expectedState: target.state, - timeoutMs: params.timeoutMs ?? MCP_OAUTH_CALLBACK_TIMEOUT_MS, - port: target.port, - callbackPath: target.callbackPath, - redirectUri: target.redirectUri, - hostname: target.hostname, - successTitle: "MCP OAuth complete", - corsOriginAllowlist: [params.authorizationUrl.host], - signal: controller.signal, - onProgress: () => markListening?.(), - }); - - try { - const startupError = await Promise.race([ - listening.then(() => undefined), - callback.then( - () => undefined, - (error: unknown) => error, - ), - ]); - // Even a failed listener leaves the authorization URL usable with --code. - params.onReady(); - if (startupError !== undefined) { - throw formatMcpOAuthCallbackError(startupError, target, params.manualFallbackCommand); - } - try { - return (await callback).code; - } catch (error) { - throw formatMcpOAuthCallbackError(error, target, params.manualFallbackCommand); - } - } finally { - controller.abort(); - await callback.catch(() => undefined); - } -} diff --git a/src/infra/oauth-loopback-callback.test.ts b/src/infra/oauth-loopback-callback.test.ts new file mode 100644 index 000000000000..80a68d2e47bb --- /dev/null +++ b/src/infra/oauth-loopback-callback.test.ts @@ -0,0 +1,286 @@ +import type { LookupAddress } from "node:dns"; +import * as dnsPromises from "node:dns/promises"; +import type { Server } from "node:http"; +import { createServer } from "node:net"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getFreePort } from "../test-utils/ports.js"; +import { + startOAuthLoopbackCallbackServer, + type OAuthLoopbackCallbackServer, +} from "./oauth-loopback-callback.js"; + +const openCallbacks: OAuthLoopbackCallbackServer[] = []; + +afterEach(async () => { + await Promise.all(openCallbacks.splice(0).map((callback) => callback.close())); + vi.restoreAllMocks(); +}); + +function callbackUrl(hostname: string, port: number, query = ""): string { + const host = hostname.includes(":") ? `[${hostname}]` : hostname; + return `http://${host}:${port}/oauth/callback${query}`; +} + +async function getFreeIpv6Port(): Promise { + const probe = createServer(); + try { + await new Promise((resolve, reject) => { + probe.once("error", reject); + probe.listen(0, "::1", resolve); + }); + const address = probe.address(); + return typeof address === "object" && address ? address.port : undefined; + } catch { + return undefined; + } finally { + await new Promise((resolve) => { + probe.close(() => resolve()); + }); + } +} + +async function start(hostname = "127.0.0.1") { + const port = hostname === "::1" ? await getFreeIpv6Port() : await getFreePort(); + if (!port) { + return undefined; + } + const callback = await startOAuthLoopbackCallbackServer({ + redirectUrl: callbackUrl(hostname, port), + expectedState: "state-1234567890", + timeoutMs: 5_000, + }); + openCallbacks.push(callback); + return { callback, port }; +} + +describe("OAuth loopback callback server", () => { + it("is listening before start resolves, returns the full response, then closes", async () => { + const started = await start(); + if (!started) { + throw new Error("IPv4 loopback unavailable"); + } + const responsePromise = fetch( + callbackUrl("127.0.0.1", started.port, "?code=authorization-code&state=state-1234567890"), + ).then(async (response) => ({ + status: response.status, + body: await response.text(), + headers: response.headers, + })); + + await expect(started.callback.waitForCallback()).resolves.toEqual({ + type: "authorization_code", + code: "authorization-code", + state: "state-1234567890", + }); + const response = await responsePromise; + expect(response.status).toBe(200); + expect(response.body).toContain("Authorization received"); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("referrer-policy")).toBe("no-referrer"); + + await vi.waitFor(async () => { + await expect(fetch(callbackUrl("127.0.0.1", started.port))).rejects.toThrow(); + }); + }); + + it("keeps waiting after wrong path, method, missing state, and wrong state", async () => { + const started = await start(); + if (!started) { + throw new Error("IPv4 loopback unavailable"); + } + const base = callbackUrl("127.0.0.1", started.port); + expect((await fetch(`http://127.0.0.1:${started.port}/wrong`)).status).toBe(404); + expect((await fetch(base, { method: "POST" })).status).toBe(405); + expect((await fetch(`${base}?code=code`)).status).toBe(400); + expect((await fetch(`${base}?code=code&state=wrong`)).status).toBe(400); + + const response = await fetch(`${base}?code=right&state=state-1234567890`); + expect(response.status).toBe(200); + await response.text(); + await expect(started.callback.waitForCallback()).resolves.toMatchObject({ + type: "authorization_code", + code: "right", + }); + }); + + it("settles a matching-state OAuth error after flushing its response", async () => { + const started = await start(); + if (!started) { + throw new Error("IPv4 loopback unavailable"); + } + const responsePromise = fetch( + callbackUrl( + "127.0.0.1", + started.port, + "?error=access_denied&error_description=nope&state=state-1234567890", + ), + ).then(async (response) => ({ status: response.status, body: await response.text() })); + + await expect(started.callback.waitForCallback()).resolves.toEqual({ + type: "oauth_error", + error: "access_denied", + errorDescription: "nope", + }); + await expect(responsePromise).resolves.toEqual({ + status: 400, + body: "Authorization was not completed.", + }); + }); + + it("accepts only one concurrent valid callback", async () => { + const started = await start(); + if (!started) { + throw new Error("IPv4 loopback unavailable"); + } + const url = callbackUrl("127.0.0.1", started.port, "?code=only-code&state=state-1234567890"); + const responses = await Promise.allSettled([fetch(url), fetch(url)]); + const statuses = responses.flatMap((result) => + result.status === "fulfilled" ? [result.value.status] : [], + ); + expect(statuses.filter((status) => status === 200)).toHaveLength(1); + await expect(started.callback.waitForCallback()).resolves.toMatchObject({ code: "only-code" }); + }); + + it("rejects on timeout and abort and closes the listener", async () => { + const timedOut = await start(); + if (!timedOut) { + throw new Error("IPv4 loopback unavailable"); + } + await timedOut.callback.close(); + await expect(timedOut.callback.waitForCallback()).rejects.toThrow("cancelled"); + + const port = await getFreePort(); + const controller = new AbortController(); + const callback = await startOAuthLoopbackCallbackServer({ + redirectUrl: callbackUrl("127.0.0.1", port), + expectedState: "state-1234567890", + timeoutMs: 30, + signal: controller.signal, + }); + openCallbacks.push(callback); + await expect(callback.waitForCallback()).rejects.toThrow("timeout"); + + const abortPort = await getFreePort(); + const abortController = new AbortController(); + const aborted = await startOAuthLoopbackCallbackServer({ + redirectUrl: callbackUrl("127.0.0.1", abortPort), + expectedState: "state-1234567890", + timeoutMs: 5_000, + signal: abortController.signal, + }); + openCallbacks.push(aborted); + abortController.abort(); + await expect(aborted.waitForCallback()).rejects.toThrow("cancelled"); + }); + + it("observes aborts that arrive while localhost resolution is pending", async () => { + let releaseLookup!: () => void; + const pendingLookup = new Promise((resolve) => { + releaseLookup = () => resolve([{ address: "127.0.0.1", family: 4 }]); + }); + const controller = new AbortController(); + const port = await getFreePort(); + const startPromise = startOAuthLoopbackCallbackServer({ + redirectUrl: `http://localhost:${port}/oauth/callback`, + expectedState: "state-1234567890", + timeoutMs: 5_000, + signal: controller.signal, + lookup: () => pendingLookup, + }); + controller.abort(); + await expect(startPromise).rejects.toThrow("cancelled"); + releaseLookup(); + }); + + it("validates localhost resolution even with an explicit IPv4 bind host", async () => { + await expect( + startOAuthLoopbackCallbackServer({ + redirectUrl: "http://localhost:8989/oauth/callback", + bindHostname: "127.0.0.1", + expectedState: "state-1234567890", + timeoutMs: 5_000, + lookup: async () => [{ address: "203.0.113.1", family: 4 }], + }), + ).rejects.toThrow("exclusively to loopback"); + }); + + it("binds every loopback address resolved for localhost", async () => { + const port = await getFreePort(); + const addresses = [ + ...new Set( + (await dnsPromises.lookup("localhost", { all: true, verbatim: true })).map( + (entry) => entry.address, + ), + ), + ]; + const callback = await startOAuthLoopbackCallbackServer({ + redirectUrl: `http://localhost:${port}/oauth/callback`, + bindHostname: "127.0.0.1", + expectedState: "state-1234567890", + timeoutMs: 5_000, + }); + openCallbacks.push(callback); + + for (const address of addresses) { + const response = await fetch(callbackUrl(address, port, "?code=bad&state=wrong")); + expect(response.status).toBe(400); + } + const response = await fetch( + callbackUrl(addresses[0]!, port, "?code=right&state=state-1234567890"), + ); + expect(response.status).toBe(200); + await response.text(); + await expect(callback.waitForCallback()).resolves.toMatchObject({ code: "right" }); + }); + + it("supports an IPv6 loopback redirect when IPv6 is available", async () => { + const started = await start("::1"); + if (!started) { + return; + } + const response = await fetch( + callbackUrl("::1", started.port, "?code=ipv6&state=state-1234567890"), + ); + expect(response.status).toBe(200); + await response.text(); + await expect(started.callback.waitForCallback()).resolves.toMatchObject({ code: "ipv6" }); + }); + + it("uses HTTP port 80 when the redirect omits a port and rejects port zero", async () => { + let observedPort: number | undefined; + const fakeServer = { + listening: false, + once: () => fakeServer, + listen: (port: number, _hostname: string, callback: () => void) => { + observedPort = port; + fakeServer.listening = true; + callback(); + return fakeServer; + }, + removeAllListeners: () => fakeServer, + on: () => fakeServer, + close: (callback: () => void) => { + fakeServer.listening = false; + callback(); + return fakeServer; + }, + closeAllConnections: () => undefined, + }; + const callback = await startOAuthLoopbackCallbackServer({ + redirectUrl: "http://127.0.0.1/oauth/callback", + expectedState: "state-1234567890", + timeoutMs: 5_000, + createServer: (() => + fakeServer as unknown as Server) as typeof import("node:http").createServer, + }); + expect(observedPort).toBe(80); + await callback.close(); + await expect( + startOAuthLoopbackCallbackServer({ + redirectUrl: "http://127.0.0.1:0/oauth/callback", + expectedState: "state-1234567890", + timeoutMs: 5_000, + }), + ).rejects.toThrow("valid TCP port"); + }); +}); diff --git a/src/infra/oauth-loopback-callback.ts b/src/infra/oauth-loopback-callback.ts new file mode 100644 index 000000000000..ece0b4a7b99c --- /dev/null +++ b/src/infra/oauth-loopback-callback.ts @@ -0,0 +1,327 @@ +import type { LookupAddress } from "node:dns"; +import type { IncomingMessage, Server, ServerResponse } from "node:http"; + +type OAuthLoopbackCallbackResult = + | { type: "authorization_code"; code: string; state: string } + | { type: "oauth_error"; error: string; errorDescription?: string }; + +export type OAuthLoopbackCallbackServer = { + waitForCallback: () => Promise; + close: () => Promise; +}; + +type RenderedResponse = { body: string; contentType: string }; +type CorsOriginResolver = (originHeader: string | string[] | undefined) => string | undefined; +type LoopbackLookup = ( + hostname: string, + options: { all: true; verbatim: true }, +) => Promise; + +function unbracket(hostname: string): string { + return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; +} + +function isLoopbackAddress(address: string): boolean { + if (address === "::1") { + return true; + } + const octets = address.split(".").map(Number); + return ( + octets.length === 4 && octets[0] === 127 && octets.every((octet) => octet >= 0 && octet <= 255) + ); +} + +function resolveLoopbackHostname( + hostname: string, + lookupOverride?: LoopbackLookup, +): string[] | Promise { + if (hostname === "127.0.0.1" || hostname === "::1") { + return [hostname]; + } + if (hostname !== "localhost") { + throw new Error("OAuth callback redirect must use localhost, 127.0.0.1, or ::1"); + } + const loadLookup: Promise = lookupOverride + ? Promise.resolve(lookupOverride) + : import("node:dns/promises").then(({ lookup }) => lookup as LoopbackLookup); + return loadLookup.then(async (lookup) => { + const addresses = [ + ...new Set( + (await lookup("localhost", { all: true, verbatim: true })).map(({ address }) => address), + ), + ]; + if (addresses.length === 0 || addresses.some((address) => !isLoopbackAddress(address))) { + throw new Error("localhost did not resolve exclusively to loopback addresses"); + } + return addresses; + }); +} + +function resolveBindAddresses( + redirectUrl: URL, + bindHostname?: string, + lookup?: LoopbackLookup, +): string[] | Promise { + const redirectHostname = unbracket(redirectUrl.hostname); + const redirectAddresses = resolveLoopbackHostname(redirectHostname, lookup); + const requestedHostname = bindHostname ? unbracket(bindHostname) : redirectHostname; + if (requestedHostname === redirectHostname) { + return redirectAddresses; + } + const requestedAddresses = resolveLoopbackHostname(requestedHostname, lookup); + return Promise.all([redirectAddresses, requestedAddresses]).then(([redirect, requested]) => [ + ...new Set([...requested, ...redirect]), + ]); +} + +async function waitForAbortable(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) { + return await promise; + } + return await new Promise((resolve, reject) => { + const abort = () => reject(new Error("OAuth callback cancelled")); + signal.addEventListener("abort", abort, { once: true }); + promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)); + if (signal.aborted) { + abort(); + } + }); +} + +function resolveOAuthLoopbackPort(redirectUrl: URL): number { + const port = redirectUrl.port ? Number(redirectUrl.port) : 80; + if (!Number.isInteger(port) || port <= 0 || port > 65_535) { + throw new Error("OAuth callback redirect must use a valid TCP port"); + } + return port; +} + +function prepareResponse( + request: IncomingMessage, + response: ServerResponse, + resolveCorsOrigin?: CorsOriginResolver, +): void { + response.setHeader("Cache-Control", "no-store"); + response.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'"); + response.setHeader("Referrer-Policy", "no-referrer"); + response.setHeader("X-Content-Type-Options", "nosniff"); + const origin = resolveCorsOrigin?.(request.headers.origin); + if (!origin) { + return; + } + response.setHeader("Access-Control-Allow-Origin", origin); + response.setHeader( + "Vary", + "Origin, Access-Control-Request-Method, Access-Control-Request-Headers", + ); + response.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + response.setHeader( + "Access-Control-Allow-Headers", + typeof request.headers["access-control-request-headers"] === "string" + ? request.headers["access-control-request-headers"] + : "content-type", + ); + response.setHeader("Access-Control-Allow-Private-Network", "true"); + response.setHeader("Access-Control-Max-Age", "600"); +} + +async function closeServers(servers: readonly Server[]): Promise { + await Promise.all( + servers.map( + (server) => + new Promise((resolve) => { + if (!server.listening) { + resolve(); + return; + } + server.close(() => resolve()); + server.closeAllConnections?.(); + }), + ), + ); +} + +/** Binds the authoritative loopback redirect before returning, then waits separately. */ +export async function startOAuthLoopbackCallbackServer(params: { + redirectUrl: string | URL; + expectedState: string; + timeoutMs: number; + signal?: AbortSignal; + bindHostname?: string; + lookup?: LoopbackLookup; + createServer?: typeof import("node:http").createServer; + resolveCorsOrigin?: CorsOriginResolver; + renderSuccess?: () => RenderedResponse; + renderError?: (message: string) => RenderedResponse; +}): Promise { + const redirectUrl = new URL(params.redirectUrl); + const redirectHostname = unbracket(redirectUrl.hostname); + if ( + redirectUrl.protocol !== "http:" || + !["localhost", "127.0.0.1", "::1"].includes(redirectHostname) + ) { + throw new Error("OAuth callback redirect must use HTTP on a loopback address"); + } + if (!params.expectedState || !Number.isFinite(params.timeoutMs) || params.timeoutMs <= 0) { + throw new Error("OAuth callback requires state and a positive timeout"); + } + if (params.signal?.aborted) { + throw new Error("OAuth callback cancelled"); + } + + const resolvedAddresses = resolveBindAddresses(redirectUrl, params.bindHostname, params.lookup); + const addresses = Array.isArray(resolvedAddresses) + ? resolvedAddresses + : await waitForAbortable(resolvedAddresses, params.signal); + const port = resolveOAuthLoopbackPort(redirectUrl); + const callbackPath = redirectUrl.pathname || "/"; + const createServer = params.createServer ?? (await import("node:http")).createServer; + const servers: Server[] = []; + let settled = false; + let binding = true; + const timeoutRef: { current?: NodeJS.Timeout } = {}; + let closePromise: Promise | undefined; + let resolveWait!: (result: OAuthLoopbackCallbackResult) => void; + let rejectWait!: (error: Error) => void; + const waitPromise = new Promise((resolve, reject) => { + resolveWait = resolve; + rejectWait = reject; + }); + void waitPromise.catch(() => undefined); + const close = () => (binding ? Promise.resolve() : (closePromise ??= closeServers(servers))); + const cleanup = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + params.signal?.removeEventListener("abort", onAbort); + }; + const settleError = (error: unknown) => { + if (settled) { + return; + } + settled = true; + cleanup(); + rejectWait(error instanceof Error ? error : new Error("OAuth callback failed")); + void close(); + }; + const onAbort = () => settleError(new Error("OAuth callback cancelled")); + const settleResult = (result: OAuthLoopbackCallbackResult, response: ServerResponse) => { + if (settled) { + return; + } + settled = true; + cleanup(); + let finished = false; + const finish = () => { + if (finished) { + return; + } + finished = true; + resolveWait(result); + void close(); + }; + response.once("finish", finish); + response.once("close", finish); + }; + const renderSuccess = + params.renderSuccess ?? + (() => ({ + body: "Authorization received; return to the terminal while OpenClaw finishes.", + contentType: "text/plain; charset=utf-8", + })); + const renderError = + params.renderError ?? + ((message: string) => ({ + body: message, + contentType: "text/plain; charset=utf-8", + })); + const respond = (response: ServerResponse, status: number, rendered: RenderedResponse) => { + response.writeHead(status, { "Content-Type": rendered.contentType }); + response.end(rendered.body); + }; + const handleRequest = (request: IncomingMessage, response: ServerResponse) => { + try { + prepareResponse(request, response, params.resolveCorsOrigin); + if (settled) { + respond(response, 409, renderError("OAuth callback was already received.")); + } else if (request.method === "OPTIONS") { + response.writeHead(204).end(); + } else { + const url = new URL(request.url ?? "/", redirectUrl.origin); + if (url.pathname !== callbackPath) { + respond(response, 404, renderError("Callback route not found.")); + } else if (request.method !== "GET") { + response.setHeader("Allow", "GET, OPTIONS"); + respond(response, 405, renderError("Method not allowed.")); + } else if (url.searchParams.get("state") !== params.expectedState) { + respond(response, 400, renderError("Invalid OAuth state.")); + } else if (url.searchParams.has("error")) { + const error = url.searchParams.get("error")!; + const errorDescription = url.searchParams.get("error_description") ?? undefined; + settleResult( + { type: "oauth_error", error, ...(errorDescription ? { errorDescription } : {}) }, + response, + ); + respond(response, 400, renderError("Authorization was not completed.")); + } else { + const code = url.searchParams.get("code")?.trim(); + if (!code) { + respond(response, 400, renderError("Missing OAuth authorization code.")); + } else { + settleResult( + { type: "authorization_code", code, state: params.expectedState }, + response, + ); + respond(response, 200, renderSuccess()); + } + } + } + } catch (error) { + if (!response.headersSent) { + respond(response, 500, renderError("OAuth callback failed.")); + } + settleError(error); + } + }; + + params.signal?.addEventListener("abort", onAbort, { once: true }); + if (params.signal?.aborted) { + onAbort(); + throw new Error("OAuth callback cancelled"); + } + try { + // A partial localhost bind lets browsers choose an unserved family, so fail as one unit. + for (const address of addresses) { + const server = createServer(handleRequest); + servers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, address, resolve); + }); + server.removeAllListeners("error"); + server.on("error", settleError); + if (settled) { + throw new Error("OAuth callback cancelled"); + } + } + } catch (error) { + binding = false; + cleanup(); + await closeServers(servers); + throw error; + } + binding = false; + timeoutRef.current = setTimeout( + () => settleError(new Error("OAuth callback timeout")), + params.timeoutMs, + ); + return { + waitForCallback: () => waitPromise, + close: async () => { + if (!settled) { + settleError(new Error("OAuth callback cancelled")); + } + await close(); + }, + }; +} diff --git a/src/llm/utils/oauth/anthropic.test.ts b/src/llm/utils/oauth/anthropic.test.ts index 0a1c495fdcea..170755030b5c 100644 --- a/src/llm/utils/oauth/anthropic.test.ts +++ b/src/llm/utils/oauth/anthropic.test.ts @@ -180,4 +180,26 @@ describe("Anthropic OAuth callback host", () => { expect(credentials).toMatchObject({ access: "access-token", refresh: "refresh-token" }); expect(tokenExchange).toHaveBeenCalledOnce(); }); + + it("settles an OAuth error callback immediately", async () => { + vi.stubEnv("OPENCLAW_OAUTH_CALLBACK_HOST", "127.0.0.1"); + let callback: Promise | undefined; + const login = anthropicOAuthProvider.login({ + onAuth: ({ url }) => { + const state = new URL(url).searchParams.get("state"); + if (!state) { + throw new Error("authorization URL did not include OAuth state"); + } + callback = getLocalCallback( + `http://127.0.0.1:53692/callback?error=access_denied&state=${state}`, + ); + }, + onPrompt: async () => { + throw new Error("error callback did not settle the listener"); + }, + }); + + await expect(login).rejects.toThrow("Anthropic OAuth error: access_denied"); + await callback; + }); }); diff --git a/src/llm/utils/oauth/anthropic.ts b/src/llm/utils/oauth/anthropic.ts index b1c4b3461751..edb47e353849 100644 --- a/src/llm/utils/oauth/anthropic.ts +++ b/src/llm/utils/oauth/anthropic.ts @@ -5,9 +5,9 @@ * It is only intended for CLI use, not browser environments. */ -import type { Server } from "node:http"; import { toErrorObject } from "../../../infra/errors.js"; import { readResponseWithLimit } from "../../../infra/http-body.js"; +import { startOAuthLoopbackCallbackServer } from "../../../infra/oauth-loopback-callback.js"; import { generateOAuthState, generatePKCE, @@ -30,18 +30,11 @@ import type { } from "./types.js"; type CallbackServerInfo = { - server: Server; cancelWait: () => void; waitForCode: () => Promise<{ code: string; state: string } | null>; + close: () => Promise; }; -type NodeApis = { - createServer: typeof import("node:http").createServer; -}; - -let nodeApis: NodeApis | null = null; -let nodeApisPromise: Promise | null = null; - const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; const TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; @@ -50,6 +43,7 @@ const LOOPBACK_CALLBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); const CALLBACK_PORT = 53692; const CALLBACK_PATH = "/callback"; const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`; +const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; function resolveCallbackHost(env: NodeJS.ProcessEnv = process.env): string { const host = env.OPENCLAW_OAUTH_CALLBACK_HOST?.trim() || DEFAULT_CALLBACK_HOST; @@ -65,22 +59,6 @@ const SCOPES = /** Max response body bytes for Anthropic OAuth token endpoint (16 MiB). */ const OAUTH_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; -async function getNodeApis(): Promise { - if (nodeApis) { - return nodeApis; - } - if (!nodeApisPromise) { - if (typeof process === "undefined" || (!process.versions?.node && !process.versions?.bun)) { - throw new Error("Anthropic OAuth is only available in Node.js environments"); - } - nodeApisPromise = import("node:http").then((httpModule) => ({ - createServer: httpModule.createServer, - })); - } - nodeApis = await nodeApisPromise; - return nodeApis; -} - function formatErrorDetails(error: unknown): string { if (error instanceof Error) { const details: string[] = [`${error.name}: ${error.message}`]; @@ -155,79 +133,47 @@ function parseTokenCredentials( } async function startCallbackServer(expectedState: string): Promise { - const { createServer } = await getNodeApis(); - - return new Promise((resolve, reject) => { - let settleWait: ((value: { code: string; state: string } | null) => void) | undefined; - const waitForCodePromise = new Promise<{ code: string; state: string } | null>( - (resolveWait) => { - let settled = false; - settleWait = (value) => { - if (settled) { - return; - } - settled = true; - resolveWait(value); - }; - }, - ); - - const server = createServer((req, res) => { - try { - const url = new URL(req.url || "", "http://localhost"); - if (url.pathname !== CALLBACK_PATH) { - res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }); - res.end(oauthErrorHtml("Callback route not found.")); - return; - } - - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - const error = url.searchParams.get("error"); - - if (error) { - res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); - res.end(oauthErrorHtml("Anthropic authentication did not complete.", `Error: ${error}`)); - return; - } - - if (!code || !state) { - res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); - res.end(oauthErrorHtml("Missing code or state parameter.")); - return; - } - - if (state !== expectedState) { - res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); - res.end(oauthErrorHtml("State mismatch.")); - return; - } - - res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - res.end(oauthSuccessHtml("Anthropic authentication completed. You can close this window.")); - settleWait?.({ code, state }); - } catch { - res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("Internal error"); - } - }); - - const callbackHost = resolveCallbackHost(); - - server.on("error", (err) => { - reject(err); - }); - - server.listen(CALLBACK_PORT, callbackHost, () => { - resolve({ - server, - cancelWait: () => { - settleWait?.(null); - }, - waitForCode: () => waitForCodePromise, - }); - }); + if (typeof process === "undefined" || (!process.versions?.node && !process.versions?.bun)) { + throw new Error("Anthropic OAuth is only available in Node.js environments"); + } + const callback = await startOAuthLoopbackCallbackServer({ + redirectUrl: REDIRECT_URI, + expectedState, + timeoutMs: CALLBACK_TIMEOUT_MS, + bindHostname: resolveCallbackHost(), + renderSuccess: () => ({ + body: oauthSuccessHtml( + "Authorization received; return to the terminal while OpenClaw finishes.", + ), + contentType: "text/html; charset=utf-8", + }), + renderError: (message) => ({ + body: oauthErrorHtml(message), + contentType: "text/html; charset=utf-8", + }), }); + return { + cancelWait: () => void callback.close(), + waitForCode: async () => { + try { + const result = await callback.waitForCallback(); + if (result.type === "oauth_error") { + throw new Error(`Anthropic OAuth error: ${result.error}`); + } + return { code: result.code, state: result.state }; + } catch (error) { + if ( + error instanceof Error && + (error.message === "OAuth callback timeout" || + error.message === "OAuth callback cancelled") + ) { + return null; + } + throw error; + } + }, + close: callback.close, + }; } async function postJson( @@ -426,7 +372,7 @@ async function loginAnthropic(options: { options.onProgress?.("Exchanging authorization code for tokens..."); return exchangeAuthorizationCode(code, state, verifier, REDIRECT_URI, options.signal); } finally { - server.server.close(); + await server.close(); } } diff --git a/src/plugin-sdk/provider-auth-runtime.test.ts b/src/plugin-sdk/provider-auth-runtime.test.ts index 1b7f726390cb..a4b7e56eddd6 100644 --- a/src/plugin-sdk/provider-auth-runtime.test.ts +++ b/src/plugin-sdk/provider-auth-runtime.test.ts @@ -124,6 +124,45 @@ describe("plugin-sdk provider-auth-runtime", () => { await expect(callback).rejects.toThrow("OAuth callback cancelled"); }); + it("binds the redirect host when the public hostname option is omitted", async () => { + const port = await getFreePort(); + const callback = providerAuthRuntime.waitForLocalOAuthCallback({ + expectedState: "state-1", + timeoutMs: 5_000, + port, + callbackPath: "/callback", + redirectUri: `http://127.0.0.1:${port}/callback`, + successTitle: "OAuth complete", + }); + + const response = await fetch(`http://127.0.0.1:${port}/callback?code=code-1&state=state-1`); + expect(response.status).toBe(200); + await expect(callback).resolves.toEqual({ code: "code-1", state: "state-1" }); + }); + + it("keeps an explicit localhost bind compatible with an IPv4 redirect", async () => { + const port = await getFreePort(); + let markReady!: () => void; + const ready = new Promise((resolve) => { + markReady = resolve; + }); + const callback = providerAuthRuntime.waitForLocalOAuthCallback({ + expectedState: "state-1", + timeoutMs: 5_000, + port, + callbackPath: "/callback", + redirectUri: `http://127.0.0.1:${port}/callback`, + hostname: "localhost", + successTitle: "OAuth complete", + onProgress: markReady, + }); + + await ready; + const response = await fetch(`http://127.0.0.1:${port}/callback?code=code-1&state=state-1`); + expect(response.status).toBe(200); + await expect(callback).resolves.toEqual({ code: "code-1", state: "state-1" }); + }); + it("does not echo CORS for unallowlisted callback origins but keeps waiting", async () => { const port = await getFreePort(); const callback = providerAuthRuntime.waitForLocalOAuthCallback({ diff --git a/src/plugin-sdk/provider-auth-runtime.ts b/src/plugin-sdk/provider-auth-runtime.ts index c58b41fe6b9c..ca9a8779bddc 100644 --- a/src/plugin-sdk/provider-auth-runtime.ts +++ b/src/plugin-sdk/provider-auth-runtime.ts @@ -8,6 +8,7 @@ import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js"; import { resolveApiKeyForProvider as resolveModelApiKeyForProvider } from "../agents/model-auth.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; +import { startOAuthLoopbackCallbackServer } from "../infra/oauth-loopback-callback.js"; import { escapeHtml } from "../shared/html-escape.js"; import { resolveTimerTimeoutMs } from "../shared/number-coercion.js"; @@ -164,7 +165,7 @@ export async function waitForLocalOAuthCallback(params: { successTitle: string; /** Optional progress message emitted once the listener starts. */ progressMessage?: string; - /** Loopback hostname to bind; defaults to localhost. */ + /** Extra loopback hostname to bind; the redirect URI hostname is always bound. */ hostname?: string; /** Progress callback invoked after the server begins listening. */ onProgress?: (message: string) => void; @@ -175,175 +176,47 @@ export async function waitForLocalOAuthCallback(params: { */ corsOriginAllowlist?: readonly string[]; }): Promise { - const hostname = params.hostname ?? "localhost"; const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, 1); const escapedSuccessTitle = escapeHtml(params.successTitle); + const callbackUrl = new URL(params.redirectUri); + callbackUrl.port = String(params.port); + callbackUrl.pathname = params.callbackPath; const resolveOAuthCallbackOrigin = buildOAuthCallbackOriginResolver(params.corsOriginAllowlist); const hasCorsOriginAllowlist = params.corsOriginAllowlist?.some((host) => host.trim().length > 0) ?? false; - - return new Promise((resolve, reject) => { - let settled = false; - let timeout: NodeJS.Timeout | null = null; - const server = createServer((req, res) => { - try { - // A browser may reuse loopback HTTP/1.1 connections. Closing each - // response prevents an accepted socket from pinning the CLI process. - res.setHeader("Connection", "close"); - applyOAuthCallbackCorsHeaders( - req, - res, - hasCorsOriginAllowlist ? resolveOAuthCallbackOrigin : undefined, - ); - const requestUrl = new URL(req.url ?? "/", params.redirectUri); - if (req.method === "OPTIONS") { - res.statusCode = 204; - res.end(); - return; - } - if (requestUrl.pathname !== params.callbackPath) { - res.statusCode = 404; - res.setHeader("Content-Type", "text/plain"); - res.end("Not found"); - return; - } - if (req.method !== "GET") { - res.statusCode = 405; - res.setHeader("Allow", "GET, OPTIONS"); - res.setHeader("Content-Type", "text/plain"); - res.end("Method not allowed"); - return; - } - - const state = requestUrl.searchParams.get("state")?.trim(); - if (!state) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain"); - res.once("finish", () => finish(new Error("Missing OAuth state"), undefined, true)); - res.end("Missing state"); - return; - } - if (state !== params.expectedState) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain"); - res.once("finish", () => finish(new Error("OAuth state mismatch"), undefined, true)); - res.end("Invalid state"); - return; - } - - const error = requestUrl.searchParams.get("error"); - if (error) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain"); - res.once("finish", () => finish(new Error(`OAuth error: ${error}`), undefined, true)); - res.end(`Authentication failed: ${error}`); - return; - } - - const code = requestUrl.searchParams.get("code")?.trim(); - if (!code) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain"); - res.once("finish", () => finish(new Error("Missing OAuth code"), undefined, true)); - res.end("Missing code"); - return; - } - - res.statusCode = 200; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.once("finish", () => finish(undefined, { code, state }, true)); - res.end( - "" + - `

${escapedSuccessTitle}

` + - "

You can close this window and return to OpenClaw.

", - ); - } catch (err) { - finish(err instanceof Error ? err : new Error("OAuth callback failed"), undefined, true); - } - }); - - const finish = (err?: Error, result?: OAuthCallbackResult, forceClose = false) => { - if (settled) { - return; - } - settled = true; - if (timeout) { - clearTimeout(timeout); - } - params.signal?.removeEventListener("abort", onAbort); - try { - server.close(); - if (forceClose) { - server.closeAllConnections(); - } - } catch { - // ignore close errors - } - if (err) { - reject(err); - } else if (result) { - resolve(result); - } - }; - - const onAbort = () => finish(new Error("OAuth callback cancelled"), undefined, true); - params.signal?.addEventListener("abort", onAbort, { once: true }); - if (params.signal?.aborted) { - onAbort(); - return; - } - - server.once("error", (err) => { - finish( - err instanceof Error ? err : new Error("OAuth callback server error"), - undefined, - true, - ); - }); - - server.listen(params.port, hostname, () => { - params.onProgress?.( - params.progressMessage ?? `Waiting for OAuth callback on ${params.redirectUri}...`, - ); - }); - - timeout = setTimeout(() => { - finish(new Error("OAuth callback timeout"), undefined, true); - }, timeoutMs); + const callback = await startOAuthLoopbackCallbackServer({ + redirectUrl: callbackUrl, + expectedState: params.expectedState, + timeoutMs, + ...(params.hostname ? { bindHostname: params.hostname } : {}), + createServer, + ...(params.signal ? { signal: params.signal } : {}), + resolveCorsOrigin: hasCorsOriginAllowlist + ? resolveOAuthCallbackOrigin + : (originHeader) => { + const value = Array.isArray(originHeader) ? originHeader[0] : originHeader; + return value && isHttpOrigin(value) ? value : undefined; + }, + renderSuccess: () => ({ + body: + "" + + `

${escapedSuccessTitle}

` + + "

You can close this window and return to OpenClaw.

", + contentType: "text/html; charset=utf-8", + }), }); -} - -function applyOAuthCallbackCorsHeaders( - req: import("node:http").IncomingMessage, - res: import("node:http").ServerResponse, - resolveOrigin?: (originHeader: string | string[] | undefined) => string | undefined, -): void { - const origin = - resolveOrigin === undefined - ? typeof req.headers.origin === "string" && isHttpOrigin(req.headers.origin) - ? req.headers.origin - : undefined - : resolveOrigin(req.headers.origin); - if (origin) { - res.setHeader("Access-Control-Allow-Origin", origin); - res.setHeader("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"); - } - if (resolveOrigin !== undefined && !origin) { - // With an allowlist present, untrusted origins receive a bare 204 preflight - // response so browser navigation still works but scripts cannot read it. - return; - } - - const requestedHeaders = req.headers["access-control-request-headers"]; - res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); - res.setHeader( - "Access-Control-Allow-Headers", - typeof requestedHeaders === "string" && requestedHeaders.trim().length > 0 - ? requestedHeaders - : "content-type", + params.onProgress?.( + params.progressMessage ?? `Waiting for OAuth callback on ${params.redirectUri}...`, ); - res.setHeader("Access-Control-Allow-Private-Network", "true"); - res.setHeader("Access-Control-Max-Age", "600"); + try { + const result = await callback.waitForCallback(); + if (result.type === "oauth_error") { + throw new Error(`OAuth error: ${result.error}`); + } + return { code: result.code, state: result.state }; + } finally { + await callback.close(); + } } function isHttpOrigin(value: string): boolean {