mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(codex): import single Codex auth.json as OAuth connection
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) - `<ImportCodexAuthModal>` 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
This commit is contained in:
@@ -1059,6 +1059,7 @@ export default function ProviderDetailPage() {
|
||||
);
|
||||
const [applyingCodexAuthId, setApplyingCodexAuthId] = useState<string | null>(null);
|
||||
const [exportingCodexAuthId, setExportingCodexAuthId] = useState<string | null>(null);
|
||||
const [importCodexModalOpen, setImportCodexModalOpen] = useState(false);
|
||||
const [codexGlobalFastServiceTier, setCodexGlobalFastServiceTier] = useState(false);
|
||||
const [savingCodexGlobalFastServiceTier, setSavingCodexGlobalFastServiceTier] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
@@ -3339,6 +3340,17 @@ export default function ProviderDetailPage() {
|
||||
Experimental OAuth
|
||||
</Button>
|
||||
)}
|
||||
{providerId === "codex" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="upload_file"
|
||||
onClick={() => setImportCodexModalOpen(true)}
|
||||
>
|
||||
{typeof t.has === "function" && t.has("importCodexAuth")
|
||||
? t("importCodexAuth")
|
||||
: "Import auth"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -3787,6 +3799,17 @@ export default function ProviderDetailPage() {
|
||||
isCcCompatible={isCcCompatible}
|
||||
/>
|
||||
)}
|
||||
{/* Codex Import Auth Modal */}
|
||||
{providerId === "codex" && importCodexModalOpen && (
|
||||
<ImportCodexAuthModal
|
||||
key={importCodexModalOpen ? "open" : "closed"}
|
||||
onClose={() => setImportCodexModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
setImportCodexModalOpen(false);
|
||||
fetchData();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Batch Test Results Modal */}
|
||||
{batchTestResults && (
|
||||
<div
|
||||
@@ -6972,6 +6995,279 @@ function AddApiKeyModal({
|
||||
);
|
||||
}
|
||||
|
||||
interface ImportCodexAuthModalProps {
|
||||
onClose: () => 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<unknown>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [detectedEmail, setDetectedEmail] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [overwriteExisting, setOverwriteExisting] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resultError, setResultError] = useState<string | null>(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<string, unknown>) : 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<string, unknown>)
|
||||
: 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<HTMLInputElement>) {
|
||||
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 (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={t("codexImportModalTitle") || "Import Codex Auth"}
|
||||
maxWidth="max-w-lg"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border">
|
||||
{(["upload", "paste"] as const).map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => {
|
||||
setTab(id);
|
||||
setParsedJson(null);
|
||||
setParseError(null);
|
||||
setDetectedEmail(null);
|
||||
}}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === id
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
{id === "upload"
|
||||
? t("codexImportTabUpload") || "Upload file"
|
||||
: t("codexImportTabPaste") || "Paste JSON"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Upload tab */}
|
||||
{tab === "upload" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-text-main">
|
||||
{t("codexImportFileLabel") || "Choose auth.json"}
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
className="text-sm text-text-muted file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border file:border-border file:text-xs file:bg-bg-subtle file:text-text-main hover:file:bg-bg-hover cursor-pointer"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("codexImportFileHint") ||
|
||||
"Select the auth.json file exported from Codex or OmniRoute."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paste tab */}
|
||||
{tab === "paste" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-text-main">
|
||||
{t("codexImportPasteLabel") || "Paste the JSON content"}
|
||||
</label>
|
||||
<textarea
|
||||
value={pasteText}
|
||||
onChange={(e) => handlePasteChange(e.target.value)}
|
||||
rows={8}
|
||||
placeholder='{ "auth_mode": "chatgpt", ... }'
|
||||
className="w-full rounded-lg border border-border bg-bg-subtle px-3 py-2 text-xs font-mono text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parse error */}
|
||||
{parseError && <p className="text-sm text-red-500">{parseError}</p>}
|
||||
|
||||
{/* Detected email preview */}
|
||||
{detectedEmail && !parseError && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("codexImportDetectedEmail", { email: detectedEmail }) ||
|
||||
`Detected: ${detectedEmail}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Shared fields */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-text-main">
|
||||
{t("codexImportEmailLabel") || "Account email"}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
className="rounded-lg border border-border bg-bg-subtle px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("codexImportEmailHint") || "Auto-detected from the file; edit if needed."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-text-main">
|
||||
{t("codexImportNameLabel") || "Connection name (optional)"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={email || "Codex (imported)"}
|
||||
className="rounded-lg border border-border bg-bg-subtle px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={overwriteExisting}
|
||||
onChange={(e) => setOverwriteExisting(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-sm text-text-main">
|
||||
{t("codexImportOverwriteLabel") ||
|
||||
"Replace existing connection if account already exists"}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Result error banner */}
|
||||
{resultError && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3 text-sm text-red-400">
|
||||
{resultError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit} loading={loading} fullWidth>
|
||||
{t("codexImportSubmit") || "Import"}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAndValidateHttpBaseUrl(rawValue, fallbackUrl) {
|
||||
const value = (typeof rawValue === "string" ? rawValue.trim() : "") || fallbackUrl;
|
||||
try {
|
||||
|
||||
96
src/app/api/providers/codex-auth/import/route.ts
Normal file
96
src/app/api/providers/codex-auth/import/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3106,6 +3106,25 @@
|
||||
"codexAuthApplyFailed": "Failed to apply Codex auth.json locally",
|
||||
"codexAuthExported": "Codex auth.json exported",
|
||||
"codexAuthExportFailed": "Failed to export Codex auth.json",
|
||||
"importCodexAuth": "Import auth",
|
||||
"codexImportModalTitle": "Import Codex Auth",
|
||||
"codexImportTabUpload": "Upload file",
|
||||
"codexImportTabPaste": "Paste JSON",
|
||||
"codexImportFileLabel": "Choose auth.json",
|
||||
"codexImportFileHint": "Select the auth.json file exported from Codex or from OmniRoute.",
|
||||
"codexImportPasteLabel": "Paste the JSON content",
|
||||
"codexImportEmailLabel": "Account email",
|
||||
"codexImportEmailHint": "Auto-detected from the file; edit if needed.",
|
||||
"codexImportNameLabel": "Connection name (optional)",
|
||||
"codexImportOverwriteLabel": "Replace existing connection if account already exists",
|
||||
"codexImportSubmit": "Import",
|
||||
"codexImportSuccess": "Codex connection imported successfully",
|
||||
"codexImportInvalidJson": "Could not parse the file as JSON",
|
||||
"codexImportInvalidShape": "The file is not a valid Codex auth.json",
|
||||
"codexImportDuplicate": "Account already exists — enable \"Replace existing\" to overwrite",
|
||||
"codexImportFailed": "Failed to import Codex auth",
|
||||
"codexImportDetectedEmail": "Detected: {email}",
|
||||
"codexImportNoEmailDetected": "No email detected in file",
|
||||
"advancedSettings": "Advanced Settings",
|
||||
"chatPathLabel": "Chat Endpoint Path",
|
||||
"chatPathPlaceholder": "/chat/completions",
|
||||
|
||||
219
src/lib/oauth/utils/codexAuthImport.ts
Normal file
219
src/lib/oauth/utils/codexAuthImport.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
// ──── API Key Schemas ────
|
||||
|
||||
export const createKeySchema = z.object({
|
||||
|
||||
115
tests/unit/codex-import-route.test.ts
Normal file
115
tests/unit/codex-import-route.test.ts
Normal 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);
|
||||
});
|
||||
247
tests/unit/codexAuthImport.test.ts
Normal file
247
tests/unit/codexAuthImport.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user