fiemwl / src /lib /oauth /utils /agyAuthImport.ts
automindy's picture
Upload 1980 files
6111b2b verified
Raw
History Blame Contribute Delete
9.54 kB
import {
getProviderConnections,
createProviderConnection,
updateProviderConnection,
} from "@/lib/localDb";
import { AGY_CONFIG } from "@/lib/oauth/constants/oauth";
import {
getAntigravityHeaders,
getAntigravityLoadCodeAssistMetadata,
} from "@omniroute/open-sse/services/antigravityHeaders.ts";
import { extractCodeAssistOnboardTierId } from "@omniroute/open-sse/services/codeAssistSubscription.ts";
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;
}
/**
* Error carrying an HTTP status + machine code, mirroring GeminiAuthFileError so the
* agy-auth routes can translate it to a clean response (never a raw stack trace).
*/
export class AgyAuthFileError extends Error {
status: number;
code: string;
constructor(message: string, status = 400, code = "invalid_request") {
super(message);
this.name = "AgyAuthFileError";
this.status = status;
this.code = code;
}
}
// ──── Public types ────────────────────────────────────────────────────────────
export interface ParsedAgyAuth {
accessToken: string;
refreshToken: string;
tokenType: string;
expiresAt: string | null;
authMethod: string | null;
}
export interface EnrichedAgyAuth extends ParsedAgyAuth {
email: string | null;
projectId: string | null;
tier: string | null;
}
export interface CreateAgyConnectionOptions {
name?: string;
email?: string;
overwriteExisting?: boolean;
}
// ──── Parse & validate ────────────────────────────────────────────────────────
/**
* Parse the Antigravity CLI (`agy`) token file. Unlike gemini-cli's flat
* `oauth_creds.json`, the agy file nests the token under `.token`, uses an ISO `expiry`
* string, and has NO `id_token`. A flat top-level shape is accepted as a fallback.
*/
export function parseAndValidateAgyToken(raw: unknown): ParsedAgyAuth {
const doc = toRecord(raw);
// agy nests credentials under `.token`; fall back to the top level for flat exports.
const token = doc.token && typeof doc.token === "object" ? toRecord(doc.token) : doc;
const accessToken = toNonEmptyString(token.access_token);
const refreshToken = toNonEmptyString(token.refresh_token);
if (!accessToken) {
throw new AgyAuthFileError(
"access_token is missing or empty in the agy token file",
400,
"missing_access_token"
);
}
if (!refreshToken) {
throw new AgyAuthFileError(
"refresh_token is missing or empty in the agy token file",
400,
"missing_refresh_token"
);
}
// agy uses an ISO `expiry`; also accept a unix-ms `expiry_date`/`expires_at` for safety.
let expiresAt: string | null = null;
const isoExpiry = toNonEmptyString(token.expiry) ?? toNonEmptyString(token.expires_at);
if (isoExpiry) {
const ms = new Date(isoExpiry).getTime();
expiresAt = Number.isNaN(ms) ? null : new Date(ms).toISOString();
} else if (typeof token.expiry_date === "number" && Number.isFinite(token.expiry_date)) {
expiresAt = new Date(token.expiry_date).toISOString();
}
const tokenType = toNonEmptyString(token.token_type) ?? "Bearer";
const authMethod = toNonEmptyString(doc.auth_method) ?? toNonEmptyString(token.auth_method);
return { accessToken, refreshToken, tokenType, expiresAt, authMethod };
}
// ──── Enrich with the Antigravity Code Assist backend ─────────────────────────
/**
* Resolve the account email (userinfo) and GCP project id (loadCodeAssist) for the token.
* Best-effort + time-boxed; the agy CLI has already onboarded the project, so we do NOT
* run the onboardUser provisioning loop here (that can take up to ~50s).
*/
export async function enrichWithAntigravityBackend(
parsed: ParsedAgyAuth
): Promise<EnrichedAgyAuth> {
let email: string | null = null;
let projectId: string | null = null;
let tier: string | null = null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
try {
const userInfoRes = await fetch(`${AGY_CONFIG.userInfoUrl}?alt=json`, {
headers: { Authorization: `Bearer ${parsed.accessToken}` },
signal: controller.signal,
});
if (userInfoRes.ok) {
email = toNonEmptyString(toRecord(await userInfoRes.json()).email);
}
} catch {
// best effort β€” email stays null
} finally {
clearTimeout(timer);
}
const loadController = new AbortController();
const loadTimer = setTimeout(() => loadController.abort(), 8000);
try {
const headers = getAntigravityHeaders("loadCodeAssist", parsed.accessToken);
const metadata = getAntigravityLoadCodeAssistMetadata();
for (const endpoint of AGY_CONFIG.loadCodeAssistEndpoints) {
try {
const res = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify({ metadata }),
signal: loadController.signal,
});
if (!res.ok) continue;
const data = toRecord(await res.json());
const project = data.cloudaicompanionProject;
projectId =
(typeof project === "string" ? toNonEmptyString(project) : null) ??
toNonEmptyString(toRecord(project).id);
tier = extractCodeAssistOnboardTierId(data) || null;
break;
} catch {
// try next endpoint
}
}
} catch {
// best effort β€” projectId stays null
} finally {
clearTimeout(loadTimer);
}
return { ...parsed, email, projectId, tier };
}
// ──── Find existing connection ────────────────────────────────────────────────
export async function findExistingAgyConnection(email: string): Promise<JsonRecord | null> {
const connections = await getProviderConnections({ provider: "agy" });
const lowerEmail = email.toLowerCase();
return (
(connections.find((c) => {
const conn = c as JsonRecord;
return toNonEmptyString(conn.email)?.toLowerCase() === lowerEmail;
}) as JsonRecord | undefined) ?? null
);
}
// ──── Create / update connection ──────────────────────────────────────────────
export async function createConnectionFromAgyToken(
enriched: EnrichedAgyAuth,
options: CreateAgyConnectionOptions
): Promise<{ connection: JsonRecord; created: boolean }> {
const resolvedEmail = options.email || enriched.email;
if (resolvedEmail) {
const existing = await findExistingAgyConnection(resolvedEmail);
if (existing) {
if (!options.overwriteExisting) {
throw new AgyAuthFileError(
"An Antigravity 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,
expiresAt: enriched.expiresAt,
email: resolvedEmail || (existing.email as string | undefined),
name:
options.name ||
(existing.name as string | undefined) ||
resolvedEmail ||
"Antigravity CLI (imported)",
testStatus: "active",
providerSpecificData: {
...toRecord(existing.providerSpecificData),
tokenType: enriched.tokenType,
authMethod: enriched.authMethod,
projectId: enriched.projectId ?? toRecord(existing.providerSpecificData).projectId,
tier: enriched.tier ?? toRecord(existing.providerSpecificData).tier,
importedAt: new Date().toISOString(),
},
});
return { connection: updated || existing, created: false };
}
} else if (!options.overwriteExisting) {
throw new AgyAuthFileError(
"Could not verify the account email from the agy token (no userinfo). Pass overwriteExisting: true to import without email verification.",
409,
"identity_unverified"
);
}
const name = options.name || resolvedEmail || "Antigravity CLI (imported)";
const connection = await createProviderConnection({
provider: "agy",
authType: "oauth",
name,
email: resolvedEmail || undefined,
accessToken: enriched.accessToken,
refreshToken: enriched.refreshToken,
expiresAt: enriched.expiresAt,
isActive: true,
testStatus: "active",
providerSpecificData: {
tokenType: enriched.tokenType,
authMethod: enriched.authMethod,
projectId: enriched.projectId,
tier: enriched.tier,
importedAt: new Date().toISOString(),
},
});
return { connection, created: true };
}