mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 12:14:19 +00:00
feat: add OAuth paste-callback traceability
This commit is contained in:
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-'));
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user