codev / src /services /openaiAuth /client.ts
chenbhao's picture
feat: desktop
1f21206
Raw
History Blame Contribute Delete
5.73 kB
import { randomBytes } from 'crypto'
import { generateCodeChallenge } from '../oauth/crypto.js'
import type {
OpenAIJwtClaims,
OpenAIOAuthTokenResponse,
OpenAIOAuthTokens,
} from './types.js'
export const OPENAI_AUTH_ISSUER = 'https://auth.openai.com'
export const OPENAI_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
export const OPENAI_CODEX_API_ENDPOINT =
'https://chatgpt.com/backend-api/codex/responses'
export const OPENAI_CODEX_OAUTH_PORT = 1455
export const OPENAI_CODEX_REDIRECT_PATH = '/auth/callback'
export const OPENAI_CODEX_TOKEN_USER_AGENT = 'codex-cli/0.91.0'
const DEFAULT_TOKEN_LIFETIME_MS = 3600 * 1000
const OPENAI_TOKEN_ERROR_BODY_LIMIT = 500
const OPENAI_TOKEN_REQUEST_HEADERS = {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': OPENAI_CODEX_TOKEN_USER_AGENT,
} as const
export function generateOpenAIState(): string {
return randomBytes(32).toString('hex')
}
export function generateOpenAICodeVerifier(): string {
return randomBytes(64).toString('hex')
}
export function buildOpenAIAuthorizeUrl(input: {
redirectUri: string
codeVerifier: string
state: string
}): string {
const params = new URLSearchParams({
response_type: 'code',
client_id: OPENAI_CODEX_CLIENT_ID,
redirect_uri: input.redirectUri,
scope: 'openid profile email offline_access',
code_challenge: generateCodeChallenge(input.codeVerifier),
code_challenge_method: 'S256',
id_token_add_organizations: 'true',
codex_cli_simplified_flow: 'true',
state: input.state,
})
return `${OPENAI_AUTH_ISSUER}/oauth/authorize?${params.toString()}`
}
export async function exchangeOpenAICodeForTokens(input: {
code: string
redirectUri: string
codeVerifier: string
}): Promise<OpenAIOAuthTokenResponse> {
const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
method: 'POST',
headers: OPENAI_TOKEN_REQUEST_HEADERS,
body: new URLSearchParams({
grant_type: 'authorization_code',
code: input.code,
redirect_uri: input.redirectUri,
client_id: OPENAI_CODEX_CLIENT_ID,
code_verifier: input.codeVerifier,
}).toString(),
})
if (!response.ok) {
throw await buildOpenAITokenHttpError('exchange', response)
}
return (await response.json()) as OpenAIOAuthTokenResponse
}
export async function refreshOpenAITokens(
refreshToken: string,
): Promise<OpenAIOAuthTokenResponse> {
const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
method: 'POST',
headers: OPENAI_TOKEN_REQUEST_HEADERS,
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: OPENAI_CODEX_CLIENT_ID,
scope: 'openid profile email',
}).toString(),
})
if (!response.ok) {
throw await buildOpenAITokenHttpError('refresh', response)
}
return (await response.json()) as OpenAIOAuthTokenResponse
}
async function buildOpenAITokenHttpError(
operation: 'exchange' | 'refresh',
response: Response,
): Promise<Error> {
const body = await response.text().catch(() => '')
const sanitizedBody = sanitizeOpenAITokenErrorBody(body)
const bodySuffix = sanitizedBody ? `: ${sanitizedBody}` : ''
return new Error(
`OpenAI token ${operation} failed: ${response.status}${bodySuffix}`,
)
}
function sanitizeOpenAITokenErrorBody(body: string): string {
return body
.replace(
/"((?:access_token|refresh_token|id_token|code|code_verifier))"\s*:\s*"[^"]*"/gi,
'"$1":"[redacted]"',
)
.replace(
/\b(access_token|refresh_token|id_token|code|code_verifier)=([^&\s]+)/gi,
'$1=[redacted]',
)
.slice(0, OPENAI_TOKEN_ERROR_BODY_LIMIT)
}
export function parseOpenAIJwtClaims(
token?: string,
): OpenAIJwtClaims | undefined {
if (!token) return undefined
const parts = token.split('.')
if (parts.length !== 3) return undefined
try {
return JSON.parse(Buffer.from(parts[1]!, 'base64url').toString())
} catch {
return undefined
}
}
export function extractOpenAIAccountId(
claims?: OpenAIJwtClaims,
): string | undefined {
if (!claims) return undefined
return (
claims.chatgpt_account_id ||
claims['https://api.openai.com/auth']?.chatgpt_account_id ||
claims.organizations?.[0]?.id
)
}
export function normalizeOpenAITokens(
response: OpenAIOAuthTokenResponse,
): OpenAIOAuthTokens {
const claims =
parseOpenAIJwtClaims(response.id_token) ??
parseOpenAIJwtClaims(response.access_token)
if (!response.refresh_token) {
throw new Error('OpenAI OAuth response did not include a refresh token')
}
return {
accessToken: response.access_token,
refreshToken: response.refresh_token,
expiresAt: Date.now() + (response.expires_in ?? 3600) * 1000,
idToken: response.id_token,
accountId: extractOpenAIAccountId(claims),
email: claims?.email,
clientId: OPENAI_CODEX_CLIENT_ID,
}
}
export function isOpenAITokenExpired(expiresAt: number): boolean {
return expiresAt - Date.now() <= 5 * 60 * 1000
}
export function withRefreshedAccessToken(
existing: OpenAIOAuthTokens,
refreshed: OpenAIOAuthTokenResponse,
): OpenAIOAuthTokens {
const claims =
parseOpenAIJwtClaims(refreshed.id_token) ??
parseOpenAIJwtClaims(refreshed.access_token)
return {
accessToken: refreshed.access_token,
refreshToken: refreshed.refresh_token ?? existing.refreshToken,
expiresAt: Date.now() + (refreshed.expires_in ?? 3600) * 1000,
idToken: refreshed.id_token ?? existing.idToken,
accountId: extractOpenAIAccountId(claims) ?? existing.accountId,
email: claims?.email ?? existing.email,
clientId: existing.clientId ?? OPENAI_CODEX_CLIENT_ID,
}
}