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

@@ -0,0 +1,166 @@
import { createServer } from 'node:http'
import { afterEach, expect, mock, test } from 'bun:test'
import { CodexOAuthService } from './codexOAuth.js'
const originalFetch = globalThis.fetch
const originalCallbackPort = process.env.CODEX_OAUTH_CALLBACK_PORT
const originalClientId = process.env.CODEX_OAUTH_CLIENT_ID
afterEach(() => {
mock.restore()
globalThis.fetch = originalFetch
if (originalCallbackPort === undefined) {
delete process.env.CODEX_OAUTH_CALLBACK_PORT
} else {
process.env.CODEX_OAUTH_CALLBACK_PORT = originalCallbackPort
}
if (originalClientId === undefined) {
delete process.env.CODEX_OAUTH_CLIENT_ID
} else {
process.env.CODEX_OAUTH_CLIENT_ID = originalClientId
}
})
async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = createServer()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Failed to allocate test port.')))
return
}
const { port } = address
server.close(error => {
if (error) {
reject(error)
return
}
resolve(port)
})
})
})
}
function buildCallbackRequest(authUrl: string): string {
const authorizeUrl = new URL(authUrl)
const redirectUri = authorizeUrl.searchParams.get('redirect_uri')
const state = authorizeUrl.searchParams.get('state')
if (!redirectUri || !state) {
throw new Error('Codex OAuth test did not receive a valid authorization URL.')
}
const callbackUrl = new URL(redirectUri)
callbackUrl.searchParams.set('code', 'auth-code')
callbackUrl.searchParams.set('state', state)
return callbackUrl.toString()
}
test('serves updated success copy after a successful Codex OAuth flow', async () => {
const callbackPort = await getFreePort()
process.env.CODEX_OAUTH_CALLBACK_PORT = String(callbackPort)
process.env.CODEX_OAUTH_CLIENT_ID = 'test-client-id'
globalThis.fetch = mock(async (input, init) => {
const url = String(input)
if (url.startsWith('http://localhost:')) {
return originalFetch(input, init)
}
return new Response(
JSON.stringify({
access_token: 'access-token',
refresh_token: 'refresh-token',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
)
}) as typeof fetch
const service = new CodexOAuthService()
let callbackResponsePromise!: Promise<Response>
const flowPromise = service.startOAuthFlow(async authUrl => {
callbackResponsePromise = originalFetch(buildCallbackRequest(authUrl))
})
const tokens = await flowPromise
const callbackResponse = await callbackResponsePromise
const html = await callbackResponse.text()
expect(tokens.accessToken).toBe('access-token')
expect(tokens.refreshToken).toBe('refresh-token')
expect(html).toContain('You can return to OpenClaude now.')
expect(html).toContain(
'OpenClaude will finish activating your new Codex OAuth login.',
)
expect(html).not.toContain('continue automatically')
})
test('cancellation during token exchange returns a cancelled page and rejects the flow', async () => {
const callbackPort = await getFreePort()
process.env.CODEX_OAUTH_CALLBACK_PORT = String(callbackPort)
process.env.CODEX_OAUTH_CLIENT_ID = 'test-client-id'
let resolveFetchStart!: () => void
const fetchStarted = new Promise<void>(resolve => {
resolveFetchStart = resolve
})
globalThis.fetch = mock((input, init) => {
const url = String(input)
if (url.startsWith('http://localhost:')) {
return originalFetch(input, init)
}
return new Promise<Response>((_resolve, reject) => {
resolveFetchStart()
const signal = init?.signal
if (!signal) {
return
}
if (signal.aborted) {
reject(signal.reason)
return
}
signal.addEventListener(
'abort',
() => {
reject(signal.reason)
},
{ once: true },
)
})
}) as typeof fetch
const service = new CodexOAuthService()
let callbackResponsePromise!: Promise<Response>
const flowPromise = service.startOAuthFlow(async authUrl => {
callbackResponsePromise = originalFetch(buildCallbackRequest(authUrl))
})
await fetchStarted
service.cleanup()
await expect(flowPromise).rejects.toThrow('Codex OAuth flow was cancelled.')
const callbackResponse = await callbackResponsePromise
const html = await callbackResponse.text()
expect(html).toContain('Codex login cancelled')
expect(html).toContain('retry in OpenClaude')
})

View File

@@ -0,0 +1,307 @@
import { AuthCodeListener } from '../oauth/auth-code-listener.js'
import {
generateCodeChallenge,
generateCodeVerifier,
generateState,
} from '../oauth/crypto.js'
import {
asTrimmedString,
CODEX_OAUTH_ISSUER,
CODEX_OAUTH_ORIGINATOR,
CODEX_OAUTH_SCOPE,
escapeHtml,
exchangeCodexIdTokenForApiKey,
getCodexOAuthCallbackPort,
getCodexOAuthClientId,
parseChatgptAccountId,
} from './codexOAuthShared.js'
type CodexOAuthTokenResponse = {
id_token?: string
access_token?: string
refresh_token?: string
}
export type CodexOAuthTokens = {
apiKey?: string
accessToken: string
refreshToken: string
idToken?: string
accountId?: string
}
function buildCodexAuthorizeUrl(options: {
port: number
codeChallenge: string
state: string
}): string {
const redirectUri = `http://localhost:${options.port}/auth/callback`
const authUrl = new URL(`${CODEX_OAUTH_ISSUER}/oauth/authorize`)
authUrl.searchParams.append('response_type', 'code')
authUrl.searchParams.append('client_id', getCodexOAuthClientId())
authUrl.searchParams.append('redirect_uri', redirectUri)
authUrl.searchParams.append('scope', CODEX_OAUTH_SCOPE)
authUrl.searchParams.append('code_challenge', options.codeChallenge)
authUrl.searchParams.append('code_challenge_method', 'S256')
authUrl.searchParams.append('id_token_add_organizations', 'true')
authUrl.searchParams.append('codex_cli_simplified_flow', 'true')
authUrl.searchParams.append('state', options.state)
authUrl.searchParams.append('originator', CODEX_OAUTH_ORIGINATOR)
return authUrl.toString()
}
function renderSuccessPage(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Codex Login Complete</title>
<style>
body { font-family: sans-serif; padding: 32px; line-height: 1.5; color: #111827; }
h1 { margin: 0 0 12px; font-size: 22px; }
p { margin: 0 0 10px; }
</style>
</head>
<body>
<h1>Codex login complete</h1>
<p>You can return to OpenClaude now.</p>
<p>OpenClaude will finish activating your new Codex OAuth login.</p>
</body>
</html>`
}
function renderErrorPage(message: string): string {
const safeMessage = escapeHtml(message)
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Codex Login Failed</title>
<style>
body { font-family: sans-serif; padding: 32px; line-height: 1.5; color: #111827; }
h1 { margin: 0 0 12px; font-size: 22px; color: #991b1b; }
p { margin: 0 0 10px; }
</style>
</head>
<body>
<h1>Codex login failed</h1>
<p>${safeMessage}</p>
<p>You can close this window and try again in OpenClaude.</p>
</body>
</html>`
}
function renderCancelledPage(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Codex Login Cancelled</title>
<style>
body { font-family: sans-serif; padding: 32px; line-height: 1.5; color: #111827; }
h1 { margin: 0 0 12px; font-size: 22px; }
p { margin: 0 0 10px; }
</style>
</head>
<body>
<h1>Codex login cancelled</h1>
<p>You can close this window and retry in OpenClaude.</p>
</body>
</html>`
}
async function exchangeAuthorizationCode(options: {
authorizationCode: string
codeVerifier: string
port: number
signal?: AbortSignal
}): Promise<CodexOAuthTokens> {
const redirectUri = `http://localhost:${options.port}/auth/callback`
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: options.authorizationCode,
redirect_uri: redirectUri,
client_id: getCodexOAuthClientId(),
code_verifier: options.codeVerifier,
})
const response = await fetch(`${CODEX_OAUTH_ISSUER}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
signal: options.signal
? AbortSignal.any([options.signal, AbortSignal.timeout(15_000)])
: AbortSignal.timeout(15_000),
})
if (!response.ok) {
const errorText = await response.text().catch(() => '')
throw new Error(
errorText.trim()
? `Codex OAuth token exchange failed (${response.status}): ${errorText.trim()}`
: `Codex OAuth token exchange failed with status ${response.status}.`,
)
}
const payload = (await response.json()) as CodexOAuthTokenResponse
const accessToken = asTrimmedString(payload.access_token)
const refreshToken = asTrimmedString(payload.refresh_token)
if (!accessToken || !refreshToken) {
throw new Error(
'Codex OAuth completed, but the token response was missing credentials.',
)
}
const idToken = asTrimmedString(payload.id_token)
const apiKey = idToken
? await exchangeCodexIdTokenForApiKey(idToken).catch(() => undefined)
: undefined
return {
apiKey,
accessToken,
refreshToken,
idToken,
accountId:
parseChatgptAccountId(idToken) ?? parseChatgptAccountId(accessToken),
}
}
export class CodexOAuthService {
private authCodeListener: AuthCodeListener | null = null
private port: number | null = null
private tokenExchangeAbortController: AbortController | null = null
private buildCancellationError(): Error {
return new Error('Codex OAuth flow was cancelled.')
}
async startOAuthFlow(
authURLHandler: (authUrl: string) => Promise<void>,
): Promise<CodexOAuthTokens> {
const codeVerifier = generateCodeVerifier()
const callbackPort = getCodexOAuthCallbackPort()
const authCodeListener = new AuthCodeListener('/auth/callback')
this.authCodeListener = authCodeListener
this.port = null
try {
const port = await authCodeListener.start(callbackPort)
this.port = port
const state = generateState()
const codeChallenge = await generateCodeChallenge(codeVerifier)
const authUrl = buildCodexAuthorizeUrl({
port,
codeChallenge,
state,
})
try {
const authorizationCode = await authCodeListener.waitForAuthorization(
state,
async () => {
await authURLHandler(authUrl)
},
)
const tokenExchangeAbortController = new AbortController()
this.tokenExchangeAbortController = tokenExchangeAbortController
let tokens: CodexOAuthTokens
try {
tokens = await exchangeAuthorizationCode({
authorizationCode,
codeVerifier,
port,
signal: tokenExchangeAbortController.signal,
})
} finally {
if (
this.tokenExchangeAbortController === tokenExchangeAbortController
) {
this.tokenExchangeAbortController = null
}
}
if (this.authCodeListener !== authCodeListener) {
throw this.buildCancellationError()
}
authCodeListener.handleSuccessRedirect([], res => {
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
})
res.end(renderSuccessPage())
})
return tokens
} catch (error) {
const resolvedError =
this.authCodeListener === authCodeListener
? error
: this.buildCancellationError()
if (authCodeListener.hasPendingResponse()) {
const isCancellation =
resolvedError instanceof Error &&
resolvedError.message === 'Codex OAuth flow was cancelled.'
authCodeListener.handleErrorRedirect(res => {
res.writeHead(isCancellation ? 200 : 400, {
'Content-Type': 'text/html; charset=utf-8',
})
res.end(
isCancellation
? renderCancelledPage()
: renderErrorPage(
resolvedError instanceof Error
? resolvedError.message
: String(resolvedError),
),
)
})
}
throw resolvedError
} finally {
this.cleanup()
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (
message.includes('EADDRINUSE') ||
message.includes(String(callbackPort))
) {
throw new Error(
`Codex OAuth needs localhost:${callbackPort} for its callback. Close any app already using that port and try again.`,
)
}
throw error
}
}
cleanup(): void {
const cancellationError = this.buildCancellationError()
this.tokenExchangeAbortController?.abort(cancellationError)
this.tokenExchangeAbortController = null
if (this.authCodeListener?.hasPendingResponse()) {
this.authCodeListener.handleErrorRedirect(res => {
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
})
res.end(renderCancelledPage())
})
}
this.authCodeListener?.cancelPendingAuthorization(cancellationError)
this.authCodeListener = null
this.port = null
}
}

View File

@@ -0,0 +1,139 @@
export const CODEX_OAUTH_ISSUER = 'https://auth.openai.com'
export const CODEX_REFRESH_URL = `${CODEX_OAUTH_ISSUER}/oauth/token`
export const DEFAULT_CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
export const DEFAULT_CODEX_OAUTH_CALLBACK_PORT = 1455
export const CODEX_OAUTH_SCOPE =
'openid profile email offline_access api.connectors.read api.connectors.invoke'
export const CODEX_OAUTH_ORIGINATOR = 'codex_cli_rs'
export const CODEX_API_KEY_TOKEN_NAME = 'openai-api-key'
export const CODEX_ID_TOKEN_SUBJECT_TYPE =
'urn:ietf:params:oauth:token-type:id_token'
export const CODEX_TOKEN_EXCHANGE_GRANT =
'urn:ietf:params:oauth:grant-type:token-exchange'
export function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed ? trimmed : undefined
}
export function getCodexOAuthClientId(
env: NodeJS.ProcessEnv = process.env,
): string {
return asTrimmedString(env.CODEX_OAUTH_CLIENT_ID) ?? DEFAULT_CODEX_OAUTH_CLIENT_ID
}
export function getCodexOAuthCallbackPort(
env: NodeJS.ProcessEnv = process.env,
): number {
const rawPort = asTrimmedString(env.CODEX_OAUTH_CALLBACK_PORT)
if (!rawPort) {
return DEFAULT_CODEX_OAUTH_CALLBACK_PORT
}
const parsed = Number.parseInt(rawPort, 10)
if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
return parsed
}
return DEFAULT_CODEX_OAUTH_CALLBACK_PORT
}
export 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
}
}
export function parseChatgptAccountId(
token: string | undefined,
): string | undefined {
if (!token) return undefined
const payload = decodeJwtPayload(token)
const nestedAuth =
payload?.['https://api.openai.com/auth'] &&
typeof payload['https://api.openai.com/auth'] === 'object'
? (payload['https://api.openai.com/auth'] as Record<string, unknown>)
: undefined
return (
asTrimmedString(
nestedAuth?.chatgpt_account_id ??
payload?.['https://api.openai.com/auth.chatgpt_account_id'] ??
payload?.chatgpt_account_id,
) ?? undefined
)
}
export function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, char => {
switch (char) {
case '&':
return '&amp;'
case '<':
return '&lt;'
case '>':
return '&gt;'
case '"':
return '&quot;'
case '\'':
return '&#39;'
default:
return char
}
})
}
export async function exchangeCodexIdTokenForApiKey(
idToken: string,
): Promise<string> {
const body = new URLSearchParams({
grant_type: CODEX_TOKEN_EXCHANGE_GRANT,
client_id: getCodexOAuthClientId(),
requested_token: CODEX_API_KEY_TOKEN_NAME,
subject_token: idToken,
subject_token_type: CODEX_ID_TOKEN_SUBJECT_TYPE,
})
const response = await fetch(CODEX_REFRESH_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
signal: AbortSignal.timeout(15_000),
})
if (!response.ok) {
const bodyText = await response.text().catch(() => '')
throw new Error(
bodyText.trim()
? `Codex API key exchange failed (${response.status}): ${bodyText.trim()}`
: `Codex API key exchange failed with status ${response.status}.`,
)
}
const payload = (await response.json()) as { access_token?: string }
const apiKey = asTrimmedString(payload.access_token)
if (!apiKey) {
throw new Error(
'Codex API key exchange completed, but no API key token was returned.',
)
}
return apiKey
}

View File

@@ -8,10 +8,6 @@ import {
convertCodexResponseToAnthropicMessage,
convertToolsToResponsesTools,
} from './codexShim.js'
import {
resolveCodexApiCredentials,
resolveProviderRequest,
} from './providerConfig.js'
const tempDirs: string[] = []
const originalEnv = {
@@ -63,6 +59,10 @@ async function collectStreamEventTypes(responseText: string): Promise<string[]>
return events
}
async function importFreshProviderConfigModule() {
return import(`./providerConfig.js?ts=${Date.now()}-${Math.random()}`)
}
describe('Codex provider config', () => {
const originalOpenaiBaseUrl = process.env.OPENAI_BASE_URL
const originalOpenaiApiBase = process.env.OPENAI_API_BASE
@@ -79,7 +79,8 @@ describe('Codex provider config', () => {
else process.env.OPENAI_API_BASE = originalOpenaiApiBase
})
test('resolves codexplan alias to Codex transport with reasoning', () => {
test('resolves codexplan alias to Codex transport with reasoning', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_BASE
delete process.env.CLAUDE_CODE_USE_GITHUB
@@ -91,7 +92,8 @@ describe('Codex provider config', () => {
expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex')
})
test('resolves codexspark alias to Codex transport with Codex base URL', () => {
test('resolves codexspark alias to Codex transport with Codex base URL', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_BASE
delete process.env.CLAUDE_CODE_USE_GITHUB
@@ -102,7 +104,8 @@ describe('Codex provider config', () => {
expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex')
})
test('does not force Codex transport when a local non-Codex base URL is explicit', () => {
test('does not force Codex transport when a local non-Codex base URL is explicit', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
const resolved = resolveProviderRequest({
model: 'codexplan',
baseUrl: 'http://127.0.0.1:8080/v1',
@@ -113,7 +116,8 @@ describe('Codex provider config', () => {
expect(resolved.resolvedModel).toBe('gpt-5.4')
})
test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is the string "undefined"', () => {
test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is the string "undefined"', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
// On Windows, env vars can leak as the literal string "undefined" instead of
// the JS value undefined when not properly unset (issue #336).
process.env.OPENAI_BASE_URL = 'undefined'
@@ -121,20 +125,23 @@ describe('Codex provider config', () => {
expect(resolved.transport).toBe('codex_responses')
})
test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is an empty string', () => {
test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is an empty string', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
process.env.OPENAI_BASE_URL = ''
const resolved = resolveProviderRequest({ model: 'codexplan' })
expect(resolved.transport).toBe('codex_responses')
})
test('prefers explicit baseUrl option over env var', () => {
test('prefers explicit baseUrl option over env var', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
process.env.OPENAI_BASE_URL = 'https://example.com/v1'
const resolved = resolveProviderRequest({ model: 'codexplan', baseUrl: 'https://chatgpt.com/backend-api/codex' })
expect(resolved.transport).toBe('codex_responses')
expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex')
})
test('default gpt-4o uses OpenAI base URL (no regression)', () => {
test('default gpt-4o uses OpenAI base URL (no regression)', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
delete process.env.OPENAI_BASE_URL
delete process.env.CLAUDE_CODE_USE_GITHUB
@@ -144,7 +151,8 @@ describe('Codex provider config', () => {
expect(resolved.resolvedModel).toBe('gpt-4o')
})
test('resolves codexplan from env var OPENAI_MODEL to Codex endpoint', () => {
test('resolves codexplan from env var OPENAI_MODEL to Codex endpoint', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
process.env.OPENAI_MODEL = 'codexplan'
delete process.env.OPENAI_BASE_URL
delete process.env.CLAUDE_CODE_USE_GITHUB
@@ -155,7 +163,8 @@ describe('Codex provider config', () => {
expect(resolved.resolvedModel).toBe('gpt-5.4')
})
test('does not override custom base URL for codexplan (e.g., local provider)', () => {
test('does not override custom base URL for codexplan (e.g., local provider)', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
process.env.OPENAI_MODEL = 'codexplan'
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
delete process.env.CLAUDE_CODE_USE_GITHUB
@@ -165,7 +174,8 @@ describe('Codex provider config', () => {
expect(resolved.baseUrl).toBe('http://localhost:11434/v1')
})
test('loads Codex credentials from auth.json fallback', () => {
test('loads Codex credentials from auth.json fallback', async () => {
const { resolveCodexApiCredentials } = await importFreshProviderConfigModule()
const authPath = createTempAuthJson({
tokens: {
access_token: 'header.payload.signature',
@@ -181,6 +191,31 @@ describe('Codex provider config', () => {
expect(credentials.accountId).toBe('acct_test')
expect(credentials.source).toBe('auth.json')
})
test('does not treat auth.json id_token as a Codex bearer credential', async () => {
const { resolveCodexApiCredentials } = await importFreshProviderConfigModule()
const idTokenPayload = Buffer.from(
JSON.stringify({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_from_id_token',
},
}),
'utf8',
).toString('base64url')
const authPath = createTempAuthJson({
tokens: {
id_token: `header.${idTokenPayload}.signature`,
},
})
const credentials = resolveCodexApiCredentials({
CODEX_AUTH_JSON_PATH: authPath,
} as NodeJS.ProcessEnv)
expect(credentials.apiKey).toBe('')
expect(credentials.accountId).toBe('acct_from_id_token')
expect(credentials.source).toBe('none')
})
})
describe('Codex request translation', () => {

View File

@@ -1,7 +1,13 @@
import {
readCodexCredentialsAsync,
refreshCodexAccessTokenIfNeeded,
} from '../../utils/codexCredentials.js'
import { logForDebugging } from '../../utils/debug.js'
import { isBareMode } from '../../utils/envUtils.js'
import {
DEFAULT_CODEX_BASE_URL,
isCodexBaseUrl,
resolveCodexApiCredentials,
resolveRuntimeCodexCredentials,
resolveProviderRequest,
} from './providerConfig.js'
@@ -391,6 +397,18 @@ export function getCodexUsageUrl(baseUrl = DEFAULT_CODEX_BASE_URL): string {
}
export async function fetchCodexUsage(): Promise<CodexUsageData> {
const refreshResult = await refreshCodexAccessTokenIfNeeded().catch(
async error => {
logForDebugging(
`[codex] access token refresh failed before usage fetch: ${error instanceof Error ? error.message : String(error)}`,
{ level: 'warn' },
)
return {
refreshed: false,
credentials: await readCodexCredentialsAsync(),
}
},
)
const request = resolveProviderRequest({
model: process.env.OPENAI_MODEL,
baseUrl: process.env.OPENAI_BASE_URL,
@@ -401,16 +419,19 @@ export async function fetchCodexUsage(): Promise<CodexUsageData> {
)
}
const credentials = resolveCodexApiCredentials()
const credentials = resolveRuntimeCodexCredentials({
storedCredentials: refreshResult.credentials,
})
if (!credentials.apiKey) {
const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider'
const authHint = credentials.authPath
? ` or place a Codex auth.json at ${credentials.authPath}`
: ''
? `${oauthHint} or place a Codex auth.json at ${credentials.authPath}`
: oauthHint
throw new Error(`Codex auth is required. Set CODEX_API_KEY${authHint}.`)
}
if (!credentials.accountId) {
throw new Error(
'Codex auth is missing chatgpt_account_id. Re-login with the Codex CLI or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.',
'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, the Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.',
)
}

View File

@@ -22,7 +22,12 @@
*/
import { APIError } from '@anthropic-ai/sdk'
import { isEnvTruthy } from '../../utils/envUtils.js'
import {
readCodexCredentialsAsync,
refreshCodexAccessTokenIfNeeded,
} from '../../utils/codexCredentials.js'
import { logForDebugging } from '../../utils/debug.js'
import { isBareMode, isEnvTruthy } from '../../utils/envUtils.js'
import { resolveGeminiCredential } from '../../utils/geminiAuth.js'
import { hydrateGeminiAccessTokenFromSecureStorage } from '../../utils/geminiCredentials.js'
import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js'
@@ -44,7 +49,7 @@ import {
} from './codexShim.js'
import {
isLocalProviderUrl,
resolveCodexApiCredentials,
resolveRuntimeCodexCredentials,
resolveProviderRequest,
getGithubEndpointType,
} from './providerConfig.js'
@@ -1139,7 +1144,6 @@ class OpenAIShimMessages {
const githubEndpointType = getGithubEndpointType(request.baseUrl)
const isGithubMode = isGithubModelsMode()
const isGithubWithCodexTransport = isGithubMode && request.transport === 'codex_responses'
const isGithubCopilotEndpoint = isGithubMode && githubEndpointType === 'copilot'
if (isGithubWithCodexTransport) {
const apiKey = this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? ''
@@ -1166,11 +1170,26 @@ class OpenAIShimMessages {
}
if (request.transport === 'codex_responses' && !isGithubMode) {
const credentials = resolveCodexApiCredentials()
const refreshResult = await refreshCodexAccessTokenIfNeeded().catch(
async error => {
logForDebugging(
`[codex] access token refresh failed before request: ${error instanceof Error ? error.message : String(error)}`,
{ level: 'warn' },
)
return {
refreshed: false,
credentials: await readCodexCredentialsAsync(),
}
},
)
const credentials = resolveRuntimeCodexCredentials({
storedCredentials: refreshResult.credentials,
})
if (!credentials.apiKey) {
const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider'
const authHint = credentials.authPath
? ` or place a Codex auth.json at ${credentials.authPath}`
: ''
? `${oauthHint} or place a Codex auth.json at ${credentials.authPath}`
: oauthHint
const safeModel =
redactSecretValueForDisplay(request.requestedModel, process.env as SecretValueSource) ??
'the requested model'
@@ -1180,7 +1199,7 @@ class OpenAIShimMessages {
}
if (!credentials.accountId) {
throw new Error(
'Codex auth is missing chatgpt_account_id. Re-login with the Codex CLI or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.',
'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, the Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.',
)
}

View File

@@ -0,0 +1,225 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import * as realOs from 'node:os'
function makeJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' }))
.toString('base64url')
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
return `${header}.${body}.signature`
}
describe('resolveCodexApiCredentials with secure storage', () => {
afterEach(() => {
mock.restore()
})
test('loads Codex credentials from OpenClaude secure storage', async () => {
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => ({
apiKey: 'codex-api-key-token',
accessToken: 'header.payload.signature',
accountId: 'acct_secure',
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-secure-storage'
)
const credentials = resolveCodexApiCredentials({} as NodeJS.ProcessEnv)
expect(credentials.apiKey).toBe('codex-api-key-token')
expect(credentials.accountId).toBe('acct_secure')
expect(credentials.source).toBe('secure-storage')
})
test('prefers explicit env credentials over secure storage', async () => {
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => ({
accessToken: 'stored-token',
accountId: 'acct_stored',
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-env-precedence'
)
const credentials = resolveCodexApiCredentials({
CODEX_API_KEY: 'env-token',
CHATGPT_ACCOUNT_ID: 'acct_env',
} as NodeJS.ProcessEnv)
expect(credentials.apiKey).toBe('env-token')
expect(credentials.accountId).toBe('acct_env')
expect(credentials.source).toBe('env')
})
test('parses nested chatgpt_account_id from a CODEX_API_KEY JWT', async () => {
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => undefined,
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-env-nested-account'
)
const credentials = resolveCodexApiCredentials({
CODEX_API_KEY: makeJwt({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_nested_env',
},
}),
} as NodeJS.ProcessEnv)
expect(credentials.accountId).toBe('acct_nested_env')
expect(credentials.source).toBe('env')
})
test('parses nested chatgpt_account_id from auth.json tokens', async () => {
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => undefined,
}))
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-auth-'))
const authPath = join(tempDir, 'auth.json')
writeFileSync(
authPath,
JSON.stringify({
openai_api_key: makeJwt({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_nested_auth_json',
},
}),
}),
'utf8',
)
try {
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-auth-json-nested-account'
)
const credentials = resolveCodexApiCredentials({
CODEX_AUTH_JSON_PATH: authPath,
} as NodeJS.ProcessEnv)
expect(credentials.accountId).toBe('acct_nested_auth_json')
expect(credentials.source).toBe('auth.json')
} finally {
rmSync(tempDir, { force: true, recursive: true })
}
})
test('does not read default auth.json when secure storage already has Codex credentials', async () => {
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => ({
apiKey: 'codex-api-key-token',
accessToken: 'header.payload.signature',
accountId: 'acct_secure',
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-secure-storage-no-auth-io'
)
const credentials = resolveCodexApiCredentials({} as NodeJS.ProcessEnv)
expect(credentials.apiKey).toBe('codex-api-key-token')
expect(credentials.accountId).toBe('acct_secure')
expect(credentials.source).toBe('secure-storage')
})
test('falls back to the default auth.json when stored Codex refresh is cooling down', async () => {
const tempHomeDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-home-'))
const authJson = JSON.stringify({
openai_api_key: makeJwt({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_auth_json',
},
}),
})
mkdirSync(join(tempHomeDir, '.codex'), { recursive: true })
writeFileSync(join(tempHomeDir, '.codex', 'auth.json'), authJson, 'utf8')
mock.module('node:os', () => ({
...realOs,
homedir: () => tempHomeDir,
}))
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => true,
readCodexCredentials: () => ({
accessToken: 'stored-token',
refreshToken: 'refresh-stored',
accountId: 'acct_stored',
lastRefreshFailureAt: Date.now(),
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-refresh-cooldown-fallback'
)
try {
const credentials = resolveCodexApiCredentials({} as NodeJS.ProcessEnv)
expect(credentials.source).toBe('auth.json')
expect(credentials.accountId).toBe('acct_auth_json')
expect(credentials.apiKey).not.toBe('stored-token')
} finally {
rmSync(tempHomeDir, { force: true, recursive: true })
}
})
test('preserves the stored account id when auth.json fallback lacks one', async () => {
const tempHomeDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-home-'))
const authJson = JSON.stringify({
openai_api_key: 'auth-json-access-token',
})
mkdirSync(join(tempHomeDir, '.codex'), { recursive: true })
writeFileSync(join(tempHomeDir, '.codex', 'auth.json'), authJson, 'utf8')
mock.module('node:os', () => ({
...realOs,
homedir: () => tempHomeDir,
}))
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => true,
readCodexCredentials: () => ({
accessToken: 'stored-token',
refreshToken: 'refresh-stored',
accountId: 'acct_stored',
lastRefreshFailureAt: Date.now(),
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-refresh-cooldown-account-id-fallback'
)
try {
const credentials = resolveCodexApiCredentials({} as NodeJS.ProcessEnv)
expect(credentials.source).toBe('auth.json')
expect(credentials.apiKey).toBe('auth-json-access-token')
expect(credentials.accountId).toBe('acct_stored')
} finally {
rmSync(tempHomeDir, { force: true, recursive: true })
}
})
})

View File

@@ -0,0 +1,107 @@
import { afterEach, expect, mock, test } from 'bun:test'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { resolveRuntimeCodexCredentials } from './providerConfig.js'
afterEach(() => {
mock.restore()
})
function makeJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' }))
.toString('base64url')
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
return `${header}.${body}.signature`
}
test('runtime credential resolution honors explicit auth.json over stored secure-storage tokens', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-explicit-auth-'))
const authPath = join(tempDir, 'auth.json')
writeFileSync(
authPath,
JSON.stringify({
openai_api_key: makeJwt({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_explicit_auth_json',
},
}),
}),
'utf8',
)
try {
const credentials = resolveRuntimeCodexCredentials({
env: {
CODEX_AUTH_JSON_PATH: authPath,
} as NodeJS.ProcessEnv,
storedCredentials: {
apiKey: 'stored-api-key',
accessToken: 'stored-access-token',
accountId: 'acct_stored',
},
})
expect(credentials.source).toBe('auth.json')
expect(credentials.accountId).toBe('acct_explicit_auth_json')
expect(credentials.apiKey).not.toBe('stored-api-key')
} finally {
rmSync(tempDir, { force: true, recursive: true })
}
})
test('runtime credential resolution preserves an explicit auth.json path even when it is missing', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-missing-auth-'))
const authPath = join(tempDir, 'missing-auth.json')
try {
const credentials = resolveRuntimeCodexCredentials({
env: {
CODEX_AUTH_JSON_PATH: authPath,
} as NodeJS.ProcessEnv,
storedCredentials: {
apiKey: 'stored-api-key',
accessToken: 'stored-access-token',
accountId: 'acct_stored',
},
})
expect(credentials.source).toBe('none')
expect(credentials.authPath).toBe(authPath)
expect(credentials.apiKey).toBe('')
} finally {
rmSync(tempDir, { force: true, recursive: true })
}
})
test('runtime credential resolution avoids sync secure-storage reads when async credentials are provided', async () => {
let syncReadCalled = false
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => {
syncReadCalled = true
throw new Error('sync secure-storage read should not run in runtime resolution')
},
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveRuntimeCodexCredentials } = await import(
'./providerConfig.js?runtime-no-sync-secure-storage'
)
const credentials = resolveRuntimeCodexCredentials({
env: {} as NodeJS.ProcessEnv,
storedCredentials: {
accessToken: 'stored-access-token',
accountId: 'acct_stored',
},
})
expect(syncReadCalled).toBe(false)
expect(credentials.source).toBe('secure-storage')
expect(credentials.apiKey).toBe('stored-access-token')
expect(credentials.accountId).toBe('acct_stored')
})

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)
}

View File

@@ -0,0 +1,155 @@
import { afterEach, expect, mock, test } from 'bun:test'
afterEach(() => {
mock.restore()
})
test('custom error responses log the error redirect analytics event', async () => {
const events: Array<{
name: string
metadata: Record<string, boolean | number | undefined>
}> = []
mock.module('src/services/analytics/index.js', () => ({
logEvent: (
name: string,
metadata: Record<string, boolean | number | undefined>,
) => {
events.push({ name, metadata })
},
}))
const { AuthCodeListener } = await import(
`./auth-code-listener.js?ts=${Date.now()}-${Math.random()}`
)
const listener = new AuthCodeListener('/callback')
const response = {
writeHead: () => {},
end: () => {},
}
;(listener as any).pendingResponse = response
listener.handleErrorRedirect(res => {
res.writeHead(400, {
'Content-Type': 'text/plain; charset=utf-8',
})
res.end('cancelled')
})
expect(events).toEqual([
{
name: 'tengu_oauth_automatic_redirect_error',
metadata: { custom_handler: true },
},
])
})
test('custom handlers that do not end the response are closed automatically and still log analytics', async () => {
const events: Array<{
name: string
metadata: Record<string, boolean | number | undefined>
}> = []
const response = {
destroyed: false,
headersSent: false,
writableEnded: false,
writeHead: () => {
response.headersSent = true
},
end: () => {
response.writableEnded = true
},
}
mock.module('src/services/analytics/index.js', () => ({
logEvent: (
name: string,
metadata: Record<string, boolean | number | undefined>,
) => {
events.push({ name, metadata })
},
}))
mock.module('../../utils/log.js', () => ({
logError: () => {},
}))
const { AuthCodeListener } = await import(
`./auth-code-listener.js?ts=${Date.now()}-${Math.random()}`
)
const listener = new AuthCodeListener('/callback')
;(listener as any).pendingResponse = response
listener.handleErrorRedirect(res => {
res.writeHead(400, {
'Content-Type': 'text/plain; charset=utf-8',
})
})
expect(response.writableEnded).toBe(true)
expect((listener as any).pendingResponse).toBeNull()
expect(events).toEqual([
{
name: 'tengu_oauth_automatic_redirect_error',
metadata: { custom_handler: true },
},
])
})
test('custom handlers that throw are logged, converted to a fallback response, and do not log analytics', async () => {
const events: Array<{
name: string
metadata: Record<string, boolean | number | undefined>
}> = []
const loggedErrors: unknown[] = []
const response = {
destroyed: false,
headersSent: false,
writableEnded: false,
statusCode: 0,
body: '',
writeHead: (statusCode: number) => {
response.headersSent = true
response.statusCode = statusCode
},
end: (body = '') => {
response.writableEnded = true
response.body = body
},
}
mock.module('src/services/analytics/index.js', () => ({
logEvent: (
name: string,
metadata: Record<string, boolean | number | undefined>,
) => {
events.push({ name, metadata })
},
}))
mock.module('../../utils/log.js', () => ({
logError: (error: unknown) => {
loggedErrors.push(error)
},
}))
const { AuthCodeListener } = await import(
`./auth-code-listener.js?ts=${Date.now()}-${Math.random()}`
)
const listener = new AuthCodeListener('/callback')
;(listener as any).pendingResponse = response
listener.handleErrorRedirect(() => {
throw new Error('handler exploded')
})
expect(response.statusCode).toBe(500)
expect(response.body).toBe('Authentication redirect failed')
expect(response.writableEnded).toBe(true)
expect((listener as any).pendingResponse).toBeNull()
expect(loggedErrors).toHaveLength(1)
expect(events).toEqual([])
})

View File

@@ -0,0 +1,31 @@
import { afterEach, expect, test } from 'bun:test'
import { AuthCodeListener } from './auth-code-listener.js'
const listeners: AuthCodeListener[] = []
afterEach(() => {
while (listeners.length > 0) {
listeners.pop()?.close()
}
})
test('cancelPendingAuthorization rejects a pending OAuth wait', async () => {
const listener = new AuthCodeListener('/callback')
listeners.push(listener)
await listener.start()
const pendingAuthorization = listener.waitForAuthorization(
'state-test',
async () => {},
)
listener.cancelPendingAuthorization(
new Error('Codex OAuth flow was cancelled.'),
)
await expect(pendingAuthorization).rejects.toThrow(
'Codex OAuth flow was cancelled.',
)
})

View File

@@ -71,6 +71,42 @@ export class AuthCodeListener {
})
}
private respondToPendingRequest(options: {
handler: (res: ServerResponse) => void
analyticsEvent:
| 'tengu_oauth_automatic_redirect'
| 'tengu_oauth_automatic_redirect_error'
analyticsMetadata?: Record<string, boolean>
}): void {
if (!this.pendingResponse) return
const response = this.pendingResponse
try {
options.handler(response)
if (!response.writableEnded && !response.destroyed) {
response.end()
}
logEvent(options.analyticsEvent, options.analyticsMetadata ?? {})
} catch (error) {
logError(error)
if (!response.headersSent && !response.destroyed) {
response.writeHead(500, {
'Content-Type': 'text/plain; charset=utf-8',
})
}
if (!response.writableEnded && !response.destroyed) {
response.end('Authentication redirect failed')
}
} finally {
if (this.pendingResponse === response) {
this.pendingResponse = null
}
}
}
/**
* Completes the OAuth flow by redirecting the user's browser to a success page.
* Different success pages are shown based on the granted scopes.
@@ -85,9 +121,13 @@ export class AuthCodeListener {
// If custom handler provided, use it instead of default redirect
if (customHandler) {
customHandler(this.pendingResponse, scopes)
this.pendingResponse = null
logEvent('tengu_oauth_automatic_redirect', { custom_handler: true })
this.respondToPendingRequest({
handler: res => {
customHandler(res, scopes)
},
analyticsEvent: 'tengu_oauth_automatic_redirect',
analyticsMetadata: { custom_handler: true },
})
return
}
@@ -97,29 +137,48 @@ export class AuthCodeListener {
: getOauthConfig().CONSOLE_SUCCESS_URL
// Send browser to success page
this.pendingResponse.writeHead(302, { Location: successUrl })
this.pendingResponse.end()
this.pendingResponse = null
logEvent('tengu_oauth_automatic_redirect', {})
this.respondToPendingRequest({
handler: res => {
res.writeHead(302, { Location: successUrl })
res.end()
},
analyticsEvent: 'tengu_oauth_automatic_redirect',
})
}
/**
* Handles error case by sending a redirect to the appropriate success page with an error indicator,
* ensuring the browser flow is completed properly.
*/
handleErrorRedirect(): void {
handleErrorRedirect(customHandler?: (res: ServerResponse) => void): void {
if (!this.pendingResponse) return
if (customHandler) {
this.respondToPendingRequest({
handler: customHandler,
analyticsEvent: 'tengu_oauth_automatic_redirect_error',
analyticsMetadata: { custom_handler: true },
})
return
}
// TODO: swap to a different url once we have an error page
const errorUrl = getOauthConfig().CLAUDEAI_SUCCESS_URL
// Send browser to error page
this.pendingResponse.writeHead(302, { Location: errorUrl })
this.pendingResponse.end()
this.pendingResponse = null
this.respondToPendingRequest({
handler: res => {
res.writeHead(302, { Location: errorUrl })
res.end()
},
analyticsEvent: 'tengu_oauth_automatic_redirect_error',
})
}
logEvent('tengu_oauth_automatic_redirect_error', {})
cancelPendingAuthorization(
error: Error = new Error('OAuth authorization was cancelled.'),
): void {
this.reject(error)
this.close()
}
private startLocalListener(onReady: () => Promise<void>): void {
@@ -176,8 +235,7 @@ export class AuthCodeListener {
private handleError(err: Error): void {
logError(err)
this.close()
this.reject(err)
this.cancelPendingAuthorization(err)
}
private resolve(authorizationCode: string): void {
@@ -185,6 +243,7 @@ export class AuthCodeListener {
this.promiseResolver(authorizationCode)
this.promiseResolver = null
this.promiseRejecter = null
this.expectedState = null
}
}
@@ -193,6 +252,7 @@ export class AuthCodeListener {
this.promiseRejecter(error)
this.promiseResolver = null
this.promiseRejecter = null
this.expectedState = null
}
}
@@ -207,5 +267,8 @@ export class AuthCodeListener {
this.localServer.removeAllListeners()
this.localServer.close()
}
this.expectedState = null
this.port = 0
}
}