fix: route Cursor auth through browser polling

Closes #1194
This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-07 11:25:17 -04:00
committed by GitHub
parent a95266e4cc
commit 8b681df455
12 changed files with 152 additions and 25 deletions
+7 -1
View File
@@ -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
@@ -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');
@@ -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())
);
});
+16
View File
@@ -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<CLIProxyProvider>(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;
}
@@ -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.`;
}
+7
View File
@@ -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);
});
});
@@ -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([
{
@@ -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();
});
});
@@ -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) {
+9 -8
View File
@@ -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<Partial<Record<CLIProxyProvider, string>>> =
Object.freeze({
ghcp: 'GitHub Copilot',
@@ -368,10 +369,10 @@ const DEVICE_CODE_PROVIDER_INSTRUCTIONS: Readonly<Partial<Record<CLIProxyProvide
kimi: 'Sign in with your Kimi account and finish the device authorization.',
});
/** Check if provider uses Device Code flow */
/** Check if the add-account UI should use the verification-code auth dialog. */
export function isDeviceCodeProvider(provider: unknown): boolean {
const normalized = normalizeProviderInput(provider);
return isValidProvider(normalized) && DEVICE_CODE_PROVIDERS.includes(normalized);
return isValidProvider(normalized) && VERIFICATION_CODE_AUTH_PROVIDERS.includes(normalized);
}
/** Provider display name tuned for device-code UX copy. */
@@ -45,6 +45,47 @@ describe('useCliproxyAuthFlow', () => {
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<Response>();
let startCount = 0;
@@ -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({