summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSeth Troisi <[email protected]>2025-07-10 18:59:02 -0700
committerGitHub <[email protected]>2025-07-11 01:59:02 +0000
commit8a128d8dc6c1c5d7aea7004e0efa9fd175be36e5 (patch)
treecf9d6f3bb06586e6cefe3d72bdd015531f887494
parentab66e3a24ebc3ec6c2e8f0c68065680066e265cf (diff)
Add NO_BROWSER environment variable to trigger offline oauth flow (#3713)
-rw-r--r--packages/cli/src/config/config.ts1
-rw-r--r--packages/cli/src/gemini.tsx10
-rw-r--r--packages/cli/src/ui/App.tsx30
-rw-r--r--packages/core/src/code_assist/codeAssist.ts4
-rw-r--r--packages/core/src/code_assist/oauth2.test.ts22
-rw-r--r--packages/core/src/code_assist/oauth2.ts98
-rw-r--r--packages/core/src/config/config.ts7
-rw-r--r--packages/core/src/core/client.test.ts1
-rw-r--r--packages/core/src/core/client.ts1
-rw-r--r--packages/core/src/core/contentGenerator.test.ts27
-rw-r--r--packages/core/src/core/contentGenerator.ts3
11 files changed, 169 insertions, 35 deletions
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index 20a8afcf..b80b6dd0 100644
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -317,6 +317,7 @@ export async function loadCliConfig(
name: e.config.name,
version: e.config.version,
})),
+ noBrowser: !!process.env.NO_BROWSER,
});
}
diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index 84a3da62..8b58c46a 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -35,6 +35,7 @@ import {
sessionId,
logUserPrompt,
AuthType,
+ getOauthClient,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from './config/auth.js';
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
@@ -165,6 +166,15 @@ export async function main() {
}
}
}
+
+ if (
+ settings.merged.selectedAuthType === AuthType.LOGIN_WITH_GOOGLE &&
+ config.getNoBrowser()
+ ) {
+ // Do oauth before app renders to make copying the link possible.
+ await getOauthClient(settings.merged.selectedAuthType, config);
+ }
+
let input = config.getQuestion();
const startupWarnings = [
...(await getStartupWarnings()),
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index e3a5eb55..2a6bf088 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -728,13 +728,29 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
/>
</Box>
) : isAuthenticating ? (
- <AuthInProgress
- onTimeout={() => {
- setAuthError('Authentication timed out. Please try again.');
- cancelAuthentication();
- openAuthDialog();
- }}
- />
+ <>
+ <AuthInProgress
+ onTimeout={() => {
+ setAuthError('Authentication timed out. Please try again.');
+ cancelAuthentication();
+ openAuthDialog();
+ }}
+ />
+ {showErrorDetails && (
+ <OverflowProvider>
+ <Box flexDirection="column">
+ <DetailedMessagesDisplay
+ messages={filteredConsoleMessages}
+ maxHeight={
+ constrainHeight ? debugConsoleMaxHeight : undefined
+ }
+ width={inputWidth}
+ />
+ <ShowMoreLines constrainHeight={constrainHeight} />
+ </Box>
+ </OverflowProvider>
+ )}
+ </>
) : isAuthDialogOpen ? (
<Box flexDirection="column">
<AuthDialog
diff --git a/packages/core/src/code_assist/codeAssist.ts b/packages/core/src/code_assist/codeAssist.ts
index 5f3f843e..23dfe403 100644
--- a/packages/core/src/code_assist/codeAssist.ts
+++ b/packages/core/src/code_assist/codeAssist.ts
@@ -8,17 +8,19 @@ import { AuthType, ContentGenerator } from '../core/contentGenerator.js';
import { getOauthClient } from './oauth2.js';
import { setupUser } from './setup.js';
import { CodeAssistServer, HttpOptions } from './server.js';
+import { Config } from '../config/config.js';
export async function createCodeAssistContentGenerator(
httpOptions: HttpOptions,
authType: AuthType,
+ config: Config,
sessionId?: string,
): Promise<ContentGenerator> {
if (
authType === AuthType.LOGIN_WITH_GOOGLE ||
authType === AuthType.CLOUD_SHELL
) {
- const authClient = await getOauthClient(authType);
+ const authClient = await getOauthClient(authType, config);
const projectId = await setupUser(authClient);
return new CodeAssistServer(authClient, projectId, httpOptions, sessionId);
}
diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts
index 76d43726..d8cd525b 100644
--- a/packages/core/src/code_assist/oauth2.test.ts
+++ b/packages/core/src/code_assist/oauth2.test.ts
@@ -14,6 +14,7 @@ import open from 'open';
import crypto from 'crypto';
import * as os from 'os';
import { AuthType } from '../core/contentGenerator.js';
+import { Config } from '../config/config.js';
vi.mock('os', async (importOriginal) => {
const os = await importOriginal<typeof import('os')>();
@@ -28,6 +29,10 @@ vi.mock('http');
vi.mock('open');
vi.mock('crypto');
+const mockConfig = {
+ getNoBrowser: () => false,
+} as unknown as Config;
+
// Mock fetch globally
global.fetch = vi.fn();
@@ -136,7 +141,10 @@ describe('oauth2', () => {
return mockHttpServer as unknown as http.Server;
});
- const clientPromise = getOauthClient(AuthType.LOGIN_WITH_GOOGLE);
+ const clientPromise = getOauthClient(
+ AuthType.LOGIN_WITH_GOOGLE,
+ mockConfig,
+ );
// wait for server to start listening.
await serverListeningPromise;
@@ -214,7 +222,7 @@ describe('oauth2', () => {
() => mockClient as unknown as OAuth2Client,
);
- await getOauthClient(AuthType.LOGIN_WITH_GOOGLE);
+ await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig);
expect(fs.promises.readFile).toHaveBeenCalledWith(
'/user/home/.gemini/oauth_creds.json',
@@ -227,7 +235,7 @@ describe('oauth2', () => {
});
it('should use Compute to get a client if no cached credentials exist', async () => {
- await getOauthClient(AuthType.CLOUD_SHELL);
+ await getOauthClient(AuthType.CLOUD_SHELL, mockConfig);
expect(Compute).toHaveBeenCalledWith({});
expect(mockGetAccessToken).toHaveBeenCalled();
@@ -238,13 +246,13 @@ describe('oauth2', () => {
mockComputeClient.credentials = newCredentials;
mockGetAccessToken.mockResolvedValue({ token: 'new-adc-token' });
- await getOauthClient(AuthType.CLOUD_SHELL);
+ await getOauthClient(AuthType.CLOUD_SHELL, mockConfig);
expect(fs.promises.writeFile).not.toHaveBeenCalled();
});
it('should return the Compute client on successful ADC authentication', async () => {
- const client = await getOauthClient(AuthType.CLOUD_SHELL);
+ const client = await getOauthClient(AuthType.CLOUD_SHELL, mockConfig);
expect(client).toBe(mockComputeClient);
});
@@ -252,7 +260,9 @@ describe('oauth2', () => {
const testError = new Error('ADC Failed');
mockGetAccessToken.mockRejectedValue(testError);
- await expect(getOauthClient(AuthType.CLOUD_SHELL)).rejects.toThrow(
+ await expect(
+ getOauthClient(AuthType.CLOUD_SHELL, mockConfig),
+ ).rejects.toThrow(
'Could not authenticate using Cloud Shell credentials. Please select a different authentication method or ensure you are in a properly configured environment. Error: ADC Failed',
);
});
diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts
index 93d0e28b..2d3c04d0 100644
--- a/packages/core/src/code_assist/oauth2.ts
+++ b/packages/core/src/code_assist/oauth2.ts
@@ -4,7 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { OAuth2Client, Credentials, Compute } from 'google-auth-library';
+import {
+ OAuth2Client,
+ Credentials,
+ Compute,
+ CodeChallengeMethod,
+} from 'google-auth-library';
import * as http from 'http';
import url from 'url';
import crypto from 'crypto';
@@ -13,8 +18,10 @@ import open from 'open';
import path from 'node:path';
import { promises as fs, existsSync, readFileSync } from 'node:fs';
import * as os from 'os';
+import { Config } from '../config/config.js';
import { getErrorMessage } from '../utils/errors.js';
import { AuthType } from '../core/contentGenerator.js';
+import readline from 'node:readline';
// OAuth Client ID used to initiate OAuth2Client class.
const OAUTH_CLIENT_ID =
@@ -57,6 +64,7 @@ export interface OauthWebLogin {
export async function getOauthClient(
authType: AuthType,
+ config: Config,
): Promise<OAuth2Client> {
const client = new OAuth2Client({
clientId: OAUTH_CLIENT_ID,
@@ -109,27 +117,93 @@ export async function getOauthClient(
}
}
- // Otherwise, obtain creds using standard web flow
- const webLogin = await authWithWeb(client);
+ if (config.getNoBrowser()) {
+ let success = false;
+ const maxRetries = 2;
+ for (let i = 0; !success && i < maxRetries; i++) {
+ success = await authWithUserCode(client);
+ if (!success) {
+ console.error(
+ '\nFailed to authenticate with user code.',
+ i === maxRetries - 1 ? '' : 'Retrying...\n',
+ );
+ }
+ }
+ if (!success) {
+ process.exit(1);
+ }
+ } else {
+ const webLogin = await authWithWeb(client);
- console.log(
- `\n\nCode Assist login required.\n` +
- `Attempting to open authentication page in your browser.\n` +
- `Otherwise navigate to:\n\n${webLogin.authUrl}\n\n`,
- );
- await open(webLogin.authUrl);
- console.log('Waiting for authentication...');
+ // This does basically nothing, as it isn't show to the user.
+ console.log(
+ `\n\nCode Assist login required.\n` +
+ `Attempting to open authentication page in your browser.\n` +
+ `Otherwise navigate to:\n\n${webLogin.authUrl}\n\n`,
+ );
+ await open(webLogin.authUrl);
+ console.log('Waiting for authentication...');
- await webLogin.loginCompletePromise;
+ await webLogin.loginCompletePromise;
+ }
return client;
}
+async function authWithUserCode(client: OAuth2Client): Promise<boolean> {
+ const redirectUri = 'https://sdk.cloud.google.com/authcode_cloudcode.html';
+ const codeVerifier = await client.generateCodeVerifierAsync();
+ const state = crypto.randomBytes(32).toString('hex');
+ const authUrl: string = client.generateAuthUrl({
+ redirect_uri: redirectUri,
+ access_type: 'offline',
+ scope: OAUTH_SCOPE,
+ code_challenge_method: CodeChallengeMethod.S256,
+ code_challenge: codeVerifier.codeChallenge,
+ state,
+ });
+ console.error('Please visit the following URL to authorize the application:');
+ console.error('');
+ console.error(authUrl);
+ console.error('');
+
+ const code = await new Promise<string>((resolve) => {
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+ rl.question('Enter the authorization code: ', (answer) => {
+ rl.close();
+ resolve(answer.trim());
+ });
+ });
+
+ if (!code) {
+ console.error('Authorization code is required.');
+ return false;
+ } else {
+ console.error(`Received authorization code: "${code}"`);
+ }
+
+ try {
+ const response = await client.getToken({
+ code,
+ codeVerifier: codeVerifier.codeVerifier,
+ redirect_uri: redirectUri,
+ });
+ client.setCredentials(response.tokens);
+ } catch (_error) {
+ // Consider logging the error.
+ return false;
+ }
+ return true;
+}
+
async function authWithWeb(client: OAuth2Client): Promise<OauthWebLogin> {
const port = await getAvailablePort();
const redirectUri = `http://localhost:${port}/oauth2callback`;
const state = crypto.randomBytes(32).toString('hex');
- const authUrl: string = client.generateAuthUrl({
+ const authUrl = client.generateAuthUrl({
redirect_uri: redirectUri,
access_type: 'offline',
scope: OAUTH_SCOPE,
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index 51915fc8..15e9e73b 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -141,6 +141,7 @@ export interface ConfigParameters {
extensionContextFilePaths?: string[];
listExtensions?: boolean;
activeExtensions?: ActiveExtension[];
+ noBrowser?: boolean;
}
export class Config {
@@ -179,6 +180,7 @@ export class Config {
private readonly bugCommand: BugCommandSettings | undefined;
private readonly model: string;
private readonly extensionContextFilePaths: string[];
+ private readonly noBrowser: boolean;
private modelSwitchedDuringSession: boolean = false;
private readonly listExtensions: boolean;
private readonly _activeExtensions: ActiveExtension[];
@@ -227,6 +229,7 @@ export class Config {
this.extensionContextFilePaths = params.extensionContextFilePaths ?? [];
this.listExtensions = params.listExtensions ?? false;
this._activeExtensions = params.activeExtensions ?? [];
+ this.noBrowser = params.noBrowser ?? false;
if (params.contextFileName) {
setGeminiMdFilename(params.contextFileName);
@@ -475,6 +478,10 @@ export class Config {
return this._activeExtensions;
}
+ getNoBrowser(): boolean {
+ return this.noBrowser;
+ }
+
async getGitService(): Promise<GitService> {
if (!this.gitService) {
this.gitService = new GitService(this.targetDir);
diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts
index 44828a74..2769e1b0 100644
--- a/packages/core/src/core/client.test.ts
+++ b/packages/core/src/core/client.test.ts
@@ -180,6 +180,7 @@ describe('Gemini Client (client.ts)', () => {
getFileService: vi.fn().mockReturnValue(fileService),
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
setQuotaErrorOccurred: vi.fn(),
+ getNoBrowser: vi.fn().mockReturnValue(false),
};
return mock as unknown as Config;
});
diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts
index eee52cb4..5d9ac0cb 100644
--- a/packages/core/src/core/client.ts
+++ b/packages/core/src/core/client.ts
@@ -109,6 +109,7 @@ export class GeminiClient {
async initialize(contentGeneratorConfig: ContentGeneratorConfig) {
this.contentGenerator = await createContentGenerator(
contentGeneratorConfig,
+ this.config,
this.config.getSessionId(),
);
this.chat = await this.startChat();
diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts
index eb480710..92144aa4 100644
--- a/packages/core/src/core/contentGenerator.test.ts
+++ b/packages/core/src/core/contentGenerator.test.ts
@@ -12,20 +12,26 @@ import {
} from './contentGenerator.js';
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
import { GoogleGenAI } from '@google/genai';
+import { Config } from '../config/config.js';
vi.mock('../code_assist/codeAssist.js');
vi.mock('@google/genai');
+const mockConfig = {} as unknown as Config;
+
describe('createContentGenerator', () => {
it('should create a CodeAssistContentGenerator', async () => {
const mockGenerator = {} as unknown;
vi.mocked(createCodeAssistContentGenerator).mockResolvedValue(
mockGenerator as never,
);
- const generator = await createContentGenerator({
- model: 'test-model',
- authType: AuthType.LOGIN_WITH_GOOGLE,
- });
+ const generator = await createContentGenerator(
+ {
+ model: 'test-model',
+ authType: AuthType.LOGIN_WITH_GOOGLE,
+ },
+ mockConfig,
+ );
expect(createCodeAssistContentGenerator).toHaveBeenCalled();
expect(generator).toBe(mockGenerator);
});
@@ -35,11 +41,14 @@ describe('createContentGenerator', () => {
models: {},
} as unknown;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
- const generator = await createContentGenerator({
- model: 'test-model',
- apiKey: 'test-api-key',
- authType: AuthType.USE_GEMINI,
- });
+ const generator = await createContentGenerator(
+ {
+ model: 'test-model',
+ apiKey: 'test-api-key',
+ authType: AuthType.USE_GEMINI,
+ },
+ mockConfig,
+ );
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts
index e9e1138f..fee10fad 100644
--- a/packages/core/src/core/contentGenerator.ts
+++ b/packages/core/src/core/contentGenerator.ts
@@ -15,6 +15,7 @@ import {
} from '@google/genai';
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
+import { Config } from '../config/config.js';
import { getEffectiveModel } from './modelCheck.js';
/**
@@ -99,6 +100,7 @@ export async function createContentGeneratorConfig(
export async function createContentGenerator(
config: ContentGeneratorConfig,
+ gcConfig: Config,
sessionId?: string,
): Promise<ContentGenerator> {
const version = process.env.CLI_VERSION || process.version;
@@ -114,6 +116,7 @@ export async function createContentGenerator(
return createCodeAssistContentGenerator(
httpOptions,
config.authType,
+ gcConfig,
sessionId,
);
}