diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 0c41a94db7..c5656a6b72 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -240,6 +240,10 @@ Settings are organized into categories. All settings should be placed within the - **Description:** The currently selected authentication type. - **Default:** `undefined` +- **`security.auth.enforcedType`** (string): + - **Description:** The required auth type (useful for enterprises). + - **Default:** `undefined` + - **`security.auth.useExternal`** (boolean): - **Description:** Whether to use an external authentication flow. - **Default:** `undefined` diff --git a/docs/cli/enterprise.md b/docs/cli/enterprise.md index d4c0dcf96a..9333bf1c4b 100644 --- a/docs/cli/enterprise.md +++ b/docs/cli/enterprise.md @@ -317,6 +317,20 @@ For auditing and monitoring purposes, you can configure Gemini CLI to send telem **Note:** Ensure that `logPrompts` is set to `false` in an enterprise setting to avoid collecting potentially sensitive information from user prompts. +## Authentication + +You can enforce a specific authentication method for all users by setting the `enforcedAuthType` in the system-level `settings.json` file. This prevents users from choosing a different authentication method. See the [Authentication docs](./authentication.md) for more details. + +**Example:** Enforce the use of Google login for all users. + +```json +{ + "enforcedAuthType": "oauth-personal" +} +``` + +If a user has a different authentication method configured, they will be prompted to switch to the enforced method. In non-interactive mode, the CLI will exit with an error if the configured authentication method does not match the enforced one. + ## Putting It All Together: Example System `settings.json` Here is an example of a system `settings.json` file that combines several of the patterns discussed above to create a secure, controlled environment for Gemini CLI. diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 291df99139..4ad91eb4e1 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -85,6 +85,7 @@ const MIGRATION_MAP: Record = { excludeMCPServers: 'mcp.excluded', folderTrust: 'security.folderTrust.enabled', selectedAuthType: 'security.auth.selectedType', + enforcedAuthType: 'security.auth.enforcedType', useExternalAuth: 'security.auth.useExternal', autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory', dnsResolutionOrder: 'advanced.dnsResolutionOrder', diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 1708ddcaa3..2bd3a354f3 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -737,6 +737,16 @@ export const SETTINGS_SCHEMA = { description: 'The currently selected authentication type.', showInDialog: false, }, + enforcedType: { + type: 'string', + label: 'Enforced Auth Type', + category: 'Advanced', + requiresRestart: true, + default: undefined as AuthType | undefined, + description: + 'The required auth type. If this does not match the selected auth type, the user will be prompted to re-authenticate.', + showInDialog: false, + }, useExternal: { type: 'boolean', label: 'Use External Auth', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index a358b83bdf..79bf14270f 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -425,6 +425,7 @@ export async function main() { settings.merged.security?.auth?.selectedType, settings.merged.security?.auth?.useExternal, config, + settings, ); if (config.getDebugMode()) { diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index e11aefc2df..c83cbc8a85 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -333,6 +333,16 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { 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 ) { @@ -346,6 +356,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { } }, [ settings.merged.security?.auth?.selectedType, + settings.merged.security?.auth?.enforcedType, settings.merged.security?.auth?.useExternal, openAuthDialog, setAuthError, diff --git a/packages/cli/src/ui/components/AuthDialog.test.tsx b/packages/cli/src/ui/components/AuthDialog.test.tsx index dc5d6090d3..abe68b3755 100644 --- a/packages/cli/src/ui/components/AuthDialog.test.tsx +++ b/packages/cli/src/ui/components/AuthDialog.test.tsx @@ -427,4 +427,47 @@ describe('AuthDialog', () => { 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/components/AuthDialog.tsx b/packages/cli/src/ui/components/AuthDialog.tsx index 01ead9ee8e..719aa1574f 100644 --- a/packages/cli/src/ui/components/AuthDialog.tsx +++ b/packages/cli/src/ui/components/AuthDialog.tsx @@ -62,7 +62,7 @@ export function AuthDialog({ } return null; }); - const items = [ + let items = [ { label: 'Login with Google', value: AuthType.LOGIN_WITH_GOOGLE, @@ -82,7 +82,13 @@ export function AuthDialog({ { label: 'Vertex AI', value: AuthType.USE_VERTEX_AI }, ]; - const initialAuthIndex = items.findIndex((item) => { + if (settings.merged.security?.auth?.enforcedType) { + items = items.filter( + (item) => item.value === settings.merged.security?.auth?.enforcedType, + ); + } + + let initialAuthIndex = items.findIndex((item) => { if (settings.merged.security?.auth?.selectedType) { return item.value === settings.merged.security.auth.selectedType; } @@ -100,6 +106,9 @@ export function AuthDialog({ return item.value === AuthType.LOGIN_WITH_GOOGLE; }); + if (settings.merged.security?.auth?.enforcedType) { + initialAuthIndex = 0; + } const handleAuthSelect = (authMethod: AuthType) => { const error = validateAuthMethod(authMethod); diff --git a/packages/cli/src/validateNonInterActiveAuth.test.ts b/packages/cli/src/validateNonInterActiveAuth.test.ts index e0a36d7bec..f7ef8c85be 100644 --- a/packages/cli/src/validateNonInterActiveAuth.test.ts +++ b/packages/cli/src/validateNonInterActiveAuth.test.ts @@ -5,10 +5,10 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { NonInteractiveConfig } from './validateNonInterActiveAuth.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import { AuthType } from '@google/gemini-cli-core'; import * as auth from './config/auth.js'; +import { type LoadedSettings } from './config/settings.js'; describe('validateNonInterActiveAuth', () => { let originalEnvGeminiApiKey: string | undefined; @@ -16,9 +16,8 @@ describe('validateNonInterActiveAuth', () => { let originalEnvGcp: string | undefined; let consoleErrorSpy: ReturnType; let processExitSpy: ReturnType; - let refreshAuthMock: jest.MockedFunction< - (authType: AuthType) => Promise - >; + let refreshAuthMock: vi.Mock; + let mockSettings: LoadedSettings; beforeEach(() => { originalEnvGeminiApiKey = process.env['GEMINI_API_KEY']; @@ -32,6 +31,25 @@ describe('validateNonInterActiveAuth', () => { throw new Error(`process.exit(${code}) called`); }); refreshAuthMock = vi.fn().mockResolvedValue('refreshed'); + mockSettings = { + system: { path: '', settings: {} }, + systemDefaults: { path: '', settings: {} }, + user: { path: '', settings: {} }, + workspace: { path: '', settings: {} }, + errors: [], + setValue: vi.fn(), + merged: { + security: { + auth: { + enforcedType: undefined, + }, + }, + }, + isTrusted: true, + migratedInMemorScopes: new Set(), + forScope: vi.fn(), + computeMergedSettings: vi.fn(), + } as unknown as LoadedSettings; }); afterEach(() => { @@ -54,7 +72,7 @@ describe('validateNonInterActiveAuth', () => { }); it('exits if no auth type is configured or env vars set', async () => { - const nonInteractiveConfig: NonInteractiveConfig = { + const nonInteractiveConfig = { refreshAuth: refreshAuthMock, }; try { @@ -62,6 +80,7 @@ describe('validateNonInterActiveAuth', () => { undefined, undefined, nonInteractiveConfig, + mockSettings, ); expect.fail('Should have exited'); } catch (e) { @@ -75,26 +94,28 @@ describe('validateNonInterActiveAuth', () => { it('uses LOGIN_WITH_GOOGLE if GOOGLE_GENAI_USE_GCA is set', async () => { process.env['GOOGLE_GENAI_USE_GCA'] = 'true'; - const nonInteractiveConfig: NonInteractiveConfig = { + const nonInteractiveConfig = { refreshAuth: refreshAuthMock, }; await validateNonInteractiveAuth( undefined, undefined, nonInteractiveConfig, + mockSettings, ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.LOGIN_WITH_GOOGLE); }); it('uses USE_GEMINI if GEMINI_API_KEY is set', async () => { process.env['GEMINI_API_KEY'] = 'fake-key'; - const nonInteractiveConfig: NonInteractiveConfig = { + const nonInteractiveConfig = { refreshAuth: refreshAuthMock, }; await validateNonInteractiveAuth( undefined, undefined, nonInteractiveConfig, + mockSettings, ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI); }); @@ -103,13 +124,14 @@ describe('validateNonInterActiveAuth', () => { process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true'; process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project'; process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1'; - const nonInteractiveConfig: NonInteractiveConfig = { + const nonInteractiveConfig = { refreshAuth: refreshAuthMock, }; await validateNonInteractiveAuth( undefined, undefined, nonInteractiveConfig, + mockSettings, ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_VERTEX_AI); }); @@ -117,13 +139,14 @@ describe('validateNonInterActiveAuth', () => { it('uses USE_VERTEX_AI if GOOGLE_GENAI_USE_VERTEXAI is true and GOOGLE_API_KEY is set', async () => { process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true'; process.env['GOOGLE_API_KEY'] = 'vertex-api-key'; - const nonInteractiveConfig: NonInteractiveConfig = { + const nonInteractiveConfig = { refreshAuth: refreshAuthMock, }; await validateNonInteractiveAuth( undefined, undefined, nonInteractiveConfig, + mockSettings, ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_VERTEX_AI); }); @@ -134,13 +157,14 @@ describe('validateNonInterActiveAuth', () => { process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true'; process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project'; process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1'; - const nonInteractiveConfig: NonInteractiveConfig = { + const nonInteractiveConfig = { refreshAuth: refreshAuthMock, }; await validateNonInteractiveAuth( undefined, undefined, nonInteractiveConfig, + mockSettings, ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.LOGIN_WITH_GOOGLE); }); @@ -150,13 +174,14 @@ describe('validateNonInterActiveAuth', () => { process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true'; process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project'; process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1'; - const nonInteractiveConfig: NonInteractiveConfig = { + const nonInteractiveConfig = { refreshAuth: refreshAuthMock, }; await validateNonInteractiveAuth( undefined, undefined, nonInteractiveConfig, + mockSettings, ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_VERTEX_AI); }); @@ -166,13 +191,14 @@ describe('validateNonInterActiveAuth', () => { process.env['GEMINI_API_KEY'] = 'fake-key'; process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project'; process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1'; - const nonInteractiveConfig: NonInteractiveConfig = { + const nonInteractiveConfig = { refreshAuth: refreshAuthMock, }; await validateNonInteractiveAuth( undefined, undefined, nonInteractiveConfig, + mockSettings, ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI); }); @@ -180,13 +206,14 @@ describe('validateNonInterActiveAuth', () => { it('uses configuredAuthType if provided', async () => { // Set required env var for USE_GEMINI process.env['GEMINI_API_KEY'] = 'fake-key'; - const nonInteractiveConfig: NonInteractiveConfig = { + const nonInteractiveConfig = { refreshAuth: refreshAuthMock, }; await validateNonInteractiveAuth( AuthType.USE_GEMINI, undefined, nonInteractiveConfig, + mockSettings, ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI); }); @@ -194,7 +221,7 @@ describe('validateNonInterActiveAuth', () => { it('exits if validateAuthMethod returns error', async () => { // Mock validateAuthMethod to return error vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!'); - const nonInteractiveConfig: NonInteractiveConfig = { + const nonInteractiveConfig = { refreshAuth: refreshAuthMock, }; try { @@ -202,6 +229,7 @@ describe('validateNonInterActiveAuth', () => { AuthType.USE_GEMINI, undefined, nonInteractiveConfig, + mockSettings, ); expect.fail('Should have exited'); } catch (e) { @@ -216,7 +244,7 @@ describe('validateNonInterActiveAuth', () => { const validateAuthMethodSpy = vi .spyOn(auth, 'validateAuthMethod') .mockReturnValue('Auth error!'); - const nonInteractiveConfig: NonInteractiveConfig = { + const nonInteractiveConfig = { refreshAuth: refreshAuthMock, }; @@ -226,6 +254,7 @@ describe('validateNonInterActiveAuth', () => { 'invalid-auth-type' as AuthType, true, // useExternalAuth = true nonInteractiveConfig, + mockSettings, ); expect(validateAuthMethodSpy).not.toHaveBeenCalled(); @@ -234,4 +263,44 @@ describe('validateNonInterActiveAuth', () => { // We still expect refreshAuth to be called with the (invalid) type expect(refreshAuthMock).toHaveBeenCalledWith('invalid-auth-type'); }); + + it('uses enforcedAuthType if provided', async () => { + mockSettings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; + mockSettings.merged.security.auth.selectedType = AuthType.USE_GEMINI; + // Set required env var for USE_GEMINI to ensure enforcedAuthType takes precedence + process.env['GEMINI_API_KEY'] = 'fake-key'; + const nonInteractiveConfig = { + refreshAuth: refreshAuthMock, + }; + await validateNonInteractiveAuth( + AuthType.USE_GEMINI, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI); + }); + + it('exits if currentAuthType does not match enforcedAuthType', async () => { + mockSettings.merged.security.auth.enforcedType = AuthType.LOGIN_WITH_GOOGLE; + process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true'; + const nonInteractiveConfig = { + refreshAuth: refreshAuthMock, + }; + try { + await validateNonInteractiveAuth( + AuthType.USE_GEMINI, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect.fail('Should have exited'); + } catch (e) { + expect((e as Error).message).toContain('process.exit(1) called'); + } + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'The configured auth type is oauth-personal, but the current auth type is vertex-ai. Please re-authenticate with the correct type.', + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); }); diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index 1a15c0faf6..1339c1eb4c 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -8,6 +8,7 @@ import type { Config } from '@google/gemini-cli-core'; import { AuthType } from '@google/gemini-cli-core'; import { USER_SETTINGS_PATH } from './config/settings.js'; import { validateAuthMethod } from './config/auth.js'; +import { type LoadedSettings } from './config/settings.js'; function getAuthTypeFromEnv(): AuthType | undefined { if (process.env['GOOGLE_GENAI_USE_GCA'] === 'true') { @@ -26,8 +27,21 @@ export async function validateNonInteractiveAuth( configuredAuthType: AuthType | undefined, useExternalAuth: boolean | undefined, nonInteractiveConfig: Config, + settings: LoadedSettings, ) { - const effectiveAuthType = configuredAuthType || getAuthTypeFromEnv(); + const enforcedType = settings.merged.security?.auth?.enforcedType; + if (enforcedType) { + const currentAuthType = getAuthTypeFromEnv(); + if (currentAuthType !== enforcedType) { + console.error( + `The configured auth type is ${enforcedType}, but the current auth type is ${currentAuthType}. Please re-authenticate with the correct type.`, + ); + process.exit(1); + } + } + + const effectiveAuthType = + enforcedType || getAuthTypeFromEnv() || configuredAuthType; if (!effectiveAuthType) { console.error(