Files
ccs/src/cliproxy/auth/oauth-process.ts
T
Tam Nhu Tran 641d492cd6 feat(cliproxy): add OAuth callback traceability across profiles
Introduces structured per-phase tracing for OAuth flows so users see
branch-specific error messages instead of a generic "token not found"
fallback.

- New oauth-trace/ module: recorder, redactor, three sinks
  (in-memory ring buffer, verbose stdout, opt-in JSONL file at
  ~/.ccs/logs/oauth-YYYYMMDD.log mode 0o600 via CCS_OAUTH_LOG_FILE=1)
- Branch-specific diagnostics for: URL-not-displayed,
  callback-not-observed, binary-error, token-exchange-error,
  session-expired, token-file-missing; UNKNOWN fallback preserves
  prior UX
- Trace recorder threaded through oauth-process.ts spawn/stdout/stderr/
  exit lifecycle; SIGINT/SIGTERM cleanup flushes recorder and records
  Cancelled
- Redactor strips code, state, access_token, refresh_token, id_token,
  client_secret, code_verifier, device_code, assertion, subject_token,
  plus Authorization: Bearer headers; covers URL fragments,
  URL-encoded keys, and arrays
- 68 new tests including adversarial regression cases for redactor
  bypass attempts

Phases for paste-callback path and cross-profile failure-matrix
deferred to follow-up #1207.

Closes #1206
2026-05-10 15:12:40 -04:00

1019 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, warn } from '../../utils/ui';
import { killWithEscalation } from '../../utils/process-utils';
import { tryKiroImport } from './kiro-import';
import { CLIProxyProvider } from '../types';
import { AccountInfo } from '../accounts/account-manager';
import {
parseProjectList,
parseDefaultProject,
isProjectSelectionPrompt,
isProjectList,
generateSessionId,
requestProjectSelection,
cancelProjectSelection,
type GCloudProject,
type ProjectSelectionPrompt,
} from '../auth/project-selection-handler';
import { KiroAuthMethod, ProviderOAuthConfig } from './auth-types';
import { getTimeoutTroubleshooting, showStep } from './environment-detector';
import {
type ProviderTokenSnapshot,
findNewTokenSnapshot,
listProviderTokenSnapshots,
registerAccountFromToken,
} from './token-manager';
import {
deviceCodeEvents,
DEVICE_CODE_TIMEOUT_MS,
type DeviceCodePrompt,
} from '../auth/device-code-handler';
import { OAUTH_FLOW_TYPES } from '../../management';
import {
registerAuthSession,
attachProcessToSession,
unregisterAuthSession,
authSessionEvents,
} from '../auth/auth-session-manager';
import { createOAuthTraceRecorder, OAuthTracePhase, type OAuthTraceRecorder } from './oauth-trace';
import { redactString } from './oauth-trace/redactor';
import { createFileSink } from './oauth-trace/sink-file';
import { diagnoseFailure, formatErrorMessage } from './oauth-trace/diagnose-failure';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-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;
expectedAccountId?: string;
authFlowType?: 'device_code' | 'authorization_code';
kiroMethod?: KiroAuthMethod;
manualCallback?: boolean;
}
/** Internal state for OAuth process */
interface ProcessState {
stderrData: string;
urlDisplayed: boolean;
browserOpened: boolean;
projectPromptHandled: boolean;
accumulatedOutput: string;
parsedProjects: GCloudProject[];
sessionId: string;
/** Device code displayed to user (for Device Code Flow) */
deviceCodeDisplayed: boolean;
/** The user code to enter at verification URL */
userCode: string | null;
kiroMethodSelectionHandled: boolean;
manualCallbackPrompted: boolean;
cancelManualCallbackPrompt: (() => void) | null;
}
/**
* 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');
}
}
function resolveAuthFlowType(options: OAuthProcessOptions): 'device_code' | 'authorization_code' {
return options.authFlowType || OAUTH_FLOW_TYPES[options.provider] || 'authorization_code';
}
export function isLoopbackHost(hostname: string): boolean {
const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase();
return (
normalized === '127.0.0.1' ||
normalized === 'localhost' ||
normalized === '::1' ||
normalized === '0:0:0:0:0:0:0:1'
);
}
export function getExpectedLocalCallback(authUrl: string): {
origin: string;
pathname: string;
state: string | null;
} | null {
try {
const parsedAuthUrl = new URL(authUrl);
const redirectUriRaw = parsedAuthUrl.searchParams.get('redirect_uri');
if (!redirectUriRaw) {
return null;
}
const redirectUri = new URL(redirectUriRaw);
if (!isLoopbackHost(redirectUri.hostname)) {
return null;
}
return {
origin: redirectUri.origin,
pathname: redirectUri.pathname,
state: parsedAuthUrl.searchParams.get('state'),
};
} catch {
return null;
}
}
export function validateManualCallbackUrl(callbackUrl: string, authUrl: string): string | null {
let parsedCallback: URL;
try {
parsedCallback = new URL(callbackUrl);
} catch {
return 'Invalid callback URL format';
}
if (!parsedCallback.searchParams.get('code')) {
return 'Invalid callback URL: missing code parameter';
}
const expectedCallback = getExpectedLocalCallback(authUrl);
if (!expectedCallback) {
return 'Unable to determine the expected local callback target';
}
if (!isLoopbackHost(parsedCallback.hostname)) {
return 'Callback URL must target the local OAuth callback server';
}
if (
parsedCallback.origin !== expectedCallback.origin ||
parsedCallback.pathname !== expectedCallback.pathname
) {
return 'Callback URL does not match the expected local OAuth callback target';
}
if (expectedCallback.state) {
const callbackState = parsedCallback.searchParams.get('state');
if (callbackState !== expectedCallback.state) {
return 'Callback URL state does not match the active OAuth session';
}
}
return null;
}
export function getKiroBuilderIdSelectionInput(output: string): string | null {
const promptMatch = /Select login method/i.exec(output);
if (!promptMatch || promptMatch.index === undefined) {
return null;
}
const promptWindow = output.slice(promptMatch.index, promptMatch.index + 600);
const optionMatch = /(?:^|\n)\s*(\d+)\s*[\).:-]?\s*.*\bBuilder ID\b/im.exec(promptWindow);
if (!optionMatch) {
return null;
}
return `${optionMatch[1]}\n`;
}
export function extractLikelyOAuthAuthorizationUrl(output: string): string | null {
const urls = Array.from(output.matchAll(/https?:\/\/[^\s]+/g), (match) => match[0]);
let selectedUrl: string | null = null;
let selectedScore = 0;
for (const url of urls) {
try {
const parsed = new URL(url);
let score = 0;
if (parsed.searchParams.has('redirect_uri')) score += 4;
if (parsed.searchParams.has('state')) score += 2;
if (parsed.searchParams.has('code_challenge')) score += 1;
if (parsed.pathname.includes('/authorize')) score += 1;
if (isLoopbackHost(parsed.hostname)) score -= 3;
if (score >= selectedScore && score > 0) {
selectedUrl = url;
selectedScore = score;
}
} catch {
continue;
}
}
return selectedUrl;
}
async function promptManualCallbackUrl(
displayName: string,
state: ProcessState,
timeoutMs: number
): Promise<string | null> {
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise<string | null>((resolve) => {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
const finish = (value: string | null) => {
if (settled) {
return;
}
settled = true;
if (timeout) {
clearTimeout(timeout);
}
state.cancelManualCallbackPrompt = null;
resolve(value);
};
state.cancelManualCallbackPrompt = () => {
if (!settled) {
rl.close();
finish(null);
}
};
rl.on('close', () => {
finish(null);
});
console.log('');
console.log(info(`${displayName} is waiting for the OAuth callback.`));
console.log('Paste the full callback URL after you finish the login in your browser.');
rl.question('> ', (answer) => {
rl.close();
finish(answer.trim() || null);
});
timeout = setTimeout(() => {
if (!settled) {
console.log('');
console.log(fail('Timed out waiting for callback URL'));
rl.close();
}
}, timeoutMs);
});
}
async function replayManualCallback(
oauthConfig: ProviderOAuthConfig,
authProcess: ChildProcess,
authUrl: string,
verbose: boolean,
state: ProcessState,
timeoutMs: number
): Promise<boolean> {
if (!authUrl.includes('http://') && !authUrl.includes('https://')) {
return false;
}
const callbackUrl = await promptManualCallbackUrl(oauthConfig.displayName, state, timeoutMs);
if (!callbackUrl) {
console.log(info('Cancelled'));
killWithEscalation(authProcess);
return true;
}
const validationError = validateManualCallbackUrl(callbackUrl, authUrl);
if (validationError) {
console.log(fail(validationError));
killWithEscalation(authProcess);
return true;
}
console.log(info('Replaying callback to the local auth server...'));
try {
const response = await fetch(callbackUrl);
if (!response.ok && response.status >= 400) {
console.log(fail(`OAuth callback failed with status ${response.status}`));
killWithEscalation(authProcess);
return true;
}
console.log(ok('Callback submitted. Waiting for token exchange...'));
} catch (error) {
if (verbose) {
console.log(fail(`Failed to replay callback: ${(error as Error).message}`));
} else {
console.log(fail('Failed to replay callback to the local auth server'));
}
killWithEscalation(authProcess);
}
return true;
}
/**
* 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;
const flowType = resolveAuthFlowType(options);
const isDeviceCodeFlow = flowType === 'device_code';
if (
options.provider === 'kiro' &&
options.kiroMethod === 'aws' &&
!state.kiroMethodSelectionHandled &&
state.accumulatedOutput.includes('Select login method')
) {
const builderIdSelection = getKiroBuilderIdSelectionInput(state.accumulatedOutput);
if (builderIdSelection) {
state.kiroMethodSelectionHandled = true;
authProcess.stdin?.write(builderIdSelection);
log(`Auto-selected Kiro Builder ID flow (${builderIdSelection.trim()})`);
}
}
// 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 (Authorization Code flows only - Device Code has no stdin pipe)
if (!isDeviceCodeFlow && !state.projectPromptHandled && isProjectSelectionPrompt(output)) {
state.projectPromptHandled = true;
await handleProjectSelection(output, state, options, authProcess, log);
}
// Handle Device Code Flow: parse and display user code
if (isDeviceCodeFlow && !state.deviceCodeDisplayed) {
// Parse device/user code from various formats:
// "Enter code: XXXX-YYYY" or "code XXXX-YYYY" or "user code: XXXX-YYYY"
const codeMatch = state.accumulatedOutput.match(
/(?:enter\s+)?(?:user\s+)?code[:\s]+["']?([A-Z0-9]{4,8}[-\s]?[A-Z0-9]{4,8})["']?/i
);
const urlMatch = state.accumulatedOutput.match(/(https?:\/\/[^\s]+device[^\s]*)/i);
if (codeMatch) {
state.userCode = codeMatch[1].toUpperCase();
state.deviceCodeDisplayed = true;
log(`Parsed device code: ${state.userCode}`);
const verificationUrl = urlMatch?.[1] || 'https://github.com/login/device';
// Emit device code event for WebSocket broadcast to UI
const deviceCodePrompt: DeviceCodePrompt = {
sessionId: state.sessionId,
provider: options.provider,
userCode: state.userCode,
verificationUrl,
expiresAt: Date.now() + DEVICE_CODE_TIMEOUT_MS,
};
deviceCodeEvents.emit('deviceCode:received', deviceCodePrompt);
// Display device code prominently in CLI
console.log('');
console.log(' ╔══════════════════════════════════════════════════════╗');
console.log(` ║ Enter this code: ${state.userCode.padEnd(35)}║`);
console.log(' ╚══════════════════════════════════════════════════════╝');
console.log('');
console.log(info(`Open: ${verificationUrl}`));
console.log('');
// Update step display for device code flow
process.stdout.write('\x1b[1A\x1b[2K');
showStep(2, 4, 'ok', 'Device code received');
showStep(3, 4, 'progress', 'Waiting for authorization...');
}
}
// Detect callback server / browser (for Authorization Code flows only)
if (
!isDeviceCodeFlow &&
!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 URL for all modes (enables VS Code terminal URL detection popup)
if (!isDeviceCodeFlow && !state.urlDisplayed) {
const authUrl = extractLikelyOAuthAuthorizationUrl(state.accumulatedOutput);
if (authUrl) {
console.log('');
console.log(info(`${options.oauthConfig.displayName} OAuth URL:`));
console.log(` ${authUrl}`);
console.log('');
state.urlDisplayed = true;
if (options.manualCallback && !state.manualCallbackPrompted) {
state.manualCallbackPrompted = true;
await replayManualCallback(
options.oauthConfig,
authProcess,
authUrl,
options.verbose,
state,
10 * 60 * 1000
);
}
}
}
}
/** Display OAuth URL from stderr if in headless mode */
function displayUrlFromStderr(
output: string,
state: ProcessState,
oauthConfig: ProviderOAuthConfig
): void {
const authUrl = extractLikelyOAuthAuthorizationUrl(output);
if (authUrl) {
console.log('');
console.log(info(`${oauthConfig.displayName} OAuth URL:`));
console.log(` ${authUrl}`);
console.log('');
state.urlDisplayed = true;
}
}
const ANSI_ESCAPE_REGEX = /\x1b\[[0-9;]*m/g;
export function extractLikelyAuthFailureFromLogs(
provider: CLIProxyProvider,
logData: string
): string | null {
if (!logData.trim()) {
return null;
}
const normalizedLines = logData
.split('\n')
.map((line) => line.replace(ANSI_ESCAPE_REGEX, '').trim())
.filter(Boolean)
.map((line) => {
const messageIndex = line.indexOf('msg="');
if (messageIndex >= 0) {
const message = line
.slice(messageIndex + 5)
.replace(/"$/, '')
.trim();
if (message) {
return message;
}
}
return line;
});
const providerPatterns: Partial<Record<CLIProxyProvider, RegExp[]>> = {
ghcp: [
/github copilot authentication failed:\s*(.+)/i,
/failed to verify copilot access[^:]*:\s*(.+)/i,
],
kiro: [
/kiro idc authentication failed:\s*(.+)/i,
/kiro authentication failed:\s*(.+)/i,
/login failed:\s*(.+)/i,
/failed to register client:\s*(.+)/i,
],
};
const prioritizedPatterns = [
...(providerPatterns[provider] || []),
/^authentication failed:\s*(.+)/i,
/^failed to save auth:\s*(.+)/i,
];
for (let i = normalizedLines.length - 1; i >= 0; i--) {
const line = normalizedLines[i];
for (const pattern of prioritizedPatterns) {
const match = line.match(pattern);
if (match?.[1]?.trim()) {
return match[1].trim().slice(0, 240);
}
}
}
return null;
}
export function extractLikelyAuthFailureFromStderr(
provider: CLIProxyProvider,
stderrData: string
): string | null {
return extractLikelyAuthFailureFromLogs(provider, stderrData);
}
export function analyzeSuccessfulAuthExit(options: {
provider: CLIProxyProvider;
knownTokenFiles: ProviderTokenSnapshot[];
currentTokenFiles: ProviderTokenSnapshot[];
expectedAccountId?: string;
stdoutData: string;
stderrData: string;
}): { tokenSnapshot: ProviderTokenSnapshot | null; failureReason: string | null } {
const tokenSnapshot = findNewTokenSnapshot(
options.currentTokenFiles,
options.knownTokenFiles,
options.expectedAccountId
);
const failureReason = extractLikelyAuthFailureFromLogs(
options.provider,
[options.stdoutData, options.stderrData].filter(Boolean).join('\n')
);
return { tokenSnapshot, failureReason };
}
/** Handle token not found after successful process exit */
async function handleTokenNotFound(
provider: CLIProxyProvider,
callbackPort: number | null,
tokenDir: string,
nickname: string | undefined,
expectedAccountId: string | undefined,
verbose: boolean,
failureReason?: string,
trace?: OAuthTraceRecorder
): Promise<AccountInfo | null> {
console.log('');
if (failureReason) {
// Sanitize internal URLs/paths from failure reason to avoid leaking infrastructure details
const sanitizedReason = failureReason
.replace(/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)[^\s]*/gi, '[internal-url]')
.replace(/\/(?:root|home|opt|tmp|var)\/[^\s]*/g, '[path]');
console.log(fail('Authentication failed before a usable token was saved'));
console.log(` ${sanitizedReason}`);
console.log('');
console.log(`Try: ccs ${provider} --auth --verbose`);
return null;
}
// Kiro-specific: Try auto-import from Kiro IDE
if (provider === 'kiro') {
console.log(warn('Callback redirected to Kiro IDE. Attempting to import token...'));
const result = await tryKiroImport(tokenDir, verbose);
if (result.success) {
const providerInfo = result.provider ? ` (Provider: ${result.provider})` : '';
console.log(ok(`Imported Kiro token from IDE${providerInfo}`));
return registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId);
}
console.log(fail(`Auto-import failed: ${result.error}`));
console.log('');
console.log('To manually import from Kiro IDE:');
console.log(' 1. Ensure you are logged into Kiro IDE');
console.log(' 2. Run: ccs kiro --import');
return null;
}
// Branch-specific diagnosis when trace recorder is wired (Phase 7).
if (trace) {
const diagnosis = diagnoseFailure(trace.snapshot());
if (diagnosis.branchId !== 'UNKNOWN') {
console.log(fail('Authentication failed'));
const lines = formatErrorMessage(diagnosis, {
verbose,
platform: process.platform,
callbackPort,
provider,
});
for (const line of lines) console.log(line);
return null;
}
}
console.log(fail('Token not found after authentication'));
console.log('');
console.log('The browser showed success but callback was not received.');
console.log('');
console.log('Common causes:');
console.log(' 1. OAuth session timed out (sessions expire after ~10 minutes)');
console.log(' 2. Callback server could not receive the redirect');
console.log(' 3. Browser did not redirect to localhost properly');
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('If you copied the OAuth URL to a different browser:');
console.log(' - Complete authentication within the timeout window');
console.log(' - Ensure you are on the same machine (localhost callback)');
console.log(' - Copy the entire URL including all parameters');
console.log('');
console.log(`Try: ccs ${provider} --auth --verbose`);
return null;
}
/** Handle process exit with error */
function handleProcessError(code: number | null, state: ProcessState, headless: boolean): void {
console.log('');
console.log(fail(`CLIProxy 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,
expectedAccountId,
} = options;
const log = (msg: string) => {
if (verbose) console.error(`[auth] ${msg}`);
};
return new Promise<AccountInfo | null>((resolve) => {
const flowType = resolveAuthFlowType(options);
const isDeviceCodeFlow = flowType === 'device_code';
const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir);
// Device-code flows can usually inherit stdin, but Kiro's default AWS flow now
// prints an intermediate Builder ID vs IDC selector that CCS auto-answers.
const stdinMode =
isDeviceCodeFlow &&
process.stdin.isTTY &&
!(provider === 'kiro' && options.kiroMethod === 'aws')
? 'inherit'
: 'pipe';
const authProcess = spawn(binaryPath, args, {
stdio: [stdinMode, 'pipe', 'pipe'],
env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir },
});
// H7: Mutable ref for stdin keepalive interval (set later, needed in cleanup)
let stdinKeepalive: ReturnType<typeof setInterval> | null = null;
// Mutable ref so cleanup/handleCancel can flush trace even though `trace`
// is assigned after them. DEFERRED: per-event syscall throttling.
let traceRef: OAuthTraceRecorder | null = null;
// H5: Signal handling - properly kill child process on SIGINT/SIGTERM
// H8: Also clear stdinKeepalive interval to prevent memory leak
const cleanup = () => {
if (stdinKeepalive) clearInterval(stdinKeepalive);
if (traceRef) {
try {
traceRef.record(OAuthTracePhase.Cancelled, { reason: 'signal' });
void traceRef.flush();
} catch {
// never block shutdown on trace errors
}
}
if (authProcess && authProcess.exitCode === null) {
killWithEscalation(authProcess);
}
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
const state: ProcessState = {
stderrData: '',
urlDisplayed: false,
browserOpened: false,
projectPromptHandled: false,
accumulatedOutput: '',
parsedProjects: [],
sessionId: generateSessionId(),
deviceCodeDisplayed: false,
userCode: null,
kiroMethodSelectionHandled: false,
manualCallbackPrompted: false,
cancelManualCallbackPrompt: null,
};
// OAuth trace recorder — opt-in file sink via CCS_OAUTH_LOG_FILE=1
const fileSink =
process.env['CCS_OAUTH_LOG_FILE'] === '1'
? createFileSink({ dir: path.join(getCcsDir(), 'logs') })
: undefined;
const trace = createOAuthTraceRecorder({
sessionId: state.sessionId,
provider,
verbose,
fileSink,
});
traceRef = trace; // wire late-binding ref for cleanup/handleCancel
trace.record(OAuthTracePhase.BinarySpawn, {
callbackPort,
headless,
manualCallback: !!options.manualCallback,
flowType,
});
// Register session for cancellation support
registerAuthSession(state.sessionId, provider);
attachProcessToSession(state.sessionId, authProcess);
// Listen for external cancel signal
const handleCancel = (cancelledSessionId: string) => {
if (cancelledSessionId === state.sessionId && authProcess && authProcess.exitCode === null) {
log('Session cancelled externally');
if (traceRef) {
try {
traceRef.record(OAuthTracePhase.Cancelled, { reason: 'external' });
void traceRef.flush();
} catch {
// never block shutdown on trace errors
}
}
killWithEscalation(authProcess);
}
};
authSessionEvents.on('session:cancelled', handleCancel);
const startTime = Date.now();
// H7: Stdin keepalive for Authorization Code flows
// CLIProxyAPIPlus has a 15-second timer that prompts for manual URL paste.
// If the user completes browser auth after this timer fires but before the
// non-blocking check, the prompt blocks forever on stdin.
// Workaround: Send newline every 16s to skip the manual prompt and continue polling.
if (!isDeviceCodeFlow && stdinMode === 'pipe') {
stdinKeepalive = setInterval(() => {
if (authProcess.stdin && !authProcess.stdin.destroyed) {
authProcess.stdin.write('\n');
log('Sent stdin keepalive (skip manual URL prompt)');
}
}, 16000);
}
authProcess.stdout?.on('data', async (data: Buffer) => {
const out = data.toString();
const wasUrlDisplayed = state.urlDisplayed;
const wasBrowserOpened = state.browserOpened;
await handleStdout(out, state, options, authProcess, log);
if (!wasUrlDisplayed && state.urlDisplayed) {
trace.record(OAuthTracePhase.AuthUrlDisplayed, {});
}
if (!wasBrowserOpened && state.browserOpened) {
trace.record(OAuthTracePhase.BrowserOpened, { port: callbackPort });
// CLIProxyAPI emits "Callback server listening" when bind succeeds; treat as
// best-available signal that the loopback path is reachable. This is a heuristic.
trace.record(OAuthTracePhase.CallbackObservedHeuristic, { port: callbackPort });
}
// Capture redacted snippet (cap at 200 chars; high-frequency lines accepted as data).
const snippet = redactString(out.trim().slice(0, 200));
if (snippet) trace.record(OAuthTracePhase.BinaryStdout, { snippet });
});
authProcess.stderr?.on('data', async (data: Buffer) => {
const output = data.toString();
state.stderrData += output;
log(`stderr: ${output.trim()}`);
const snippet = redactString(output.trim().slice(0, 200));
if (snippet) trace.record(OAuthTracePhase.BinaryStderr, { snippet });
if (headless && !state.urlDisplayed) {
displayUrlFromStderr(output, state, oauthConfig);
}
if (options.manualCallback && !state.manualCallbackPrompted) {
const authUrl =
extractLikelyOAuthAuthorizationUrl(output) ?? output.match(/https?:\/\/[^\s]+/)?.[0];
if (authUrl) {
state.manualCallbackPrompted = true;
await replayManualCallback(
options.oauthConfig,
authProcess,
authUrl,
options.verbose,
state,
10 * 60 * 1000
);
}
}
});
// Show waiting message after delay
setTimeout(() => {
if (isDeviceCodeFlow) {
// Device Code Flow: show polling message
if (!state.deviceCodeDisplayed) {
// Code not yet displayed, show generic waiting message
showStep(3, 4, 'progress', 'Waiting for device code...');
}
showStep(4, 4, 'progress', 'Polling for authorization...');
console.log('');
console.log(
info('Complete the login in your browser. This page will update automatically.')
);
} else {
// Authorization Code Flow: show callback server message
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
// Device code flows need longer timeout to match CLIProxy binary's polling window (60 attempts × 5s = 300s)
const timeoutMs = options.manualCallback
? 10 * 60 * 1000
: headless || isDeviceCodeFlow
? 300000
: 120000;
const timeout = setTimeout(() => {
// H7: Clear stdin keepalive interval
if (stdinKeepalive) clearInterval(stdinKeepalive);
state.cancelManualCallbackPrompt?.();
// H5: Remove signal handlers before killing process
process.removeListener('SIGINT', cleanup);
process.removeListener('SIGTERM', cleanup);
authSessionEvents.removeListener('session:cancelled', handleCancel);
unregisterAuthSession(state.sessionId);
cancelProjectSelection(state.sessionId);
trace.record(OAuthTracePhase.Timeout, { timeoutMs });
void trace.flush();
killWithEscalation(authProcess);
console.log('');
console.log(fail(`OAuth timed out after ${timeoutMs / 60000} minutes`));
for (const line of getTimeoutTroubleshooting(provider, callbackPort ?? null)) {
console.log(line);
}
resolve(null);
}, timeoutMs);
authProcess.on('exit', async (code) => {
clearTimeout(timeout);
// H7: Clear stdin keepalive interval
if (stdinKeepalive) clearInterval(stdinKeepalive);
state.cancelManualCallbackPrompt?.();
// H5: Remove signal handlers to prevent memory leaks
process.removeListener('SIGINT', cleanup);
process.removeListener('SIGTERM', cleanup);
authSessionEvents.removeListener('session:cancelled', handleCancel);
unregisterAuthSession(state.sessionId);
cancelProjectSelection(state.sessionId);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
trace.record(OAuthTracePhase.BinaryExit, {
code,
stderrTail: redactString(state.stderrData.trim().split('\n').pop() ?? ''),
});
if (code === 0) {
const exitAnalysis = analyzeSuccessfulAuthExit({
provider,
knownTokenFiles,
currentTokenFiles: listProviderTokenSnapshots(provider, tokenDir),
expectedAccountId,
stdoutData: state.accumulatedOutput,
stderrData: state.stderrData,
});
if (exitAnalysis.tokenSnapshot) {
trace.record(OAuthTracePhase.TokenFileAppeared, {});
await trace.flush();
console.log('');
console.log(ok(`Authentication successful (${elapsed}s)`));
// Emit device code completion event for UI
if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
deviceCodeEvents.emit('deviceCode:completed', state.sessionId);
}
resolve(
registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId)
);
} else {
// Emit device code failure event for UI
if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
deviceCodeEvents.emit('deviceCode:failed', {
sessionId: state.sessionId,
error: exitAnalysis.failureReason || 'Token not found after authentication',
});
}
trace.record(OAuthTracePhase.TokenFileMissing, {});
await trace.flush();
// Try auto-import for Kiro, show error for others
const account = await handleTokenNotFound(
provider,
callbackPort,
tokenDir,
nickname,
expectedAccountId,
verbose,
exitAnalysis.failureReason || undefined,
trace
);
resolve(account);
}
} else {
// Emit device code failure event for UI
if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
deviceCodeEvents.emit('deviceCode:failed', {
sessionId: state.sessionId,
error: `Auth process exited with code ${code}`,
});
}
handleProcessError(code, state, headless);
resolve(null);
}
});
authProcess.on('error', (error) => {
clearTimeout(timeout);
// H7: Clear stdin keepalive interval
if (stdinKeepalive) clearInterval(stdinKeepalive);
state.cancelManualCallbackPrompt?.();
// H5: Remove signal handlers to prevent memory leaks
process.removeListener('SIGINT', cleanup);
process.removeListener('SIGTERM', cleanup);
authSessionEvents.removeListener('session:cancelled', handleCancel);
unregisterAuthSession(state.sessionId);
cancelProjectSelection(state.sessionId);
trace.record(OAuthTracePhase.Error, {}, error);
void trace.flush();
console.log('');
console.log(fail(`Failed to start auth process: ${error.message}`));
resolve(null);
});
});
}