Skip to content

Commit 57226a7

Browse files
Merge pull request #879 from corbitsdev/cl-7425-chatgpt-oauth-main
Switch ChatGPT login onto the shared Codex provider package
2 parents 16673f3 + 140310f commit 57226a7

9 files changed

Lines changed: 135 additions & 208 deletions

File tree

‎src/auth/codex/constants.ts‎

Lines changed: 13 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,26 @@
1-
// OAuth constants for the Codex (ChatGPT Plus/Pro subscription) login flow.
2-
// These values mirror the public Codex CLI client: the client id is a public
3-
// identifier (not a secret), and the endpoints belong to OpenAI's consumer
4-
// authorization server at auth.openai.com — distinct from the platform API key
5-
// system at platform.openai.com.
6-
//
7-
// A successful login yields a token billed against the user's ChatGPT
8-
// subscription. The inference base URL is chatgpt.com/backend-api, which is
9-
// OpenAI-compatible but a different surface from api.openai.com.
1+
import { CODEX_REDIRECT_URI } from "@corbits/codex-provider";
102

11-
// Public client identifier for the Codex CLI authorization flow. Not a secret.
12-
export const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
3+
export {
4+
CODEX_BASE_URL,
5+
CODEX_REDIRECT_URI,
6+
CODEX_REFRESH_SKEW_MS,
7+
CODEX_RESPONSES_PATH,
8+
} from "@corbits/codex-provider";
139

14-
export const CODEX_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
15-
export const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
10+
const codexRedirect = new URL(CODEX_REDIRECT_URI);
11+
export const CODEX_CALLBACK_PORT = Number(codexRedirect.port);
12+
export const CODEX_CALLBACK_PATH = codexRedirect.pathname;
1613

17-
// Token refresh runs on the send path before inference, outside the harness
18-
// timers, so the request must abort rather than hang the agent if the endpoint
19-
// stalls.
20-
export const CODEX_TOKEN_TIMEOUT_MS = 15_000;
21-
22-
// The Codex CLI registers a fixed loopback redirect on port 1455; the
23-
// authorization server only accepts this exact redirect_uri for this client, so
24-
// (unlike the MCP flow) the callback port is not free to vary.
25-
export const CODEX_CALLBACK_PORT = 1455;
26-
export const CODEX_CALLBACK_PATH = "/auth/callback";
27-
export const CODEX_REDIRECT_URI = `http://localhost:${String(CODEX_CALLBACK_PORT)}${CODEX_CALLBACK_PATH}`;
28-
29-
export const CODEX_SCOPES = [
30-
"openid",
31-
"profile",
32-
"email",
33-
"offline_access",
34-
] as const;
35-
36-
// Inference surface reached with the subscription token. NOTE: the Codex
37-
// backend serves the OpenAI *Responses* API at `${CODEX_BASE_URL}/codex/
38-
// responses`, not Chat Completions, and requires a `chatgpt-account-id` header
39-
// (see CodexTokens.accountId) plus `OpenAI-Beta: responses=experimental`. The
40-
// Responses adapter is tracked as follow-up work; this base + the stored
41-
// accountId are the inputs it needs.
42-
export const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
43-
export const CODEX_RESPONSES_PATH = "/codex/responses";
4414
// Live usage/quota for the prepaid plan (window %, reset, credits) and the
4515
// account's available model catalog. The models endpoint requires a
4616
// client_version query param.
4717
export const CODEX_USAGE_PATH = "/codex/usage";
4818
export const CODEX_MODELS_PATH = "/codex/models";
4919
export const CODEX_CLIENT_VERSION = "0.50.0";
5020

51-
// Extra authorize-request params the Codex flow requires.
52-
export const CODEX_AUTHORIZE_EXTRA_PARAMS: Record<string, string> = {
53-
codex_cli_simplified_flow: "true",
54-
id_token_add_organizations: "true",
55-
originator: "codex_cli_rs",
56-
};
21+
// Client identity the Codex backend expects on usage/model requests, matching
22+
// the public Codex CLI originator.
23+
export const CODEX_ORIGINATOR = "codex_cli_rs";
5724

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

73-
// Refresh a token this many milliseconds before its stated expiry so a request
74-
// is never sent with a token about to lapse mid-flight.
75-
export const CODEX_REFRESH_SKEW_MS = 60_000;
76-
7740
// How often a headless run re-checks its Codex token and reseeds the source.
7841
// Half the skew so the refresh window is never missed between ticks.
7942
export const CODEX_HEADLESS_REFRESH_INTERVAL_MS = 30_000;

‎src/auth/codex/login.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
import {
2+
buildAuthorizeUrl,
23
openInBrowser,
34
startOAuthLogin,
45
type OAuthLoginHandle,
56
type StartOAuthLoginOptions,
67
} from "@corbits/oauth-core";
8+
import {
9+
CODEX_BASE_URL,
10+
codexOAuthConfig,
11+
exchangeCodexCode,
12+
type CodexTokens,
13+
} from "@corbits/codex-provider";
714

815
import type { CallbackPageCopy } from "../callback-page.js";
916
import { saveCodexProfile } from "../../config/oauth-stores.js";
10-
import { CODEX_BASE_URL, CODEX_DEFAULT_MODELS } from "./constants.js";
1117
import { startCodexCallbackServer } from "./callback-server.js";
12-
import { buildAuthorizeUrl, exchangeCode } from "./oauth.js";
13-
import type { CodexTokens } from "./store.js";
18+
import { CODEX_DEFAULT_MODELS } from "./constants.js";
19+
import { withDefaultCodexExpiry } from "./store.js";
1420

1521
export { openInBrowser };
1622

@@ -20,21 +26,20 @@ export type StartCodexLoginOptions = StartOAuthLoginOptions & {
2026
copy: CallbackPageCopy;
2127
};
2228

23-
// Drive the loopback PKCE login for a Codex profile.
2429
export async function startCodexLogin(
2530
opts: StartCodexLoginOptions,
2631
): Promise<CodexLoginHandle> {
2732
const { home, copy, ...loginOpts } = opts;
2833
return startOAuthLogin(loginOpts, {
2934
startCallbackServer: (state) => startCodexCallbackServer(state, copy),
30-
buildAuthorizeUrl,
31-
exchangeCode,
35+
buildAuthorizeUrl: (pkce, state) =>
36+
buildAuthorizeUrl(codexOAuthConfig, pkce, state),
37+
exchangeCode: async (code, verifier, now) =>
38+
withDefaultCodexExpiry(await exchangeCodexCode(code, verifier, now), now),
3239
saveProfile: (profile) => saveCodexProfile(profile, home),
3340
});
3441
}
3542

36-
// Metadata describing the Codex provider surface, used when projecting a logged
37-
// in profile into the provider catalog.
3843
export const codexProviderSurface = {
3944
baseURL: CODEX_BASE_URL,
4045
models: [...CODEX_DEFAULT_MODELS],

‎src/auth/codex/oauth.ts‎

Lines changed: 0 additions & 111 deletions
This file was deleted.

‎src/auth/codex/pkce.ts‎

Lines changed: 0 additions & 1 deletion
This file was deleted.

‎src/auth/codex/session.ts‎

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ import {
55
OAuthRefreshFailedError,
66
type TokenSession,
77
} from "@corbits/oauth-core";
8+
import {
9+
CODEX_REFRESH_SKEW_MS,
10+
refreshCodexTokens,
11+
type CodexTokens,
12+
} from "@corbits/codex-provider";
813

914
import {
1015
loadCodexProfile,
1116
updateCodexTokens,
1217
} from "../../config/oauth-stores.js";
13-
import { CODEX_REFRESH_SKEW_MS } from "./constants.js";
14-
import { refreshTokens } from "./oauth.js";
15-
import type { CodexTokens } from "./store.js";
18+
import { withDefaultCodexExpiry } from "./store.js";
1619

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

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

70+
async function refreshCodexTokensForStore(
71+
refreshToken: string,
72+
now: number,
73+
previous: CodexTokens,
74+
): Promise<CodexTokens> {
75+
return withDefaultCodexExpiry(
76+
await refreshCodexTokens(refreshToken, now, previous),
77+
now,
78+
);
79+
}
80+
6781
function sessionFor(home?: string): TokenSession<CodexTokens, CodexAccess> {
6882
const key = home ?? "";
6983
const existing = sessions.get(key);
@@ -72,13 +86,18 @@ function sessionFor(home?: string): TokenSession<CodexTokens, CodexAccess> {
7286
skewMs: CODEX_REFRESH_SKEW_MS,
7387
loadProfile: (name) => loadCodexProfile(name, home),
7488
updateTokens: (name, tokens) => updateCodexTokens(name, tokens, home),
75-
refreshTokens,
89+
// createTokenSession only passes (refresh, now). The package refresh
90+
// helper needs prior tokens to keep chatgpt-account-id; mergeRefreshed
91+
// supplies that after this stub call.
92+
refreshTokens: (refreshToken, now) =>
93+
refreshCodexTokensForStore(refreshToken, now, {
94+
access: "",
95+
refresh: refreshToken,
96+
}),
7697
toAccess: (tokens) => ({
7798
access: tokens.access,
7899
accountId: tokens.accountId,
79100
}),
80-
// The refresh response rarely re-issues an id_token, so carry the account id
81-
// forward from the prior tokens when the refresh did not supply one.
82101
mergeRefreshed: (refreshed, previous) =>
83102
refreshed.accountId === undefined && previous.accountId !== undefined
84103
? { ...refreshed, accountId: previous.accountId }
@@ -109,7 +128,11 @@ export async function refreshStagedCodexTokens(
109128
now: number = Date.now(),
110129
): Promise<CodexTokens> {
111130
if (!isCodexTokenExpired(tokens, now)) return tokens;
112-
const refreshed = await refreshTokens(tokens.refresh, now);
131+
const refreshed = await refreshCodexTokensForStore(
132+
tokens.refresh,
133+
now,
134+
tokens,
135+
);
113136
Object.assign(tokens, refreshed);
114137
return tokens;
115138
}

‎src/auth/codex/store.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
import { type } from "arktype";
22
import type { AuthProfile } from "@corbits/oauth-core";
3+
import type { CodexTokens } from "@corbits/codex-provider";
34

4-
import { createAuthStore, type BaseTokens } from "../store.js";
5+
import { createAuthStore } from "../store.js";
56

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

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

1714
export type CodexProfile = AuthProfile<CodexTokens>;
1815

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

27+
// The package mapper leaves expiresAt unset when the token endpoint omits
28+
// expires_in. Disk profiles must have a concrete expiry so the arktype guard
29+
// can load them; 3600s matches the previous host mapper.
30+
const DEFAULT_EXPIRES_IN_S = 3600;
31+
32+
export function withDefaultCodexExpiry(
33+
tokens: CodexTokens,
34+
now: number,
35+
): CodexTokens {
36+
if (tokens.expiresAt !== undefined) return tokens;
37+
return { ...tokens, expiresAt: now + DEFAULT_EXPIRES_IN_S * 1000 };
38+
}
39+
3040
export function createCodexAuthStore(settingsDirName: string) {
3141
return createAuthStore<CodexTokens>({
3242
filename: "codex-auth.json",

‎src/auth/codex/usage.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import {
22
CODEX_BASE_URL,
33
CODEX_CLIENT_VERSION,
44
CODEX_MODELS_PATH,
5+
CODEX_ORIGINATOR,
56
CODEX_USAGE_PATH,
6-
CODEX_AUTHORIZE_EXTRA_PARAMS,
77
} from "./constants.js";
88
import { getValidCodexToken } from "./session.js";
99

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

0 commit comments

Comments
 (0)