From 8b681df455ed1386818e5a3f919b7ac4f3f7b53b Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Thu, 7 May 2026 11:25:17 -0400 Subject: [PATCH] fix: route Cursor auth through browser polling Closes #1194 --- docs/system-architecture/provider-flows.md | 8 +++- .../__tests__/provider-capabilities.test.ts | 12 ++++++ .../backend-ui-default-ports-sync.test.ts | 10 ++--- src/cliproxy/provider-capabilities.ts | 16 ++++++++ src/web-server/routes/cliproxy-auth-routes.ts | 10 ++++- tests/unit/ui/provider-config.test.ts | 7 ++++ ...iproxy-auth-routes-manual-callback.test.ts | 30 +++++++++++++- .../web-server/cliproxy-auth-routes.test.ts | 15 +++---- .../components/account/add-account-dialog.tsx | 4 +- ui/src/lib/provider-config.ts | 17 ++++---- .../hooks/use-cliproxy-auth-flow.test.tsx | 41 +++++++++++++++++++ ui/tests/unit/ui/lib/provider-config.test.ts | 7 ++++ 12 files changed, 152 insertions(+), 25 deletions(-) diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index 2eb12953..46a3bce9 100644 --- a/docs/system-architecture/provider-flows.md +++ b/docs/system-architecture/provider-flows.md @@ -1,6 +1,6 @@ # Provider Integration Flows -Last Updated: 2026-03-30 +Last Updated: 2026-05-07 Detailed provider integration flows including CLIProxyAPI, legacy GLMT compatibility transforms, remote CLIProxy, quota management, and authentication. @@ -44,6 +44,11 @@ Generated local CLIProxy configs also keep the management dashboard aligned with | - User enters code at github.com/login/device | - Polls for token completion | | + | +---> Browser URL Polling (no callback port) + | - Cursor + | - Opens provider login URL returned by CLIProxyAPIPlus + | - Polls auth state until token is saved + | | | v | +------------------+ | | OAuth Server | Browser-based auth @@ -81,6 +86,7 @@ Generated local CLIProxy configs also keep the management dashboard aligned with | Antigravity | `agy` | Authorization Code | 9876 | CLIProxyAPI | | Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | 9876 | CLIProxyAPIPlus fork | | GitHub Copilot | `ghcp` | Device Code | none | CLIProxyAPIPlus fork | +| Cursor | `cursor` | Browser URL polling | none | CLIProxyAPIPlus fork | ### Codex Duplicate-Email Account Identity diff --git a/src/cliproxy/__tests__/provider-capabilities.test.ts b/src/cliproxy/__tests__/provider-capabilities.test.ts index d55cb54d..02fea0b0 100644 --- a/src/cliproxy/__tests__/provider-capabilities.test.ts +++ b/src/cliproxy/__tests__/provider-capabilities.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test'; import { buildProviderAliasMap, CLIPROXY_PROVIDER_IDS, + getDeviceCodeVerificationProviders, getOAuthCallbackPort, getOAuthFlowType, PROVIDER_CAPABILITIES, @@ -76,6 +77,17 @@ describe('provider-capabilities', () => { ]); }); + it('separates browser URL auth providers from verification-code device flows', () => { + expect(getDeviceCodeVerificationProviders()).toEqual([ + 'qwen', + 'kiro', + 'ghcp', + 'kimi', + 'codebuddy', + 'kilo', + ]); + }); + it('maps external provider aliases to canonical IDs', () => { expect(mapExternalProviderName('gemini-cli')).toBe('gemini'); expect(mapExternalProviderName('antigravity')).toBe('agy'); diff --git a/src/cliproxy/config/__tests__/backend-ui-default-ports-sync.test.ts b/src/cliproxy/config/__tests__/backend-ui-default-ports-sync.test.ts index 58ba0eb6..33045ff3 100644 --- a/src/cliproxy/config/__tests__/backend-ui-default-ports-sync.test.ts +++ b/src/cliproxy/config/__tests__/backend-ui-default-ports-sync.test.ts @@ -9,9 +9,9 @@ import { CLIPROXY_DEFAULT_PORT as BACKEND_CLIPROXY_DEFAULT_PORT } from '../port- import { DEFAULT_CURSOR_PORT as BACKEND_CURSOR_DEFAULT_PORT } from '../../../cursor/cursor-models'; import { CLIPROXY_PROVIDER_IDS as BACKEND_CLIPROXY_PROVIDER_IDS, + getDeviceCodeVerificationProviders, getProviderDescription as getBackendProviderDescription, getProviderDisplayName as getBackendProviderDisplayName, - getProvidersByOAuthFlow, } from '../../provider-capabilities'; import { CLIPROXY_DEFAULT_PORT as UI_CLIPROXY_DEFAULT_PORT, @@ -20,9 +20,9 @@ import { import { CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS, CORE_CLIPROXY_PROVIDERS as UI_CORE_CLIPROXY_PROVIDERS, - DEVICE_CODE_PROVIDERS as UI_DEVICE_CODE_PROVIDERS, PLUS_EXTRA_CLIPROXY_PROVIDERS as UI_PLUS_EXTRA_CLIPROXY_PROVIDERS, PROVIDER_METADATA as UI_PROVIDER_METADATA, + VERIFICATION_CODE_AUTH_PROVIDERS as UI_VERIFICATION_CODE_AUTH_PROVIDERS, } from '../../../../ui/src/lib/provider-config'; import { PLUS_ONLY_PROVIDERS as BACKEND_PLUS_ONLY_PROVIDERS } from '../../types/index'; @@ -43,9 +43,9 @@ describe('Default Port Sync', () => { expect(sorted(UI_CLIPROXY_PROVIDERS)).toEqual(sorted(BACKEND_CLIPROXY_PROVIDER_IDS)); }); - test('Device code providers are synced between backend and UI', () => { - expect(sorted(UI_DEVICE_CODE_PROVIDERS)).toEqual( - sorted(getProvidersByOAuthFlow('device_code')) + test('verification-code auth providers are synced between backend and UI', () => { + expect(sorted(UI_VERIFICATION_CODE_AUTH_PROVIDERS)).toEqual( + sorted(getDeviceCodeVerificationProviders()) ); }); diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index bafd436a..d473d13f 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -188,6 +188,12 @@ export const CLIPROXY_PROVIDER_IDS = Object.freeze( Object.keys(PROVIDER_CAPABILITIES) as CLIProxyProvider[] ); +export const BROWSER_URL_AUTH_PROVIDER_IDS = Object.freeze([ + 'cursor', +] as const satisfies readonly CLIProxyProvider[]); + +const BROWSER_URL_AUTH_PROVIDER_SET = new Set(BROWSER_URL_AUTH_PROVIDER_IDS); + /** Providers currently supported by quota status fetchers. */ export const QUOTA_SUPPORTED_PROVIDER_IDS = Object.freeze([ 'agy', @@ -278,6 +284,16 @@ export function getProvidersByOAuthFlow(flowType: OAuthFlowType): CLIProxyProvid ); } +export function isBrowserUrlAuthProvider(provider: CLIProxyProvider): boolean { + return BROWSER_URL_AUTH_PROVIDER_SET.has(provider); +} + +export function getDeviceCodeVerificationProviders(): CLIProxyProvider[] { + return getProvidersByOAuthFlow('device_code').filter( + (provider) => !isBrowserUrlAuthProvider(provider) + ); +} + export function getOAuthFlowType(provider: CLIProxyProvider): OAuthFlowType { return PROVIDER_CAPABILITIES[provider].oauthFlow; } diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 5faae932..a1547ce7 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -56,7 +56,11 @@ import { normalizeKiroAuthMethod, toKiroManagementMethod, } from '../../cliproxy/auth/auth-types'; -import { getOAuthFlowType, mapExternalProviderName } from '../../cliproxy/provider-capabilities'; +import { + getOAuthFlowType, + isBrowserUrlAuthProvider, + mapExternalProviderName, +} from '../../cliproxy/provider-capabilities'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { @@ -286,6 +290,10 @@ export function getStartUrlUnsupportedReason( return null; } + if (isBrowserUrlAuthProvider(provider)) { + return null; + } + if (getOAuthFlowType(provider) === 'device_code') { return `Provider '${provider}' uses Device Code flow. Use /api/cliproxy/auth/${provider}/start instead.`; } diff --git a/tests/unit/ui/provider-config.test.ts b/tests/unit/ui/provider-config.test.ts index fe85c2aa..8e5b04b4 100644 --- a/tests/unit/ui/provider-config.test.ts +++ b/tests/unit/ui/provider-config.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test'; import { getDeviceCodeProviderInstruction, getProviderDisplayName, + isDeviceCodeProvider, } from '../../../ui/src/lib/provider-config'; describe('provider-config fallbacks', () => { @@ -15,4 +16,10 @@ describe('provider-config fallbacks', () => { 'Complete the authorization in your browser.' ); }); + + it('does not classify Cursor browser URL auth as a verification-code device flow', () => { + expect(isDeviceCodeProvider('cursor')).toBe(false); + expect(isDeviceCodeProvider('ghcp')).toBe(true); + expect(isDeviceCodeProvider('qwen')).toBe(true); + }); }); diff --git a/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts index 4a4ee4b8..89b83904 100644 --- a/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts @@ -11,7 +11,7 @@ import { getCachedQuota, setCachedQuota, } from '../../../src/cliproxy/quota/quota-response-cache'; -import { restoreFetch, mockFetch } from '../../mocks'; +import { restoreFetch, mockFetch, getCapturedFetchRequests } from '../../mocks'; describe('cliproxy-auth-routes manual callback nickname persistence', () => { let server: Server; @@ -179,6 +179,34 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => { expect(registry.providers.kiro.accounts['github-ABC123']?.nickname).toBe('work'); }); + it('starts Cursor auth through the browser URL management endpoint', async () => { + mockFetch([ + { + url: /\/v0\/management\/cursor-auth-url\?is_webui=true$/, + response: { + url: 'https://cursor.com/loginDeepControl?state=cursor-state', + state: 'cursor-state', + }, + }, + ]); + + const startResponse = await postJson('/api/cliproxy/auth/cursor/start-url', { + nickname: 'work', + }); + + expect(startResponse.status).toBe(200); + expect(startResponse.body).toEqual({ + success: true, + authUrl: 'https://cursor.com/loginDeepControl?state=cursor-state', + state: 'cursor-state', + method: null, + }); + + const requests = getCapturedFetchRequests(); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toContain('/v0/management/cursor-auth-url?is_webui=true'); + }); + it('returns wait after callback submission when the local token is not yet available', async () => { mockFetch([ { diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts index 8c21fb73..b6cf9d82 100644 --- a/tests/unit/web-server/cliproxy-auth-routes.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -13,15 +13,14 @@ describe('cliproxy-auth-routes start-url guard', () => { ); expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow"); expect(getStartUrlUnsupportedReason('qwen')).toContain("Provider 'qwen' uses Device Code flow"); - expect(getStartUrlUnsupportedReason('cursor')).toContain( - "Provider 'cursor' uses Device Code flow" - ); expect(getStartUrlUnsupportedReason('codebuddy')).toContain( "Provider 'codebuddy' uses Device Code flow" ); - expect(getStartUrlUnsupportedReason('kilo')).toContain( - "Provider 'kilo' uses Device Code flow" - ); + expect(getStartUrlUnsupportedReason('kilo')).toContain("Provider 'kilo' uses Device Code flow"); + }); + + it('allows Cursor browser URL auth on start-url', () => { + expect(getStartUrlUnsupportedReason('cursor')).toBeNull(); }); it('allows Kiro social methods on start-url', () => { @@ -138,6 +137,8 @@ describe('cliproxy-auth-routes nickname validation', () => { ]; expect(getStartAuthNicknameError('kiro', 'work', existingAccounts, 'github-ABC123')).toBeNull(); - expect(getStartAuthNicknameError('kiro', 'github-ABC123', existingAccounts, 'github-ABC123')).toBeNull(); + expect( + getStartAuthNicknameError('kiro', 'github-ABC123', existingAccounts, 'github-ABC123') + ).toBeNull(); }); }); diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index b8fcaa38..403ba88f 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -293,8 +293,8 @@ export function AddAccountDialog({ /** * Start auth flow using provider capabilities. - * - Device code providers use /start and rely on WebSocket events for code display. - * - Authorization code providers use /start-url and polling. + * - Verification-code providers use /start and WebSocket events for code display. + * - Browser URL providers use /start-url and polling. */ const handleAuthenticate = () => { if (isPowerUserModePending) { diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 00c34cc7..b99bf270 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -7,7 +7,7 @@ import { CLIPROXY_PROVIDER_IDS, PROVIDER_CAPABILITIES, - getProvidersByOAuthFlow, + getDeviceCodeVerificationProviders, } from '../../../src/cliproxy/provider-capabilities'; import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/cliproxy/ai-providers'; import { PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types'; @@ -346,13 +346,14 @@ export function getProviderDescription(provider: unknown): string { return PROVIDER_METADATA[normalized].description; } -/** - * Providers that use Device Code OAuth flow instead of Authorization Code flow. - */ -export const DEVICE_CODE_PROVIDERS: CLIProxyProvider[] = [ - ...getProvidersByOAuthFlow('device_code'), +/** Providers whose add-account UX shows a verification code and uses `/start`. */ +export const VERIFICATION_CODE_AUTH_PROVIDERS: CLIProxyProvider[] = [ + ...getDeviceCodeVerificationProviders(), ]; +/** Backward-compatible alias for verification-code auth providers. */ +export const DEVICE_CODE_PROVIDERS = VERIFICATION_CODE_AUTH_PROVIDERS; + const DEVICE_CODE_PROVIDER_DISPLAY_NAMES: Readonly>> = Object.freeze({ ghcp: 'GitHub Copilot', @@ -368,10 +369,10 @@ const DEVICE_CODE_PROVIDER_INSTRUCTIONS: Readonly { vi.restoreAllMocks(); }); + it('routes Cursor through start-url browser polling by default', async () => { + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/cursor/start-url')) { + return Promise.resolve( + createJsonResponse({ + success: true, + authUrl: 'https://cursor.com/loginDeepControl?state=cursor-state', + state: 'cursor-state', + }) + ); + } + + if (url.includes('/status?state=cursor-state')) { + return Promise.resolve(createJsonResponse({ status: 'wait' })); + } + + return Promise.resolve(createJsonResponse({ success: true, account: { id: 'cursor-test' } })); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper }); + + await act(async () => { + await result.current.startAuth('cursor'); + }); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/cliproxy/auth/cursor/start-url', + expect.objectContaining({ method: 'POST' }) + ); + expect(result.current.isDeviceCodeFlow).toBe(false); + expect(result.current.authUrl).toContain('cursor.com/loginDeepControl'); + expect(window.open).toHaveBeenCalledWith( + 'https://cursor.com/loginDeepControl?state=cursor-state', + '_blank' + ); + }); + it('ignores stale poll completions from a superseded auth attempt', async () => { const firstPoll = createDeferred(); let startCount = 0; diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index f83ede63..f4552276 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -12,6 +12,7 @@ import { getRequestedUpstreamModelRuleErrors, getRequestedModelId, groupProvidersBySection, + isDeviceCodeProvider, isPlusExtraProvider, parseRequestedUpstreamModelRules, PLUS_EXTRA_CLIPROXY_PROVIDERS, @@ -90,6 +91,12 @@ describe('provider presentation metadata', () => { expect(grouped[1]?.items.map((entry) => entry.provider)).toEqual(['cursor']); }); + it('keeps Cursor out of verification-code device flow routing', () => { + expect(isDeviceCodeProvider('cursor')).toBe(false); + expect(isDeviceCodeProvider('ghcp')).toBe(true); + expect(isDeviceCodeProvider('qwen')).toBe(true); + }); + it('detects plus-extra providers inside composite variants', () => { expect( variantUsesPlusExtraProvider({