diff --git a/apps/cli/src/commands/auth/index.ts b/apps/cli/src/commands/auth/index.ts index 52ae7673a7e..40523f27fa2 100644 --- a/apps/cli/src/commands/auth/index.ts +++ b/apps/cli/src/commands/auth/index.ts @@ -1,3 +1,6 @@ export * from "./login.js" export * from "./logout.js" export * from "./status.js" +export * from "./openai-codex-login.js" +export * from "./openai-codex-logout.js" +export * from "./openai-codex-status.js" diff --git a/apps/cli/src/commands/auth/openai-codex-login.ts b/apps/cli/src/commands/auth/openai-codex-login.ts new file mode 100644 index 00000000000..a0935dd0b49 --- /dev/null +++ b/apps/cli/src/commands/auth/openai-codex-login.ts @@ -0,0 +1,9 @@ +import { + loginOpenAiCodex, + type OpenAiCodexLoginOptions, + type OpenAiCodexLoginResult, +} from "@/lib/auth/openai-codex-oauth.js" + +export async function openaiCodexLogin(options: OpenAiCodexLoginOptions = {}): Promise { + return loginOpenAiCodex(options) +} diff --git a/apps/cli/src/commands/auth/openai-codex-logout.ts b/apps/cli/src/commands/auth/openai-codex-logout.ts new file mode 100644 index 00000000000..38d140e773a --- /dev/null +++ b/apps/cli/src/commands/auth/openai-codex-logout.ts @@ -0,0 +1,6 @@ +import { logoutOpenAiCodex } from "@/lib/auth/openai-codex-oauth.js" + +export async function openaiCodexLogout(): Promise { + await logoutOpenAiCodex() + console.log("✓ Successfully logged out of OpenAI Codex") +} diff --git a/apps/cli/src/commands/auth/openai-codex-status.ts b/apps/cli/src/commands/auth/openai-codex-status.ts new file mode 100644 index 00000000000..d6de7d159c5 --- /dev/null +++ b/apps/cli/src/commands/auth/openai-codex-status.ts @@ -0,0 +1,17 @@ +import { isAuthenticatedOpenAiCodex } from "@/lib/auth/openai-codex-oauth.js" +import { loadOpenAiCodexCredentials } from "@/lib/storage/openai-codex-credentials.js" + +export async function openaiCodexStatus(): Promise { + const authenticated = await isAuthenticatedOpenAiCodex() + + if (authenticated) { + const credentials = await loadOpenAiCodexCredentials() + console.log("✓ Authenticated with OpenAI Codex") + if (credentials?.email) { + console.log(` Email: ${credentials.email}`) + } + } else { + console.log("✗ Not authenticated with OpenAI Codex") + console.log(" Run 'roo-code auth openai-codex:login' to authenticate") + } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 8d3f5af521e..ac40da4edae 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -2,7 +2,7 @@ import { Command } from "commander" import { DEFAULT_FLAGS } from "@/types/constants.js" import { VERSION } from "@/lib/utils/version.js" -import { run, login, logout, status } from "@/commands/index.js" +import { run, login, logout, status, openaiCodexLogin, openaiCodexLogout, openaiCodexStatus } from "@/commands/index.js" const program = new Command() @@ -62,4 +62,29 @@ authCommand process.exit(result.authenticated ? 0 : 1) }) +authCommand + .command("openai-codex:login") + .description("Authenticate with OpenAI Codex (ChatGPT Plus/Pro)") + .option("-v, --verbose", "Enable verbose output", false) + .action(async (options: { verbose: boolean }) => { + const result = await openaiCodexLogin({ verbose: options.verbose }) + process.exit(result.success ? 0 : 1) + }) + +authCommand + .command("openai-codex:logout") + .description("Log out from OpenAI Codex") + .action(async () => { + await openaiCodexLogout() + process.exit(0) + }) + +authCommand + .command("openai-codex:status") + .description("Show OpenAI Codex authentication status") + .action(async () => { + await openaiCodexStatus() + process.exit(0) + }) + program.parse() diff --git a/apps/cli/src/lib/auth/openai-codex-oauth.ts b/apps/cli/src/lib/auth/openai-codex-oauth.ts new file mode 100644 index 00000000000..35b4d967d75 --- /dev/null +++ b/apps/cli/src/lib/auth/openai-codex-oauth.ts @@ -0,0 +1,492 @@ +import * as crypto from "crypto" +import * as http from "http" +import { URL } from "url" +import { exec } from "child_process" + +import type { OpenAiCodexCredentials } from "../storage/openai-codex-credentials.js" +import { + saveOpenAiCodexCredentials, + loadOpenAiCodexCredentials, + clearOpenAiCodexCredentials, +} from "../storage/openai-codex-credentials.js" + +/** + * OpenAI Codex OAuth Configuration + * Based on the OpenAI Codex OAuth implementation + */ +export const OPENAI_CODEX_OAUTH_CONFIG = { + authorizationEndpoint: "https://auth.openai.com/oauth/authorize", + tokenEndpoint: "https://auth.openai.com/oauth/token", + clientId: "app_EMoamEEZ73f0CkXaXp7hrann", + redirectUri: "http://localhost:1455/auth/callback", + scopes: "openid profile email offline_access", + callbackPort: 1455, +} as const + +interface TokenResponse { + access_token: string + refresh_token?: string + id_token?: string + expires_in: number + email?: string + token_type?: string +} + +interface IdTokenClaims { + chatgpt_account_id?: string + organizations?: Array<{ id: string }> + email?: string + "https://api.openai.com/auth"?: { + chatgpt_account_id?: string + } +} + +/** + * Parse JWT claims from a token + */ +function parseJwtClaims(token: string): IdTokenClaims | undefined { + const parts = token.split(".") + if (parts.length !== 3 || !parts[1]) return undefined + try { + // Base64url decode: convert base64url to base64, then decode + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/") + const payload = Buffer.from(base64, "base64").toString("utf-8") + return JSON.parse(payload) as IdTokenClaims + } catch { + return undefined + } +} + +/** + * Extract ChatGPT account ID from JWT claims + */ +function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined { + return ( + claims.chatgpt_account_id || + claims["https://api.openai.com/auth"]?.chatgpt_account_id || + claims.organizations?.[0]?.id + ) +} + +/** + * Extract ChatGPT account ID from token response + */ +function extractAccountId(tokens: { id_token?: string; access_token: string }): string | undefined { + if (tokens.id_token) { + const claims = parseJwtClaims(tokens.id_token) + const accountId = claims && extractAccountIdFromClaims(claims) + if (accountId) return accountId + } + if (tokens.access_token) { + const claims = parseJwtClaims(tokens.access_token) + return claims ? extractAccountIdFromClaims(claims) : undefined + } + return undefined +} + +/** + * Generates a cryptographically random PKCE code verifier + */ +export function generateCodeVerifier(): string { + const buffer = crypto.randomBytes(32) + return buffer.toString("base64url") +} + +/** + * Generates the PKCE code challenge from the verifier using S256 method + */ +export function generateCodeChallenge(verifier: string): string { + const hash = crypto.createHash("sha256").update(verifier).digest() + return hash.toString("base64url") +} + +/** + * Generates a random state parameter for CSRF protection + */ +export function generateState(): string { + return crypto.randomBytes(16).toString("hex") +} + +/** + * Builds the authorization URL for OpenAI Codex OAuth flow + */ +export function buildAuthorizationUrl(codeChallenge: string, state: string): string { + const params = new URLSearchParams({ + client_id: OPENAI_CODEX_OAUTH_CONFIG.clientId, + redirect_uri: OPENAI_CODEX_OAUTH_CONFIG.redirectUri, + scope: OPENAI_CODEX_OAUTH_CONFIG.scopes, + code_challenge: codeChallenge, + code_challenge_method: "S256", + response_type: "code", + state, + // Codex-specific parameters + codex_cli_simplified_flow: "true", + originator: "roo-code", + }) + + return `${OPENAI_CODEX_OAUTH_CONFIG.authorizationEndpoint}?${params.toString()}` +} + +/** + * Exchanges the authorization code for tokens + */ +export async function exchangeCodeForTokens(code: string, codeVerifier: string): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + client_id: OPENAI_CODEX_OAUTH_CONFIG.clientId, + code, + redirect_uri: OPENAI_CODEX_OAUTH_CONFIG.redirectUri, + code_verifier: codeVerifier, + }) + + const response = await fetch(OPENAI_CODEX_OAUTH_CONFIG.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + signal: AbortSignal.timeout(30000), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${errorText}`) + } + + const data = await response.json() + const tokenResponse = data as TokenResponse + + if (!tokenResponse.access_token || !tokenResponse.refresh_token || !tokenResponse.expires_in) { + throw new Error("Invalid token response: missing required fields") + } + + const expiresAt = Date.now() + tokenResponse.expires_in * 1000 + const accountId = extractAccountId({ + id_token: tokenResponse.id_token, + access_token: tokenResponse.access_token, + }) + + return { + type: "openai-codex", + access_token: tokenResponse.access_token, + refresh_token: tokenResponse.refresh_token, + expires: expiresAt, + email: tokenResponse.email, + accountId, + } +} + +/** + * Refreshes the access token using the refresh token + */ +export async function refreshAccessToken(credentials: OpenAiCodexCredentials): Promise { + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: OPENAI_CODEX_OAUTH_CONFIG.clientId, + refresh_token: credentials.refresh_token, + }) + + const response = await fetch(OPENAI_CODEX_OAUTH_CONFIG.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + signal: AbortSignal.timeout(30000), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`) + } + + const data = await response.json() + const tokenResponse = data as TokenResponse + + const expiresAt = Date.now() + tokenResponse.expires_in * 1000 + const newAccountId = extractAccountId({ + id_token: tokenResponse.id_token, + access_token: tokenResponse.access_token, + }) + + return { + type: "openai-codex", + access_token: tokenResponse.access_token, + refresh_token: tokenResponse.refresh_token ?? credentials.refresh_token, + expires: expiresAt, + email: tokenResponse.email ?? credentials.email, + accountId: newAccountId ?? credentials.accountId, + } +} + +/** + * Checks if the credentials are expired (with 5 minute buffer) + */ +export function isTokenExpired(credentials: OpenAiCodexCredentials): boolean { + const bufferMs = 5 * 60 * 1000 // 5 minutes buffer + return Date.now() >= credentials.expires - bufferMs +} + +/** + * Get a valid access token, refreshing if necessary + */ +export async function getAccessToken(): Promise { + let credentials = await loadOpenAiCodexCredentials() + + if (!credentials) { + return null + } + + // Refresh if expired + if (isTokenExpired(credentials)) { + try { + credentials = await refreshAccessToken(credentials) + await saveOpenAiCodexCredentials(credentials) + } catch (error) { + console.error("Failed to refresh OpenAI Codex token:", error) + // Clear invalid credentials + await clearOpenAiCodexCredentials() + return null + } + } + + return credentials.access_token +} + +/** + * Open browser for authentication + */ +function openBrowser(url: string): Promise { + return new Promise((resolve, reject) => { + const platform = process.platform + let command: string + + switch (platform) { + case "darwin": + command = `open "${url}"` + break + case "win32": + command = `start "" "${url}"` + break + default: + command = `xdg-open "${url}"` + break + } + + exec(command, (error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) +} + +export interface OpenAiCodexLoginOptions { + timeout?: number + verbose?: boolean +} + +export interface OpenAiCodexLoginResult { + success: boolean + error?: string + email?: string +} + +/** + * Perform OpenAI Codex OAuth login flow + */ +export async function loginOpenAiCodex({ + timeout = 5 * 60 * 1000, + verbose = false, +}: OpenAiCodexLoginOptions = {}): Promise { + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + const state = generateState() + + if (verbose) { + console.log( + `[OpenAI Codex Auth] Starting local callback server on port ${OPENAI_CODEX_OAUTH_CONFIG.callbackPort}`, + ) + } + + const credentialsPromise = new Promise((resolve, reject) => { + const server = http.createServer(async (req, res) => { + const url = new URL(req.url || "", `http://localhost:${OPENAI_CODEX_OAUTH_CONFIG.callbackPort}`) + + if (url.pathname !== "/auth/callback") { + res.writeHead(404) + res.end("Not Found") + return + } + + const code = url.searchParams.get("code") + const receivedState = url.searchParams.get("state") + const error = url.searchParams.get("error") + + if (error) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) + res.end(` + +Authentication Failed + +

Authentication failed: ${error}

+

You can close this window and return to the CLI.

+ +`) + res.on("close", () => { + server.close() + reject(new Error(`OAuth error: ${error}`)) + }) + return + } + + if (!code || !receivedState) { + res.writeHead(400) + res.end("Missing code or state parameter") + res.on("close", () => { + server.close() + reject(new Error("Missing code or state parameter")) + }) + return + } + + if (receivedState !== state) { + res.writeHead(400) + res.end("State mismatch - possible CSRF attack") + res.on("close", () => { + server.close() + reject(new Error("State mismatch")) + }) + return + } + + try { + const credentials = await exchangeCodeForTokens(code, codeVerifier) + await saveOpenAiCodexCredentials(credentials) + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) + res.end(` + + + +Authentication Successful + + + +
+

✓ Authentication Successful

+

You can close this window and return to the CLI.

+
+ + +`) + + res.on("close", () => { + server.close() + resolve(credentials) + }) + } catch (exchangeError) { + res.writeHead(500) + res.end(`Token exchange failed: ${exchangeError}`) + res.on("close", () => { + server.close() + reject(exchangeError) + }) + } + }) + + server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + reject( + new Error( + `Port ${OPENAI_CODEX_OAUTH_CONFIG.callbackPort} is already in use. ` + + `Please close any other applications using this port and try again.`, + ), + ) + } else { + reject(err) + } + }) + + const timeoutId = setTimeout(() => { + server.close() + reject(new Error("Authentication timed out")) + }, timeout) + + server.listen(OPENAI_CODEX_OAUTH_CONFIG.callbackPort, "127.0.0.1", () => { + if (verbose) { + console.log( + `[OpenAI Codex Auth] Callback server listening on port ${OPENAI_CODEX_OAUTH_CONFIG.callbackPort}`, + ) + } + }) + + server.on("close", () => { + clearTimeout(timeoutId) + if (verbose) { + console.log("[OpenAI Codex Auth] Callback server closed") + } + }) + }) + + const authUrl = buildAuthorizationUrl(codeChallenge, state) + + console.log("Opening browser for OpenAI Codex authentication...") + console.log(`If the browser doesn't open, visit: ${authUrl}`) + + try { + await openBrowser(authUrl) + } catch (error) { + if (verbose) { + console.warn("[OpenAI Codex Auth] Failed to open browser automatically:", error) + } + console.log("Please open the URL above in your browser manually.") + } + + try { + const credentials = await credentialsPromise + console.log("✓ Successfully authenticated with OpenAI Codex!") + if (credentials.email) { + console.log(` Email: ${credentials.email}`) + } + return { success: true, email: credentials.email } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`✗ OpenAI Codex authentication failed: ${message}`) + return { success: false, error: message } + } +} + +/** + * Logout from OpenAI Codex (clear credentials) + */ +export async function logoutOpenAiCodex(): Promise { + await clearOpenAiCodexCredentials() +} + +/** + * Check if authenticated with OpenAI Codex + */ +export async function isAuthenticatedOpenAiCodex(): Promise { + const credentials = await loadOpenAiCodexCredentials() + return credentials !== null +} diff --git a/apps/cli/src/lib/storage/index.ts b/apps/cli/src/lib/storage/index.ts index 53424472c2a..ecb034d8152 100644 --- a/apps/cli/src/lib/storage/index.ts +++ b/apps/cli/src/lib/storage/index.ts @@ -1,4 +1,5 @@ export * from "./config-dir.js" export * from "./settings.js" export * from "./credentials.js" +export * from "./openai-codex-credentials.js" export * from "./ephemeral.js" diff --git a/apps/cli/src/lib/storage/openai-codex-credentials.ts b/apps/cli/src/lib/storage/openai-codex-credentials.ts new file mode 100644 index 00000000000..4747b061852 --- /dev/null +++ b/apps/cli/src/lib/storage/openai-codex-credentials.ts @@ -0,0 +1,54 @@ +import fs from "fs/promises" +import path from "path" + +import { getConfigDir } from "./index.js" + +const OPENAI_CODEX_CREDENTIALS_FILE = path.join(getConfigDir(), "openai-codex-credentials.json") + +export interface OpenAiCodexCredentials { + type: "openai-codex" + access_token: string + refresh_token: string + expires: number + email?: string + accountId?: string +} + +export async function saveOpenAiCodexCredentials(credentials: OpenAiCodexCredentials): Promise { + await fs.mkdir(getConfigDir(), { recursive: true }) + + await fs.writeFile(OPENAI_CODEX_CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { + mode: 0o600, // Read/write for owner only + }) +} + +export async function loadOpenAiCodexCredentials(): Promise { + try { + const data = await fs.readFile(OPENAI_CODEX_CREDENTIALS_FILE, "utf-8") + return JSON.parse(data) as OpenAiCodexCredentials + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null + } + throw error + } +} + +export async function clearOpenAiCodexCredentials(): Promise { + try { + await fs.unlink(OPENAI_CODEX_CREDENTIALS_FILE) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error + } + } +} + +export async function hasOpenAiCodexCredentials(): Promise { + const credentials = await loadOpenAiCodexCredentials() + return credentials !== null +} + +export function getOpenAiCodexCredentialsPath(): string { + return OPENAI_CODEX_CREDENTIALS_FILE +} diff --git a/apps/cli/src/lib/utils/provider.ts b/apps/cli/src/lib/utils/provider.ts index 64aec430c1b..759f2e4e68e 100644 --- a/apps/cli/src/lib/utils/provider.ts +++ b/apps/cli/src/lib/utils/provider.ts @@ -2,22 +2,23 @@ import { RooCodeSettings } from "@roo-code/types" import type { SupportedProvider } from "@/types/index.js" -const envVarMap: Record = { +const envVarMap: Record = { anthropic: "ANTHROPIC_API_KEY", "openai-native": "OPENAI_API_KEY", + "openai-codex": null, // Uses OAuth, not API key gemini: "GOOGLE_API_KEY", openrouter: "OPENROUTER_API_KEY", "vercel-ai-gateway": "VERCEL_AI_GATEWAY_API_KEY", roo: "ROO_API_KEY", } -export function getEnvVarName(provider: SupportedProvider): string { +export function getEnvVarName(provider: SupportedProvider): string | null { return envVarMap[provider] } export function getApiKeyFromEnv(provider: SupportedProvider): string | undefined { const envVar = getEnvVarName(provider) - return process.env[envVar] + return envVar ? process.env[envVar] : undefined } export function getProviderSettings( @@ -36,6 +37,10 @@ export function getProviderSettings( if (apiKey) config.openAiNativeApiKey = apiKey if (model) config.apiModelId = model break + case "openai-codex": + // OpenAI Codex uses OAuth, credentials are handled separately + if (model) config.apiModelId = model + break case "gemini": if (apiKey) config.geminiApiKey = apiKey if (model) config.apiModelId = model diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts index cd64c9b1629..f3067d039b0 100644 --- a/apps/cli/src/types/types.ts +++ b/apps/cli/src/types/types.ts @@ -3,6 +3,7 @@ import type { ProviderName, ReasoningEffortExtended } from "@roo-code/types" export const supportedProviders = [ "anthropic", "openai-native", + "openai-codex", "gemini", "openrouter", "vercel-ai-gateway",