diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx
index ef3dd4a9fe..9b9cb84b81 100644
--- a/packages/cli/src/ui/App.test.tsx
+++ b/packages/cli/src/ui/App.test.tsx
@@ -15,7 +15,6 @@ import type {
ToolRegistry,
SandboxConfig,
GeminiClient,
- AuthType,
} from '@google/gemini-cli-core';
import {
ApprovalMode,
@@ -34,7 +33,6 @@ import type { UpdateObject } from './utils/updateCheck.js';
import { checkForUpdates } from './utils/updateCheck.js';
import { EventEmitter } from 'node:events';
import { updateEventEmitter } from '../utils/updateEventEmitter.js';
-import * as auth from '../config/auth.js';
import * as useTerminalSize from './hooks/useTerminalSize.js';
// Define a more complete mock server config based on actual Config
@@ -224,14 +222,12 @@ vi.mock('./hooks/useGeminiStream', () => ({
})),
}));
-vi.mock('./hooks/useAuthCommand', () => ({
+vi.mock('./auth/useAuth.js', () => ({
useAuthCommand: vi.fn(() => ({
- isAuthDialogOpen: false,
- openAuthDialog: vi.fn(),
- handleAuthSelect: vi.fn(),
- handleAuthHighlight: vi.fn(),
- isAuthenticating: false,
- cancelAuthentication: vi.fn(),
+ authState: 'authenticated',
+ setAuthState: vi.fn(),
+ authError: null,
+ onAuthError: vi.fn(),
})),
}));
@@ -1189,60 +1185,6 @@ describe('App UI', () => {
});
});
- describe('auth validation', () => {
- it('should call validateAuthMethod when useExternalAuth is false', async () => {
- const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod');
- mockSettings = createMockSettings({
- workspace: {
- security: {
- auth: {
- selectedType: 'USE_GEMINI' as AuthType,
- useExternal: false,
- },
- },
- ui: { theme: 'Default' },
- },
- });
-
- const { unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- expect(validateAuthMethodSpy).toHaveBeenCalledWith('USE_GEMINI');
- });
-
- it('should NOT call validateAuthMethod when useExternalAuth is true', async () => {
- const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod');
- mockSettings = createMockSettings({
- workspace: {
- security: {
- auth: {
- selectedType: 'USE_GEMINI' as AuthType,
- useExternal: true,
- },
- },
- ui: { theme: 'Default' },
- },
- });
-
- const { unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- expect(validateAuthMethodSpy).not.toHaveBeenCalled();
- });
- });
-
describe('when in a narrow terminal', () => {
it('should render with a column layout', () => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index dc1a2c9cd3..28bf124895 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -15,6 +15,7 @@ import {
useStdout,
} from 'ink';
import {
+ AuthState,
StreamingState,
type HistoryItem,
MessageType,
@@ -25,7 +26,7 @@ import { useTerminalSize } from './hooks/useTerminalSize.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
-import { useAuthCommand } from './hooks/useAuthCommand.js';
+import { useAuthCommand } from './auth/useAuth.js';
import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
@@ -40,8 +41,6 @@ import { ShellModeIndicator } from './components/ShellModeIndicator.js';
import { InputPrompt } from './components/InputPrompt.js';
import { Footer } from './components/Footer.js';
import { ThemeDialog } from './components/ThemeDialog.js';
-import { AuthDialog } from './components/AuthDialog.js';
-import { AuthInProgress } from './components/AuthInProgress.js';
import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
import { FolderTrustDialog } from './components/FolderTrustDialog.js';
import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
@@ -82,7 +81,6 @@ import {
} from '@google/gemini-cli-core';
import type { IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
import { IdeIntegrationNudge } from './IdeIntegrationNudge.js';
-import { validateAuthMethod } from '../config/auth.js';
import { useLogger } from './hooks/useLogger.js';
import { StreamingContext } from './contexts/StreamingContext.js';
import {
@@ -116,6 +114,8 @@ import { isNarrowWidth } from './utils/isNarrowWidth.js';
import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js';
import { WorkspaceMigrationDialog } from './components/WorkspaceMigrationDialog.js';
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
+import { AuthInProgress } from './auth/AuthInProgress.js';
+import { AuthDialog } from './auth/AuthDialog.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
// Maximum number of queued messages to display in UI to prevent performance issues
@@ -217,7 +217,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const [geminiMdFileCount, setGeminiMdFileCount] = useState(0);
const [debugMessage, setDebugMessage] = useState('');
const [themeError, setThemeError] = useState(null);
- const [authError, setAuthError] = useState(null);
+
const [editorError, setEditorError] = useState(null);
const [footerHeight, setFooterHeight] = useState(0);
const [corgiMode, setCorgiMode] = useState(false);
@@ -338,52 +338,18 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
{ isActive: showIdeRestartPrompt },
);
- const {
- isAuthDialogOpen,
- openAuthDialog,
- handleAuthSelect,
- isAuthenticating,
- cancelAuthentication,
- } = useAuthCommand(settings, setAuthError, config);
-
- useEffect(() => {
- if (
- settings.merged.security?.auth?.enforcedType &&
- settings.merged.security?.auth.selectedType &&
- settings.merged.security?.auth.enforcedType !==
- settings.merged.security?.auth.selectedType
- ) {
- setAuthError(
- `Authentication is enforced to be ${settings.merged.security?.auth.enforcedType}, but you are currently using ${settings.merged.security?.auth.selectedType}.`,
- );
- openAuthDialog();
- } else if (
- settings.merged.security?.auth?.selectedType &&
- !settings.merged.security?.auth?.useExternal
- ) {
- const error = validateAuthMethod(
- settings.merged.security.auth.selectedType,
- );
- if (error) {
- setAuthError(error);
- openAuthDialog();
- }
- }
- }, [
- settings.merged.security?.auth?.selectedType,
- settings.merged.security?.auth?.enforcedType,
- settings.merged.security?.auth?.useExternal,
- openAuthDialog,
- setAuthError,
- ]);
+ const { authState, setAuthState, authError, onAuthError } = useAuthCommand(
+ settings,
+ config,
+ );
// Sync user tier from config when authentication changes
useEffect(() => {
// Only sync when not currently authenticating
- if (!isAuthenticating) {
+ if (authState === AuthState.Authenticated) {
setUserTier(config.getGeminiClient()?.getUserTier());
}
- }, [config, isAuthenticating]);
+ }, [config, authState]);
const {
isEditorDialogOpen,
@@ -622,11 +588,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
return editorType as EditorType;
}, [settings, openEditorDialog]);
- const onAuthError = useCallback(() => {
- setAuthError('reauth required');
- openAuthDialog();
- }, [openAuthDialog, setAuthError]);
-
// Core hooks and processors
const {
vimEnabled: vimModeEnabled,
@@ -650,7 +611,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
refreshStatic,
setDebugMessage,
openThemeDialog,
- openAuthDialog,
+ setAuthState,
openEditorDialog,
toggleCorgiMode,
setQuittingMessages,
@@ -843,7 +804,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
handleSlashCommand('/ide status');
} else if (keyMatchers[Command.QUIT](key)) {
// When authenticating, let AuthInProgress component handle Ctrl+C.
- if (isAuthenticating) {
+ if (authState === AuthState.Unauthenticated) {
return;
}
if (!ctrlCPressedOnce) {
@@ -879,7 +840,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
setCtrlDPressedOnce,
ctrlDTimerRef,
handleSlashCommand,
- isAuthenticating,
+ authState,
cancelOngoingRequest,
settings.merged.general?.debugKeystrokeLogging,
],
@@ -981,8 +942,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
if (
initialPrompt &&
!initialPromptSubmitted.current &&
- !isAuthenticating &&
- !isAuthDialogOpen &&
+ authState === AuthState.Authenticated &&
!isThemeDialogOpen &&
!isEditorDialogOpen &&
!showPrivacyNotice &&
@@ -994,8 +954,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
}, [
initialPrompt,
submitQuery,
- isAuthenticating,
- isAuthDialogOpen,
+ authState,
isThemeDialogOpen,
isEditorDialogOpen,
showPrivacyNotice,
@@ -1136,7 +1095,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
if (choice === 'auth') {
cancelOngoingRequest?.();
- openAuthDialog();
+ setAuthState(AuthState.Updating);
} else {
addItem(
{
@@ -1209,13 +1168,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
onRestartRequest={() => process.exit(0)}
/>
- ) : isAuthenticating ? (
+ ) : authState === AuthState.Unauthenticated ? (
<>
{
- setAuthError('Authentication timed out. Please try again.');
- cancelAuthentication();
- openAuthDialog();
+ onAuthError('Authentication timed out. Please try again.');
}}
/>
{showErrorDetails && (
@@ -1233,12 +1190,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
)}
>
- ) : isAuthDialogOpen ? (
+ ) : authState === AuthState.Updating ? (
) : isEditorDialogOpen ? (
diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx
new file mode 100644
index 0000000000..1c3a017352
--- /dev/null
+++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx
@@ -0,0 +1,258 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { renderWithProviders } from '../../test-utils/render.js';
+import {
+ describe,
+ it,
+ expect,
+ vi,
+ beforeEach,
+ afterEach,
+ type Mock,
+} from 'vitest';
+import { AuthDialog } from './AuthDialog.js';
+import { AuthType, type Config } from '@google/gemini-cli-core';
+import type { LoadedSettings } from '../../config/settings.js';
+import { SettingScope } from '../../config/settings.js';
+import { AuthState } from '../types.js';
+import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
+import { useKeypress } from '../hooks/useKeypress.js';
+import { validateAuthMethodWithSettings } from './useAuth.js';
+import { runExitCleanup } from '../../utils/cleanup.js';
+import { clearCachedCredentialFile } from '@google/gemini-cli-core';
+import { Text } from 'ink';
+
+// Mocks
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ clearCachedCredentialFile: vi.fn(),
+ };
+});
+
+vi.mock('../../utils/cleanup.js', () => ({
+ runExitCleanup: vi.fn(),
+}));
+
+vi.mock('./useAuth.js', () => ({
+ validateAuthMethodWithSettings: vi.fn(),
+}));
+
+vi.mock('../hooks/useKeypress.js', () => ({
+ useKeypress: vi.fn(),
+}));
+
+vi.mock('../components/shared/RadioButtonSelect.js', () => ({
+ RadioButtonSelect: vi.fn(({ items, initialIndex }) => (
+ <>
+ {items.map((item: { value: string; label: string }, index: number) => (
+
+ {index === initialIndex ? '(selected)' : '(not selected)'}{' '}
+ {item.label}
+
+ ))}
+ >
+ )),
+}));
+
+const mockedUseKeypress = useKeypress as Mock;
+const mockedRadioButtonSelect = RadioButtonSelect as Mock;
+const mockedValidateAuthMethod = validateAuthMethodWithSettings as Mock;
+const mockedRunExitCleanup = runExitCleanup as Mock;
+const mockedClearCachedCredentialFile = clearCachedCredentialFile as Mock;
+
+describe('AuthDialog', () => {
+ let props: {
+ config: Config;
+ settings: LoadedSettings;
+ setAuthState: (state: AuthState) => void;
+ authError: string | null;
+ onAuthError: (error: string) => void;
+ };
+ const originalEnv = { ...process.env };
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ process.env = {};
+
+ props = {
+ config: {
+ isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
+ } as unknown as Config,
+ settings: {
+ merged: {
+ security: {
+ auth: {},
+ },
+ },
+ setValue: vi.fn(),
+ } as unknown as LoadedSettings,
+ setAuthState: vi.fn(),
+ authError: null,
+ onAuthError: vi.fn(),
+ };
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ it('shows Cloud Shell option when in Cloud Shell environment', () => {
+ process.env['CLOUD_SHELL'] = 'true';
+ renderWithProviders();
+ const items = mockedRadioButtonSelect.mock.calls[0][0].items;
+ expect(items).toContainEqual({
+ label: 'Use Cloud Shell user credentials',
+ value: AuthType.CLOUD_SHELL,
+ });
+ });
+
+ it('filters auth types when enforcedType is set', () => {
+ props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
+ renderWithProviders();
+ const items = mockedRadioButtonSelect.mock.calls[0][0].items;
+ expect(items).toHaveLength(1);
+ expect(items[0].value).toBe(AuthType.USE_GEMINI);
+ });
+
+ it('sets initial index to 0 when enforcedType is set', () => {
+ props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
+ renderWithProviders();
+ const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
+ expect(initialIndex).toBe(0);
+ });
+
+ it('selects initial auth type from settings', () => {
+ props.settings.merged.security!.auth!.selectedType = AuthType.USE_VERTEX_AI;
+ renderWithProviders();
+ const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
+ expect(items[initialIndex].value).toBe(AuthType.USE_VERTEX_AI);
+ });
+
+ it('selects initial auth type from GEMINI_DEFAULT_AUTH_TYPE env var', () => {
+ process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
+ renderWithProviders();
+ const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
+ expect(items[initialIndex].value).toBe(AuthType.USE_GEMINI);
+ });
+
+ it('selects initial auth type from GEMINI_API_KEY env var', () => {
+ process.env['GEMINI_API_KEY'] = 'test-key';
+ renderWithProviders();
+ const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
+ expect(items[initialIndex].value).toBe(AuthType.USE_GEMINI);
+ });
+
+ it('defaults to Login with Google', () => {
+ renderWithProviders();
+ const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
+ expect(items[initialIndex].value).toBe(AuthType.LOGIN_WITH_GOOGLE);
+ });
+
+ describe('handleAuthSelect', () => {
+ it('calls onAuthError if validation fails', () => {
+ mockedValidateAuthMethod.mockReturnValue('Invalid method');
+ renderWithProviders();
+ const { onSelect: handleAuthSelect } =
+ mockedRadioButtonSelect.mock.calls[0][0];
+ handleAuthSelect(AuthType.USE_GEMINI);
+
+ expect(mockedValidateAuthMethod).toHaveBeenCalledWith(
+ AuthType.USE_GEMINI,
+ props.settings,
+ );
+ expect(props.onAuthError).toHaveBeenCalledWith('Invalid method');
+ expect(props.settings.setValue).not.toHaveBeenCalled();
+ });
+
+ it('calls onSelect if validation passes', async () => {
+ mockedValidateAuthMethod.mockReturnValue(null);
+ renderWithProviders();
+ const { onSelect: handleAuthSelect } =
+ mockedRadioButtonSelect.mock.calls[0][0];
+ await handleAuthSelect(AuthType.USE_GEMINI);
+
+ expect(mockedValidateAuthMethod).toHaveBeenCalledWith(
+ AuthType.USE_GEMINI,
+ props.settings,
+ );
+ expect(props.onAuthError).not.toHaveBeenCalled();
+ expect(mockedClearCachedCredentialFile).toHaveBeenCalled();
+ expect(props.settings.setValue).toHaveBeenCalledWith(
+ SettingScope.User,
+ 'security.auth.selectedType',
+ AuthType.USE_GEMINI,
+ );
+ expect(props.setAuthState).toHaveBeenCalledWith(
+ AuthState.Unauthenticated,
+ );
+ });
+
+ it('exits process for Login with Google when browser is suppressed', async () => {
+ const exitSpy = vi
+ .spyOn(process, 'exit')
+ .mockImplementation(() => undefined as never);
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+ vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
+ mockedValidateAuthMethod.mockReturnValue(null);
+
+ renderWithProviders();
+ const { onSelect: handleAuthSelect } =
+ mockedRadioButtonSelect.mock.calls[0][0];
+ await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
+
+ expect(mockedRunExitCleanup).toHaveBeenCalled();
+ expect(logSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Please restart Gemini CLI'),
+ );
+ expect(exitSpy).toHaveBeenCalledWith(0);
+
+ exitSpy.mockRestore();
+ logSpy.mockRestore();
+ });
+ });
+
+ it('displays authError when provided', () => {
+ props.authError = 'Something went wrong';
+ const { lastFrame } = renderWithProviders();
+ expect(lastFrame()).toContain('Something went wrong');
+ });
+
+ describe('useKeypress', () => {
+ it('does nothing on escape if authError is present', () => {
+ props.authError = 'Some error';
+ renderWithProviders();
+ const keypressHandler = mockedUseKeypress.mock.calls[0][0];
+ keypressHandler({ name: 'escape' });
+ expect(props.onAuthError).not.toHaveBeenCalled();
+ expect(props.setAuthState).not.toHaveBeenCalled();
+ });
+
+ it('calls onAuthError on escape if no auth method is set', () => {
+ props.settings.merged.security!.auth!.selectedType = undefined;
+ renderWithProviders();
+ const keypressHandler = mockedUseKeypress.mock.calls[0][0];
+ keypressHandler({ name: 'escape' });
+ expect(props.onAuthError).toHaveBeenCalledWith(
+ 'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
+ );
+ });
+
+ it('calls onSelect(undefined) on escape if auth method is set', () => {
+ props.settings.merged.security!.auth!.selectedType = AuthType.USE_GEMINI;
+ renderWithProviders();
+ const keypressHandler = mockedUseKeypress.mock.calls[0][0];
+ keypressHandler({ name: 'escape' });
+ expect(props.setAuthState).toHaveBeenCalledWith(
+ AuthState.Unauthenticated,
+ );
+ expect(props.settings.setValue).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx
similarity index 64%
rename from packages/cli/src/ui/components/AuthDialog.tsx
rename to packages/cli/src/ui/auth/AuthDialog.tsx
index 719aa1574f..5a3a01ca56 100644
--- a/packages/cli/src/ui/components/AuthDialog.tsx
+++ b/packages/cli/src/ui/auth/AuthDialog.tsx
@@ -5,63 +5,37 @@
*/
import type React from 'react';
-import { useState } from 'react';
+import { useCallback } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
-import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
+import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import type { LoadedSettings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
-import { AuthType } from '@google/gemini-cli-core';
-import { validateAuthMethod } from '../../config/auth.js';
+import {
+ AuthType,
+ clearCachedCredentialFile,
+ type Config,
+} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
+import { AuthState } from '../types.js';
+import { runExitCleanup } from '../../utils/cleanup.js';
+import { validateAuthMethodWithSettings } from './useAuth.js';
interface AuthDialogProps {
- onSelect: (authMethod: AuthType | undefined, scope: SettingScope) => void;
+ config: Config;
settings: LoadedSettings;
- initialErrorMessage?: string | null;
-}
-
-function parseDefaultAuthType(
- defaultAuthType: string | undefined,
-): AuthType | null {
- if (
- defaultAuthType &&
- Object.values(AuthType).includes(defaultAuthType as AuthType)
- ) {
- return defaultAuthType as AuthType;
- }
- return null;
+ setAuthState: (state: AuthState) => void;
+ authError: string | null;
+ onAuthError: (error: string) => void;
}
export function AuthDialog({
- onSelect,
+ config,
settings,
- initialErrorMessage,
+ setAuthState,
+ authError,
+ onAuthError,
}: AuthDialogProps): React.JSX.Element {
- const [errorMessage, setErrorMessage] = useState(() => {
- if (initialErrorMessage) {
- return initialErrorMessage;
- }
-
- const defaultAuthType = parseDefaultAuthType(
- process.env['GEMINI_DEFAULT_AUTH_TYPE'],
- );
-
- if (process.env['GEMINI_DEFAULT_AUTH_TYPE'] && defaultAuthType === null) {
- return (
- `Invalid value for GEMINI_DEFAULT_AUTH_TYPE: "${process.env['GEMINI_DEFAULT_AUTH_TYPE']}". ` +
- `Valid values are: ${Object.values(AuthType).join(', ')}.`
- );
- }
-
- if (
- process.env['GEMINI_API_KEY'] &&
- (!defaultAuthType || defaultAuthType === AuthType.USE_GEMINI)
- ) {
- return 'Existing API key detected (GEMINI_API_KEY). Select "Gemini API Key" option to use it.';
- }
- return null;
- });
let items = [
{
label: 'Login with Google',
@@ -88,14 +62,20 @@ export function AuthDialog({
);
}
+ let defaultAuthType = null;
+ const defaultAuthTypeEnv = process.env['GEMINI_DEFAULT_AUTH_TYPE'];
+ if (
+ defaultAuthTypeEnv &&
+ Object.values(AuthType).includes(defaultAuthTypeEnv as AuthType)
+ ) {
+ defaultAuthType = defaultAuthTypeEnv as AuthType;
+ }
+
let initialAuthIndex = items.findIndex((item) => {
if (settings.merged.security?.auth?.selectedType) {
return item.value === settings.merged.security.auth.selectedType;
}
- const defaultAuthType = parseDefaultAuthType(
- process.env['GEMINI_DEFAULT_AUTH_TYPE'],
- );
if (defaultAuthType) {
return item.value === defaultAuthType;
}
@@ -110,12 +90,37 @@ export function AuthDialog({
initialAuthIndex = 0;
}
+ const onSelect = useCallback(
+ async (authType: AuthType | undefined, scope: SettingScope) => {
+ if (authType) {
+ await clearCachedCredentialFile();
+
+ settings.setValue(scope, 'security.auth.selectedType', authType);
+ if (
+ authType === AuthType.LOGIN_WITH_GOOGLE &&
+ config.isBrowserLaunchSuppressed()
+ ) {
+ runExitCleanup();
+ console.log(
+ `
+----------------------------------------------------------------
+Logging in with Google... Please restart Gemini CLI to continue.
+----------------------------------------------------------------
+ `,
+ );
+ process.exit(0);
+ }
+ }
+ setAuthState(AuthState.Unauthenticated);
+ },
+ [settings, config, setAuthState],
+ );
+
const handleAuthSelect = (authMethod: AuthType) => {
- const error = validateAuthMethod(authMethod);
+ const error = validateAuthMethodWithSettings(authMethod, settings);
if (error) {
- setErrorMessage(error);
+ onAuthError(error);
} else {
- setErrorMessage(null);
onSelect(authMethod, SettingScope.User);
}
};
@@ -125,12 +130,12 @@ export function AuthDialog({
if (key.name === 'escape') {
// Prevent exit if there is an error message.
// This means they user is not authenticated yet.
- if (errorMessage) {
+ if (authError) {
return;
}
if (settings.merged.security?.auth?.selectedType === undefined) {
// Prevent exiting if no auth method is set
- setErrorMessage(
+ onAuthError(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
);
return;
@@ -160,9 +165,9 @@ export function AuthDialog({
onSelect={handleAuthSelect}
/>
- {errorMessage && (
+ {authError && (
- {errorMessage}
+ {authError}
)}
diff --git a/packages/cli/src/ui/components/AuthInProgress.tsx b/packages/cli/src/ui/auth/AuthInProgress.tsx
similarity index 100%
rename from packages/cli/src/ui/components/AuthInProgress.tsx
rename to packages/cli/src/ui/auth/AuthInProgress.tsx
diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts
new file mode 100644
index 0000000000..69898f64a4
--- /dev/null
+++ b/packages/cli/src/ui/auth/useAuth.ts
@@ -0,0 +1,96 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useState, useEffect, useCallback } from 'react';
+import type { LoadedSettings } from '../../config/settings.js';
+import { AuthType, type Config } from '@google/gemini-cli-core';
+import { getErrorMessage } from '@google/gemini-cli-core';
+import { AuthState } from '../types.js';
+import { validateAuthMethod } from '../../config/auth.js';
+
+export function validateAuthMethodWithSettings(
+ authType: AuthType,
+ settings: LoadedSettings,
+): string | null {
+ const enforcedType = settings.merged.security?.auth?.enforcedType;
+ if (enforcedType && enforcedType !== authType) {
+ return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`;
+ }
+ if (settings.merged.security?.auth?.useExternal) {
+ return null;
+ }
+ return validateAuthMethod(authType);
+}
+
+export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
+ const [authState, setAuthState] = useState(
+ AuthState.Unauthenticated,
+ );
+
+ const [authError, setAuthError] = useState(null);
+
+ const onAuthError = useCallback(
+ (error: string) => {
+ setAuthError(error);
+ setAuthState(AuthState.Updating);
+ },
+ [setAuthError, setAuthState],
+ );
+
+ useEffect(() => {
+ (async () => {
+ if (authState !== AuthState.Unauthenticated) {
+ return;
+ }
+
+ const authType = settings.merged.security?.auth?.selectedType;
+ if (!authType) {
+ if (process.env['GEMINI_API_KEY']) {
+ onAuthError(
+ 'Existing API key detected (GEMINI_API_KEY). Select "Gemini API Key" option to use it.',
+ );
+ } else {
+ onAuthError('No authentication method selected.');
+ }
+ return;
+ }
+ const error = validateAuthMethodWithSettings(authType, settings);
+ if (error) {
+ onAuthError(error);
+ return;
+ }
+
+ const defaultAuthType = process.env['GEMINI_DEFAULT_AUTH_TYPE'];
+ if (
+ defaultAuthType &&
+ !Object.values(AuthType).includes(defaultAuthType as AuthType)
+ ) {
+ onAuthError(
+ `Invalid value for GEMINI_DEFAULT_AUTH_TYPE: "${defaultAuthType}". ` +
+ `Valid values are: ${Object.values(AuthType).join(', ')}.`,
+ );
+ return;
+ }
+
+ try {
+ await config.refreshAuth(authType);
+
+ console.log(`Authenticated via "${authType}".`);
+ setAuthError(null);
+ setAuthState(AuthState.Authenticated);
+ } catch (e) {
+ onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
+ }
+ })();
+ }, [settings, config, authState, setAuthState, setAuthError, onAuthError]);
+
+ return {
+ authState,
+ setAuthState,
+ authError,
+ onAuthError,
+ };
+};
diff --git a/packages/cli/src/ui/components/AuthDialog.test.tsx b/packages/cli/src/ui/components/AuthDialog.test.tsx
deleted file mode 100644
index abe68b3755..0000000000
--- a/packages/cli/src/ui/components/AuthDialog.test.tsx
+++ /dev/null
@@ -1,473 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { AuthDialog } from './AuthDialog.js';
-import { LoadedSettings, SettingScope } from '../../config/settings.js';
-import { AuthType } from '@google/gemini-cli-core';
-import { renderWithProviders } from '../../test-utils/render.js';
-
-describe('AuthDialog', () => {
- const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
-
- let originalEnv: NodeJS.ProcessEnv;
-
- beforeEach(() => {
- originalEnv = { ...process.env };
- process.env['GEMINI_API_KEY'] = '';
- process.env['GEMINI_DEFAULT_AUTH_TYPE'] = '';
- vi.clearAllMocks();
- });
-
- afterEach(() => {
- process.env = originalEnv;
- });
-
- it('should show an error if the initial auth type is invalid', () => {
- process.env['GEMINI_API_KEY'] = '';
-
- const settings: LoadedSettings = new LoadedSettings(
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- {
- settings: {},
- path: '',
- },
- {
- settings: {
- security: {
- auth: {
- selectedType: AuthType.USE_GEMINI,
- },
- },
- },
- path: '',
- },
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- true,
- new Set(),
- );
-
- const { lastFrame } = renderWithProviders(
- {}}
- settings={settings}
- initialErrorMessage="GEMINI_API_KEY environment variable not found"
- />,
- );
-
- expect(lastFrame()).toContain(
- 'GEMINI_API_KEY environment variable not found',
- );
- });
-
- describe('GEMINI_API_KEY environment variable', () => {
- it('should detect GEMINI_API_KEY environment variable', () => {
- process.env['GEMINI_API_KEY'] = 'foobar';
-
- const settings: LoadedSettings = new LoadedSettings(
- {
- settings: {
- security: { auth: { selectedType: undefined } },
- ui: { customThemes: {} },
- mcpServers: {},
- },
- path: '',
- },
- {
- settings: {},
- path: '',
- },
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- true,
- new Set(),
- );
-
- const { lastFrame } = renderWithProviders(
- {}} settings={settings} />,
- );
-
- expect(lastFrame()).toContain(
- 'Existing API key detected (GEMINI_API_KEY)',
- );
- });
-
- it('should not show the GEMINI_API_KEY message if GEMINI_DEFAULT_AUTH_TYPE is set to something else', () => {
- process.env['GEMINI_API_KEY'] = 'foobar';
- process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.LOGIN_WITH_GOOGLE;
-
- const settings: LoadedSettings = new LoadedSettings(
- {
- settings: {
- security: { auth: { selectedType: undefined } },
- ui: { customThemes: {} },
- mcpServers: {},
- },
- path: '',
- },
- {
- settings: {},
- path: '',
- },
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- true,
- new Set(),
- );
-
- const { lastFrame } = renderWithProviders(
- {}} settings={settings} />,
- );
-
- expect(lastFrame()).not.toContain(
- 'Existing API key detected (GEMINI_API_KEY)',
- );
- });
-
- it('should show the GEMINI_API_KEY message if GEMINI_DEFAULT_AUTH_TYPE is set to use api key', () => {
- process.env['GEMINI_API_KEY'] = 'foobar';
- process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
-
- const settings: LoadedSettings = new LoadedSettings(
- {
- settings: {
- security: { auth: { selectedType: undefined } },
- ui: { customThemes: {} },
- mcpServers: {},
- },
- path: '',
- },
- {
- settings: {},
- path: '',
- },
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- true,
- new Set(),
- );
-
- const { lastFrame } = renderWithProviders(
- {}} settings={settings} />,
- );
-
- expect(lastFrame()).toContain(
- 'Existing API key detected (GEMINI_API_KEY)',
- );
- });
- });
-
- describe('GEMINI_DEFAULT_AUTH_TYPE environment variable', () => {
- it('should select the auth type specified by GEMINI_DEFAULT_AUTH_TYPE', () => {
- process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.LOGIN_WITH_GOOGLE;
-
- const settings: LoadedSettings = new LoadedSettings(
- {
- settings: {
- security: { auth: { selectedType: undefined } },
- ui: { customThemes: {} },
- mcpServers: {},
- },
- path: '',
- },
- {
- settings: {},
- path: '',
- },
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- true,
- new Set(),
- );
-
- const { lastFrame } = renderWithProviders(
- {}} settings={settings} />,
- );
-
- // This is a bit brittle, but it's the best way to check which item is selected.
- expect(lastFrame()).toContain('● 1. Login with Google');
- });
-
- it('should fall back to default if GEMINI_DEFAULT_AUTH_TYPE is not set', () => {
- const settings: LoadedSettings = new LoadedSettings(
- {
- settings: {
- security: { auth: { selectedType: undefined } },
- ui: { customThemes: {} },
- mcpServers: {},
- },
- path: '',
- },
- {
- settings: {},
- path: '',
- },
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- true,
- new Set(),
- );
-
- const { lastFrame } = renderWithProviders(
- {}} settings={settings} />,
- );
-
- // Default is LOGIN_WITH_GOOGLE
- expect(lastFrame()).toContain('● 1. Login with Google');
- });
-
- it('should show an error and fall back to default if GEMINI_DEFAULT_AUTH_TYPE is invalid', () => {
- process.env['GEMINI_DEFAULT_AUTH_TYPE'] = 'invalid-auth-type';
-
- const settings: LoadedSettings = new LoadedSettings(
- {
- settings: {
- security: { auth: { selectedType: undefined } },
- ui: { customThemes: {} },
- mcpServers: {},
- },
- path: '',
- },
- {
- settings: {},
- path: '',
- },
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- true,
- new Set(),
- );
-
- const { lastFrame } = renderWithProviders(
- {}} settings={settings} />,
- );
-
- expect(lastFrame()).toContain(
- 'Invalid value for GEMINI_DEFAULT_AUTH_TYPE: "invalid-auth-type"',
- );
-
- // Default is LOGIN_WITH_GOOGLE
- expect(lastFrame()).toContain('● 1. Login with Google');
- });
- });
-
- it('should prevent exiting when no auth method is selected and show error message', async () => {
- const onSelect = vi.fn();
- const settings: LoadedSettings = new LoadedSettings(
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- {
- settings: {},
- path: '',
- },
- {
- settings: {
- security: { auth: { selectedType: undefined } },
- ui: { customThemes: {} },
- mcpServers: {},
- },
- path: '',
- },
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- true,
- new Set(),
- );
-
- const { lastFrame, stdin, unmount } = renderWithProviders(
- ,
- );
- await wait();
-
- // Simulate pressing escape key
- stdin.write('\u001b'); // ESC key
- await wait();
-
- // Should show error message instead of calling onSelect
- expect(lastFrame()).toContain(
- 'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
- );
- expect(onSelect).not.toHaveBeenCalled();
- unmount();
- });
-
- it('should not exit if there is already an error message', async () => {
- const onSelect = vi.fn();
- const settings: LoadedSettings = new LoadedSettings(
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- {
- settings: {},
- path: '',
- },
- {
- settings: {
- security: { auth: { selectedType: undefined } },
- ui: { customThemes: {} },
- mcpServers: {},
- },
- path: '',
- },
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- true,
- new Set(),
- );
-
- const { lastFrame, stdin, unmount } = renderWithProviders(
- ,
- );
- await wait();
-
- expect(lastFrame()).toContain('Initial error');
-
- // Simulate pressing escape key
- stdin.write('\u001b'); // ESC key
- await wait();
-
- // Should not call onSelect
- expect(onSelect).not.toHaveBeenCalled();
- unmount();
- });
-
- it('should allow exiting when auth method is already selected', async () => {
- const onSelect = vi.fn();
- const settings: LoadedSettings = new LoadedSettings(
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- {
- settings: {},
- path: '',
- },
- {
- settings: {
- security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
- ui: { customThemes: {} },
- mcpServers: {},
- },
- path: '',
- },
- {
- settings: { ui: { customThemes: {} }, mcpServers: {} },
- path: '',
- },
- true,
- new Set(),
- );
-
- const { stdin, unmount } = renderWithProviders(
- ,
- );
- await wait();
-
- // Simulate pressing escape key
- stdin.write('\u001b'); // ESC key
- await wait();
-
- // Should call onSelect with undefined to exit
- expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
- unmount();
- });
-
- describe('enforcedAuthType', () => {
- it('should display the enforced auth type message if enforcedAuthType is set', () => {
- const settings: LoadedSettings = new LoadedSettings(
- {
- settings: {
- security: {
- auth: {
- enforcedType: AuthType.USE_VERTEX_AI,
- },
- },
- },
- path: '',
- },
- {
- settings: {
- security: {
- auth: {
- selectedType: AuthType.USE_VERTEX_AI,
- },
- },
- },
- path: '',
- },
- {
- settings: {},
- path: '',
- },
- {
- settings: {},
- path: '',
- },
- true,
- new Set(),
- );
-
- const { lastFrame } = renderWithProviders(
- {}} settings={settings} />,
- );
-
- expect(lastFrame()).toContain('1. Vertex AI');
- });
- });
-});
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
index a7c3ce583f..1587af93b9 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
@@ -21,11 +21,12 @@ import {
} from '@google/gemini-cli-core';
import { useSessionStats } from '../contexts/SessionContext.js';
import { runExitCleanup } from '../../utils/cleanup.js';
-import type {
- Message,
- HistoryItemWithoutId,
- HistoryItem,
- SlashCommandProcessorResult,
+import {
+ type Message,
+ type HistoryItemWithoutId,
+ type HistoryItem,
+ type SlashCommandProcessorResult,
+ AuthState,
} from '../types.js';
import { MessageType } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
@@ -47,7 +48,7 @@ export const useSlashCommandProcessor = (
refreshStatic: () => void,
onDebugMessage: (message: string) => void,
openThemeDialog: () => void,
- openAuthDialog: () => void,
+ setAuthState: (state: AuthState) => void,
openEditorDialog: () => void,
toggleCorgiMode: () => void,
setQuittingMessages: (message: HistoryItem[]) => void,
@@ -375,7 +376,7 @@ export const useSlashCommandProcessor = (
case 'dialog':
switch (result.dialog) {
case 'auth':
- openAuthDialog();
+ setAuthState(AuthState.Updating);
return { type: 'handled' };
case 'theme':
openThemeDialog();
@@ -554,7 +555,7 @@ export const useSlashCommandProcessor = (
[
config,
addItem,
- openAuthDialog,
+ setAuthState,
commands,
commandContext,
addMessage,
diff --git a/packages/cli/src/ui/hooks/useAuthCommand.ts b/packages/cli/src/ui/hooks/useAuthCommand.ts
deleted file mode 100644
index f1fdcd9118..0000000000
--- a/packages/cli/src/ui/hooks/useAuthCommand.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { useState, useCallback, useEffect } from 'react';
-import type { LoadedSettings, SettingScope } from '../../config/settings.js';
-import { AuthType, type Config } from '@google/gemini-cli-core';
-import {
- clearCachedCredentialFile,
- getErrorMessage,
-} from '@google/gemini-cli-core';
-import { runExitCleanup } from '../../utils/cleanup.js';
-
-export const useAuthCommand = (
- settings: LoadedSettings,
- setAuthError: (error: string | null) => void,
- config: Config,
-) => {
- const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(
- settings.merged.security?.auth?.selectedType === undefined,
- );
-
- const openAuthDialog = useCallback(() => {
- setIsAuthDialogOpen(true);
- }, []);
-
- const [isAuthenticating, setIsAuthenticating] = useState(false);
-
- useEffect(() => {
- const authFlow = async () => {
- const authType = settings.merged.security?.auth?.selectedType;
- if (isAuthDialogOpen || !authType) {
- return;
- }
-
- try {
- setIsAuthenticating(true);
- await config.refreshAuth(authType);
- console.log(`Authenticated via "${authType}".`);
- } catch (e) {
- setAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
- openAuthDialog();
- } finally {
- setIsAuthenticating(false);
- }
- };
-
- void authFlow();
- }, [isAuthDialogOpen, settings, config, setAuthError, openAuthDialog]);
-
- const handleAuthSelect = useCallback(
- async (authType: AuthType | undefined, scope: SettingScope) => {
- if (authType) {
- await clearCachedCredentialFile();
-
- settings.setValue(scope, 'security.auth.selectedType', authType);
- if (
- authType === AuthType.LOGIN_WITH_GOOGLE &&
- config.isBrowserLaunchSuppressed()
- ) {
- runExitCleanup();
- console.log(
- `
-----------------------------------------------------------------
-Logging in with Google... Please restart Gemini CLI to continue.
-----------------------------------------------------------------
- `,
- );
- process.exit(0);
- }
- }
- setIsAuthDialogOpen(false);
- setAuthError(null);
- },
- [settings, setAuthError, config],
- );
-
- const cancelAuthentication = useCallback(() => {
- setIsAuthenticating(false);
- }, []);
-
- return {
- isAuthDialogOpen,
- openAuthDialog,
- handleAuthSelect,
- isAuthenticating,
- cancelAuthentication,
- };
-};
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index 7e325c3c1b..edaf33d547 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -95,7 +95,7 @@ export const useGeminiStream = (
) => Promise,
shellModeActive: boolean,
getPreferredEditor: () => EditorType | undefined,
- onAuthError: () => void,
+ onAuthError: (error: string) => void,
performMemoryRefresh: () => Promise,
modelSwitchedFromQuotaError: boolean,
setModelSwitchedFromQuotaError: React.Dispatch>,
@@ -751,7 +751,7 @@ export const useGeminiStream = (
}
} catch (error: unknown) {
if (error instanceof UnauthorizedError) {
- onAuthError();
+ onAuthError('Session expired or is unauthorized.');
} else if (!isNodeError(error) || error.name !== 'AbortError') {
addItem(
{
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index 5461b521a5..ca222f4d6b 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -11,6 +11,15 @@ import type {
} from '@google/gemini-cli-core';
import type { PartListUnion } from '@google/genai';
+export enum AuthState {
+ // Attemtping to authenticate or re-authenticate
+ Unauthenticated = 'unauthenticated',
+ // Auth dialog is open for user to select auth method
+ Updating = 'updating',
+ // Successfully authenticated
+ Authenticated = 'authenticated',
+}
+
// Only defining the state enum needed by the UI
export enum StreamingState {
Idle = 'idle',