mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 22:32:12 +03:00
229 lines
7.5 KiB
TypeScript
229 lines
7.5 KiB
TypeScript
import {
|
|
getProviderConnections,
|
|
createProviderConnection,
|
|
updateProviderConnection,
|
|
} from "@/lib/localDb";
|
|
import { CodexAuthFileError } from "@/lib/oauth/utils/codexAuthFile";
|
|
|
|
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 extractExpiresAt(idToken: string): string | null {
|
|
const payload = decodeJwtPayload(idToken);
|
|
if (!payload) return null;
|
|
const exp = payload.exp;
|
|
if (typeof exp !== "number" || !Number.isFinite(exp)) return null;
|
|
return new Date(exp * 1000).toISOString();
|
|
}
|
|
|
|
function extractJwtEmail(idToken: string): string | null {
|
|
const payload = decodeJwtPayload(idToken);
|
|
if (!payload) return null;
|
|
return toNonEmptyString(payload.email);
|
|
}
|
|
|
|
function extractCodexAccountId(
|
|
idToken: string,
|
|
tokensAccountId: string | undefined
|
|
): string | null {
|
|
if (tokensAccountId && tokensAccountId.trim()) return tokensAccountId.trim();
|
|
const payload = decodeJwtPayload(idToken);
|
|
const authInfo = payload ? toRecord(payload["https://api.openai.com/auth"]) : {};
|
|
return (
|
|
toNonEmptyString(authInfo.chatgpt_account_id) || toNonEmptyString(authInfo.account_id) || null
|
|
);
|
|
}
|
|
|
|
// ──── Public types ────────────────────────────────────────────────────────────
|
|
|
|
export interface ParsedCodexAuth {
|
|
idToken: string;
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
accountId: string;
|
|
email: string | null;
|
|
expiresAt: string | null;
|
|
}
|
|
|
|
export interface CreateConnectionOptions {
|
|
name?: string;
|
|
email?: string;
|
|
overwriteExisting?: boolean;
|
|
}
|
|
|
|
// ──── Parse & validate ────────────────────────────────────────────────────────
|
|
|
|
export function parseAndValidateCodexAuth(raw: unknown): ParsedCodexAuth {
|
|
const doc = toRecord(raw);
|
|
|
|
// Codex CLI no longer writes auth_mode in auth.json (only OmniRoute's own export
|
|
// includes it). Accept both formats as long as the required tokens are present.
|
|
if (doc.auth_mode !== undefined && doc.auth_mode !== null && doc.auth_mode !== "chatgpt") {
|
|
throw new CodexAuthFileError(
|
|
"Not a Codex auth.json — unexpected auth_mode value",
|
|
400,
|
|
"invalid_auth_file"
|
|
);
|
|
}
|
|
|
|
const tokens = toRecord(doc.tokens);
|
|
const idToken = toNonEmptyString(tokens.id_token);
|
|
const accessToken = toNonEmptyString(tokens.access_token);
|
|
const refreshToken = toNonEmptyString(tokens.refresh_token);
|
|
|
|
if (!idToken) {
|
|
throw new CodexAuthFileError(
|
|
"id_token is missing or empty in the auth.json",
|
|
400,
|
|
"missing_id_token"
|
|
);
|
|
}
|
|
|
|
if (!accessToken) {
|
|
throw new CodexAuthFileError(
|
|
"access_token is missing or empty in the auth.json",
|
|
400,
|
|
"missing_access_token"
|
|
);
|
|
}
|
|
|
|
if (!refreshToken) {
|
|
throw new CodexAuthFileError(
|
|
"refresh_token is missing or empty in the auth.json",
|
|
400,
|
|
"missing_refresh_token"
|
|
);
|
|
}
|
|
|
|
const tokensAccountId = toNonEmptyString(tokens.account_id) ?? undefined;
|
|
const accountId = extractCodexAccountId(idToken, tokensAccountId);
|
|
|
|
if (!accountId) {
|
|
throw new CodexAuthFileError(
|
|
"Unable to derive account_id from the auth.json tokens",
|
|
400,
|
|
"missing_account_id"
|
|
);
|
|
}
|
|
|
|
return {
|
|
idToken,
|
|
accessToken,
|
|
refreshToken,
|
|
accountId,
|
|
email: extractJwtEmail(idToken),
|
|
expiresAt: extractExpiresAt(idToken),
|
|
};
|
|
}
|
|
|
|
// ──── Create / update connection ──────────────────────────────────────────────
|
|
|
|
export async function createConnectionFromAuthFile(
|
|
parsed: ParsedCodexAuth,
|
|
options: CreateConnectionOptions
|
|
): Promise<{ connection: JsonRecord; created: boolean }> {
|
|
const existing = await findExistingCodexConnection(parsed.accountId);
|
|
|
|
if (existing) {
|
|
if (!options.overwriteExisting) {
|
|
throw new CodexAuthFileError(
|
|
"A Codex connection for this account already exists. Pass overwriteExisting: true to replace it.",
|
|
409,
|
|
"duplicate_account"
|
|
);
|
|
}
|
|
|
|
const updated = await updateProviderConnection(existing.id as string, {
|
|
accessToken: parsed.accessToken,
|
|
refreshToken: parsed.refreshToken,
|
|
idToken: parsed.idToken,
|
|
expiresAt: parsed.expiresAt,
|
|
email: options.email || parsed.email || (existing.email as string | undefined),
|
|
name:
|
|
options.name ||
|
|
(existing.name as string | undefined) ||
|
|
options.email ||
|
|
parsed.email ||
|
|
"Codex (imported)",
|
|
testStatus: "active",
|
|
providerSpecificData: {
|
|
...toRecord(existing.providerSpecificData),
|
|
workspaceId: parsed.accountId,
|
|
importedAt: new Date().toISOString(),
|
|
},
|
|
});
|
|
|
|
return { connection: updated || existing, created: false };
|
|
}
|
|
|
|
const name = options.name || options.email || parsed.email || "Codex (imported)";
|
|
|
|
const connection = await createProviderConnection({
|
|
provider: "codex",
|
|
authType: "oauth",
|
|
name,
|
|
email: options.email || parsed.email || undefined,
|
|
accessToken: parsed.accessToken,
|
|
refreshToken: parsed.refreshToken,
|
|
idToken: parsed.idToken,
|
|
expiresAt: parsed.expiresAt,
|
|
isActive: true,
|
|
testStatus: "active",
|
|
providerSpecificData: {
|
|
workspaceId: parsed.accountId,
|
|
importedAt: new Date().toISOString(),
|
|
},
|
|
});
|
|
|
|
// Fix C REVERTED: do NOT refresh-on-import.
|
|
//
|
|
// Reason: production tests showed that auth.json files exported from the
|
|
// Codex CLI are often already partially rotated (the CLI continues to use
|
|
// the tokens after export). Calling the OpenAI refresh endpoint with a
|
|
// stale refresh_token returns "invalid_grant" / "refresh_token_reused" /
|
|
// "refresh_token_invalidated" AND has been observed to invalidate the
|
|
// entire token family upstream — causing every freshly imported account
|
|
// to land in a permanently-broken state, with the only working connection
|
|
// being whichever auth.json the user re-exported most recently.
|
|
//
|
|
// The id_token's `exp` claim is now used as the initial `expiresAt`. In
|
|
// practice OpenAI Codex id_tokens carry a multi-day lifetime, so this
|
|
// does NOT trigger the import-burst proactive-refresh storm that Fix C
|
|
// originally tried to prevent. When the access_token actually expires,
|
|
// the reactive 401 path in chatCore.ts (with the per-connection mutex)
|
|
// handles a single, atomic rotation — that mechanism is intact via the
|
|
// Fix A `runWithOnPersist` plumbing.
|
|
|
|
return { connection, created: true };
|
|
}
|
|
|
|
async function findExistingCodexConnection(accountId: string): Promise<JsonRecord | null> {
|
|
const connections = await getProviderConnections({ provider: "codex" });
|
|
return (
|
|
(connections.find((c) => {
|
|
const psd = toRecord((c as JsonRecord).providerSpecificData);
|
|
return toNonEmptyString(psd.workspaceId) === accountId;
|
|
}) as JsonRecord | undefined) ?? null
|
|
);
|
|
}
|