diff --git a/src/cli/ai/modern-ai-provider.ts b/src/cli/ai/modern-ai-provider.ts index bd62441e..f46da2a6 100644 --- a/src/cli/ai/modern-ai-provider.ts +++ b/src/cli/ai/modern-ai-provider.ts @@ -1280,9 +1280,28 @@ Please provide corrected arguments for this tool. Only output the corrected JSON switch (config.provider) { case 'openai': { - // OpenAI provider is already response-API compatible via model options; no chainable helper here. - const openaiProvider = createOpenAI({ apiKey, compatibility: 'strict' }) - baseModel = openaiProvider(config.model) + const oauthTokens = simpleConfigManager.getOpenAIOAuthTokens() + + // Check if this is a Codex model and OAuth is available + const { isCodexModel } = require('../auth/openai-oauth') + if (oauthTokens && isCodexModel(config.model)) { + // Use OAuth with custom fetch for ChatGPT Pro/Plus subscription (Codex access) + const { createOAuthFetch } = require('../auth/openai-oauth') + const openaiProvider = createOpenAI({ + apiKey: 'oauth', // Placeholder, actual auth via fetch + compatibility: 'strict', + fetch: createOAuthFetch( + () => Promise.resolve(simpleConfigManager.getOpenAIOAuthTokens()), + (tokens: { access: string; refresh: string; expires: number; tokenType?: string }) => + simpleConfigManager.setOpenAIOAuthTokens(tokens) + ), + }) + baseModel = openaiProvider(config.model) + } else { + // Fallback to API key + const openaiProvider = createOpenAI({ apiKey, compatibility: 'strict' }) + baseModel = openaiProvider(config.model) + } break } case 'anthropic': { diff --git a/src/cli/auth/openai-oauth.ts b/src/cli/auth/openai-oauth.ts new file mode 100644 index 00000000..3b8ebbdd --- /dev/null +++ b/src/cli/auth/openai-oauth.ts @@ -0,0 +1,181 @@ +import { createHash, randomBytes } from 'node:crypto' + +// OpenAI OAuth configuration +const AUTH_URL = 'https://auth.openai.com/authorize' +const TOKEN_URL = 'https://auth.openai.com/oauth/token' +const REDIRECT_URI = 'https://auth.openai.com/oauth/callback' +const CODEX_API_URL = 'https://api.openai.com/v1' + +// Codex models available with ChatGPT subscription +export const CODEX_MODELS = ['gpt-5.2-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini'] as const + +export interface OpenAITokens { + access: string + refresh: string + expires: number + tokenType: string +} + +interface PKCEChallenge { + verifier: string + challenge: string + state: string +} + +/** + * Generate PKCE challenge for OAuth flow + */ +function generatePKCE(): PKCEChallenge { + const verifier = randomBytes(32).toString('base64url') + const challenge = createHash('sha256').update(verifier).digest('base64url') + const state = randomBytes(16).toString('base64url') + return { verifier, challenge, state } +} + +/** + * Start OAuth flow and return authorization URL + */ +export function getAuthorizationUrl(): { url: string; verifier: string; state: string } { + const pkce = generatePKCE() + const url = new URL(AUTH_URL) + + url.searchParams.set('client_id', 'chatgpt-subscription') + url.searchParams.set('response_type', 'code') + url.searchParams.set('redirect_uri', REDIRECT_URI) + url.searchParams.set('scope', 'openid profile email offline_access model.request model.read') + url.searchParams.set('code_challenge', pkce.challenge) + url.searchParams.set('code_challenge_method', 'S256') + url.searchParams.set('state', pkce.state) + + return { url: url.toString(), verifier: pkce.verifier, state: pkce.state } +} + + +/** + * Exchange authorization code for access tokens + */ +export async function exchangeCodeForTokens(code: string, verifier: string): Promise { + try { + // Code format: may include #state suffix (like Anthropic OAuth) + const splits = code.split('#') + const actualCode = splits[0] + + const response = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: actualCode, + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + client_id: 'chatgpt-subscription', + }).toString(), + }) + + if (!response.ok) { + const error = await response.text().catch(() => 'Unknown error') + console.error(`OAuth exchange failed: ${response.status} - ${error}`) + return null + } + + const json = await response.json() + return { + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + (json.expires_in || 3600) * 1000, + tokenType: json.token_type || 'Bearer', + } + } catch (error) { + console.error('Token exchange error:', error) + return null + } +} + +/** + * Refresh access token using refresh token + */ +export async function refreshTokens(refreshToken: string): Promise { + try { + const response = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: 'chatgpt-subscription', + }).toString(), + }) + + if (!response.ok) { + const error = await response.text().catch(() => 'Unknown error') + console.error(`Token refresh failed: ${response.status} - ${error}`) + return null + } + + const json = await response.json() + return { + access: json.access_token, + refresh: json.refresh_token || refreshToken, // Reuse old refresh token if not provided + expires: Date.now() + (json.expires_in || 3600) * 1000, + tokenType: json.token_type || 'Bearer', + } + } catch (error) { + console.error('Token refresh error:', error) + return null + } +} + +/** + * Create a custom fetch function with OAuth authentication and token refresh + */ +export function createOAuthFetch( + getTokens: () => Promise, + saveTokens: (tokens: OpenAITokens) => void +): (input: RequestInfo | URL, init?: RequestInit) => Promise { + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + let tokens = await getTokens() + if (!tokens) { + throw new Error('No OpenAI OAuth tokens available. Run authentication flow first.') + } + + // Refresh token if expired + if (tokens.expires < Date.now()) { + const newTokens = await refreshTokens(tokens.refresh) + if (!newTokens) { + throw new Error('Token refresh failed. Please re-authenticate.') + } + tokens = newTokens + saveTokens(tokens) + } + + // Prepare request headers + const headers = new Headers(init?.headers) + headers.set('Authorization', `${tokens.tokenType} ${tokens.access}`) + + // Remove dummy API key headers if present + headers.delete('api-key') + headers.delete('x-api-key') + + // Rewrite URL to Codex endpoint if needed + let url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + if (url.includes('/chat/completions')) { + url = url.replace(/^https?:\/\/[^/]+/, CODEX_API_URL) + } + + return fetch(url, { ...init, headers }) + } +} + +/** + * Validate if a model is a Codex model + */ +export function isCodexModel(model: string): boolean { + return CODEX_MODELS.some((codexModel) => model.includes(codexModel)) +} + +/** + * Get available Codex models + */ +export function getCodexModels(): readonly string[] { + return CODEX_MODELS +} diff --git a/src/cli/core/config-manager.ts b/src/cli/core/config-manager.ts index 0959c918..95f9c948 100644 --- a/src/cli/core/config-manager.ts +++ b/src/cli/core/config-manager.ts @@ -779,6 +779,18 @@ const ConfigSchema = z.object({ .optional(), // Temporary OAuth verifier (not saved to disk) anthropicOAuthVerifier: z.string().optional(), + // OpenAI OAuth for ChatGPT Pro/Plus subscription (Codex access) + openaiOAuth: z + .object({ + access: z.string().describe('Encrypted OAuth access token'), + refresh: z.string().describe('Encrypted OAuth refresh token'), + expires: z.number().describe('Token expiration timestamp'), + tokenType: z.string().default('Bearer').describe('Token type'), + }) + .optional(), + // Temporary OAuth state and verifier for OpenAI (not saved to disk) + openaiOAuthVerifier: z.string().optional(), + openaiOAuthState: z.string().optional(), // Enhanced diff display configuration diff: z .object({ @@ -3219,6 +3231,76 @@ export class SimpleConfigManager { this.config.anthropicOAuthVerifier = undefined } + // OpenAI OAuth management for ChatGPT Pro/Plus subscription (Codex access) + getOpenAIOAuthTokens(): { + access: string + refresh: string + expires: number + tokenType: string + } | null { + if (!this.config.openaiOAuth) { + return null + } + + try { + return { + access: KeyEncryption.decrypt(this.config.openaiOAuth.access), + refresh: KeyEncryption.decrypt(this.config.openaiOAuth.refresh), + expires: this.config.openaiOAuth.expires, + tokenType: this.config.openaiOAuth.tokenType || 'Bearer', + } + } catch (error) { + console.warn(chalk.yellow('Warning: Failed to decrypt OpenAI OAuth tokens')) + return null + } + } + + setOpenAIOAuthTokens(tokens: { access: string; refresh: string; expires: number; tokenType?: string }): void { + this.config.openaiOAuth = { + access: KeyEncryption.encrypt(tokens.access), + refresh: KeyEncryption.encrypt(tokens.refresh), + expires: tokens.expires, + tokenType: tokens.tokenType || 'Bearer', + } + this.saveConfig() + } + + clearOpenAIOAuth(): void { + this.config.openaiOAuth = undefined + this.saveConfig() + } + + hasOpenAIOAuth(): boolean { + return !!this.config.openaiOAuth?.access + } + + // Temporary verifier and state storage for OpenAI OAuth flow + setOpenAIOAuthVerifier(verifier: string): void { + this.config.openaiOAuthVerifier = verifier + // Don't save to disk - only keep in memory for security + } + + getOpenAIOAuthVerifier(): string | null { + return this.config.openaiOAuthVerifier || null + } + + clearOpenAIOAuthVerifier(): void { + this.config.openaiOAuthVerifier = undefined + } + + setOpenAIOAuthState(state: string): void { + this.config.openaiOAuthState = state + // Don't save to disk - only keep in memory for security + } + + getOpenAIOAuthState(): string | null { + return this.config.openaiOAuthState || null + } + + clearOpenAIOAuthState(): void { + this.config.openaiOAuthState = undefined + } + // Session Context Awareness setSessionContext(context: SessionContext | null): void { this.currentSessionContext = context diff --git a/src/cli/nik-cli.ts b/src/cli/nik-cli.ts index 009bf189..a652480c 100644 --- a/src/cli/nik-cli.ts +++ b/src/cli/nik-cli.ts @@ -17305,9 +17305,15 @@ This file is automatically maintained by NikCLI to provide consistent context ac case 'anthropic-login': await this.handleAnthropicOAuthLogin() break + case 'openai': + await this.handleOpenAIOAuth(args.slice(1)) + break + case 'openai-login': + await this.handleOpenAIOAuthLogin() + break default: this.printPanel( - boxen('Usage: /auth [signin|signup|signout|profile|quotas|anthropic]', { + boxen('Usage: /auth [signin|signup|signout|profile|quotas|anthropic|openai]', { title: 'Auth Command', padding: 1, margin: 1, @@ -17579,6 +17585,238 @@ This file is automatically maintained by NikCLI to provide consistent context ac } } + /** + * Handle OpenAI OAuth for ChatGPT Pro/Plus subscription (Codex access) + */ + private async handleOpenAIOAuth(args: string[]): Promise { + const subCmd = args[0] || 'login' + + try { + const { getAuthorizationUrl, exchangeCodeForTokens } = await import('./auth/openai-oauth') + + if (subCmd === 'status') { + const hasOAuth = this.configManager.hasOpenAIOAuth() + const tokens = this.configManager.getOpenAIOAuthTokens() + + if (hasOAuth && tokens) { + const expiresIn = Math.max(0, Math.floor((tokens.expires - Date.now()) / 1000 / 60)) + this.printPanel( + boxen( + [ + chalk.green('✓ OpenAI OAuth: Connected'), + '', + `Token expires in: ${expiresIn} minutes`, + chalk.dim('Using ChatGPT Pro/Plus subscription (Codex access)'), + ].join('\n'), + { + title: '🔐 OpenAI OAuth Status', + padding: 1, + margin: 1, + borderStyle: 'round', + borderColor: 'green', + } + ) + ) + } else { + this.printPanel( + boxen( + [ + chalk.yellow('⚠︎ OpenAI OAuth: Not connected'), + '', + chalk.dim('Use /auth openai to connect your ChatGPT Pro/Plus subscription'), + ].join('\n'), + { + title: '🔐 OpenAI OAuth Status', + padding: 1, + margin: 1, + borderStyle: 'round', + borderColor: 'yellow', + } + ) + ) + } + return + } + + if (subCmd === 'logout' || subCmd === 'disconnect') { + this.configManager.clearOpenAIOAuth() + this.printPanel( + boxen(chalk.green('✓ OpenAI OAuth disconnected'), { + title: '🔐 OpenAI OAuth', + padding: 1, + margin: 1, + borderStyle: 'round', + borderColor: 'green', + }) + ) + return + } + + // Login flow - show only URL + const { url, verifier, state } = getAuthorizationUrl() + const nik: any = (global as any).__nikCLI + + // Store verifier and state temporarily for login completion + this.configManager.setOpenAIOAuthVerifier(verifier) + this.configManager.setOpenAIOAuthState(state) + + // Persistent panel with URL + nik?.beginPanelOutput?.() + this.printPanel( + boxen( + [ + chalk.cyan('🔐 OpenAI OAuth - ChatGPT Pro/Plus (Codex)'), + chalk.gray('─'.repeat(40)), + '', + 'Open this URL in your browser:', + '', + `\x1b]8;;${url}\x07${chalk.blue(url)}\x1b]8;;\x07`, + '', + chalk.dim('After authorizing, use /auth openai-login to complete the process.'), + ].join('\n'), + { + title: 'OpenAI OAuth Login', + padding: 1, + margin: 1, + borderStyle: 'round', + borderColor: 'cyan', + } + ) + ) + nik?.endPanelOutput?.() + } catch (error: any) { + this.printPanel( + boxen(chalk.red(`✖ OAuth error: ${error.message}`), { + title: 'Error', + padding: 1, + margin: 1, + borderStyle: 'round', + borderColor: 'red', + }) + ) + } + } + + /** + * Handle OpenAI OAuth login with interactive code input + */ + private async handleOpenAIOAuthLogin(): Promise { + const nik: any = (global as any).__nikCLI + + try { + const { exchangeCodeForTokens } = await import('./auth/openai-oauth') + + // Get stored verifier from previous auth request + const verifier = this.configManager.getOpenAIOAuthVerifier() + + if (!verifier) { + this.printPanel( + boxen( + [ + chalk.red('✖ No authorization session found'), + '', + chalk.dim('Please start the authorization process again:'), + chalk.dim('1. Run /auth openai to get a fresh URL'), + chalk.dim('2. Complete the authorization in your browser'), + chalk.dim('3. Use /auth openai-login immediately after'), + ].join('\n'), + { + title: 'Session Error', + padding: 1, + margin: 1, + borderStyle: 'round', + borderColor: 'red', + } + ) + ) + return + } + + // Suspend prompt for interactive input + const inquirer = (await import('inquirer')).default + const { inputQueue } = await import('./core/input-queue') + + nik?.suspendPrompt?.() + inputQueue.enableBypass() + + let code: string + try { + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'code', + message: 'Paste authorization code:', + validate: (input: string) => (input.length > 10 ? true : 'Please paste the full authorization code'), + }, + ]) + code = answers.code + } finally { + inputQueue.disableBypass() + nik?.renderPromptAfterOutput?.() + } + + // Exchange code for tokens + this.printPanel( + boxen(chalk.blue('⚡︎ Exchanging code for tokens...'), { + title: 'Processing', + padding: 1, + margin: 1, + borderStyle: 'round', + borderColor: 'blue', + }) + ) + + const tokens = await exchangeCodeForTokens(code.trim(), verifier) + + // Clear the temporary verifier and state + this.configManager.clearOpenAIOAuthVerifier() + this.configManager.clearOpenAIOAuthState() + + if (tokens) { + this.configManager.setOpenAIOAuthTokens(tokens) + + this.printPanel( + boxen( + [ + chalk.green('✓ OpenAI OAuth connected successfully!'), + '', + chalk.dim('You can now use Codex models with your ChatGPT Pro/Plus subscription.'), + chalk.dim('Available models: gpt-5.1-codex, gpt-5.1-codex-mini'), + chalk.dim('Token will auto-refresh when needed.'), + ].join('\n'), + { + title: '🎉 Success', + padding: 1, + margin: 1, + borderStyle: 'round', + borderColor: 'green', + } + ) + ) + } else { + this.printPanel( + boxen(chalk.red('✖ Failed to exchange code for tokens. Please try again.'), { + title: 'Error', + padding: 1, + margin: 1, + borderStyle: 'round', + borderColor: 'red', + }) + ) + } + } catch (error: any) { + this.printPanel( + boxen(chalk.red(`✖ OAuth error: ${error.message}`), { + title: 'Error', + padding: 1, + margin: 1, + borderStyle: 'round', + borderColor: 'red', + }) + ) + } + } + /** * Wrap long URLs to fit in terminal */