feat(cli): add OpenAI Codex OAuth support

- Add openai-codex to supported providers list
- Implement OAuth authentication flow for OpenAI Codex
- Create credentials storage for OpenAI Codex tokens
- Add CLI commands: auth openai-codex:login, auth openai-codex:logout, auth openai-codex:status
- Update provider settings to handle openai-codex without API key
- OAuth uses PKCE flow with local callback server on port 1455
This commit is contained in:
Roo Code 2026-01-15 17:28:48 +00:00
parent 4ee494b08c
commit 6a4d54df83
10 changed files with 617 additions and 4 deletions

View file

@ -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"

View file

@ -0,0 +1,9 @@
import {
loginOpenAiCodex,
type OpenAiCodexLoginOptions,
type OpenAiCodexLoginResult,
} from "@/lib/auth/openai-codex-oauth.js"
export async function openaiCodexLogin(options: OpenAiCodexLoginOptions = {}): Promise<OpenAiCodexLoginResult> {
return loginOpenAiCodex(options)
}

View file

@ -0,0 +1,6 @@
import { logoutOpenAiCodex } from "@/lib/auth/openai-codex-oauth.js"
export async function openaiCodexLogout(): Promise<void> {
await logoutOpenAiCodex()
console.log("✓ Successfully logged out of OpenAI Codex")
}

View file

@ -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<void> {
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")
}
}

View file

@ -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()

View file

@ -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<OpenAiCodexCredentials> {
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<OpenAiCodexCredentials> {
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<string | null> {
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<void> {
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<OpenAiCodexLoginResult> {
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<OpenAiCodexCredentials>((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(`<!DOCTYPE html>
<html>
<head><title>Authentication Failed</title></head>
<body>
<h1>Authentication failed: ${error}</h1>
<p>You can close this window and return to the CLI.</p>
</body>
</html>`)
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(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Authentication Successful</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #10a37f 0%, #0d8f6f 100%);
color: white;
}
.container {
text-align: center;
padding: 2rem;
}
h1 { font-size: 2rem; margin-bottom: 1rem; }
p { opacity: 0.9; }
</style>
</head>
<body>
<div class="container">
<h1>&#10003; Authentication Successful</h1>
<p>You can close this window and return to the CLI.</p>
</div>
<script>setTimeout(() => window.close(), 3000);</script>
</body>
</html>`)
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<void> {
await clearOpenAiCodexCredentials()
}
/**
* Check if authenticated with OpenAI Codex
*/
export async function isAuthenticatedOpenAiCodex(): Promise<boolean> {
const credentials = await loadOpenAiCodexCredentials()
return credentials !== null
}

View file

@ -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"

View file

@ -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<void> {
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<OpenAiCodexCredentials | null> {
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<void> {
try {
await fs.unlink(OPENAI_CODEX_CREDENTIALS_FILE)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error
}
}
}
export async function hasOpenAiCodexCredentials(): Promise<boolean> {
const credentials = await loadOpenAiCodexCredentials()
return credentials !== null
}
export function getOpenAiCodexCredentialsPath(): string {
return OPENAI_CODEX_CREDENTIALS_FILE
}

View file

@ -2,22 +2,23 @@ import { RooCodeSettings } from "@roo-code/types"
import type { SupportedProvider } from "@/types/index.js"
const envVarMap: Record<SupportedProvider, string> = {
const envVarMap: Record<SupportedProvider, string | null> = {
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

View file

@ -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",