diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 1b5e104a58..997b8931d9 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 [importCodexModalOpen, setImportCodexModalOpen] = useState(false); const [codexGlobalFastServiceTier, setCodexGlobalFastServiceTier] = useState(false); @@ -2215,6 +2218,7 @@ export default function ProviderDetailPage() { } notify.success(defaultSuccess); + setApplyCodexModalConnectionId(null); } catch (error) { console.error("Error applying Codex auth locally:", error); notify.error(defaultError); @@ -3451,7 +3455,7 @@ export default function ProviderDetailPage() { isRefreshing={refreshingId === conn.id} onApplyCodexAuthLocal={ providerId === "codex" - ? () => handleApplyCodexAuthLocal(conn.id) + ? () => setApplyCodexModalConnectionId(conn.id) : undefined } isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} @@ -3613,7 +3617,7 @@ export default function ProviderDetailPage() { isRefreshing={refreshingId === conn.id} onApplyCodexAuthLocal={ providerId === "codex" - ? () => handleApplyCodexAuthLocal(conn.id) + ? () => setApplyCodexModalConnectionId(conn.id) : undefined } isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} @@ -3781,6 +3785,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/app/api/providers/codex-auth/import/route.ts b/src/app/api/providers/codex-auth/import/route.ts new file mode 100644 index 0000000000..121e09afb7 --- /dev/null +++ b/src/app/api/providers/codex-auth/import/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { CodexAuthFileError } from "@/lib/oauth/utils/codexAuthFile"; +import { + parseAndValidateCodexAuth, + createConnectionFromAuthFile, +} from "@/lib/oauth/utils/codexAuthImport"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { getProviderAuditTarget } from "@/lib/compliance/providerAudit"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { importCodexAuthSchema } from "@/shared/validation/schemas"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults"; + +function sanitizeConnectionForResponse(connection: Record) { + const safe = { ...connection }; + delete safe.accessToken; + delete safe.refreshToken; + delete safe.idToken; + delete safe.apiKey; + if (safe.providerSpecificData) { + safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(safe.providerSpecificData); + } + return safe; +} + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const auditContext = getAuditRequestContext(request); + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsedBody = validateBody(importCodexAuthSchema, body); + if (isValidationFailure(parsedBody)) { + return NextResponse.json({ error: parsedBody.error }, { status: 400 }); + } + + const { source, name, email, overwriteExisting } = parsedBody.data; + + let rawJson: unknown; + try { + rawJson = source.kind === "json" ? source.json : JSON.parse(source.text); + } catch { + return NextResponse.json( + { error: "Could not parse the content as JSON", code: "invalid_json" }, + { status: 400 } + ); + } + + try { + const parsed = parseAndValidateCodexAuth(rawJson); + const { connection, created } = await createConnectionFromAuthFile(parsed, { + name, + email, + overwriteExisting, + }); + + logAuditEvent({ + action: "provider.credentials.imported", + actor: "admin", + target: getProviderAuditTarget(connection), + resourceType: "provider_credentials", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider: "codex", + created, + email: parsed.email || email, + }, + }); + + return NextResponse.json({ + connection: sanitizeConnectionForResponse(connection as Record), + created, + }); + } catch (error) { + if (error instanceof CodexAuthFileError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status } + ); + } + return NextResponse.json( + { error: sanitizeErrorMessage(error) || "Failed to import Codex auth" }, + { status: 500 } + ); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index cce0419e51..9c2c145e76 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; + +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 CodexAuthFileInput { + auth_mode?: unknown; + OPENAI_API_KEY?: unknown; + tokens?: { + id_token?: unknown; + access_token?: unknown; + refresh_token?: unknown; + account_id?: unknown; + }; + last_refresh?: unknown; +} + +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); + + if (doc.auth_mode !== "chatgpt") { + throw new CodexAuthFileError( + 'Not a Codex auth.json — expected auth_mode: "chatgpt"', + 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(), + }, + }); + + return { connection, created: true }; +} + +async function findExistingCodexConnection(accountId: string): Promise { + 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 + ); +} diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 98fd66281c..da8c4709de 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -322,6 +322,21 @@ export const bulkCreateProviderSchema = z } }); +// ──── Codex Import Schema ──── + +export const importCodexAuthSchema = z.object({ + source: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("json"), json: z.unknown() }), + z.object({ + kind: z.literal("text"), + text: z.string().max(256 * 1024, "Paste content must be under 256 KB"), + }), + ]), + name: z.string().min(1).max(200).optional(), + email: z.string().email("Must be a valid email").optional(), + overwriteExisting: z.boolean().optional(), +}); + // ──── Codex Import Bulk Schema ──── export const importCodexAuthBulkSchema = z.object({ diff --git a/tests/unit/codex-import-route.test.ts b/tests/unit/codex-import-route.test.ts new file mode 100644 index 0000000000..ceef2ab1c7 --- /dev/null +++ b/tests/unit/codex-import-route.test.ts @@ -0,0 +1,115 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { z } from "zod"; + +// Local copy of importCodexAuthSchema — avoids importing Next.js deps from schemas.ts. +const importCodexAuthSchema = z.object({ + source: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("json"), json: z.unknown() }), + z.object({ + kind: z.literal("text"), + text: z.string().max(256 * 1024), + }), + ]), + name: z.string().min(1).max(200).optional(), + email: z.string().email().optional(), + overwriteExisting: z.boolean().optional(), +}); + +function parse(body: unknown) { + return importCodexAuthSchema.safeParse(body); +} + +// ──── Valid cases ───────────────────────────────────────────────────────────── + +test("schema: valid json source", () => { + const result = parse({ + source: { kind: "json", json: { auth_mode: "chatgpt" } }, + }); + assert.ok(result.success); + assert.equal(result.data.source.kind, "json"); +}); + +test("schema: valid text source", () => { + const result = parse({ + source: { kind: "text", text: JSON.stringify({ auth_mode: "chatgpt" }) }, + }); + assert.ok(result.success); + assert.equal(result.data.source.kind, "text"); +}); + +test("schema: optional fields are optional", () => { + const result = parse({ source: { kind: "json", json: {} } }); + assert.ok(result.success); + assert.equal(result.data.name, undefined); + assert.equal(result.data.email, undefined); + assert.equal(result.data.overwriteExisting, undefined); +}); + +test("schema: all optional fields accepted", () => { + const result = parse({ + source: { kind: "json", json: {} }, + name: "My Account", + email: "user@example.com", + overwriteExisting: true, + }); + assert.ok(result.success); + assert.equal(result.data.name, "My Account"); + assert.equal(result.data.email, "user@example.com"); + assert.equal(result.data.overwriteExisting, true); +}); + +// ──── Invalid cases ─────────────────────────────────────────────────────────── + +test("schema: missing source fails", () => { + const result = parse({}); + assert.ok(!result.success); +}); + +test("schema: unknown kind fails", () => { + const result = parse({ source: { kind: "file" } }); + assert.ok(!result.success); +}); + +test("schema: invalid email fails", () => { + const result = parse({ + source: { kind: "json", json: {} }, + email: "not-an-email", + }); + assert.ok(!result.success); + const emailIssue = result.error.issues.find((i) => i.path.includes("email")); + assert.ok(emailIssue, "expected email validation issue"); +}); + +test("schema: empty name fails", () => { + const result = parse({ + source: { kind: "json", json: {} }, + name: "", + }); + assert.ok(!result.success); +}); + +test("schema: text source with oversized content fails", () => { + const bigText = "x".repeat(256 * 1024 + 1); + const result = parse({ source: { kind: "text", text: bigText } }); + assert.ok(!result.success); +}); + +test("schema: text source exactly at 256KB limit passes", () => { + const maxText = "x".repeat(256 * 1024); + const result = parse({ source: { kind: "text", text: maxText } }); + assert.ok(result.success); +}); + +test("schema: source missing kind fails", () => { + const result = parse({ source: { json: {} } }); + assert.ok(!result.success); +}); + +test("schema: overwriteExisting must be boolean", () => { + const result = parse({ + source: { kind: "json", json: {} }, + overwriteExisting: "yes", + }); + assert.ok(!result.success); +}); diff --git a/tests/unit/codexAuthFile.test.ts b/tests/unit/codexAuthFile.test.ts new file mode 100644 index 0000000000..e75e1e72f7 --- /dev/null +++ b/tests/unit/codexAuthFile.test.ts @@ -0,0 +1,83 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// We don't import the full codexAuthFile module (it pulls in DB/cliRuntime). +// Instead, we re-implement the same primitives here and verify their shape +// matches the rules documented in PR1 — and unit-test the pure helpers via +// dynamic import for the ones that don't need DB. + +function buildJwt(payload: Record): 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 '.'"); +}); diff --git a/tests/unit/codexAuthImport.test.ts b/tests/unit/codexAuthImport.test.ts new file mode 100644 index 0000000000..a99e1f4172 --- /dev/null +++ b/tests/unit/codexAuthImport.test.ts @@ -0,0 +1,247 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Pure-function copy of helpers from codexAuthImport.ts so we don't drag DB deps. + +type JsonRecord = Record; + +function buildJwt(payload: JsonRecord): 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`; +} + +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 extractJwtEmail(idToken: string): string | null { + const payload = decodeJwtPayload(idToken); + if (!payload) return null; + return toNonEmptyString(payload.email); +} + +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 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 + ); +} + +// Mirror of parseAndValidateCodexAuth (without the throw — just the logic) + +interface ParsedCodexAuth { + idToken: string; + accessToken: string; + refreshToken: string; + accountId: string; + email: string | null; + expiresAt: string | null; +} + +function parseCodexAuth(raw: unknown): ParsedCodexAuth | { error: string; code: string } { + const doc = toRecord(raw); + + if (doc.auth_mode !== "chatgpt") { + return { error: "Not a Codex auth.json", code: "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) return { error: "missing id_token", code: "missing_id_token" }; + if (!accessToken) return { error: "missing access_token", code: "missing_access_token" }; + if (!refreshToken) return { error: "missing refresh_token", code: "missing_refresh_token" }; + + const tokensAccountId = toNonEmptyString(tokens.account_id) ?? undefined; + const accountId = extractCodexAccountId(idToken, tokensAccountId); + if (!accountId) return { error: "missing account_id", code: "missing_account_id" }; + + return { + idToken, + accessToken, + refreshToken, + accountId, + email: extractJwtEmail(idToken), + expiresAt: extractExpiresAt(idToken), + }; +} + +// ──── Tests ─────────────────────────────────────────────────────────────────── + +test("parseCodexAuth: valid file returns all fields", () => { + const idToken = buildJwt({ + email: "alice@example.com", + exp: 9999999999, + "https://api.openai.com/auth": { chatgpt_account_id: "acct-abc123" }, + }); + const raw = { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + id_token: idToken, + access_token: "at-xxx", + refresh_token: "rt-yyy", + account_id: "acct-abc123", + }, + last_refresh: new Date().toISOString(), + }; + const result = parseCodexAuth(raw); + assert.ok(!("error" in result)); + const parsed = result as ParsedCodexAuth; + assert.equal(parsed.idToken, idToken); + assert.equal(parsed.accessToken, "at-xxx"); + assert.equal(parsed.refreshToken, "rt-yyy"); + assert.equal(parsed.accountId, "acct-abc123"); + assert.equal(parsed.email, "alice@example.com"); + assert.ok(parsed.expiresAt !== null); +}); + +test("parseCodexAuth: wrong auth_mode returns error", () => { + const result = parseCodexAuth({ auth_mode: "api_key", tokens: {} }); + assert.ok("error" in result); + assert.equal((result as { code: string }).code, "invalid_auth_file"); +}); + +test("parseCodexAuth: missing id_token returns error", () => { + const result = parseCodexAuth({ + auth_mode: "chatgpt", + tokens: { access_token: "at", refresh_token: "rt" }, + }); + assert.ok("error" in result); + assert.equal((result as { code: string }).code, "missing_id_token"); +}); + +test("parseCodexAuth: missing access_token returns error", () => { + const idToken = buildJwt({ email: "a@b.com" }); + const result = parseCodexAuth({ + auth_mode: "chatgpt", + tokens: { id_token: idToken, refresh_token: "rt" }, + }); + assert.ok("error" in result); + assert.equal((result as { code: string }).code, "missing_access_token"); +}); + +test("parseCodexAuth: missing refresh_token returns error", () => { + const idToken = buildJwt({ email: "a@b.com" }); + const result = parseCodexAuth({ + auth_mode: "chatgpt", + tokens: { id_token: idToken, access_token: "at" }, + }); + assert.ok("error" in result); + assert.equal((result as { code: string }).code, "missing_refresh_token"); +}); + +test("JWT email extraction: email claim extracted", () => { + const jwt = buildJwt({ email: "test@example.com", sub: "123" }); + assert.equal(extractJwtEmail(jwt), "test@example.com"); +}); + +test("JWT email extraction: no email claim returns null", () => { + const jwt = buildJwt({ sub: "123" }); + assert.equal(extractJwtEmail(jwt), null); +}); + +test("JWT email extraction: malformed JWT returns null", () => { + assert.equal(extractJwtEmail("not.a.valid.jwt.at.all"), null); +}); + +test("extractCodexAccountId: tokens.account_id wins over JWT claim", () => { + const jwt = buildJwt({ + "https://api.openai.com/auth": { chatgpt_account_id: "claim-id" }, + }); + assert.equal(extractCodexAccountId(jwt, "direct-id"), "direct-id"); +}); + +test("extractCodexAccountId: falls back to JWT chatgpt_account_id claim", () => { + const jwt = buildJwt({ + "https://api.openai.com/auth": { chatgpt_account_id: "claim-id" }, + }); + assert.equal(extractCodexAccountId(jwt, undefined), "claim-id"); +}); + +test("extractCodexAccountId: falls back to account_id claim", () => { + const jwt = buildJwt({ + "https://api.openai.com/auth": { account_id: "acct-fallback" }, + }); + assert.equal(extractCodexAccountId(jwt, undefined), "acct-fallback"); +}); + +test("extractCodexAccountId: returns null when no id available", () => { + const jwt = buildJwt({ sub: "123" }); + assert.equal(extractCodexAccountId(jwt, undefined), null); +}); + +test("extractExpiresAt: derives ISO date from exp claim", () => { + const expUnix = 1900000000; + const jwt = buildJwt({ exp: expUnix }); + const result = extractExpiresAt(jwt); + assert.ok(result !== null); + assert.equal(new Date(result).getTime(), expUnix * 1000); +}); + +test("extractExpiresAt: returns null when no exp claim", () => { + const jwt = buildJwt({ sub: "123" }); + assert.equal(extractExpiresAt(jwt), null); +}); + +test("parseCodexAuth: accountId from tokens.account_id takes precedence over JWT", () => { + const idToken = buildJwt({ + "https://api.openai.com/auth": { chatgpt_account_id: "jwt-id" }, + }); + const result = parseCodexAuth({ + auth_mode: "chatgpt", + tokens: { + id_token: idToken, + access_token: "at", + refresh_token: "rt", + account_id: "direct-id", + }, + }); + assert.ok(!("error" in result)); + assert.equal((result as ParsedCodexAuth).accountId, "direct-id"); +}); + +test("parseCodexAuth: non-object input returns error", () => { + const result = parseCodexAuth("not an object"); + assert.ok("error" in result); + assert.equal((result as { code: string }).code, "invalid_auth_file"); +}); + +test("parseCodexAuth: null input returns error", () => { + const result = parseCodexAuth(null); + assert.ok("error" in result); + assert.equal((result as { code: string }).code, "invalid_auth_file"); +});