mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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
This commit is contained in:
@@ -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<CompatModelRow & { id: string }>;
|
||||
}>({ customModels: [], modelCompatOverrides: [] });
|
||||
const [compatSavingModelId, setCompatSavingModelId] = useState<string | null>(null);
|
||||
const [applyingCodexAuthId, setApplyingCodexAuthId] = useState<string | null>(null);
|
||||
const [exportingCodexAuthId, setExportingCodexAuthId] = useState<string | null>(null);
|
||||
|
||||
const providerInfo = providerNode
|
||||
? {
|
||||
@@ -1248,6 +1254,39 @@ export default function ProviderDetailPage() {
|
||||
|
||||
// T12: Manual token refresh
|
||||
const [refreshingId, setRefreshingId] = useState<string | null>(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
|
||||
</Button>
|
||||
)}
|
||||
{isCodex && onApplyCodexAuthLocal && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="download_done"
|
||||
loading={isApplyingCodexAuthLocal}
|
||||
disabled={isApplyingCodexAuthLocal}
|
||||
onClick={onApplyCodexAuthLocal}
|
||||
className="!h-7 !px-2 text-xs text-emerald-500 hover:text-emerald-400"
|
||||
title={applyCodexAuthLabel}
|
||||
>
|
||||
{applyCodexAuthLabel}
|
||||
</Button>
|
||||
)}
|
||||
{isCodex && onExportCodexAuthFile && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="download"
|
||||
loading={isExportingCodexAuthFile}
|
||||
disabled={isExportingCodexAuthFile}
|
||||
onClick={onExportCodexAuthFile}
|
||||
className="!h-7 !px-2 text-xs text-sky-500 hover:text-sky-400"
|
||||
title={exportCodexAuthLabel}
|
||||
>
|
||||
{exportCodexAuthLabel}
|
||||
</Button>
|
||||
)}
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={connection.isActive ?? true}
|
||||
@@ -4090,6 +4269,10 @@ ConnectionRow.propTypes = {
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onReauth: PropTypes.func,
|
||||
onApplyCodexAuthLocal: PropTypes.func,
|
||||
isApplyingCodexAuthLocal: PropTypes.bool,
|
||||
onExportCodexAuthFile: PropTypes.func,
|
||||
isExportingCodexAuthFile: PropTypes.bool,
|
||||
};
|
||||
|
||||
function AddApiKeyModal({
|
||||
|
||||
41
src/app/api/providers/[id]/codex-auth/apply-local/route.ts
Normal file
41
src/app/api/providers/[id]/codex-auth/apply-local/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime";
|
||||
import { CodexAuthFileError, writeCodexAuthFileToLocalCli } 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 apply Codex auth file";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
|
||||
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
37
src/app/api/providers/[id]/codex-auth/export/route.ts
Normal file
37
src/app/api/providers/[id]/codex-auth/export/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
298
src/lib/oauth/utils/codexAuthFile.ts
Normal file
298
src/lib/oauth/utils/codexAuthFile.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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<CodexConnectionLike> {
|
||||
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<BuiltCodexAuthFile> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user