feat(oauth): accept 9router camelCase Codex export in bulk import (#6665)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-08 19:22:54 -03:00
parent 5144712ab6
commit 2241b394dd
3 changed files with 121 additions and 2 deletions

View File

@@ -11,6 +11,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
### ✨ New Features
- **Provider/model param filters**: config-driven parameter denylist/allowlist per provider/model with auto-learn from upstream 400s (#6649 — thanks @ThongAccount, closes #6625)
- **9router Codex import**: the Codex bulk-import endpoint (`POST /api/oauth/codex/import`) now accepts 9router's camelCase account export (`accessToken`/`refreshToken`/`idToken`/`expiresAt` + nested `providerSpecificData`), not just snake_case — `normalizeCodexImportRecord` maps the camelCase aliases onto the existing snake_case keys, filling each only when absent so snake_case/mixed exports keep working unchanged ([#6665](https://github.com/diegosouzapw/OmniRoute/issues/6665)) — thanks @deadcoder0904. Regression guard: `tests/unit/codexBulkImport.test.ts` (9router camelCase record, pre-supplied `providerSpecificData` without an id_token, snake_case-not-overridden, and a full `{accounts:[...]}` flatten).
### 🐛 Bug Fixes

View File

@@ -138,6 +138,36 @@ function unwrapCodexAuthJson(rec: Record<string, unknown>): Record<string, unkno
return { ...rec, ...tokens };
}
/**
* Map the camelCase field names used by 9router's Codex account export
* (`accessToken`, `refreshToken`, `idToken`, `expiresAt`, and a nested
* `providerSpecificData` block) onto the snake_case keys the rest of the
* normalizer already understands (#6665). A snake_case key is only filled from
* its camelCase alias when it is absent, so a snake_case or mixed export keeps
* working unchanged.
*/
function applyCamelCaseAliases(
rec: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = { ...rec };
const fillFrom = (snake: string, value: unknown) => {
if (out[snake] === undefined && typeof value === "string" && value) {
out[snake] = value;
}
};
fillFrom("access_token", rec.accessToken);
fillFrom("refresh_token", rec.refreshToken);
fillFrom("id_token", rec.idToken);
fillFrom("expired", rec.expiresAt);
const psd = rec.providerSpecificData;
if (psd && typeof psd === "object" && !Array.isArray(psd)) {
const block = psd as Record<string, unknown>;
fillFrom("account_id", block.chatgptAccountId);
fillFrom("chatgpt_plan_type", block.chatgptPlanType);
}
return out;
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
@@ -151,7 +181,9 @@ export function normalizeCodexImportRecord(input: unknown): NormalizeResult {
return { ok: false, error: "Record is not an object" };
}
const rec = unwrapCodexAuthJson(input as Record<string, unknown>);
const rec = applyCamelCaseAliases(
unwrapCodexAuthJson(input as Record<string, unknown>),
);
// Allow type field to be missing or "codex"; reject anything else explicitly so
// users don't accidentally import claude/gemini exports through this path.
@@ -181,7 +213,10 @@ export function normalizeCodexImportRecord(input: unknown): NormalizeResult {
fromJwt.chatgptAccountId,
rec.account_id as string | undefined,
);
const chatgptPlanType = pickString(fromJwt.chatgptPlanType);
const chatgptPlanType = pickString(
fromJwt.chatgptPlanType,
rec.chatgpt_plan_type as string | undefined,
);
const expiresAt =
parseExpiry(rec.expired) ?? parseAccessTokenExp(accessToken);

View File

@@ -243,3 +243,86 @@ describe("flattenCodexImportPayload", () => {
assert.equal(flattenCodexImportPayload(null).ok, false);
});
});
// ── 9router camelCase export (#6665) ────────────────────────────────────────────
describe("9router camelCase Codex export (#6665)", () => {
// The exact shape a 9router Codex account export produces (camelCase fields +
// nested providerSpecificData), as pasted in issue #6665.
const NINEROUTER_RECORD = {
accessToken: "access-9r",
refreshToken: "refresh-9r",
idToken: makeIdToken({
email: "jwt-9r@example.com",
"https://api.openai.com/auth": {
chatgpt_account_id: "acct-jwt-9r",
chatgpt_plan_type: "plus",
},
}),
email: "top-9r@example.com",
expiresAt: "2026-07-17T13:18:27.000Z",
expiresIn: 849516,
providerSpecificData: {
chatgptAccountId: "acct-psd-9r",
chatgptPlanType: "pro",
},
testStatus: "active",
isActive: true,
lastRefreshAt: "2026-07-07T17:19:51.150Z",
};
test("normalizes a 9router camelCase record", () => {
const result = normalizeCodexImportRecord(NINEROUTER_RECORD);
assert.equal(result.ok, true);
if (!result.ok) return;
const { payload } = result;
assert.equal(payload.accessToken, "access-9r");
assert.equal(payload.refreshToken, "refresh-9r");
assert.equal(payload.idToken, NINEROUTER_RECORD.idToken);
// JWT-derived account info wins over the pre-supplied providerSpecificData.
assert.equal(payload.email, "jwt-9r@example.com");
assert.deepEqual(payload.providerSpecificData, {
chatgptAccountId: "acct-jwt-9r",
chatgptPlanType: "plus",
});
// camelCase `expiresAt` is honored as the expiry source.
assert.equal(Date.parse(payload.expiresAt), Date.parse(NINEROUTER_RECORD.expiresAt));
});
test("uses pre-supplied providerSpecificData when there is no id_token", () => {
const result = normalizeCodexImportRecord({
accessToken: "a",
refreshToken: "r",
email: "no-jwt-9r@example.com",
providerSpecificData: { chatgptAccountId: "acct-psd", chatgptPlanType: "team" },
});
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.payload.email, "no-jwt-9r@example.com");
assert.deepEqual(result.payload.providerSpecificData, {
chatgptAccountId: "acct-psd",
chatgptPlanType: "team",
});
});
test("an explicit snake_case field is NOT overridden by a camelCase alias", () => {
const result = normalizeCodexImportRecord({
access_token: "snake-wins",
accessToken: "camel-loses",
refresh_token: "r",
email: "mixed@example.com",
});
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.payload.accessToken, "snake-wins");
});
test("a full 9router {accounts:[...]} export flattens to its records", () => {
const flat = flattenCodexImportPayload([NINEROUTER_RECORD]);
assert.equal(flat.ok, true);
if (!flat.ok) return;
assert.equal(flat.records.length, 1);
const norm = normalizeCodexImportRecord(flat.records[0]);
assert.equal(norm.ok, true);
});
});