mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(auth): honor OAuth login cancellation
This commit is contained in:
@@ -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)
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
|
||||
47
extensions/openai/openai-codex-oauth-abort.runtime.ts
Normal file
47
extensions/openai/openai-codex-oauth-abort.runtime.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -8,7 +8,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
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 {
|
||||
return new DOMException("timed out", "TimeoutError");
|
||||
@@ -19,6 +19,34 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const flow = await testing.createAuthorizationFlow("openclaw-test");
|
||||
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 () => {
|
||||
ssrfMocks.fetchWithSsrFGuard.mockRejectedValueOnce(timeoutError());
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
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 type {
|
||||
OAuthCredentials,
|
||||
@@ -42,6 +47,7 @@ type NodeOAuthRuntime = {
|
||||
http: typeof import("node:http");
|
||||
};
|
||||
type TokenRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
@@ -81,9 +87,27 @@ function createState(randomBytes: typeof import("node:crypto").randomBytes): str
|
||||
return randomBytes(16).toString("hex");
|
||||
}
|
||||
|
||||
function waitForManualPromptFallback(): Promise<null> {
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(null), MANUAL_PROMPT_FALLBACK_MS);
|
||||
function waitForManualPromptFallback(signal?: AbortSignal): Promise<null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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?.();
|
||||
});
|
||||
}
|
||||
@@ -152,7 +176,11 @@ function formatTokenRequestError(
|
||||
operation: "exchange" | "refresh",
|
||||
error: unknown,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): string {
|
||||
if (signal?.aborted) {
|
||||
return "Login cancelled";
|
||||
}
|
||||
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
|
||||
return `OpenAI Codex token ${operation} timed out after ${timeoutMs}ms`;
|
||||
}
|
||||
@@ -164,6 +192,7 @@ async function postTokenForm(
|
||||
options: TokenRequestOptions = {},
|
||||
): Promise<Response> {
|
||||
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
|
||||
throwIfOAuthLoginAborted(options.signal);
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: TOKEN_URL,
|
||||
init: {
|
||||
@@ -172,6 +201,7 @@ async function postTokenForm(
|
||||
body,
|
||||
},
|
||||
timeoutMs,
|
||||
signal: options.signal,
|
||||
auditContext: "openai-codex-oauth-token",
|
||||
});
|
||||
try {
|
||||
@@ -203,12 +233,12 @@ async function exchangeAuthorizationCode(
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
{ timeoutMs },
|
||||
{ signal: options.signal, timeoutMs },
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
type: "failed",
|
||||
message: formatTokenRequestError("exchange", error, timeoutMs),
|
||||
message: formatTokenRequestError("exchange", error, timeoutMs, options.signal),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -250,7 +280,7 @@ async function refreshAccessToken(
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
}),
|
||||
{ timeoutMs },
|
||||
{ signal: options.signal, timeoutMs },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -284,6 +314,7 @@ async function refreshAccessToken(
|
||||
"refresh",
|
||||
error,
|
||||
options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS,
|
||||
options.signal,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -417,14 +448,21 @@ export async function loginOpenAICodex(options: {
|
||||
onProgress?: (message: string) => void;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
originator?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<OAuthCredentials> {
|
||||
throwIfOAuthLoginAborted(options.signal);
|
||||
const { verifier, redirectUri, state, url } = await createAuthorizationFlow(options.originator);
|
||||
const server = await startLocalOAuthServer(state);
|
||||
|
||||
options.onAuth({ url, instructions: "A browser window should open. Complete login to finish." });
|
||||
|
||||
let code: string | undefined;
|
||||
try {
|
||||
throwIfOAuthLoginAborted(options.signal);
|
||||
options.onAuth({
|
||||
url,
|
||||
instructions: "A browser window should open. Complete login to finish.",
|
||||
});
|
||||
throwIfOAuthLoginAborted(options.signal);
|
||||
|
||||
if (options.onManualCodeInput) {
|
||||
// Race between browser callback and manual input
|
||||
let manualCode: string | undefined;
|
||||
@@ -440,7 +478,11 @@ export async function loginOpenAICodex(options: {
|
||||
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 (manualError) {
|
||||
@@ -461,7 +503,7 @@ export async function loginOpenAICodex(options: {
|
||||
|
||||
// If still no code, wait for manual promise to complete and try that
|
||||
if (!code) {
|
||||
await manualPromise;
|
||||
await withOAuthLoginAbort(manualPromise, options.signal, server.cancelWait);
|
||||
if (manualError) {
|
||||
throw manualError;
|
||||
}
|
||||
@@ -475,7 +517,11 @@ export async function loginOpenAICodex(options: {
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
code = result.code;
|
||||
} else {
|
||||
@@ -485,23 +531,30 @@ export async function loginOpenAICodex(options: {
|
||||
return promptCode;
|
||||
},
|
||||
);
|
||||
code = await Promise.race([
|
||||
callbackPromise.then((callback) => callback?.code),
|
||||
promptCodePromise,
|
||||
]);
|
||||
code = await withOAuthLoginAbort(
|
||||
Promise.race([callbackPromise.then((callback) => callback?.code), promptCodePromise]),
|
||||
options.signal,
|
||||
server.cancelWait,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to onPrompt if still no code
|
||||
if (!code) {
|
||||
code = await promptForAuthorizationCode(options.onPrompt, state);
|
||||
code = await withOAuthLoginAbort(
|
||||
promptForAuthorizationCode(options.onPrompt, state),
|
||||
options.signal,
|
||||
server.cancelWait,
|
||||
);
|
||||
}
|
||||
|
||||
if (!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") {
|
||||
throw new Error(tokenResult.message);
|
||||
}
|
||||
@@ -555,6 +608,7 @@ export const openaiCodexOAuthProvider: OAuthProviderInterface = {
|
||||
onPrompt: callbacks.onPrompt,
|
||||
onProgress: callbacks.onProgress,
|
||||
onManualCodeInput: callbacks.onManualCodeInput,
|
||||
signal: callbacks.signal,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -571,6 +625,7 @@ export const testing = {
|
||||
callbackHost: CALLBACK_HOST,
|
||||
createAuthorizationFlow,
|
||||
exchangeAuthorizationCode,
|
||||
loginOpenAICodex,
|
||||
refreshAccessToken,
|
||||
resolveCallbackHost,
|
||||
resolveRedirectUri,
|
||||
|
||||
58
src/llm/utils/oauth/abort.ts
Normal file
58
src/llm/utils/oauth/abort.ts
Normal 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]);
|
||||
}
|
||||
@@ -1,11 +1,39 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { refreshAnthropicToken } from "./anthropic.js";
|
||||
import { anthropicOAuthProvider, refreshAnthropicToken } from "./anthropic.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
*/
|
||||
|
||||
import type { Server } from "node:http";
|
||||
import {
|
||||
buildOAuthRequestSignal,
|
||||
createOAuthLoginCancelledError,
|
||||
throwIfOAuthLoginAborted,
|
||||
withOAuthLoginAbort,
|
||||
} from "./abort.js";
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js";
|
||||
import { generateOAuthState, generatePKCE } from "./pkce.js";
|
||||
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, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -199,7 +211,7 @@ async function postJson(url: string, body: Record<string, string | number>): Pro
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
signal: buildOAuthRequestSignal({ signal: options.signal, timeoutMs }),
|
||||
});
|
||||
|
||||
const responseBody = await response.text();
|
||||
@@ -218,18 +230,26 @@ async function exchangeAuthorizationCode(
|
||||
state: string,
|
||||
verifier: string,
|
||||
redirectUri: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<OAuthCredentials> {
|
||||
let responseBody: string;
|
||||
try {
|
||||
responseBody = await postJson(TOKEN_URL, {
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
state,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
});
|
||||
responseBody = await postJson(
|
||||
TOKEN_URL,
|
||||
{
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
state,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
throw createOAuthLoginCancelledError();
|
||||
}
|
||||
throw new Error(
|
||||
`Token exchange request failed. url=${TOKEN_URL}; redirect_uri=${redirectUri}; response_type=authorization_code; details=${formatErrorDetails(error)}`,
|
||||
{ cause: error },
|
||||
@@ -265,7 +285,9 @@ export async function loginAnthropic(options: {
|
||||
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
|
||||
onProgress?: (message: string) => void;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<OAuthCredentials> {
|
||||
throwIfOAuthLoginAborted(options.signal);
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
const expectedState = generateOAuthState();
|
||||
const server = await startCallbackServer(expectedState);
|
||||
@@ -275,6 +297,7 @@ export async function loginAnthropic(options: {
|
||||
let redirectUriForExchange = REDIRECT_URI;
|
||||
|
||||
try {
|
||||
throwIfOAuthLoginAborted(options.signal);
|
||||
const authParams = new URLSearchParams({
|
||||
code: "true",
|
||||
client_id: CLIENT_ID,
|
||||
@@ -291,6 +314,7 @@ export async function loginAnthropic(options: {
|
||||
instructions:
|
||||
"Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.",
|
||||
});
|
||||
throwIfOAuthLoginAborted(options.signal);
|
||||
|
||||
if (options.onManualCodeInput) {
|
||||
let manualInput: string | undefined;
|
||||
@@ -306,7 +330,11 @@ export async function loginAnthropic(options: {
|
||||
server.cancelWait();
|
||||
});
|
||||
|
||||
const result = await server.waitForCode();
|
||||
const result = await withOAuthLoginAbort(
|
||||
server.waitForCode(),
|
||||
options.signal,
|
||||
server.cancelWait,
|
||||
);
|
||||
|
||||
if (manualError) {
|
||||
throw manualError;
|
||||
@@ -326,7 +354,7 @@ export async function loginAnthropic(options: {
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
await manualPromise;
|
||||
await withOAuthLoginAbort(manualPromise, options.signal, server.cancelWait);
|
||||
if (manualError) {
|
||||
throw manualError;
|
||||
}
|
||||
@@ -340,7 +368,11 @@ export async function loginAnthropic(options: {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const result = await server.waitForCode();
|
||||
const result = await withOAuthLoginAbort(
|
||||
server.waitForCode(),
|
||||
options.signal,
|
||||
server.cancelWait,
|
||||
);
|
||||
if (result?.code) {
|
||||
code = result.code;
|
||||
state = result.state;
|
||||
@@ -349,10 +381,14 @@ export async function loginAnthropic(options: {
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
const input = await options.onPrompt({
|
||||
message: "Paste the authorization code or full redirect URL:",
|
||||
placeholder: REDIRECT_URI,
|
||||
});
|
||||
const input = await withOAuthLoginAbort(
|
||||
options.onPrompt({
|
||||
message: "Paste the authorization code or full redirect URL:",
|
||||
placeholder: REDIRECT_URI,
|
||||
}),
|
||||
options.signal,
|
||||
server.cancelWait,
|
||||
);
|
||||
const parsed = parseAuthorizationInput(input);
|
||||
if (parsed.state && parsed.state !== expectedState) {
|
||||
throw new Error("OAuth state mismatch");
|
||||
@@ -370,7 +406,7 @@ export async function loginAnthropic(options: {
|
||||
}
|
||||
|
||||
options.onProgress?.("Exchanging authorization code for tokens...");
|
||||
return exchangeAuthorizationCode(code, state, verifier, redirectUriForExchange);
|
||||
return exchangeAuthorizationCode(code, state, verifier, redirectUriForExchange, options.signal);
|
||||
} finally {
|
||||
server.server.close();
|
||||
}
|
||||
@@ -427,6 +463,7 @@ export const anthropicOAuthProvider: OAuthProviderInterface = {
|
||||
onPrompt: callbacks.onPrompt,
|
||||
onProgress: callbacks.onProgress,
|
||||
onManualCodeInput: callbacks.onManualCodeInput,
|
||||
signal: callbacks.signal,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
|
||||
@@ -58,6 +58,34 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const flow = await testing.createAuthorizationFlow("openclaw-test");
|
||||
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 () => {
|
||||
stubTokenResponse({
|
||||
access_token: "new-secret-access-token",
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
* 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 { resolveOpenAICodexAccountId } from "./openai-codex-jwt.js";
|
||||
import { generatePKCE } from "./pkce.js";
|
||||
@@ -40,6 +46,7 @@ type NodeOAuthRuntime = {
|
||||
http: typeof import("node:http");
|
||||
};
|
||||
type TokenRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
@@ -79,9 +86,27 @@ function createState(randomBytes: typeof import("node:crypto").randomBytes): str
|
||||
return randomBytes(16).toString("hex");
|
||||
}
|
||||
|
||||
function waitForManualPromptFallback(): Promise<null> {
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(null), MANUAL_PROMPT_FALLBACK_MS);
|
||||
function waitForManualPromptFallback(signal?: AbortSignal): Promise<null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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?.();
|
||||
});
|
||||
}
|
||||
@@ -150,7 +175,11 @@ function formatTokenRequestError(
|
||||
operation: "exchange" | "refresh",
|
||||
error: unknown,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): string {
|
||||
if (signal?.aborted) {
|
||||
return "Login cancelled";
|
||||
}
|
||||
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
|
||||
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;
|
||||
let response: Response;
|
||||
try {
|
||||
throwIfOAuthLoginAborted(options.signal);
|
||||
response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
@@ -176,12 +206,12 @@ async function exchangeAuthorizationCode(
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
signal: buildOAuthRequestSignal({ signal: options.signal, timeoutMs }),
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
type: "failed",
|
||||
message: formatTokenRequestError("exchange", error, timeoutMs),
|
||||
message: formatTokenRequestError("exchange", error, timeoutMs, options.signal),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -217,6 +247,7 @@ async function refreshAccessToken(
|
||||
): Promise<TokenResult> {
|
||||
try {
|
||||
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
|
||||
throwIfOAuthLoginAborted(options.signal);
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
@@ -225,7 +256,7 @@ async function refreshAccessToken(
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
}),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
signal: buildOAuthRequestSignal({ signal: options.signal, timeoutMs }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -259,6 +290,7 @@ async function refreshAccessToken(
|
||||
"refresh",
|
||||
error,
|
||||
options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS,
|
||||
options.signal,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -391,14 +423,21 @@ export async function loginOpenAICodex(options: {
|
||||
onProgress?: (message: string) => void;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
originator?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<OAuthCredentials> {
|
||||
throwIfOAuthLoginAborted(options.signal);
|
||||
const { verifier, redirectUri, state, url } = await createAuthorizationFlow(options.originator);
|
||||
const server = await startLocalOAuthServer(state);
|
||||
|
||||
options.onAuth({ url, instructions: "A browser window should open. Complete login to finish." });
|
||||
|
||||
let code: string | undefined;
|
||||
try {
|
||||
throwIfOAuthLoginAborted(options.signal);
|
||||
options.onAuth({
|
||||
url,
|
||||
instructions: "A browser window should open. Complete login to finish.",
|
||||
});
|
||||
throwIfOAuthLoginAborted(options.signal);
|
||||
|
||||
if (options.onManualCodeInput) {
|
||||
// Race between browser callback and manual input
|
||||
let manualCode: string | undefined;
|
||||
@@ -414,7 +453,11 @@ export async function loginOpenAICodex(options: {
|
||||
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 (manualError) {
|
||||
@@ -435,7 +478,7 @@ export async function loginOpenAICodex(options: {
|
||||
|
||||
// If still no code, wait for manual promise to complete and try that
|
||||
if (!code) {
|
||||
await manualPromise;
|
||||
await withOAuthLoginAbort(manualPromise, options.signal, server.cancelWait);
|
||||
if (manualError) {
|
||||
throw manualError;
|
||||
}
|
||||
@@ -449,7 +492,11 @@ export async function loginOpenAICodex(options: {
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
code = result.code;
|
||||
} else {
|
||||
@@ -459,23 +506,30 @@ export async function loginOpenAICodex(options: {
|
||||
return promptCode;
|
||||
},
|
||||
);
|
||||
code = await Promise.race([
|
||||
callbackPromise.then((callback) => callback?.code),
|
||||
promptCodePromise,
|
||||
]);
|
||||
code = await withOAuthLoginAbort(
|
||||
Promise.race([callbackPromise.then((callback) => callback?.code), promptCodePromise]),
|
||||
options.signal,
|
||||
server.cancelWait,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to onPrompt if still no code
|
||||
if (!code) {
|
||||
code = await promptForAuthorizationCode(options.onPrompt, state);
|
||||
code = await withOAuthLoginAbort(
|
||||
promptForAuthorizationCode(options.onPrompt, state),
|
||||
options.signal,
|
||||
server.cancelWait,
|
||||
);
|
||||
}
|
||||
|
||||
if (!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") {
|
||||
throw new Error(tokenResult.message);
|
||||
}
|
||||
@@ -529,6 +583,7 @@ export const openaiCodexOAuthProvider: OAuthProviderInterface = {
|
||||
onPrompt: callbacks.onPrompt,
|
||||
onProgress: callbacks.onProgress,
|
||||
onManualCodeInput: callbacks.onManualCodeInput,
|
||||
signal: callbacks.signal,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -545,6 +600,7 @@ export const testing = {
|
||||
callbackHost: CALLBACK_HOST,
|
||||
createAuthorizationFlow,
|
||||
exchangeAuthorizationCode,
|
||||
loginOpenAICodex,
|
||||
refreshAccessToken,
|
||||
resolveCallbackHost,
|
||||
resolveRedirectUri,
|
||||
|
||||
Reference in New Issue
Block a user