From cdb803b9a431128a851a4da82edaa82494ac6215 Mon Sep 17 00:00:00 2001 From: Bryan Morgan Date: Sun, 29 Jun 2025 16:35:20 -0400 Subject: [PATCH] Added obfuscated google account ID to clearcut log messages (#2593) --- packages/core/src/code_assist/oauth2.test.ts | 28 +++++- packages/core/src/code_assist/oauth2.ts | 98 ++++++++++++++++++- .../clearcut-logger/clearcut-logger.ts | 6 +- packages/core/src/utils/user_id.test.ts | 48 +++++++++ packages/core/src/utils/user_id.ts | 55 ++++++++--- 5 files changed, 215 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/utils/user_id.test.ts diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts index 0f5b791b..7e3c38f0 100644 --- a/packages/core/src/code_assist/oauth2.test.ts +++ b/packages/core/src/code_assist/oauth2.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { getOauthClient } from './oauth2.js'; +import { getOauthClient, getCachedGoogleAccountId } from './oauth2.js'; import { OAuth2Client } from 'google-auth-library'; import * as fs from 'fs'; import * as path from 'path'; @@ -27,6 +27,9 @@ vi.mock('http'); vi.mock('open'); vi.mock('crypto'); +// Mock fetch globally +global.fetch = vi.fn(); + describe('oauth2', () => { let tempHomeDir: string; @@ -52,10 +55,14 @@ describe('oauth2', () => { const mockGenerateAuthUrl = vi.fn().mockReturnValue(mockAuthUrl); const mockGetToken = vi.fn().mockResolvedValue({ tokens: mockTokens }); const mockSetCredentials = vi.fn(); + const mockGetAccessToken = vi + .fn() + .mockResolvedValue({ token: 'mock-access-token' }); const mockOAuth2Client = { generateAuthUrl: mockGenerateAuthUrl, getToken: mockGetToken, setCredentials: mockSetCredentials, + getAccessToken: mockGetAccessToken, credentials: mockTokens, } as unknown as OAuth2Client; vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); @@ -63,6 +70,12 @@ describe('oauth2', () => { vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never); vi.mocked(open).mockImplementation(async () => ({}) as never); + // Mock the UserInfo API response + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ id: 'test-google-account-id-123' }), + } as unknown as Response); + let requestCallback!: http.RequestListener< typeof http.IncomingMessage, typeof http.ServerResponse @@ -126,5 +139,18 @@ describe('oauth2', () => { const tokenPath = path.join(tempHomeDir, '.gemini', 'oauth_creds.json'); const tokenData = JSON.parse(fs.readFileSync(tokenPath, 'utf-8')); expect(tokenData).toEqual(mockTokens); + + // Verify Google Account ID was cached + const googleAccountIdPath = path.join( + tempHomeDir, + '.gemini', + 'google_account_id', + ); + expect(fs.existsSync(googleAccountIdPath)).toBe(true); + const cachedGoogleAccountId = fs.readFileSync(googleAccountIdPath, 'utf-8'); + expect(cachedGoogleAccountId).toBe('test-google-account-id-123'); + + // Verify the getCachedGoogleAccountId function works + expect(getCachedGoogleAccountId()).toBe('test-google-account-id-123'); }); }); diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index 68f6e137..d07c8560 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -41,6 +41,7 @@ const SIGN_IN_FAILURE_URL = const GEMINI_DIR = '.gemini'; const CREDENTIAL_FILENAME = 'oauth_creds.json'; +const GOOGLE_ACCOUNT_ID_FILENAME = 'google_account_id'; /** * An Authentication URL for updating the credentials of a Oauth2Client @@ -60,6 +61,21 @@ export async function getOauthClient(): Promise { if (await loadCachedCredentials(client)) { // Found valid cached credentials. + // Check if we need to retrieve Google Account ID + if (!getCachedGoogleAccountId()) { + try { + const googleAccountId = await getGoogleAccountId(client); + if (googleAccountId) { + await cacheGoogleAccountId(googleAccountId); + } + } catch (error) { + console.error( + 'Failed to retrieve Google Account ID for existing credentials:', + error, + ); + // Continue with existing auth flow + } + } return client; } @@ -116,6 +132,20 @@ async function authWithWeb(client: OAuth2Client): Promise { client.setCredentials(tokens); await cacheCredentials(client.credentials); + // Retrieve and cache Google Account ID during authentication + try { + const googleAccountId = await getGoogleAccountId(client); + if (googleAccountId) { + await cacheGoogleAccountId(googleAccountId); + } + } catch (error) { + console.error( + 'Failed to retrieve Google Account ID during authentication:', + error, + ); + // Don't fail the auth flow if Google Account ID retrieval fails + } + res.writeHead(HTTP_REDIRECT, { Location: SIGN_IN_SUCCESS_URL }); res.end(); resolve(); @@ -193,10 +223,76 @@ function getCachedCredentialPath(): string { return path.join(os.homedir(), GEMINI_DIR, CREDENTIAL_FILENAME); } +function getGoogleAccountIdCachePath(): string { + return path.join(os.homedir(), GEMINI_DIR, GOOGLE_ACCOUNT_ID_FILENAME); +} + +async function cacheGoogleAccountId(googleAccountId: string): Promise { + const filePath = getGoogleAccountIdCachePath(); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, googleAccountId, 'utf-8'); +} + +export function getCachedGoogleAccountId(): string | null { + try { + const filePath = getGoogleAccountIdCachePath(); + // eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-syntax + const fs_sync = require('fs'); + if (fs_sync.existsSync(filePath)) { + return fs_sync.readFileSync(filePath, 'utf-8').trim() || null; + } + return null; + } catch (_error) { + return null; + } +} + export async function clearCachedCredentialFile() { try { - await fs.rm(getCachedCredentialPath()); + await fs.rm(getCachedCredentialPath(), { force: true }); + // Clear the Google Account ID cache when credentials are cleared + await fs.rm(getGoogleAccountIdCachePath(), { 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 getGoogleAccountId( + client: OAuth2Client, +): Promise { + try { + const { token } = await client.getAccessToken(); + if (!token) { + return null; + } + + const response = await fetch( + 'https://www.googleapis.com/oauth2/v2/userinfo', + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (!response.ok) { + console.error( + 'Failed to fetch user info:', + response.status, + response.statusText, + ); + return null; + } + + const userInfo = await response.json(); + return userInfo.id || null; + } catch (error) { + console.error('Error retrieving Google Account ID:', error); + return null; + } +} diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index f06ddb94..dec38f34 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -17,7 +17,8 @@ import { } from '../types.js'; import { EventMetadataKey } from './event-metadata-key.js'; import { Config } from '../../config/config.js'; -import { getPersistentUserId } from '../../utils/user_id.js'; +import { getInstallationId } from '../../utils/user_id.js'; +import { getObfuscatedGoogleAccountId } from '../../utils/user_id.js'; const start_session_event_name = 'start_session'; const new_prompt_event_name = 'new_prompt'; @@ -69,7 +70,8 @@ export class ClearcutLogger { console_type: 'GEMINI_CLI', application: 102, event_name: name, - client_install_id: getPersistentUserId(), + obfuscated_google_account_id: getObfuscatedGoogleAccountId(), + client_install_id: getInstallationId(), event_metadata: [data] as object[], }; } diff --git a/packages/core/src/utils/user_id.test.ts b/packages/core/src/utils/user_id.test.ts new file mode 100644 index 00000000..81b99ef4 --- /dev/null +++ b/packages/core/src/utils/user_id.test.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { getInstallationId, getObfuscatedGoogleAccountId } from './user_id.js'; + +describe('user_id', () => { + describe('getInstallationId', () => { + it('should return a valid UUID format string', () => { + const installationId = getInstallationId(); + + expect(installationId).toBeDefined(); + expect(typeof installationId).toBe('string'); + expect(installationId.length).toBeGreaterThan(0); + + // Should return the same ID on subsequent calls (consistent) + const secondCall = getInstallationId(); + expect(secondCall).toBe(installationId); + }); + }); + + describe('getObfuscatedGoogleAccountId', () => { + it('should return a non-empty string', () => { + const result = getObfuscatedGoogleAccountId(); + + expect(result).toBeDefined(); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + + // Should be consistent on subsequent calls + const secondCall = getObfuscatedGoogleAccountId(); + expect(secondCall).toBe(result); + }); + + it('should return the same as installation ID when no Google Account ID is cached', () => { + // In a clean test environment, there should be no cached Google Account ID + // so getObfuscatedGoogleAccountId should fall back to installation ID + const googleAccountIdResult = getObfuscatedGoogleAccountId(); + const installationIdResult = getInstallationId(); + + // They should be the same when no Google Account ID is cached + expect(googleAccountIdResult).toBe(installationIdResult); + }); + }); +}); diff --git a/packages/core/src/utils/user_id.ts b/packages/core/src/utils/user_id.ts index 5db080a4..4f17bf96 100644 --- a/packages/core/src/utils/user_id.ts +++ b/packages/core/src/utils/user_id.ts @@ -12,7 +12,7 @@ import { GEMINI_DIR } from './paths.js'; const homeDir = os.homedir() ?? ''; const geminiDir = path.join(homeDir, GEMINI_DIR); -const userIdFile = path.join(geminiDir, 'user_id'); +const installationIdFile = path.join(geminiDir, 'installation_id'); function ensureGeminiDirExists() { if (!fs.existsSync(geminiDir)) { @@ -20,39 +20,62 @@ function ensureGeminiDirExists() { } } -function readUserIdFromFile(): string | null { - if (fs.existsSync(userIdFile)) { - const userId = fs.readFileSync(userIdFile, 'utf-8').trim(); - return userId || null; +function readInstallationIdFromFile(): string | null { + if (fs.existsSync(installationIdFile)) { + const installationid = fs.readFileSync(installationIdFile, 'utf-8').trim(); + return installationid || null; } return null; } -function writeUserIdToFile(userId: string) { - fs.writeFileSync(userIdFile, userId, 'utf-8'); +function writeInstallationIdToFile(installationId: string) { + fs.writeFileSync(installationIdFile, installationId, 'utf-8'); } /** - * Retrieves the persistent user ID from a file, creating it if it doesn't exist. - * This ID is used for unique user tracking. + * Retrieves the installation ID from a file, creating it if it doesn't exist. + * This ID is used for unique user installation tracking. * @returns A UUID string for the user. */ -export function getPersistentUserId(): string { +export function getInstallationId(): string { try { ensureGeminiDirExists(); - let userId = readUserIdFromFile(); + let installationId = readInstallationIdFromFile(); - if (!userId) { - userId = randomUUID(); - writeUserIdToFile(userId); + if (!installationId) { + installationId = randomUUID(); + writeInstallationIdToFile(installationId); } - return userId; + return installationId; } catch (error) { console.error( - 'Error accessing persistent user ID file, generating ephemeral ID:', + 'Error accessing installation ID file, generating ephemeral ID:', error, ); return '123456789'; } } + +/** + * 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). + */ +export function getObfuscatedGoogleAccountId(): string { + // Try to get cached Google Account ID first + try { + // Dynamically import to avoid circular dependencies + // eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-syntax + const { getCachedGoogleAccountId } = require('../code_assist/oauth2.js'); + const googleAccountId = getCachedGoogleAccountId(); + if (googleAccountId) { + return googleAccountId; + } + } catch (_error) { + // If there's any error accessing Google Account ID, fall back to installation ID + } + + // Fall back to installation ID when no Google Account ID is cached or on error + return getInstallationId(); +}