mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 22:16:41 +00:00
348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
/**
|
|
* OAuth Handler for CLIProxyAPI
|
|
*
|
|
* Manages OAuth authentication flow for CLIProxy providers (Gemini, Codex, Antigravity, Kiro, Copilot).
|
|
* 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
|
|
* 5. Handle Device Code flows for Copilot/Qwen (no callback server)
|
|
*/
|
|
|
|
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,
|
|
PROVIDERS_WITHOUT_EMAIL,
|
|
validateNickname,
|
|
} 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, registerAccountFromToken } from './token-manager';
|
|
import { executeOAuthProcess } from './oauth-process';
|
|
import { importKiroToken } from './kiro-import';
|
|
|
|
/**
|
|
* 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');
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Prompt user for account nickname (required for kiro/ghcp)
|
|
* Returns null if user cancels
|
|
*/
|
|
async function promptNickname(
|
|
provider: CLIProxyProvider,
|
|
existingAccounts: AccountInfo[]
|
|
): Promise<string | null> {
|
|
const readline = await import('readline');
|
|
const rl = readline.createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout,
|
|
});
|
|
|
|
const existingNicknames = existingAccounts.map(
|
|
(a) => a.nickname?.toLowerCase() || a.id.toLowerCase()
|
|
);
|
|
|
|
console.log('');
|
|
console.log(info(`${provider} accounts require a unique nickname to distinguish them.`));
|
|
if (existingNicknames.length > 0) {
|
|
console.log(` Existing: ${existingNicknames.join(', ')}`);
|
|
}
|
|
|
|
return new Promise<string | null>((resolve) => {
|
|
let resolved = false;
|
|
|
|
// Handle Ctrl+C gracefully (only if not already resolved)
|
|
rl.on('close', () => {
|
|
if (!resolved) {
|
|
resolved = true;
|
|
resolve(null);
|
|
}
|
|
});
|
|
|
|
const askForNickname = () => {
|
|
rl.question('[?] Enter a nickname for this account: ', (answer) => {
|
|
const nickname = answer.trim();
|
|
|
|
if (!nickname) {
|
|
console.log(fail('Nickname cannot be empty'));
|
|
askForNickname();
|
|
return;
|
|
}
|
|
|
|
const validationError = validateNickname(nickname);
|
|
if (validationError) {
|
|
console.log(fail(validationError));
|
|
askForNickname();
|
|
return;
|
|
}
|
|
|
|
if (existingNicknames.includes(nickname.toLowerCase())) {
|
|
console.log(fail(`Nickname "${nickname}" is already in use. Choose a different one.`));
|
|
askForNickname();
|
|
return;
|
|
}
|
|
|
|
resolved = true;
|
|
rl.close();
|
|
resolve(nickname);
|
|
});
|
|
};
|
|
|
|
askForNickname();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* Handles both Authorization Code (callback server) and Device Code (polling) flows
|
|
*/
|
|
export async function triggerOAuth(
|
|
provider: CLIProxyProvider,
|
|
options: OAuthOptions = {}
|
|
): Promise<AccountInfo | null> {
|
|
const oauthConfig = getOAuthConfig(provider);
|
|
const { verbose = false, add = false, fromUI = false, noIncognito = true } = options;
|
|
let { nickname } = options;
|
|
|
|
// Check for existing accounts
|
|
const existingAccounts = getProviderAccounts(provider);
|
|
|
|
// For kiro/ghcp: require nickname if not provided (CLI only, not fromUI)
|
|
if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !nickname && !fromUI) {
|
|
const promptedNickname = await promptNickname(provider, existingAccounts);
|
|
if (!promptedNickname) {
|
|
console.log(info('Cancelled'));
|
|
return null;
|
|
}
|
|
nickname = promptedNickname;
|
|
}
|
|
|
|
// Handle --import flag: skip OAuth and import from Kiro IDE directly
|
|
if (options.import && provider === 'kiro') {
|
|
const tokenDir = getProviderTokenDir(provider);
|
|
const success = await importKiroToken(verbose);
|
|
if (success) {
|
|
return registerAccountFromToken(provider, tokenDir, nickname);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const callbackPort = OAUTH_PORTS[provider];
|
|
const isCLI = !fromUI;
|
|
const headless = options.headless ?? isHeadlessEnvironment();
|
|
const isDeviceCodeFlow = callbackPort === null;
|
|
|
|
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 (skip for device code flows which don't need callback ports)
|
|
if (!isDeviceCodeFlow && !(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 (only for authorization code flows)
|
|
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');
|
|
}
|
|
// Kiro-specific: --no-incognito to use normal browser (saves login credentials)
|
|
if (provider === 'kiro' && noIncognito) {
|
|
args.push('--no-incognito');
|
|
}
|
|
|
|
// Show step based on flow type
|
|
if (isDeviceCodeFlow) {
|
|
showStep(2, 4, 'progress', `Starting ${oauthConfig.displayName} Device Code flow...`);
|
|
console.log('');
|
|
console.log(info('Device Code Flow - follow the instructions below'));
|
|
} else {
|
|
showStep(2, 4, 'progress', `Starting callback server on port ${callbackPort}...`);
|
|
|
|
// Show headless instructions (only for authorization code flows)
|
|
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
|
|
const account = await executeOAuthProcess({
|
|
provider,
|
|
binaryPath,
|
|
args,
|
|
tokenDir,
|
|
oauthConfig,
|
|
callbackPort,
|
|
headless,
|
|
verbose,
|
|
isCLI,
|
|
nickname,
|
|
});
|
|
|
|
// Show hint for Kiro users about --no-incognito option (first-time auth only)
|
|
if (account && provider === 'kiro' && !noIncognito) {
|
|
console.log('');
|
|
console.log(info('Tip: To save your AWS login credentials for future sessions:'));
|
|
console.log(' Use: ccs kiro --no-incognito');
|
|
console.log(' Or enable "Kiro: Use normal browser" in: ccs config');
|
|
}
|
|
|
|
return account;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|