feat(oauth): enhance auth flow with detailed pre-flight checks and real-time progress

This commit is contained in:
kaitranntt
2025-12-14 23:07:03 -05:00
parent f0e8408abb
commit e80c48c55f
3 changed files with 389 additions and 86 deletions
+173 -78
View File
@@ -15,12 +15,10 @@
import { execSync, spawn } from 'child_process'; import { execSync, spawn } from 'child_process';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { ProgressIndicator } from '../utils/progress-indicator';
import { ok, fail, info, warn, color } from '../utils/ui'; import { ok, fail, info, warn, color } from '../utils/ui';
import { ensureCLIProxyBinary } from './binary-manager'; import { ensureCLIProxyBinary } from './binary-manager';
import { generateConfig, getProviderAuthDir } from './config-generator'; import { generateConfig, getProviderAuthDir } from './config-generator';
import { CLIProxyProvider } from './types'; import { CLIProxyProvider } from './types';
import { getKillCLIProxyCommand } from '../utils/platform-commands';
import { import {
AccountInfo, AccountInfo,
discoverExistingAccounts, discoverExistingAccounts,
@@ -30,7 +28,10 @@ import {
registerAccount, registerAccount,
touchAccount, touchAccount,
} from './account-manager'; } from './account-manager';
import { preflightOAuthCheck } from '../management/oauth-port-diagnostics'; import {
enhancedPreflightOAuthCheck,
OAUTH_CALLBACK_PORTS as OAUTH_PORTS,
} from '../management/oauth-port-diagnostics';
/** /**
* OAuth callback ports used by CLIProxyAPI (hardcoded in binary) * OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
@@ -427,9 +428,54 @@ export function clearAuth(provider: CLIProxyProvider): boolean {
return removedCount > 0; return removedCount > 0;
} }
/**
* Display a single step status line
*/
function showStep(
step: number,
total: number,
status: 'ok' | 'fail' | 'progress',
message: string
): void {
const statusIcon = status === 'ok' ? '[OK]' : status === 'fail' ? '[X]' : '[..]';
console.log(`${statusIcon} [${step}/${total}] ${message}`);
}
/**
* Get platform-specific troubleshooting for OAuth timeout
*/
function getTimeoutTroubleshooting(provider: CLIProxyProvider, port: number | null): string[] {
const lines: string[] = [];
lines.push('');
lines.push('TROUBLESHOOTING:');
lines.push(' 1. Check browser completed auth (should show success page)');
if (port) {
if (process.platform === 'win32') {
lines.push(` 2. Verify port ${port} is reachable: curl http://localhost:${port}`);
lines.push(' 3. Windows Firewall may block - run as Administrator:');
lines.push(
` netsh advfirewall firewall add rule name="CCS OAuth" dir=in action=allow protocol=TCP localport=${port}`
);
lines.push(` 4. Try: ccs ${provider} --auth --verbose`);
} else if (process.platform === 'darwin') {
lines.push(` 2. Check for port conflicts: lsof -ti:${port}`);
lines.push(` 3. Try: ccs ${provider} --auth --verbose`);
} else {
lines.push(` 2. Check for port conflicts: lsof -ti:${port} or ss -tlnp | grep ${port}`);
lines.push(` 3. Try: ccs ${provider} --auth --verbose`);
}
} else {
lines.push(` 2. Try: ccs ${provider} --auth --verbose`);
}
return lines;
}
/** /**
* Trigger OAuth flow for provider * Trigger OAuth flow for provider
* Auto-detects headless environment and uses --no-browser flag accordingly * Auto-detects headless environment and uses --no-browser flag accordingly
* Shows real-time step-by-step progress for better user feedback
* @param provider - The CLIProxy provider to authenticate * @param provider - The CLIProxy provider to authenticate
* @param options - OAuth options * @param options - OAuth options
* @param options.add - If true, skip confirm prompt when adding another account * @param options.add - If true, skip confirm prompt when adding another account
@@ -447,6 +493,7 @@ export async function triggerOAuth(
): Promise<AccountInfo | null> { ): Promise<AccountInfo | null> {
const oauthConfig = getOAuthConfig(provider); const oauthConfig = getOAuthConfig(provider);
const { verbose = false, add = false, nickname } = options; const { verbose = false, add = false, nickname } = options;
const callbackPort = OAUTH_PORTS[provider];
// Auto-detect headless if not explicitly set // Auto-detect headless if not explicitly set
const headless = options.headless ?? isHeadlessEnvironment(); const headless = options.headless ?? isHeadlessEnvironment();
@@ -487,25 +534,49 @@ export async function triggerOAuth(
} }
} }
// Pre-flight check: verify OAuth callback port is available // Enhanced pre-flight check with real-time display
const preflight = await preflightOAuthCheck(provider); console.log('');
console.log(info(`Pre-flight OAuth check for ${oauthConfig.displayName}...`));
const preflight = await enhancedPreflightOAuthCheck(provider);
// Display each check result
for (const check of preflight.checks) {
const icon = check.status === 'ok' ? '[OK]' : check.status === 'warn' ? '[!]' : '[X]';
console.log(` ${icon} ${check.name}: ${check.message}`);
if (check.fixCommand && check.status !== 'ok') {
console.log(` Fix: ${check.fixCommand}`);
}
}
// Show firewall warning prominently on Windows
if (preflight.firewallWarning) {
console.log('');
console.log(warn('Windows Firewall may block OAuth callback'));
console.log(' If auth hangs, run as Administrator:');
console.log(` ${color(preflight.firewallFixCommand || '', 'command')}`);
}
if (!preflight.ready) { if (!preflight.ready) {
console.log(''); console.log('');
console.log(warn('OAuth pre-flight check failed:')); console.log(fail('Pre-flight check failed. Resolve issues above and retry.'));
for (const issue of preflight.issues) {
console.log(` ${issue}`);
}
console.log('');
console.log(info('Resolve the port conflict and try again.'));
return null; return null;
} }
// Ensure binary exists console.log('');
// Step 1: Ensure binary exists
showStep(1, 4, 'progress', 'Preparing CLIProxy binary...');
let binaryPath: string; let binaryPath: string;
try { try {
binaryPath = await ensureCLIProxyBinary(verbose); binaryPath = await ensureCLIProxyBinary(verbose);
// Clear and rewrite with OK
process.stdout.write('\x1b[1A\x1b[2K'); // Move up and clear line
showStep(1, 4, 'ok', 'CLIProxy binary ready');
} catch (error) { } catch (error) {
console.error(fail('Failed to prepare CLIProxy binary')); process.stdout.write('\x1b[1A\x1b[2K');
showStep(1, 4, 'fail', 'Failed to prepare CLIProxy binary');
console.error(fail((error as Error).message));
throw error; throw error;
} }
@@ -517,13 +588,12 @@ export async function triggerOAuth(
const configPath = generateConfig(provider); const configPath = generateConfig(provider);
log(`Config generated: ${configPath}`); log(`Config generated: ${configPath}`);
// Free OAuth callback port if this provider shares it with another // Free OAuth callback port if needed
// Qwen and Gemini both use port 8085 - kill any existing process to avoid conflict const localCallbackPort = OAUTH_CALLBACK_PORTS[provider];
const callbackPort = OAUTH_CALLBACK_PORTS[provider]; if (localCallbackPort) {
if (callbackPort) { const killed = killProcessOnPort(localCallbackPort, verbose);
const killed = killProcessOnPort(callbackPort, verbose);
if (killed) { if (killed) {
log(`Freed port ${callbackPort} for OAuth callback`); log(`Freed port ${localCallbackPort} for OAuth callback`);
} }
} }
@@ -533,31 +603,23 @@ export async function triggerOAuth(
args.push('--no-browser'); args.push('--no-browser');
} }
// Show appropriate message // Step 2: Starting callback server
console.log(''); showStep(2, 4, 'progress', `Starting callback server on port ${callbackPort || 'N/A'}...`);
// Show headless instructions if needed
if (headless) { if (headless) {
console.log(info('Headless mode detected - manual authentication required'));
console.log(''); console.log('');
console.log(warn('PORT FORWARDING REQUIRED')); console.log(warn('PORT FORWARDING REQUIRED'));
console.log(' OAuth callback uses localhost:8085 which must be reachable.'); console.log(` OAuth callback uses localhost:${callbackPort} which must be reachable.`);
console.log(' Run this on your LOCAL machine (replace <USER> and <HOST>):'); console.log(' Run this on your LOCAL machine:');
console.log(
` ${color(`ssh -L ${callbackPort}:localhost:${callbackPort} <USER>@<HOST>`, 'command')}`
);
console.log(''); console.log('');
console.log(` ${color('ssh -L 8085:localhost:8085 <USER>@<HOST>', 'command')}`);
console.log('');
console.log(info(`${oauthConfig.displayName} OAuth URL:`));
} else {
console.log(info(`Opening browser for ${oauthConfig.displayName} authentication...`));
console.log(info('Complete the login in your browser.'));
console.log('');
}
const spinner = new ProgressIndicator(`Authenticating with ${oauthConfig.displayName}`);
if (!headless) {
spinner.start();
} }
return new Promise<AccountInfo | null>((resolve) => { return new Promise<AccountInfo | null>((resolve) => {
// Spawn CLIProxyAPI with auth flag (and --no-browser if headless) // Spawn CLIProxyAPI with auth flag
const authProcess = spawn(binaryPath, args, { const authProcess = spawn(binaryPath, args, {
stdio: ['inherit', 'pipe', 'pipe'], stdio: ['inherit', 'pipe', 'pipe'],
env: { env: {
@@ -568,23 +630,30 @@ export async function triggerOAuth(
let stderrData = ''; let stderrData = '';
let urlDisplayed = false; let urlDisplayed = false;
let browserOpened = false;
const startTime = Date.now();
authProcess.stdout?.on('data', (data: Buffer) => { authProcess.stdout?.on('data', (data: Buffer) => {
const output = data.toString(); const output = data.toString();
log(`stdout: ${output.trim()}`); log(`stdout: ${output.trim()}`);
// Detect when callback server starts or browser opens
if (!browserOpened && (output.includes('listening') || output.includes('http'))) {
process.stdout.write('\x1b[1A\x1b[2K');
showStep(2, 4, 'ok', `Callback server listening on port ${callbackPort}`);
showStep(3, 4, 'progress', 'Opening browser...');
browserOpened = true;
}
// In headless mode, display OAuth URLs prominently // In headless mode, display OAuth URLs prominently
if (headless) { if (headless) {
const lines = output.split('\n'); const urlMatch = output.match(/https?:\/\/[^\s]+/);
for (const line of lines) { if (urlMatch && !urlDisplayed) {
// Look for URLs in the output console.log('');
const urlMatch = line.match(/https?:\/\/[^\s]+/); console.log(info(`${oauthConfig.displayName} OAuth URL:`));
if (urlMatch && !urlDisplayed) { console.log(` ${urlMatch[0]}`);
console.log(` ${urlMatch[0]}`); console.log('');
console.log(''); urlDisplayed = true;
console.log(info('Waiting for authentication... (press Ctrl+C to cancel)'));
urlDisplayed = true;
}
} }
} }
}); });
@@ -594,70 +663,96 @@ export async function triggerOAuth(
stderrData += output; stderrData += output;
log(`stderr: ${output.trim()}`); log(`stderr: ${output.trim()}`);
// Also check stderr for URLs (some tools output there) // Also check stderr for URLs
if (headless && !urlDisplayed) { if (headless && !urlDisplayed) {
const urlMatch = output.match(/https?:\/\/[^\s]+/); const urlMatch = output.match(/https?:\/\/[^\s]+/);
if (urlMatch) { if (urlMatch) {
console.log('');
console.log(info(`${oauthConfig.displayName} OAuth URL:`));
console.log(` ${urlMatch[0]}`); console.log(` ${urlMatch[0]}`);
console.log(''); console.log('');
console.log(info('Waiting for authentication... (press Ctrl+C to cancel)'));
urlDisplayed = true; urlDisplayed = true;
} }
} }
}); });
// Timeout after 5 minutes for headless (user needs time to copy URL) // After a short delay, assume browser opened and show waiting
setTimeout(() => {
if (!browserOpened) {
process.stdout.write('\x1b[1A\x1b[2K');
showStep(2, 4, 'ok', `Callback server ready (port ${callbackPort})`);
showStep(3, 4, 'ok', 'Browser opened');
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 after 5 minutes for headless, 2 minutes for normal
const timeoutMs = headless ? 300000 : 120000; const timeoutMs = headless ? 300000 : 120000;
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (!headless) spinner.fail('Authentication timeout');
authProcess.kill(); authProcess.kill();
console.error(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`)); console.log('');
console.error(''); console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`));
if (!headless) {
console.error('Troubleshooting:'); // Show platform-specific troubleshooting
console.error(' - Make sure a browser is available'); const troubleshooting = getTimeoutTroubleshooting(provider, callbackPort);
console.error(' - Try running with --verbose for details'); for (const line of troubleshooting) {
console.log(line);
} }
resolve(null); resolve(null);
}, timeoutMs); }, timeoutMs);
authProcess.on('exit', (code) => { authProcess.on('exit', (code) => {
clearTimeout(timeout); clearTimeout(timeout);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (code === 0) { if (code === 0) {
// Verify token was created BEFORE showing success // Verify token was created BEFORE showing success
if (isAuthenticated(provider)) { if (isAuthenticated(provider)) {
if (!headless) spinner.succeed(`Authenticated with ${oauthConfig.displayName}`); console.log('');
console.log(ok('Authentication successful')); console.log(ok(`Authentication successful (${elapsed}s)`));
// Register the account in accounts registry // Register the account in accounts registry
const account = registerAccountFromToken(provider, tokenDir, nickname); const account = registerAccountFromToken(provider, tokenDir, nickname);
resolve(account); resolve(account);
} else { } else {
if (!headless) spinner.fail('Authentication incomplete'); console.log('');
console.error(fail('Token not found after authentication')); console.log(fail('Token not found after authentication'));
// Qwen uses Device Code Flow (polling), others use Authorization Code Flow (callback) console.log('');
if (provider === 'qwen') { console.log('The browser showed success but callback was not received.');
console.error(' Qwen uses Device Code Flow - ensure you completed auth in browser');
console.error(' The polling may have timed out or been cancelled'); // Show platform-specific guidance
console.error(' Try: ccs qwen --auth --verbose'); if (process.platform === 'win32') {
} else { console.log('');
console.error(' The OAuth flow may have been cancelled or callback port was in use'); console.log('On Windows, this usually means:');
console.error(` Try: ${getKillCLIProxyCommand()} && ccs ${provider} --auth`); console.log(' 1. Windows Firewall blocked the callback');
console.error(' Or run: ccs doctor --fix'); 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(`Try: ccs ${provider} --auth --verbose`);
resolve(null); resolve(null);
} }
} else { } else {
if (!headless) spinner.fail('Authentication failed'); console.log('');
console.error(fail(`CLIProxyAPI auth exited with code ${code}`)); console.log(fail(`CLIProxyAPI auth exited with code ${code}`));
if (stderrData && !urlDisplayed) { if (stderrData && !urlDisplayed) {
console.error(` ${stderrData.trim().split('\n')[0]}`); console.log(` ${stderrData.trim().split('\n')[0]}`);
} }
// Show headless hint if we detected headless environment
if (headless && !urlDisplayed) { if (headless && !urlDisplayed) {
console.error(''); console.log('');
console.error(info('No OAuth URL was displayed. Try with --verbose for details.')); console.log(info('No OAuth URL was displayed. Try with --verbose for details.'));
} }
resolve(null); resolve(null);
} }
@@ -665,8 +760,8 @@ export async function triggerOAuth(
authProcess.on('error', (error) => { authProcess.on('error', (error) => {
clearTimeout(timeout); clearTimeout(timeout);
if (!headless) spinner.fail('Authentication error'); console.log('');
console.error(fail(`Failed to start auth process: ${error.message}`)); console.log(fail(`Failed to start auth process: ${error.message}`));
resolve(null); resolve(null);
}); });
}); });
+128 -8
View File
@@ -11,7 +11,15 @@
* - Qwen: Device Code Flow (no port needed) * - Qwen: Device Code Flow (no port needed)
*/ */
import { getPortProcess, PortProcess, isCLIProxyProcess } from '../utils/port-utils'; import {
getPortProcess,
PortProcess,
isCLIProxyProcess,
checkWindowsFirewall,
testLocalhostBinding,
FirewallCheckResult,
BindingTestResult,
} from '../utils/port-utils';
import { CLIProxyProvider } from '../cliproxy/types'; import { CLIProxyProvider } from '../cliproxy/types';
/** /**
@@ -199,28 +207,139 @@ export function formatOAuthPortDiagnostics(diagnostics: OAuthPortDiagnostic[]):
return lines; return lines;
} }
/**
* Enhanced pre-flight check result with detailed diagnostics
*/
export interface EnhancedPreflightResult {
ready: boolean;
issues: string[];
checks: PreflightCheck[];
firewallWarning?: string;
firewallFixCommand?: string;
}
/**
* Individual pre-flight check result
*/
export interface PreflightCheck {
name: string;
status: 'ok' | 'warn' | 'fail';
message: string;
fixCommand?: string;
}
/** /**
* Pre-flight check before OAuth - returns issues or empty array if OK * Pre-flight check before OAuth - returns issues or empty array if OK
* @deprecated Use enhancedPreflightOAuthCheck for more detailed diagnostics
*/ */
export async function preflightOAuthCheck(provider: CLIProxyProvider): Promise<{ export async function preflightOAuthCheck(provider: CLIProxyProvider): Promise<{
ready: boolean; ready: boolean;
issues: string[]; issues: string[];
}> { }> {
const diagnostic = await checkOAuthPort(provider); const result = await enhancedPreflightOAuthCheck(provider);
return {
ready: result.ready,
issues: result.issues,
};
}
/**
* Enhanced pre-flight check with detailed step-by-step diagnostics
* Returns structured results for real-time display
*/
export async function enhancedPreflightOAuthCheck(
provider: CLIProxyProvider
): Promise<EnhancedPreflightResult> {
const port = OAUTH_CALLBACK_PORTS[provider];
const flowType = OAUTH_FLOW_TYPES[provider];
const checks: PreflightCheck[] = [];
const issues: string[] = []; const issues: string[] = [];
if (diagnostic.status === 'occupied' && diagnostic.process) { // Device code flow doesn't need port checks
issues.push( if (flowType === 'device_code' || port === null) {
`OAuth callback port ${diagnostic.port} is blocked by ${diagnostic.process.processName} (PID ${diagnostic.process.pid})` checks.push({
); name: 'OAuth Flow',
if (diagnostic.recommendation) { status: 'ok',
issues.push(`Fix: ${diagnostic.recommendation}`); message: 'Uses Device Code Flow (no callback port needed)',
});
return { ready: true, issues: [], checks };
}
// Check 1: Port availability via process detection
const portProcess = await getPortProcess(port);
if (portProcess && !isCLIProxyProcess(portProcess)) {
checks.push({
name: 'Port Availability',
status: 'fail',
message: `Port ${port} blocked by ${portProcess.processName} (PID ${portProcess.pid})`,
fixCommand:
process.platform === 'win32'
? `taskkill /F /PID ${portProcess.pid}`
: `kill ${portProcess.pid}`,
});
issues.push(`Port ${port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`);
} else if (portProcess && isCLIProxyProcess(portProcess)) {
checks.push({
name: 'Port Availability',
status: 'ok',
message: `Port ${port} in use by CLIProxy (OK)`,
});
} else {
checks.push({
name: 'Port Availability',
status: 'ok',
message: `Port ${port} is available`,
});
}
// Check 2: Localhost binding test (verifies we can actually listen)
const bindingResult: BindingTestResult = await testLocalhostBinding(port);
if (!bindingResult.success && !portProcess) {
// Only fail if no process found but binding failed (weird state)
checks.push({
name: 'Localhost Binding',
status: 'warn',
message: bindingResult.message,
});
} else if (bindingResult.success) {
checks.push({
name: 'Localhost Binding',
status: 'ok',
message: 'Can bind to localhost',
});
}
// Check 3: Windows Firewall (only on Windows)
let firewallWarning: string | undefined;
let firewallFixCommand: string | undefined;
if (process.platform === 'win32') {
const firewallResult: FirewallCheckResult = await checkWindowsFirewall(port);
if (firewallResult.mayBlock) {
checks.push({
name: 'Windows Firewall',
status: 'warn',
message: firewallResult.message,
fixCommand: firewallResult.fixCommand,
});
firewallWarning = firewallResult.message;
firewallFixCommand = firewallResult.fixCommand;
// Don't add to issues - just a warning, not a blocker
} else if (firewallResult.checked) {
checks.push({
name: 'Windows Firewall',
status: 'ok',
message: 'Firewall rules allow port',
});
} }
} }
return { return {
ready: issues.length === 0, ready: issues.length === 0,
issues, issues,
checks,
firewallWarning,
firewallFixCommand,
}; };
} }
@@ -233,4 +352,5 @@ export default {
getPortConflicts, getPortConflicts,
formatOAuthPortDiagnostics, formatOAuthPortDiagnostics,
preflightOAuthCheck, preflightOAuthCheck,
enhancedPreflightOAuthCheck,
}; };
+88
View File
@@ -128,3 +128,91 @@ export function isCLIProxyProcess(process: PortProcess | null): boolean {
'cli-proxy-api.exe', 'cli-proxy-api.exe',
].includes(name); ].includes(name);
} }
/**
* Windows firewall check result
*/
export interface FirewallCheckResult {
checked: boolean;
mayBlock: boolean;
message: string;
fixCommand?: string;
}
/**
* Check if Windows Firewall might block a port
* Returns immediately on non-Windows platforms
*/
export async function checkWindowsFirewall(port: number): Promise<FirewallCheckResult> {
if (process.platform !== 'win32') {
return { checked: false, mayBlock: false, message: 'Not Windows' };
}
try {
// Check for inbound rules allowing our port
const { stdout } = await execAsync(
`netsh advfirewall firewall show rule name=all dir=in | findstr /C:"LocalPort" | findstr /C:"${port}"`,
{ timeout: 5000 }
);
// If we find the port in firewall rules, it's likely allowed
if (stdout.trim()) {
return { checked: true, mayBlock: false, message: `Port ${port} found in firewall rules` };
}
// Port not in rules - might be blocked
return {
checked: true,
mayBlock: true,
message: `Port ${port} may be blocked by Windows Firewall`,
fixCommand: `netsh advfirewall firewall add rule name="CCS OAuth" dir=in action=allow protocol=TCP localport=${port}`,
};
} catch {
// Command failed - could be no matching rules, assume might block
return {
checked: true,
mayBlock: true,
message: `Could not verify firewall rules for port ${port}`,
fixCommand: `netsh advfirewall firewall add rule name="CCS OAuth" dir=in action=allow protocol=TCP localport=${port}`,
};
}
}
/**
* Localhost binding test result
*/
export interface BindingTestResult {
success: boolean;
message: string;
}
/**
* Test if we can bind to localhost on a specific port
* This verifies network stack is working and port is actually available
*/
export async function testLocalhostBinding(port: number): Promise<BindingTestResult> {
const net = await import('net');
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
resolve({ success: false, message: `Port ${port} is already in use` });
} else if (err.code === 'EACCES') {
resolve({ success: false, message: `Permission denied for port ${port}` });
} else {
resolve({ success: false, message: `Cannot bind to port ${port}: ${err.message}` });
}
});
server.once('listening', () => {
server.close(() => {
resolve({ success: true, message: `Port ${port} is available` });
});
});
// Try to bind to localhost only (like CLIProxyAPI does)
server.listen(port, '127.0.0.1');
});
}