diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts index 76d43726..58081e6a 100644 --- a/packages/core/src/code_assist/oauth2.test.ts +++ b/packages/core/src/code_assist/oauth2.test.ts @@ -61,30 +61,11 @@ describe('oauth2', () => { const mockGetAccessToken = vi .fn() .mockResolvedValue({ token: 'mock-access-token' }); - const mockRefreshAccessToken = vi.fn().mockImplementation((callback) => { - // Mock the callback-style refreshAccessToken method - const mockTokensWithIdToken = { - access_token: 'test-access-token', - refresh_token: 'test-refresh-token', - id_token: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LWdvb2dsZS1hY2NvdW50LWlkLTEyMyJ9.signature', // Mock JWT with sub: test-google-account-id-123 - }; - callback(null, mockTokensWithIdToken); - }); - const mockVerifyIdToken = vi.fn().mockResolvedValue({ - getPayload: () => ({ - sub: 'test-google-account-id-123', - aud: 'test-audience', - iss: 'https://accounts.google.com', - }), - }); const mockOAuth2Client = { generateAuthUrl: mockGenerateAuthUrl, getToken: mockGetToken, setCredentials: mockSetCredentials, getAccessToken: mockGetAccessToken, - refreshAccessToken: mockRefreshAccessToken, - verifyIdToken: mockVerifyIdToken, credentials: mockTokens, on: vi.fn(), } as unknown as OAuth2Client; diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index 93d0e28b..c2fd0d12 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -44,6 +44,7 @@ const SIGN_IN_FAILURE_URL = const GEMINI_DIR = '.gemini'; const CREDENTIAL_FILENAME = 'oauth_creds.json'; const GOOGLE_ACCOUNT_ID_FILENAME = 'google_account_id'; +const GOOGLE_ACCOUNT_EMAIL_FILENAME = 'google_account_email'; /** * An Authentication URL for updating the credentials of a Oauth2Client @@ -70,13 +71,10 @@ export async function getOauthClient( // If there are cached creds on disk, they always take precedence if (await loadCachedCredentials(client)) { // Found valid cached credentials. - // Check if we need to retrieve Google Account ID - if (!getCachedGoogleAccountId()) { + // Check if we need to retrieve Google Account ID or Email + if (!getCachedGoogleAccountId() || !getCachedGoogleAccountEmail()) { try { - const googleAccountId = await getRawGoogleAccountId(client); - if (googleAccountId) { - await cacheGoogleAccountId(googleAccountId); - } + await fetchAndCacheUserInfo(client); } catch { // Non-fatal, continue with existing auth. } @@ -163,10 +161,7 @@ async function authWithWeb(client: OAuth2Client): Promise { client.setCredentials(tokens); // Retrieve and cache Google Account ID during authentication try { - const googleAccountId = await getRawGoogleAccountId(client); - if (googleAccountId) { - await cacheGoogleAccountId(googleAccountId); - } + await fetchAndCacheUserInfo(client); } catch (error) { console.error( 'Failed to retrieve Google Account ID during authentication:', @@ -275,57 +270,73 @@ export function getCachedGoogleAccountId(): string | null { } } +function getGoogleAccountEmailCachePath(): string { + return path.join(os.homedir(), GEMINI_DIR, GOOGLE_ACCOUNT_EMAIL_FILENAME); +} + +async function cacheGoogleAccountEmail(email: string): Promise { + const filePath = getGoogleAccountEmailCachePath(); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, email, 'utf-8'); +} + +export function getCachedGoogleAccountEmail(): string | null { + try { + const filePath = getGoogleAccountEmailCachePath(); + if (existsSync(filePath)) { + return readFileSync(filePath, 'utf-8').trim() || null; + } + return null; + } catch (error) { + console.debug('Error reading cached Google Account Email:', error); + return null; + } +} + export async function clearCachedCredentialFile() { try { await fs.rm(getCachedCredentialPath(), { force: true }); // Clear the Google Account ID cache when credentials are cleared await fs.rm(getGoogleAccountIdCachePath(), { force: true }); + await fs.rm(getGoogleAccountEmailCachePath(), { force: true }); } catch (_) { /* empty */ } } -/** - * Retrieves the authenticated user's Google Account ID from Google's UserInfo API. - * @param client - The authenticated OAuth2Client - * @returns The user's Google Account ID or null if not available - */ -export async function getRawGoogleAccountId( - client: OAuth2Client, -): Promise { +async function fetchAndCacheUserInfo(client: OAuth2Client): Promise { try { - // 1. Get a new Access Token including the id_token - const refreshedTokens = await new Promise( - (resolve, reject) => { - client.refreshAccessToken((err, tokens) => { - if (err) { - return reject(err); - } - resolve(tokens ?? null); - }); + const { token } = await client.getAccessToken(); + if (!token) { + return; + } + + const response = await fetch( + 'https://www.googleapis.com/oauth2/v2/userinfo', + { + headers: { + Authorization: `Bearer ${token}`, + }, }, ); - if (!refreshedTokens?.id_token) { - console.warn('No id_token obtained after refreshing tokens.'); - return null; + if (!response.ok) { + console.error( + 'Failed to fetch user info:', + response.status, + response.statusText, + ); + return; } - // 2. Verify the ID token to securely get the user's Google Account ID. - const ticket = await client.verifyIdToken({ - idToken: refreshedTokens.id_token, - audience: OAUTH_CLIENT_ID, - }); - - const payload = ticket.getPayload(); - if (!payload?.sub) { - console.warn('Could not extract sub claim from verified ID token.'); - return null; + const userInfo = await response.json(); + if (userInfo.id) { + await cacheGoogleAccountId(userInfo.id); + } + if (userInfo.email) { + await cacheGoogleAccountEmail(userInfo.email); } - - return payload.sub; } catch (error) { - console.error('Error retrieving or verifying Google Account ID:', error); - return null; + console.error('Error retrieving user info:', error); } } diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index a64a9795..1b1cec04 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -17,8 +17,10 @@ import { } from '../types.js'; import { EventMetadataKey } from './event-metadata-key.js'; import { Config } from '../../config/config.js'; -import { getInstallationId } from '../../utils/user_id.js'; -import { getGoogleAccountId } from '../../utils/user_id.js'; +import { + getInstallationId, + getGoogleAccountEmail, +} from '../../utils/user_id.js'; const start_session_event_name = 'start_session'; const new_prompt_event_name = 'new_prompt'; @@ -66,13 +68,23 @@ export class ClearcutLogger { } createLogEvent(name: string, data: object): object { - return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const logEvent: any = { console_type: 'GEMINI_CLI', application: 102, event_name: name, - client_install_id: getInstallationId(), event_metadata: [data] as object[], }; + + const email = getGoogleAccountEmail(); + // Should log either email or install ID, not both. See go/cloudmill-1p-oss-instrumentation#define-sessionable-id + if (email) { + logEvent.client_email = email; + } else { + logEvent.client_install_id = getInstallationId(); + } + + return logEvent; } flushIfNeeded(): void { @@ -80,33 +92,24 @@ export class ClearcutLogger { return; } - // Fire and forget - don't await this.flushToClearcut().catch((error) => { console.debug('Error flushing to Clearcut:', error); }); } - async flushToClearcut(): Promise { + flushToClearcut(): Promise { if (this.config?.getDebugMode()) { console.log('Flushing log events to Clearcut.'); } const eventsToSend = [...this.events]; this.events.length = 0; - const googleAccountId = await getGoogleAccountId(); - return new Promise((resolve, reject) => { const request = [ { log_source_name: 'CONCORD', request_time_ms: Date.now(), log_event: eventsToSend, - // Add UserInfo with the raw Gaia ID - user_info: googleAccountId - ? { - UserID: googleAccountId, - } - : undefined, }, ]; const body = JSON.stringify(request); @@ -255,7 +258,7 @@ export class ClearcutLogger { this.enqueueLogEvent(this.createLogEvent(start_session_event_name, data)); // Flush start event immediately this.flushToClearcut().catch((error) => { - console.debug('Error flushing start session event to Clearcut:', error); + console.debug('Error flushing to Clearcut:', error); }); } diff --git a/packages/core/src/utils/user_id.test.ts b/packages/core/src/utils/user_id.test.ts index 185d6c95..42d8958c 100644 --- a/packages/core/src/utils/user_id.test.ts +++ b/packages/core/src/utils/user_id.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect } from 'vitest'; -import { getInstallationId, getGoogleAccountId } from './user_id.js'; +import { getInstallationId, getGoogleAccountEmail } from './user_id.js'; describe('user_id', () => { describe('getInstallationId', () => { @@ -22,30 +22,24 @@ describe('user_id', () => { }); }); - describe('getGoogleAccountId', () => { - it('should return a non-empty string', async () => { - const result = await getGoogleAccountId(); + describe('getGoogleAccountEmail', () => { + it('should return a non-empty string', () => { + const result = getGoogleAccountEmail(); expect(result).toBeDefined(); expect(typeof result).toBe('string'); // Should be consistent on subsequent calls - const secondCall = await getGoogleAccountId(); + const secondCall = getGoogleAccountEmail(); expect(secondCall).toBe(result); }); - it('should return empty string when no Google Account ID is cached, or a valid ID when cached', async () => { - // The function can return either an empty string (if no cached ID) or a valid Google Account ID (if cached) - const googleAccountIdResult = await getGoogleAccountId(); + it('should return empty string when no Google Account email is cached', () => { + // In a clean test environment, there should be no cached Google Account email + const googleAccountEmailResult = getGoogleAccountEmail(); - expect(googleAccountIdResult).toBeDefined(); - expect(typeof googleAccountIdResult).toBe('string'); - - // Should be either empty string or a numeric string (Google Account ID) - if (googleAccountIdResult !== '') { - // If we have a cached ID, it should be a numeric string - expect(googleAccountIdResult).toMatch(/^\d+$/); - } + // They should be the same when no Google Account email is cached + expect(googleAccountEmailResult).toBe(''); }); }); }); diff --git a/packages/core/src/utils/user_id.ts b/packages/core/src/utils/user_id.ts index 42bbee35..045cbd77 100644 --- a/packages/core/src/utils/user_id.ts +++ b/packages/core/src/utils/user_id.ts @@ -8,8 +8,11 @@ import * as os from 'os'; import * as fs from 'fs'; import * as path from 'path'; import { randomUUID } from 'crypto'; +import { createRequire } from 'module'; import { GEMINI_DIR } from './paths.js'; +const require = createRequire(import.meta.url); + const homeDir = os.homedir() ?? ''; const geminiDir = path.join(homeDir, GEMINI_DIR); const installationIdFile = path.join(geminiDir, 'installation_id'); @@ -58,24 +61,23 @@ export function getInstallationId(): string { } /** - * Retrieves the obfuscated Google Account ID for the currently authenticated user. - * When OAuth is available, returns the user's cached Google Account ID. Otherwise, returns the installation ID. - * @returns A string ID for the user (Google Account ID if available, otherwise installation ID). + * Retrieves the email for the currently authenticated user. + * When OAuth is available, returns the user's cached email. Otherwise, returns an empty string. + * @returns A string email for the user (Google Account email if available, otherwise empty string). */ -export async function getGoogleAccountId(): Promise { - // Try to get cached Google Account ID first +export function getGoogleAccountEmail(): string { + // Try to get cached Google Account email first try { - // Dynamic import to avoid circular dependencies - const { getCachedGoogleAccountId } = await import( - '../code_assist/oauth2.js' - ); - const googleAccountId = getCachedGoogleAccountId(); - if (googleAccountId) { - return googleAccountId; + // Dynamically import to avoid circular dependencies + // eslint-disable-next-line no-restricted-syntax + const { getCachedGoogleAccountEmail } = require('../code_assist/oauth2.js'); + const googleAccountEmail = getCachedGoogleAccountEmail(); + if (googleAccountEmail) { + return googleAccountEmail; } } catch (error) { - // If there's any error accessing Google Account ID, just return empty string - console.debug('Could not get cached Google Account ID:', error); + // If there's any error accessing Google Account email, just return empty string + console.debug('Could not get cached Google Account email:', error); } return '';