diff --git a/src/cliproxy/auth/__tests__/oauth-diagnose-failure.test.ts b/src/cliproxy/auth/__tests__/oauth-diagnose-failure.test.ts index 12efb200..25ce783b 100644 --- a/src/cliproxy/auth/__tests__/oauth-diagnose-failure.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-diagnose-failure.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; +import { OAUTH_CONFIGS } from '../auth-types'; import { diagnoseFailure, formatErrorMessage } from '../oauth-trace/diagnose-failure'; import { OAuthTracePhase, type OAuthTraceEvent } from '../oauth-trace/trace-events'; @@ -195,3 +196,87 @@ describe('formatErrorMessage', () => { expect(lines.some((l) => l.includes('--verbose'))).toBe(true); }); }); + +describe('cross-profile OAuth failure matrix', () => { + const providers = Object.keys(OAUTH_CONFIGS); + const matrix = [ + { + name: 'url-not-displayed', + expectedBranch: 'URL_NOT_DISPLAYED', + snapshot: () => [ + ev(OAuthTracePhase.BinarySpawn), + ev(OAuthTracePhase.BinaryExit, { data: { code: 0 } }), + ], + hint: '--verbose', + }, + { + name: 'callback-not-observed', + expectedBranch: 'CALLBACK_NEVER_OBSERVED', + snapshot: () => [ + ev(OAuthTracePhase.AuthUrlDisplayed), + ev(OAuthTracePhase.BrowserOpened), + ev(OAuthTracePhase.BinaryExit, { data: { code: 0 } }), + ], + hint: '--no-browser', + }, + { + name: 'binary-error', + expectedBranch: 'BINARY_ERROR_EXIT', + snapshot: () => [ + ev(OAuthTracePhase.BinarySpawn), + ev(OAuthTracePhase.BinaryExit, { data: { code: 2, stderrTail: 'boom' } }), + ], + hint: '--verbose', + }, + { + name: 'token-exchange-error', + expectedBranch: 'TOKEN_EXCHANGE_REJECTED', + snapshot: () => [ + ev(OAuthTracePhase.PasteCallbackSubmitted), + ev(OAuthTracePhase.Error, { + error: { code: 'CALLBACK_REJECTED', message: 'invalid_grant' }, + }), + ], + hint: '--verbose', + }, + { + name: 'session-expired', + expectedBranch: 'TIMEOUT', + snapshot: () => [ + ev(OAuthTracePhase.PasteCallbackPrompted), + ev(OAuthTracePhase.Timeout, { data: { timeoutMs: 600000 } }), + ], + hint: '--auth', + }, + { + name: 'token-file-missing', + expectedBranch: 'TOKEN_FILE_MISSING_POST_EXIT', + snapshot: () => [ + ev(OAuthTracePhase.AuthUrlDisplayed), + ev(OAuthTracePhase.BrowserOpened), + ev(OAuthTracePhase.CallbackObservedHeuristic), + ev(OAuthTracePhase.BinaryExit, { data: { code: 0 } }), + ev(OAuthTracePhase.TokenFileMissing), + ], + hint: 'ccs update', + }, + ] as const; + + test.each(providers.flatMap((provider) => matrix.map((scenario) => ({ provider, scenario }))))( + '$provider $scenario.name emits branch identifier and remediation hint', + ({ provider, scenario }) => { + reset(); + const diagnosis = diagnoseFailure(scenario.snapshot()); + expect(diagnosis.branchId).toBe(scenario.expectedBranch); + + const message = formatErrorMessage(diagnosis, { + verbose: false, + platform: 'linux', + callbackPort: 1455, + provider, + }).join('\n'); + expect(message).toContain(scenario.hint); + expect(message.includes(`ccs ${provider}`) || message.includes('ccs update')).toBe(true); + } + ); +}); diff --git a/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts index 82b83ca0..9bf3436b 100644 --- a/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts @@ -5,6 +5,8 @@ import * as path from 'path'; import type { ProxyTarget } from '../../proxy/proxy-target-resolver'; import { InteractivePrompt } from '../../../utils/prompt'; import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../../../tests/mocks'; +import { createOAuthTraceRecorder } from '../oauth-trace/trace-recorder'; +import { OAuthTracePhase } from '../oauth-trace/trace-events'; const remoteTarget: ProxyTarget = { host: 'proxy.example.com', @@ -291,6 +293,113 @@ describe('resolvePasteCallbackAuthUrl', () => { }); }); +describe('handlePasteCallbackMode traceability', () => { + it('records paste-callback lifecycle events and redacts callback secrets on exchange rejection', async () => { + mockFetch([ + { + url: /\/v0\/management\/codex-auth-url\?is_webui=true$/, + response: { + auth_url: + 'https://auth.example.com/authorize?client_id=public&state=upstream-state-secret', + state: 'upstream-state-secret', + }, + }, + { + url: /\/v0\/management\/oauth-callback$/, + response: { status: 'error', error: 'invalid_grant: expired code' }, + status: 400, + }, + ]); + + const trace = createOAuthTraceRecorder({ + sessionId: 'paste-test', + provider: 'codex', + verbose: false, + }); + const { handlePasteCallbackMode } = await import( + `../oauth-handler?paste-trace-rejected=${Date.now()}` + ); + + const result = await handlePasteCallbackMode( + 'codex', + { provider: 'codex', displayName: 'Codex', authUrl: '', scopes: [], authFlag: '' }, + false, + fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-paste-trace-rejected-')), + undefined, + undefined, + { + trace, + promptForCallbackUrl: async () => + 'http://localhost:1455/callback?code=oauth-code-secret&state=upstream-state-secret', + } + ); + + expect(result).toBeNull(); + expect(trace.snapshot().map((event) => event.phase)).toEqual([ + OAuthTracePhase.AuthUrlDisplayed, + OAuthTracePhase.PasteCallbackPrompted, + OAuthTracePhase.PasteCallbackReceived, + OAuthTracePhase.PasteCallbackSubmitted, + OAuthTracePhase.Error, + ]); + expect(JSON.stringify(trace.snapshot())).not.toContain('oauth-code-secret'); + expect(JSON.stringify(trace.snapshot())).not.toContain('upstream-state-secret'); + }); + + it('records token exchange and token-file-missing when callback succeeds without a local token', async () => { + mockFetch([ + { + url: /\/v0\/management\/codex-auth-url\?is_webui=true$/, + response: { + auth_url: 'https://auth.example.com/authorize?client_id=public&state=state-123', + state: 'state-123', + }, + }, + { + url: /\/v0\/management\/oauth-callback$/, + response: { status: 'ok' }, + }, + { + url: /\/v0\/management\/get-auth-status\?state=state-123$/, + response: { status: 'ok' }, + }, + ]); + + const trace = createOAuthTraceRecorder({ + sessionId: 'paste-test-missing-token', + provider: 'codex', + verbose: false, + }); + const { handlePasteCallbackMode } = await import( + `../oauth-handler?paste-trace-token-missing=${Date.now()}` + ); + + const result = await handlePasteCallbackMode( + 'codex', + { provider: 'codex', displayName: 'Codex', authUrl: '', scopes: [], authFlag: '' }, + false, + fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-paste-trace-token-missing-')), + undefined, + undefined, + { + trace, + promptForCallbackUrl: async () => + 'http://localhost:1455/callback?code=oauth-code-secret&state=state-123', + timeoutMs: 5, + pollIntervalMs: 1, + } + ); + + expect(result).toBeNull(); + expect(trace.snapshot().map((event) => event.phase)).toContain( + OAuthTracePhase.TokenExchangePending + ); + expect(trace.snapshot().map((event) => event.phase)).toContain( + OAuthTracePhase.TokenFileMissing + ); + }); +}); + describe('findNewTokenSnapshotForManualAuth', () => { it('detects newly created provider token files', async () => { const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-kiro-manual-auth-')); diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index bde903b3..2622d8ff 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -11,6 +11,7 @@ */ import * as fs from 'fs'; +import * as path from 'path'; import { fail, info, warn, color, ok } from '../../utils/ui'; import { createLogger } from '../../services/logging'; import { ensureCLIProxyBinary, getStoredConfiguredBackend } from '../binary-manager'; @@ -76,6 +77,11 @@ import { } from '../accounts/account-safety'; import { ensureCliAntigravityResponsibility } from '../auth/antigravity-responsibility'; import { InteractivePrompt } from '../../utils/prompt'; +import { getCcsDir } from '../../utils/config-manager'; +import { generateSessionId } from './project-selection-handler'; +import { createFileSink } from './oauth-trace/sink-file'; +import { createOAuthTraceRecorder, OAuthTracePhase, type OAuthTraceRecorder } from './oauth-trace'; +import { diagnoseFailure, formatErrorMessage } from './oauth-trace/diagnose-failure'; interface PasteCallbackStartData { url?: string; @@ -84,6 +90,13 @@ interface PasteCallbackStartData { status?: string; } +interface PasteCallbackTraceOptions { + trace?: OAuthTraceRecorder; + promptForCallbackUrl?: (timeoutMs: number) => Promise; + timeoutMs?: number; + pollIntervalMs?: number; +} + const PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS = 3000; const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000; const GEMINI_PLUS_CLIENT_ID_ENV = 'CLIPROXY_GEMINI_OAUTH_CLIENT_ID'; @@ -641,7 +654,81 @@ export function usesKiroLocalCallbackReplay( * Handle paste-callback mode: show auth URL, prompt for callback paste * Uses proxy target resolver to connect to correct CLIProxyAPI instance (local or remote) */ -async function handlePasteCallbackMode( +function createPasteCallbackTraceRecorder( + provider: CLIProxyProvider, + verbose: boolean +): OAuthTraceRecorder { + const fileSink = + process.env['CCS_OAUTH_LOG_FILE'] === '1' + ? createFileSink({ dir: path.join(getCcsDir(), 'logs') }) + : undefined; + return createOAuthTraceRecorder({ + sessionId: generateSessionId(), + provider, + verbose, + fileSink, + }); +} + +async function promptForPasteCallbackUrl(timeoutMs: number): Promise { + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + let resolved = false; + + rl.on('close', () => { + if (!resolved) { + resolved = true; + resolve(null); + } + }); + + console.log(info('After completing authentication, paste the callback URL here:')); + rl.question('> ', (answer) => { + resolved = true; + rl.close(); + resolve(answer.trim() || null); + }); + + setTimeout(() => { + if (!resolved) { + resolved = true; + rl.close(); + console.log(''); + console.log( + fail(`Timed out waiting for callback URL (${Math.round(timeoutMs / 60000)} minutes)`) + ); + resolve(null); + } + }, timeoutMs); + }); +} + +function printPasteCallbackTraceDiagnosis( + trace: OAuthTraceRecorder, + provider: CLIProxyProvider, + verbose: boolean +): void { + const diagnosis = diagnoseFailure(trace.snapshot()); + if (diagnosis.branchId === 'UNKNOWN') { + return; + } + const callbackPort = OAUTH_PORTS[provider] ?? null; + for (const line of formatErrorMessage(diagnosis, { + verbose, + platform: process.platform, + callbackPort, + provider, + })) { + console.log(line); + } +} + +export async function handlePasteCallbackMode( provider: CLIProxyProvider, oauthConfig: ProviderOAuthConfig, verbose: boolean, @@ -652,12 +739,14 @@ async function handlePasteCallbackMode( kiroMethod?: OAuthOptions['kiroMethod']; gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl']; add?: boolean; - } + } & PasteCallbackTraceOptions ): Promise { // Resolve CLIProxyAPI target (local or remote based on config) const target = getProxyTarget(); // OAuth state timeout (10 minutes, matches CLIProxyAPI state TTL) - const OAUTH_STATE_TIMEOUT_MS = 10 * 60 * 1000; + const OAUTH_STATE_TIMEOUT_MS = options?.timeoutMs ?? 10 * 60 * 1000; + const pollIntervalMs = options?.pollIntervalMs ?? PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS; + const trace = options?.trace ?? createPasteCallbackTraceRecorder(provider, verbose); console.log(''); console.log(info(`Starting ${oauthConfig.displayName} OAuth (paste-callback mode)...`)); @@ -694,19 +783,37 @@ async function handlePasteCallbackMode( } } warnPossible403Ban(provider, startError); + trace.record(OAuthTracePhase.Error, { startPath }, { message: startError }); + await trace.flush(); + printPasteCallbackTraceDiagnosis(trace, provider, verbose); return null; } - const authUrl = await resolvePasteCallbackAuthUrl(target, startData, OAUTH_STATE_TIMEOUT_MS); + const authUrl = await resolvePasteCallbackAuthUrl( + target, + startData, + OAUTH_STATE_TIMEOUT_MS, + pollIntervalMs + ); if (!authUrl) { console.log(fail('No authorization URL received')); + trace.record(OAuthTracePhase.Error, {}, { message: 'No authorization URL received' }); + await trace.flush(); + printPasteCallbackTraceDiagnosis(trace, provider, verbose); return null; } const authUrlCredentialError = getGeminiAuthUrlCredentialError(provider, authUrl); if (authUrlCredentialError) { console.log(fail(authUrlCredentialError)); + trace.record( + OAuthTracePhase.Error, + {}, + { code: 'GEMINI_PLUS_MISSING_CRED', message: authUrlCredentialError } + ); + await trace.flush(); + printPasteCallbackTraceDiagnosis(trace, provider, verbose); return null; } @@ -721,47 +828,22 @@ async function handlePasteCallbackMode( console.log(''); console.log(` ${authUrl}`); console.log(''); + trace.record(OAuthTracePhase.AuthUrlDisplayed, { authUrl }); // Prompt for callback URL - const readline = await import('readline'); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - const callbackUrl = await new Promise((resolve) => { - let resolved = false; - - rl.on('close', () => { - if (!resolved) { - resolved = true; - resolve(null); - } - }); - - console.log(info('After completing authentication, paste the callback URL here:')); - rl.question('> ', (answer) => { - resolved = true; - rl.close(); - resolve(answer.trim() || null); - }); - - // Timeout after 10 minutes (match state TTL) - setTimeout(() => { - if (!resolved) { - resolved = true; - rl.close(); - console.log(''); - console.log(fail('Timed out waiting for callback URL (10 minutes)')); - resolve(null); - } - }, OAUTH_STATE_TIMEOUT_MS); - }); + trace.record(OAuthTracePhase.PasteCallbackPrompted, { timeoutMs: OAUTH_STATE_TIMEOUT_MS }); + const callbackUrl = await (options?.promptForCallbackUrl ?? promptForPasteCallbackUrl)( + OAUTH_STATE_TIMEOUT_MS + ); if (!callbackUrl) { console.log(info('Cancelled')); + trace.record(OAuthTracePhase.Cancelled, { reason: 'empty_callback' }); + await trace.flush(); + printPasteCallbackTraceDiagnosis(trace, provider, verbose); return null; } + trace.record(OAuthTracePhase.PasteCallbackReceived, { callbackUrl }); // Validate callback URL let code: string | undefined; @@ -770,11 +852,17 @@ async function handlePasteCallbackMode( code = parsed.searchParams.get('code') || undefined; } catch { console.log(fail('Invalid URL format')); + trace.record(OAuthTracePhase.PasteCallbackInvalid, { reason: 'invalid_url' }); + await trace.flush(); + printPasteCallbackTraceDiagnosis(trace, provider, verbose); return null; } if (!code) { console.log(fail('Invalid callback URL: missing code parameter')); + trace.record(OAuthTracePhase.PasteCallbackInvalid, { reason: 'missing_code' }); + await trace.flush(); + printPasteCallbackTraceDiagnosis(trace, provider, verbose); return null; } @@ -782,6 +870,7 @@ async function handlePasteCallbackMode( console.log(info('Submitting callback...')); const callbackProvider = CLIPROXY_CALLBACK_PROVIDER_MAP[provider] || provider; + trace.record(OAuthTracePhase.PasteCallbackSubmitted, { provider: callbackProvider }); const callbackResponse = await fetch(buildProxyUrl(target, getManagementOAuthCallbackPath()), { method: 'POST', @@ -802,10 +891,18 @@ async function handlePasteCallbackMode( callbackData.error || `OAuth callback failed with status ${callbackResponse.status}`; console.log(fail(callbackError)); warnPossible403Ban(provider, callbackError); + trace.record( + OAuthTracePhase.Error, + { status: callbackResponse.status }, + { code: 'CALLBACK_REJECTED', message: callbackError } + ); + await trace.flush(); + printPasteCallbackTraceDiagnosis(trace, provider, verbose); return null; } console.log(info('Callback submitted. Waiting for token exchange...')); + trace.record(OAuthTracePhase.TokenExchangePending, { state: oauthState ?? undefined }); const { tokenSnapshot, error: tokenWaitError } = await waitForManualCallbackToken( provider, target, @@ -813,12 +910,20 @@ async function handlePasteCallbackMode( oauthState, knownTokenFiles, expectedAccountId, - OAUTH_STATE_TIMEOUT_MS + OAUTH_STATE_TIMEOUT_MS, + pollIntervalMs ); if (tokenWaitError) { console.log(fail(tokenWaitError)); warnPossible403Ban(provider, tokenWaitError); + trace.record( + OAuthTracePhase.Error, + {}, + { code: 'CALLBACK_REJECTED', message: tokenWaitError } + ); + await trace.flush(); + printPasteCallbackTraceDiagnosis(trace, provider, verbose); return null; } @@ -828,8 +933,12 @@ async function handlePasteCallbackMode( 'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.' ) ); + trace.record(OAuthTracePhase.TokenFileMissing, {}); + await trace.flush(); + printPasteCallbackTraceDiagnosis(trace, provider, verbose); return null; } + trace.record(OAuthTracePhase.TokenFileAppeared, {}); const account = registerAccountFromToken( provider, @@ -843,8 +952,12 @@ async function handlePasteCallbackMode( console.log( fail('Authenticated token could not be matched to the requested account. Retry the flow.') ); + trace.record(OAuthTracePhase.TokenFileMissing, { reason: 'account_match_failed' }); + await trace.flush(); + printPasteCallbackTraceDiagnosis(trace, provider, verbose); return null; } + await trace.flush(); console.log(ok('Authentication successful!')); @@ -863,6 +976,9 @@ async function handlePasteCallbackMode( } else { console.log(fail('OAuth failed. Use --verbose for details.')); } + trace.record(OAuthTracePhase.Error, {}, error as Error); + await trace.flush(); + printPasteCallbackTraceDiagnosis(trace, provider, verbose); return null; } }