mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(oauth): add Gemini CLI auth import/export libs + CLI_TOOLS registration (PR1)
This commit is contained in:
412
src/lib/oauth/utils/geminiAuthFile.ts
Normal file
412
src/lib/oauth/utils/geminiAuthFile.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
237
src/lib/oauth/utils/geminiAuthImport.ts
Normal file
237
src/lib/oauth/utils/geminiAuthImport.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
getProviderConnections,
|
||||
createProviderConnection,
|
||||
updateProviderConnection,
|
||||
} from "@/lib/localDb";
|
||||
import { GeminiAuthFileError } from "@/lib/oauth/utils/geminiAuthFile";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function extractJwtEmail(idToken: string): string | null {
|
||||
const payload = decodeJwtPayload(idToken);
|
||||
if (!payload) return null;
|
||||
return toNonEmptyString(payload.email);
|
||||
}
|
||||
|
||||
// ──── Public types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ParsedGeminiAuth {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
idToken: string;
|
||||
scope: string;
|
||||
tokenType: string;
|
||||
expiresAt: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface EnrichedGeminiAuth extends ParsedGeminiAuth {
|
||||
projectId: string | null;
|
||||
}
|
||||
|
||||
export interface CreateConnectionOptions {
|
||||
name?: string;
|
||||
email?: string;
|
||||
overwriteExisting?: boolean;
|
||||
}
|
||||
|
||||
// ──── Parse & validate ────────────────────────────────────────────────────────
|
||||
|
||||
export function parseAndValidateGeminiAuth(raw: unknown): ParsedGeminiAuth {
|
||||
const doc = toRecord(raw);
|
||||
|
||||
const accessToken = toNonEmptyString(doc.access_token);
|
||||
const refreshToken = toNonEmptyString(doc.refresh_token);
|
||||
const idToken = toNonEmptyString(doc.id_token);
|
||||
|
||||
if (!accessToken) {
|
||||
throw new GeminiAuthFileError(
|
||||
"access_token is missing or empty in the oauth_creds.json",
|
||||
400,
|
||||
"missing_access_token"
|
||||
);
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new GeminiAuthFileError(
|
||||
"refresh_token is missing or empty in the oauth_creds.json",
|
||||
400,
|
||||
"missing_refresh_token"
|
||||
);
|
||||
}
|
||||
|
||||
if (!idToken) {
|
||||
throw new GeminiAuthFileError(
|
||||
"id_token is missing or empty in the oauth_creds.json",
|
||||
400,
|
||||
"missing_id_token"
|
||||
);
|
||||
}
|
||||
|
||||
const expiryDateMs = doc.expiry_date;
|
||||
let expiresAt: string | null = null;
|
||||
if (typeof expiryDateMs === "number" && Number.isFinite(expiryDateMs)) {
|
||||
expiresAt = new Date(expiryDateMs).toISOString();
|
||||
}
|
||||
|
||||
const scope = toNonEmptyString(doc.scope) ?? "";
|
||||
const tokenType = toNonEmptyString(doc.token_type) ?? "Bearer";
|
||||
const email = extractJwtEmail(idToken);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
idToken,
|
||||
scope,
|
||||
tokenType,
|
||||
expiresAt,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
// ──── Enrich with Cloud Code Assist project info ──────────────────────────────
|
||||
|
||||
export async function enrichWithLoadCodeAssist(
|
||||
parsed: ParsedGeminiAuth
|
||||
): Promise<EnrichedGeminiAuth> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${parsed.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ metadata: { ideType: "GEMINI_CLI", platform: "linux" } }),
|
||||
signal: controller.signal,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { ...parsed, projectId: null };
|
||||
}
|
||||
|
||||
const data = toRecord(await response.json());
|
||||
const projectId = toNonEmptyString(data.projectId) ?? toNonEmptyString(data.cloudaiProjectId);
|
||||
return { ...parsed, projectId };
|
||||
} catch {
|
||||
return { ...parsed, projectId: null };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// ──── Find existing connection ────────────────────────────────────────────────
|
||||
|
||||
export async function findExistingGeminiConnection(email: string): Promise<JsonRecord | null> {
|
||||
const connections = await getProviderConnections({ provider: "gemini-cli" });
|
||||
const lowerEmail = email.toLowerCase();
|
||||
return (
|
||||
(connections.find((c) => {
|
||||
const conn = c as JsonRecord;
|
||||
if (toNonEmptyString(conn.email)?.toLowerCase() === lowerEmail) return true;
|
||||
const psd = toRecord(conn.providerSpecificData);
|
||||
return toNonEmptyString(psd.bootstrapEmail)?.toLowerCase() === lowerEmail;
|
||||
}) as JsonRecord | undefined) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// ──── Create / update connection ──────────────────────────────────────────────
|
||||
|
||||
export async function createConnectionFromAuthFile(
|
||||
enriched: EnrichedGeminiAuth,
|
||||
options: CreateConnectionOptions
|
||||
): Promise<{ connection: JsonRecord; created: boolean }> {
|
||||
const resolvedEmail = options.email || enriched.email;
|
||||
|
||||
if (resolvedEmail) {
|
||||
const existing = await findExistingGeminiConnection(resolvedEmail);
|
||||
|
||||
if (existing) {
|
||||
if (!options.overwriteExisting) {
|
||||
throw new GeminiAuthFileError(
|
||||
"A Gemini CLI connection for this account already exists. Pass overwriteExisting: true to replace it.",
|
||||
409,
|
||||
"duplicate_account"
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await updateProviderConnection(existing.id as string, {
|
||||
accessToken: enriched.accessToken,
|
||||
refreshToken: enriched.refreshToken,
|
||||
idToken: enriched.idToken,
|
||||
expiresAt: enriched.expiresAt,
|
||||
email: resolvedEmail || (existing.email as string | undefined),
|
||||
name:
|
||||
options.name ||
|
||||
(existing.name as string | undefined) ||
|
||||
resolvedEmail ||
|
||||
"Gemini (imported)",
|
||||
testStatus: "active",
|
||||
providerSpecificData: {
|
||||
...toRecord(existing.providerSpecificData),
|
||||
scope: enriched.scope,
|
||||
tokenType: enriched.tokenType,
|
||||
projectId: enriched.projectId ?? (toRecord(existing.providerSpecificData).projectId),
|
||||
importedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
return { connection: updated || existing, created: false };
|
||||
}
|
||||
} else if (!options.overwriteExisting) {
|
||||
throw new GeminiAuthFileError(
|
||||
"Cannot verify identity from the oauth_creds.json — id_token does not contain an email claim. Pass overwriteExisting: true to import without email verification.",
|
||||
409,
|
||||
"identity_unverified"
|
||||
);
|
||||
}
|
||||
|
||||
const name = options.name || resolvedEmail || "Gemini (imported)";
|
||||
|
||||
const connection = await createProviderConnection({
|
||||
provider: "gemini-cli",
|
||||
authType: "oauth",
|
||||
name,
|
||||
email: resolvedEmail || undefined,
|
||||
accessToken: enriched.accessToken,
|
||||
refreshToken: enriched.refreshToken,
|
||||
idToken: enriched.idToken,
|
||||
expiresAt: enriched.expiresAt,
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: {
|
||||
scope: enriched.scope,
|
||||
tokenType: enriched.tokenType,
|
||||
projectId: enriched.projectId,
|
||||
importedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
return { connection, created: true };
|
||||
}
|
||||
89
src/lib/oauth/utils/geminiAuthZipExtract.ts
Normal file
89
src/lib/oauth/utils/geminiAuthZipExtract.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import path from "path";
|
||||
import { unzipSync, type Unzipped } from "fflate";
|
||||
|
||||
export interface ExtractedZipFile {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ExtractZipOptions {
|
||||
maxFiles?: number;
|
||||
maxFileSizeBytes?: number;
|
||||
maxTotalSizeBytes?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_FILES = 50;
|
||||
const DEFAULT_MAX_FILE_SIZE = 256 * 1024;
|
||||
const DEFAULT_MAX_TOTAL = 10 * 1024 * 1024;
|
||||
|
||||
function isSafeEntryName(name: string): boolean {
|
||||
if (!name.toLowerCase().endsWith(".json")) return false;
|
||||
if (name.includes("..")) return false;
|
||||
if (path.isAbsolute(name)) return false;
|
||||
if (/[\r\n\0]/.test(name)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function extractGeminiAuthZip(
|
||||
zipBuffer: Buffer,
|
||||
options: ExtractZipOptions = {}
|
||||
): ExtractedZipFile[] {
|
||||
const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
|
||||
const maxFileSize = options.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE;
|
||||
const maxTotal = options.maxTotalSizeBytes ?? DEFAULT_MAX_TOTAL;
|
||||
|
||||
let unzipped: Unzipped;
|
||||
try {
|
||||
unzipped = unzipSync(new Uint8Array(zipBuffer));
|
||||
} catch {
|
||||
throw new Error("Could not parse ZIP archive — file may be corrupt or not a valid ZIP");
|
||||
}
|
||||
|
||||
const entries = Object.entries(unzipped).filter(([, data]) => data !== undefined);
|
||||
const jsonEntries = entries.filter(([name]) => name.toLowerCase().endsWith(".json"));
|
||||
|
||||
if (jsonEntries.length === 0) {
|
||||
throw new Error("ZIP archive contains no .json files");
|
||||
}
|
||||
|
||||
if (jsonEntries.length > maxFiles) {
|
||||
throw new Error(
|
||||
`ZIP archive contains ${jsonEntries.length} .json files — max allowed is ${maxFiles}`
|
||||
);
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
const result: ExtractedZipFile[] = [];
|
||||
|
||||
for (const [entryName, data] of jsonEntries) {
|
||||
const baseName = path.basename(entryName);
|
||||
|
||||
if (!isSafeEntryName(baseName)) {
|
||||
throw new Error(
|
||||
`ZIP entry "${baseName}" has an unsafe filename (must be a .json file without path traversal)`
|
||||
);
|
||||
}
|
||||
|
||||
if (!isSafeEntryName(entryName)) {
|
||||
throw new Error(
|
||||
`ZIP entry path "${entryName}" is unsafe (no "..", absolute paths, or control characters allowed)`
|
||||
);
|
||||
}
|
||||
|
||||
if (data.byteLength > maxFileSize) {
|
||||
throw new Error(
|
||||
`ZIP entry "${baseName}" is ${data.byteLength} bytes — exceeds ${maxFileSize} byte limit per file`
|
||||
);
|
||||
}
|
||||
|
||||
totalBytes += data.byteLength;
|
||||
if (totalBytes > maxTotal) {
|
||||
throw new Error(`ZIP archive total uncompressed size exceeds ${maxTotal} byte limit`);
|
||||
}
|
||||
|
||||
const content = new TextDecoder("utf-8").decode(data);
|
||||
result.push({ name: baseName, content });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -166,6 +166,17 @@ const CLI_TOOLS: Record<string, any> = {
|
||||
env: ".qwen/.env",
|
||||
},
|
||||
},
|
||||
"gemini-cli": {
|
||||
defaultCommand: "gemini",
|
||||
envBinKey: "CLI_GEMINI_BIN",
|
||||
requiresBinary: true,
|
||||
healthcheckTimeoutMs: 8000,
|
||||
paths: {
|
||||
auth: ".gemini/oauth_creds.json",
|
||||
accounts: ".gemini/google_accounts.json",
|
||||
settings: ".gemini/settings.json",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const isWindows = () => process.platform === "win32";
|
||||
|
||||
482
tests/unit/geminiAuthFile.test.ts
Normal file
482
tests/unit/geminiAuthFile.test.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
|
||||
// Pure-function mirrors of helpers from geminiAuthFile.ts — no DB/cliRuntime deps.
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function buildJwt(payload: JsonRecord): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
return `${header}.${body}.fake-signature`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeFileNamePart(value: string): string {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._@-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return normalized || "account";
|
||||
}
|
||||
|
||||
interface GeminiConnectionLike {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
accessToken?: string | null;
|
||||
refreshToken?: string | null;
|
||||
idToken?: string | null;
|
||||
expiresAt?: string | null;
|
||||
providerSpecificData?: JsonRecord | null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
interface GeminiAuthFilePayload {
|
||||
access_token: string;
|
||||
scope: string;
|
||||
token_type: string;
|
||||
id_token: string;
|
||||
expiry_date: number;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
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() <= REFRESH_BUFFER_MS;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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.",
|
||||
409,
|
||||
"access_token_missing"
|
||||
);
|
||||
}
|
||||
if (!refreshToken) {
|
||||
throw new GeminiAuthFileError(
|
||||
"Gemini connection is missing refresh_token.",
|
||||
409,
|
||||
"reauth_required"
|
||||
);
|
||||
}
|
||||
if (!idToken) {
|
||||
throw new GeminiAuthFileError(
|
||||
"Gemini connection is missing id_token.",
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
interface GoogleAccountsSidecar {
|
||||
active: string;
|
||||
old: string[];
|
||||
}
|
||||
|
||||
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 {
|
||||
// absent or invalid
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
return { updated: true, savedBakPath };
|
||||
}
|
||||
|
||||
// ──── Tests: buildGeminiAuthPayload ───────────────────────────────────────────
|
||||
|
||||
test("buildGeminiAuthPayload: produces correct Google OAuth2 shape", () => {
|
||||
const idToken = buildJwt({ email: "user@google.com", sub: "12345" });
|
||||
const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString();
|
||||
const conn: GeminiConnectionLike = {
|
||||
accessToken: "fake-access-token-aaa",
|
||||
refreshToken: "1//refresh",
|
||||
idToken,
|
||||
expiresAt,
|
||||
providerSpecificData: {
|
||||
scope: "https://www.googleapis.com/auth/cloud-platform openid email",
|
||||
tokenType: "Bearer",
|
||||
},
|
||||
};
|
||||
const payload = buildGeminiAuthPayload(conn);
|
||||
assert.equal(payload.access_token, "fake-access-token-aaa");
|
||||
assert.equal(payload.refresh_token, "1//refresh");
|
||||
assert.equal(payload.id_token, idToken);
|
||||
assert.equal(payload.token_type, "Bearer");
|
||||
assert.equal(payload.scope, "https://www.googleapis.com/auth/cloud-platform openid email");
|
||||
assert.ok(typeof payload.expiry_date === "number", "expiry_date must be a number");
|
||||
});
|
||||
|
||||
test("buildGeminiAuthPayload: expiry_date is unix ms, not ISO", () => {
|
||||
const idToken = buildJwt({ email: "u@g.com" });
|
||||
const expiresAt = "2026-01-01T00:00:00.000Z";
|
||||
const conn: GeminiConnectionLike = {
|
||||
accessToken: "fake-access-token-x",
|
||||
refreshToken: "rt",
|
||||
idToken,
|
||||
expiresAt,
|
||||
providerSpecificData: {},
|
||||
};
|
||||
const payload = buildGeminiAuthPayload(conn);
|
||||
assert.equal(payload.expiry_date, new Date(expiresAt).getTime());
|
||||
assert.ok(payload.expiry_date > 1_000_000_000_000, "should be ms-epoch, not seconds");
|
||||
});
|
||||
|
||||
test("buildGeminiAuthPayload: scope is string with spaces, not array", () => {
|
||||
const idToken = buildJwt({ email: "u@g.com" });
|
||||
const conn: GeminiConnectionLike = {
|
||||
accessToken: "fake-access-token-x",
|
||||
refreshToken: "rt",
|
||||
idToken,
|
||||
providerSpecificData: {
|
||||
scope: "https://www.googleapis.com/auth/cloud-platform openid email",
|
||||
},
|
||||
};
|
||||
const payload = buildGeminiAuthPayload(conn);
|
||||
assert.ok(typeof payload.scope === "string", "scope must be a string");
|
||||
assert.ok(payload.scope.includes(" "), "scope must use spaces as separator");
|
||||
assert.ok(!Array.isArray(payload.scope), "scope must not be an array");
|
||||
});
|
||||
|
||||
test("buildGeminiAuthPayload: token_type defaults to Bearer when providerSpecificData.tokenType absent", () => {
|
||||
const idToken = buildJwt({ email: "u@g.com" });
|
||||
const conn: GeminiConnectionLike = {
|
||||
accessToken: "fake-access-token-x",
|
||||
refreshToken: "rt",
|
||||
idToken,
|
||||
providerSpecificData: {},
|
||||
};
|
||||
const payload = buildGeminiAuthPayload(conn);
|
||||
assert.equal(payload.token_type, "Bearer");
|
||||
});
|
||||
|
||||
test("buildGeminiAuthPayload: throws access_token_missing when accessToken absent", () => {
|
||||
const idToken = buildJwt({ email: "u@g.com" });
|
||||
const conn: GeminiConnectionLike = {
|
||||
accessToken: null,
|
||||
refreshToken: "rt",
|
||||
idToken,
|
||||
providerSpecificData: {},
|
||||
};
|
||||
assert.throws(
|
||||
() => buildGeminiAuthPayload(conn),
|
||||
(err: GeminiAuthFileError) => {
|
||||
assert.equal(err.code, "access_token_missing");
|
||||
assert.equal(err.status, 409);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("buildGeminiAuthPayload: throws reauth_required when refreshToken absent", () => {
|
||||
const idToken = buildJwt({ email: "u@g.com" });
|
||||
const conn: GeminiConnectionLike = {
|
||||
accessToken: "fake-access-token-x",
|
||||
refreshToken: null,
|
||||
idToken,
|
||||
providerSpecificData: {},
|
||||
};
|
||||
assert.throws(
|
||||
() => buildGeminiAuthPayload(conn),
|
||||
(err: GeminiAuthFileError) => {
|
||||
assert.equal(err.code, "reauth_required");
|
||||
assert.equal(err.status, 409);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("buildGeminiAuthPayload: throws id_token_missing when idToken absent", () => {
|
||||
const conn: GeminiConnectionLike = {
|
||||
accessToken: "fake-access-token-x",
|
||||
refreshToken: "rt",
|
||||
idToken: null,
|
||||
providerSpecificData: {},
|
||||
};
|
||||
assert.throws(
|
||||
() => buildGeminiAuthPayload(conn),
|
||||
(err: GeminiAuthFileError) => {
|
||||
assert.equal(err.code, "id_token_missing");
|
||||
assert.equal(err.status, 409);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// ──── Tests: shouldRefreshGeminiConnection ────────────────────────────────────
|
||||
|
||||
test("shouldRefreshGeminiConnection: true when expiresAt < now+5min", () => {
|
||||
const conn: GeminiConnectionLike = {
|
||||
accessToken: "fake-access-token-x",
|
||||
expiresAt: new Date(Date.now() + 2 * 60 * 1000).toISOString(),
|
||||
};
|
||||
assert.ok(shouldRefreshGeminiConnection(conn) === true);
|
||||
});
|
||||
|
||||
test("shouldRefreshGeminiConnection: false when expiresAt > now+10min", () => {
|
||||
const conn: GeminiConnectionLike = {
|
||||
accessToken: "fake-access-token-x",
|
||||
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
|
||||
};
|
||||
assert.ok(shouldRefreshGeminiConnection(conn) === false);
|
||||
});
|
||||
|
||||
test("shouldRefreshGeminiConnection: true when accessToken absent", () => {
|
||||
const conn: GeminiConnectionLike = {
|
||||
accessToken: null,
|
||||
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
|
||||
};
|
||||
assert.ok(shouldRefreshGeminiConnection(conn) === true);
|
||||
});
|
||||
|
||||
// ──── Tests: extractGeminiEmail ───────────────────────────────────────────────
|
||||
|
||||
test("extractGeminiEmail: extracts email from id_token claim first", () => {
|
||||
const idToken = buildJwt({ email: "jwt@google.com" });
|
||||
const conn: GeminiConnectionLike = { idToken, email: "other@google.com" };
|
||||
assert.equal(extractGeminiEmail(conn), "jwt@google.com");
|
||||
});
|
||||
|
||||
test("extractGeminiEmail: falls back to connection.email", () => {
|
||||
const conn: GeminiConnectionLike = { email: "conn@google.com" };
|
||||
assert.equal(extractGeminiEmail(conn), "conn@google.com");
|
||||
});
|
||||
|
||||
test("extractGeminiEmail: falls back to displayName if no email", () => {
|
||||
const conn: GeminiConnectionLike = { displayName: "My Account" };
|
||||
assert.equal(extractGeminiEmail(conn), "My Account");
|
||||
});
|
||||
|
||||
// ──── Tests: filename ─────────────────────────────────────────────────────────
|
||||
|
||||
test("filename: gemini-auth-{email}.json when email available", () => {
|
||||
const sanitized = sanitizeFileNamePart("user@google.com");
|
||||
const filename = `gemini-auth-${sanitized}.json`;
|
||||
assert.equal(filename, "gemini-auth-user@google.com.json");
|
||||
});
|
||||
|
||||
test("filename: gemini-auth-{label}.json fallback when no email", () => {
|
||||
const sanitized = sanitizeFileNamePart("Production Account");
|
||||
const filename = `gemini-auth-${sanitized}.json`;
|
||||
assert.equal(filename, "gemini-auth-production-account.json");
|
||||
});
|
||||
|
||||
// ──── Tests: mergeGoogleAccountsFile ─────────────────────────────────────────
|
||||
|
||||
test("mergeGoogleAccountsFile: absent file creates fresh doc with active=newEmail", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-test-"));
|
||||
const accountsPath = path.join(tmpDir, "google_accounts.json");
|
||||
try {
|
||||
const result = await mergeGoogleAccountsFile(accountsPath, "new@google.com");
|
||||
assert.equal(result.updated, true);
|
||||
assert.equal(result.savedBakPath, null);
|
||||
const written = JSON.parse(await fs.readFile(accountsPath, "utf8")) as GoogleAccountsSidecar;
|
||||
assert.equal(written.active, "new@google.com");
|
||||
assert.deepEqual(written.old, []);
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("mergeGoogleAccountsFile: noop when active already equals newEmail", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-test-"));
|
||||
const accountsPath = path.join(tmpDir, "google_accounts.json");
|
||||
try {
|
||||
await fs.writeFile(
|
||||
accountsPath,
|
||||
JSON.stringify({ active: "same@google.com", old: [] }) + "\n"
|
||||
);
|
||||
const result = await mergeGoogleAccountsFile(accountsPath, "same@google.com");
|
||||
assert.equal(result.updated, false);
|
||||
assert.equal(result.savedBakPath, null);
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("mergeGoogleAccountsFile: moves old active to old[] when active differs", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-test-"));
|
||||
const accountsPath = path.join(tmpDir, "google_accounts.json");
|
||||
try {
|
||||
await fs.writeFile(
|
||||
accountsPath,
|
||||
JSON.stringify({ active: "old@google.com", old: ["ancient@google.com"] }) + "\n"
|
||||
);
|
||||
const result = await mergeGoogleAccountsFile(accountsPath, "new@google.com");
|
||||
assert.equal(result.updated, true);
|
||||
assert.ok(result.savedBakPath !== null, "should have created a .bak file");
|
||||
const written = JSON.parse(await fs.readFile(accountsPath, "utf8")) as GoogleAccountsSidecar;
|
||||
assert.equal(written.active, "new@google.com");
|
||||
assert.ok(written.old.includes("old@google.com"));
|
||||
assert.ok(written.old.includes("ancient@google.com"));
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("mergeGoogleAccountsFile: overwrites invalid JSON file, updated=true", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-test-"));
|
||||
const accountsPath = path.join(tmpDir, "google_accounts.json");
|
||||
try {
|
||||
await fs.writeFile(accountsPath, "not valid json");
|
||||
const result = await mergeGoogleAccountsFile(accountsPath, "new@google.com");
|
||||
assert.equal(result.updated, true);
|
||||
const written = JSON.parse(await fs.readFile(accountsPath, "utf8")) as GoogleAccountsSidecar;
|
||||
assert.equal(written.active, "new@google.com");
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ──── Tests: sanitizeFileNamePart ─────────────────────────────────────────────
|
||||
|
||||
test("sanitizeFileNamePart keeps @ and . for emails", () => {
|
||||
assert.equal(sanitizeFileNamePart("user@google.com"), "user@google.com");
|
||||
assert.equal(sanitizeFileNamePart("User.Name@Example.Org"), "user.name@example.org");
|
||||
});
|
||||
|
||||
test("sanitizeFileNamePart strips filesystem-invalid chars", () => {
|
||||
assert.equal(sanitizeFileNamePart("name with spaces"), "name-with-spaces");
|
||||
assert.equal(sanitizeFileNamePart("a\\b:c*d?"), "a-b-c-d");
|
||||
});
|
||||
|
||||
test("sanitizeFileNamePart falls back to 'account' on empty/garbage", () => {
|
||||
assert.equal(sanitizeFileNamePart(""), "account");
|
||||
assert.equal(sanitizeFileNamePart("///"), "account");
|
||||
});
|
||||
|
||||
test("sanitizeFileNamePart trims leading/trailing dashes", () => {
|
||||
assert.equal(sanitizeFileNamePart("--foo--"), "foo");
|
||||
});
|
||||
|
||||
// ──── Tests: .bak timestamp format ───────────────────────────────────────────
|
||||
|
||||
test(".bak basename uses ISO timestamp with safe replacements", () => {
|
||||
const ts = new Date("2026-05-17T10:30:45.123Z").toISOString().replace(/[:.]/g, "-");
|
||||
const basename = `oauth_creds-${ts}.bak`;
|
||||
assert.equal(basename, "oauth_creds-2026-05-17T10-30-45-123Z.bak");
|
||||
assert.ok(!ts.includes(":"), "timestamp should not contain ':'");
|
||||
assert.ok(!ts.includes("."), "timestamp should not contain '.'");
|
||||
});
|
||||
449
tests/unit/geminiAuthImport.test.ts
Normal file
449
tests/unit/geminiAuthImport.test.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Pure-function copy of helpers from geminiAuthImport.ts so we don't drag DB deps.
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function buildJwt(payload: JsonRecord): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
return `${header}.${body}.fake-signature`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function extractJwtEmail(idToken: string): string | null {
|
||||
const payload = decodeJwtPayload(idToken);
|
||||
if (!payload) return null;
|
||||
return toNonEmptyString(payload.email);
|
||||
}
|
||||
|
||||
// Mirror of parseAndValidateGeminiAuth (pure logic, no throws — returns result or error)
|
||||
|
||||
interface ParsedGeminiAuth {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
idToken: string;
|
||||
scope: string;
|
||||
tokenType: string;
|
||||
expiresAt: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
function parseGeminiAuth(raw: unknown): ParsedGeminiAuth | { error: string; code: string } {
|
||||
const doc = toRecord(raw);
|
||||
|
||||
const accessToken = toNonEmptyString(doc.access_token);
|
||||
const refreshToken = toNonEmptyString(doc.refresh_token);
|
||||
const idToken = toNonEmptyString(doc.id_token);
|
||||
|
||||
if (!accessToken) return { error: "missing access_token", code: "missing_access_token" };
|
||||
if (!refreshToken) return { error: "missing refresh_token", code: "missing_refresh_token" };
|
||||
if (!idToken) return { error: "missing id_token", code: "missing_id_token" };
|
||||
|
||||
const expiryDateMs = doc.expiry_date;
|
||||
let expiresAt: string | null = null;
|
||||
if (typeof expiryDateMs === "number" && Number.isFinite(expiryDateMs)) {
|
||||
expiresAt = new Date(expiryDateMs).toISOString();
|
||||
}
|
||||
|
||||
const scope = toNonEmptyString(doc.scope) ?? "";
|
||||
const tokenType = toNonEmptyString(doc.token_type) ?? "Bearer";
|
||||
const email = extractJwtEmail(idToken);
|
||||
|
||||
return { accessToken, refreshToken, idToken, scope, tokenType, expiresAt, email };
|
||||
}
|
||||
|
||||
// ──── Tests: parseAndValidateGeminiAuth ──────────────────────────────────────
|
||||
|
||||
test("parseAndValidateGeminiAuth: accepts valid Google OAuth2 payload", () => {
|
||||
const idToken = buildJwt({ email: "user@example.com", sub: "123" });
|
||||
const result = parseGeminiAuth({
|
||||
access_token: "fake-goog-access-token",
|
||||
refresh_token: "1//refresh",
|
||||
id_token: idToken,
|
||||
scope: "https://www.googleapis.com/auth/cloud-platform openid",
|
||||
token_type: "Bearer",
|
||||
expiry_date: 1768527451123,
|
||||
});
|
||||
assert.ok(!("error" in result), `Expected success, got error: ${JSON.stringify(result)}`);
|
||||
const parsed = result as ParsedGeminiAuth;
|
||||
assert.equal(parsed.accessToken, "fake-goog-access-token");
|
||||
assert.equal(parsed.refreshToken, "1//refresh");
|
||||
assert.equal(parsed.idToken, idToken);
|
||||
assert.equal(parsed.scope, "https://www.googleapis.com/auth/cloud-platform openid");
|
||||
assert.equal(parsed.tokenType, "Bearer");
|
||||
assert.equal(parsed.email, "user@example.com");
|
||||
});
|
||||
|
||||
test("parseAndValidateGeminiAuth: rejects empty access_token (missing_access_token)", () => {
|
||||
const idToken = buildJwt({ email: "user@example.com" });
|
||||
const result = parseGeminiAuth({
|
||||
access_token: "",
|
||||
refresh_token: "1//refresh",
|
||||
id_token: idToken,
|
||||
});
|
||||
assert.ok("error" in result);
|
||||
assert.equal((result as { code: string }).code, "missing_access_token");
|
||||
});
|
||||
|
||||
test("parseAndValidateGeminiAuth: rejects missing access_token", () => {
|
||||
const idToken = buildJwt({ email: "user@example.com" });
|
||||
const result = parseGeminiAuth({
|
||||
refresh_token: "1//refresh",
|
||||
id_token: idToken,
|
||||
});
|
||||
assert.ok("error" in result);
|
||||
assert.equal((result as { code: string }).code, "missing_access_token");
|
||||
});
|
||||
|
||||
test("parseAndValidateGeminiAuth: rejects empty refresh_token (missing_refresh_token)", () => {
|
||||
const idToken = buildJwt({ email: "user@example.com" });
|
||||
const result = parseGeminiAuth({
|
||||
access_token: "fake-goog-access-token",
|
||||
refresh_token: " ",
|
||||
id_token: idToken,
|
||||
});
|
||||
assert.ok("error" in result);
|
||||
assert.equal((result as { code: string }).code, "missing_refresh_token");
|
||||
});
|
||||
|
||||
test("parseAndValidateGeminiAuth: rejects empty id_token (missing_id_token)", () => {
|
||||
const result = parseGeminiAuth({
|
||||
access_token: "fake-goog-access-token",
|
||||
refresh_token: "1//refresh",
|
||||
id_token: "",
|
||||
});
|
||||
assert.ok("error" in result);
|
||||
assert.equal((result as { code: string }).code, "missing_id_token");
|
||||
});
|
||||
|
||||
test("parseAndValidateGeminiAuth: converts expiry_date ms to ISO string", () => {
|
||||
const idToken = buildJwt({ email: "user@example.com" });
|
||||
const expiryMs = 1768527451123;
|
||||
const result = parseGeminiAuth({
|
||||
access_token: "fake-goog-access-token",
|
||||
refresh_token: "1//refresh",
|
||||
id_token: idToken,
|
||||
expiry_date: expiryMs,
|
||||
});
|
||||
assert.ok(!("error" in result));
|
||||
const parsed = result as ParsedGeminiAuth;
|
||||
assert.ok(parsed.expiresAt !== null, "expiresAt should not be null");
|
||||
assert.ok(parsed.expiresAt!.includes("T"), "expiresAt should be ISO format");
|
||||
assert.equal(new Date(parsed.expiresAt!).getTime(), expiryMs);
|
||||
});
|
||||
|
||||
test("parseAndValidateGeminiAuth: expiresAt is null when expiry_date absent", () => {
|
||||
const idToken = buildJwt({ email: "user@example.com" });
|
||||
const result = parseGeminiAuth({
|
||||
access_token: "fake-goog-access-token",
|
||||
refresh_token: "1//refresh",
|
||||
id_token: idToken,
|
||||
});
|
||||
assert.ok(!("error" in result));
|
||||
assert.equal((result as ParsedGeminiAuth).expiresAt, null);
|
||||
});
|
||||
|
||||
test("parseAndValidateGeminiAuth: extracts email from JWT id_token", () => {
|
||||
const idToken = buildJwt({ email: "diego@example.com", sub: "user-123" });
|
||||
const result = parseGeminiAuth({
|
||||
access_token: "fake-goog-access-token",
|
||||
refresh_token: "1//refresh",
|
||||
id_token: idToken,
|
||||
});
|
||||
assert.ok(!("error" in result));
|
||||
assert.equal((result as ParsedGeminiAuth).email, "diego@example.com");
|
||||
});
|
||||
|
||||
test("parseAndValidateGeminiAuth: email is null when id_token has no email claim", () => {
|
||||
const idToken = buildJwt({ sub: "user-123" });
|
||||
const result = parseGeminiAuth({
|
||||
access_token: "fake-goog-access-token",
|
||||
refresh_token: "1//refresh",
|
||||
id_token: idToken,
|
||||
});
|
||||
assert.ok(!("error" in result));
|
||||
assert.equal((result as ParsedGeminiAuth).email, null);
|
||||
});
|
||||
|
||||
test("parseAndValidateGeminiAuth: scope absent defaults to empty string", () => {
|
||||
const idToken = buildJwt({ email: "user@example.com" });
|
||||
const result = parseGeminiAuth({
|
||||
access_token: "fake-goog-access-token",
|
||||
refresh_token: "1//refresh",
|
||||
id_token: idToken,
|
||||
});
|
||||
assert.ok(!("error" in result));
|
||||
assert.equal((result as ParsedGeminiAuth).scope, "");
|
||||
});
|
||||
|
||||
test("parseAndValidateGeminiAuth: token_type absent defaults to Bearer", () => {
|
||||
const idToken = buildJwt({ email: "user@example.com" });
|
||||
const result = parseGeminiAuth({
|
||||
access_token: "fake-goog-access-token",
|
||||
refresh_token: "1//refresh",
|
||||
id_token: idToken,
|
||||
});
|
||||
assert.ok(!("error" in result));
|
||||
assert.equal((result as ParsedGeminiAuth).tokenType, "Bearer");
|
||||
});
|
||||
|
||||
test("parseAndValidateGeminiAuth: scope is string with spaces (not array)", () => {
|
||||
const idToken = buildJwt({ email: "user@example.com" });
|
||||
const scopeStr = "https://www.googleapis.com/auth/cloud-platform openid email";
|
||||
const result = parseGeminiAuth({
|
||||
access_token: "fake-goog-access-token",
|
||||
refresh_token: "1//refresh",
|
||||
id_token: idToken,
|
||||
scope: scopeStr,
|
||||
});
|
||||
assert.ok(!("error" in result));
|
||||
const parsed = result as ParsedGeminiAuth;
|
||||
assert.equal(typeof parsed.scope, "string");
|
||||
assert.ok(parsed.scope.includes(" "), "scope should be space-separated string");
|
||||
});
|
||||
|
||||
// ──── Tests: enrichWithLoadCodeAssist (mocked fetch) ────────────────────────
|
||||
|
||||
test("enrichWithLoadCodeAssist: returns projectId on success", async () => {
|
||||
const parsed: ParsedGeminiAuth = {
|
||||
accessToken: "fake-goog-access-token",
|
||||
refreshToken: "1//refresh",
|
||||
idToken: "eyJ.eyJ.sig",
|
||||
scope: "openid",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: null,
|
||||
email: "user@example.com",
|
||||
};
|
||||
|
||||
// Inline mirror of enrichWithLoadCodeAssist with injected fetch
|
||||
async function enrichWithMockFetch(
|
||||
p: ParsedGeminiAuth,
|
||||
mockFetch: typeof fetch
|
||||
): Promise<{ projectId: string | null } & ParsedGeminiAuth> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const response = await mockFetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${p.accessToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ metadata: { ideType: "GEMINI_CLI", platform: "linux" } }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return { ...p, projectId: null };
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
const projectId =
|
||||
(typeof data.projectId === "string" ? data.projectId.trim() || null : null) ??
|
||||
(typeof data.cloudaiProjectId === "string" ? data.cloudaiProjectId.trim() || null : null);
|
||||
return { ...p, projectId };
|
||||
} catch {
|
||||
return { ...p, projectId: null };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
const mockFetch = async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({ projectId: "my-gcp-project-123" }),
|
||||
} as Response);
|
||||
|
||||
const enriched = await enrichWithMockFetch(parsed, mockFetch as typeof fetch);
|
||||
assert.equal(enriched.projectId, "my-gcp-project-123");
|
||||
assert.equal(enriched.accessToken, parsed.accessToken);
|
||||
});
|
||||
|
||||
test("enrichWithLoadCodeAssist: returns projectId null on 401 (best-effort)", async () => {
|
||||
const parsed: ParsedGeminiAuth = {
|
||||
accessToken: "fake-goog-expired-token",
|
||||
refreshToken: "1//refresh",
|
||||
idToken: "eyJ.eyJ.sig",
|
||||
scope: "",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: null,
|
||||
email: null,
|
||||
};
|
||||
|
||||
async function enrichWithMockFetch(
|
||||
p: ParsedGeminiAuth,
|
||||
mockFetch: typeof fetch
|
||||
): Promise<{ projectId: string | null } & ParsedGeminiAuth> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const response = await mockFetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${p.accessToken}`, "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return { ...p, projectId: null };
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
const projectId = typeof data.projectId === "string" ? data.projectId || null : null;
|
||||
return { ...p, projectId };
|
||||
} catch {
|
||||
return { ...p, projectId: null };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
const mockFetch = async () => ({ ok: false, status: 401 } as Response);
|
||||
|
||||
const enriched = await enrichWithMockFetch(parsed, mockFetch as typeof fetch);
|
||||
assert.equal(enriched.projectId, null);
|
||||
});
|
||||
|
||||
test("enrichWithLoadCodeAssist: returns projectId null on network error (best-effort)", async () => {
|
||||
const parsed: ParsedGeminiAuth = {
|
||||
accessToken: "fake-goog-access-token",
|
||||
refreshToken: "1//refresh",
|
||||
idToken: "eyJ.eyJ.sig",
|
||||
scope: "",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: null,
|
||||
email: null,
|
||||
};
|
||||
|
||||
async function enrichWithMockFetch(
|
||||
p: ParsedGeminiAuth,
|
||||
mockFetch: typeof fetch
|
||||
): Promise<{ projectId: string | null } & ParsedGeminiAuth> {
|
||||
try {
|
||||
const response = await mockFetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${p.accessToken}`, "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
if (!response.ok) return { ...p, projectId: null };
|
||||
return { ...p, projectId: null };
|
||||
} catch {
|
||||
return { ...p, projectId: null };
|
||||
}
|
||||
}
|
||||
|
||||
const mockFetch = async () => {
|
||||
throw new Error("network failure");
|
||||
};
|
||||
|
||||
const enriched = await enrichWithMockFetch(parsed, mockFetch as typeof fetch);
|
||||
assert.equal(enriched.projectId, null);
|
||||
});
|
||||
|
||||
// ──── Tests: createConnectionFromAuthFile (pure logic mirrors) ───────────────
|
||||
|
||||
test("createConnectionFromAuthFile: throws 409 duplicate_account when exists + overwrite=false", () => {
|
||||
// Mirror the guard logic
|
||||
function checkDuplicate(
|
||||
existingEmail: string | null,
|
||||
newEmail: string | null,
|
||||
overwriteExisting: boolean
|
||||
): string | null {
|
||||
if (newEmail && existingEmail?.toLowerCase() === newEmail.toLowerCase()) {
|
||||
if (!overwriteExisting) return "duplicate_account";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = checkDuplicate("user@example.com", "user@example.com", false);
|
||||
assert.equal(result, "duplicate_account");
|
||||
});
|
||||
|
||||
test("createConnectionFromAuthFile: allows update when exists + overwrite=true", () => {
|
||||
function checkDuplicate(
|
||||
existingEmail: string | null,
|
||||
newEmail: string | null,
|
||||
overwriteExisting: boolean
|
||||
): string | null {
|
||||
if (newEmail && existingEmail?.toLowerCase() === newEmail.toLowerCase()) {
|
||||
if (!overwriteExisting) return "duplicate_account";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = checkDuplicate("user@example.com", "user@example.com", true);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("createConnectionFromAuthFile: throws 409 identity_unverified when no email + overwrite=false", () => {
|
||||
function checkIdentity(
|
||||
resolvedEmail: string | null,
|
||||
overwriteExisting: boolean
|
||||
): string | null {
|
||||
if (!resolvedEmail && !overwriteExisting) return "identity_unverified";
|
||||
return null;
|
||||
}
|
||||
|
||||
assert.equal(checkIdentity(null, false), "identity_unverified");
|
||||
});
|
||||
|
||||
test("createConnectionFromAuthFile: allows create without email when overwrite=true", () => {
|
||||
function checkIdentity(
|
||||
resolvedEmail: string | null,
|
||||
overwriteExisting: boolean
|
||||
): string | null {
|
||||
if (!resolvedEmail && !overwriteExisting) return "identity_unverified";
|
||||
return null;
|
||||
}
|
||||
|
||||
assert.equal(checkIdentity(null, true), null);
|
||||
});
|
||||
|
||||
test("createConnectionFromAuthFile: email from options.email takes precedence over enriched.email", () => {
|
||||
const resolveEmail = (optionsEmail: string | undefined, enrichedEmail: string | null) =>
|
||||
optionsEmail || enrichedEmail;
|
||||
|
||||
assert.equal(resolveEmail("override@example.com", "original@example.com"), "override@example.com");
|
||||
assert.equal(resolveEmail(undefined, "original@example.com"), "original@example.com");
|
||||
assert.equal(resolveEmail(undefined, null), null);
|
||||
});
|
||||
|
||||
test("providerSpecificData: projectId from enrichment is stored", () => {
|
||||
const buildPsd = (scope: string, tokenType: string, projectId: string | null) => ({
|
||||
scope,
|
||||
tokenType,
|
||||
projectId,
|
||||
importedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const psd = buildPsd("openid", "Bearer", "my-project-123");
|
||||
assert.equal(psd.projectId, "my-project-123");
|
||||
assert.equal(psd.scope, "openid");
|
||||
assert.equal(psd.tokenType, "Bearer");
|
||||
assert.ok(psd.importedAt.includes("T"), "importedAt should be ISO");
|
||||
});
|
||||
|
||||
test("providerSpecificData: projectId is null when enrichment failed", () => {
|
||||
const buildPsd = (scope: string, tokenType: string, projectId: string | null) => ({
|
||||
scope,
|
||||
tokenType,
|
||||
projectId,
|
||||
importedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const psd = buildPsd("openid", "Bearer", null);
|
||||
assert.equal(psd.projectId, null);
|
||||
});
|
||||
Reference in New Issue
Block a user