From 922dae85461b5c09dd90841a160d89da4e44d724 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 28 Mar 2026 13:28:06 -0300 Subject: [PATCH] feat: add Codex auth.json export and apply-local buttons for CLI integration - Add codexAuthFile.ts utility: builds Codex auth.json payload from OAuth connection (id_token, access_token, refresh_token, account_id) with auto-refresh if expired - Add POST /api/providers/[id]/codex-auth/export: downloads auth.json file - Add POST /api/providers/[id]/codex-auth/apply-local: writes auth.json to local CLI path - Add 'Apply auth' and 'Export auth' buttons to ConnectionRow (Codex provider only) - Add i18n keys for en and pt-BR --- .../dashboard/providers/[id]/page.tsx | 183 +++++++++++ .../[id]/codex-auth/apply-local/route.ts | 41 +++ .../providers/[id]/codex-auth/export/route.ts | 37 +++ src/i18n/messages/en.json | 6 + src/i18n/messages/pt-BR.json | 6 + src/lib/oauth/utils/codexAuthFile.ts | 298 ++++++++++++++++++ 6 files changed, 571 insertions(+) create mode 100644 src/app/api/providers/[id]/codex-auth/apply-local/route.ts create mode 100644 src/app/api/providers/[id]/codex-auth/export/route.ts create mode 100644 src/lib/oauth/utils/codexAuthFile.ts diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 2bdb4c71c6..0e63a5f6af 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -403,6 +403,10 @@ interface ConnectionRowProps { proxyHost?: string; onRefreshToken?: () => void; isRefreshing?: boolean; + onApplyCodexAuthLocal?: () => void; + isApplyingCodexAuthLocal?: boolean; + onExportCodexAuthFile?: () => void; + isExportingCodexAuthFile?: boolean; } interface AddApiKeyModalProps { @@ -821,6 +825,8 @@ export default function ProviderDetailPage() { modelCompatOverrides: Array; }>({ customModels: [], modelCompatOverrides: [] }); const [compatSavingModelId, setCompatSavingModelId] = useState(null); + const [applyingCodexAuthId, setApplyingCodexAuthId] = useState(null); + const [exportingCodexAuthId, setExportingCodexAuthId] = useState(null); const providerInfo = providerNode ? { @@ -1248,6 +1254,39 @@ export default function ProviderDetailPage() { // T12: Manual token refresh const [refreshingId, setRefreshingId] = useState(null); + + const parseApiErrorMessage = async (res: Response, fallback: string) => { + const contentType = res.headers.get("content-type") || ""; + + if (contentType.includes("application/json")) { + const data = await res.json().catch(() => ({})); + if (typeof data?.error === "string" && data.error.trim()) { + return data.error; + } + if (data?.error?.message) { + return data.error.message; + } + } + + const text = await res.text().catch(() => ""); + return text.trim() || fallback; + }; + + const getAttachmentFilename = (res: Response, fallback: string) => { + const disposition = res.headers.get("content-disposition") || ""; + const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8Match?.[1]) { + return decodeURIComponent(utf8Match[1]); + } + + const plainMatch = disposition.match(/filename="([^"]+)"/i); + if (plainMatch?.[1]) { + return plainMatch[1]; + } + + return fallback; + }; + const handleRefreshToken = async (connectionId: string) => { if (refreshingId) return; setRefreshingId(connectionId); @@ -1268,6 +1307,82 @@ export default function ProviderDetailPage() { } }; + const handleApplyCodexAuthLocal = async (connectionId: string) => { + if (applyingCodexAuthId) return; + setApplyingCodexAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("codexAuthAppliedLocal") + ? t("codexAuthAppliedLocal") + : "Codex auth.json applied locally"; + const defaultError = + typeof t.has === "function" && t.has("codexAuthApplyFailed") + ? t("codexAuthApplyFailed") + : "Failed to apply Codex auth.json locally"; + + try { + const res = await fetch(`/api/providers/${connectionId}/codex-auth/apply-local`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + notify.success(defaultSuccess); + } catch (error) { + console.error("Error applying Codex auth locally:", error); + notify.error(defaultError); + } finally { + setApplyingCodexAuthId(null); + } + }; + + const handleExportCodexAuthFile = async (connectionId: string) => { + if (exportingCodexAuthId) return; + setExportingCodexAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("codexAuthExported") + ? t("codexAuthExported") + : "Codex auth.json exported"; + const defaultError = + typeof t.has === "function" && t.has("codexAuthExportFailed") + ? t("codexAuthExportFailed") + : "Failed to export Codex auth.json"; + + try { + const res = await fetch(`/api/providers/${connectionId}/codex-auth/export`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + const blob = await res.blob(); + const filename = getAttachmentFilename(res, "codex-auth.json"); + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000); + + notify.success(defaultSuccess); + } catch (error) { + console.error("Error exporting Codex auth file:", error); + notify.error(defaultError); + } finally { + setExportingCodexAuthId(null); + } + }; + const handleSwapPriority = async (conn1, conn2) => { if (!conn1 || !conn2) return; try { @@ -2103,6 +2218,18 @@ export default function ProviderDetailPage() { onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined} onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined} isRefreshing={refreshingId === conn.id} + onApplyCodexAuthLocal={ + providerId === "codex" + ? () => handleApplyCodexAuthLocal(conn.id) + : undefined + } + isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} + onExportCodexAuthFile={ + providerId === "codex" + ? () => handleExportCodexAuthFile(conn.id) + : undefined + } + isExportingCodexAuthFile={exportingCodexAuthId === conn.id} onProxy={() => setProxyTarget({ level: "key", @@ -2194,6 +2321,18 @@ export default function ProviderDetailPage() { onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined} onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined} isRefreshing={refreshingId === conn.id} + onApplyCodexAuthLocal={ + providerId === "codex" + ? () => handleApplyCodexAuthLocal(conn.id) + : undefined + } + isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} + onExportCodexAuthFile={ + providerId === "codex" + ? () => handleExportCodexAuthFile(conn.id) + : undefined + } + isExportingCodexAuthFile={exportingCodexAuthId === conn.id} onProxy={() => setProxyTarget({ level: "key", @@ -3776,11 +3915,23 @@ function ConnectionRow({ proxyHost, onRefreshToken, isRefreshing, + onApplyCodexAuthLocal, + isApplyingCodexAuthLocal, + onExportCodexAuthFile, + isExportingCodexAuthFile, }: ConnectionRowProps) { const t = useTranslations("providers"); const displayName = isOAuth ? connection.name || connection.email || connection.displayName || t("oauthAccount") : connection.name; + const applyCodexAuthLabel = + typeof t.has === "function" && t.has("applyCodexAuthLocal") + ? t("applyCodexAuthLocal") + : "Apply auth"; + const exportCodexAuthLabel = + typeof t.has === "function" && t.has("exportCodexAuthFile") + ? t("exportCodexAuthFile") + : "Export auth"; // Use useState + useEffect for impure Date.now() to avoid calling during render const [isCooldown, setIsCooldown] = useState(false); @@ -4014,6 +4165,34 @@ function ConnectionRow({ Token )} + {isCodex && onApplyCodexAuthLocal && ( + + )} + {isCodex && onExportCodexAuthFile && ( + + )} }) { + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard, code: "writes_disabled" }, { status: 403 }); + } + + const { id } = await params; + const result = await writeCodexAuthFileToLocalCli(id); + + return NextResponse.json({ + success: true, + connectionId: id, + connectionLabel: result.connectionLabel, + authPath: result.authPath, + writtenAt: new Date().toISOString(), + }); + } catch (error) { + console.error("[Codex Auth Apply] Failed:", error); + return toErrorResponse(error); + } +} diff --git a/src/app/api/providers/[id]/codex-auth/export/route.ts b/src/app/api/providers/[id]/codex-auth/export/route.ts new file mode 100644 index 0000000000..f435abc2f2 --- /dev/null +++ b/src/app/api/providers/[id]/codex-auth/export/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { buildCodexAuthFile, CodexAuthFileError } from "@/lib/oauth/utils/codexAuthFile"; + +function toErrorResponse(error: unknown) { + if (error instanceof CodexAuthFileError) { + return NextResponse.json( + { + error: error.message, + code: error.code, + }, + { status: error.status } + ); + } + + const message = error instanceof Error ? error.message : "Failed to export Codex auth file"; + return NextResponse.json({ error: message }, { status: 500 }); +} + +export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const built = await buildCodexAuthFile(id); + + return new Response(built.content, { + status: 200, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Disposition": `attachment; filename="${built.fileName}"`, + "Cache-Control": "no-store, max-age=0", + "X-Content-Type-Options": "nosniff", + }, + }); + } catch (error) { + console.error("[Codex Auth Export] Failed:", error); + return toErrorResponse(error); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index ff911de27b..6e11c136c2 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1635,6 +1635,12 @@ "compatibleProdPlaceholder": "{type} Compatible (Prod)", "tokenRefreshed": "Token refreshed successfully", "tokenRefreshFailed": "Token refresh failed", + "applyCodexAuthLocal": "Apply auth", + "exportCodexAuthFile": "Export auth", + "codexAuthAppliedLocal": "Codex auth.json applied locally", + "codexAuthApplyFailed": "Failed to apply Codex auth.json locally", + "codexAuthExported": "Codex auth.json exported", + "codexAuthExportFailed": "Failed to export Codex auth.json", "advancedSettings": "Advanced Settings", "chatPathLabel": "Chat Endpoint Path", "chatPathPlaceholder": "/chat/completions", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 4735c8c39c..093acb94af 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -1575,6 +1575,12 @@ "compatProtocolClaude": "Anthropic Messages", "tokenRefreshed": "Token refreshed successfully", "tokenRefreshFailed": "Token refresh failed", + "applyCodexAuthLocal": "Aplicar auth", + "exportCodexAuthFile": "Exportar auth", + "codexAuthAppliedLocal": "auth.json do Codex aplicado localmente", + "codexAuthApplyFailed": "Falha ao aplicar o auth.json do Codex localmente", + "codexAuthExported": "auth.json do Codex exportado", + "codexAuthExportFailed": "Falha ao exportar o auth.json do Codex", "compatBadgeUpstreamHeaders": "Headers", "compatUpstreamAddRow": "Add header", "compatUpstreamHeaderName": "Header name", diff --git a/src/lib/oauth/utils/codexAuthFile.ts b/src/lib/oauth/utils/codexAuthFile.ts new file mode 100644 index 0000000000..f89a71f4e1 --- /dev/null +++ b/src/lib/oauth/utils/codexAuthFile.ts @@ -0,0 +1,298 @@ +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; + +interface CodexConnectionLike { + 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 CodexAuthFilePayload { + auth_mode: "chatgpt"; + OPENAI_API_KEY: null; + tokens: { + id_token: string; + access_token: string; + refresh_token: string; + account_id: string; + }; + last_refresh: string; +} + +export interface BuiltCodexAuthFile { + connectionId: string; + connectionLabel: string; + fileName: string; + payload: CodexAuthFilePayload; + content: string; +} + +export class CodexAuthFileError extends Error { + status: number; + code: string; + + constructor(message: string, status = 400, code = "invalid_request") { + super(message); + this.name = "CodexAuthFileError"; + this.status = status; + this.code = code; + } +} + +const CODEX_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; + } +} + +function extractCodexAccountId(idToken: string, providerSpecificData: unknown): string | null { + const payload = decodeJwtPayload(idToken); + const authInfo = payload ? toRecord(payload["https://api.openai.com/auth"]) : {}; + + return ( + toNonEmptyString(authInfo.chatgpt_account_id) || + toNonEmptyString(authInfo.account_id) || + toNonEmptyString(toRecord(providerSpecificData).workspaceId) + ); +} + +function shouldRefreshCodexConnection(connection: CodexConnectionLike): 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() <= CODEX_REFRESH_BUFFER_MS; +} + +function getConnectionLabel(connection: CodexConnectionLike): string { + return ( + toNonEmptyString(connection.name) || + toNonEmptyString(connection.email) || + toNonEmptyString(connection.displayName) || + toNonEmptyString(connection.id) || + "codex-account" + ); +} + +function sanitizeFileNamePart(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return normalized || "account"; +} + +function buildCodexAuthPayload(connection: CodexConnectionLike): CodexAuthFilePayload { + const idToken = toNonEmptyString(connection.idToken); + const accessToken = toNonEmptyString(connection.accessToken); + const refreshToken = toNonEmptyString(connection.refreshToken); + + if (!idToken) { + throw new CodexAuthFileError( + "Codex connection is missing id_token. Re-authenticate this account before exporting.", + 409, + "reauth_required" + ); + } + + if (!accessToken) { + throw new CodexAuthFileError( + "Codex connection is missing access_token. Refresh or re-authenticate this account first.", + 409, + "access_token_missing" + ); + } + + if (!refreshToken) { + throw new CodexAuthFileError( + "Codex connection is missing refresh_token. Re-authenticate this account before exporting.", + 409, + "reauth_required" + ); + } + + const accountId = extractCodexAccountId(idToken, connection.providerSpecificData); + if (!accountId) { + throw new CodexAuthFileError( + "Unable to derive Codex account_id from the stored session. Re-authenticate this account.", + 409, + "account_id_missing" + ); + } + + return { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + id_token: idToken, + access_token: accessToken, + refresh_token: refreshToken, + account_id: accountId, + }, + last_refresh: new Date().toISOString(), + }; +} + +async function resolveFreshCodexConnection(connectionId: string): Promise { + const connection = (await getProviderConnectionById(connectionId)) as CodexConnectionLike | null; + if (!connection) { + throw new CodexAuthFileError("Connection not found", 404, "not_found"); + } + + if (connection.provider !== "codex") { + throw new CodexAuthFileError("Only Codex provider connections can export Codex auth files"); + } + + if (connection.authType !== "oauth") { + throw new CodexAuthFileError("Only OAuth Codex connections support auth.json export"); + } + + if (!shouldRefreshCodexConnection(connection)) { + return connection; + } + + const refreshToken = toNonEmptyString(connection.refreshToken); + if (!refreshToken) { + throw new CodexAuthFileError( + "Codex connection requires refresh but no refresh_token is available. Re-authenticate first.", + 409, + "reauth_required" + ); + } + + const refreshed = await getAccessToken("codex", { + connectionId, + accessToken: connection.accessToken, + refreshToken, + expiresAt: connection.expiresAt, + expiresIn: connection.expiresIn, + idToken: connection.idToken, + providerSpecificData: connection.providerSpecificData, + }); + + if (isUnrecoverableRefreshError(refreshed)) { + throw new CodexAuthFileError( + "Codex refresh token is no longer valid. Re-authenticate this account before exporting.", + 409, + "reauth_required" + ); + } + + if (!refreshed?.accessToken) { + throw new CodexAuthFileError( + "Failed to refresh the Codex 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 buildCodexAuthFile(connectionId: string): Promise { + const connection = await resolveFreshCodexConnection(connectionId); + const payload = buildCodexAuthPayload(connection); + const connectionLabel = getConnectionLabel(connection); + const fileName = `codex-auth-${sanitizeFileNamePart(connectionLabel)}.json`; + const content = JSON.stringify(payload, null, 2) + "\n"; + + return { + connectionId, + connectionLabel, + fileName, + payload, + content, + }; +} + +export async function writeCodexAuthFileToLocalCli(connectionId: string) { + const built = await buildCodexAuthFile(connectionId); + const paths = getCliConfigPaths("codex"); + const authPath = paths?.auth; + + if (!authPath) { + throw new CodexAuthFileError("Codex auth path could not be resolved", 500, "path_unavailable"); + } + + await fs.mkdir(path.dirname(authPath), { recursive: true }); + await createBackup("codex", 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. + } + + return { + ...built, + authPath, + }; +}