From d0cd3451fd3237b990000d1efcd22659be27ae53 Mon Sep 17 00:00:00 2001 From: frizynn Date: Thu, 19 Mar 2026 15:55:10 -0300 Subject: [PATCH] refactor: consolidate OAuth callback server and helper utilities Extract duplicated implementations of startCallbackServer, parseRedirectUrl, getUserEmail, and token refresh logic from google-antigravity.ts and google-gemini-cli.ts into a shared google-oauth-utils.ts module. --- .../src/utils/oauth/google-antigravity.ts | 176 ++------------- .../src/utils/oauth/google-gemini-cli.ts | 174 ++------------- .../src/utils/oauth/google-oauth-utils.ts | 201 ++++++++++++++++++ 3 files changed, 228 insertions(+), 323 deletions(-) create mode 100644 packages/pi-ai/src/utils/oauth/google-oauth-utils.ts diff --git a/packages/pi-ai/src/utils/oauth/google-antigravity.ts b/packages/pi-ai/src/utils/oauth/google-antigravity.ts index b9a49d473..f4f946963 100644 --- a/packages/pi-ai/src/utils/oauth/google-antigravity.ts +++ b/packages/pi-ai/src/utils/oauth/google-antigravity.ts @@ -6,7 +6,13 @@ * It is only intended for CLI use, not browser environments. */ -import type { Server } from "node:http"; +import { + type CallbackServerInfo, + getGoogleUserEmail, + parseRedirectUrl, + refreshGoogleOAuthToken, + startCallbackServer, +} from "./google-oauth-utils.js"; import { generatePKCE } from "./pkce.js"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js"; @@ -14,14 +20,6 @@ type AntigravityCredentials = 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; - }); -} - // Antigravity OAuth credentials (different from Gemini CLI) const decode = (s: string) => atob(s); const CLIENT_ID = decode( @@ -42,109 +40,13 @@ const SCOPES = [ const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; const TOKEN_URL = "https://oauth2.googleapis.com/token"; +// Callback server configuration +const CALLBACK_PORT = 51121; +const CALLBACK_PATH = "/oauth-callback"; + // Fallback project ID when discovery fails const DEFAULT_PROJECT_ID = "rising-fact-p41fc"; -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("Antigravity 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:51121`); - - if (url.pathname === "/oauth-callback") { - 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(51121, "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 | { id?: string }; currentTier?: { id?: string }; @@ -212,61 +114,11 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin return DEFAULT_PROJECT_ID; } -/** - * 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}`, - }, - signal: AbortSignal.timeout(30_000), - }); - - if (response.ok) { - const data = (await response.json()) as { email?: string }; - return data.email; - } - } catch { - // Ignore errors, email is optional - } - return undefined; -} - /** * Refresh Antigravity token */ export async function refreshAntigravityToken(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", - }), - signal: AbortSignal.timeout(30_000), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Antigravity 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, - }; + return refreshGoogleOAuthToken(refreshToken, CLIENT_ID, CLIENT_SECRET, "Antigravity", { projectId }); } /** @@ -286,7 +138,7 @@ export async function loginAntigravity( // Start local server for callback onProgress?.("Starting local server for OAuth callback..."); - const server = await startCallbackServer(); + const server: CallbackServerInfo = await startCallbackServer(CALLBACK_PORT, CALLBACK_PATH, "Antigravity"); let code: string | undefined; @@ -415,7 +267,7 @@ export async function loginAntigravity( // Get user email onProgress?.("Getting user info..."); - const email = await getUserEmail(tokenData.access_token); + const email = await getGoogleUserEmail(tokenData.access_token); // Discover project const projectId = await discoverProject(tokenData.access_token, onProgress); diff --git a/packages/pi-ai/src/utils/oauth/google-gemini-cli.ts b/packages/pi-ai/src/utils/oauth/google-gemini-cli.ts index 8650e8afd..b3669b107 100644 --- a/packages/pi-ai/src/utils/oauth/google-gemini-cli.ts +++ b/packages/pi-ai/src/utils/oauth/google-gemini-cli.ts @@ -6,7 +6,13 @@ * It is only intended for CLI use, not browser environments. */ -import type { Server } from "node:http"; +import { + type CallbackServerInfo, + getGoogleUserEmail, + parseRedirectUrl, + refreshGoogleOAuthToken, + startCallbackServer, +} from "./google-oauth-utils.js"; import { generatePKCE } from "./pkce.js"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js"; @@ -14,14 +20,6 @@ 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", @@ -37,105 +35,9 @@ 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 {}; - } -} +// Callback server configuration +const CALLBACK_PORT = 8085; +const CALLBACK_PATH = "/oauth2callback"; interface LoadCodeAssistPayload { cloudaicompanionProject?: string; @@ -356,61 +258,11 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin ); } -/** - * 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}`, - }, - signal: AbortSignal.timeout(30_000), - }); - - 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", - }), - signal: AbortSignal.timeout(30_000), - }); - - 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, - }; + return refreshGoogleOAuthToken(refreshToken, CLIENT_ID, CLIENT_SECRET, "Google Cloud", { projectId }); } /** @@ -430,7 +282,7 @@ export async function loginGeminiCli( // Start local server for callback onProgress?.("Starting local server for OAuth callback..."); - const server = await startCallbackServer(); + const server: CallbackServerInfo = await startCallbackServer(CALLBACK_PORT, CALLBACK_PATH, "Gemini CLI"); let code: string | undefined; @@ -559,7 +411,7 @@ export async function loginGeminiCli( // Get user email onProgress?.("Getting user info..."); - const email = await getUserEmail(tokenData.access_token); + const email = await getGoogleUserEmail(tokenData.access_token); // Discover project const projectId = await discoverProject(tokenData.access_token, onProgress); diff --git a/packages/pi-ai/src/utils/oauth/google-oauth-utils.ts b/packages/pi-ai/src/utils/oauth/google-oauth-utils.ts new file mode 100644 index 000000000..0188145d7 --- /dev/null +++ b/packages/pi-ai/src/utils/oauth/google-oauth-utils.ts @@ -0,0 +1,201 @@ +/** + * Shared utilities for Google OAuth providers (Gemini CLI and Antigravity). + * + * 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 type { OAuthCredentials } from "./types.js"; + +// Lazy-loaded http.createServer for Node.js environments +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; + }); +} + +export type CallbackServerInfo = { + server: Server; + cancelWait: () => void; + waitForCode: () => Promise<{ code: string; state: string } | null>; +}; + +/** + * Get the lazily imported Node.js createServer function. + * Throws if not running in a Node.js environment. + */ +async function getNodeCreateServer( + providerName: string, +): Promise { + if (_createServer) return _createServer; + if (_httpImportPromise) { + await _httpImportPromise; + } + if (_createServer) return _createServer; + throw new Error(`${providerName} OAuth is only available in Node.js environments`); +} + +/** + * Start a local HTTP server to receive the OAuth callback. + * + * @param port - The port to listen on (e.g. 8085, 51121) + * @param callbackPath - The URL path for the callback (e.g. "/oauth2callback", "/oauth-callback") + * @param providerName - Human-readable provider name for error messages + */ +export async function startCallbackServer( + port: number, + callbackPath: string, + providerName: string, +): Promise { + const createServer = await getNodeCreateServer(providerName); + + 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:${port}`); + + if (url.pathname === callbackPath) { + 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(port, "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 a redirect URL to extract the authorization code and state parameters. + */ +export 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 {}; + } +} + +/** + * Get the user's email address from a Google OAuth access token. + */ +export async function getGoogleUserEmail(accessToken: string): Promise { + try { + const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + signal: AbortSignal.timeout(30_000), + }); + + if (response.ok) { + const data = (await response.json()) as { email?: string }; + return data.email; + } + } catch { + // Ignore errors, email is optional + } + return undefined; +} + +/** + * Refresh a Google OAuth token using the standard Google token endpoint. + * + * @param refreshToken - The refresh token + * @param clientId - The OAuth client ID + * @param clientSecret - The OAuth client secret + * @param providerName - Human-readable provider name for error messages + * @param extraFields - Additional fields to include in the returned credentials + */ +export async function refreshGoogleOAuthToken( + refreshToken: string, + clientId: string, + clientSecret: string, + providerName: string, + extraFields?: Record, +): Promise { + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }), + signal: AbortSignal.timeout(30_000), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`${providerName} 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, + ...extraFields, + }; +}