diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts index 4223bb75..ae5d80d6 100644 --- a/packages/core/src/code_assist/oauth2.test.ts +++ b/packages/core/src/code_assist/oauth2.test.ts @@ -58,11 +58,30 @@ 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 a55f3804..043572d7 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -11,7 +11,7 @@ import crypto from 'crypto'; import * as net from 'net'; import open from 'open'; import path from 'node:path'; -import { promises as fs } from 'node:fs'; +import { promises as fs, existsSync, readFileSync } from 'node:fs'; import * as os from 'os'; // OAuth Client ID used to initiate OAuth2Client class. @@ -67,7 +67,7 @@ export async function getOauthClient(): Promise { // Check if we need to retrieve Google Account ID if (!getCachedGoogleAccountId()) { try { - const googleAccountId = await getGoogleAccountId(client); + const googleAccountId = await getRawGoogleAccountId(client); if (googleAccountId) { await cacheGoogleAccountId(googleAccountId); } @@ -135,7 +135,7 @@ async function authWithWeb(client: OAuth2Client): Promise { client.setCredentials(tokens); // Retrieve and cache Google Account ID during authentication try { - const googleAccountId = await getGoogleAccountId(client); + const googleAccountId = await getRawGoogleAccountId(client); if (googleAccountId) { await cacheGoogleAccountId(googleAccountId); } @@ -237,13 +237,12 @@ async function cacheGoogleAccountId(googleAccountId: string): Promise { 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; + if (existsSync(filePath)) { + return readFileSync(filePath, 'utf-8').trim() || null; } return null; - } catch (_error) { + } catch (error) { + console.debug('Error reading cached Google Account ID:', error); return null; } } @@ -263,37 +262,42 @@ export async function clearCachedCredentialFile() { * @param client - The authenticated OAuth2Client * @returns The user's Google Account ID or null if not available */ -export async function getGoogleAccountId( +export async function getRawGoogleAccountId( 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}`, - }, + // 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); + }); }, ); - if (!response.ok) { - console.error( - 'Failed to fetch user info:', - response.status, - response.statusText, - ); + if (!refreshedTokens?.id_token) { + console.warn('No id_token obtained after refreshing tokens.'); return null; } - const userInfo = await response.json(); - return userInfo.id || null; + // 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; + } + + return payload.sub; } catch (error) { - console.error('Error retrieving Google Account ID:', error); + console.error('Error retrieving or verifying 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 dc319170..73c82f23 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -18,7 +18,7 @@ import { import { EventMetadataKey } from './event-metadata-key.js'; import { Config } from '../../config/config.js'; import { getInstallationId } from '../../utils/user_id.js'; -import { getObfuscatedGoogleAccountId } from '../../utils/user_id.js'; +import { getGoogleAccountId } from '../../utils/user_id.js'; const start_session_event_name = 'start_session'; const new_prompt_event_name = 'new_prompt'; @@ -70,7 +70,6 @@ export class ClearcutLogger { console_type: 'GEMINI_CLI', application: 102, event_name: name, - obfuscated_google_account_id: getObfuscatedGoogleAccountId(), client_install_id: getInstallationId(), event_metadata: [data] as object[], }; @@ -81,22 +80,33 @@ export class ClearcutLogger { return; } - this.flushToClearcut(); + // Fire and forget - don't await + this.flushToClearcut().catch((error) => { + console.debug('Error flushing to Clearcut:', error); + }); } - flushToClearcut(): Promise { + async 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); @@ -244,7 +254,9 @@ export class ClearcutLogger { ]; this.enqueueLogEvent(this.createLogEvent(start_session_event_name, data)); // Flush start event immediately - this.flushToClearcut(); + this.flushToClearcut().catch((error) => { + console.debug('Error flushing start session event to Clearcut:', error); + }); } logNewPromptEvent(event: UserPromptEvent): void { @@ -256,7 +268,9 @@ export class ClearcutLogger { ]; this.enqueueLogEvent(this.createLogEvent(new_prompt_event_name, data)); - this.flushToClearcut(); + this.flushToClearcut().catch((error) => { + console.debug('Error flushing to Clearcut:', error); + }); } logToolCallEvent(event: ToolCallEvent): void { @@ -288,7 +302,9 @@ export class ClearcutLogger { ]; this.enqueueLogEvent(this.createLogEvent(tool_call_event_name, data)); - this.flushToClearcut(); + this.flushToClearcut().catch((error) => { + console.debug('Error flushing to Clearcut:', error); + }); } logApiRequestEvent(event: ApiRequestEvent): void { @@ -300,7 +316,9 @@ export class ClearcutLogger { ]; this.enqueueLogEvent(this.createLogEvent(api_request_event_name, data)); - this.flushToClearcut(); + this.flushToClearcut().catch((error) => { + console.debug('Error flushing to Clearcut:', error); + }); } logApiResponseEvent(event: ApiResponseEvent): void { @@ -349,7 +367,9 @@ export class ClearcutLogger { ]; this.enqueueLogEvent(this.createLogEvent(api_response_event_name, data)); - this.flushToClearcut(); + this.flushToClearcut().catch((error) => { + console.debug('Error flushing to Clearcut:', error); + }); } logApiErrorEvent(event: ApiErrorEvent): void { @@ -373,7 +393,9 @@ export class ClearcutLogger { ]; this.enqueueLogEvent(this.createLogEvent(api_error_event_name, data)); - this.flushToClearcut(); + this.flushToClearcut().catch((error) => { + console.debug('Error flushing to Clearcut:', error); + }); } logEndSessionEvent(event: EndSessionEvent): void { @@ -386,7 +408,9 @@ export class ClearcutLogger { this.enqueueLogEvent(this.createLogEvent(end_session_event_name, data)); // Flush immediately on session end. - this.flushToClearcut(); + this.flushToClearcut().catch((error) => { + console.debug('Error flushing to Clearcut:', error); + }); } shutdown() { diff --git a/packages/core/src/utils/user_id.test.ts b/packages/core/src/utils/user_id.test.ts index 5d9c72e4..185d6c95 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, getObfuscatedGoogleAccountId } from './user_id.js'; +import { getInstallationId, getGoogleAccountId } from './user_id.js'; describe('user_id', () => { describe('getInstallationId', () => { @@ -22,25 +22,30 @@ describe('user_id', () => { }); }); - describe('getObfuscatedGoogleAccountId', () => { - it('should return a non-empty string', () => { - const result = getObfuscatedGoogleAccountId(); + describe('getGoogleAccountId', () => { + it('should return a non-empty string', async () => { + const result = await getGoogleAccountId(); expect(result).toBeDefined(); expect(typeof result).toBe('string'); // Should be consistent on subsequent calls - const secondCall = getObfuscatedGoogleAccountId(); + const secondCall = await getGoogleAccountId(); expect(secondCall).toBe(result); }); - it('should return empty string 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(); + 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(); - // They should be the same when no Google Account ID is cached - expect(googleAccountIdResult).toBe(''); + 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+$/); + } }); }); }); diff --git a/packages/core/src/utils/user_id.ts b/packages/core/src/utils/user_id.ts index 5238fc3c..42bbee35 100644 --- a/packages/core/src/utils/user_id.ts +++ b/packages/core/src/utils/user_id.ts @@ -62,18 +62,20 @@ export function getInstallationId(): string { * 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 { +export async function getGoogleAccountId(): Promise { // 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'); + // Dynamic import to avoid circular dependencies + const { getCachedGoogleAccountId } = await import( + '../code_assist/oauth2.js' + ); const googleAccountId = getCachedGoogleAccountId(); if (googleAccountId) { return googleAccountId; } - } catch (_error) { + } 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); } return '';