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)
- 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.

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,
}));
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());

View File

@@ -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,

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 { 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",

View File

@@ -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,
});
},

View File

@@ -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",

View File

@@ -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,