mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 19:52:50 +03:00
chore(merge): resolve conflicts for bulk import — keep both schemas and all i18n keys
This commit is contained in:
@@ -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-<timestamp>.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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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).",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
// ──── Codex Import Bulk Schema ────
|
||||
|
||||
export const importCodexAuthBulkSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user