diff --git a/src/auth/codex/constants.ts b/src/auth/codex/constants.ts index 2b22dbae2..bb31ce679 100644 --- a/src/auth/codex/constants.ts +++ b/src/auth/codex/constants.ts @@ -1,46 +1,16 @@ -// 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. @@ -48,12 +18,9 @@ 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 = { - 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 @@ -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; diff --git a/src/auth/codex/login.ts b/src/auth/codex/login.ts index 669f3b689..b76a1ad53 100644 --- a/src/auth/codex/login.ts +++ b/src/auth/codex/login.ts @@ -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 }; @@ -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 { 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], diff --git a/src/auth/codex/oauth.ts b/src/auth/codex/oauth.ts deleted file mode 100644 index 5b249abaf..000000000 --- a/src/auth/codex/oauth.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { - baseTokensFromResponse, - buildAuthorizeUrl as buildSharedAuthorizeUrl, - exchangeCode as exchangeSharedCode, - refreshTokenRequest, - type OAuthClientConfig, - type Pkce, - type TokenResponse, -} from "@corbits/oauth-core"; - -import { - CODEX_AUTHORIZE_EXTRA_PARAMS, - CODEX_AUTHORIZE_URL, - CODEX_CLIENT_ID, - CODEX_REDIRECT_URI, - CODEX_SCOPES, - CODEX_TOKEN_TIMEOUT_MS, - CODEX_TOKEN_URL, -} from "./constants.js"; -import type { CodexTokens } from "./store.js"; - -export const codexOAuthConfig: OAuthClientConfig = { - clientId: CODEX_CLIENT_ID, - authorizeUrl: CODEX_AUTHORIZE_URL, - tokenUrl: CODEX_TOKEN_URL, - redirectUri: CODEX_REDIRECT_URI, - scopes: CODEX_SCOPES, - extraAuthorizeParams: CODEX_AUTHORIZE_EXTRA_PARAMS, - tokenTimeoutMs: CODEX_TOKEN_TIMEOUT_MS, -}; - -// Build the authorization URL the user opens to grant Codex access. -export function buildAuthorizeUrl(pkce: Pkce, state: string): string { - return buildSharedAuthorizeUrl(codexOAuthConfig, pkce, state); -} - -// Decode the ChatGPT account id from an id_token (a JWT). The claim lives at -// `chatgpt_account_id` or nested under the `https://api.openai.com/auth` claim. -// Only the payload segment is read; the signature is not verified here because -// the token came straight from the authorization server over TLS and is used -// solely to label the account, not to authorize anything. -export function accountIdFromIdToken( - idToken: string | undefined, -): string | undefined { - if (idToken === undefined) return undefined; - const payload = idToken.split(".")[1]; - if (payload === undefined) return undefined; - try { - const json = Buffer.from(payload, "base64url").toString("utf8"); - const claims = JSON.parse(json) as Record; - const direct = claims["chatgpt_account_id"]; - if (typeof direct === "string") return direct; - const nested = claims["https://api.openai.com/auth"]; - if (typeof nested === "object" && nested !== null) { - const id = (nested as Record)["chatgpt_account_id"]; - if (typeof id === "string") return id; - } - } catch { - // A malformed id_token just means no account id; the caller may still - // function for flows that do not require the header. - } - return undefined; -} - -// Default access-token lifetime when the server omits expires_in. Conservative -// so the refresh path engages sooner rather than trusting a stale token. -const DEFAULT_EXPIRES_IN_S = 3600; - -// Convert a token response to stored tokens. `now` is injectable so callers -// (and tests) control the expiry baseline; `previousRefresh` is carried forward -// when a refresh response omits a new refresh_token (servers may rotate or not). -export function tokensFromResponse( - response: TokenResponse, - now: number, - previousRefresh?: string, -): CodexTokens { - const base = baseTokensFromResponse(response, now, previousRefresh); - const accountId = accountIdFromIdToken(response.id_token); - return { - access: base.access, - refresh: base.refresh, - expiresAt: base.expiresAt ?? now + DEFAULT_EXPIRES_IN_S * 1000, - ...(accountId !== undefined ? { accountId } : {}), - }; -} - -// Exchange an authorization code for tokens. `now` defaults to the current time -// but stays injectable for deterministic tests. -export async function exchangeCode( - code: string, - verifier: string, - now: number, -): Promise { - return tokensFromResponse( - await exchangeSharedCode(codexOAuthConfig, code, verifier), - now, - ); -} - -// Mint a fresh access token from a refresh token. Carries the prior refresh -// token forward if the server does not rotate it. -export async function refreshTokens( - refreshToken: string, - now: number, -): Promise { - return tokensFromResponse( - await refreshTokenRequest(codexOAuthConfig, refreshToken), - now, - refreshToken, - ); -} diff --git a/src/auth/codex/pkce.ts b/src/auth/codex/pkce.ts deleted file mode 100644 index 8387398d1..000000000 --- a/src/auth/codex/pkce.ts +++ /dev/null @@ -1 +0,0 @@ -export { generatePkce, generateState, type Pkce } from "@corbits/oauth-core"; diff --git a/src/auth/codex/session.ts b/src/auth/codex/session.ts index c8c7804e3..5503ec514 100644 --- a/src/auth/codex/session.ts +++ b/src/auth/codex/session.ts @@ -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 @@ -64,6 +67,17 @@ function wrapCodexAuthError(name: string, err: unknown): never { const sessions = new Map>(); +async function refreshCodexTokensForStore( + refreshToken: string, + now: number, + previous: CodexTokens, +): Promise { + return withDefaultCodexExpiry( + await refreshCodexTokens(refreshToken, now, previous), + now, + ); +} + function sessionFor(home?: string): TokenSession { const key = home ?? ""; const existing = sessions.get(key); @@ -72,13 +86,18 @@ function sessionFor(home?: string): TokenSession { 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 } @@ -109,7 +128,11 @@ export async function refreshStagedCodexTokens( now: number = Date.now(), ): Promise { 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; } diff --git a/src/auth/codex/store.ts b/src/auth/codex/store.ts index 1e6c2a4ed..f83e844f9 100644 --- a/src/auth/codex/store.ts +++ b/src/auth/codex/store.ts @@ -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; @@ -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({ filename: "codex-auth.json", diff --git a/src/auth/codex/usage.ts b/src/auth/codex/usage.ts index ffcd1016c..d67757136 100644 --- a/src/auth/codex/usage.ts +++ b/src/auth/codex/usage.ts @@ -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"; @@ -92,8 +92,8 @@ export function codexAuthHeadersForToken( ): Record { const headers: Record = { 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; diff --git a/tests/unit/codex-auth.test.ts b/tests/unit/codex-auth.test.ts index b93bf88cf..e73ad6ae5 100644 --- a/tests/unit/codex-auth.test.ts +++ b/tests/unit/codex-auth.test.ts @@ -3,13 +3,20 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createHash } from "node:crypto"; -import { generatePkce, generateState } from "../../src/auth/codex/pkce.js"; import { - accountIdFromIdToken, + generatePkce, + generateState, buildAuthorizeUrl, - tokensFromResponse, -} from "../../src/auth/codex/oauth.js"; -import type { CodexProfile } from "../../src/auth/codex/store.js"; +} from "@corbits/oauth-core"; +import { + accountIdFromIdToken, + codexOAuthConfig, + codexTokensFromResponse, +} from "@corbits/codex-provider"; +import { + type CodexProfile, + withDefaultCodexExpiry, +} from "../../src/auth/codex/store.js"; import { listCodexProfiles, loadCodexProfile, @@ -17,10 +24,7 @@ import { saveCodexProfile, updateCodexTokens, } from "../../src/config/oauth-stores.js"; -import { - CODEX_CLIENT_ID, - CODEX_REDIRECT_URI, -} from "../../src/auth/codex/constants.js"; +import { CODEX_REDIRECT_URI } from "../../src/auth/codex/constants.js"; function base64url(buf: Buffer): string { return buf @@ -56,28 +60,28 @@ describe("buildAuthorizeUrl", () => { test("carries client id, redirect, PKCE challenge, state, and Codex params", () => { const pkce = generatePkce(); const state = generateState(); - const url = new URL(buildAuthorizeUrl(pkce, state)); + const url = new URL(buildAuthorizeUrl(codexOAuthConfig, pkce, state)); expect(url.origin + url.pathname).toBe( "https://auth.openai.com/oauth/authorize", ); expect(url.searchParams.get("response_type")).toBe("code"); - expect(url.searchParams.get("client_id")).toBe(CODEX_CLIENT_ID); + expect(url.searchParams.get("client_id")).toBe(codexOAuthConfig.clientId); expect(url.searchParams.get("redirect_uri")).toBe(CODEX_REDIRECT_URI); expect(url.searchParams.get("code_challenge")).toBe(pkce.challenge); expect(url.searchParams.get("code_challenge_method")).toBe("S256"); expect(url.searchParams.get("state")).toBe(state); expect(url.searchParams.get("scope")).toBe( - "openid profile email offline_access", + codexOAuthConfig.scopes.join(" "), ); expect(url.searchParams.get("codex_cli_simplified_flow")).toBe("true"); expect(url.searchParams.get("originator")).toBe("codex_cli_rs"); }); }); -describe("tokensFromResponse", () => { +describe("codexTokensFromResponse", () => { test("computes absolute expiry from expires_in seconds", () => { const now = 1_000_000; - const tokens = tokensFromResponse( + const tokens = codexTokensFromResponse( { access_token: "a", refresh_token: "r", expires_in: 3600 }, now, ); @@ -87,7 +91,7 @@ describe("tokensFromResponse", () => { }); test("carries previous refresh token forward when response omits one", () => { - const tokens = tokensFromResponse( + const tokens = codexTokensFromResponse( { access_token: "a", expires_in: 60 }, 0, "old-refresh", @@ -96,22 +100,23 @@ describe("tokensFromResponse", () => { }); test("throws when no refresh token is available anywhere", () => { - expect(() => tokensFromResponse({ access_token: "a" }, 0)).toThrow( + expect(() => codexTokensFromResponse({ access_token: "a" }, 0)).toThrow( /refresh_token/, ); }); test("falls back to a default lifetime when expires_in is absent", () => { - const tokens = tokensFromResponse( - { access_token: "a", refresh_token: "r" }, - 0, + const now = 0; + const tokens = withDefaultCodexExpiry( + codexTokensFromResponse({ access_token: "a", refresh_token: "r" }, now), + now, ); - expect(tokens.expiresAt).toBeGreaterThan(0); + expect(tokens.expiresAt).toBe(3600 * 1000); }); test("extracts accountId from a JWT id_token", () => { const jwt = makeIdToken({ chatgpt_account_id: "acct-123" }); - const tokens = tokensFromResponse( + const tokens = codexTokensFromResponse( { access_token: "a", refresh_token: "r", id_token: jwt }, 0, ); @@ -119,7 +124,7 @@ describe("tokensFromResponse", () => { }); test("omits accountId when the id_token is absent", () => { - const tokens = tokensFromResponse( + const tokens = codexTokensFromResponse( { access_token: "a", refresh_token: "r" }, 0, ); diff --git a/tests/unit/codex-session.test.ts b/tests/unit/codex-session.test.ts index 6aa9f55ad..1d49fea35 100644 --- a/tests/unit/codex-session.test.ts +++ b/tests/unit/codex-session.test.ts @@ -132,6 +132,39 @@ describe("getValidCodexToken", () => { }); }); + test("refresh carries the stored account id when the response omits id_token", async () => { + await withHome(async (home) => { + await saveCodexProfile( + { + name: "p", + createdAt: 0, + tokens: { + access: "old", + refresh: "old-r", + expiresAt: 1_000, + accountId: "acct-1", + }, + }, + home, + ); + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + access_token: "fresh", + refresh_token: "new-r", + expires_in: 3600, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + )) as unknown as typeof fetch; + const token = await getValidCodexToken("p", 5_000, home); + expect(token.access).toBe("fresh"); + expect(token.accountId).toBe("acct-1"); + }); + }); + test("throws CodexAuthError(missing) for an unknown profile", async () => { await withHome(async (home) => { const err = await getValidCodexToken("ghost", 0, home).catch(