fix(auth): honor OAuth login cancellation

This commit is contained in:
Vincent Koc
2026-05-28 02:16:30 +02:00
parent a20c091411
commit 5846878924
9 changed files with 428 additions and 56 deletions

View File

@@ -54,7 +54,7 @@ Docs: https://docs.openclaw.ai
- Agents/providers: add OpenAI-compatible cache retention, forward cached token usage in chat completions, preserve runtime context before active user turns, strip stale Anthropic thinking, load Claude CLI OAuth for Pi auth profiles, avoid false Codex runtime live switches, and quarantine unsupported tool schemas. (#82062, #87167, #86855) - Agents/providers: add OpenAI-compatible cache retention, forward cached token usage in chat completions, preserve runtime context before active user turns, strip stale Anthropic thinking, load Claude CLI OAuth for Pi auth profiles, avoid false Codex runtime live switches, and quarantine unsupported tool schemas. (#82062, #87167, #86855)
- Gateway/performance: cache plugin metadata fingerprints and stable plugin index fingerprints, borrow read-only session metadata safely, keep the active session working store hot, keep status on a bounded fast path, and preserve model auth profile suffixes. (#86439) - Gateway/performance: cache plugin metadata fingerprints and stable plugin index fingerprints, borrow read-only session metadata safely, keep the active session working store hot, keep status on a bounded fast path, and preserve model auth profile suffixes. (#86439)
- Package/install/release: align npm package exclusions and inventory, omit unpacked test helpers, skip Homebrew until macOS packages need it, cap tsdown heap in containers, bound install/release smoke waits, and harden post-publish verification. - Package/install/release: align npm package exclusions and inventory, omit unpacked test helpers, skip Homebrew until macOS packages need it, cap tsdown heap in containers, bound install/release smoke waits, and harden post-publish verification.
- Codex: bound ChatGPT OAuth token exchange and refresh requests so stalled auth endpoints fail instead of hanging login or refresh. - Codex/Auth: bound ChatGPT OAuth token exchange and refresh requests, and honor cancellation across Codex and Anthropic OAuth login flows.
- QA/E2E/CI: bound Telegram, kitchen-sink, Open WebUI, ClawHub, MCP, Discord, realtime, labeler, and GitHub API waits; fail empty explicit test, live-media, gateway CPU, plugin gauntlet, and beta-smoke runs instead of false-greening. - QA/E2E/CI: bound Telegram, kitchen-sink, Open WebUI, ClawHub, MCP, Discord, realtime, labeler, and GitHub API waits; fail empty explicit test, live-media, gateway CPU, plugin gauntlet, and beta-smoke runs instead of false-greening.
- Agents/Codex: keep spawned agent bootstrap files rooted in the agent workspace while running task commands, transcripts, and compaction from the requested cwd. (#87218) Thanks @mbelinky. - Agents/Codex: keep spawned agent bootstrap files rooted in the agent workspace while running task commands, transcripts, and compaction from the requested cwd. (#87218) Thanks @mbelinky.

View File

@@ -0,0 +1,47 @@
export function createOAuthLoginCancelledError(): Error {
return new Error("Login cancelled");
}
export function throwIfOAuthLoginAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw createOAuthLoginCancelledError();
}
}
export function withOAuthLoginAbort<T>(
promise: Promise<T>,
signal?: AbortSignal,
onAbort?: () => void,
): Promise<T> {
if (!signal) {
return promise;
}
return new Promise<T>((resolve, reject) => {
const cleanup = () => {
signal.removeEventListener("abort", abort);
};
const abort = () => {
cleanup();
onAbort?.();
reject(createOAuthLoginCancelledError());
};
if (signal.aborted) {
abort();
return;
}
signal.addEventListener("abort", abort, { once: true });
promise.then(
(value) => {
cleanup();
resolve(value);
},
(error) => {
cleanup();
reject(error);
},
);
});
}

View File

@@ -8,7 +8,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: ssrfMocks.fetchWithSsrFGuard, fetchWithSsrFGuard: ssrfMocks.fetchWithSsrFGuard,
})); }));
import { testing } from "./openai-codex-oauth-flow.runtime.js"; import { openaiCodexOAuthProvider, testing } from "./openai-codex-oauth-flow.runtime.js";
function timeoutError(): Error { function timeoutError(): Error {
return new DOMException("timed out", "TimeoutError"); return new DOMException("timed out", "TimeoutError");
@@ -19,6 +19,34 @@ afterEach(() => {
}); });
describe("OpenAI Codex OAuth flow", () => { describe("OpenAI Codex OAuth flow", () => {
it("cancels provider login before opening the OAuth flow", async () => {
const controller = new AbortController();
controller.abort();
await expect(
openaiCodexOAuthProvider.login({
onAuth: vi.fn(),
onPrompt: vi.fn(async () => "unused-code"),
signal: controller.signal,
}),
).rejects.toThrow("Login cancelled");
});
it("does not open the OAuth flow after cancellation during setup", async () => {
const controller = new AbortController();
const onAuth = vi.fn();
const loginPromise = openaiCodexOAuthProvider.login({
onAuth,
onPrompt: vi.fn(async () => "unused-code"),
signal: controller.signal,
});
controller.abort();
await expect(loginPromise).rejects.toThrow("Login cancelled");
expect(onAuth).not.toHaveBeenCalled();
});
it("waits for Node OAuth runtime before creating an authorization flow", async () => { it("waits for Node OAuth runtime before creating an authorization flow", async () => {
const flow = await testing.createAuthorizationFlow("openclaw-test"); const flow = await testing.createAuthorizationFlow("openclaw-test");
const url = new URL(flow.url); const url = new URL(flow.url);
@@ -64,6 +92,24 @@ describe("OpenAI Codex OAuth flow", () => {
}); });
}); });
it("cancels token exchange requests with the caller signal", async () => {
const controller = new AbortController();
controller.abort();
const result = await testing.exchangeAuthorizationCode(
"code",
"verifier",
testing.resolveRedirectUri("localhost"),
{ signal: controller.signal, timeoutMs: 5 },
);
expect(ssrfMocks.fetchWithSsrFGuard).not.toHaveBeenCalled();
expect(result).toMatchObject({
type: "failed",
message: "Login cancelled",
});
});
it("times out token refresh requests", async () => { it("times out token refresh requests", async () => {
ssrfMocks.fetchWithSsrFGuard.mockRejectedValueOnce(timeoutError()); ssrfMocks.fetchWithSsrFGuard.mockRejectedValueOnce(timeoutError());

View File

@@ -7,6 +7,11 @@
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveCodexAuthIdentity } from "./openai-codex-auth-identity.js"; import { resolveCodexAuthIdentity } from "./openai-codex-auth-identity.js";
import {
createOAuthLoginCancelledError,
throwIfOAuthLoginAborted,
withOAuthLoginAbort,
} from "./openai-codex-oauth-abort.runtime.js";
import { oauthErrorHtml, oauthSuccessHtml } from "./openai-codex-oauth-page.runtime.js"; import { oauthErrorHtml, oauthSuccessHtml } from "./openai-codex-oauth-page.runtime.js";
import type { import type {
OAuthCredentials, OAuthCredentials,
@@ -42,6 +47,7 @@ type NodeOAuthRuntime = {
http: typeof import("node:http"); http: typeof import("node:http");
}; };
type TokenRequestOptions = { type TokenRequestOptions = {
signal?: AbortSignal;
timeoutMs?: number; timeoutMs?: number;
}; };
@@ -81,9 +87,27 @@ function createState(randomBytes: typeof import("node:crypto").randomBytes): str
return randomBytes(16).toString("hex"); return randomBytes(16).toString("hex");
} }
function waitForManualPromptFallback(): Promise<null> { function waitForManualPromptFallback(signal?: AbortSignal): Promise<null> {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
const timeout = setTimeout(() => resolve(null), MANUAL_PROMPT_FALLBACK_MS); if (signal?.aborted) {
reject(createOAuthLoginCancelledError());
return;
}
const cleanup = () => {
signal?.removeEventListener("abort", abort);
};
const abort = () => {
clearTimeout(timeout);
cleanup();
reject(createOAuthLoginCancelledError());
};
const timeout = setTimeout(() => {
cleanup();
resolve(null);
}, MANUAL_PROMPT_FALLBACK_MS);
signal?.addEventListener("abort", abort, { once: true });
timeout.unref?.(); timeout.unref?.();
}); });
} }
@@ -152,7 +176,11 @@ function formatTokenRequestError(
operation: "exchange" | "refresh", operation: "exchange" | "refresh",
error: unknown, error: unknown,
timeoutMs: number, timeoutMs: number,
signal?: AbortSignal,
): string { ): string {
if (signal?.aborted) {
return "Login cancelled";
}
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) { if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
return `OpenAI Codex token ${operation} timed out after ${timeoutMs}ms`; return `OpenAI Codex token ${operation} timed out after ${timeoutMs}ms`;
} }
@@ -164,6 +192,7 @@ async function postTokenForm(
options: TokenRequestOptions = {}, options: TokenRequestOptions = {},
): Promise<Response> { ): Promise<Response> {
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS; const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
throwIfOAuthLoginAborted(options.signal);
const { response, release } = await fetchWithSsrFGuard({ const { response, release } = await fetchWithSsrFGuard({
url: TOKEN_URL, url: TOKEN_URL,
init: { init: {
@@ -172,6 +201,7 @@ async function postTokenForm(
body, body,
}, },
timeoutMs, timeoutMs,
signal: options.signal,
auditContext: "openai-codex-oauth-token", auditContext: "openai-codex-oauth-token",
}); });
try { try {
@@ -203,12 +233,12 @@ async function exchangeAuthorizationCode(
code_verifier: verifier, code_verifier: verifier,
redirect_uri: redirectUri, redirect_uri: redirectUri,
}), }),
{ timeoutMs }, { signal: options.signal, timeoutMs },
); );
} catch (error) { } catch (error) {
return { return {
type: "failed", type: "failed",
message: formatTokenRequestError("exchange", error, timeoutMs), message: formatTokenRequestError("exchange", error, timeoutMs, options.signal),
}; };
} }
@@ -250,7 +280,7 @@ async function refreshAccessToken(
refresh_token: refreshToken, refresh_token: refreshToken,
client_id: CLIENT_ID, client_id: CLIENT_ID,
}), }),
{ timeoutMs }, { signal: options.signal, timeoutMs },
); );
if (!response.ok) { if (!response.ok) {
@@ -284,6 +314,7 @@ async function refreshAccessToken(
"refresh", "refresh",
error, error,
options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS, options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS,
options.signal,
), ),
}; };
} }
@@ -417,14 +448,21 @@ export async function loginOpenAICodex(options: {
onProgress?: (message: string) => void; onProgress?: (message: string) => void;
onManualCodeInput?: () => Promise<string>; onManualCodeInput?: () => Promise<string>;
originator?: string; originator?: string;
signal?: AbortSignal;
}): Promise<OAuthCredentials> { }): Promise<OAuthCredentials> {
throwIfOAuthLoginAborted(options.signal);
const { verifier, redirectUri, state, url } = await createAuthorizationFlow(options.originator); const { verifier, redirectUri, state, url } = await createAuthorizationFlow(options.originator);
const server = await startLocalOAuthServer(state); const server = await startLocalOAuthServer(state);
options.onAuth({ url, instructions: "A browser window should open. Complete login to finish." });
let code: string | undefined; let code: string | undefined;
try { try {
throwIfOAuthLoginAborted(options.signal);
options.onAuth({
url,
instructions: "A browser window should open. Complete login to finish.",
});
throwIfOAuthLoginAborted(options.signal);
if (options.onManualCodeInput) { if (options.onManualCodeInput) {
// Race between browser callback and manual input // Race between browser callback and manual input
let manualCode: string | undefined; let manualCode: string | undefined;
@@ -440,7 +478,11 @@ export async function loginOpenAICodex(options: {
server.cancelWait(); server.cancelWait();
}); });
const result = await server.waitForCode(); const result = await withOAuthLoginAbort(
server.waitForCode(),
options.signal,
server.cancelWait,
);
// If manual input was cancelled, throw that error // If manual input was cancelled, throw that error
if (manualError) { if (manualError) {
@@ -461,7 +503,7 @@ export async function loginOpenAICodex(options: {
// If still no code, wait for manual promise to complete and try that // If still no code, wait for manual promise to complete and try that
if (!code) { if (!code) {
await manualPromise; await withOAuthLoginAbort(manualPromise, options.signal, server.cancelWait);
if (manualError) { if (manualError) {
throw manualError; throw manualError;
} }
@@ -475,7 +517,11 @@ export async function loginOpenAICodex(options: {
} }
} else { } else {
const callbackPromise = server.waitForCode(); const callbackPromise = server.waitForCode();
const result = await Promise.race([callbackPromise, waitForManualPromptFallback()]); const result = await withOAuthLoginAbort(
Promise.race([callbackPromise, waitForManualPromptFallback(options.signal)]),
options.signal,
server.cancelWait,
);
if (result?.code) { if (result?.code) {
code = result.code; code = result.code;
} else { } else {
@@ -485,23 +531,30 @@ export async function loginOpenAICodex(options: {
return promptCode; return promptCode;
}, },
); );
code = await Promise.race([ code = await withOAuthLoginAbort(
callbackPromise.then((callback) => callback?.code), Promise.race([callbackPromise.then((callback) => callback?.code), promptCodePromise]),
promptCodePromise, options.signal,
]); server.cancelWait,
);
} }
} }
// Fallback to onPrompt if still no code // Fallback to onPrompt if still no code
if (!code) { if (!code) {
code = await promptForAuthorizationCode(options.onPrompt, state); code = await withOAuthLoginAbort(
promptForAuthorizationCode(options.onPrompt, state),
options.signal,
server.cancelWait,
);
} }
if (!code) { if (!code) {
throw new Error("Missing authorization code"); throw new Error("Missing authorization code");
} }
const tokenResult = await exchangeAuthorizationCode(code, verifier, redirectUri); const tokenResult = await exchangeAuthorizationCode(code, verifier, redirectUri, {
signal: options.signal,
});
if (tokenResult.type !== "success") { if (tokenResult.type !== "success") {
throw new Error(tokenResult.message); throw new Error(tokenResult.message);
} }
@@ -555,6 +608,7 @@ export const openaiCodexOAuthProvider: OAuthProviderInterface = {
onPrompt: callbacks.onPrompt, onPrompt: callbacks.onPrompt,
onProgress: callbacks.onProgress, onProgress: callbacks.onProgress,
onManualCodeInput: callbacks.onManualCodeInput, onManualCodeInput: callbacks.onManualCodeInput,
signal: callbacks.signal,
}); });
}, },
@@ -571,6 +625,7 @@ export const testing = {
callbackHost: CALLBACK_HOST, callbackHost: CALLBACK_HOST,
createAuthorizationFlow, createAuthorizationFlow,
exchangeAuthorizationCode, exchangeAuthorizationCode,
loginOpenAICodex,
refreshAccessToken, refreshAccessToken,
resolveCallbackHost, resolveCallbackHost,
resolveRedirectUri, resolveRedirectUri,

View File

@@ -0,0 +1,58 @@
export function createOAuthLoginCancelledError(): Error {
return new Error("Login cancelled");
}
export function throwIfOAuthLoginAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw createOAuthLoginCancelledError();
}
}
export function withOAuthLoginAbort<T>(
promise: Promise<T>,
signal?: AbortSignal,
onAbort?: () => void,
): Promise<T> {
if (!signal) {
return promise;
}
return new Promise<T>((resolve, reject) => {
const cleanup = () => {
signal.removeEventListener("abort", abort);
};
const abort = () => {
cleanup();
onAbort?.();
reject(createOAuthLoginCancelledError());
};
if (signal.aborted) {
abort();
return;
}
signal.addEventListener("abort", abort, { once: true });
promise.then(
(value) => {
cleanup();
resolve(value);
},
(error) => {
cleanup();
reject(error);
},
);
});
}
export function buildOAuthRequestSignal(options: {
signal?: AbortSignal;
timeoutMs: number;
}): AbortSignal {
const timeoutSignal = AbortSignal.timeout(options.timeoutMs);
if (!options.signal) {
return timeoutSignal;
}
return AbortSignal.any([options.signal, timeoutSignal]);
}

View File

@@ -1,11 +1,39 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { refreshAnthropicToken } from "./anthropic.js"; import { anthropicOAuthProvider, refreshAnthropicToken } from "./anthropic.js";
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
describe("Anthropic OAuth token responses", () => { describe("Anthropic OAuth token responses", () => {
it("cancels provider login before opening the OAuth flow", async () => {
const controller = new AbortController();
controller.abort();
await expect(
anthropicOAuthProvider.login({
onAuth: vi.fn(),
onPrompt: vi.fn(async () => "unused-code"),
signal: controller.signal,
}),
).rejects.toThrow("Login cancelled");
});
it("does not open the OAuth flow after cancellation during setup", async () => {
const controller = new AbortController();
const onAuth = vi.fn();
const loginPromise = anthropicOAuthProvider.login({
onAuth,
onPrompt: vi.fn(async () => "unused-code"),
signal: controller.signal,
});
controller.abort();
await expect(loginPromise).rejects.toThrow("Login cancelled");
expect(onAuth).not.toHaveBeenCalled();
});
it("does not echo token payload values when refresh JSON parsing fails", async () => { it("does not echo token payload values when refresh JSON parsing fails", async () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",

View File

@@ -6,6 +6,12 @@
*/ */
import type { Server } from "node:http"; import type { Server } from "node:http";
import {
buildOAuthRequestSignal,
createOAuthLoginCancelledError,
throwIfOAuthLoginAborted,
withOAuthLoginAbort,
} from "./abort.js";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js";
import { generateOAuthState, generatePKCE } from "./pkce.js"; import { generateOAuthState, generatePKCE } from "./pkce.js";
import type { import type {
@@ -191,7 +197,13 @@ async function startCallbackServer(expectedState: string): Promise<CallbackServe
}); });
} }
async function postJson(url: string, body: Record<string, string | number>): Promise<string> { async function postJson(
url: string,
body: Record<string, string | number>,
options: { signal?: AbortSignal; timeoutMs?: number } = {},
): Promise<string> {
const timeoutMs = options.timeoutMs ?? 30_000;
throwIfOAuthLoginAborted(options.signal);
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
headers: { headers: {
@@ -199,7 +211,7 @@ async function postJson(url: string, body: Record<string, string | number>): Pro
Accept: "application/json", Accept: "application/json",
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000), signal: buildOAuthRequestSignal({ signal: options.signal, timeoutMs }),
}); });
const responseBody = await response.text(); const responseBody = await response.text();
@@ -218,18 +230,26 @@ async function exchangeAuthorizationCode(
state: string, state: string,
verifier: string, verifier: string,
redirectUri: string, redirectUri: string,
signal?: AbortSignal,
): Promise<OAuthCredentials> { ): Promise<OAuthCredentials> {
let responseBody: string; let responseBody: string;
try { try {
responseBody = await postJson(TOKEN_URL, { responseBody = await postJson(
grant_type: "authorization_code", TOKEN_URL,
client_id: CLIENT_ID, {
code, grant_type: "authorization_code",
state, client_id: CLIENT_ID,
redirect_uri: redirectUri, code,
code_verifier: verifier, state,
}); redirect_uri: redirectUri,
code_verifier: verifier,
},
{ signal },
);
} catch (error) { } catch (error) {
if (signal?.aborted) {
throw createOAuthLoginCancelledError();
}
throw new Error( throw new Error(
`Token exchange request failed. url=${TOKEN_URL}; redirect_uri=${redirectUri}; response_type=authorization_code; details=${formatErrorDetails(error)}`, `Token exchange request failed. url=${TOKEN_URL}; redirect_uri=${redirectUri}; response_type=authorization_code; details=${formatErrorDetails(error)}`,
{ cause: error }, { cause: error },
@@ -265,7 +285,9 @@ export async function loginAnthropic(options: {
onPrompt: (prompt: OAuthPrompt) => Promise<string>; onPrompt: (prompt: OAuthPrompt) => Promise<string>;
onProgress?: (message: string) => void; onProgress?: (message: string) => void;
onManualCodeInput?: () => Promise<string>; onManualCodeInput?: () => Promise<string>;
signal?: AbortSignal;
}): Promise<OAuthCredentials> { }): Promise<OAuthCredentials> {
throwIfOAuthLoginAborted(options.signal);
const { verifier, challenge } = await generatePKCE(); const { verifier, challenge } = await generatePKCE();
const expectedState = generateOAuthState(); const expectedState = generateOAuthState();
const server = await startCallbackServer(expectedState); const server = await startCallbackServer(expectedState);
@@ -275,6 +297,7 @@ export async function loginAnthropic(options: {
let redirectUriForExchange = REDIRECT_URI; let redirectUriForExchange = REDIRECT_URI;
try { try {
throwIfOAuthLoginAborted(options.signal);
const authParams = new URLSearchParams({ const authParams = new URLSearchParams({
code: "true", code: "true",
client_id: CLIENT_ID, client_id: CLIENT_ID,
@@ -291,6 +314,7 @@ export async function loginAnthropic(options: {
instructions: instructions:
"Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.", "Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.",
}); });
throwIfOAuthLoginAborted(options.signal);
if (options.onManualCodeInput) { if (options.onManualCodeInput) {
let manualInput: string | undefined; let manualInput: string | undefined;
@@ -306,7 +330,11 @@ export async function loginAnthropic(options: {
server.cancelWait(); server.cancelWait();
}); });
const result = await server.waitForCode(); const result = await withOAuthLoginAbort(
server.waitForCode(),
options.signal,
server.cancelWait,
);
if (manualError) { if (manualError) {
throw manualError; throw manualError;
@@ -326,7 +354,7 @@ export async function loginAnthropic(options: {
} }
if (!code) { if (!code) {
await manualPromise; await withOAuthLoginAbort(manualPromise, options.signal, server.cancelWait);
if (manualError) { if (manualError) {
throw manualError; throw manualError;
} }
@@ -340,7 +368,11 @@ export async function loginAnthropic(options: {
} }
} }
} else { } else {
const result = await server.waitForCode(); const result = await withOAuthLoginAbort(
server.waitForCode(),
options.signal,
server.cancelWait,
);
if (result?.code) { if (result?.code) {
code = result.code; code = result.code;
state = result.state; state = result.state;
@@ -349,10 +381,14 @@ export async function loginAnthropic(options: {
} }
if (!code) { if (!code) {
const input = await options.onPrompt({ const input = await withOAuthLoginAbort(
message: "Paste the authorization code or full redirect URL:", options.onPrompt({
placeholder: REDIRECT_URI, message: "Paste the authorization code or full redirect URL:",
}); placeholder: REDIRECT_URI,
}),
options.signal,
server.cancelWait,
);
const parsed = parseAuthorizationInput(input); const parsed = parseAuthorizationInput(input);
if (parsed.state && parsed.state !== expectedState) { if (parsed.state && parsed.state !== expectedState) {
throw new Error("OAuth state mismatch"); throw new Error("OAuth state mismatch");
@@ -370,7 +406,7 @@ export async function loginAnthropic(options: {
} }
options.onProgress?.("Exchanging authorization code for tokens..."); options.onProgress?.("Exchanging authorization code for tokens...");
return exchangeAuthorizationCode(code, state, verifier, redirectUriForExchange); return exchangeAuthorizationCode(code, state, verifier, redirectUriForExchange, options.signal);
} finally { } finally {
server.server.close(); server.server.close();
} }
@@ -427,6 +463,7 @@ export const anthropicOAuthProvider: OAuthProviderInterface = {
onPrompt: callbacks.onPrompt, onPrompt: callbacks.onPrompt,
onProgress: callbacks.onProgress, onProgress: callbacks.onProgress,
onManualCodeInput: callbacks.onManualCodeInput, onManualCodeInput: callbacks.onManualCodeInput,
signal: callbacks.signal,
}); });
}, },

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { refreshOpenAICodexToken, testing } from "./openai-codex.js"; import { openaiCodexOAuthProvider, refreshOpenAICodexToken, testing } from "./openai-codex.js";
function createJwt(payload: Record<string, unknown>): string { function createJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
@@ -58,6 +58,34 @@ afterEach(() => {
}); });
describe("OpenAI Codex OAuth token responses", () => { describe("OpenAI Codex OAuth token responses", () => {
it("cancels provider login before opening the OAuth flow", async () => {
const controller = new AbortController();
controller.abort();
await expect(
openaiCodexOAuthProvider.login({
onAuth: vi.fn(),
onPrompt: vi.fn(async () => "unused-code"),
signal: controller.signal,
}),
).rejects.toThrow("Login cancelled");
});
it("does not open the OAuth flow after cancellation during setup", async () => {
const controller = new AbortController();
const onAuth = vi.fn();
const loginPromise = openaiCodexOAuthProvider.login({
onAuth,
onPrompt: vi.fn(async () => "unused-code"),
signal: controller.signal,
});
controller.abort();
await expect(loginPromise).rejects.toThrow("Login cancelled");
expect(onAuth).not.toHaveBeenCalled();
});
it("waits for Node OAuth runtime before creating an authorization flow", async () => { it("waits for Node OAuth runtime before creating an authorization flow", async () => {
const flow = await testing.createAuthorizationFlow("openclaw-test"); const flow = await testing.createAuthorizationFlow("openclaw-test");
const url = new URL(flow.url); const url = new URL(flow.url);
@@ -115,6 +143,23 @@ describe("OpenAI Codex OAuth token responses", () => {
}); });
}); });
it("cancels token exchange requests with the caller signal", async () => {
const controller = new AbortController();
controller.abort();
const result = await testing.exchangeAuthorizationCode(
"code",
"verifier",
testing.resolveRedirectUri("localhost"),
{ signal: controller.signal, timeoutMs: 5 },
);
expect(result).toMatchObject({
type: "failed",
message: "Login cancelled",
});
});
it("does not echo token payload values when the refresh response is malformed", async () => { it("does not echo token payload values when the refresh response is malformed", async () => {
stubTokenResponse({ stubTokenResponse({
access_token: "new-secret-access-token", access_token: "new-secret-access-token",

View File

@@ -5,6 +5,12 @@
* It is only intended for CLI use, not browser environments. * It is only intended for CLI use, not browser environments.
*/ */
import {
buildOAuthRequestSignal,
createOAuthLoginCancelledError,
throwIfOAuthLoginAborted,
withOAuthLoginAbort,
} from "./abort.js";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js";
import { resolveOpenAICodexAccountId } from "./openai-codex-jwt.js"; import { resolveOpenAICodexAccountId } from "./openai-codex-jwt.js";
import { generatePKCE } from "./pkce.js"; import { generatePKCE } from "./pkce.js";
@@ -40,6 +46,7 @@ type NodeOAuthRuntime = {
http: typeof import("node:http"); http: typeof import("node:http");
}; };
type TokenRequestOptions = { type TokenRequestOptions = {
signal?: AbortSignal;
timeoutMs?: number; timeoutMs?: number;
}; };
@@ -79,9 +86,27 @@ function createState(randomBytes: typeof import("node:crypto").randomBytes): str
return randomBytes(16).toString("hex"); return randomBytes(16).toString("hex");
} }
function waitForManualPromptFallback(): Promise<null> { function waitForManualPromptFallback(signal?: AbortSignal): Promise<null> {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
const timeout = setTimeout(() => resolve(null), MANUAL_PROMPT_FALLBACK_MS); if (signal?.aborted) {
reject(createOAuthLoginCancelledError());
return;
}
const cleanup = () => {
signal?.removeEventListener("abort", abort);
};
const abort = () => {
clearTimeout(timeout);
cleanup();
reject(createOAuthLoginCancelledError());
};
const timeout = setTimeout(() => {
cleanup();
resolve(null);
}, MANUAL_PROMPT_FALLBACK_MS);
signal?.addEventListener("abort", abort, { once: true });
timeout.unref?.(); timeout.unref?.();
}); });
} }
@@ -150,7 +175,11 @@ function formatTokenRequestError(
operation: "exchange" | "refresh", operation: "exchange" | "refresh",
error: unknown, error: unknown,
timeoutMs: number, timeoutMs: number,
signal?: AbortSignal,
): string { ): string {
if (signal?.aborted) {
return "Login cancelled";
}
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) { if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
return `OpenAI Codex token ${operation} timed out after ${timeoutMs}ms`; return `OpenAI Codex token ${operation} timed out after ${timeoutMs}ms`;
} }
@@ -166,6 +195,7 @@ async function exchangeAuthorizationCode(
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS; const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
let response: Response; let response: Response;
try { try {
throwIfOAuthLoginAborted(options.signal);
response = await fetch(TOKEN_URL, { response = await fetch(TOKEN_URL, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -176,12 +206,12 @@ async function exchangeAuthorizationCode(
code_verifier: verifier, code_verifier: verifier,
redirect_uri: redirectUri, redirect_uri: redirectUri,
}), }),
signal: AbortSignal.timeout(timeoutMs), signal: buildOAuthRequestSignal({ signal: options.signal, timeoutMs }),
}); });
} catch (error) { } catch (error) {
return { return {
type: "failed", type: "failed",
message: formatTokenRequestError("exchange", error, timeoutMs), message: formatTokenRequestError("exchange", error, timeoutMs, options.signal),
}; };
} }
@@ -217,6 +247,7 @@ async function refreshAccessToken(
): Promise<TokenResult> { ): Promise<TokenResult> {
try { try {
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS; const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
throwIfOAuthLoginAborted(options.signal);
const response = await fetch(TOKEN_URL, { const response = await fetch(TOKEN_URL, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -225,7 +256,7 @@ async function refreshAccessToken(
refresh_token: refreshToken, refresh_token: refreshToken,
client_id: CLIENT_ID, client_id: CLIENT_ID,
}), }),
signal: AbortSignal.timeout(timeoutMs), signal: buildOAuthRequestSignal({ signal: options.signal, timeoutMs }),
}); });
if (!response.ok) { if (!response.ok) {
@@ -259,6 +290,7 @@ async function refreshAccessToken(
"refresh", "refresh",
error, error,
options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS, options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS,
options.signal,
), ),
}; };
} }
@@ -391,14 +423,21 @@ export async function loginOpenAICodex(options: {
onProgress?: (message: string) => void; onProgress?: (message: string) => void;
onManualCodeInput?: () => Promise<string>; onManualCodeInput?: () => Promise<string>;
originator?: string; originator?: string;
signal?: AbortSignal;
}): Promise<OAuthCredentials> { }): Promise<OAuthCredentials> {
throwIfOAuthLoginAborted(options.signal);
const { verifier, redirectUri, state, url } = await createAuthorizationFlow(options.originator); const { verifier, redirectUri, state, url } = await createAuthorizationFlow(options.originator);
const server = await startLocalOAuthServer(state); const server = await startLocalOAuthServer(state);
options.onAuth({ url, instructions: "A browser window should open. Complete login to finish." });
let code: string | undefined; let code: string | undefined;
try { try {
throwIfOAuthLoginAborted(options.signal);
options.onAuth({
url,
instructions: "A browser window should open. Complete login to finish.",
});
throwIfOAuthLoginAborted(options.signal);
if (options.onManualCodeInput) { if (options.onManualCodeInput) {
// Race between browser callback and manual input // Race between browser callback and manual input
let manualCode: string | undefined; let manualCode: string | undefined;
@@ -414,7 +453,11 @@ export async function loginOpenAICodex(options: {
server.cancelWait(); server.cancelWait();
}); });
const result = await server.waitForCode(); const result = await withOAuthLoginAbort(
server.waitForCode(),
options.signal,
server.cancelWait,
);
// If manual input was cancelled, throw that error // If manual input was cancelled, throw that error
if (manualError) { if (manualError) {
@@ -435,7 +478,7 @@ export async function loginOpenAICodex(options: {
// If still no code, wait for manual promise to complete and try that // If still no code, wait for manual promise to complete and try that
if (!code) { if (!code) {
await manualPromise; await withOAuthLoginAbort(manualPromise, options.signal, server.cancelWait);
if (manualError) { if (manualError) {
throw manualError; throw manualError;
} }
@@ -449,7 +492,11 @@ export async function loginOpenAICodex(options: {
} }
} else { } else {
const callbackPromise = server.waitForCode(); const callbackPromise = server.waitForCode();
const result = await Promise.race([callbackPromise, waitForManualPromptFallback()]); const result = await withOAuthLoginAbort(
Promise.race([callbackPromise, waitForManualPromptFallback(options.signal)]),
options.signal,
server.cancelWait,
);
if (result?.code) { if (result?.code) {
code = result.code; code = result.code;
} else { } else {
@@ -459,23 +506,30 @@ export async function loginOpenAICodex(options: {
return promptCode; return promptCode;
}, },
); );
code = await Promise.race([ code = await withOAuthLoginAbort(
callbackPromise.then((callback) => callback?.code), Promise.race([callbackPromise.then((callback) => callback?.code), promptCodePromise]),
promptCodePromise, options.signal,
]); server.cancelWait,
);
} }
} }
// Fallback to onPrompt if still no code // Fallback to onPrompt if still no code
if (!code) { if (!code) {
code = await promptForAuthorizationCode(options.onPrompt, state); code = await withOAuthLoginAbort(
promptForAuthorizationCode(options.onPrompt, state),
options.signal,
server.cancelWait,
);
} }
if (!code) { if (!code) {
throw new Error("Missing authorization code"); throw new Error("Missing authorization code");
} }
const tokenResult = await exchangeAuthorizationCode(code, verifier, redirectUri); const tokenResult = await exchangeAuthorizationCode(code, verifier, redirectUri, {
signal: options.signal,
});
if (tokenResult.type !== "success") { if (tokenResult.type !== "success") {
throw new Error(tokenResult.message); throw new Error(tokenResult.message);
} }
@@ -529,6 +583,7 @@ export const openaiCodexOAuthProvider: OAuthProviderInterface = {
onPrompt: callbacks.onPrompt, onPrompt: callbacks.onPrompt,
onProgress: callbacks.onProgress, onProgress: callbacks.onProgress,
onManualCodeInput: callbacks.onManualCodeInput, onManualCodeInput: callbacks.onManualCodeInput,
signal: callbacks.signal,
}); });
}, },
@@ -545,6 +600,7 @@ export const testing = {
callbackHost: CALLBACK_HOST, callbackHost: CALLBACK_HOST,
createAuthorizationFlow, createAuthorizationFlow,
exchangeAuthorizationCode, exchangeAuthorizationCode,
loginOpenAICodex,
refreshAccessToken, refreshAccessToken,
resolveCallbackHost, resolveCallbackHost,
resolveRedirectUri, resolveRedirectUri,