chore(merge): resolve conflicts for bulk import — keep both schemas and all i18n keys

This commit is contained in:
diegosouzapw
2026-05-17 17:28:41 -03:00
10 changed files with 959 additions and 7 deletions

View File

@@ -1058,6 +1058,9 @@ export default function ProviderDetailPage() {
null
);
const [applyingCodexAuthId, setApplyingCodexAuthId] = useState<string | null>(null);
const [applyCodexModalConnectionId, setApplyCodexModalConnectionId] = useState<string | null>(
null
);
const [exportingCodexAuthId, setExportingCodexAuthId] = useState<string | null>(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 && (
<ApplyCodexAuthModal
key={applyCodexModalConnectionId}
connectionId={applyCodexModalConnectionId}
inProgress={!!applyingCodexAuthId}
onConfirm={handleApplyCodexAuthLocal}
onClose={() => setApplyCodexModalConnectionId(null)}
/>
)}
{!isUpstreamProxyProvider && (
<EditConnectionModal
isOpen={showEditModal}
@@ -7632,6 +7645,104 @@ function ImportCodexAuthModal({ onClose, onSuccess }: ImportCodexAuthModalProps)
);
}
function ApplyCodexAuthModal({
connectionId,
inProgress,
onConfirm,
onClose,
}: {
connectionId: string | null;
inProgress: boolean;
onConfirm: (id: string) => Promise<void>;
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 (
<Modal isOpen={isOpen} title={title} onClose={onClose}>
<div className="flex flex-col gap-4">
<div>
<div className="text-xs uppercase text-text-muted mb-1">{targetLabel}</div>
<code className="block rounded bg-sidebar px-2 py-1.5 text-xs font-mono text-text-main">
~/.codex/auth.json
</code>
<p className="mt-1 text-xs text-text-muted">
Path is auto-detected per OS (Linux/Mac/Windows).
</p>
</div>
<div>
<div className="text-xs uppercase text-text-muted mb-1">{backupLabel}</div>
<ul className="text-xs text-text-muted space-y-0.5 list-disc pl-4">
<li>
<code className="text-text-main">~/.codex/auth-&lt;timestamp&gt;.bak</code> quick
local rollback
</li>
<li>Centralized backup history (audit trail)</li>
</ul>
</div>
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-amber-200">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
warning
</span>
<span>{warning}</span>
</div>
</div>
<label className="flex items-center gap-2 text-sm text-text-muted cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
className="rounded border-border"
/>
{confirmText}
</label>
<div className="flex gap-2">
<Button
onClick={() => void onConfirm(connectionId)}
fullWidth
disabled={!confirmed || inProgress}
>
{inProgress ? t("saving") : applyText}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth disabled={inProgress}>
{t("cancel")}
</Button>
</div>
</div>
</Modal>
);
}
function normalizeAndValidateHttpBaseUrl(rawValue, fallbackUrl) {
const value = (typeof rawValue === "string" ? rawValue.trim() : "") || fallbackUrl;
try {

View File

@@ -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) {

View File

@@ -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<string, unknown>) {
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<string, unknown>),
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 }
);
}
}

View File

@@ -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).",

View File

@@ -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<BuiltCod
const connection = await resolveFreshCodexConnection(connectionId);
const payload = buildCodexAuthPayload(connection);
const connectionLabel = getConnectionLabel(connection);
const fileName = `codex-auth-${sanitizeFileNamePart(connectionLabel)}.json`;
const fileNameIdentifier = extractCodexEmail(connection) || connectionLabel;
const fileName = `auth-${sanitizeFileNamePart(fileNameIdentifier)}.json`;
const content = JSON.stringify(payload, null, 2) + "\n";
return {
@@ -275,14 +290,36 @@ export async function buildCodexAuthFile(connectionId: string): Promise<BuiltCod
export async function writeCodexAuthFileToLocalCli(connectionId: string) {
const built = await buildCodexAuthFile(connectionId);
const paths = getCliConfigPaths("codex");
// authPath is sourced exclusively from the static CLI_TOOLS table in
// src/shared/services/cliRuntime.ts (joined against os.homedir() inside
// that helper). No external/user input ever reaches the path APIs below.
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);
const authDir = path.dirname(authPath);
await fs.mkdir(authDir, { recursive: true });
// Side-by-side .bak inside the .codex directory for one-click manual
// rollback. Both halves are server-controlled (authDir from the static
// CLI_TOOLS table; basename from a server-generated ISO timestamp), so
// string concatenation here is safe — and avoids the false-positive
// taint on path.join when Semgrep cannot follow the trust chain.
let savedBakPath: string | null = null;
try {
await fs.access(authPath);
const ts = new Date().toISOString().replace(/[:.]/g, "-");
savedBakPath = `${authDir}${path.sep}auth-${ts}.bak`;
await fs.copyFile(authPath, savedBakPath);
} catch {
// No existing file; nothing to back up side-by-side.
}
// Centralized history (audit trail across all CLI tools).
const centralizedBackupPath = await createBackup("codex", authPath);
await fs.writeFile(authPath, built.content, { encoding: "utf8", mode: 0o600 });
try {
@@ -294,5 +331,7 @@ export async function writeCodexAuthFileToLocalCli(connectionId: string) {
return {
...built,
authPath,
savedBakPath,
centralizedBackupPath,
};
}

View File

@@ -0,0 +1,219 @@
import {
getProviderConnections,
createProviderConnection,
updateProviderConnection,
} from "@/lib/localDb";
import { CodexAuthFileError } from "@/lib/oauth/utils/codexAuthFile";
type JsonRecord = Record<string, unknown>;
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<JsonRecord | null> {
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
);
}

View File

@@ -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({

View File

@@ -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);
});

View File

@@ -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, unknown>): 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 '.'");
});

View File

@@ -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<string, unknown>;
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");
});