Skip to content
Merged
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
63 changes: 13 additions & 50 deletions src/auth/codex/constants.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,26 @@
// OAuth constants for the Codex (ChatGPT Plus/Pro subscription) login flow.
// These values mirror the public Codex CLI client: the client id is a public
// identifier (not a secret), and the endpoints belong to OpenAI's consumer
// authorization server at auth.openai.com — distinct from the platform API key
// system at platform.openai.com.
//
// A successful login yields a token billed against the user's ChatGPT
// subscription. The inference base URL is chatgpt.com/backend-api, which is
// OpenAI-compatible but a different surface from api.openai.com.
import { CODEX_REDIRECT_URI } from "@corbits/codex-provider";

// Public client identifier for the Codex CLI authorization flow. Not a secret.
export const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
export {
CODEX_BASE_URL,
CODEX_REDIRECT_URI,
CODEX_REFRESH_SKEW_MS,
CODEX_RESPONSES_PATH,
} from "@corbits/codex-provider";

export const CODEX_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
export const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
const codexRedirect = new URL(CODEX_REDIRECT_URI);
export const CODEX_CALLBACK_PORT = Number(codexRedirect.port);
export const CODEX_CALLBACK_PATH = codexRedirect.pathname;

// Token refresh runs on the send path before inference, outside the harness
// timers, so the request must abort rather than hang the agent if the endpoint
// stalls.
export const CODEX_TOKEN_TIMEOUT_MS = 15_000;

// The Codex CLI registers a fixed loopback redirect on port 1455; the
// authorization server only accepts this exact redirect_uri for this client, so
// (unlike the MCP flow) the callback port is not free to vary.
export const CODEX_CALLBACK_PORT = 1455;
export const CODEX_CALLBACK_PATH = "/auth/callback";
export const CODEX_REDIRECT_URI = `http://localhost:${String(CODEX_CALLBACK_PORT)}${CODEX_CALLBACK_PATH}`;

export const CODEX_SCOPES = [
"openid",
"profile",
"email",
"offline_access",
] as const;

// Inference surface reached with the subscription token. NOTE: the Codex
// backend serves the OpenAI *Responses* API at `${CODEX_BASE_URL}/codex/
// responses`, not Chat Completions, and requires a `chatgpt-account-id` header
// (see CodexTokens.accountId) plus `OpenAI-Beta: responses=experimental`. The
// Responses adapter is tracked as follow-up work; this base + the stored
// accountId are the inputs it needs.
export const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
export const CODEX_RESPONSES_PATH = "/codex/responses";
// Live usage/quota for the prepaid plan (window %, reset, credits) and the
// account's available model catalog. The models endpoint requires a
// client_version query param.
export const CODEX_USAGE_PATH = "/codex/usage";
export const CODEX_MODELS_PATH = "/codex/models";
export const CODEX_CLIENT_VERSION = "0.50.0";

// Extra authorize-request params the Codex flow requires.
export const CODEX_AUTHORIZE_EXTRA_PARAMS: Record<string, string> = {
codex_cli_simplified_flow: "true",
id_token_add_organizations: "true",
originator: "codex_cli_rs",
};
// Client identity the Codex backend expects on usage/model requests, matching
// the public Codex CLI originator.
export const CODEX_ORIGINATOR = "codex_cli_rs";

// Fallback model list, used only when the live catalog (GET /codex/models) is
// unavailable — e.g. while rate-limited it returns an empty list. The Codex
Expand All @@ -70,10 +37,6 @@ export const CODEX_DEFAULT_MODELS = [
"gpt-5.4-mini",
] as const;

// Refresh a token this many milliseconds before its stated expiry so a request
// is never sent with a token about to lapse mid-flight.
export const CODEX_REFRESH_SKEW_MS = 60_000;

// How often a headless run re-checks its Codex token and reseeds the source.
// Half the skew so the refresh window is never missed between ticks.
export const CODEX_HEADLESS_REFRESH_INTERVAL_MS = 30_000;
21 changes: 13 additions & 8 deletions src/auth/codex/login.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import {
buildAuthorizeUrl,
openInBrowser,
startOAuthLogin,
type OAuthLoginHandle,
type StartOAuthLoginOptions,
} from "@corbits/oauth-core";
import {
CODEX_BASE_URL,
codexOAuthConfig,
exchangeCodexCode,
type CodexTokens,
} from "@corbits/codex-provider";

import type { CallbackPageCopy } from "../callback-page.js";
import { saveCodexProfile } from "../../config/oauth-stores.js";
import { CODEX_BASE_URL, CODEX_DEFAULT_MODELS } from "./constants.js";
import { startCodexCallbackServer } from "./callback-server.js";
import { buildAuthorizeUrl, exchangeCode } from "./oauth.js";
import type { CodexTokens } from "./store.js";
import { CODEX_DEFAULT_MODELS } from "./constants.js";
import { withDefaultCodexExpiry } from "./store.js";

export { openInBrowser };

Expand All @@ -20,21 +26,20 @@ export type StartCodexLoginOptions = StartOAuthLoginOptions & {
copy: CallbackPageCopy;
};

// Drive the loopback PKCE login for a Codex profile.
export async function startCodexLogin(
opts: StartCodexLoginOptions,
): Promise<CodexLoginHandle> {
const { home, copy, ...loginOpts } = opts;
return startOAuthLogin(loginOpts, {
startCallbackServer: (state) => startCodexCallbackServer(state, copy),
buildAuthorizeUrl,
exchangeCode,
buildAuthorizeUrl: (pkce, state) =>
buildAuthorizeUrl(codexOAuthConfig, pkce, state),
exchangeCode: async (code, verifier, now) =>
withDefaultCodexExpiry(await exchangeCodexCode(code, verifier, now), now),
saveProfile: (profile) => saveCodexProfile(profile, home),
});
}

// Metadata describing the Codex provider surface, used when projecting a logged
// in profile into the provider catalog.
export const codexProviderSurface = {
baseURL: CODEX_BASE_URL,
models: [...CODEX_DEFAULT_MODELS],
Expand Down
111 changes: 0 additions & 111 deletions src/auth/codex/oauth.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/auth/codex/pkce.ts

This file was deleted.

37 changes: 30 additions & 7 deletions src/auth/codex/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import {
OAuthRefreshFailedError,
type TokenSession,
} from "@corbits/oauth-core";
import {
CODEX_REFRESH_SKEW_MS,
refreshCodexTokens,
type CodexTokens,
} from "@corbits/codex-provider";

import {
loadCodexProfile,
updateCodexTokens,
} from "../../config/oauth-stores.js";
import { CODEX_REFRESH_SKEW_MS } from "./constants.js";
import { refreshTokens } from "./oauth.js";
import type { CodexTokens } from "./store.js";
import { withDefaultCodexExpiry } from "./store.js";

// Raised when a Codex profile cannot yield a usable access token: it is gone,
// or its refresh token has been revoked/expired. Carries the profile name so
Expand Down Expand Up @@ -64,6 +67,17 @@ function wrapCodexAuthError(name: string, err: unknown): never {

const sessions = new Map<string, TokenSession<CodexTokens, CodexAccess>>();

async function refreshCodexTokensForStore(
refreshToken: string,
now: number,
previous: CodexTokens,
): Promise<CodexTokens> {
return withDefaultCodexExpiry(
await refreshCodexTokens(refreshToken, now, previous),
now,
);
}

function sessionFor(home?: string): TokenSession<CodexTokens, CodexAccess> {
const key = home ?? "";
const existing = sessions.get(key);
Expand All @@ -72,13 +86,18 @@ function sessionFor(home?: string): TokenSession<CodexTokens, CodexAccess> {
skewMs: CODEX_REFRESH_SKEW_MS,
loadProfile: (name) => loadCodexProfile(name, home),
updateTokens: (name, tokens) => updateCodexTokens(name, tokens, home),
refreshTokens,
// createTokenSession only passes (refresh, now). The package refresh
// helper needs prior tokens to keep chatgpt-account-id; mergeRefreshed
// supplies that after this stub call.
refreshTokens: (refreshToken, now) =>
refreshCodexTokensForStore(refreshToken, now, {
access: "",
refresh: refreshToken,
}),
toAccess: (tokens) => ({
access: tokens.access,
accountId: tokens.accountId,
}),
// The refresh response rarely re-issues an id_token, so carry the account id
// forward from the prior tokens when the refresh did not supply one.
mergeRefreshed: (refreshed, previous) =>
refreshed.accountId === undefined && previous.accountId !== undefined
? { ...refreshed, accountId: previous.accountId }
Expand Down Expand Up @@ -109,7 +128,11 @@ export async function refreshStagedCodexTokens(
now: number = Date.now(),
): Promise<CodexTokens> {
if (!isCodexTokenExpired(tokens, now)) return tokens;
const refreshed = await refreshTokens(tokens.refresh, now);
const refreshed = await refreshCodexTokensForStore(
tokens.refresh,
now,
tokens,
);
Object.assign(tokens, refreshed);
return tokens;
}
22 changes: 16 additions & 6 deletions src/auth/codex/store.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
import { type } from "arktype";
import type { AuthProfile } from "@corbits/oauth-core";
import type { CodexTokens } from "@corbits/codex-provider";

import { createAuthStore, type BaseTokens } from "../store.js";
import { createAuthStore } from "../store.js";

// On-disk store for Codex OAuth profiles. A user may hold multiple Codex
// subscriptions (personal, work, ...), so credentials are keyed by a
// user-chosen profile name within a single file. The provider type is shared;
// the profile name is what differentiates instances throughout the app.

export type CodexTokens = BaseTokens & {
// ChatGPT account id extracted from the id_token, required as the
// `chatgpt-account-id` header on every Codex inference request.
accountId?: string;
};
export type { CodexTokens };

export type CodexProfile = AuthProfile<CodexTokens>;

Expand All @@ -27,6 +24,19 @@ function isCodexTokens(value: unknown): value is CodexTokens {
return !(CodexTokensShape(value) instanceof type.errors);
}

// The package mapper leaves expiresAt unset when the token endpoint omits
// expires_in. Disk profiles must have a concrete expiry so the arktype guard
// can load them; 3600s matches the previous host mapper.
const DEFAULT_EXPIRES_IN_S = 3600;

export function withDefaultCodexExpiry(
tokens: CodexTokens,
now: number,
): CodexTokens {
if (tokens.expiresAt !== undefined) return tokens;
return { ...tokens, expiresAt: now + DEFAULT_EXPIRES_IN_S * 1000 };
}

export function createCodexAuthStore(settingsDirName: string) {
return createAuthStore<CodexTokens>({
filename: "codex-auth.json",
Expand Down
6 changes: 3 additions & 3 deletions src/auth/codex/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import {
CODEX_BASE_URL,
CODEX_CLIENT_VERSION,
CODEX_MODELS_PATH,
CODEX_ORIGINATOR,
CODEX_USAGE_PATH,
CODEX_AUTHORIZE_EXTRA_PARAMS,
} from "./constants.js";
import { getValidCodexToken } from "./session.js";

Expand Down Expand Up @@ -92,8 +92,8 @@ export function codexAuthHeadersForToken(
): Record<string, string> {
const headers: Record<string, string> = {
authorization: `Bearer ${token.access}`,
originator: CODEX_AUTHORIZE_EXTRA_PARAMS["originator"] ?? "codex_cli_rs",
"user-agent": `${commandName} (codex_cli_rs/${CODEX_CLIENT_VERSION})`,
originator: CODEX_ORIGINATOR,
"user-agent": `${commandName} (${CODEX_ORIGINATOR}/${CODEX_CLIENT_VERSION})`,
};
if (token.accountId !== undefined)
headers["chatgpt-account-id"] = token.accountId;
Expand Down
Loading
Loading