/** * Gemini CLI OAuth flow (Google Cloud Code Assist) * Standard Gemini models only (gemini-2.0-flash, gemini-2.5-*) * * NOTE: This module uses Node.js http.createServer for the OAuth callback. * It is only intended for CLI use, not browser environments. */ import type { Server } from "node:http"; import { generatePKCE } from "./pkce.js"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js"; type GeminiCredentials = OAuthCredentials & { projectId: string; }; let _createServer: typeof import("node:http").createServer | null = null; let _httpImportPromise: Promise | null = null; if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { _httpImportPromise = import("node:http").then((m) => { _createServer = m.createServer; }); } const decode = (s: string) => atob(s); const CLIENT_ID = decode( "NjgxMjU1ODA5Mzk1LW9vOGZ0Mm9wcmRybnA5ZTNhcWY2YXYzaG1kaWIxMzVqLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29t", ); const CLIENT_SECRET = decode("R09DU1BYLTR1SGdNUG0tMW83U2stZ2VWNkN1NWNsWEZzeGw="); const REDIRECT_URI = "http://localhost:8085/oauth2callback"; const SCOPES = [ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", ]; const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; const TOKEN_URL = "https://oauth2.googleapis.com/token"; const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"; type CallbackServerInfo = { server: Server; cancelWait: () => void; waitForCode: () => Promise<{ code: string; state: string } | null>; }; /** * Start a local HTTP server to receive the OAuth callback */ async function getNodeCreateServer(): Promise { if (_createServer) return _createServer; if (_httpImportPromise) { await _httpImportPromise; } if (_createServer) return _createServer; throw new Error("Gemini CLI OAuth is only available in Node.js environments"); } async function startCallbackServer(): Promise { const createServer = await getNodeCreateServer(); return new Promise((resolve, reject) => { let result: { code: string; state: string } | null = null; let cancelled = false; const server = createServer((req, res) => { const url = new URL(req.url || "", `http://localhost:8085`); if (url.pathname === "/oauth2callback") { const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); const error = url.searchParams.get("error"); if (error) { res.writeHead(400, { "Content-Type": "text/html" }); res.end( `

Authentication Failed

Error: ${error}

You can close this window.

`, ); return; } if (code && state) { res.writeHead(200, { "Content-Type": "text/html" }); res.end( `

Authentication Successful

You can close this window and return to the terminal.

`, ); result = { code, state }; } else { res.writeHead(400, { "Content-Type": "text/html" }); res.end( `

Authentication Failed

Missing code or state parameter.

`, ); } } else { res.writeHead(404); res.end(); } }); server.on("error", (err) => { reject(err); }); server.listen(8085, "127.0.0.1", () => { resolve({ server, cancelWait: () => { cancelled = true; }, waitForCode: async () => { const sleep = () => new Promise((r) => setTimeout(r, 100)); while (!result && !cancelled) { await sleep(); } return result; }, }); }); }); } /** * Parse redirect URL to extract code and state */ function parseRedirectUrl(input: string): { code?: string; state?: string } { const value = input.trim(); if (!value) return {}; try { const url = new URL(value); return { code: url.searchParams.get("code") ?? undefined, state: url.searchParams.get("state") ?? undefined, }; } catch { // Not a URL, return empty return {}; } } interface LoadCodeAssistPayload { cloudaicompanionProject?: string; currentTier?: { id?: string }; allowedTiers?: Array<{ id?: string; isDefault?: boolean }>; } /** * Long-running operation response from onboardUser */ interface LongRunningOperationResponse { name?: string; done?: boolean; response?: { cloudaicompanionProject?: { id?: string }; }; } // Tier IDs as used by the Cloud Code API const TIER_FREE = "free-tier"; const TIER_LEGACY = "legacy-tier"; const TIER_STANDARD = "standard-tier"; interface GoogleRpcErrorResponse { error?: { details?: Array<{ reason?: string }>; }; } /** * Wait helper for onboarding retries */ function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Get default tier from allowed tiers */ function getDefaultTier(allowedTiers?: Array<{ id?: string; isDefault?: boolean }>): { id?: string } { if (!allowedTiers || allowedTiers.length === 0) return { id: TIER_LEGACY }; const defaultTier = allowedTiers.find((t) => t.isDefault); return defaultTier ?? { id: TIER_LEGACY }; } function isVpcScAffectedUser(payload: unknown): boolean { if (!payload || typeof payload !== "object") return false; if (!("error" in payload)) return false; const error = (payload as GoogleRpcErrorResponse).error; if (!error?.details || !Array.isArray(error.details)) return false; return error.details.some((detail) => detail.reason === "SECURITY_POLICY_VIOLATED"); } /** * Poll a long-running operation until completion */ async function pollOperation( operationName: string, headers: Record, onProgress?: (message: string) => void, ): Promise { let attempt = 0; while (true) { if (attempt > 0) { onProgress?.(`Waiting for project provisioning (attempt ${attempt + 1})...`); await wait(5000); } const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { method: "GET", headers, }); if (!response.ok) { throw new Error(`Failed to poll operation: ${response.status} ${response.statusText}`); } const data = (await response.json()) as LongRunningOperationResponse; if (data.done) { return data; } attempt += 1; } } /** * Discover or provision a Google Cloud project for the user */ async function discoverProject(accessToken: string, onProgress?: (message: string) => void): Promise { // Check for user-provided project ID via environment variable const envProjectId = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID; const headers = { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "User-Agent": "google-api-nodejs-client/9.15.1", "X-Goog-Api-Client": "gl-node/22.17.0", }; // Try to load existing project via loadCodeAssist onProgress?.("Checking for existing Cloud Code Assist project..."); const loadResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, { method: "POST", headers, body: JSON.stringify({ cloudaicompanionProject: envProjectId, metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI", duetProject: envProjectId, }, }), }); let data: LoadCodeAssistPayload; if (!loadResponse.ok) { let errorPayload: unknown; try { errorPayload = await loadResponse.clone().json(); } catch { errorPayload = undefined; } if (isVpcScAffectedUser(errorPayload)) { data = { currentTier: { id: TIER_STANDARD } }; } else { const errorText = await loadResponse.text(); throw new Error(`loadCodeAssist failed: ${loadResponse.status} ${loadResponse.statusText}: ${errorText}`); } } else { data = (await loadResponse.json()) as LoadCodeAssistPayload; } // If user already has a current tier and project, use it if (data.currentTier) { if (data.cloudaicompanionProject) { return data.cloudaicompanionProject; } // User has a tier but no managed project - they need to provide one via env var if (envProjectId) { return envProjectId; } throw new Error( "This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID environment variable. " + "See https://goo.gle/gemini-cli-auth-docs#workspace-gca", ); } // User needs to be onboarded - get the default tier const tier = getDefaultTier(data.allowedTiers); const tierId = tier?.id ?? TIER_FREE; if (tierId !== TIER_FREE && !envProjectId) { throw new Error( "This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID environment variable. " + "See https://goo.gle/gemini-cli-auth-docs#workspace-gca", ); } onProgress?.("Provisioning Cloud Code Assist project (this may take a moment)..."); // Build onboard request - for free tier, don't include project ID (Google provisions one) // For other tiers, include the user's project ID if available const onboardBody: Record = { tierId, metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI", }, }; if (tierId !== TIER_FREE && envProjectId) { onboardBody.cloudaicompanionProject = envProjectId; (onboardBody.metadata as Record).duetProject = envProjectId; } // Start onboarding - this returns a long-running operation const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, { method: "POST", headers, body: JSON.stringify(onboardBody), }); if (!onboardResponse.ok) { const errorText = await onboardResponse.text(); throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}: ${errorText}`); } let lroData = (await onboardResponse.json()) as LongRunningOperationResponse; // If the operation isn't done yet, poll until completion if (!lroData.done && lroData.name) { lroData = await pollOperation(lroData.name, headers, onProgress); } // Try to get project ID from the response const projectId = lroData.response?.cloudaicompanionProject?.id; if (projectId) { return projectId; } // If no project ID from onboarding, fall back to env var if (envProjectId) { return envProjectId; } throw new Error( "Could not discover or provision a Google Cloud project. " + "Try setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID environment variable. " + "See https://goo.gle/gemini-cli-auth-docs#workspace-gca", ); } /** * Get user email from the access token */ async function getUserEmail(accessToken: string): Promise { try { const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { headers: { Authorization: `Bearer ${accessToken}`, }, }); if (response.ok) { const data = (await response.json()) as { email?: string }; return data.email; } } catch { // Ignore errors, email is optional } return undefined; } /** * Refresh Google Cloud Code Assist token */ export async function refreshGoogleCloudToken(refreshToken: string, projectId: string): Promise { const response = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, refresh_token: refreshToken, grant_type: "refresh_token", }), }); if (!response.ok) { const error = await response.text(); throw new Error(`Google Cloud token refresh failed: ${error}`); } const data = (await response.json()) as { access_token: string; expires_in: number; refresh_token?: string; }; return { refresh: data.refresh_token || refreshToken, access: data.access_token, expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, projectId, }; } /** * Login with Gemini CLI (Google Cloud Code Assist) OAuth * * @param onAuth - Callback with URL and optional instructions * @param onProgress - Optional progress callback * @param onManualCodeInput - Optional promise that resolves with user-pasted redirect URL. * Races with browser callback - whichever completes first wins. */ export async function loginGeminiCli( onAuth: (info: { url: string; instructions?: string }) => void, onProgress?: (message: string) => void, onManualCodeInput?: () => Promise, ): Promise { const { verifier, challenge } = await generatePKCE(); // Start local server for callback onProgress?.("Starting local server for OAuth callback..."); const server = await startCallbackServer(); let code: string | undefined; try { // Build authorization URL const authParams = new URLSearchParams({ client_id: CLIENT_ID, response_type: "code", redirect_uri: REDIRECT_URI, scope: SCOPES.join(" "), code_challenge: challenge, code_challenge_method: "S256", state: verifier, access_type: "offline", prompt: "consent", }); const authUrl = `${AUTH_URL}?${authParams.toString()}`; // Notify caller with URL to open onAuth({ url: authUrl, instructions: "Complete the sign-in in your browser.", }); // Wait for the callback, racing with manual input if provided onProgress?.("Waiting for OAuth callback..."); if (onManualCodeInput) { // Race between browser callback and manual input let manualInput: string | undefined; let manualError: Error | undefined; const manualPromise = onManualCodeInput() .then((input) => { manualInput = input; server.cancelWait(); }) .catch((err) => { manualError = err instanceof Error ? err : new Error(String(err)); server.cancelWait(); }); const result = await server.waitForCode(); // If manual input was cancelled, throw that error if (manualError) { throw manualError; } if (result?.code) { // Browser callback won - verify state if (result.state !== verifier) { throw new Error("OAuth state mismatch - possible CSRF attack"); } code = result.code; } else if (manualInput) { // Manual input won const parsed = parseRedirectUrl(manualInput); if (parsed.state && parsed.state !== verifier) { throw new Error("OAuth state mismatch - possible CSRF attack"); } code = parsed.code; } // If still no code, wait for manual promise and try that if (!code) { await manualPromise; if (manualError) { throw manualError; } if (manualInput) { const parsed = parseRedirectUrl(manualInput); if (parsed.state && parsed.state !== verifier) { throw new Error("OAuth state mismatch - possible CSRF attack"); } code = parsed.code; } } } else { // Original flow: just wait for callback const result = await server.waitForCode(); if (result?.code) { if (result.state !== verifier) { throw new Error("OAuth state mismatch - possible CSRF attack"); } code = result.code; } } if (!code) { throw new Error("No authorization code received"); } // Exchange code for tokens onProgress?.("Exchanging authorization code for tokens..."); const tokenResponse = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code, grant_type: "authorization_code", redirect_uri: REDIRECT_URI, code_verifier: verifier, }), }); if (!tokenResponse.ok) { const error = await tokenResponse.text(); throw new Error(`Token exchange failed: ${error}`); } const tokenData = (await tokenResponse.json()) as { access_token: string; refresh_token: string; expires_in: number; }; if (!tokenData.refresh_token) { throw new Error("No refresh token received. Please try again."); } // Get user email onProgress?.("Getting user info..."); const email = await getUserEmail(tokenData.access_token); // Discover project const projectId = await discoverProject(tokenData.access_token, onProgress); // Calculate expiry time (current time + expires_in seconds - 5 min buffer) const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000; const credentials: OAuthCredentials = { refresh: tokenData.refresh_token, access: tokenData.access_token, expires: expiresAt, projectId, email, }; return credentials; } finally { server.server.close(); } } export const geminiCliOAuthProvider: OAuthProviderInterface = { id: "google-gemini-cli", name: "Google Cloud Code Assist (Gemini CLI)", usesCallbackServer: true, async login(callbacks: OAuthLoginCallbacks): Promise { return loginGeminiCli(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput); }, async refreshToken(credentials: OAuthCredentials): Promise { const creds = credentials as GeminiCredentials; if (!creds.projectId) { throw new Error("Google Cloud credentials missing projectId"); } return refreshGoogleCloudToken(creds.refresh, creds.projectId); }, getApiKey(credentials: OAuthCredentials): string { const creds = credentials as GeminiCredentials; return JSON.stringify({ token: creds.access, projectId: creds.projectId }); }, };