mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-05-23 04:28:06 +00:00
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
247 lines
8.3 KiB
TypeScript
247 lines
8.3 KiB
TypeScript
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");
|
|
});
|