refactor(auth): isolate external oauth overlays

This commit is contained in:
Peter Steinberger
2026-04-06 13:29:46 +01:00
parent 49e3ecfe5e
commit 7e0e2f81e5
12 changed files with 588 additions and 59 deletions

View File

@@ -1,4 +1,5 @@
type CodexJwtPayload = {
exp?: unknown;
iss?: unknown;
sub?: unknown;
"https://api.openai.com/profile"?: {
@@ -19,6 +20,16 @@ function normalizeNonEmptyString(value: unknown): string | undefined {
return trimmed || undefined;
}
function normalizeFutureEpochSeconds(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.trunc(value);
}
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
return Number.parseInt(value.trim(), 10);
}
return undefined;
}
export function decodeCodexJwtPayload(accessToken: string): CodexJwtPayload | null {
const parts = accessToken.split(".");
if (parts.length !== 3) {
@@ -55,6 +66,12 @@ export function resolveCodexStableSubject(payload: CodexJwtPayload | null): stri
return sub;
}
export function resolveCodexAccessTokenExpiry(accessToken: string): number | undefined {
const payload = decodeCodexJwtPayload(accessToken);
const exp = normalizeFutureEpochSeconds(payload?.exp);
return exp ? exp * 1000 : undefined;
}
export function resolveCodexAuthIdentity(params: { accessToken: string; email?: string | null }): {
email?: string;
profileName?: string;

View File

@@ -0,0 +1,84 @@
import fs from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
OPENAI_CODEX_DEFAULT_PROFILE_ID,
readOpenAICodexCliOAuthProfile,
} from "./openai-codex-cli-auth.js";
function buildJwt(payload: Record<string, unknown>) {
const encode = (value: Record<string, unknown>) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
return `${encode({ alg: "none", typ: "JWT" })}.${encode(payload)}.sig`;
}
describe("readOpenAICodexCliOAuthProfile", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("reads Codex CLI chatgpt auth into the default OpenAI Codex profile", () => {
const accessToken = buildJwt({
exp: Math.floor(Date.now() / 1000) + 600,
"https://api.openai.com/profile": {
email: "codex@example.com",
},
});
vi.spyOn(fs, "readFileSync").mockReturnValue(
JSON.stringify({
auth_mode: "chatgpt",
tokens: {
access_token: accessToken,
refresh_token: "refresh-token",
account_id: "acct_123",
},
}),
);
const parsed = readOpenAICodexCliOAuthProfile({
store: { version: 1, profiles: {} },
});
expect(parsed).toMatchObject({
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
credential: {
type: "oauth",
provider: "openai-codex",
access: accessToken,
refresh: "refresh-token",
accountId: "acct_123",
email: "codex@example.com",
managedBy: "codex-cli",
},
});
expect(parsed?.credential.expires).toBeGreaterThan(Date.now());
});
it("does not override a locally managed OpenAI Codex profile", () => {
vi.spyOn(fs, "readFileSync").mockReturnValue(
JSON.stringify({
auth_mode: "chatgpt",
tokens: {
access_token: "access-token",
refresh_token: "refresh-token",
},
}),
);
const parsed = readOpenAICodexCliOAuthProfile({
store: {
version: 1,
profiles: {
[OPENAI_CODEX_DEFAULT_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "local-access",
refresh: "local-refresh",
expires: Date.now() + 60_000,
},
},
},
});
expect(parsed).toBeNull();
});
});

View File

@@ -0,0 +1,99 @@
import fs from "node:fs";
import path from "node:path";
import type { AuthProfileStore, OAuthCredential } from "openclaw/plugin-sdk/provider-auth";
import { resolveRequiredHomeDir } from "openclaw/plugin-sdk/provider-auth";
import {
resolveCodexAccessTokenExpiry,
resolveCodexAuthIdentity,
} from "./openai-codex-auth-identity.js";
const PROVIDER_ID = "openai-codex";
const CODEX_CLI_MANAGED_BY = "codex-cli";
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 trimNonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
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 {
return null;
}
}
export function readOpenAICodexCliOAuthProfile(params: {
env?: NodeJS.ProcessEnv;
store: AuthProfileStore;
}): { profileId: string; credential: OAuthCredential } | null {
const existing = params.store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID];
if (
existing?.type === "oauth" &&
existing.provider === PROVIDER_ID &&
existing.managedBy !== CODEX_CLI_MANAGED_BY
) {
return 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 });
return {
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
credential: {
type: "oauth",
provider: PROVIDER_ID,
access,
refresh,
expires: resolveCodexAccessTokenExpiry(access) ?? 0,
...(accountId ? { accountId } : {}),
...(identity.email ? { email: identity.email } : {}),
...(identity.profileName ? { displayName: identity.profileName } : {}),
managedBy: CODEX_CLI_MANAGED_BY,
},
};
}

View File

@@ -4,7 +4,6 @@ import type {
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import {
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
listProfilesForProvider,
type OAuthCredential,
@@ -22,6 +21,7 @@ import { fetchCodexUsage } from "openclaw/plugin-sdk/provider-usage";
import { OPENAI_CODEX_DEFAULT_MODEL } from "./default-models.js";
import { resolveCodexAuthIdentity } from "./openai-codex-auth-identity.js";
import { buildOpenAICodexProvider } from "./openai-codex-catalog.js";
import { CODEX_CLI_PROFILE_ID, readOpenAICodexCliOAuthProfile } from "./openai-codex-cli-auth.js";
import { buildOpenAIReplayPolicy } from "./replay-policy.js";
import {
cloneFirstTemplateModel,
@@ -303,6 +303,13 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
},
resolveDynamicModel: (ctx) => resolveCodexForwardCompatModel(ctx),
buildAuthDoctorHint: (ctx) => buildOpenAICodexAuthDoctorHint(ctx),
resolveExternalOAuthProfiles: (ctx) => {
const profile = readOpenAICodexCliOAuthProfile({
env: ctx.env,
store: ctx.store,
});
return profile ? [{ ...profile, persistence: "runtime-only" }] : undefined;
},
supportsXHighThinking: ({ modelId }) =>
matchesExactOrPrefix(modelId, OPENAI_CODEX_XHIGH_MODEL_IDS),
isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_CODEX_MODERN_MODEL_IDS),