feat: add OAuth paste-callback traceability

This commit is contained in:
Tam Nhu Tran committed 2026-05-22 14:12:53 -04:00
1 parent 1cfaedf4b2
commit 1af6625f5c
3 files changed
+349 -39

No files matched your search

@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { OAUTH_CONFIGS } from '../auth-types';
import { diagnoseFailure, formatErrorMessage } from '../oauth-trace/diagnose-failure'; import { diagnoseFailure, formatErrorMessage } from '../oauth-trace/diagnose-failure';
import { OAuthTracePhase, type OAuthTraceEvent } from '../oauth-trace/trace-events'; import { OAuthTracePhase, type OAuthTraceEvent } from '../oauth-trace/trace-events';
@@ -195,3 +196,87 @@ describe('formatErrorMessage', () => {
expect(lines.some((l) => l.includes('--verbose'))).toBe(true); 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);
}
);
});
@@ -5,6 +5,8 @@ import * as path from 'path';
import type { ProxyTarget } from '../../proxy/proxy-target-resolver'; import type { ProxyTarget } from '../../proxy/proxy-target-resolver';
import { InteractivePrompt } from '../../../utils/prompt'; import { InteractivePrompt } from '../../../utils/prompt';
import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../../../tests/mocks'; import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../../../tests/mocks';
import { createOAuthTraceRecorder } from '../oauth-trace/trace-recorder';
import { OAuthTracePhase } from '../oauth-trace/trace-events';
const remoteTarget: ProxyTarget = { const remoteTarget: ProxyTarget = {
host: 'proxy.example.com', 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', () => { describe('findNewTokenSnapshotForManualAuth', () => {
it('detects newly created provider token files', async () => { it('detects newly created provider token files', async () => {
const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-kiro-manual-auth-')); const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-kiro-manual-auth-'));
+155 -39
View File
@@ -11,6 +11,7 @@
*/ */
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path';
import { fail, info, warn, color, ok } from '../../utils/ui'; import { fail, info, warn, color, ok } from '../../utils/ui';
import { createLogger } from '../../services/logging'; import { createLogger } from '../../services/logging';
import { ensureCLIProxyBinary, getStoredConfiguredBackend } from '../binary-manager'; import { ensureCLIProxyBinary, getStoredConfiguredBackend } from '../binary-manager';
@@ -76,6 +77,11 @@ import {
} from '../accounts/account-safety'; } from '../accounts/account-safety';
import { ensureCliAntigravityResponsibility } from '../auth/antigravity-responsibility'; import { ensureCliAntigravityResponsibility } from '../auth/antigravity-responsibility';
import { InteractivePrompt } from '../../utils/prompt'; 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 { interface PasteCallbackStartData {
url?: string; url?: string;
@@ -84,6 +90,13 @@ interface PasteCallbackStartData {
status?: string; status?: string;
} }
interface PasteCallbackTraceOptions {
trace?: OAuthTraceRecorder;
promptForCallbackUrl?: (timeoutMs: number) => Promise<string | null>;
timeoutMs?: number;
pollIntervalMs?: number;
}
const PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS = 3000; const PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS = 3000;
const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000; const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
const GEMINI_PLUS_CLIENT_ID_ENV = 'CLIPROXY_GEMINI_OAUTH_CLIENT_ID'; 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 * Handle paste-callback mode: show auth URL, prompt for callback paste
* Uses proxy target resolver to connect to correct CLIProxyAPI instance (local or remote) * 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<string | null> {
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise<string | null>((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, provider: CLIProxyProvider,
oauthConfig: ProviderOAuthConfig, oauthConfig: ProviderOAuthConfig,
verbose: boolean, verbose: boolean,
@@ -652,12 +739,14 @@ async function handlePasteCallbackMode(
kiroMethod?: OAuthOptions['kiroMethod']; kiroMethod?: OAuthOptions['kiroMethod'];
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl']; gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
add?: boolean; add?: boolean;
} } & PasteCallbackTraceOptions
): Promise<AccountInfo | null> { ): Promise<AccountInfo | null> {
// Resolve CLIProxyAPI target (local or remote based on config) // Resolve CLIProxyAPI target (local or remote based on config)
const target = getProxyTarget(); const target = getProxyTarget();
// OAuth state timeout (10 minutes, matches CLIProxyAPI state TTL) // 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('');
console.log(info(`Starting ${oauthConfig.displayName} OAuth (paste-callback mode)...`)); console.log(info(`Starting ${oauthConfig.displayName} OAuth (paste-callback mode)...`));
@@ -694,19 +783,37 @@ async function handlePasteCallbackMode(
} }
} }
warnPossible403Ban(provider, startError); warnPossible403Ban(provider, startError);
trace.record(OAuthTracePhase.Error, { startPath }, { message: startError });
await trace.flush();
printPasteCallbackTraceDiagnosis(trace, provider, verbose);
return null; 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) { if (!authUrl) {
console.log(fail('No authorization URL received')); 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; return null;
} }
const authUrlCredentialError = getGeminiAuthUrlCredentialError(provider, authUrl); const authUrlCredentialError = getGeminiAuthUrlCredentialError(provider, authUrl);
if (authUrlCredentialError) { if (authUrlCredentialError) {
console.log(fail(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; return null;
} }
@@ -721,47 +828,22 @@ async function handlePasteCallbackMode(
console.log(''); console.log('');
console.log(` ${authUrl}`); console.log(` ${authUrl}`);
console.log(''); console.log('');
trace.record(OAuthTracePhase.AuthUrlDisplayed, { authUrl });
// Prompt for callback URL // Prompt for callback URL
const readline = await import('readline'); trace.record(OAuthTracePhase.PasteCallbackPrompted, { timeoutMs: OAUTH_STATE_TIMEOUT_MS });
const rl = readline.createInterface({ const callbackUrl = await (options?.promptForCallbackUrl ?? promptForPasteCallbackUrl)(
input: process.stdin, OAUTH_STATE_TIMEOUT_MS
output: process.stdout, );
});
const callbackUrl = await new Promise<string | null>((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);
});
if (!callbackUrl) { if (!callbackUrl) {
console.log(info('Cancelled')); console.log(info('Cancelled'));
trace.record(OAuthTracePhase.Cancelled, { reason: 'empty_callback' });
await trace.flush();
printPasteCallbackTraceDiagnosis(trace, provider, verbose);
return null; return null;
} }
trace.record(OAuthTracePhase.PasteCallbackReceived, { callbackUrl });
// Validate callback URL // Validate callback URL
let code: string | undefined; let code: string | undefined;
@@ -770,11 +852,17 @@ async function handlePasteCallbackMode(
code = parsed.searchParams.get('code') || undefined; code = parsed.searchParams.get('code') || undefined;
} catch { } catch {
console.log(fail('Invalid URL format')); console.log(fail('Invalid URL format'));
trace.record(OAuthTracePhase.PasteCallbackInvalid, { reason: 'invalid_url' });
await trace.flush();
printPasteCallbackTraceDiagnosis(trace, provider, verbose);
return null; return null;
} }
if (!code) { if (!code) {
console.log(fail('Invalid callback URL: missing code parameter')); 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; return null;
} }
@@ -782,6 +870,7 @@ async function handlePasteCallbackMode(
console.log(info('Submitting callback...')); console.log(info('Submitting callback...'));
const callbackProvider = CLIPROXY_CALLBACK_PROVIDER_MAP[provider] || provider; const callbackProvider = CLIPROXY_CALLBACK_PROVIDER_MAP[provider] || provider;
trace.record(OAuthTracePhase.PasteCallbackSubmitted, { provider: callbackProvider });
const callbackResponse = await fetch(buildProxyUrl(target, getManagementOAuthCallbackPath()), { const callbackResponse = await fetch(buildProxyUrl(target, getManagementOAuthCallbackPath()), {
method: 'POST', method: 'POST',
@@ -802,10 +891,18 @@ async function handlePasteCallbackMode(
callbackData.error || `OAuth callback failed with status ${callbackResponse.status}`; callbackData.error || `OAuth callback failed with status ${callbackResponse.status}`;
console.log(fail(callbackError)); console.log(fail(callbackError));
warnPossible403Ban(provider, 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; return null;
} }
console.log(info('Callback submitted. Waiting for token exchange...')); console.log(info('Callback submitted. Waiting for token exchange...'));
trace.record(OAuthTracePhase.TokenExchangePending, { state: oauthState ?? undefined });
const { tokenSnapshot, error: tokenWaitError } = await waitForManualCallbackToken( const { tokenSnapshot, error: tokenWaitError } = await waitForManualCallbackToken(
provider, provider,
target, target,
@@ -813,12 +910,20 @@ async function handlePasteCallbackMode(
oauthState, oauthState,
knownTokenFiles, knownTokenFiles,
expectedAccountId, expectedAccountId,
OAUTH_STATE_TIMEOUT_MS OAUTH_STATE_TIMEOUT_MS,
pollIntervalMs
); );
if (tokenWaitError) { if (tokenWaitError) {
console.log(fail(tokenWaitError)); console.log(fail(tokenWaitError));
warnPossible403Ban(provider, tokenWaitError); warnPossible403Ban(provider, tokenWaitError);
trace.record(
OAuthTracePhase.Error,
{},
{ code: 'CALLBACK_REJECTED', message: tokenWaitError }
);
await trace.flush();
printPasteCallbackTraceDiagnosis(trace, provider, verbose);
return null; 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.' '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; return null;
} }
trace.record(OAuthTracePhase.TokenFileAppeared, {});
const account = registerAccountFromToken( const account = registerAccountFromToken(
provider, provider,
@@ -843,8 +952,12 @@ async function handlePasteCallbackMode(
console.log( console.log(
fail('Authenticated token could not be matched to the requested account. Retry the flow.') 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; return null;
} }
await trace.flush();
console.log(ok('Authentication successful!')); console.log(ok('Authentication successful!'));
@@ -863,6 +976,9 @@ async function handlePasteCallbackMode(
} else { } else {
console.log(fail('OAuth failed. Use --verbose for details.')); 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; return null;
} }
} }