mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
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:
+42
-909
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user