fix(mcp): listen on the OAuth loopback redirect during mcp login (#120433)

This commit is contained in:
Peter Steinberger
2026-08-07 19:29:13 -07:00
committed by GitHub
parent 2f1692c955
commit 2c5214f9e8
9 changed files with 623 additions and 53 deletions
+4 -4
View File
@@ -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"`. The first run prints an authorization URL; rerun with `--code` after approval.
- `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.
- `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 prints the authorization URL and stores temporary OAuth verifier state in shared SQLite.
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.
</Step>
<Step title="Finish with the code">
After approving in the browser, pass the returned code back to OpenClaw.
<Step title="Use the manual fallback when needed">
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.
```bash
openclaw mcp login docs --code abc123
+1 -1
View File
@@ -111,7 +111,7 @@ Set `auth: "oauth"` plus any required `oauth` metadata, then:
openclaw mcp login <name>
```
Follow the printed authorization URL and rerun with `--code` when prompted.
Follow the printed authorization URL. OpenClaw normally captures the loopback redirect and saves the credentials automatically; use the printed `--code` command when the browser cannot reach the callback listener.
### Changes do not reach an active agent
+141
View File
@@ -707,6 +707,147 @@ 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"));
+65 -19
View File
@@ -293,16 +293,22 @@ async function runMcpOAuthLoginAttempt(
config?: McpOAuthConfig;
authorizationCode?: string;
fetchFn?: FetchLike;
onAuthorizationUrl?: (url: URL) => void | Promise<void>;
onAuthorizationUrl?: (url: URL) => string | void | Promise<string | void>;
resourceMetadataUrl?: URL;
scope?: string;
forceAuthorization?: boolean;
},
lease: OpenClawStateLeaseContext,
): Promise<"authorized" | "redirect"> {
): 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,
@@ -316,7 +322,29 @@ async function runMcpOAuthLoginAttempt(
},
);
lease.assertOwned();
return result === "AUTHORIZED" ? "authorized" : "redirect";
return {
...(authorizationCode ? { authorizationCode } : {}),
result: result === "AUTHORIZED" ? "authorized" : "redirect",
};
}
async function exchangeCapturedMcpOAuthCode(
params: Parameters<typeof runMcpOAuthLoginAttempt>[0],
attempt: Awaited<ReturnType<typeof runMcpOAuthLoginAttempt>>,
lease: OpenClawStateLeaseContext,
): Promise<"authorized" | "redirect"> {
if (attempt.result !== "redirect" || !attempt.authorizationCode) {
return attempt.result;
}
const exchanged = await runMcpOAuthLoginAttempt(
{
...params,
authorizationCode: attempt.authorizationCode,
onAuthorizationUrl: undefined,
},
lease,
);
return exchanged.result;
}
/** Runs both redirect-registration attempts under one OAuth session lease. */
@@ -326,7 +354,7 @@ export async function runMcpOAuthLogin(params: {
config?: McpOAuthConfig;
authorizationCode?: string;
fetchFn?: FetchLike;
onAuthorizationUrl?: (url: URL) => void | Promise<void>;
onAuthorizationUrl?: (url: URL) => string | void | Promise<string | void>;
}): Promise<"authorized" | "redirect"> {
const storeKey = resolveMcpOAuthStoreKey(params.serverName, params.serverUrl);
return await withMcpOAuthLease(storeKey, async (lease) => {
@@ -344,29 +372,47 @@ export async function runMcpOAuthLogin(params: {
scope: normalizeOptionalString(pendingChallenge?.scope),
forceAuthorization: pendingChallenge?.requiresAuthorization === true,
};
let effectiveParams = loginParams;
let attempt: Awaited<ReturnType<typeof runMcpOAuthLoginAttempt>>;
try {
return await runMcpOAuthLoginAttempt(loginParams, lease);
attempt = await runMcpOAuthLoginAttempt(loginParams, lease);
} catch (error) {
if (
!normalizeOptionalString(params.authorizationCode) &&
!normalizeOptionalString(params.config?.redirectUrl) &&
isMcpOAuthRedirectRegistrationError(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;
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;
}
throw error;
}
return await exchangeCapturedMcpOAuthCode(effectiveParams, attempt, lease);
});
}
+46
View File
@@ -7,10 +7,12 @@ 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 { createDeferred } from "../shared/deferred.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;
type RunMcpOAuthLogin = typeof import("../agents/mcp-oauth.js").runMcpOAuthLogin;
const mocks = vi.hoisted(() => {
const runtime = {
@@ -671,6 +673,50 @@ describe("mcp cli", () => {
});
});
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<RunMcpOAuthLogin>[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 <code>.",
);
});
});
it("clears stored OAuth credentials on logout", async () => {
await withTempHome("openclaw-cli-mcp-home-", async () => {
const workspaceDir = await createWorkspace();
+15 -5
View File
@@ -39,6 +39,7 @@ 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";
@@ -1348,12 +1349,21 @@ export function registerMcpCli(program: Command) {
headers: withoutMcpAuthorizationHeader(resolved.headers),
resourceUrl: resolved.url,
}),
onAuthorizationUrl: (url) => {
defaultRuntime.log(`Open this URL to authorize "${name}":`);
defaultRuntime.log(url.toString());
defaultRuntime.log(
`After approval, run ${formatCliCommand(`openclaw mcp login ${name} --code <code>`)}.`,
onAuthorizationUrl: async (url) => {
const manualFallbackCommand = formatCliCommand(
`openclaw mcp login ${name} --code <code>`,
);
return await waitForMcpOAuthAuthorizationCode({
authorizationUrl: url,
manualFallbackCommand,
onReady: () => {
defaultRuntime.log(`Open this URL to authorize "${name}":`);
defaultRuntime.log(url.toString());
defaultRuntime.log(
`If the browser redirect cannot reach this machine, stop this command and run ${manualFallbackCommand}.`,
);
},
});
},
});
if (result === "authorized") {
+178
View File
@@ -0,0 +1,178 @@
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<number> {
const server = createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "::1", resolve);
});
const port = (server.address() as AddressInfo).port;
await new Promise<void>((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<void>((resolve) => {
announceReady = resolve;
});
const onReady = vi.fn(() => announceReady?.());
const callback = waitForMcpOAuthAuthorizationCode({
authorizationUrl: authorizationUrl({ port }),
manualFallbackCommand: "openclaw mcp login docs --code <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<void>((resolve) => {
announceReady = resolve;
});
const callback = waitForMcpOAuthAuthorizationCode({
authorizationUrl: authorizationUrl({ port }),
manualFallbackCommand: "openclaw mcp login docs --code <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<void>((resolve) => {
announceReady = resolve;
});
const callback = waitForMcpOAuthAuthorizationCode({
authorizationUrl: authorizationUrl({ port, redirectHost: "[::1]" }),
manualFallbackCommand: "openclaw mcp login docs --code <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<void>((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 <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 <code>.`,
);
expect(onReady).toHaveBeenCalledOnce();
} finally {
await new Promise<void>((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 <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 <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 <code>",
onReady,
}),
).resolves.toBeUndefined();
expect(onReady).toHaveBeenCalledOnce();
});
});
+134
View File
@@ -0,0 +1,134 @@
// 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<string | undefined> {
const target = resolveMcpOAuthLoopbackTarget(params.authorizationUrl);
if (!target) {
params.onReady();
return undefined;
}
const controller = new AbortController();
let markListening: (() => void) | undefined;
const listening = new Promise<void>((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);
}
}
+39 -24
View File
@@ -187,12 +187,15 @@ export async function waitForLocalOAuthCallback(params: {
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 ?? "/", `http://${hostname}:${params.port}`);
const requestUrl = new URL(req.url ?? "/", params.redirectUri);
if (req.method === "OPTIONS") {
res.statusCode = 204;
res.end();
@@ -212,49 +215,54 @@ export async function waitForLocalOAuthCallback(params: {
return;
}
const error = requestUrl.searchParams.get("error");
const code = requestUrl.searchParams.get("code")?.trim();
const state = requestUrl.searchParams.get("state")?.trim();
if (error) {
if (!state) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain");
res.end(`Authentication failed: ${error}`);
finish(new Error(`OAuth error: ${error}`));
res.once("finish", () => finish(new Error("Missing OAuth state"), undefined, true));
res.end("Missing state");
return;
}
if (!code || !state) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain");
res.end("Missing code or state");
finish(new Error("Missing OAuth code or 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");
finish(new Error("OAuth state mismatch"));
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(
"<!doctype html><html><head><meta charset='utf-8'/></head>" +
`<body><h2>${escapedSuccessTitle}</h2>` +
"<p>You can close this window and return to OpenClaw.</p></body></html>",
);
finish(undefined, { code, state });
} catch (err) {
finish(err instanceof Error ? err : new Error("OAuth callback failed"));
finish(err instanceof Error ? err : new Error("OAuth callback failed"), undefined, true);
}
});
const finish = (err?: Error, result?: OAuthCallbackResult) => {
const finish = (err?: Error, result?: OAuthCallbackResult, forceClose = false) => {
if (settled) {
return;
}
@@ -265,6 +273,9 @@ export async function waitForLocalOAuthCallback(params: {
params.signal?.removeEventListener("abort", onAbort);
try {
server.close();
if (forceClose) {
server.closeAllConnections();
}
} catch {
// ignore close errors
}
@@ -275,7 +286,7 @@ export async function waitForLocalOAuthCallback(params: {
}
};
const onAbort = () => finish(new Error("OAuth callback cancelled"));
const onAbort = () => finish(new Error("OAuth callback cancelled"), undefined, true);
params.signal?.addEventListener("abort", onAbort, { once: true });
if (params.signal?.aborted) {
onAbort();
@@ -283,7 +294,11 @@ export async function waitForLocalOAuthCallback(params: {
}
server.once("error", (err) => {
finish(err instanceof Error ? err : new Error("OAuth callback server error"));
finish(
err instanceof Error ? err : new Error("OAuth callback server error"),
undefined,
true,
);
});
server.listen(params.port, hostname, () => {
@@ -293,7 +308,7 @@ export async function waitForLocalOAuthCallback(params: {
});
timeout = setTimeout(() => {
finish(new Error("OAuth callback timeout"));
finish(new Error("OAuth callback timeout"), undefined, true);
}, timeoutMs);
});
}