File size: 8,186 Bytes
6111b2b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 | 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);
// Codex CLI no longer writes auth_mode in auth.json (only OmniRoute's own export
// includes it). Accept both formats as long as the required tokens are present.
if (doc.auth_mode !== undefined && doc.auth_mode !== null && doc.auth_mode !== "chatgpt") {
throw new CodexAuthFileError(
"Not a Codex auth.json β unexpected auth_mode value",
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(),
},
});
// Fix C REVERTED: do NOT refresh-on-import.
//
// Reason: production tests showed that auth.json files exported from the
// Codex CLI are often already partially rotated (the CLI continues to use
// the tokens after export). Calling the OpenAI refresh endpoint with a
// stale refresh_token returns "invalid_grant" / "refresh_token_reused" /
// "refresh_token_invalidated" AND has been observed to invalidate the
// entire token family upstream β causing every freshly imported account
// to land in a permanently-broken state, with the only working connection
// being whichever auth.json the user re-exported most recently.
//
// The id_token's `exp` claim is now used as the initial `expiresAt`. In
// practice OpenAI Codex id_tokens carry a multi-day lifetime, so this
// does NOT trigger the import-burst proactive-refresh storm that Fix C
// originally tried to prevent. When the access_token actually expires,
// the reactive 401 path in chatCore.ts (with the per-connection mutex)
// handles a single, atomic rotation β that mechanism is intact via the
// Fix A `runWithOnPersist` plumbing.
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
);
}
|