Add Codex OAuth provider flow for ChatGPT account sign-in (#503)

* feat: add Codex OAuth provider flow

* fix: harden Codex OAuth storage, session activation, and UI
This commit is contained in:
Henrique Fernandes
2026-04-13 11:34:16 -03:00
committed by GitHub
parent 252808bbd0
commit fc7dc9ca0d
34 changed files with 5187 additions and 508 deletions

View File

@@ -3,7 +3,16 @@ import { isIP } from 'node:net'
import { homedir } from 'node:os'
import { join } from 'node:path'
import {
isCodexRefreshFailureCoolingDown,
readCodexCredentials,
type CodexCredentialBlob,
} from '../../utils/codexCredentials.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import {
asTrimmedString,
parseChatgptAccountId,
} from './codexOAuthShared.js'
export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'
@@ -78,7 +87,7 @@ export type ResolvedCodexCredentials = {
apiKey: string
accountId?: string
authPath?: string
source: 'env' | 'auth.json' | 'none'
source: 'env' | 'secure-storage' | 'auth.json' | 'none'
}
type ModelDescriptor = {
@@ -114,12 +123,6 @@ function isPrivateIpv6Address(hostname: string): boolean {
return (prefix & 0xfe00) === 0xfc00 || (prefix & 0xffc0) === 0xfe80
}
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed ? trimmed : undefined
}
// Reads an env-var-style string intended as a URL or path, rejecting both
// empty strings and the literal string "undefined" that Windows shells can
// write when a variable is unset-then-referenced without quotes (issue #336).
@@ -151,23 +154,6 @@ function readNestedString(
return undefined
}
function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
const parts = token.split('.')
if (parts.length < 2) return undefined
try {
const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
const json = Buffer.from(padded, 'base64').toString('utf8')
const parsed = JSON.parse(json)
return parsed && typeof parsed === 'object'
? (parsed as Record<string, unknown>)
: undefined
} catch {
return undefined
}
}
function parseReasoningEffort(value: string | undefined): ReasoningEffort | undefined {
if (!value) return undefined
const normalized = value.trim().toLowerCase()
@@ -494,18 +480,6 @@ export function resolveCodexAuthPath(
return join(homedir(), '.codex', 'auth.json')
}
export function parseChatgptAccountId(
token: string | undefined,
): string | undefined {
if (!token) return undefined
const payload = decodeJwtPayload(token)
const fromClaim = asTrimmedString(
payload?.['https://api.openai.com/auth.chatgpt_account_id'],
)
if (fromClaim) return fromClaim
return asTrimmedString(payload?.chatgpt_account_id)
}
function loadCodexAuthJson(
authPath: string,
): Record<string, unknown> | undefined {
@@ -521,8 +495,97 @@ function loadCodexAuthJson(
}
}
export function resolveCodexApiCredentials(
env: NodeJS.ProcessEnv = process.env,
function resolveCodexAuthJsonCredentials(options: {
authJson: Record<string, unknown> | undefined
authPath: string
envAccountId?: string
missingSource?: ResolvedCodexCredentials['source']
}): ResolvedCodexCredentials {
const { authJson, authPath, envAccountId } = options
if (!authJson) {
return {
apiKey: '',
authPath,
source: options.missingSource ?? 'none',
}
}
const apiKey = readNestedString(authJson, [
['openai_api_key'],
['openaiApiKey'],
['access_token'],
['accessToken'],
['tokens', 'access_token'],
['tokens', 'accessToken'],
['auth', 'access_token'],
['auth', 'accessToken'],
['token', 'access_token'],
['token', 'accessToken'],
])
// OIDC identity tokens can carry the ChatGPT account id, but they are not
// valid bearer credentials for Codex API requests.
const idToken = readNestedString(authJson, [
['id_token'],
['idToken'],
['tokens', 'id_token'],
['tokens', 'idToken'],
])
const accountId =
envAccountId ??
readNestedString(authJson, [
['account_id'],
['accountId'],
['tokens', 'account_id'],
['tokens', 'accountId'],
['auth', 'account_id'],
['auth', 'accountId'],
]) ??
parseChatgptAccountId(apiKey) ??
parseChatgptAccountId(idToken)
if (!apiKey) {
return {
apiKey: '',
accountId,
authPath,
source: options.missingSource ?? 'none',
}
}
return {
apiKey,
accountId,
authPath,
source: 'auth.json',
}
}
export function resolveStoredCodexCredentials(options: {
storedCredentials: Pick<
CodexCredentialBlob,
'apiKey' | 'accessToken' | 'idToken' | 'accountId'
>
envAccountId?: string
}): ResolvedCodexCredentials {
const { storedCredentials, envAccountId } = options
return {
apiKey: storedCredentials.apiKey ?? storedCredentials.accessToken,
accountId:
envAccountId ??
storedCredentials.accountId ??
parseChatgptAccountId(storedCredentials.idToken) ??
parseChatgptAccountId(storedCredentials.accessToken),
source: 'secure-storage',
}
}
function resolveEnvOrAuthJsonCodexCredentials(
env: NodeJS.ProcessEnv,
options?: {
explicitAuthPathOnly?: boolean
},
): ResolvedCodexCredentials {
const envApiKey = asTrimmedString(env.CODEX_API_KEY)
const envAccountId =
@@ -537,55 +600,127 @@ export function resolveCodexApiCredentials(
}
}
const explicitAuthPathConfigured = Boolean(
asTrimmedString(env.CODEX_AUTH_JSON_PATH) ?? asTrimmedString(env.CODEX_HOME),
)
if (!explicitAuthPathConfigured && options?.explicitAuthPathOnly) {
return {
apiKey: '',
accountId: envAccountId,
source: 'none',
}
}
const authPath = resolveCodexAuthPath(env)
const authJson = loadCodexAuthJson(authPath)
if (!authJson) {
return {
apiKey: '',
authPath,
source: 'none',
}
}
const apiKey = readNestedString(authJson, [
['access_token'],
['accessToken'],
['tokens', 'access_token'],
['tokens', 'accessToken'],
['auth', 'access_token'],
['auth', 'accessToken'],
['token', 'access_token'],
['token', 'accessToken'],
['tokens', 'id_token'],
['tokens', 'idToken'],
])
const accountId =
envAccountId ??
readNestedString(authJson, [
['account_id'],
['accountId'],
['tokens', 'account_id'],
['tokens', 'accountId'],
['auth', 'account_id'],
['auth', 'accountId'],
]) ??
parseChatgptAccountId(apiKey)
if (!apiKey) {
return {
apiKey: '',
accountId,
authPath,
source: 'none',
}
}
return {
apiKey,
accountId,
return resolveCodexAuthJsonCredentials({
authJson,
authPath,
source: 'auth.json',
envAccountId,
})
}
export function resolveRuntimeCodexCredentials(options?: {
env?: NodeJS.ProcessEnv
storedCredentials?: Pick<
CodexCredentialBlob,
'apiKey' | 'accessToken' | 'idToken' | 'accountId'
>
}): ResolvedCodexCredentials {
const env = options?.env ?? process.env
const explicitCredentials = resolveEnvOrAuthJsonCodexCredentials(env, {
explicitAuthPathOnly: true,
})
const explicitAuthPathConfigured = Boolean(
asTrimmedString(env.CODEX_AUTH_JSON_PATH) ?? asTrimmedString(env.CODEX_HOME),
)
const hasStoredCredentialsOption = Boolean(
options &&
Object.prototype.hasOwnProperty.call(options, 'storedCredentials'),
)
if (
explicitAuthPathConfigured ||
explicitCredentials.source === 'env' ||
explicitCredentials.source === 'auth.json'
) {
return explicitCredentials
}
if (options?.storedCredentials?.accessToken) {
return resolveStoredCodexCredentials({
storedCredentials: options.storedCredentials,
envAccountId:
asTrimmedString(env.CODEX_ACCOUNT_ID) ??
asTrimmedString(env.CHATGPT_ACCOUNT_ID),
})
}
if (hasStoredCredentialsOption) {
return resolveEnvOrAuthJsonCodexCredentials(env)
}
return resolveCodexApiCredentials(env)
}
export function resolveCodexApiCredentials(
env: NodeJS.ProcessEnv = process.env,
): ResolvedCodexCredentials {
const envAccountId =
asTrimmedString(env.CODEX_ACCOUNT_ID) ??
asTrimmedString(env.CHATGPT_ACCOUNT_ID)
const envOrExplicitAuthJsonCredentials = resolveEnvOrAuthJsonCodexCredentials(
env,
{
explicitAuthPathOnly: true,
},
)
if (
envOrExplicitAuthJsonCredentials.source === 'env' ||
envOrExplicitAuthJsonCredentials.source === 'auth.json' ||
envOrExplicitAuthJsonCredentials.authPath
) {
return envOrExplicitAuthJsonCredentials
}
const storedCredentials = readCodexCredentials()
if (storedCredentials?.accessToken) {
const resolvedStoredCredentials = resolveStoredCodexCredentials({
storedCredentials,
envAccountId,
})
const shouldCheckDefaultAuthJson =
!resolvedStoredCredentials.accountId ||
isCodexRefreshFailureCoolingDown(storedCredentials)
if (!shouldCheckDefaultAuthJson) {
return resolvedStoredCredentials
}
const authPath = resolveCodexAuthPath(env)
const authJson = loadCodexAuthJson(authPath)
const resolvedAuthJsonCredentials = resolveCodexAuthJsonCredentials({
authJson,
authPath,
envAccountId,
})
if (resolvedAuthJsonCredentials.apiKey) {
return {
...resolvedAuthJsonCredentials,
accountId:
resolvedAuthJsonCredentials.accountId ??
resolvedStoredCredentials.accountId,
}
}
return resolvedStoredCredentials
}
return resolveEnvOrAuthJsonCodexCredentials(env)
}
export function getReasoningEffortForModel(model: string): ReasoningEffort | undefined {
@@ -595,3 +730,18 @@ export function getReasoningEffortForModel(model: string): ReasoningEffort | und
const aliasConfig = CODEX_ALIAS_MODELS[alias]
return aliasConfig?.reasoningEffort
}
export function supportsCodexReasoningEffort(model: string): boolean {
const normalized = model.trim().toLowerCase()
const base = normalized.split('?', 1)[0] ?? normalized
if (base === 'gpt-5.3-codex-spark' || base === 'codexspark') {
return false
}
if (getReasoningEffortForModel(base) !== undefined) {
return true
}
return /^gpt-5(?:[.-]|$)/.test(base)
}