Files
openclaw/extensions/openai/openai-codex-cli-auth.ts
Val Alexander f45bc09206 [codex] fix(auth): harden OAuth refresh and Codex CLI bootstrap flows (#68396)
* Harden OAuth refresh and Codex CLI bootstrap flows

- Treat near-expiry OAuth credentials as unusable for bootstrap and refresh
- Add clearer timeout and callback validation handling for OpenAI Codex OAuth
- Tighten file lock retry behavior for stale OAuth refresh contention

* fix(auth): address PR review threads

* fix(auth): adopt fresher imported refresh tokens

* test(auth): align oauth expiry fixtures with refresh margin

* fix(auth): tighten Codex OAuth bootstrap and local fallback

* Keep explicit local auth over CLI bootstrap

- Preserve existing non-OAuth local profiles during external CLI OAuth sync
- Add regression coverage for OpenAI Codex and generic external OAuth overlays

* fix(auth): distinguish oauth lock timeout sources

* fix(auth): reject cross-account external oauth bootstrap

* fix(auth): narrow refresh contention classification
2026-04-18 01:02:29 -05:00

175 lines
5.5 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import {
hasUsableOAuthCredential,
resolveRequiredHomeDir,
type AuthProfileStore,
type OAuthCredential,
} from "openclaw/plugin-sdk/provider-auth";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import {
resolveCodexAccessTokenExpiry,
resolveCodexAuthIdentity,
} from "./openai-codex-auth-identity.js";
import { trimNonEmptyString } from "./openai-codex-shared.js";
const PROVIDER_ID = "openai-codex";
const log = createSubsystemLogger("openai/codex-cli-auth");
export const CODEX_CLI_PROFILE_ID = `${PROVIDER_ID}:codex-cli`;
export const OPENAI_CODEX_DEFAULT_PROFILE_ID = `${PROVIDER_ID}:default`;
type CodexCliAuthFile = {
auth_mode?: unknown;
tokens?: {
access_token?: unknown;
refresh_token?: unknown;
account_id?: unknown;
};
};
function resolveCodexCliHome(env: NodeJS.ProcessEnv): string {
const configured = trimNonEmptyString(env.CODEX_HOME);
if (!configured) {
return path.join(resolveRequiredHomeDir(), ".codex");
}
if (configured === "~") {
return resolveRequiredHomeDir();
}
if (configured.startsWith("~/")) {
return path.join(resolveRequiredHomeDir(), configured.slice(2));
}
return path.resolve(configured);
}
function readCodexCliAuthFile(env: NodeJS.ProcessEnv): CodexCliAuthFile | null {
try {
const authPath = path.join(resolveCodexCliHome(env), "auth.json");
const raw = fs.readFileSync(authPath, "utf8");
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? (parsed as CodexCliAuthFile) : null;
} catch (error) {
const code =
error instanceof SyntaxError
? "INVALID_JSON"
: error instanceof Error && "code" in error
? (error as NodeJS.ErrnoException).code
: undefined;
if (code === "ENOENT") {
return null;
}
log.debug(
`Failed to read Codex CLI auth file (code=${typeof code === "string" ? code : "UNKNOWN"})`,
);
return null;
}
}
function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean {
return (
a.type === b.type &&
a.provider === b.provider &&
a.access === b.access &&
a.refresh === b.refresh &&
a.clientId === b.clientId &&
a.email === b.email &&
a.displayName === b.displayName &&
a.enterpriseUrl === b.enterpriseUrl &&
a.projectId === b.projectId &&
a.accountId === b.accountId
);
}
function normalizeAuthIdentityToken(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function normalizeAuthEmailToken(value: string | undefined): string | undefined {
return normalizeAuthIdentityToken(value)?.toLowerCase();
}
// Keep this overwrite guard aligned with the canonical OAuth identity-copy rule
// in src/agents/auth-profiles/oauth.ts without widening the plugin SDK surface.
function isSafeToReplaceStoredIdentity(
existing: Pick<OAuthCredential, "accountId" | "email">,
incoming: Pick<OAuthCredential, "accountId" | "email">,
): boolean {
const existingAccountId = normalizeAuthIdentityToken(existing.accountId);
const incomingAccountId = normalizeAuthIdentityToken(incoming.accountId);
const existingEmail = normalizeAuthEmailToken(existing.email);
const incomingEmail = normalizeAuthEmailToken(incoming.email);
if (existingAccountId !== undefined && incomingAccountId !== undefined) {
return existingAccountId === incomingAccountId;
}
if (existingEmail !== undefined && incomingEmail !== undefined) {
return existingEmail === incomingEmail;
}
const existingHasIdentity = existingAccountId !== undefined || existingEmail !== undefined;
if (existingHasIdentity) {
return false;
}
return true;
}
export function readOpenAICodexCliOAuthProfile(params: {
env?: NodeJS.ProcessEnv;
store: AuthProfileStore;
}): { profileId: string; credential: OAuthCredential } | null {
const authFile = readCodexCliAuthFile(params.env ?? process.env);
if (!authFile || authFile.auth_mode !== "chatgpt") {
return null;
}
const access = trimNonEmptyString(authFile.tokens?.access_token);
const refresh = trimNonEmptyString(authFile.tokens?.refresh_token);
if (!access || !refresh) {
return null;
}
const accountId = trimNonEmptyString(authFile.tokens?.account_id);
const identity = resolveCodexAuthIdentity({ accessToken: access });
const credential: OAuthCredential = {
type: "oauth",
provider: PROVIDER_ID,
access,
refresh,
expires: resolveCodexAccessTokenExpiry(access) ?? 0,
...(accountId ? { accountId } : {}),
...(identity.email ? { email: identity.email } : {}),
...(identity.profileName ? { displayName: identity.profileName } : {}),
};
const existing = params.store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID];
const existingOAuth =
existing?.type === "oauth" && existing.provider === PROVIDER_ID ? existing : undefined;
if (existing && !existingOAuth) {
log.debug("kept explicit local auth over Codex CLI bootstrap", {
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
localType: existing.type,
localProvider: existing.provider,
});
return null;
}
if (
existingOAuth &&
hasUsableOAuthCredential(existingOAuth) &&
!oauthCredentialMatches(existingOAuth, credential)
) {
return null;
}
if (
existingOAuth &&
!oauthCredentialMatches(existingOAuth, credential) &&
!isSafeToReplaceStoredIdentity(existingOAuth, credential)
) {
return null;
}
return {
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
credential,
};
}