Files
ccs/src/cliproxy/auth-handler.ts
T
Kai (Tam Nhu) TranandGitHub a3f1e52ac6 feat(cliproxy): add qwen code oauth provider support (#31)
Add 'qwen' as new CLIProxy OAuth provider with --qwen-login flag.
Support qwen3-coder model with base config at config/base-qwen.settings.json.
Register qwen OAuth endpoints and token prefix handling.
Update help documentation across all CLI entry points.

Implements issue #29
2025-12-01 03:51:47 -05:00

504 lines
15 KiB
TypeScript

/**
* Auth Handler for CLIProxyAPI
*
* Manages OAuth authentication 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
*
* Token storage: ~/.ccs/cliproxy/auth/<provider>/
* Each provider has its own directory to avoid conflicts.
*/
import * as fs from 'fs';
import * as path from 'path';
import { spawn } from 'child_process';
import { ProgressIndicator } from '../utils/progress-indicator';
import { getProviderAuthDir, generateConfig } from './config-generator';
import { ensureCLIProxyBinary } from './binary-manager';
import { CLIProxyProvider } from './types';
/**
* Detect if running in a headless environment (no browser available)
*/
function isHeadlessEnvironment(): boolean {
// SSH session
if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) {
return true;
}
// No display (Linux/X11)
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
return true;
}
// Non-interactive (piped stdin)
if (!process.stdin.isTTY) {
return true;
}
return false;
}
/**
* 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;
}
/**
* 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',
},
};
/**
* 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-'],
};
/**
* 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'],
};
/**
* 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
}
}
}
return {
provider,
authenticated: tokenFiles.length > 0,
tokenDir,
tokenFiles,
lastAuth,
};
}
/**
* Get auth status for all providers
*/
export function getAllAuthStatus(): AuthStatus[] {
const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen'];
return providers.map(getAuthStatus);
}
/**
* Clear authentication for provider
*/
export function clearAuth(provider: CLIProxyProvider): boolean {
const tokenDir = getProviderTokenDir(provider);
if (!fs.existsSync(tokenDir)) {
return false;
}
// Remove all files in token directory
const files = fs.readdirSync(tokenDir);
for (const file of files) {
fs.unlinkSync(path.join(tokenDir, file));
}
// Remove directory
fs.rmdirSync(tokenDir);
return true;
}
/**
* Trigger OAuth flow for provider
* Auto-detects headless environment and uses --no-browser flag accordingly
*/
export async function triggerOAuth(
provider: CLIProxyProvider,
options: { verbose?: boolean; headless?: boolean } = {}
): Promise<boolean> {
const oauthConfig = getOAuthConfig(provider);
const { verbose = false } = options;
// Auto-detect headless if not explicitly set
const headless = options.headless ?? isHeadlessEnvironment();
const log = (msg: string) => {
if (verbose) {
console.error(`[auth] ${msg}`);
}
};
// Ensure binary exists
let binaryPath: string;
try {
binaryPath = await ensureCLIProxyBinary(verbose);
} catch (error) {
console.error('[X] Failed to prepare CLIProxy binary');
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}`);
// Build args: config + auth flag + optional --no-browser for headless
const args = ['--config', configPath, oauthConfig.authFlag];
if (headless) {
args.push('--no-browser');
}
// Show appropriate message
console.log('');
if (headless) {
console.log(`[i] Headless mode detected - manual authentication required`);
console.log(`[i] ${oauthConfig.displayName} will display an OAuth URL below`);
console.log('');
} else {
console.log(`[i] Opening browser for ${oauthConfig.displayName} authentication...`);
console.log('[i] Complete the login in your browser.');
console.log('');
}
const spinner = new ProgressIndicator(`Authenticating with ${oauthConfig.displayName}`);
if (!headless) {
spinner.start();
}
return new Promise<boolean>((resolve) => {
// Spawn CLIProxyAPI with auth flag (and --no-browser if headless)
const authProcess = spawn(binaryPath, args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
CLI_PROXY_AUTH_DIR: tokenDir,
},
});
let stderrData = '';
let urlDisplayed = false;
authProcess.stdout?.on('data', (data: Buffer) => {
const output = data.toString();
log(`stdout: ${output.trim()}`);
// In headless mode, display OAuth URLs prominently
if (headless) {
const lines = output.split('\n');
for (const line of lines) {
// Look for URLs in the output
const urlMatch = line.match(/https?:\/\/[^\s]+/);
if (urlMatch && !urlDisplayed) {
console.log(` ${urlMatch[0]}`);
console.log('');
console.log('[i] Waiting for authentication... (press Ctrl+C to cancel)');
urlDisplayed = true;
}
}
}
});
authProcess.stderr?.on('data', (data: Buffer) => {
const output = data.toString();
stderrData += output;
log(`stderr: ${output.trim()}`);
// Also check stderr for URLs (some tools output there)
if (headless && !urlDisplayed) {
const urlMatch = output.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
console.log(` ${urlMatch[0]}`);
console.log('');
console.log('[i] Waiting for authentication... (press Ctrl+C to cancel)');
urlDisplayed = true;
}
}
});
// Timeout after 5 minutes for headless (user needs time to copy URL)
const timeoutMs = headless ? 300000 : 120000;
const timeout = setTimeout(() => {
if (!headless) spinner.fail('Authentication timeout');
authProcess.kill();
console.error(`[X] OAuth timed out after ${headless ? 5 : 2} minutes`);
console.error('');
if (!headless) {
console.error('Troubleshooting:');
console.error(' - Make sure a browser is available');
console.error(' - Try running with --verbose for details');
}
resolve(false);
}, timeoutMs);
authProcess.on('exit', (code) => {
clearTimeout(timeout);
if (code === 0) {
// Verify token was created BEFORE showing success
if (isAuthenticated(provider)) {
if (!headless) spinner.succeed(`Authenticated with ${oauthConfig.displayName}`);
console.log('[OK] Authentication successful');
resolve(true);
} else {
if (!headless) spinner.fail('Authentication incomplete');
console.error('[X] Token not found after authentication');
console.error(' The OAuth flow may have been cancelled or port 8085 was in use');
console.error(' Try: pkill -f cli-proxy-api && ccs gemini --auth');
resolve(false);
}
} else {
if (!headless) spinner.fail('Authentication failed');
console.error(`[X] CLIProxyAPI auth exited with code ${code}`);
if (stderrData && !urlDisplayed) {
console.error(` ${stderrData.trim().split('\n')[0]}`);
}
// Show headless hint if we detected headless environment
if (headless && !urlDisplayed) {
console.error('');
console.error('[i] No OAuth URL was displayed. Try with --verbose for details.');
}
resolve(false);
}
});
authProcess.on('error', (error) => {
clearTimeout(timeout);
if (!headless) spinner.fail('Authentication error');
console.error(`[X] Failed to start auth process: ${error.message}`);
resolve(false);
});
});
}
/**
* Ensure provider is authenticated
* Triggers OAuth flow if not authenticated
*/
export async function ensureAuth(
provider: CLIProxyProvider,
options: { verbose?: boolean; headless?: boolean } = {}
): Promise<boolean> {
// Check if already authenticated
if (isAuthenticated(provider)) {
if (options.verbose) {
console.error(`[auth] ${provider} already authenticated`);
}
return true;
}
// Not authenticated - trigger OAuth
const oauthConfig = getOAuthConfig(provider);
console.log(`[i] ${oauthConfig.displayName} authentication required`);
return triggerOAuth(provider, options);
}
/**
* 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');
}