* feat: add Codex OAuth provider flow * fix: harden Codex OAuth storage, session activation, and UI
140 lines
4.0 KiB
TypeScript
140 lines
4.0 KiB
TypeScript
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 '&'
|
|
case '<':
|
|
return '<'
|
|
case '>':
|
|
return '>'
|
|
case '"':
|
|
return '"'
|
|
case '\'':
|
|
return '''
|
|
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
|
|
}
|