diff --git a/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts index 40d3321b..025e03fc 100644 --- a/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts @@ -80,6 +80,69 @@ describe('requestPasteCallbackStart', () => { }); }); +describe('Gemini Plus OAuth credential diagnostics', () => { + it('fails fast when Gemini uses Plus without OAuth client env', async () => { + const { getGeminiPlusOAuthCredentialError } = await import( + `../oauth-handler?gemini-plus-missing-env=${Date.now()}` + ); + + const error = getGeminiPlusOAuthCredentialError('gemini', 'plus', {}); + + expect(error).toContain('Gemini OAuth from CLIProxy Plus is missing'); + expect(error).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_ID'); + expect(error).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET'); + expect(error).toContain('cliproxy.backend'); + expect(error).toContain('original'); + }); + + it('allows Gemini Plus when both OAuth client env values exist', async () => { + const { getGeminiPlusOAuthCredentialError } = await import( + `../oauth-handler?gemini-plus-env-present=${Date.now()}` + ); + + expect( + getGeminiPlusOAuthCredentialError('gemini', 'plus', { + CLIPROXY_GEMINI_OAUTH_CLIENT_ID: 'client-id', + CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET: 'client-secret', + }) + ).toBeNull(); + }); + + it('does not warn for Gemini on the original backend', async () => { + const { getGeminiPlusOAuthCredentialError } = await import( + `../oauth-handler?gemini-original-backend=${Date.now()}` + ); + + expect(getGeminiPlusOAuthCredentialError('gemini', 'original', {})).toBeNull(); + }); + + it('detects Gemini auth URLs missing client_id before display', async () => { + const { getGeminiAuthUrlCredentialError } = await import( + `../oauth-handler?gemini-auth-url-missing-client=${Date.now()}` + ); + + const error = getGeminiAuthUrlCredentialError( + 'gemini', + 'https://accounts.google.com/o/oauth2/v2/auth?client_id=&redirect_uri=http%3A%2F%2Flocalhost%3A8085%2Foauth2callback&state=test' + ); + + expect(error).toContain('Gemini OAuth from CLIProxy Plus is missing'); + }); + + it('allows Gemini auth URLs with client_id present', async () => { + const { getGeminiAuthUrlCredentialError } = await import( + `../oauth-handler?gemini-auth-url-client-present=${Date.now()}` + ); + + expect( + getGeminiAuthUrlCredentialError( + 'gemini', + 'https://accounts.google.com/o/oauth2/v2/auth?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A8085%2Foauth2callback&state=test' + ) + ).toBeNull(); + }); +}); + describe('usesKiroLocalCallbackReplay', () => { it('limits local callback replay to CLI auth-code flows', async () => { const { usesKiroLocalCallbackReplay } = await import( diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 3777e9a3..c8dd2f74 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -13,9 +13,9 @@ import * as fs from 'fs'; import { fail, info, warn, color, ok } from '../../utils/ui'; import { createLogger } from '../../services/logging'; -import { ensureCLIProxyBinary } from '../binary-manager'; +import { ensureCLIProxyBinary, getStoredConfiguredBackend } from '../binary-manager'; import { generateConfig } from '../config/config-generator'; -import { CLIProxyProvider } from '../types'; +import { CLIProxyBackend, CLIProxyProvider } from '../types'; import { AccountInfo, getProviderAccounts, @@ -82,9 +82,54 @@ interface PasteCallbackStartData { const PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS = 3000; const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000; +const GEMINI_PLUS_CLIENT_ID_ENV = 'CLIPROXY_GEMINI_OAUTH_CLIENT_ID'; +const GEMINI_PLUS_CLIENT_SECRET_ENV = 'CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET'; const logger = createLogger('cliproxy:auth:oauth'); +function buildGeminiPlusOAuthCredentialMessage(missing?: string[]): string { + const missingText = missing?.length ? ` Missing: ${missing.join(', ')}.` : ''; + return ( + 'Gemini OAuth from CLIProxy Plus is missing Google OAuth client credentials.' + + missingText + + ` Set ${GEMINI_PLUS_CLIENT_ID_ENV} and ${GEMINI_PLUS_CLIENT_SECRET_ENV} before starting CLIProxy Plus,` + + ' or switch `cliproxy.backend` to `original` for Gemini.' + ); +} + +export function getGeminiPlusOAuthCredentialError( + provider: CLIProxyProvider, + backend: CLIProxyBackend, + env: NodeJS.ProcessEnv = process.env +): string | null { + if (provider !== 'gemini' || backend !== 'plus') { + return null; + } + + const missing = [GEMINI_PLUS_CLIENT_ID_ENV, GEMINI_PLUS_CLIENT_SECRET_ENV].filter( + (name) => !env[name]?.trim() + ); + + return missing.length > 0 ? buildGeminiPlusOAuthCredentialMessage(missing) : null; +} + +export function getGeminiAuthUrlCredentialError( + provider: CLIProxyProvider, + authUrl: string +): string | null { + if (provider !== 'gemini') { + return null; + } + + try { + const parsed = new URL(authUrl); + const clientId = parsed.searchParams.get('client_id')?.trim(); + return clientId ? null : buildGeminiPlusOAuthCredentialMessage(); + } catch { + return null; + } +} + export async function requestPasteCallbackStart( provider: CLIProxyProvider, target: ProxyTarget, @@ -549,6 +594,12 @@ async function handlePasteCallbackMode( return null; } + const authUrlCredentialError = getGeminiAuthUrlCredentialError(provider, authUrl); + if (authUrlCredentialError) { + console.log(fail(authUrlCredentialError)); + return null; + } + const oauthState = startData.state || parseAuthUrlState(authUrl); const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir); @@ -920,6 +971,17 @@ export async function triggerOAuth( const useSelectedKiroDirectCliFlow = provider === 'kiro' && (isDeviceCodeFlow || useSelectedKiroLocalPasteCallback); + if (!(selectedPasteCallback && !useSelectedKiroDirectCliFlow)) { + const credentialError = getGeminiPlusOAuthCredentialError( + provider, + getStoredConfiguredBackend() + ); + if (credentialError) { + console.log(fail(credentialError)); + return null; + } + } + if (existingAccounts.length > 0 && !add) { console.log(''); console.log(