Spaces:
Runtime error
Runtime error
| 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 { | |
| // file absent or invalid — start fresh | |
| } | |
| if (existing.active === newEmail) { | |
| return { updated: false, savedBakPath: null }; | |
| } | |
| // Side-by-side backup | |
| 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 { | |
| // Best effort on platforms that ignore chmod semantics. | |
| } | |
| return { updated: true, savedBakPath }; | |
| } | |
| export async function writeGeminiAuthFileToLocalCli(connectionId: string): Promise<ApplyResult> { | |
| const built = await buildGeminiAuthFile(connectionId); | |
| const paths = getCliConfigPaths("gemini-cli"); | |
| // authPath and accountsPath are sourced exclusively from the static CLI_TOOLS table | |
| // in src/shared/services/cliRuntime.ts joined against os.homedir() — no user input | |
| // ever reaches the path APIs below. | |
| 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 }); | |
| // Side-by-side .bak inside the .gemini directory for one-click manual rollback. | |
| 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 { | |
| // No existing file; nothing to back up side-by-side. | |
| } | |
| // Centralized history (audit trail across all CLI tools). | |
| const centralizedBackupPath = await createBackup("gemini-cli", authPath); | |
| await fs.writeFile(authPath, built.content, { encoding: "utf8", mode: 0o600 }); | |
| try { | |
| await fs.chmod(authPath, 0o600); | |
| } catch { | |
| // Best effort on platforms that ignore chmod semantics. | |
| } | |
| 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, | |
| }; | |
| } | |