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.
This commit is contained in:
frizynn 2026-03-19 15:55:10 -03:00
parent eaf0538150
commit d0cd3451fd
3 changed files with 228 additions and 323 deletions

View file

@ -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<void> | 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<typeof import("node:http").createServer> {
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<CallbackServerInfo> {
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(
`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
);
return;
}
if (code && state) {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(
`<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
);
result = { code, state };
} else {
res.writeHead(400, { "Content-Type": "text/html" });
res.end(
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
);
}
} 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<string | undefined> {
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<OAuthCredentials> {
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);

View file

@ -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<void> | 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<typeof import("node:http").createServer> {
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<CallbackServerInfo> {
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(
`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
);
return;
}
if (code && state) {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(
`<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
);
result = { code, state };
} else {
res.writeHead(400, { "Content-Type": "text/html" });
res.end(
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
);
}
} 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<string | undefined> {
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<OAuthCredentials> {
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);

View file

@ -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<void> | 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<typeof import("node:http").createServer> {
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<CallbackServerInfo> {
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(
`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
);
return;
}
if (code && state) {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(
`<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
);
result = { code, state };
} else {
res.writeHead(400, { "Content-Type": "text/html" });
res.end(
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
);
}
} 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<string | undefined> {
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<string, unknown>,
): Promise<OAuthCredentials> {
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,
};
}