/** * OAuth Process Execution for CLIProxyAPI * * Handles the spawning and monitoring of CLIProxy OAuth process. * Separated from oauth-handler.ts for modularity. */ import { spawn, ChildProcess } from 'child_process'; import { ok, fail, info, warn } from '../../utils/ui'; import { killWithEscalation } from '../../utils/process-utils'; import { tryKiroImport } from './kiro-import'; import { CLIProxyProvider } from '../types'; import { AccountInfo } from '../account-manager'; import { parseProjectList, parseDefaultProject, isProjectSelectionPrompt, isProjectList, generateSessionId, requestProjectSelection, cancelProjectSelection, type GCloudProject, type ProjectSelectionPrompt, } from '../project-selection-handler'; import { KiroAuthMethod, ProviderOAuthConfig } from './auth-types'; import { getTimeoutTroubleshooting, showStep } from './environment-detector'; import { isAuthenticated, registerAccountFromToken } from './token-manager'; import { deviceCodeEvents, DEVICE_CODE_TIMEOUT_MS, type DeviceCodePrompt, } from '../device-code-handler'; import { OAUTH_FLOW_TYPES } from '../../management'; import { registerAuthSession, attachProcessToSession, unregisterAuthSession, authSessionEvents, } from '../auth-session-manager'; /** Options for OAuth process execution */ export interface OAuthProcessOptions { provider: CLIProxyProvider; binaryPath: string; args: string[]; tokenDir: string; oauthConfig: ProviderOAuthConfig; callbackPort: number | null; headless: boolean; verbose: boolean; isCLI: boolean; nickname?: string; expectedAccountId?: string; authFlowType?: 'device_code' | 'authorization_code'; kiroMethod?: KiroAuthMethod; manualCallback?: boolean; } /** Internal state for OAuth process */ interface ProcessState { stderrData: string; urlDisplayed: boolean; browserOpened: boolean; projectPromptHandled: boolean; accumulatedOutput: string; parsedProjects: GCloudProject[]; sessionId: string; /** Device code displayed to user (for Device Code Flow) */ deviceCodeDisplayed: boolean; /** The user code to enter at verification URL */ userCode: string | null; kiroMethodSelectionHandled: boolean; manualCallbackPrompted: boolean; cancelManualCallbackPrompt: (() => void) | null; } /** * Handle project selection prompt */ async function handleProjectSelection( output: string, state: ProcessState, options: OAuthProcessOptions, authProcess: ChildProcess, log: (msg: string) => void ): Promise { const defaultProjectId = parseDefaultProject(output) || ''; if (state.parsedProjects.length > 0 && !options.isCLI) { log(`Requesting project selection from UI (session: ${state.sessionId})`); const prompt: ProjectSelectionPrompt = { sessionId: state.sessionId, provider: options.provider, projects: state.parsedProjects, defaultProjectId, supportsAll: output.includes('ALL'), }; try { const selectedId = await requestProjectSelection(prompt); const response = selectedId || ''; log(`User selected: ${response || '(default)'}`); authProcess.stdin?.write(response + '\n'); } catch { log('Project selection failed, using default'); authProcess.stdin?.write('\n'); } } else { log('CLI mode or no projects, auto-selecting default'); authProcess.stdin?.write('\n'); } } function resolveAuthFlowType(options: OAuthProcessOptions): 'device_code' | 'authorization_code' { return options.authFlowType || OAUTH_FLOW_TYPES[options.provider] || 'authorization_code'; } export function isLoopbackHost(hostname: string): boolean { const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase(); return ( normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1' ); } export function getExpectedLocalCallback(authUrl: string): { origin: string; pathname: string; state: string | null; } | null { try { const parsedAuthUrl = new URL(authUrl); const redirectUriRaw = parsedAuthUrl.searchParams.get('redirect_uri'); if (!redirectUriRaw) { return null; } const redirectUri = new URL(redirectUriRaw); if (!isLoopbackHost(redirectUri.hostname)) { return null; } return { origin: redirectUri.origin, pathname: redirectUri.pathname, state: parsedAuthUrl.searchParams.get('state'), }; } catch { return null; } } export function validateManualCallbackUrl(callbackUrl: string, authUrl: string): string | null { let parsedCallback: URL; try { parsedCallback = new URL(callbackUrl); } catch { return 'Invalid callback URL format'; } if (!parsedCallback.searchParams.get('code')) { return 'Invalid callback URL: missing code parameter'; } const expectedCallback = getExpectedLocalCallback(authUrl); if (!expectedCallback) { return 'Unable to determine the expected local callback target'; } if (!isLoopbackHost(parsedCallback.hostname)) { return 'Callback URL must target the local OAuth callback server'; } if ( parsedCallback.origin !== expectedCallback.origin || parsedCallback.pathname !== expectedCallback.pathname ) { return 'Callback URL does not match the expected local OAuth callback target'; } if (expectedCallback.state) { const callbackState = parsedCallback.searchParams.get('state'); if (callbackState !== expectedCallback.state) { return 'Callback URL state does not match the active OAuth session'; } } return null; } async function promptManualCallbackUrl( displayName: string, state: ProcessState, timeoutMs: number ): Promise { const readline = await import('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => { let settled = false; let timeout: ReturnType | null = null; const finish = (value: string | null) => { if (settled) { return; } settled = true; if (timeout) { clearTimeout(timeout); } state.cancelManualCallbackPrompt = null; resolve(value); }; state.cancelManualCallbackPrompt = () => { if (!settled) { rl.close(); finish(null); } }; rl.on('close', () => { finish(null); }); console.log(''); console.log(info(`${displayName} is waiting for the OAuth callback.`)); console.log('Paste the full callback URL after you finish the login in your browser.'); rl.question('> ', (answer) => { rl.close(); finish(answer.trim() || null); }); timeout = setTimeout(() => { if (!settled) { console.log(''); console.log(fail('Timed out waiting for callback URL')); rl.close(); } }, timeoutMs); }); } async function replayManualCallback( oauthConfig: ProviderOAuthConfig, authProcess: ChildProcess, authUrl: string, verbose: boolean, state: ProcessState, timeoutMs: number ): Promise { if (!authUrl.includes('http://') && !authUrl.includes('https://')) { return false; } const callbackUrl = await promptManualCallbackUrl(oauthConfig.displayName, state, timeoutMs); if (!callbackUrl) { console.log(info('Cancelled')); killWithEscalation(authProcess); return true; } const validationError = validateManualCallbackUrl(callbackUrl, authUrl); if (validationError) { console.log(fail(validationError)); killWithEscalation(authProcess); return true; } console.log(info('Replaying callback to the local auth server...')); try { const response = await fetch(callbackUrl); if (!response.ok && response.status >= 400) { console.log(fail(`OAuth callback failed with status ${response.status}`)); killWithEscalation(authProcess); return true; } console.log(ok('Callback submitted. Waiting for token exchange...')); } catch (error) { if (verbose) { console.log(fail(`Failed to replay callback: ${(error as Error).message}`)); } else { console.log(fail('Failed to replay callback to the local auth server')); } killWithEscalation(authProcess); } return true; } /** * Handle stdout data from OAuth process */ async function handleStdout( output: string, state: ProcessState, options: OAuthProcessOptions, authProcess: ChildProcess, log: (msg: string) => void ): Promise { log(`stdout: ${output.trim()}`); state.accumulatedOutput += output; const flowType = resolveAuthFlowType(options); const isDeviceCodeFlow = flowType === 'device_code'; if ( options.provider === 'kiro' && options.kiroMethod === 'aws' && !state.kiroMethodSelectionHandled && state.accumulatedOutput.includes('Select login method') ) { state.kiroMethodSelectionHandled = true; authProcess.stdin?.write('1\n'); log('Auto-selected Kiro Builder ID flow'); } // Parse project list when available if (isProjectList(state.accumulatedOutput) && state.parsedProjects.length === 0) { state.parsedProjects = parseProjectList(state.accumulatedOutput); log(`Parsed ${state.parsedProjects.length} projects`); } // Handle project selection prompt (Authorization Code flows only - Device Code has no stdin pipe) if (!isDeviceCodeFlow && !state.projectPromptHandled && isProjectSelectionPrompt(output)) { state.projectPromptHandled = true; await handleProjectSelection(output, state, options, authProcess, log); } // Handle Device Code Flow: parse and display user code if (isDeviceCodeFlow && !state.deviceCodeDisplayed) { // Parse device/user code from various formats: // "Enter code: XXXX-YYYY" or "code XXXX-YYYY" or "user code: XXXX-YYYY" const codeMatch = state.accumulatedOutput.match( /(?:enter\s+)?(?:user\s+)?code[:\s]+["']?([A-Z0-9]{4,8}[-\s]?[A-Z0-9]{4,8})["']?/i ); const urlMatch = state.accumulatedOutput.match(/(https?:\/\/[^\s]+device[^\s]*)/i); if (codeMatch) { state.userCode = codeMatch[1].toUpperCase(); state.deviceCodeDisplayed = true; log(`Parsed device code: ${state.userCode}`); const verificationUrl = urlMatch?.[1] || 'https://github.com/login/device'; // Emit device code event for WebSocket broadcast to UI const deviceCodePrompt: DeviceCodePrompt = { sessionId: state.sessionId, provider: options.provider, userCode: state.userCode, verificationUrl, expiresAt: Date.now() + DEVICE_CODE_TIMEOUT_MS, }; deviceCodeEvents.emit('deviceCode:received', deviceCodePrompt); // Display device code prominently in CLI console.log(''); console.log(' ╔══════════════════════════════════════════════════════╗'); console.log(` ║ Enter this code: ${state.userCode.padEnd(35)}║`); console.log(' ╚══════════════════════════════════════════════════════╝'); console.log(''); console.log(info(`Open: ${verificationUrl}`)); console.log(''); // Update step display for device code flow process.stdout.write('\x1b[1A\x1b[2K'); showStep(2, 4, 'ok', 'Device code received'); showStep(3, 4, 'progress', 'Waiting for authorization...'); } } // Detect callback server / browser (for Authorization Code flows only) if ( !isDeviceCodeFlow && !state.browserOpened && (output.includes('listening') || output.includes('http')) ) { process.stdout.write('\x1b[1A\x1b[2K'); showStep(2, 4, 'ok', `Callback server listening on port ${options.callbackPort}`); showStep(3, 4, 'progress', 'Opening browser...'); state.browserOpened = true; } // Display OAuth URL for all modes (enables VS Code terminal URL detection popup) if (!isDeviceCodeFlow && !state.urlDisplayed) { const urlMatch = output.match(/https?:\/\/[^\s]+/); if (urlMatch) { console.log(''); console.log(info(`${options.oauthConfig.displayName} OAuth URL:`)); console.log(` ${urlMatch[0]}`); console.log(''); state.urlDisplayed = true; if (options.manualCallback && !state.manualCallbackPrompted) { state.manualCallbackPrompted = true; await replayManualCallback( options.oauthConfig, authProcess, urlMatch[0], options.verbose, state, 10 * 60 * 1000 ); } } } } /** Display OAuth URL from stderr if in headless mode */ function displayUrlFromStderr( output: string, state: ProcessState, oauthConfig: ProviderOAuthConfig ): void { const urlMatch = output.match(/https?:\/\/[^\s]+/); if (urlMatch) { console.log(''); console.log(info(`${oauthConfig.displayName} OAuth URL:`)); console.log(` ${urlMatch[0]}`); console.log(''); state.urlDisplayed = true; } } const ANSI_ESCAPE_REGEX = /\x1b\[[0-9;]*m/g; export function extractLikelyAuthFailureFromStderr( provider: CLIProxyProvider, stderrData: string ): string | null { // Keep this scoped to ghcp to avoid over-classifying other providers. if (provider !== 'ghcp') { return null; } if (!stderrData.trim()) { return null; } const normalizedLines = stderrData .split('\n') .map((line) => line.replace(ANSI_ESCAPE_REGEX, '').trim()) .filter(Boolean) .map((line) => { const messageIndex = line.indexOf('msg="'); if (messageIndex >= 0) { const message = line .slice(messageIndex + 5) .replace(/"$/, '') .trim(); if (message) { return message; } } return line; }); const prioritizedPatterns = [ /github copilot authentication failed:\s*(.+)/i, /authentication failed:\s*(.+)/i, /failed to verify copilot access[^:]*:\s*(.+)/i, /failed to save auth:\s*(.+)/i, ]; for (let i = normalizedLines.length - 1; i >= 0; i--) { const line = normalizedLines[i]; for (const pattern of prioritizedPatterns) { const match = line.match(pattern); if (match?.[1]?.trim()) { return match[1].trim().slice(0, 240); } } } return null; } /** Handle token not found after successful process exit */ async function handleTokenNotFound( provider: CLIProxyProvider, callbackPort: number | null, tokenDir: string, nickname: string | undefined, expectedAccountId: string | undefined, verbose: boolean, failureReason?: string ): Promise { // Kiro-specific: Try auto-import from Kiro IDE if (provider === 'kiro') { console.log(''); console.log(warn('Callback redirected to Kiro IDE. Attempting to import token...')); const result = await tryKiroImport(tokenDir, verbose); if (result.success) { const providerInfo = result.provider ? ` (Provider: ${result.provider})` : ''; console.log(ok(`Imported Kiro token from IDE${providerInfo}`)); return registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId); } console.log(fail(`Auto-import failed: ${result.error}`)); console.log(''); console.log('To manually import from Kiro IDE:'); console.log(' 1. Ensure you are logged into Kiro IDE'); console.log(' 2. Run: ccs kiro --import'); return null; } // Default behavior for other providers console.log(''); if (failureReason) { // Sanitize internal URLs/paths from failure reason to avoid leaking infrastructure details const sanitizedReason = failureReason .replace(/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)[^\s]*/gi, '[internal-url]') .replace(/\/(?:root|home|opt|tmp|var)\/[^\s]*/g, '[path]'); console.log(fail('Authentication completed but token was not persisted')); console.log(` ${sanitizedReason}`); console.log(''); console.log('This usually means provider-side authorization was accepted,'); console.log('but CLIProxy failed a post-auth verification or token save step.'); console.log(''); console.log(`Try: ccs ${provider} --auth --verbose`); return null; } console.log(fail('Token not found after authentication')); console.log(''); console.log('The browser showed success but callback was not received.'); console.log(''); console.log('Common causes:'); console.log(' 1. OAuth session timed out (sessions expire after ~10 minutes)'); console.log(' 2. Callback server could not receive the redirect'); console.log(' 3. Browser did not redirect to localhost properly'); if (process.platform === 'win32') { console.log(''); console.log('On Windows, this usually means:'); console.log(' 1. Windows Firewall blocked the callback'); console.log(' 2. Antivirus software blocked the connection'); console.log(''); console.log('Try running as Administrator:'); console.log( ` netsh advfirewall firewall add rule name="CCS OAuth" dir=in action=allow protocol=TCP localport=${callbackPort}` ); } console.log(''); console.log('If you copied the OAuth URL to a different browser:'); console.log(' - Complete authentication within the timeout window'); console.log(' - Ensure you are on the same machine (localhost callback)'); console.log(' - Copy the entire URL including all parameters'); console.log(''); console.log(`Try: ccs ${provider} --auth --verbose`); return null; } /** Handle process exit with error */ function handleProcessError(code: number | null, state: ProcessState, headless: boolean): void { console.log(''); console.log(fail(`CLIProxy auth exited with code ${code}`)); if (state.stderrData && !state.urlDisplayed) { console.log(` ${state.stderrData.trim().split('\n')[0]}`); } if (headless && !state.urlDisplayed) { console.log(''); console.log(info('No OAuth URL was displayed. Try with --verbose for details.')); } } /** * Execute OAuth process and wait for completion */ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { const { provider, binaryPath, args, tokenDir, oauthConfig, callbackPort, headless, verbose, nickname, expectedAccountId, } = options; const log = (msg: string) => { if (verbose) console.error(`[auth] ${msg}`); }; return new Promise((resolve) => { const flowType = resolveAuthFlowType(options); const isDeviceCodeFlow = flowType === 'device_code'; // Device-code flows can usually inherit stdin, but Kiro's default AWS flow now // prints an intermediate Builder ID vs IDC selector that CCS auto-answers. const stdinMode = isDeviceCodeFlow && process.stdin.isTTY && !(provider === 'kiro' && options.kiroMethod === 'aws') ? 'inherit' : 'pipe'; const authProcess = spawn(binaryPath, args, { stdio: [stdinMode, 'pipe', 'pipe'], env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir }, }); // H7: Mutable ref for stdin keepalive interval (set later, needed in cleanup) let stdinKeepalive: ReturnType | null = null; // H5: Signal handling - properly kill child process on SIGINT/SIGTERM // H8: Also clear stdinKeepalive interval to prevent memory leak const cleanup = () => { if (stdinKeepalive) clearInterval(stdinKeepalive); if (authProcess && authProcess.exitCode === null) { killWithEscalation(authProcess); } }; process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); const state: ProcessState = { stderrData: '', urlDisplayed: false, browserOpened: false, projectPromptHandled: false, accumulatedOutput: '', parsedProjects: [], sessionId: generateSessionId(), deviceCodeDisplayed: false, userCode: null, kiroMethodSelectionHandled: false, manualCallbackPrompted: false, cancelManualCallbackPrompt: null, }; // Register session for cancellation support registerAuthSession(state.sessionId, provider); attachProcessToSession(state.sessionId, authProcess); // Listen for external cancel signal const handleCancel = (cancelledSessionId: string) => { if (cancelledSessionId === state.sessionId && authProcess && authProcess.exitCode === null) { log('Session cancelled externally'); killWithEscalation(authProcess); } }; authSessionEvents.on('session:cancelled', handleCancel); const startTime = Date.now(); // H7: Stdin keepalive for Authorization Code flows // CLIProxyAPIPlus has a 15-second timer that prompts for manual URL paste. // If the user completes browser auth after this timer fires but before the // non-blocking check, the prompt blocks forever on stdin. // Workaround: Send newline every 16s to skip the manual prompt and continue polling. if (!isDeviceCodeFlow && stdinMode === 'pipe') { stdinKeepalive = setInterval(() => { if (authProcess.stdin && !authProcess.stdin.destroyed) { authProcess.stdin.write('\n'); log('Sent stdin keepalive (skip manual URL prompt)'); } }, 16000); } authProcess.stdout?.on('data', async (data: Buffer) => { await handleStdout(data.toString(), state, options, authProcess, log); }); authProcess.stderr?.on('data', async (data: Buffer) => { const output = data.toString(); state.stderrData += output; log(`stderr: ${output.trim()}`); if (headless && !state.urlDisplayed) { displayUrlFromStderr(output, state, oauthConfig); } if (options.manualCallback && !state.manualCallbackPrompted) { const urlMatch = output.match(/https?:\/\/[^\s]+/); if (urlMatch) { state.manualCallbackPrompted = true; await replayManualCallback( options.oauthConfig, authProcess, urlMatch[0], options.verbose, state, 10 * 60 * 1000 ); } } }); // Show waiting message after delay setTimeout(() => { if (isDeviceCodeFlow) { // Device Code Flow: show polling message if (!state.deviceCodeDisplayed) { // Code not yet displayed, show generic waiting message showStep(3, 4, 'progress', 'Waiting for device code...'); } showStep(4, 4, 'progress', 'Polling for authorization...'); console.log(''); console.log( info('Complete the login in your browser. This page will update automatically.') ); } else { // Authorization Code Flow: show callback server message if (!state.browserOpened) { process.stdout.write('\x1b[1A\x1b[2K'); showStep(2, 4, 'ok', `Callback server ready (port ${callbackPort})`); showStep(3, 4, 'ok', 'Browser opened'); state.browserOpened = true; } showStep(4, 4, 'progress', 'Waiting for OAuth callback...'); console.log(''); console.log( info('Complete the login in your browser. This page will update automatically.') ); } if (!verbose) console.log(info('If stuck, try: ccs ' + provider + ' --auth --verbose')); }, 2000); // Timeout handling // Device code flows need longer timeout to match CLIProxy binary's polling window (60 attempts × 5s = 300s) const timeoutMs = options.manualCallback ? 10 * 60 * 1000 : headless || isDeviceCodeFlow ? 300000 : 120000; const timeout = setTimeout(() => { // H7: Clear stdin keepalive interval if (stdinKeepalive) clearInterval(stdinKeepalive); state.cancelManualCallbackPrompt?.(); // H5: Remove signal handlers before killing process process.removeListener('SIGINT', cleanup); process.removeListener('SIGTERM', cleanup); authSessionEvents.removeListener('session:cancelled', handleCancel); unregisterAuthSession(state.sessionId); cancelProjectSelection(state.sessionId); killWithEscalation(authProcess); console.log(''); console.log(fail(`OAuth timed out after ${timeoutMs / 60000} minutes`)); for (const line of getTimeoutTroubleshooting(provider, callbackPort ?? null)) { console.log(line); } resolve(null); }, timeoutMs); authProcess.on('exit', async (code) => { clearTimeout(timeout); // H7: Clear stdin keepalive interval if (stdinKeepalive) clearInterval(stdinKeepalive); state.cancelManualCallbackPrompt?.(); // H5: Remove signal handlers to prevent memory leaks process.removeListener('SIGINT', cleanup); process.removeListener('SIGTERM', cleanup); authSessionEvents.removeListener('session:cancelled', handleCancel); unregisterAuthSession(state.sessionId); cancelProjectSelection(state.sessionId); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); if (code === 0) { if (isAuthenticated(provider)) { console.log(''); console.log(ok(`Authentication successful (${elapsed}s)`)); // Emit device code completion event for UI if (isDeviceCodeFlow && state.deviceCodeDisplayed) { deviceCodeEvents.emit('deviceCode:completed', state.sessionId); } resolve( registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId) ); } else { const failureReason = extractLikelyAuthFailureFromStderr(provider, state.stderrData); // Emit device code failure event for UI if (isDeviceCodeFlow && state.deviceCodeDisplayed) { deviceCodeEvents.emit('deviceCode:failed', { sessionId: state.sessionId, error: failureReason || 'Token not found after authentication', }); } // Try auto-import for Kiro, show error for others const account = await handleTokenNotFound( provider, callbackPort, tokenDir, nickname, expectedAccountId, verbose, failureReason || undefined ); resolve(account); } } else { // Emit device code failure event for UI if (isDeviceCodeFlow && state.deviceCodeDisplayed) { deviceCodeEvents.emit('deviceCode:failed', { sessionId: state.sessionId, error: `Auth process exited with code ${code}`, }); } handleProcessError(code, state, headless); resolve(null); } }); authProcess.on('error', (error) => { clearTimeout(timeout); // H7: Clear stdin keepalive interval if (stdinKeepalive) clearInterval(stdinKeepalive); state.cancelManualCallbackPrompt?.(); // H5: Remove signal handlers to prevent memory leaks process.removeListener('SIGINT', cleanup); process.removeListener('SIGTERM', cleanup); authSessionEvents.removeListener('session:cancelled', handleCancel); unregisterAuthSession(state.sessionId); cancelProjectSelection(state.sessionId); console.log(''); console.log(fail(`Failed to start auth process: ${error.message}`)); resolve(null); }); }); }