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 # 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. 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 | - User enters code at github.com/login/device
| - Polls for token completion | - 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 | v
| +------------------+ | +------------------+
| | OAuth Server | Browser-based auth | | 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 | | Antigravity | `agy` | Authorization Code | 9876 | CLIProxyAPI |
| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | 9876 | CLIProxyAPIPlus fork | | Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | 9876 | CLIProxyAPIPlus fork |
| GitHub Copilot | `ghcp` | Device Code | none | CLIProxyAPIPlus fork | | GitHub Copilot | `ghcp` | Device Code | none | CLIProxyAPIPlus fork |
| Cursor | `cursor` | Browser URL polling | none | CLIProxyAPIPlus fork |
### Codex Duplicate-Email Account Identity ### Codex Duplicate-Email Account Identity
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test';
import { import {
buildProviderAliasMap, buildProviderAliasMap,
CLIPROXY_PROVIDER_IDS, CLIPROXY_PROVIDER_IDS,
getDeviceCodeVerificationProviders,
getOAuthCallbackPort, getOAuthCallbackPort,
getOAuthFlowType, getOAuthFlowType,
PROVIDER_CAPABILITIES, 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', () => { it('maps external provider aliases to canonical IDs', () => {
expect(mapExternalProviderName('gemini-cli')).toBe('gemini'); expect(mapExternalProviderName('gemini-cli')).toBe('gemini');
expect(mapExternalProviderName('antigravity')).toBe('agy'); 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 { DEFAULT_CURSOR_PORT as BACKEND_CURSOR_DEFAULT_PORT } from '../../../cursor/cursor-models';
import { import {
CLIPROXY_PROVIDER_IDS as BACKEND_CLIPROXY_PROVIDER_IDS, CLIPROXY_PROVIDER_IDS as BACKEND_CLIPROXY_PROVIDER_IDS,
getDeviceCodeVerificationProviders,
getProviderDescription as getBackendProviderDescription, getProviderDescription as getBackendProviderDescription,
getProviderDisplayName as getBackendProviderDisplayName, getProviderDisplayName as getBackendProviderDisplayName,
getProvidersByOAuthFlow,
} from '../../provider-capabilities'; } from '../../provider-capabilities';
import { import {
CLIPROXY_DEFAULT_PORT as UI_CLIPROXY_DEFAULT_PORT, CLIPROXY_DEFAULT_PORT as UI_CLIPROXY_DEFAULT_PORT,
@@ -20,9 +20,9 @@ import {
import { import {
CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS, CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS,
CORE_CLIPROXY_PROVIDERS as UI_CORE_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, PLUS_EXTRA_CLIPROXY_PROVIDERS as UI_PLUS_EXTRA_CLIPROXY_PROVIDERS,
PROVIDER_METADATA as UI_PROVIDER_METADATA, PROVIDER_METADATA as UI_PROVIDER_METADATA,
VERIFICATION_CODE_AUTH_PROVIDERS as UI_VERIFICATION_CODE_AUTH_PROVIDERS,
} from '../../../../ui/src/lib/provider-config'; } from '../../../../ui/src/lib/provider-config';
import { PLUS_ONLY_PROVIDERS as BACKEND_PLUS_ONLY_PROVIDERS } from '../../types/index'; 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)); expect(sorted(UI_CLIPROXY_PROVIDERS)).toEqual(sorted(BACKEND_CLIPROXY_PROVIDER_IDS));
}); });
test('Device code providers are synced between backend and UI', () => { test('verification-code auth providers are synced between backend and UI', () => {
expect(sorted(UI_DEVICE_CODE_PROVIDERS)).toEqual( expect(sorted(UI_VERIFICATION_CODE_AUTH_PROVIDERS)).toEqual(
sorted(getProvidersByOAuthFlow('device_code')) sorted(getDeviceCodeVerificationProviders())
); );
}); });
+16
View File
@@ -188,6 +188,12 @@ export const CLIPROXY_PROVIDER_IDS = Object.freeze(
Object.keys(PROVIDER_CAPABILITIES) as CLIProxyProvider[] 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. */ /** Providers currently supported by quota status fetchers. */
export const QUOTA_SUPPORTED_PROVIDER_IDS = Object.freeze([ export const QUOTA_SUPPORTED_PROVIDER_IDS = Object.freeze([
'agy', '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 { export function getOAuthFlowType(provider: CLIProxyProvider): OAuthFlowType {
return PROVIDER_CAPABILITIES[provider].oauthFlow; return PROVIDER_CAPABILITIES[provider].oauthFlow;
} }
@@ -56,7 +56,11 @@ import {
normalizeKiroAuthMethod, normalizeKiroAuthMethod,
toKiroManagementMethod, toKiroManagementMethod,
} from '../../cliproxy/auth/auth-types'; } 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 type { CLIProxyProvider } from '../../cliproxy/types';
import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
import { import {
@@ -286,6 +290,10 @@ export function getStartUrlUnsupportedReason(
return null; return null;
} }
if (isBrowserUrlAuthProvider(provider)) {
return null;
}
if (getOAuthFlowType(provider) === 'device_code') { if (getOAuthFlowType(provider) === 'device_code') {
return `Provider '${provider}' uses Device Code flow. Use /api/cliproxy/auth/${provider}/start instead.`; 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 { import {
getDeviceCodeProviderInstruction, getDeviceCodeProviderInstruction,
getProviderDisplayName, getProviderDisplayName,
isDeviceCodeProvider,
} from '../../../ui/src/lib/provider-config'; } from '../../../ui/src/lib/provider-config';
describe('provider-config fallbacks', () => { describe('provider-config fallbacks', () => {
@@ -15,4 +16,10 @@ describe('provider-config fallbacks', () => {
'Complete the authorization in your browser.' '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, getCachedQuota,
setCachedQuota, setCachedQuota,
} from '../../../src/cliproxy/quota/quota-response-cache'; } 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', () => { describe('cliproxy-auth-routes manual callback nickname persistence', () => {
let server: Server; 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'); 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 () => { it('returns wait after callback submission when the local token is not yet available', async () => {
mockFetch([ mockFetch([
{ {
@@ -13,15 +13,14 @@ describe('cliproxy-auth-routes start-url guard', () => {
); );
expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow"); expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow");
expect(getStartUrlUnsupportedReason('qwen')).toContain("Provider 'qwen' 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( expect(getStartUrlUnsupportedReason('codebuddy')).toContain(
"Provider 'codebuddy' uses Device Code flow" "Provider 'codebuddy' uses Device Code flow"
); );
expect(getStartUrlUnsupportedReason('kilo')).toContain( expect(getStartUrlUnsupportedReason('kilo')).toContain("Provider 'kilo' uses Device Code flow");
"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', () => { 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', '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. * Start auth flow using provider capabilities.
* - Device code providers use /start and rely on WebSocket events for code display. * - Verification-code providers use /start and WebSocket events for code display.
* - Authorization code providers use /start-url and polling. * - Browser URL providers use /start-url and polling.
*/ */
const handleAuthenticate = () => { const handleAuthenticate = () => {
if (isPowerUserModePending) { if (isPowerUserModePending) {
+9 -8
View File
@@ -7,7 +7,7 @@
import { import {
CLIPROXY_PROVIDER_IDS, CLIPROXY_PROVIDER_IDS,
PROVIDER_CAPABILITIES, PROVIDER_CAPABILITIES,
getProvidersByOAuthFlow, getDeviceCodeVerificationProviders,
} from '../../../src/cliproxy/provider-capabilities'; } from '../../../src/cliproxy/provider-capabilities';
import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/cliproxy/ai-providers'; import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/cliproxy/ai-providers';
import { PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types'; import { PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types';
@@ -346,13 +346,14 @@ export function getProviderDescription(provider: unknown): string {
return PROVIDER_METADATA[normalized].description; return PROVIDER_METADATA[normalized].description;
} }
/** /** Providers whose add-account UX shows a verification code and uses `/start`. */
* Providers that use Device Code OAuth flow instead of Authorization Code flow. export const VERIFICATION_CODE_AUTH_PROVIDERS: CLIProxyProvider[] = [
*/ ...getDeviceCodeVerificationProviders(),
export const DEVICE_CODE_PROVIDERS: CLIProxyProvider[] = [
...getProvidersByOAuthFlow('device_code'),
]; ];
/** 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>>> = const DEVICE_CODE_PROVIDER_DISPLAY_NAMES: Readonly<Partial<Record<CLIProxyProvider, string>>> =
Object.freeze({ Object.freeze({
ghcp: 'GitHub Copilot', 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.', 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 { export function isDeviceCodeProvider(provider: unknown): boolean {
const normalized = normalizeProviderInput(provider); 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. */ /** Provider display name tuned for device-code UX copy. */
@@ -45,6 +45,47 @@ describe('useCliproxyAuthFlow', () => {
vi.restoreAllMocks(); 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 () => { it('ignores stale poll completions from a superseded auth attempt', async () => {
const firstPoll = createDeferred<Response>(); const firstPoll = createDeferred<Response>();
let startCount = 0; let startCount = 0;
@@ -12,6 +12,7 @@ import {
getRequestedUpstreamModelRuleErrors, getRequestedUpstreamModelRuleErrors,
getRequestedModelId, getRequestedModelId,
groupProvidersBySection, groupProvidersBySection,
isDeviceCodeProvider,
isPlusExtraProvider, isPlusExtraProvider,
parseRequestedUpstreamModelRules, parseRequestedUpstreamModelRules,
PLUS_EXTRA_CLIPROXY_PROVIDERS, PLUS_EXTRA_CLIPROXY_PROVIDERS,
@@ -90,6 +91,12 @@ describe('provider presentation metadata', () => {
expect(grouped[1]?.items.map((entry) => entry.provider)).toEqual(['cursor']); 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', () => { it('detects plus-extra providers inside composite variants', () => {
expect( expect(
variantUsesPlusExtraProvider({ variantUsesPlusExtraProvider({