From 6fcd924b1c8743484823b958ff403c4c37379eab Mon Sep 17 00:00:00 2001 From: Brian Le Date: Tue, 3 Mar 2026 17:16:07 -0500 Subject: [PATCH 1/4] fix(cliproxy): use management auth-url route in paste callback --- src/cliproxy/auth/auth-types.ts | 5 +++++ src/cliproxy/auth/oauth-handler.ts | 10 ++++------ .../auth-types-management-path.test.ts | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 tests/unit/cliproxy/auth-types-management-path.test.ts diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 9d1cf401..03fa6b25 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -253,6 +253,11 @@ export const CLIPROXY_AUTH_URL_PROVIDER_MAP: Record = (provider) => getCLIProxyAuthUrlProviderName(provider) ); +export function getManagementAuthUrlPath(provider: CLIProxyProvider): string { + const authUrlProvider = CLIPROXY_AUTH_URL_PROVIDER_MAP[provider] || provider; + return `/v0/management/${authUrlProvider}-auth-url?is_webui=true`; +} + /** * Get OAuth config for provider */ diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index f4a2d3b2..47b26cb0 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -37,6 +37,7 @@ import { getOAuthConfig, ProviderOAuthConfig, CLIPROXY_CALLBACK_PROVIDER_MAP, + getManagementAuthUrlPath, normalizeKiroAuthMethod, } from './auth-types'; import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector'; @@ -275,12 +276,9 @@ async function handlePasteCallbackMode( try { // Request auth URL from CLIProxyAPI - // Note: Uses /oauth/${provider}/start endpoint (different from web-server routes which use - // /v0/management/${provider}-auth-url). Both start OAuth flows but this endpoint is simpler - // for CLI paste-callback mode as it directly returns the auth URL without is_webui param. - const startResponse = await fetch(buildProxyUrl(target, `/oauth/${provider}/start`), { - method: 'POST', - headers: buildManagementHeaders(target, { 'Content-Type': 'application/json' }), + // Use management auth-url endpoint to match CLIProxyAPI route contract. + const startResponse = await fetch(buildProxyUrl(target, getManagementAuthUrlPath(provider)), { + headers: buildManagementHeaders(target), }); if (!startResponse.ok) { diff --git a/tests/unit/cliproxy/auth-types-management-path.test.ts b/tests/unit/cliproxy/auth-types-management-path.test.ts new file mode 100644 index 00000000..cc68b9d8 --- /dev/null +++ b/tests/unit/cliproxy/auth-types-management-path.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'bun:test'; +import { getManagementAuthUrlPath } from '../../../src/cliproxy/auth/auth-types'; + +describe('auth-types management auth-url path', () => { + it('maps providers to CLIProxyAPI management auth-url routes', () => { + expect(getManagementAuthUrlPath('gemini')).toBe( + '/v0/management/gemini-cli-auth-url?is_webui=true' + ); + expect(getManagementAuthUrlPath('codex')).toBe('/v0/management/codex-auth-url?is_webui=true'); + expect(getManagementAuthUrlPath('agy')).toBe( + '/v0/management/antigravity-auth-url?is_webui=true' + ); + expect(getManagementAuthUrlPath('claude')).toBe( + '/v0/management/anthropic-auth-url?is_webui=true' + ); + expect(getManagementAuthUrlPath('ghcp')).toBe('/v0/management/github-auth-url?is_webui=true'); + expect(getManagementAuthUrlPath('kiro')).toBe('/v0/management/kiro-auth-url?is_webui=true'); + }); +}); From fa027022a8faf886c7f5891ec8cb2d3879c783e8 Mon Sep 17 00:00:00 2001 From: Brian Le Date: Tue, 3 Mar 2026 17:59:10 -0500 Subject: [PATCH 2/4] fix(cliproxy): use management oauth-callback in paste flow --- src/cliproxy/auth/auth-types.ts | 4 ++++ src/cliproxy/auth/oauth-handler.ts | 4 ++-- tests/unit/cliproxy/auth-types-management-path.test.ts | 9 ++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 03fa6b25..3566e6b2 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -258,6 +258,10 @@ export function getManagementAuthUrlPath(provider: CLIProxyProvider): string { return `/v0/management/${authUrlProvider}-auth-url?is_webui=true`; } +export function getManagementOAuthCallbackPath(): string { + return '/v0/management/oauth-callback'; +} + /** * Get OAuth config for provider */ diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 47b26cb0..9d4848d3 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -38,6 +38,7 @@ import { ProviderOAuthConfig, CLIPROXY_CALLBACK_PROVIDER_MAP, getManagementAuthUrlPath, + getManagementOAuthCallbackPath, normalizeKiroAuthMethod, } from './auth-types'; import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector'; @@ -370,8 +371,7 @@ async function handlePasteCallbackMode( const callbackProvider = CLIPROXY_CALLBACK_PROVIDER_MAP[provider] || provider; - // Note: /oauth-callback is a CLIProxyAPI endpoint (not /v0/management prefix) - const callbackResponse = await fetch(buildProxyUrl(target, '/oauth-callback'), { + const callbackResponse = await fetch(buildProxyUrl(target, getManagementOAuthCallbackPath()), { method: 'POST', headers: buildManagementHeaders(target, { 'Content-Type': 'application/json' }), body: JSON.stringify({ diff --git a/tests/unit/cliproxy/auth-types-management-path.test.ts b/tests/unit/cliproxy/auth-types-management-path.test.ts index cc68b9d8..a9f7cc35 100644 --- a/tests/unit/cliproxy/auth-types-management-path.test.ts +++ b/tests/unit/cliproxy/auth-types-management-path.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'bun:test'; -import { getManagementAuthUrlPath } from '../../../src/cliproxy/auth/auth-types'; +import { + getManagementAuthUrlPath, + getManagementOAuthCallbackPath, +} from '../../../src/cliproxy/auth/auth-types'; describe('auth-types management auth-url path', () => { it('maps providers to CLIProxyAPI management auth-url routes', () => { @@ -16,4 +19,8 @@ describe('auth-types management auth-url path', () => { expect(getManagementAuthUrlPath('ghcp')).toBe('/v0/management/github-auth-url?is_webui=true'); expect(getManagementAuthUrlPath('kiro')).toBe('/v0/management/kiro-auth-url?is_webui=true'); }); + + it('uses CLIProxyAPI management oauth-callback route', () => { + expect(getManagementOAuthCallbackPath()).toBe('/v0/management/oauth-callback'); + }); }); From 86ca18003a033be976b787811db16bf38bf82a91 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 7 Mar 2026 13:38:22 +0700 Subject: [PATCH 3/4] fix(cliproxy): preserve kiro paste-callback start route --- src/cliproxy/auth/auth-types.ts | 8 ++++++++ src/cliproxy/auth/oauth-handler.ts | 16 ++++++++++----- .../auth-types-management-path.test.ts | 20 +++++++++++++------ 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 3566e6b2..dd8ad3e6 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -258,6 +258,14 @@ export function getManagementAuthUrlPath(provider: CLIProxyProvider): string { return `/v0/management/${authUrlProvider}-auth-url?is_webui=true`; } +export function getPasteCallbackStartPath(provider: CLIProxyProvider): string { + // Kiro CLI auth methods still use the legacy start route. + if (provider === 'kiro') { + return `/oauth/${provider}/start`; + } + return getManagementAuthUrlPath(provider); +} + export function getManagementOAuthCallbackPath(): string { return '/v0/management/oauth-callback'; } diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 9d4848d3..5ef17166 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -37,7 +37,7 @@ import { getOAuthConfig, ProviderOAuthConfig, CLIPROXY_CALLBACK_PROVIDER_MAP, - getManagementAuthUrlPath, + getPasteCallbackStartPath, getManagementOAuthCallbackPath, normalizeKiroAuthMethod, } from './auth-types'; @@ -276,10 +276,16 @@ async function handlePasteCallbackMode( console.log(info(`Starting ${oauthConfig.displayName} OAuth (paste-callback mode)...`)); try { - // Request auth URL from CLIProxyAPI - // Use management auth-url endpoint to match CLIProxyAPI route contract. - const startResponse = await fetch(buildProxyUrl(target, getManagementAuthUrlPath(provider)), { - headers: buildManagementHeaders(target), + // Request auth URL from CLIProxyAPI. + // Kiro keeps its legacy start route because CLI auth methods do not share the generic + // management auth-url contract used by providers like Claude. + const startPath = getPasteCallbackStartPath(provider); + const startResponse = await fetch(buildProxyUrl(target, startPath), { + ...(provider === 'kiro' ? { method: 'POST' } : {}), + headers: + provider === 'kiro' + ? buildManagementHeaders(target, { 'Content-Type': 'application/json' }) + : buildManagementHeaders(target), }); if (!startResponse.ok) { diff --git a/tests/unit/cliproxy/auth-types-management-path.test.ts b/tests/unit/cliproxy/auth-types-management-path.test.ts index a9f7cc35..ee999850 100644 --- a/tests/unit/cliproxy/auth-types-management-path.test.ts +++ b/tests/unit/cliproxy/auth-types-management-path.test.ts @@ -1,22 +1,30 @@ import { describe, expect, it } from 'bun:test'; import { getManagementAuthUrlPath, + getPasteCallbackStartPath, getManagementOAuthCallbackPath, } from '../../../src/cliproxy/auth/auth-types'; -describe('auth-types management auth-url path', () => { +describe('auth-types paste-callback start path', () => { it('maps providers to CLIProxyAPI management auth-url routes', () => { - expect(getManagementAuthUrlPath('gemini')).toBe( + expect(getPasteCallbackStartPath('gemini')).toBe( '/v0/management/gemini-cli-auth-url?is_webui=true' ); - expect(getManagementAuthUrlPath('codex')).toBe('/v0/management/codex-auth-url?is_webui=true'); - expect(getManagementAuthUrlPath('agy')).toBe( + expect(getPasteCallbackStartPath('codex')).toBe('/v0/management/codex-auth-url?is_webui=true'); + expect(getPasteCallbackStartPath('agy')).toBe( '/v0/management/antigravity-auth-url?is_webui=true' ); - expect(getManagementAuthUrlPath('claude')).toBe( + expect(getPasteCallbackStartPath('claude')).toBe( '/v0/management/anthropic-auth-url?is_webui=true' ); - expect(getManagementAuthUrlPath('ghcp')).toBe('/v0/management/github-auth-url?is_webui=true'); + expect(getPasteCallbackStartPath('ghcp')).toBe('/v0/management/github-auth-url?is_webui=true'); + }); + + it('keeps Kiro on the legacy start route for paste-callback mode', () => { + expect(getPasteCallbackStartPath('kiro')).toBe('/oauth/kiro/start'); + }); + + it('still exposes the generic management auth-url helper', () => { expect(getManagementAuthUrlPath('kiro')).toBe('/v0/management/kiro-auth-url?is_webui=true'); }); From b25b1653b047c588a95c6f4e47aa3214e0e370aa Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 7 Mar 2026 13:48:34 +0700 Subject: [PATCH 4/4] fix(cliproxy): poll paste-callback auth urls --- src/cliproxy/auth/oauth-handler.ts | 110 +++++++++++++++--- .../oauth-handler-paste-callback.test.ts | 104 +++++++++++++++++ 2 files changed, 196 insertions(+), 18 deletions(-) create mode 100644 tests/unit/cliproxy/oauth-handler-paste-callback.test.ts diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 5ef17166..e989ae56 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -45,7 +45,12 @@ import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environmen import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from './token-manager'; import { executeOAuthProcess } from './oauth-process'; import { importKiroToken } from './kiro-import'; -import { getProxyTarget, buildProxyUrl, buildManagementHeaders } from '../proxy-target-resolver'; +import { + getProxyTarget, + buildProxyUrl, + buildManagementHeaders, + type ProxyTarget, +} from '../proxy-target-resolver'; import { checkNewAccountConflict, warnNewAccountConflict, @@ -54,6 +59,86 @@ import { } from '../account-safety'; import { ensureCliAntigravityResponsibility } from '../antigravity-responsibility'; +interface PasteCallbackStartData { + url?: string; + auth_url?: string; + state?: string; + status?: string; +} + +const PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS = 3000; + +export async function requestPasteCallbackStart( + provider: CLIProxyProvider, + target: ProxyTarget +): Promise { + const startPath = getPasteCallbackStartPath(provider); + const response = await fetch(buildProxyUrl(target, startPath), { + ...(provider === 'kiro' ? { method: 'POST' } : {}), + headers: + provider === 'kiro' + ? buildManagementHeaders(target, { 'Content-Type': 'application/json' }) + : buildManagementHeaders(target), + }); + + if (!response.ok) { + throw new Error(`OAuth start failed with status ${response.status}`); + } + + return (await response.json()) as PasteCallbackStartData; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function resolvePasteCallbackAuthUrl( + target: ProxyTarget, + startData: PasteCallbackStartData, + timeoutMs: number, + pollIntervalMs: number = PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS +): Promise { + const authUrl = startData.url || startData.auth_url; + if (authUrl) { + return authUrl; + } + + const state = startData.state; + if (!state) { + return null; + } + + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const response = await fetch( + buildProxyUrl(target, `/v0/management/get-auth-status?state=${encodeURIComponent(state)}`), + { headers: buildManagementHeaders(target) } + ); + + if (response.ok) { + const statusData = (await response.json()) as PasteCallbackStartData; + const polledAuthUrl = statusData.url || statusData.auth_url; + + if (polledAuthUrl) { + return polledAuthUrl; + } + + if (statusData.status === 'error' || statusData.status === 'device_code') { + return null; + } + } + + if (Date.now() + pollIntervalMs >= deadline) { + break; + } + + await sleep(pollIntervalMs); + } + + return null; +} + /** * Prompt user to add another account */ @@ -279,28 +364,17 @@ async function handlePasteCallbackMode( // Request auth URL from CLIProxyAPI. // Kiro keeps its legacy start route because CLI auth methods do not share the generic // management auth-url contract used by providers like Claude. - const startPath = getPasteCallbackStartPath(provider); - const startResponse = await fetch(buildProxyUrl(target, startPath), { - ...(provider === 'kiro' ? { method: 'POST' } : {}), - headers: - provider === 'kiro' - ? buildManagementHeaders(target, { 'Content-Type': 'application/json' }) - : buildManagementHeaders(target), - }); - - if (!startResponse.ok) { - const startError = `OAuth start failed with status ${startResponse.status}`; + let startData: PasteCallbackStartData; + try { + startData = await requestPasteCallbackStart(provider, target); + } catch (error) { + const startError = (error as Error).message; console.log(fail('Failed to start OAuth flow')); warnPossible403Ban(provider, startError); return null; } - const startData = (await startResponse.json()) as { - url?: string; - auth_url?: string; - status?: string; - }; - const authUrl = startData.url || startData.auth_url; + const authUrl = await resolvePasteCallbackAuthUrl(target, startData, OAUTH_STATE_TIMEOUT_MS); if (!authUrl) { console.log(fail('No authorization URL received')); diff --git a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts new file mode 100644 index 00000000..1b8da8a9 --- /dev/null +++ b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import type { ProxyTarget } from '../../../src/cliproxy/proxy-target-resolver'; +import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks'; + +const remoteTarget: ProxyTarget = { + host: 'proxy.example.com', + port: 8317, + protocol: 'https', + managementKey: 'test-mgmt-key', + isRemote: true, +}; + +afterEach(() => { + restoreFetch(); +}); + +describe('requestPasteCallbackStart', () => { + it('uses management auth-url route for non-kiro providers', async () => { + mockFetch([ + { + url: /\/v0\/management\/anthropic-auth-url\?is_webui=true$/, + response: { auth_url: 'https://auth.example.com/claude' }, + }, + ]); + + const { requestPasteCallbackStart } = await import( + `../../../src/cliproxy/auth/oauth-handler?request-claude-start=${Date.now()}` + ); + const startData = await requestPasteCallbackStart('claude', remoteTarget); + + expect(startData.auth_url).toBe('https://auth.example.com/claude'); + + const [request] = getCapturedFetchRequests(); + expect(request.url).toBe( + 'https://proxy.example.com:8317/v0/management/anthropic-auth-url?is_webui=true' + ); + expect(request.method).toBe('GET'); + expect(request.headers['Authorization']).toBe('Bearer test-mgmt-key'); + expect(request.headers['Content-Type']).toBeUndefined(); + }); + + it('keeps kiro on the legacy start route with POST', async () => { + mockFetch([ + { + url: /\/oauth\/kiro\/start$/, + method: 'POST', + response: { auth_url: 'https://auth.example.com/kiro' }, + }, + ]); + + const { requestPasteCallbackStart } = await import( + `../../../src/cliproxy/auth/oauth-handler?request-kiro-start=${Date.now()}` + ); + const startData = await requestPasteCallbackStart('kiro', remoteTarget); + + expect(startData.auth_url).toBe('https://auth.example.com/kiro'); + + const [request] = getCapturedFetchRequests(); + expect(request.url).toBe('https://proxy.example.com:8317/oauth/kiro/start'); + expect(request.method).toBe('POST'); + expect(request.headers['Authorization']).toBe('Bearer test-mgmt-key'); + expect(request.headers['Content-Type']).toBe('application/json'); + }); +}); + +describe('resolvePasteCallbackAuthUrl', () => { + it('returns the immediate auth URL without polling', async () => { + const { resolvePasteCallbackAuthUrl } = await import( + `../../../src/cliproxy/auth/oauth-handler?resolve-immediate-auth-url=${Date.now()}` + ); + const authUrl = await resolvePasteCallbackAuthUrl( + remoteTarget, + { auth_url: 'https://auth.example.com/direct' }, + 50, + 0 + ); + + expect(authUrl).toBe('https://auth.example.com/direct'); + expect(getCapturedFetchRequests()).toHaveLength(0); + }); + + it('polls management status when the start response only returns state', async () => { + mockFetch([ + { + url: /\/v0\/management\/get-auth-status\?state=state-123$/, + response: { status: 'auth_url', auth_url: 'https://auth.example.com/polled' }, + }, + ]); + + const { resolvePasteCallbackAuthUrl } = await import( + `../../../src/cliproxy/auth/oauth-handler?resolve-polled-auth-url=${Date.now()}` + ); + const authUrl = await resolvePasteCallbackAuthUrl(remoteTarget, { state: 'state-123' }, 50, 0); + + expect(authUrl).toBe('https://auth.example.com/polled'); + + const [request] = getCapturedFetchRequests(); + expect(request.url).toBe( + 'https://proxy.example.com:8317/v0/management/get-auth-status?state=state-123' + ); + expect(request.method).toBe('GET'); + expect(request.headers['Authorization']).toBe('Bearer test-mgmt-key'); + }); +});