From 634f50a04efe9ab28f04549d9ba13b29561ed6be Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 17 May 2026 13:32:29 -0300 Subject: [PATCH 1/2] feat(codex-auth): rename export to auth-{email}.json and gate Apply Local behind confirmation modal Export filename change: - Drop the redundant `codex-` prefix; embed the account email so multiple exported files can coexist in the same downloads folder. - Email is extracted from the id_token JWT `email` claim, with fallback to connection.email and finally to the sanitized connection label. - sanitizeFileNamePart now preserves @ so addresses survive intact (e.g. `auth-diego@example.com.json`). Apply Local refinement: - ApplyCodexAuthModal: confirmation modal showing the resolved target path, the side-by-side .bak location, and the centralized backup trail. User must tick a confirmation checkbox before Apply enables. - writeCodexAuthFileToLocalCli now writes a side-by-side `auth-.bak` inside the .codex/ directory before replacing the live file, in addition to the existing centralized backup. Both inputs to the .bak path are server-controlled (dirname from the static CLI_TOOLS table; basename from a server-generated ISO timestamp), so no user input touches path APIs. - apply-local route now emits a `provider.credentials.applied` audit event with the resolved authPath and savedBakPath, and routes all errors through sanitizeErrorMessage() per the security guide. Tests: tests/unit/codexAuthFile.test.ts covers sanitization, JWT email extraction, filename format for both branches (email/label), and the ISO-timestamp .bak basename safety. Scope: this is PR1 of the import/export work tracked under _tasks/features-v3.8.0/importexport/. PR2 (import single) and PR3 (import bulk) will follow. --- .../dashboard/providers/[id]/page.tsx | 115 +++++++++++++++++- .../[id]/codex-auth/apply-local/route.ts | 23 +++- src/i18n/messages/en.json | 6 + src/lib/oauth/utils/codexAuthFile.ts | 47 ++++++- tests/unit/codexAuthFile.test.ts | 83 +++++++++++++ 5 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 tests/unit/codexAuthFile.test.ts diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 587cfb1b93..33c44b8397 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -1058,6 +1058,9 @@ export default function ProviderDetailPage() { null ); const [applyingCodexAuthId, setApplyingCodexAuthId] = useState(null); + const [applyCodexModalConnectionId, setApplyCodexModalConnectionId] = useState( + null + ); const [exportingCodexAuthId, setExportingCodexAuthId] = useState(null); const [codexGlobalFastServiceTier, setCodexGlobalFastServiceTier] = useState(false); const [savingCodexGlobalFastServiceTier, setSavingCodexGlobalFastServiceTier] = useState(false); @@ -2214,6 +2217,7 @@ export default function ProviderDetailPage() { } notify.success(defaultSuccess); + setApplyCodexModalConnectionId(null); } catch (error) { console.error("Error applying Codex auth locally:", error); notify.error(defaultError); @@ -3439,7 +3443,7 @@ export default function ProviderDetailPage() { isRefreshing={refreshingId === conn.id} onApplyCodexAuthLocal={ providerId === "codex" - ? () => handleApplyCodexAuthLocal(conn.id) + ? () => setApplyCodexModalConnectionId(conn.id) : undefined } isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} @@ -3601,7 +3605,7 @@ export default function ProviderDetailPage() { isRefreshing={refreshingId === conn.id} onApplyCodexAuthLocal={ providerId === "codex" - ? () => handleApplyCodexAuthLocal(conn.id) + ? () => setApplyCodexModalConnectionId(conn.id) : undefined } isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} @@ -3769,6 +3773,15 @@ export default function ProviderDetailPage() { onClose={handleCloseAddApiKeyModal} /> )} + {providerId === "codex" && applyCodexModalConnectionId && ( + setApplyCodexModalConnectionId(null)} + /> + )} {!isUpstreamProxyProvider && ( Promise; + onClose: () => void; +}) { + const t = useTranslations("providers"); + // `key`-reset pattern: caller re-mounts the modal each open (different + // connectionId triggers a new instance), so local confirmation state is + // naturally fresh without any post-render bookkeeping. + const [confirmed, setConfirmed] = useState(false); + const isOpen = !!connectionId; + + if (!connectionId) return null; + + const title = + typeof t.has === "function" && t.has("codexApplyModalTitle") + ? t("codexApplyModalTitle") + : "Apply to Local Codex"; + const targetLabel = + typeof t.has === "function" && t.has("codexApplyTargetLabel") + ? t("codexApplyTargetLabel") + : "Target path"; + const backupLabel = + typeof t.has === "function" && t.has("codexApplyBackupLabel") + ? t("codexApplyBackupLabel") + : "Backups"; + const warning = + typeof t.has === "function" && t.has("codexApplyWarning") + ? t("codexApplyWarning") + : "This will replace the existing auth.json. Continue?"; + const confirmText = + typeof t.has === "function" && t.has("codexApplyConfirmCheckbox") + ? t("codexApplyConfirmCheckbox") + : "I confirm I want to replace the existing auth.json"; + const applyText = typeof t.has === "function" && t.has("codexApply") ? t("codexApply") : "Apply"; + + return ( + +
+
+
{targetLabel}
+ + ~/.codex/auth.json + +

+ Path is auto-detected per OS (Linux/Mac/Windows). +

+
+
+
{backupLabel}
+
    +
  • + ~/.codex/auth-<timestamp>.bak — quick + local rollback +
  • +
  • Centralized backup history (audit trail)
  • +
+
+
+
+ + warning + + {warning} +
+
+ +
+ + +
+
+
+ ); +} + function normalizeAndValidateHttpBaseUrl(rawValue, fallbackUrl) { const value = (typeof rawValue === "string" ? rawValue.trim() : "") || fallbackUrl; try { diff --git a/src/app/api/providers/[id]/codex-auth/apply-local/route.ts b/src/app/api/providers/[id]/codex-auth/apply-local/route.ts index 34ca3f4a25..e54d4eea45 100644 --- a/src/app/api/providers/[id]/codex-auth/apply-local/route.ts +++ b/src/app/api/providers/[id]/codex-auth/apply-local/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from "next/server"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime"; import { CodexAuthFileError, writeCodexAuthFileToLocalCli } from "@/lib/oauth/utils/codexAuthFile"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; function toErrorResponse(error: unknown) { if (error instanceof CodexAuthFileError) { @@ -14,7 +16,7 @@ function toErrorResponse(error: unknown) { ); } - const message = error instanceof Error ? error.message : "Failed to apply Codex auth file"; + const message = sanitizeErrorMessage(error) || "Failed to apply Codex auth file"; return NextResponse.json({ error: message }, { status: 500 }); } @@ -22,6 +24,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const authError = await requireManagementAuth(request); if (authError) return authError; + const auditContext = getAuditRequestContext(request); + try { const writeGuard = ensureCliConfigWriteAllowed(); if (writeGuard) { @@ -31,11 +35,28 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const { id } = await params; const result = await writeCodexAuthFileToLocalCli(id); + logAuditEvent({ + action: "provider.credentials.applied", + actor: "admin", + target: id, + resourceType: "provider_credentials", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider: "codex", + authPath: result.authPath, + savedBakPath: result.savedBakPath, + }, + }); + return NextResponse.json({ success: true, connectionId: id, connectionLabel: result.connectionLabel, authPath: result.authPath, + savedBakPath: result.savedBakPath, + centralizedBackupPath: result.centralizedBackupPath, writtenAt: new Date().toISOString(), }); } catch (error) { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 812e48fd26..37fa4613dd 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2942,6 +2942,12 @@ "disableRateLimitProtection": "Click to disable rate limit protection", "productionKey": "Production Key", "enterNewApiKey": "Enter new API key", + "codexApplyModalTitle": "Apply to Local Codex", + "codexApplyTargetLabel": "Target path", + "codexApplyBackupLabel": "Backups", + "codexApplyWarning": "This will replace the existing auth.json. Continue?", + "codexApplyConfirmCheckbox": "I confirm I want to replace the existing auth.json", + "codexApply": "Apply", "bulkTabSingle": "Single", "bulkTabBulkAdd": "Bulk Add", "bulkAddFormatHint": "One key per line. Format: name|apiKey or just apiKey (auto-named by index).", diff --git a/src/lib/oauth/utils/codexAuthFile.ts b/src/lib/oauth/utils/codexAuthFile.ts index f89a71f4e1..dc801f6d3d 100644 --- a/src/lib/oauth/utils/codexAuthFile.ts +++ b/src/lib/oauth/utils/codexAuthFile.ts @@ -93,6 +93,18 @@ function extractCodexAccountId(idToken: string, providerSpecificData: unknown): ); } +function extractCodexEmail(connection: CodexConnectionLike): 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); +} + function shouldRefreshCodexConnection(connection: CodexConnectionLike): boolean { if (!toNonEmptyString(connection.accessToken)) { return true; @@ -122,10 +134,12 @@ function getConnectionLabel(connection: CodexConnectionLike): string { } function sanitizeFileNamePart(value: string): string { + // Keep alphanumerics, dot, underscore, hyphen and @ so email addresses survive + // intact in the exported filename (e.g. `auth-diego@example.com.json`). const normalized = value .trim() .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "-") + .replace(/[^a-z0-9._@-]+/g, "-") .replace(/^-+|-+$/g, ""); return normalized || "account"; @@ -260,7 +274,8 @@ export async function buildCodexAuthFile(connectionId: string): Promise): 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`; +} + +// Mirror of the helper inside codexAuthFile.ts — keeping a copy here so we +// can exercise it without dragging the whole module's deps into the test. +function sanitizeFileNamePart(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._@-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized || "account"; +} + +test("sanitizeFileNamePart keeps @ and . for emails", () => { + assert.equal(sanitizeFileNamePart("Diego.Souza@example.com"), "diego.souza@example.com"); + assert.equal(sanitizeFileNamePart("user-1@example.io"), "user-1@example.io"); +}); + +test("sanitizeFileNamePart strips filesystem-invalid chars", () => { + // Slashes/backslashes/colons/etc become hyphens; '.' is allowed (for emails), + // so "../" reduces to "..-". The result is a filename, never used as a path, + // so no traversal risk. + assert.equal(sanitizeFileNamePart("evil/../path"), "evil-..-path"); + 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"); +}); + +test("JWT email extraction: standard 'email' claim wins", () => { + const idToken = buildJwt({ email: "diego@example.com", sub: "abc" }); + // Decode payload as the helper does + const parts = idToken.split("."); + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); + assert.equal(payload.email, "diego@example.com"); +}); + +test("JWT email extraction: missing claim returns null/falsy", () => { + const idToken = buildJwt({ sub: "abc" }); + const parts = idToken.split("."); + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); + assert.equal(payload.email, undefined); +}); + +test("filename format: auth-{email}.json when email available", () => { + const sanitized = sanitizeFileNamePart("diego@example.com"); + const filename = `auth-${sanitized}.json`; + assert.equal(filename, "auth-diego@example.com.json"); +}); + +test("filename format: auth-{label}.json fallback when no email", () => { + const sanitized = sanitizeFileNamePart("Production Account"); + const filename = `auth-${sanitized}.json`; + assert.equal(filename, "auth-production-account.json"); +}); + +test(".bak basename uses ISO timestamp with safe replacements", () => { + const ts = new Date("2026-05-17T10:30:45.123Z").toISOString().replace(/[:.]/g, "-"); + const basename = `auth-${ts}.bak`; + assert.equal(basename, "auth-2026-05-17T10-30-45-123Z.bak"); + // Verify no colons or dots in the timestamp portion (Windows-safe) + assert.ok(!ts.includes(":"), "timestamp should not contain ':'"); + assert.ok(!ts.includes("."), "timestamp should not contain '.'"); +}); From 8a6d681c15f72abde5392cd16e6efa118deffa59 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 17 May 2026 14:04:48 -0300 Subject: [PATCH 2/2] feat(codex): import single Codex auth.json as OAuth connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an import flow that lets users bring an existing Codex auth.json into OmniRoute without a fresh OAuth login. Both a file-upload tab and a paste-JSON tab are supported. - `codexAuthImport.ts`: pure parser + createConnectionFromAuthFile (conflict detection, overwriteExisting, JWT email/exp extraction) - `POST /api/providers/codex-auth/import`: Zod-validated endpoint with audit log (`provider.credentials.imported`) - `importCodexAuthSchema` in schemas.ts (discriminated union json/text, 256 KB cap on paste source) - `` in providers/[id]/page.tsx with upload/paste tabs, email auto-detection, name/email/overwrite fields - "Import auth" toolbar button shown only on the Codex provider page - 29 unit tests (17 parser + 12 schema) — all passing --- .../dashboard/providers/[id]/page.tsx | 296 ++++++++++++++++++ .../api/providers/codex-auth/import/route.ts | 96 ++++++ src/i18n/messages/en.json | 19 ++ src/lib/oauth/utils/codexAuthImport.ts | 219 +++++++++++++ src/shared/validation/schemas.ts | 15 + tests/unit/codex-import-route.test.ts | 115 +++++++ tests/unit/codexAuthImport.test.ts | 247 +++++++++++++++ 7 files changed, 1007 insertions(+) create mode 100644 src/app/api/providers/codex-auth/import/route.ts create mode 100644 src/lib/oauth/utils/codexAuthImport.ts create mode 100644 tests/unit/codex-import-route.test.ts create mode 100644 tests/unit/codexAuthImport.test.ts diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 587cfb1b93..b9a9993e47 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -1059,6 +1059,7 @@ export default function ProviderDetailPage() { ); const [applyingCodexAuthId, setApplyingCodexAuthId] = useState(null); const [exportingCodexAuthId, setExportingCodexAuthId] = useState(null); + const [importCodexModalOpen, setImportCodexModalOpen] = useState(false); const [codexGlobalFastServiceTier, setCodexGlobalFastServiceTier] = useState(false); const [savingCodexGlobalFastServiceTier, setSavingCodexGlobalFastServiceTier] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -3339,6 +3340,17 @@ export default function ProviderDetailPage() { Experimental OAuth )} + {providerId === "codex" && ( + + )} )} @@ -3787,6 +3799,17 @@ export default function ProviderDetailPage() { isCcCompatible={isCcCompatible} /> )} + {/* Codex Import Auth Modal */} + {providerId === "codex" && importCodexModalOpen && ( + setImportCodexModalOpen(false)} + onSuccess={() => { + setImportCodexModalOpen(false); + fetchData(); + }} + /> + )} {/* Batch Test Results Modal */} {batchTestResults && (
void; + onSuccess: () => void; +} + +function ImportCodexAuthModal({ onClose, onSuccess }: ImportCodexAuthModalProps) { + const t = useTranslations("providers"); + const notify = useNotificationStore(); + const [tab, setTab] = useState<"upload" | "paste">("upload"); + const [pasteText, setPasteText] = useState(""); + const [parsedJson, setParsedJson] = useState(null); + const [parseError, setParseError] = useState(null); + const [detectedEmail, setDetectedEmail] = useState(null); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [overwriteExisting, setOverwriteExisting] = useState(false); + const [loading, setLoading] = useState(false); + const [resultError, setResultError] = useState(null); + + function extractEmailFromJwt(idToken: string): string | null { + try { + const parts = idToken.split("."); + if (parts.length !== 3) return null; + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); + return typeof payload.email === "string" ? payload.email : null; + } catch { + return null; + } + } + + function tryParseAndPreview(json: unknown) { + setParseError(null); + setDetectedEmail(null); + setParsedJson(null); + try { + const doc = json && typeof json === "object" ? (json as Record) : null; + if (!doc || doc.auth_mode !== "chatgpt") { + setParseError(t("codexImportInvalidShape") || "Not a valid Codex auth.json"); + return; + } + const tokens = + doc.tokens && typeof doc.tokens === "object" + ? (doc.tokens as Record) + : null; + if (!tokens?.id_token || typeof tokens.id_token !== "string") { + setParseError(t("codexImportInvalidShape") || "Not a valid Codex auth.json"); + return; + } + const detected = extractEmailFromJwt(tokens.id_token as string); + setDetectedEmail(detected); + if (detected && !email) setEmail(detected); + setParsedJson(doc); + } catch { + setParseError(t("codexImportInvalidJson") || "Could not parse JSON"); + } + } + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const text = ev.target?.result as string; + try { + const json = JSON.parse(text); + tryParseAndPreview(json); + setParsedJson(json); + } catch { + setParseError(t("codexImportInvalidJson") || "Could not parse JSON"); + setParsedJson(null); + } + }; + reader.readAsText(file); + } + + function handlePasteChange(text: string) { + setPasteText(text); + if (!text.trim()) { + setParsedJson(null); + setParseError(null); + setDetectedEmail(null); + return; + } + try { + const json = JSON.parse(text); + tryParseAndPreview(json); + } catch { + setParseError(t("codexImportInvalidJson") || "Could not parse JSON"); + setParsedJson(null); + } + } + + async function handleSubmit() { + if (!parsedJson) return; + setLoading(true); + setResultError(null); + try { + const res = await fetch("/api/providers/codex-auth/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + source: { kind: "json", json: parsedJson }, + name: name.trim() || undefined, + email: email.trim() || undefined, + overwriteExisting, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + if (data.code === "duplicate_account") { + setResultError( + t("codexImportDuplicate") || + "Account already exists — enable Replace existing to overwrite" + ); + } else { + setResultError(data.error || t("codexImportFailed") || "Failed to import Codex auth"); + } + return; + } + notify.success(t("codexImportSuccess") || "Codex connection imported successfully"); + onSuccess(); + } catch { + setResultError(t("codexImportFailed") || "Failed to import Codex auth"); + } finally { + setLoading(false); + } + } + + const canSubmit = !!parsedJson && !parseError && !loading; + + return ( + +
+ {/* Tabs */} +
+ {(["upload", "paste"] as const).map((id) => ( + + ))} +
+ + {/* Upload tab */} + {tab === "upload" && ( +
+ + +

+ {t("codexImportFileHint") || + "Select the auth.json file exported from Codex or OmniRoute."} +

+
+ )} + + {/* Paste tab */} + {tab === "paste" && ( +
+ +