refactor(cliproxy): modularize auth handler and binary

- extract 6 auth modules to auth/ directory (932 → 65 lines)

- oauth-handler, oauth-process, token-manager

- environment-detector, auth-types

- extract binary/ with downloader, verifier modules

Phase 3: Large Files Breakdown (auth-handler.ts)
This commit is contained in:
kaitranntt
2025-12-19 11:48:21 -05:00
parent cd107e354c
commit 5c28935d1e
9 changed files with 1440 additions and 909 deletions
+42 -909
View File
@@ -10,895 +10,51 @@
*
* Token storage: ~/.ccs/cliproxy/auth/<provider>/
* Each provider has its own directory to avoid conflicts.
*/
import { execSync, spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { ok, fail, info, warn, color } from '../utils/ui';
import { ensureCLIProxyBinary } from './binary-manager';
import { generateConfig, getProviderAuthDir } from './config-generator';
import { CLIProxyProvider } from './types';
import {
AccountInfo,
discoverExistingAccounts,
generateNickname,
getDefaultAccount,
getProviderAccounts,
registerAccount,
touchAccount,
} from './account-manager';
import {
enhancedPreflightOAuthCheck,
OAUTH_CALLBACK_PORTS as OAUTH_PORTS,
} from '../management/oauth-port-diagnostics';
import {
parseProjectList,
parseDefaultProject,
isProjectSelectionPrompt,
isProjectList,
generateSessionId,
requestProjectSelection,
type GCloudProject,
type ProjectSelectionPrompt,
} from './project-selection-handler';
/**
* OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
* See: https://github.com/router-for-me/CLIProxyAPI/tree/main/internal/auth
*
* OAuth flow types per provider:
* - Gemini: Authorization Code Flow with local callback server on port 8085
* - Codex: Authorization Code Flow with local callback server on port 1455
* - Agy: Authorization Code Flow with local callback server on port 51121
* - Qwen: Device Code Flow (polling-based, NO callback port needed)
*
* We auto-kill processes on callback ports before OAuth to avoid conflicts.
* This file is a facade that re-exports from the auth/ submodule.
* The actual implementation is split across:
* - auth/auth-types.ts - Types and OAuth configurations
* - auth/token-manager.ts - Token storage/retrieval/validation
* - auth/oauth-handler.ts - OAuth flow handling
* - auth/environment-detector.ts - Headless detection and port management
*/
const OAUTH_CALLBACK_PORTS: Partial<Record<CLIProxyProvider, number>> = {
gemini: 8085,
// codex uses 1455
// agy uses 51121
// qwen uses Device Code Flow - no callback port needed
};
/**
* Kill any process using a specific port
* Used to free OAuth callback port before authentication
*/
function killProcessOnPort(port: number, verbose: boolean): boolean {
try {
if (process.platform === 'win32') {
// Windows: use netstat + taskkill
const result = execSync(`netstat -ano | findstr :${port}`, {
encoding: 'utf-8',
stdio: 'pipe',
});
const lines = result.trim().split('\n');
for (const line of lines) {
const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1];
if (pid && /^\d+$/.test(pid)) {
execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' });
if (verbose) console.error(`[auth] Killed process ${pid} on port ${port}`);
}
}
return true;
} else {
// Unix: use lsof + kill
const result = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', stdio: 'pipe' });
const pids = result
.trim()
.split('\n')
.filter((p) => p);
for (const pid of pids) {
execSync(`kill -9 ${pid}`, { stdio: 'pipe' });
if (verbose) console.error(`[auth] Killed process ${pid} on port ${port}`);
}
return pids.length > 0;
}
} catch {
// No process on port or command failed - that's fine
return false;
}
}
/**
* Detect if running in a headless environment (no browser available)
*
* IMPROVED: Avoids false positives on Windows desktop environments
* where isTTY may be undefined due to terminal wrapper behavior.
*
* Case study: Vietnamese Windows users reported "command hangs" because
* their terminal (PowerShell via npm) didn't set isTTY correctly.
*/
function isHeadlessEnvironment(): boolean {
// SSH session - always headless
if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) {
return true;
}
// No display on Linux (X11/Wayland) - headless
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
return true;
}
// Windows desktop - NEVER headless unless SSH (already checked above)
// This fixes false positive where Windows npm wrappers don't set isTTY correctly
// Windows desktop environments always have browser capability
if (process.platform === 'win32') {
return false;
}
// macOS - check for proper terminal
if (process.platform === 'darwin') {
// Non-interactive stdin on macOS means likely piped/scripted
if (!process.stdin.isTTY) {
return true;
}
return false;
}
// Linux with display - check TTY
if (process.platform === 'linux') {
if (!process.stdin.isTTY) {
return true;
}
return false;
}
// Default fallback for unknown platforms
return !process.stdin.isTTY;
}
/**
* Auth status for a provider
*/
export interface AuthStatus {
/** Provider name */
provider: CLIProxyProvider;
/** Whether authentication exists */
authenticated: boolean;
/** Path to token directory */
tokenDir: string;
/** Token file paths found */
tokenFiles: string[];
/** When last authenticated (if known) */
lastAuth?: Date;
/** Accounts registered for this provider (multi-account support) */
accounts: AccountInfo[];
/** Default account ID */
defaultAccount?: string;
}
/**
* OAuth config for each provider
*/
interface ProviderOAuthConfig {
/** Provider identifier */
provider: CLIProxyProvider;
/** Display name */
displayName: string;
/** OAuth authorization URL (for manual flow) */
authUrl: string;
/** Scopes required */
scopes: string[];
/** CLI flag for auth */
authFlag: string;
}
/**
* OAuth configurations per provider
* Note: CLIProxyAPI handles actual OAuth - these are for display/manual flow
*/
const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
gemini: {
provider: 'gemini',
displayName: 'Google Gemini',
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
scopes: ['https://www.googleapis.com/auth/generative-language'],
authFlag: '--login',
},
codex: {
provider: 'codex',
displayName: 'Codex',
authUrl: 'https://auth.openai.com/authorize',
scopes: ['openid', 'profile'],
authFlag: '--codex-login',
},
agy: {
provider: 'agy',
displayName: 'Antigravity',
authUrl: 'https://antigravity.ai/oauth/authorize',
scopes: ['api'],
authFlag: '--antigravity-login',
},
qwen: {
provider: 'qwen',
displayName: 'Qwen Code',
authUrl: 'https://chat.qwen.ai/api/v1/oauth2/device/code',
scopes: ['openid', 'profile', 'email', 'model.completion'],
authFlag: '--qwen-login',
},
iflow: {
provider: 'iflow',
displayName: 'iFlow',
authUrl: 'https://iflow.cn/oauth',
scopes: ['phone', 'profile', 'email'],
authFlag: '--iflow-login',
},
};
/**
* Get OAuth config for provider
*/
export function getOAuthConfig(provider: CLIProxyProvider): ProviderOAuthConfig {
const config = OAUTH_CONFIGS[provider];
if (!config) {
throw new Error(`Unknown provider: ${provider}`);
}
return config;
}
/**
* Get token directory for provider
*/
export function getProviderTokenDir(provider: CLIProxyProvider): string {
return getProviderAuthDir(provider);
}
/**
* Provider-specific auth file prefixes (fallback detection)
* CLIProxyAPI names auth files with provider prefix (e.g., "antigravity-user@email.json")
* Note: Gemini tokens may NOT have prefix - CLIProxyAPI uses {email}-{projectID}.json format
*/
const PROVIDER_AUTH_PREFIXES: Record<CLIProxyProvider, string[]> = {
gemini: ['gemini-', 'google-'],
codex: ['codex-', 'openai-'],
agy: ['antigravity-', 'agy-'],
qwen: ['qwen-'],
iflow: ['iflow-'],
};
/**
* Provider type values inside token JSON files
* CLIProxyAPI sets "type" field in token JSON (e.g., {"type": "gemini"})
*/
const PROVIDER_TYPE_VALUES: Record<CLIProxyProvider, string[]> = {
gemini: ['gemini'],
codex: ['codex'],
agy: ['antigravity'],
qwen: ['qwen'],
iflow: ['iflow'],
};
/**
* Check if a JSON file contains a token for the given provider
* Reads the file and checks the "type" field
*/
function isTokenFileForProvider(filePath: string, provider: CLIProxyProvider): boolean {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content);
const typeValue = (data.type || '').toLowerCase();
const validTypes = PROVIDER_TYPE_VALUES[provider] || [];
return validTypes.includes(typeValue);
} catch {
return false;
}
}
/**
* Check if provider has valid authentication
* CLIProxyAPI stores OAuth tokens as JSON files in the auth directory.
* Detection strategy:
* 1. First check by filename prefix (fast path)
* 2. If no match, check JSON content for "type" field (Gemini uses {email}-{projectID}.json without prefix)
*/
export function isAuthenticated(provider: CLIProxyProvider): boolean {
const tokenDir = getProviderTokenDir(provider);
if (!fs.existsSync(tokenDir)) {
return false;
}
const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
try {
const files = fs.readdirSync(tokenDir);
const jsonFiles = files.filter(
(f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials'
);
// Strategy 1: Check by filename prefix (fast path for antigravity, codex)
const prefixMatch = jsonFiles.some((f) => {
const lowerFile = f.toLowerCase();
return validPrefixes.some((prefix) => lowerFile.startsWith(prefix));
});
if (prefixMatch) return true;
// Strategy 2: Check JSON content for "type" field (needed for Gemini)
for (const f of jsonFiles) {
const filePath = path.join(tokenDir, f);
if (isTokenFileForProvider(filePath, provider)) {
return true;
}
}
return false;
} catch {
return false;
}
}
/**
* Get detailed auth status for provider
* Uses same detection strategy as isAuthenticated: prefix first, then content
*/
export function getAuthStatus(provider: CLIProxyProvider): AuthStatus {
const tokenDir = getProviderTokenDir(provider);
let tokenFiles: string[] = [];
let lastAuth: Date | undefined;
const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
if (fs.existsSync(tokenDir)) {
const files = fs.readdirSync(tokenDir);
const jsonFiles = files.filter(
(f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials'
);
// Check each file: by prefix OR by content
tokenFiles = jsonFiles.filter((f) => {
const lowerFile = f.toLowerCase();
// Strategy 1: prefix match
if (validPrefixes.some((prefix) => lowerFile.startsWith(prefix))) {
return true;
}
// Strategy 2: content match (for Gemini tokens without prefix)
const filePath = path.join(tokenDir, f);
return isTokenFileForProvider(filePath, provider);
});
// Get most recent modification time
for (const file of tokenFiles) {
const filePath = path.join(tokenDir, file);
try {
const stats = fs.statSync(filePath);
if (!lastAuth || stats.mtime > lastAuth) {
lastAuth = stats.mtime;
}
} catch {
// Skip if can't stat file
}
}
}
// Get registered accounts for multi-account support
const accounts = getProviderAccounts(provider);
const defaultAccount = getDefaultAccount(provider);
return {
provider,
authenticated: tokenFiles.length > 0,
tokenDir,
tokenFiles,
lastAuth,
accounts,
defaultAccount: defaultAccount?.id,
};
}
/**
* Get auth status for all providers
*/
export function getAllAuthStatus(): AuthStatus[] {
const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
return providers.map(getAuthStatus);
}
/**
* Clear authentication for provider
* Only removes files belonging to the specified provider (by prefix or content)
* Does NOT remove the shared auth directory or other providers' files
*/
export function clearAuth(provider: CLIProxyProvider): boolean {
const tokenDir = getProviderTokenDir(provider);
if (!fs.existsSync(tokenDir)) {
return false;
}
const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
const files = fs.readdirSync(tokenDir);
let removedCount = 0;
// Only remove files that belong to this provider
for (const file of files) {
const filePath = path.join(tokenDir, file);
const lowerFile = file.toLowerCase();
// Check by prefix first (fast path)
const matchesByPrefix = validPrefixes.some((prefix) => lowerFile.startsWith(prefix));
// If no prefix match, check by content (for Gemini tokens without prefix)
const matchesByContent = !matchesByPrefix && isTokenFileForProvider(filePath, provider);
if (matchesByPrefix || matchesByContent) {
try {
fs.unlinkSync(filePath);
removedCount++;
} catch {
// Failed to remove - skip
}
}
}
// DO NOT remove the shared auth directory - other providers may still have tokens
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) {
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
* 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 options - OAuth options
* @param options.add - If true, skip confirm prompt when adding another account
* @returns Account info if successful, null otherwise
*/
export async function triggerOAuth(
provider: CLIProxyProvider,
options: {
verbose?: boolean;
headless?: boolean;
account?: string;
add?: boolean;
nickname?: string;
/** If true, triggered from Web UI (enables project selection prompt) */
fromUI?: boolean;
} = {}
): Promise<AccountInfo | null> {
const oauthConfig = getOAuthConfig(provider);
const { verbose = false, add = false, nickname, fromUI = false } = options;
const callbackPort = OAUTH_PORTS[provider];
const isCLI = !fromUI; // CLI mode = auto-select default project
// Auto-detect headless if not explicitly set
const headless = options.headless ?? isHeadlessEnvironment();
const log = (msg: string) => {
if (verbose) {
console.error(`[auth] ${msg}`);
}
};
// Check for existing accounts and prompt if --add not specified
const existingAccounts = getProviderAccounts(provider);
if (existingAccounts.length > 0 && !add) {
console.log('');
console.log(
info(
`${existingAccounts.length} account(s) already authenticated for ${oauthConfig.displayName}`
)
);
// Import readline for confirm prompt
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const confirmed = await new Promise<boolean>((resolve) => {
rl.question('[?] Add another account? (y/N): ', (answer) => {
rl.close();
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
});
});
if (!confirmed) {
console.log(info('Cancelled'));
return null;
}
}
// Enhanced pre-flight check with real-time display
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) {
console.log('');
console.log(fail('Pre-flight check failed. Resolve issues above and retry.'));
return null;
}
console.log('');
// Step 1: Ensure binary exists
showStep(1, 4, 'progress', 'Preparing CLIProxy binary...');
let binaryPath: string;
try {
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) {
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;
}
// Ensure auth directory exists
const tokenDir = getProviderTokenDir(provider);
fs.mkdirSync(tokenDir, { recursive: true, mode: 0o700 });
// Generate config file (CLIProxyAPI requires it even for auth)
const configPath = generateConfig(provider);
log(`Config generated: ${configPath}`);
// Free OAuth callback port if needed
const localCallbackPort = OAUTH_CALLBACK_PORTS[provider];
if (localCallbackPort) {
const killed = killProcessOnPort(localCallbackPort, verbose);
if (killed) {
log(`Freed port ${localCallbackPort} for OAuth callback`);
}
}
// Build args: config + auth flag + optional --no-browser for headless
const args = ['--config', configPath, oauthConfig.authFlag];
if (headless) {
args.push('--no-browser');
}
// Step 2: Starting callback server
showStep(2, 4, 'progress', `Starting callback server on port ${callbackPort || 'N/A'}...`);
// Show headless instructions if needed
if (headless) {
console.log('');
console.log(warn('PORT FORWARDING REQUIRED'));
console.log(` OAuth callback uses localhost:${callbackPort} which must be reachable.`);
console.log(' Run this on your LOCAL machine:');
console.log(
` ${color(`ssh -L ${callbackPort}:localhost:${callbackPort} <USER>@<HOST>`, 'command')}`
);
console.log('');
}
return new Promise<AccountInfo | null>((resolve) => {
// Spawn CLIProxyAPI with auth flag
// Use pipe for stdin to auto-respond to interactive prompts (e.g., project selection)
const authProcess = spawn(binaryPath, args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
CLI_PROXY_AUTH_DIR: tokenDir,
},
});
let stderrData = '';
let urlDisplayed = false;
let browserOpened = false;
let projectPromptHandled = false;
let accumulatedOutput = ''; // Accumulate output to parse project list
let parsedProjects: GCloudProject[] = [];
const sessionId = generateSessionId(); // Unique session ID for this auth flow
const startTime = Date.now();
authProcess.stdout?.on('data', async (data: Buffer) => {
const output = data.toString();
log(`stdout: ${output.trim()}`);
// Accumulate output for project list parsing
accumulatedOutput += output;
// Parse project list when available
if (isProjectList(accumulatedOutput) && parsedProjects.length === 0) {
parsedProjects = parseProjectList(accumulatedOutput);
log(`Parsed ${parsedProjects.length} projects`);
}
// Handle project selection prompt
if (!projectPromptHandled && isProjectSelectionPrompt(output)) {
projectPromptHandled = true;
const defaultProjectId = parseDefaultProject(output) || '';
// If we have projects and this is a UI-triggered flow, request selection
if (parsedProjects.length > 0 && !isCLI) {
log(`Requesting project selection from UI (session: ${sessionId})`);
const prompt: ProjectSelectionPrompt = {
sessionId,
provider,
projects: parsedProjects,
defaultProjectId,
supportsAll: output.includes('ALL'),
};
try {
// Request selection from UI (with timeout fallback to default)
const selectedId = await requestProjectSelection(prompt);
// Write selection to stdin (empty = default, else project ID or ALL)
const response = selectedId || '';
log(`User selected: ${response || '(default)'}`);
authProcess.stdin?.write(response + '\n');
} catch {
// Fallback to default on error
log('Project selection failed, using default');
authProcess.stdin?.write('\n');
}
} else {
// CLI mode or no projects: auto-select default
log('CLI mode or no projects, auto-selecting default');
authProcess.stdin?.write('\n');
}
}
// 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
if (headless) {
const urlMatch = output.match(/https?:\/\/[^\s]+/);
if (urlMatch && !urlDisplayed) {
console.log('');
console.log(info(`${oauthConfig.displayName} OAuth URL:`));
console.log(` ${urlMatch[0]}`);
console.log('');
urlDisplayed = true;
}
}
});
authProcess.stderr?.on('data', (data: Buffer) => {
const output = data.toString();
stderrData += output;
log(`stderr: ${output.trim()}`);
// Also check stderr for URLs
if (headless && !urlDisplayed) {
const urlMatch = output.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
console.log('');
console.log(info(`${oauthConfig.displayName} OAuth URL:`));
console.log(` ${urlMatch[0]}`);
console.log('');
urlDisplayed = true;
}
}
});
// 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 timeout = setTimeout(() => {
authProcess.kill();
console.log('');
console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`));
// Show platform-specific troubleshooting
const troubleshooting = getTimeoutTroubleshooting(provider, callbackPort);
for (const line of troubleshooting) {
console.log(line);
}
resolve(null);
}, timeoutMs);
authProcess.on('exit', (code) => {
clearTimeout(timeout);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (code === 0) {
// Verify token was created BEFORE showing success
if (isAuthenticated(provider)) {
console.log('');
console.log(ok(`Authentication successful (${elapsed}s)`));
// Register the account in accounts registry
const account = registerAccountFromToken(provider, tokenDir, nickname);
resolve(account);
} else {
console.log('');
console.log(fail('Token not found after authentication'));
console.log('');
console.log('The browser showed success but callback was not received.');
// Show platform-specific guidance
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(`Try: ccs ${provider} --auth --verbose`);
resolve(null);
}
} else {
console.log('');
console.log(fail(`CLIProxyAPI auth exited with code ${code}`));
if (stderrData && !urlDisplayed) {
console.log(` ${stderrData.trim().split('\n')[0]}`);
}
if (headless && !urlDisplayed) {
console.log('');
console.log(info('No OAuth URL was displayed. Try with --verbose for details.'));
}
resolve(null);
}
});
authProcess.on('error', (error) => {
clearTimeout(timeout);
console.log('');
console.log(fail(`Failed to start auth process: ${error.message}`));
resolve(null);
});
});
}
/**
* Register account from newly created token file
* Scans auth directory for new token and extracts email
* @param provider - The CLIProxy provider
* @param tokenDir - Directory containing token files
* @param nickname - Optional nickname (uses auto-generated from email if not provided)
*/
function registerAccountFromToken(
provider: CLIProxyProvider,
tokenDir: string,
nickname?: string
): AccountInfo | null {
try {
const files = fs.readdirSync(tokenDir);
const jsonFiles = files.filter((f) => f.endsWith('.json'));
// Find newest token file for this provider
let newestFile: string | null = null;
let newestMtime = 0;
for (const file of jsonFiles) {
const filePath = path.join(tokenDir, file);
if (!isTokenFileForProvider(filePath, provider)) continue;
const stats = fs.statSync(filePath);
if (stats.mtimeMs > newestMtime) {
newestMtime = stats.mtimeMs;
newestFile = file;
}
}
if (!newestFile) {
return null;
}
// Read token to extract email
const tokenPath = path.join(tokenDir, newestFile);
const content = fs.readFileSync(tokenPath, 'utf-8');
const data = JSON.parse(content);
const email = data.email || undefined;
// Register the account (use provided nickname or auto-generate from email)
return registerAccount(provider, newestFile, email, nickname || generateNickname(email));
} catch {
return null;
}
}
/**
* Ensure provider is authenticated
* Triggers OAuth flow if not authenticated
* @param provider - The CLIProxy provider
* @param options - Auth options including optional account
* @returns true if authenticated, false otherwise
*/
export async function ensureAuth(
provider: CLIProxyProvider,
options: { verbose?: boolean; headless?: boolean; account?: string } = {}
): Promise<boolean> {
// Check if already authenticated
if (isAuthenticated(provider)) {
if (options.verbose) {
console.error(`[auth] ${provider} already authenticated`);
}
// Touch the account to update last used time
const defaultAccount = getDefaultAccount(provider);
if (defaultAccount) {
touchAccount(provider, options.account || defaultAccount.id);
}
return true;
}
// Not authenticated - trigger OAuth
const oauthConfig = getOAuthConfig(provider);
console.log(info(`${oauthConfig.displayName} authentication required`));
const account = await triggerOAuth(provider, options);
return account !== null;
}
// Re-export types
export type { AuthStatus, ProviderOAuthConfig, OAuthOptions } from './auth';
// Re-export configurations
export {
OAUTH_CALLBACK_PORTS,
OAUTH_CONFIGS,
PROVIDER_AUTH_PREFIXES,
PROVIDER_TYPE_VALUES,
getOAuthConfig,
} from './auth';
// Re-export token management functions
export {
getProviderTokenDir,
isTokenFileForProvider,
isAuthenticated,
getAuthStatus,
getAllAuthStatus,
clearAuth,
displayAuthStatus,
} from './auth';
// Re-export environment detection functions
export {
isHeadlessEnvironment,
killProcessOnPort,
getTimeoutTroubleshooting,
showStep,
} from './auth';
// Re-export OAuth handling functions
export { triggerOAuth, ensureAuth } from './auth';
// Re-export account management initialization
import { discoverExistingAccounts } from './account-manager';
/**
* Initialize accounts registry from existing tokens
@@ -907,26 +63,3 @@ export async function ensureAuth(
export function initializeAccounts(): void {
discoverExistingAccounts();
}
/**
* Display auth status for all providers
*/
export function displayAuthStatus(): void {
console.log('CLIProxy Authentication Status:');
console.log('');
const statuses = getAllAuthStatus();
for (const status of statuses) {
const oauthConfig = getOAuthConfig(status.provider);
const icon = status.authenticated ? '[OK]' : '[!]';
const authStatus = status.authenticated ? 'Authenticated' : 'Not authenticated';
const lastAuthStr = status.lastAuth ? ` (last: ${status.lastAuth.toLocaleDateString()})` : '';
console.log(`${icon} ${oauthConfig.displayName}: ${authStatus}${lastAuthStr}`);
}
console.log('');
console.log('To authenticate: ccs <provider> --auth');
console.log('To logout: ccs <provider> --logout');
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Auth Types and Configurations
*
* Type definitions and OAuth configurations for CLIProxy authentication.
*/
import { CLIProxyProvider } from '../types';
import { AccountInfo } from '../account-manager';
/**
* OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
* See: https://github.com/router-for-me/CLIProxyAPI/tree/main/internal/auth
*
* OAuth flow types per provider:
* - Gemini: Authorization Code Flow with local callback server on port 8085
* - Codex: Authorization Code Flow with local callback server on port 1455
* - Agy: Authorization Code Flow with local callback server on port 51121
* - Qwen: Device Code Flow (polling-based, NO callback port needed)
*/
export const OAUTH_CALLBACK_PORTS: Partial<Record<CLIProxyProvider, number>> = {
gemini: 8085,
// codex uses 1455
// agy uses 51121
// qwen uses Device Code Flow - no callback port needed
};
/**
* Auth status for a provider
*/
export interface AuthStatus {
/** Provider name */
provider: CLIProxyProvider;
/** Whether authentication exists */
authenticated: boolean;
/** Path to token directory */
tokenDir: string;
/** Token file paths found */
tokenFiles: string[];
/** When last authenticated (if known) */
lastAuth?: Date;
/** Accounts registered for this provider (multi-account support) */
accounts: AccountInfo[];
/** Default account ID */
defaultAccount?: string;
}
/**
* OAuth config for each provider
*/
export interface ProviderOAuthConfig {
/** Provider identifier */
provider: CLIProxyProvider;
/** Display name */
displayName: string;
/** OAuth authorization URL (for manual flow) */
authUrl: string;
/** Scopes required */
scopes: string[];
/** CLI flag for auth */
authFlag: string;
}
/**
* OAuth configurations per provider
* Note: CLIProxyAPI handles actual OAuth - these are for display/manual flow
*/
export const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
gemini: {
provider: 'gemini',
displayName: 'Google Gemini',
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
scopes: ['https://www.googleapis.com/auth/generative-language'],
authFlag: '--login',
},
codex: {
provider: 'codex',
displayName: 'Codex',
authUrl: 'https://auth.openai.com/authorize',
scopes: ['openid', 'profile'],
authFlag: '--codex-login',
},
agy: {
provider: 'agy',
displayName: 'Antigravity',
authUrl: 'https://antigravity.ai/oauth/authorize',
scopes: ['api'],
authFlag: '--antigravity-login',
},
qwen: {
provider: 'qwen',
displayName: 'Qwen Code',
authUrl: 'https://chat.qwen.ai/api/v1/oauth2/device/code',
scopes: ['openid', 'profile', 'email', 'model.completion'],
authFlag: '--qwen-login',
},
iflow: {
provider: 'iflow',
displayName: 'iFlow',
authUrl: 'https://iflow.cn/oauth',
scopes: ['phone', 'profile', 'email'],
authFlag: '--iflow-login',
},
};
/**
* Provider-specific auth file prefixes (fallback detection)
* CLIProxyAPI names auth files with provider prefix (e.g., "antigravity-user@email.json")
* Note: Gemini tokens may NOT have prefix - CLIProxyAPI uses {email}-{projectID}.json format
*/
export const PROVIDER_AUTH_PREFIXES: Record<CLIProxyProvider, string[]> = {
gemini: ['gemini-', 'google-'],
codex: ['codex-', 'openai-'],
agy: ['antigravity-', 'agy-'],
qwen: ['qwen-'],
iflow: ['iflow-'],
};
/**
* Provider type values inside token JSON files
* CLIProxyAPI sets "type" field in token JSON (e.g., {"type": "gemini"})
*/
export const PROVIDER_TYPE_VALUES: Record<CLIProxyProvider, string[]> = {
gemini: ['gemini'],
codex: ['codex'],
agy: ['antigravity'],
qwen: ['qwen'],
iflow: ['iflow'],
};
/**
* Get OAuth config for provider
*/
export function getOAuthConfig(provider: CLIProxyProvider): ProviderOAuthConfig {
const config = OAUTH_CONFIGS[provider];
if (!config) {
throw new Error(`Unknown provider: ${provider}`);
}
return config;
}
/**
* OAuth options for triggerOAuth
*/
export interface OAuthOptions {
verbose?: boolean;
headless?: boolean;
account?: string;
add?: boolean;
nickname?: string;
/** If true, triggered from Web UI (enables project selection prompt) */
fromUI?: boolean;
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Environment Detector for CLIProxyAPI
*
* Detects headless environments and manages OAuth callback ports.
*/
import { execSync } from 'child_process';
import { CLIProxyProvider } from '../types';
/**
* Detect if running in a headless environment (no browser available)
*
* IMPROVED: Avoids false positives on Windows desktop environments
* where isTTY may be undefined due to terminal wrapper behavior.
*
* Case study: Vietnamese Windows users reported "command hangs" because
* their terminal (PowerShell via npm) didn't set isTTY correctly.
*/
export function isHeadlessEnvironment(): boolean {
// SSH session - always headless
if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) {
return true;
}
// No display on Linux (X11/Wayland) - headless
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
return true;
}
// Windows desktop - NEVER headless unless SSH (already checked above)
// This fixes false positive where Windows npm wrappers don't set isTTY correctly
// Windows desktop environments always have browser capability
if (process.platform === 'win32') {
return false;
}
// macOS - check for proper terminal
if (process.platform === 'darwin') {
// Non-interactive stdin on macOS means likely piped/scripted
if (!process.stdin.isTTY) {
return true;
}
return false;
}
// Linux with display - check TTY
if (process.platform === 'linux') {
if (!process.stdin.isTTY) {
return true;
}
return false;
}
// Default fallback for unknown platforms
return !process.stdin.isTTY;
}
/**
* Kill any process using a specific port
* Used to free OAuth callback port before authentication
*/
export function killProcessOnPort(port: number, verbose: boolean): boolean {
try {
if (process.platform === 'win32') {
// Windows: use netstat + taskkill
const result = execSync(`netstat -ano | findstr :${port}`, {
encoding: 'utf-8',
stdio: 'pipe',
});
const lines = result.trim().split('\n');
for (const line of lines) {
const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1];
if (pid && /^\d+$/.test(pid)) {
execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' });
if (verbose) console.error(`[auth] Killed process ${pid} on port ${port}`);
}
}
return true;
} else {
// Unix: use lsof + kill
const result = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', stdio: 'pipe' });
const pids = result
.trim()
.split('\n')
.filter((p) => p);
for (const pid of pids) {
execSync(`kill -9 ${pid}`, { stdio: 'pipe' });
if (verbose) console.error(`[auth] Killed process ${pid} on port ${port}`);
}
return pids.length > 0;
}
} catch {
// No process on port or command failed - that's fine
return false;
}
}
/**
* Get platform-specific troubleshooting for OAuth timeout
*/
export 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) {
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;
}
/**
* Display a single step status line
*/
export 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}`);
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Auth Module - Barrel Export
*
* Re-exports all auth-related functionality from submodules.
*/
// Types and configurations
export type { AuthStatus, ProviderOAuthConfig, OAuthOptions } from './auth-types';
export {
OAUTH_CALLBACK_PORTS,
OAUTH_CONFIGS,
PROVIDER_AUTH_PREFIXES,
PROVIDER_TYPE_VALUES,
getOAuthConfig,
} from './auth-types';
// Token management
export {
getProviderTokenDir,
isTokenFileForProvider,
isAuthenticated,
getAuthStatus,
getAllAuthStatus,
clearAuth,
registerAccountFromToken,
displayAuthStatus,
} from './token-manager';
// Environment detection
export {
isHeadlessEnvironment,
killProcessOnPort,
getTimeoutTroubleshooting,
showStep,
} from './environment-detector';
// OAuth handling
export { triggerOAuth, ensureAuth } from './oauth-handler';
+230
View File
@@ -0,0 +1,230 @@
/**
* OAuth Handler for CLIProxyAPI
*
* Manages OAuth authentication flow for CLIProxy providers (Gemini, Codex, Antigravity).
* CLIProxyAPI handles OAuth internally - we just need to:
* 1. Check if auth exists (token files in CCS auth directory)
* 2. Trigger OAuth flow by spawning binary with auth flag
* 3. Auto-detect headless environments (SSH, no DISPLAY)
* 4. Use --no-browser flag for headless, display OAuth URL for manual auth
*/
import * as fs from 'fs';
import { fail, info, warn, color } from '../../utils/ui';
import { ensureCLIProxyBinary } from '../binary-manager';
import { generateConfig } from '../config-generator';
import { CLIProxyProvider } from '../types';
import {
AccountInfo,
getProviderAccounts,
getDefaultAccount,
touchAccount,
} from '../account-manager';
import {
enhancedPreflightOAuthCheck,
OAUTH_CALLBACK_PORTS as OAUTH_PORTS,
} from '../../management/oauth-port-diagnostics';
import { OAuthOptions, OAUTH_CALLBACK_PORTS, getOAuthConfig } from './auth-types';
import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector';
import { getProviderTokenDir, isAuthenticated } from './token-manager';
import { executeOAuthProcess } from './oauth-process';
/**
* Prompt user to add another account
*/
async function promptAddAccount(): Promise<boolean> {
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise<boolean>((resolve) => {
rl.question('[?] Add another account? (y/N): ', (answer) => {
rl.close();
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
});
});
}
/**
* Run pre-flight OAuth checks
*/
async function runPreflightChecks(
provider: CLIProxyProvider,
oauthConfig: { displayName: string }
): Promise<boolean> {
console.log('');
console.log(info(`Pre-flight OAuth check for ${oauthConfig.displayName}...`));
const preflight = await enhancedPreflightOAuthCheck(provider);
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}`);
}
}
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) {
console.log('');
console.log(fail('Pre-flight check failed. Resolve issues above and retry.'));
return false;
}
return true;
}
/**
* Prepare OAuth binary and config
*/
async function prepareBinary(
provider: CLIProxyProvider,
verbose: boolean
): Promise<{ binaryPath: string; tokenDir: string; configPath: string } | null> {
showStep(1, 4, 'progress', 'Preparing CLIProxy binary...');
try {
const binaryPath = await ensureCLIProxyBinary(verbose);
process.stdout.write('\x1b[1A\x1b[2K');
showStep(1, 4, 'ok', 'CLIProxy binary ready');
const tokenDir = getProviderTokenDir(provider);
fs.mkdirSync(tokenDir, { recursive: true, mode: 0o700 });
const configPath = generateConfig(provider);
if (verbose) {
console.error(`[auth] Config generated: ${configPath}`);
}
return { binaryPath, tokenDir, configPath };
} catch (error) {
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;
}
}
/**
* Trigger OAuth flow for provider
* Auto-detects headless environment and uses --no-browser flag accordingly
* Shows real-time step-by-step progress for better user feedback
*/
export async function triggerOAuth(
provider: CLIProxyProvider,
options: OAuthOptions = {}
): Promise<AccountInfo | null> {
const oauthConfig = getOAuthConfig(provider);
const { verbose = false, add = false, nickname, fromUI = false } = options;
const callbackPort = OAUTH_PORTS[provider];
const isCLI = !fromUI;
const headless = options.headless ?? isHeadlessEnvironment();
// Check for existing accounts
const existingAccounts = getProviderAccounts(provider);
if (existingAccounts.length > 0 && !add) {
console.log('');
console.log(
info(
`${existingAccounts.length} account(s) already authenticated for ${oauthConfig.displayName}`
)
);
if (!(await promptAddAccount())) {
console.log(info('Cancelled'));
return null;
}
}
// Pre-flight checks
if (!(await runPreflightChecks(provider, oauthConfig))) {
return null;
}
console.log('');
// Prepare binary
const prepared = await prepareBinary(provider, verbose);
if (!prepared) return null;
const { binaryPath, tokenDir, configPath } = prepared;
// Free callback port if needed
const localCallbackPort = OAUTH_CALLBACK_PORTS[provider];
if (localCallbackPort) {
const killed = killProcessOnPort(localCallbackPort, verbose);
if (killed && verbose) {
console.error(`[auth] Freed port ${localCallbackPort} for OAuth callback`);
}
}
// Build args
const args = ['--config', configPath, oauthConfig.authFlag];
if (headless) {
args.push('--no-browser');
}
// Show callback server step
showStep(2, 4, 'progress', `Starting callback server on port ${callbackPort || 'N/A'}...`);
// Show headless instructions
if (headless) {
console.log('');
console.log(warn('PORT FORWARDING REQUIRED'));
console.log(` OAuth callback uses localhost:${callbackPort} which must be reachable.`);
console.log(' Run this on your LOCAL machine:');
console.log(
` ${color(`ssh -L ${callbackPort}:localhost:${callbackPort} <USER>@<HOST>`, 'command')}`
);
console.log('');
}
// Execute OAuth process
return executeOAuthProcess({
provider,
binaryPath,
args,
tokenDir,
oauthConfig,
callbackPort,
headless,
verbose,
isCLI,
nickname,
});
}
/**
* Ensure provider is authenticated
* Triggers OAuth flow if not authenticated
*/
export async function ensureAuth(
provider: CLIProxyProvider,
options: { verbose?: boolean; headless?: boolean; account?: string } = {}
): Promise<boolean> {
if (isAuthenticated(provider)) {
if (options.verbose) {
console.error(`[auth] ${provider} already authenticated`);
}
const defaultAccount = getDefaultAccount(provider);
if (defaultAccount) {
touchAccount(provider, options.account || defaultAccount.id);
}
return true;
}
const oauthConfig = getOAuthConfig(provider);
console.log(info(`${oauthConfig.displayName} authentication required`));
const account = await triggerOAuth(provider, options);
return account !== null;
}
+290
View File
@@ -0,0 +1,290 @@
/**
* 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 } from '../../utils/ui';
import { CLIProxyProvider } from '../types';
import { AccountInfo } from '../account-manager';
import {
parseProjectList,
parseDefaultProject,
isProjectSelectionPrompt,
isProjectList,
generateSessionId,
requestProjectSelection,
type GCloudProject,
type ProjectSelectionPrompt,
} from '../project-selection-handler';
import { ProviderOAuthConfig } from './auth-types';
import { getTimeoutTroubleshooting, showStep } from './environment-detector';
import { isAuthenticated, registerAccountFromToken } from './token-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;
}
/** Internal state for OAuth process */
interface ProcessState {
stderrData: string;
urlDisplayed: boolean;
browserOpened: boolean;
projectPromptHandled: boolean;
accumulatedOutput: string;
parsedProjects: GCloudProject[];
sessionId: string;
}
/**
* Handle project selection prompt
*/
async function handleProjectSelection(
output: string,
state: ProcessState,
options: OAuthProcessOptions,
authProcess: ChildProcess,
log: (msg: string) => void
): Promise<void> {
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');
}
}
/**
* Handle stdout data from OAuth process
*/
async function handleStdout(
output: string,
state: ProcessState,
options: OAuthProcessOptions,
authProcess: ChildProcess,
log: (msg: string) => void
): Promise<void> {
log(`stdout: ${output.trim()}`);
state.accumulatedOutput += output;
// 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
if (!state.projectPromptHandled && isProjectSelectionPrompt(output)) {
state.projectPromptHandled = true;
await handleProjectSelection(output, state, options, authProcess, log);
}
// Detect callback server / browser
if (!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 URLs in headless mode
if (options.headless && !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;
}
}
}
/** 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;
}
}
/** Handle token not found after successful process exit */
function handleTokenNotFound(provider: CLIProxyProvider, callbackPort: number | null): void {
console.log('');
console.log(fail('Token not found after authentication'));
console.log('');
console.log('The browser showed success but callback was not received.');
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(`Try: ccs ${provider} --auth --verbose`);
}
/** Handle process exit with error */
function handleProcessError(code: number | null, state: ProcessState, headless: boolean): void {
console.log('');
console.log(fail(`CLIProxyAPI 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<AccountInfo | null> {
const {
provider,
binaryPath,
args,
tokenDir,
oauthConfig,
callbackPort,
headless,
verbose,
nickname,
} = options;
const log = (msg: string) => {
if (verbose) console.error(`[auth] ${msg}`);
};
return new Promise<AccountInfo | null>((resolve) => {
const authProcess = spawn(binaryPath, args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir },
});
const state: ProcessState = {
stderrData: '',
urlDisplayed: false,
browserOpened: false,
projectPromptHandled: false,
accumulatedOutput: '',
parsedProjects: [],
sessionId: generateSessionId(),
};
const startTime = Date.now();
authProcess.stdout?.on('data', async (data: Buffer) => {
await handleStdout(data.toString(), state, options, authProcess, log);
});
authProcess.stderr?.on('data', (data: Buffer) => {
const output = data.toString();
state.stderrData += output;
log(`stderr: ${output.trim()}`);
if (headless && !state.urlDisplayed) {
displayUrlFromStderr(output, state, oauthConfig);
}
});
// Show waiting message after delay
setTimeout(() => {
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
const timeoutMs = headless ? 300000 : 120000;
const timeout = setTimeout(() => {
authProcess.kill();
console.log('');
console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`));
for (const line of getTimeoutTroubleshooting(provider, callbackPort ?? null)) {
console.log(line);
}
resolve(null);
}, timeoutMs);
authProcess.on('exit', (code) => {
clearTimeout(timeout);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (code === 0) {
if (isAuthenticated(provider)) {
console.log('');
console.log(ok(`Authentication successful (${elapsed}s)`));
resolve(registerAccountFromToken(provider, tokenDir, nickname));
} else {
handleTokenNotFound(provider, callbackPort);
resolve(null);
}
} else {
handleProcessError(code, state, headless);
resolve(null);
}
});
authProcess.on('error', (error) => {
clearTimeout(timeout);
console.log('');
console.log(fail(`Failed to start auth process: ${error.message}`));
resolve(null);
});
});
}
+260
View File
@@ -0,0 +1,260 @@
/**
* Token Manager for CLIProxyAPI
*
* Handles OAuth token storage, retrieval, and validation.
* Tokens are stored in ~/.ccs/cliproxy/auth/ directory.
*/
import * as fs from 'fs';
import * as path from 'path';
import { CLIProxyProvider } from '../types';
import { getProviderAuthDir } from '../config-generator';
import { getProviderAccounts, getDefaultAccount } from '../account-manager';
import {
AuthStatus,
PROVIDER_AUTH_PREFIXES,
PROVIDER_TYPE_VALUES,
getOAuthConfig,
} from './auth-types';
/**
* Get token directory for provider
*/
export function getProviderTokenDir(provider: CLIProxyProvider): string {
return getProviderAuthDir(provider);
}
/**
* Check if a JSON file contains a token for the given provider
* Reads the file and checks the "type" field
*/
export function isTokenFileForProvider(filePath: string, provider: CLIProxyProvider): boolean {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content);
const typeValue = (data.type || '').toLowerCase();
const validTypes = PROVIDER_TYPE_VALUES[provider] || [];
return validTypes.includes(typeValue);
} catch {
return false;
}
}
/**
* Check if provider has valid authentication
* CLIProxyAPI stores OAuth tokens as JSON files in the auth directory.
* Detection strategy:
* 1. First check by filename prefix (fast path)
* 2. If no match, check JSON content for "type" field (Gemini uses {email}-{projectID}.json without prefix)
*/
export function isAuthenticated(provider: CLIProxyProvider): boolean {
const tokenDir = getProviderTokenDir(provider);
if (!fs.existsSync(tokenDir)) {
return false;
}
const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
try {
const files = fs.readdirSync(tokenDir);
const jsonFiles = files.filter(
(f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials'
);
// Strategy 1: Check by filename prefix (fast path for antigravity, codex)
const prefixMatch = jsonFiles.some((f) => {
const lowerFile = f.toLowerCase();
return validPrefixes.some((prefix) => lowerFile.startsWith(prefix));
});
if (prefixMatch) return true;
// Strategy 2: Check JSON content for "type" field (needed for Gemini)
for (const f of jsonFiles) {
const filePath = path.join(tokenDir, f);
if (isTokenFileForProvider(filePath, provider)) {
return true;
}
}
return false;
} catch {
return false;
}
}
/**
* Get detailed auth status for provider
* Uses same detection strategy as isAuthenticated: prefix first, then content
*/
export function getAuthStatus(provider: CLIProxyProvider): AuthStatus {
const tokenDir = getProviderTokenDir(provider);
let tokenFiles: string[] = [];
let lastAuth: Date | undefined;
const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
if (fs.existsSync(tokenDir)) {
const files = fs.readdirSync(tokenDir);
const jsonFiles = files.filter(
(f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials'
);
// Check each file: by prefix OR by content
tokenFiles = jsonFiles.filter((f) => {
const lowerFile = f.toLowerCase();
// Strategy 1: prefix match
if (validPrefixes.some((prefix) => lowerFile.startsWith(prefix))) {
return true;
}
// Strategy 2: content match (for Gemini tokens without prefix)
const filePath = path.join(tokenDir, f);
return isTokenFileForProvider(filePath, provider);
});
// Get most recent modification time
for (const file of tokenFiles) {
const filePath = path.join(tokenDir, file);
try {
const stats = fs.statSync(filePath);
if (!lastAuth || stats.mtime > lastAuth) {
lastAuth = stats.mtime;
}
} catch {
// Skip if can't stat file
}
}
}
// Get registered accounts for multi-account support
const accounts = getProviderAccounts(provider);
const defaultAccount = getDefaultAccount(provider);
return {
provider,
authenticated: tokenFiles.length > 0,
tokenDir,
tokenFiles,
lastAuth,
accounts,
defaultAccount: defaultAccount?.id,
};
}
/**
* Get auth status for all providers
*/
export function getAllAuthStatus(): AuthStatus[] {
const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
return providers.map(getAuthStatus);
}
/**
* Clear authentication for provider
* Only removes files belonging to the specified provider (by prefix or content)
* Does NOT remove the shared auth directory or other providers' files
*/
export function clearAuth(provider: CLIProxyProvider): boolean {
const tokenDir = getProviderTokenDir(provider);
if (!fs.existsSync(tokenDir)) {
return false;
}
const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
const files = fs.readdirSync(tokenDir);
let removedCount = 0;
// Only remove files that belong to this provider
for (const file of files) {
const filePath = path.join(tokenDir, file);
const lowerFile = file.toLowerCase();
// Check by prefix first (fast path)
const matchesByPrefix = validPrefixes.some((prefix) => lowerFile.startsWith(prefix));
// If no prefix match, check by content (for Gemini tokens without prefix)
const matchesByContent = !matchesByPrefix && isTokenFileForProvider(filePath, provider);
if (matchesByPrefix || matchesByContent) {
try {
fs.unlinkSync(filePath);
removedCount++;
} catch {
// Failed to remove - skip
}
}
}
// DO NOT remove the shared auth directory - other providers may still have tokens
return removedCount > 0;
}
/**
* Register account from newly created token file
* Scans auth directory for new token and extracts email
* @param provider - The CLIProxy provider
* @param tokenDir - Directory containing token files
* @param nickname - Optional nickname (uses auto-generated from email if not provided)
*/
export function registerAccountFromToken(
provider: CLIProxyProvider,
tokenDir: string,
nickname?: string
): import('../account-manager').AccountInfo | null {
const { registerAccount, generateNickname } = require('../account-manager');
try {
const files = fs.readdirSync(tokenDir);
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
let newestFile: string | null = null;
let newestMtime = 0;
for (const file of jsonFiles) {
const filePath = path.join(tokenDir, file);
if (!isTokenFileForProvider(filePath, provider)) continue;
const stats = fs.statSync(filePath);
if (stats.mtimeMs > newestMtime) {
newestMtime = stats.mtimeMs;
newestFile = file;
}
}
if (!newestFile) {
return null;
}
const tokenPath = path.join(tokenDir, newestFile);
const content = fs.readFileSync(tokenPath, 'utf-8');
const data = JSON.parse(content);
const email = data.email || undefined;
return registerAccount(provider, newestFile, email, nickname || generateNickname(email));
} catch {
return null;
}
}
/**
* Display auth status for all providers
*/
export function displayAuthStatus(): void {
console.log('CLIProxy Authentication Status:');
console.log('');
const statuses = getAllAuthStatus();
for (const status of statuses) {
const oauthConfig = getOAuthConfig(status.provider);
const icon = status.authenticated ? '[OK]' : '[!]';
const authStatus = status.authenticated ? 'Authenticated' : 'Not authenticated';
const lastAuthStr = status.lastAuth ? ` (last: ${status.lastAuth.toLocaleDateString()})` : '';
console.log(`${icon} ${oauthConfig.displayName}: ${authStatus}${lastAuthStr}`);
}
console.log('');
console.log('To authenticate: ccs <provider> --auth');
console.log('To logout: ccs <provider> --logout');
}
+230
View File
@@ -0,0 +1,230 @@
/**
* Binary Downloader
* Handles downloading files with retry logic, progress tracking, and redirect following.
*/
import * as fs from 'fs';
import * as https from 'https';
import * as http from 'http';
import { DownloadResult, ProgressCallback } from '../types';
/** Default configuration for downloader */
export interface DownloaderConfig {
/** Maximum retry attempts */
maxRetries: number;
/** Enable verbose logging */
verbose: boolean;
}
const DEFAULT_CONFIG: DownloaderConfig = {
maxRetries: 3,
verbose: false,
};
/**
* Download file from URL with progress tracking
*/
export function downloadFile(
url: string,
destPath: string,
onProgress?: ProgressCallback,
verbose = false
): Promise<void> {
return new Promise((resolve, reject) => {
const handleResponse = (res: http.IncomingMessage) => {
// Handle redirects (GitHub releases use 302)
if (res.statusCode === 301 || res.statusCode === 302) {
const redirectUrl = res.headers.location;
if (!redirectUrl) {
reject(new Error('Redirect without location header'));
return;
}
if (verbose) {
console.error(`[cliproxy] Following redirect: ${redirectUrl}`);
}
downloadFile(redirectUrl, destPath, onProgress, verbose).then(resolve).catch(reject);
return;
}
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
return;
}
const totalBytes = parseInt(res.headers['content-length'] || '0', 10);
let downloadedBytes = 0;
const fileStream = fs.createWriteStream(destPath);
res.on('data', (chunk: Buffer) => {
downloadedBytes += chunk.length;
if (onProgress && totalBytes > 0) {
onProgress({
total: totalBytes,
downloaded: downloadedBytes,
percentage: Math.round((downloadedBytes / totalBytes) * 100),
});
}
});
res.pipe(fileStream);
fileStream.on('finish', () => {
fileStream.close();
resolve();
});
fileStream.on('error', (err) => {
fs.unlink(destPath, () => {}); // Cleanup partial file
reject(err);
});
res.on('error', (err) => {
fs.unlink(destPath, () => {});
reject(err);
});
};
const protocol = url.startsWith('https') ? https : http;
const req = protocol.get(url, handleResponse);
req.on('error', reject);
req.setTimeout(60000, () => {
req.destroy();
reject(new Error('Download timeout (60s)'));
});
});
}
/**
* Download file with retry logic and exponential backoff
*/
export async function downloadWithRetry(
url: string,
destPath: string,
config: Partial<DownloaderConfig> = {}
): Promise<DownloadResult> {
const { maxRetries, verbose } = { ...DEFAULT_CONFIG, ...config };
let lastError = '';
let retries = 0;
while (retries < maxRetries) {
try {
await downloadFile(url, destPath, undefined, verbose);
return { success: true, filePath: destPath, retries };
} catch (error) {
const err = error as Error;
lastError = err.message;
retries++;
if (retries < maxRetries) {
// Exponential backoff: 1s, 2s, 4s
const delay = Math.pow(2, retries - 1) * 1000;
if (verbose) {
console.error(`[cliproxy] Retry ${retries}/${maxRetries} after ${delay}ms: ${lastError}`);
}
await sleep(delay);
}
}
}
return {
success: false,
error: `Download failed after ${retries} attempts: ${lastError}`,
retries,
};
}
/**
* Fetch text content from URL
*/
export function fetchText(url: string, verbose = false): Promise<string> {
return new Promise((resolve, reject) => {
const handleResponse = (res: http.IncomingMessage) => {
// Handle redirects
if (res.statusCode === 301 || res.statusCode === 302) {
const redirectUrl = res.headers.location;
if (!redirectUrl) {
reject(new Error('Redirect without location header'));
return;
}
fetchText(redirectUrl, verbose).then(resolve).catch(reject);
return;
}
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
return;
}
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => resolve(data));
res.on('error', reject);
};
const protocol = url.startsWith('https') ? https : http;
const req = protocol.get(url, handleResponse);
req.on('error', reject);
req.setTimeout(30000, () => {
req.destroy();
reject(new Error('Request timeout (30s)'));
});
});
}
/**
* Fetch JSON from URL (for GitHub API)
*/
export function fetchJson(url: string, verbose = false): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const options = {
headers: {
'User-Agent': 'CCS-CLIProxyAPI-Updater/1.0',
Accept: 'application/vnd.github.v3+json',
},
};
const handleResponse = (res: http.IncomingMessage) => {
if (res.statusCode === 301 || res.statusCode === 302) {
const redirectUrl = res.headers.location;
if (!redirectUrl) {
reject(new Error('Redirect without location header'));
return;
}
fetchJson(redirectUrl, verbose).then(resolve).catch(reject);
return;
}
if (res.statusCode !== 200) {
reject(new Error(`GitHub API error: HTTP ${res.statusCode}`));
return;
}
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch {
reject(new Error('Invalid JSON from GitHub API'));
}
});
res.on('error', reject);
};
const req = https.get(url, options, handleResponse);
req.on('error', reject);
req.setTimeout(10000, () => {
req.destroy();
reject(new Error('GitHub API timeout (10s)'));
});
});
}
/**
* Sleep helper
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Binary Verifier
* Handles checksum verification for downloaded binaries.
*/
import * as fs from 'fs';
import * as crypto from 'crypto';
import { ChecksumResult } from '../types';
import { fetchText } from './downloader';
/**
* Compute SHA256 checksum of file
*/
export function computeChecksum(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', (data) => hash.update(data));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
/**
* Parse checksum from checksums.txt content
*/
export function parseChecksum(content: string, binaryName: string): string | null {
const lines = content.split('\n');
for (const line of lines) {
// Format: "hash filename" or "hash filename"
const parts = line.trim().split(/\s+/);
if (parts.length >= 2 && parts[1] === binaryName) {
return parts[0].toLowerCase();
}
}
return null;
}
/**
* Verify file checksum against checksums.txt
*/
export async function verifyChecksum(
filePath: string,
binaryName: string,
checksumsUrl: string,
verbose = false
): Promise<ChecksumResult> {
// Download checksums.txt
const checksumsContent = await fetchText(checksumsUrl, verbose);
// Parse expected checksum
const expectedHash = parseChecksum(checksumsContent, binaryName);
if (!expectedHash) {
throw new Error(`Checksum not found for ${binaryName} in checksums.txt`);
}
// Compute actual checksum
const actualHash = await computeChecksum(filePath);
return {
valid: actualHash === expectedHash,
expected: expectedHash,
actual: actualHash,
};
}