From 634f50a04efe9ab28f04549d9ba13b29561ed6be Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 17 May 2026 13:32:29 -0300 Subject: [PATCH] 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 '.'"); +});