Skip to content

Added obfuscated google account ID to clearcut log messages #2593

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion packages/core/src/code_assist/oauth2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand All @@ -52,17 +55,27 @@ 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);

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
Expand Down Expand Up @@ -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');
});
});
98 changes: 97 additions & 1 deletion packages/core/src/code_assist/oauth2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -60,6 +61,21 @@ export async function getOauthClient(): Promise<OAuth2Client> {

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;
}

Expand Down Expand Up @@ -116,6 +132,20 @@ async function authWithWeb(client: OAuth2Client): Promise<OauthWebLogin> {
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();
Expand Down Expand Up @@ -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<void> {
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<string | null> {
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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[],
};
}
Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/utils/user_id.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
55 changes: 39 additions & 16 deletions packages/core/src/utils/user_id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,47 +12,70 @@ 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)) {
fs.mkdirSync(geminiDir, { recursive: true });
}
}

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();
}