Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
};
};

const resolveOAuthFetchTimeoutMs = (): number => {
return getFetchTimeoutMs(loadPluginConfig());
};

const buildManualOAuthFlow = (
pkce: { verifier: string },
url: string,
Expand Down Expand Up @@ -460,10 +464,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
message: "OAuth state mismatch. Restart login and try again.",
};
}
const oauthFetchTimeoutMs = resolveOAuthFetchTimeoutMs();
const tokens = await exchangeAuthorizationCode(
parsed.code,
pkce.verifier,
REDIRECT_URI,
{ timeoutMs: oauthFetchTimeoutMs },
);
if (tokens?.type === "success") {
const resolved = resolveAccountSelection(tokens);
Expand Down Expand Up @@ -509,10 +515,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
return { type: "failed" as const, reason: "unknown" as const, message: "OAuth callback timeout or cancelled" };
}

const oauthFetchTimeoutMs = resolveOAuthFetchTimeoutMs();
return await exchangeAuthorizationCode(
result.code,
pkce.verifier,
REDIRECT_URI,
{ timeoutMs: oauthFetchTimeoutMs },
);
};

Expand Down
166 changes: 128 additions & 38 deletions lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
export const TOKEN_URL = "https://auth.openai.com/oauth/token";
export const REDIRECT_URI = "http://localhost:1455/auth/callback";
export const SCOPE = "openid profile email offline_access";
const DEFAULT_OAUTH_EXCHANGE_TIMEOUT_MS = 60_000;

const OAUTH_SENSITIVE_QUERY_PARAMS = [
"state",
Expand Down Expand Up @@ -111,50 +112,134 @@ export function parseAuthorizationInput(input: string): ParsedAuthInput {
* @param redirectUri - OAuth redirect URI
* @returns Token result
*/
export type ExchangeAuthorizationCodeOptions = {
signal?: AbortSignal;
timeoutMs?: number;
};

function resolveExchangeTimeoutMs(timeoutMs: number | undefined): number {
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
return DEFAULT_OAUTH_EXCHANGE_TIMEOUT_MS;
}
return Math.max(1_000, Math.floor(timeoutMs));
}

function createAbortError(message: string): Error & { code?: string } {
const error = new Error(message) as Error & { code?: string };
error.name = "AbortError";
error.code = "ABORT_ERR";
return error;
}

function buildExchangeAbortContext(
options: ExchangeAuthorizationCodeOptions,
): { signal: AbortSignal; cleanup: () => void } {
const controller = new AbortController();
const timeoutMs = resolveExchangeTimeoutMs(options.timeoutMs);
const upstreamSignal = options.signal;
let timeoutId: ReturnType<typeof setTimeout> | undefined;

const onUpstreamAbort = () => {
const reason = upstreamSignal?.reason;
controller.abort(
reason instanceof Error ? reason : createAbortError("Request aborted"),
);
};

if (upstreamSignal) {
upstreamSignal.addEventListener("abort", onUpstreamAbort, { once: true });
if (upstreamSignal.aborted) {
onUpstreamAbort();
}
}

if (!controller.signal.aborted) {
timeoutId = setTimeout(() => {
controller.abort(
createAbortError(
`OAuth token exchange timed out after ${timeoutMs}ms`,
),
);
}, timeoutMs);
}

return {
signal: controller.signal,
cleanup: () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
if (upstreamSignal) {
upstreamSignal.removeEventListener("abort", onUpstreamAbort);
}
},
};
}

export async function exchangeAuthorizationCode(
code: string,
verifier: string,
redirectUri: string = REDIRECT_URI,
options: ExchangeAuthorizationCodeOptions = {},
): Promise<TokenResult> {
const res = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code,
code_verifier: verifier,
redirect_uri: redirectUri,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
logError(`code->token failed: ${res.status} ${text}`);
return { type: "failed", reason: "http_error", statusCode: res.status, message: text || undefined };
}
const rawJson = (await res.json()) as unknown;
const json = safeParseOAuthTokenResponse(rawJson);
if (!json) {
logError("token response validation failed", getOAuthResponseLogMetadata(rawJson));
return { type: "failed", reason: "invalid_response", message: "Response failed schema validation" };
}
if (!json.refresh_token || json.refresh_token.trim().length === 0) {
logError("token response missing refresh token", getOAuthResponseLogMetadata(rawJson));
const abortContext = buildExchangeAbortContext(options);
try {
const res = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
signal: abortContext.signal,
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code,
code_verifier: verifier,
redirect_uri: redirectUri,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
logError("code->token failed", { status: res.status, bodyLength: text.length });
return {
type: "failed",
reason: "http_error",
statusCode: res.status,
message: text ? "OAuth token exchange failed" : undefined,
};
}
const rawJson = (await res.json()) as unknown;
const json = safeParseOAuthTokenResponse(rawJson);
if (!json) {
logError("token response validation failed", getOAuthResponseLogMetadata(rawJson));
return { type: "failed", reason: "invalid_response", message: "Response failed schema validation" };
}
if (!json.refresh_token || json.refresh_token.trim().length === 0) {
logError("token response missing refresh token", getOAuthResponseLogMetadata(rawJson));
return {
type: "failed",
reason: "invalid_response",
message: "Missing refresh token in authorization code exchange response",
};
}
const normalizedRefreshToken = json.refresh_token.trim();
return {
type: "failed",
reason: "invalid_response",
message: "Missing refresh token in authorization code exchange response",
type: "success",
access: json.access_token,
refresh: normalizedRefreshToken,
expires: Date.now() + json.expires_in * 1000,
idToken: json.id_token,
multiAccount: true,
};
} catch (error) {
const err = error as Error;
if (abortContext.signal.aborted || isAbortError(err)) {
logError("code->token aborted", { message: err?.message ?? "Request aborted" });
return { type: "failed", reason: "unknown", message: err?.message ?? "Request aborted" };
}
logError("code->token error", { message: err?.message ?? String(err) });
return { type: "failed", reason: "network_error", message: err?.message ?? "Network request failed" };
} finally {
abortContext.cleanup();
}
const normalizedRefreshToken = json.refresh_token.trim();
return {
type: "success",
access: json.access_token,
refresh: normalizedRefreshToken,
expires: Date.now() + json.expires_in * 1000,
idToken: json.id_token,
multiAccount: true,
};
}

/**
Expand Down Expand Up @@ -206,8 +291,13 @@ export async function refreshAccessToken(

if (!response.ok) {
const text = await response.text().catch(() => "");
logError(`Token refresh failed: ${response.status} ${text}`);
return { type: "failed", reason: "http_error", statusCode: response.status, message: text || undefined };
logError("Token refresh failed", { status: response.status, bodyLength: text.length });
return {
type: "failed",
reason: "http_error",
statusCode: response.status,
message: text ? "Token refresh failed" : undefined,
};
}

const rawJson = (await response.json()) as unknown;
Expand Down
36 changes: 27 additions & 9 deletions lib/auto-update-checker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { createLogger } from "./logger.js";
import { fetchWithTimeoutAndRetry } from "./network.js";
import { getCodexCacheDir } from "./runtime-paths.js";

const log = createLogger("update-checker");
Expand All @@ -10,6 +11,9 @@ const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
const CACHE_DIR = getCodexCacheDir();
const CACHE_FILE = join(CACHE_DIR, "update-check-cache.json");
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
const UPDATE_FETCH_TIMEOUT_MS = 5_000;
const UPDATE_FETCH_RETRIES = 2;
const UPDATE_FETCH_RETRYABLE_STATUSES = [429, 500, 502, 503, 504] as const;

interface UpdateCheckCache {
lastCheck: number;
Expand Down Expand Up @@ -164,15 +168,29 @@ function compareVersions(current: string, latest: string): number {

async function fetchLatestVersion(): Promise<string | null> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);

const response = await fetch(NPM_REGISTRY_URL, {
signal: controller.signal,
headers: { Accept: "application/json" },
});

clearTimeout(timeout);
const { response, attempts, durationMs } = await fetchWithTimeoutAndRetry(
NPM_REGISTRY_URL,
{
headers: { Accept: "application/json" },
},
{
timeoutMs: UPDATE_FETCH_TIMEOUT_MS,
retries: UPDATE_FETCH_RETRIES,
retryOnStatuses: UPDATE_FETCH_RETRYABLE_STATUSES,
baseDelayMs: 100,
maxDelayMs: 1_000,
jitterMs: 100,
onRetry: (retry) => {
log.debug("Retrying npm update check", retry);
},
},
);
if (attempts > 1) {
log.debug("Recovered npm update check after retries", {
attempts,
durationMs,
});
}

if (!response.ok) {
log.debug("Failed to fetch npm registry", { status: response.status });
Expand Down
10 changes: 9 additions & 1 deletion lib/codex-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
selectBestAccountCandidate,
} from "./accounts.js";
import { ACCOUNT_LIMITS } from "./constants.js";
import { getFetchTimeoutMs, loadPluginConfig } from "./config.js";
import {
loadDashboardDisplaySettings,
DEFAULT_DASHBOARD_DISPLAY_SETTINGS,
Expand Down Expand Up @@ -1251,7 +1252,14 @@ async function runOAuthFlow(forceNewLogin: boolean): Promise<TokenResult> {
message: UI_COPY.oauth.cancelled,
};
}
return exchangeAuthorizationCode(code, pkce.verifier, REDIRECT_URI);
const authPluginConfig = loadPluginConfig();
const oauthFetchTimeoutMs = getFetchTimeoutMs(authPluginConfig);
return exchangeAuthorizationCode(
code,
pkce.verifier,
REDIRECT_URI,
{ timeoutMs: oauthFetchTimeoutMs },
);
}

async function persistAccountPool(
Expand Down
Loading