| import fs from "fs/promises";
|
| import path from "path";
|
| import { getProviderConnectionById } from "@/lib/localDb";
|
| import { createBackup } from "@/shared/services/backupService";
|
| import { getCliConfigPaths } from "@/shared/services/cliRuntime";
|
| import {
|
| TOKEN_EXPIRY_BUFFER_MS,
|
| getAccessToken,
|
| updateProviderCredentials,
|
| } from "@/sse/services/tokenRefresh";
|
| import { isUnrecoverableRefreshError } from "@omniroute/open-sse/services/tokenRefresh.ts";
|
|
|
| type JsonRecord = Record<string, unknown>;
|
|
|
| interface GeminiConnectionLike {
|
| id?: string;
|
| provider?: string;
|
| authType?: string;
|
| name?: string;
|
| email?: string;
|
| displayName?: string;
|
| accessToken?: string | null;
|
| refreshToken?: string | null;
|
| idToken?: string | null;
|
| expiresAt?: string | null;
|
| expiresIn?: number | null;
|
| providerSpecificData?: JsonRecord | null;
|
| }
|
|
|
| export interface GeminiAuthFilePayload {
|
| access_token: string;
|
| scope: string;
|
| token_type: string;
|
| id_token: string;
|
| expiry_date: number;
|
| refresh_token: string;
|
| }
|
|
|
| export interface BuiltGeminiAuthFile {
|
| connectionId: string;
|
| connectionLabel: string;
|
| email: string | null;
|
| fileName: string;
|
| payload: GeminiAuthFilePayload;
|
| content: string;
|
| }
|
|
|
| export class GeminiAuthFileError extends Error {
|
| status: number;
|
| code: string;
|
|
|
| constructor(message: string, status = 400, code = "invalid_request") {
|
| super(message);
|
| this.name = "GeminiAuthFileError";
|
| this.status = status;
|
| this.code = code;
|
| }
|
| }
|
|
|
| export interface GoogleAccountsSidecar {
|
| active: string;
|
| old: string[];
|
| }
|
|
|
| export interface ApplyResult extends BuiltGeminiAuthFile {
|
| authPath: string;
|
| accountsPath: string;
|
| savedBakPath: string | null;
|
| savedAccountsBakPath: string | null;
|
| centralizedBackupPath: string | null;
|
| googleAccountsUpdated: boolean;
|
| }
|
|
|
| const GEMINI_REFRESH_BUFFER_MS = Math.max(TOKEN_EXPIRY_BUFFER_MS, 5 * 60 * 1000);
|
|
|
| function toRecord(value: unknown): JsonRecord {
|
| return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
| }
|
|
|
| function toNonEmptyString(value: unknown): string | null {
|
| if (typeof value !== "string") return null;
|
| const trimmed = value.trim();
|
| return trimmed ? trimmed : null;
|
| }
|
|
|
| function decodeJwtPayload(jwt: string): JsonRecord | null {
|
| try {
|
| const parts = jwt.split(".");
|
| if (parts.length !== 3) return null;
|
| const payload = Buffer.from(parts[1], "base64url").toString("utf8");
|
| return toRecord(JSON.parse(payload));
|
| } catch {
|
| return null;
|
| }
|
| }
|
|
|
| export function sanitizeFileNamePart(value: string): string {
|
| const normalized = value
|
| .trim()
|
| .toLowerCase()
|
| .replace(/[^a-z0-9._@-]+/g, "-")
|
| .replace(/^-+|-+$/g, "");
|
| return normalized || "account";
|
| }
|
|
|
| export function extractGeminiEmail(connection: GeminiConnectionLike): string | null {
|
| const idToken = toNonEmptyString(connection.idToken);
|
| if (idToken) {
|
| const payload = decodeJwtPayload(idToken);
|
| if (payload) {
|
| const fromClaim = toNonEmptyString(payload.email);
|
| if (fromClaim) return fromClaim;
|
| }
|
| }
|
| return toNonEmptyString(connection.email) || toNonEmptyString(connection.displayName);
|
| }
|
|
|
| export function shouldRefreshGeminiConnection(connection: GeminiConnectionLike): boolean {
|
| if (!toNonEmptyString(connection.accessToken)) {
|
| return true;
|
| }
|
|
|
| const expiresAt = toNonEmptyString(connection.expiresAt);
|
| if (!expiresAt) {
|
| return false;
|
| }
|
|
|
| const expiresAtMs = new Date(expiresAt).getTime();
|
| if (Number.isNaN(expiresAtMs)) {
|
| return false;
|
| }
|
|
|
| return expiresAtMs - Date.now() <= GEMINI_REFRESH_BUFFER_MS;
|
| }
|
|
|
| function getConnectionLabel(connection: GeminiConnectionLike): string {
|
| return (
|
| toNonEmptyString(connection.name) ||
|
| toNonEmptyString(connection.email) ||
|
| toNonEmptyString(connection.displayName) ||
|
| toNonEmptyString(connection.id) ||
|
| "gemini-account"
|
| );
|
| }
|
|
|
| function buildGeminiAuthPayload(connection: GeminiConnectionLike): GeminiAuthFilePayload {
|
| const accessToken = toNonEmptyString(connection.accessToken);
|
| const refreshToken = toNonEmptyString(connection.refreshToken);
|
| const idToken = toNonEmptyString(connection.idToken);
|
|
|
| if (!accessToken) {
|
| throw new GeminiAuthFileError(
|
| "Gemini connection is missing access_token. Refresh or re-authenticate this account first.",
|
| 409,
|
| "access_token_missing"
|
| );
|
| }
|
|
|
| if (!refreshToken) {
|
| throw new GeminiAuthFileError(
|
| "Gemini connection is missing refresh_token. Re-authenticate this account before exporting.",
|
| 409,
|
| "reauth_required"
|
| );
|
| }
|
|
|
| if (!idToken) {
|
| throw new GeminiAuthFileError(
|
| "Gemini connection is missing id_token. Re-authenticate this account before exporting.",
|
| 409,
|
| "id_token_missing"
|
| );
|
| }
|
|
|
| const psd = toRecord(connection.providerSpecificData);
|
| const scope = toNonEmptyString(psd.scope) ?? "";
|
| const tokenType = toNonEmptyString(psd.tokenType) ?? "Bearer";
|
|
|
| let expiryDate: number;
|
| const expiresAt = toNonEmptyString(connection.expiresAt);
|
| if (expiresAt) {
|
| const ms = new Date(expiresAt).getTime();
|
| expiryDate = Number.isNaN(ms) ? Date.now() + 3600 * 1000 : ms;
|
| } else {
|
| expiryDate = Date.now() + 3600 * 1000;
|
| }
|
|
|
| return {
|
| access_token: accessToken,
|
| scope,
|
| token_type: tokenType,
|
| id_token: idToken,
|
| expiry_date: expiryDate,
|
| refresh_token: refreshToken,
|
| };
|
| }
|
|
|
| async function resolveFreshGeminiConnection(connectionId: string): Promise<GeminiConnectionLike> {
|
| const connection = (await getProviderConnectionById(connectionId)) as GeminiConnectionLike | null;
|
| if (!connection) {
|
| throw new GeminiAuthFileError("Connection not found", 404, "not_found");
|
| }
|
|
|
| if (connection.provider !== "gemini-cli") {
|
| throw new GeminiAuthFileError(
|
| "Only Gemini CLI provider connections can export Gemini auth files"
|
| );
|
| }
|
|
|
| if (connection.authType !== "oauth") {
|
| throw new GeminiAuthFileError(
|
| "Only OAuth Gemini CLI connections support oauth_creds.json export"
|
| );
|
| }
|
|
|
| if (!shouldRefreshGeminiConnection(connection)) {
|
| return connection;
|
| }
|
|
|
| const refreshToken = toNonEmptyString(connection.refreshToken);
|
| if (!refreshToken) {
|
| throw new GeminiAuthFileError(
|
| "Gemini connection requires refresh but no refresh_token is available. Re-authenticate first.",
|
| 409,
|
| "reauth_required"
|
| );
|
| }
|
|
|
| const refreshed = await getAccessToken("gemini-cli", {
|
| connectionId,
|
| accessToken: connection.accessToken,
|
| refreshToken,
|
| expiresAt: connection.expiresAt,
|
| expiresIn: connection.expiresIn,
|
| idToken: connection.idToken,
|
| providerSpecificData: connection.providerSpecificData,
|
| });
|
|
|
| if (isUnrecoverableRefreshError(refreshed)) {
|
| throw new GeminiAuthFileError(
|
| "Gemini refresh token is no longer valid. Re-authenticate this account before exporting.",
|
| 409,
|
| "reauth_required"
|
| );
|
| }
|
|
|
| if (!refreshed?.accessToken) {
|
| throw new GeminiAuthFileError(
|
| "Failed to refresh the Gemini session before exporting the auth file. Re-authenticate this account if the session is stale.",
|
| 502,
|
| "refresh_failed"
|
| );
|
| }
|
|
|
| await updateProviderCredentials(connectionId, refreshed);
|
|
|
| return {
|
| ...connection,
|
| accessToken: refreshed.accessToken,
|
| refreshToken: toNonEmptyString(refreshed.refreshToken) || refreshToken,
|
| expiresIn:
|
| typeof refreshed.expiresIn === "number" ? refreshed.expiresIn : connection.expiresIn || null,
|
| expiresAt:
|
| typeof refreshed.expiresIn === "number"
|
| ? new Date(Date.now() + refreshed.expiresIn * 1000).toISOString()
|
| : connection.expiresAt || null,
|
| providerSpecificData: refreshed.providerSpecificData
|
| ? {
|
| ...toRecord(connection.providerSpecificData),
|
| ...toRecord(refreshed.providerSpecificData),
|
| }
|
| : connection.providerSpecificData,
|
| };
|
| }
|
|
|
| export async function buildGeminiAuthFile(connectionId: string): Promise<BuiltGeminiAuthFile> {
|
| const connection = await resolveFreshGeminiConnection(connectionId);
|
| const payload = buildGeminiAuthPayload(connection);
|
| const connectionLabel = getConnectionLabel(connection);
|
| const email = extractGeminiEmail(connection);
|
| const fileNameIdentifier = email || connectionLabel;
|
| const fileName = `gemini-auth-${sanitizeFileNamePart(fileNameIdentifier)}.json`;
|
| const content = JSON.stringify(payload, null, 2) + "\n";
|
|
|
| return {
|
| connectionId,
|
| connectionLabel,
|
| email,
|
| fileName,
|
| payload,
|
| content,
|
| };
|
| }
|
|
|
| export async function mergeGoogleAccountsFile(
|
| accountsPath: string,
|
| newEmail: string
|
| ): Promise<{ updated: boolean; savedBakPath: string | null }> {
|
| let existing: GoogleAccountsSidecar = { active: "", old: [] };
|
| let fileExists = false;
|
| try {
|
| const raw = await fs.readFile(accountsPath, "utf8");
|
| const parsed = JSON.parse(raw);
|
| if (parsed && typeof parsed === "object") {
|
| existing = {
|
| active: typeof parsed.active === "string" ? parsed.active : "",
|
| old: Array.isArray(parsed.old)
|
| ? parsed.old.filter((s: unknown) => typeof s === "string")
|
| : [],
|
| };
|
| }
|
| fileExists = true;
|
| } catch {
|
|
|
| }
|
|
|
| if (existing.active === newEmail) {
|
| return { updated: false, savedBakPath: null };
|
| }
|
|
|
|
|
| let savedBakPath: string | null = null;
|
| if (fileExists) {
|
| const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
| savedBakPath = `${path.dirname(accountsPath)}${path.sep}google_accounts-${ts}.bak`;
|
| await fs.copyFile(accountsPath, savedBakPath).catch(() => {});
|
| }
|
|
|
| const newOld = [
|
| existing.active,
|
| ...existing.old.filter((e) => e !== existing.active && e !== newEmail),
|
| ].filter(Boolean);
|
|
|
| const newDoc: GoogleAccountsSidecar = {
|
| active: newEmail,
|
| old: Array.from(new Set(newOld)),
|
| };
|
|
|
| await fs.writeFile(accountsPath, JSON.stringify(newDoc, null, 2) + "\n", {
|
| encoding: "utf8",
|
| mode: 0o600,
|
| });
|
| try {
|
| await fs.chmod(accountsPath, 0o600);
|
| } catch {
|
|
|
| }
|
|
|
| return { updated: true, savedBakPath };
|
| }
|
|
|
| export async function writeGeminiAuthFileToLocalCli(connectionId: string): Promise<ApplyResult> {
|
| const built = await buildGeminiAuthFile(connectionId);
|
| const paths = getCliConfigPaths("gemini-cli");
|
|
|
|
|
|
|
| const authPath = paths?.auth;
|
| const accountsPath = paths?.accounts;
|
|
|
| if (!authPath || !accountsPath) {
|
| throw new GeminiAuthFileError(
|
| "Gemini CLI paths could not be resolved",
|
| 500,
|
| "path_unavailable"
|
| );
|
| }
|
|
|
| const authDir = path.dirname(authPath);
|
| await fs.mkdir(authDir, { recursive: true });
|
|
|
|
|
| let savedBakPath: string | null = null;
|
| try {
|
| await fs.access(authPath);
|
| const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
| savedBakPath = `${authDir}${path.sep}oauth_creds-${ts}.bak`;
|
| await fs.copyFile(authPath, savedBakPath);
|
| } catch {
|
|
|
| }
|
|
|
|
|
| const centralizedBackupPath = await createBackup("gemini-cli", authPath);
|
|
|
| await fs.writeFile(authPath, built.content, { encoding: "utf8", mode: 0o600 });
|
|
|
| try {
|
| await fs.chmod(authPath, 0o600);
|
| } catch {
|
|
|
| }
|
|
|
| const newEmail = built.email;
|
| let googleAccountsUpdated = false;
|
| let savedAccountsBakPath: string | null = null;
|
| if (newEmail) {
|
| const merged = await mergeGoogleAccountsFile(accountsPath, newEmail);
|
| googleAccountsUpdated = merged.updated;
|
| savedAccountsBakPath = merged.savedBakPath;
|
| }
|
|
|
| return {
|
| ...built,
|
| authPath,
|
| accountsPath,
|
| savedBakPath,
|
| savedAccountsBakPath,
|
| centralizedBackupPath,
|
| googleAccountsUpdated,
|
| };
|
| }
|
|
|