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 ` Codex Login Complete

Codex login complete

You can return to OpenClaude now.

OpenClaude will finish activating your new Codex OAuth login.

` } function renderErrorPage(message: string): string { const safeMessage = escapeHtml(message) return ` Codex Login Failed

Codex login failed

${safeMessage}

You can close this window and try again in OpenClaude.

` } function renderCancelledPage(): string { return ` Codex Login Cancelled

Codex login cancelled

You can close this window and retry in OpenClaude.

` } async function exchangeAuthorizationCode(options: { authorizationCode: string codeVerifier: string port: number signal?: AbortSignal }): Promise { 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, ): Promise { 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 } }