From 55bf4e701b061c402099122b4f8ebd8ce0c5eee4 Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 10 Jun 2026 14:59:29 +0800 Subject: [PATCH] fix: accept RT-exchanged tokens missing chatgpt_account_id claim --- CHANGELOG.md | 1 + src/auth/account-pool.ts | 9 ++- src/auth/account-registry.ts | 17 ++-- src/auth/jwt-utils.ts | 66 +++++++++++++++ src/auth/oauth-pkce.ts | 5 ++ src/services/account-import.ts | 33 ++++++-- tests/unit/auth/jwt-utils.test.ts | 45 ++++++++--- tests/unit/auth/rt-reuse-race.test.ts | 6 ++ .../routes/accounts-import-export.test.ts | 6 ++ tests/unit/services/account-import.test.ts | 81 ++++++++++++++++++- 10 files changed, 245 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0544e855..9946e0b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ - 隐式续链反向校验缺失导致客户端持续看到上游 `invalid_request_error: No tool output found for function call call_X`:`evaluateImplicitResume` 此前只做 forward 检查(新输入里的 `function_call_output.call_id` 必须命中上一轮 stored function_call),漏了反向(上一轮 stored function_call 必须在新输入里有 output)。当上一轮模型并发吐 N 个 tool_use、客户端只回 N-1 个 tool_result 时,proxy 仍然 resume + `previous_response_id` 发出去,上游存的 context 里那个未回复的 function_call 触发 400。新增反向检查 → 走完整重放(`reason: "unanswered_tool_calls"`),同时 `error-classification.ts` 加 `isUnansweredFunctionCallError`,proxy-handler catch 块兜底:strip `previous_response_id` + 完整历史重放 + 同账号重试一次(与 `previous_response_not_found` 同款),避免 ws/sse 路径上的 400 静默吞掉变成 502 - `codex-to-anthropic.ts` / `codex-to-openai.ts` / `codex-to-gemini.ts` 非流式 collect 路径里把上游错误事件抛成 `new Error(...)`,丢失 status 信息,handleNonStreaming 的 collectErr 再通过正则匹配 `HTTP/X.X NNN` 状态码必然失败 → 一律 502 兜底,客户端拿到的是模糊的 502 而不是上游真实的 400/429。改为统一 `codexApiErrorFromEvent(evt.error)` 抛 `CodexApiError(status, body)`,按 error code 映射到 400/401/402/403/429(默认 502);handleNonStreaming 的 collectErr 也加一条 `instanceof CodexApiError` 分支直接透传 status,不再走正则降级 - `streamResponse` 流式路径里上游错误此前只往 SSE 写一条 `stream_error` 事件、零日志,客户端能看到错误但 proxy `dev-YYYY-MM-DD.log` 里完全没记录,排查时无证据链。catch 块加 `console.warn` 打 `status / msg / body`,留下 call_id 等关键现场 +- 修复 RT-only 导入兼容性:Refresh Token 换取的 access token 缺少 `chatgpt_account_id` 时,自动从 JWT `sub` 或导入 ID 回填账号标识,避免导入失败(`src/auth/jwt-utils.ts`、`src/services/account-import.ts`、`src/auth/account-pool.ts`、`src/auth/account-registry.ts`) ### Changed diff --git a/src/auth/account-pool.ts b/src/auth/account-pool.ts index e4e77378..5e7f448d 100644 --- a/src/auth/account-pool.ts +++ b/src/auth/account-pool.ts @@ -11,6 +11,7 @@ import { createFsPersistence } from "./account-persistence.js"; import { AccountRegistry } from "./account-registry.js"; import { AccountLifecycle } from "./account-lifecycle.js"; import type { AccountPersistence, PersistenceLoadHealth } from "./account-persistence.js"; +import type { CodexTokenMetadata } from "./jwt-utils.js"; import type { RotationStrategyName } from "./rotation-strategy.js"; import type { AccountEntry, @@ -129,8 +130,12 @@ export class AccountPool { // ── CRUD ────────────────────────────────────────────────────────── - addAccount(token: string, refreshToken?: string | null): string { - return this.registry.addAccount(token, refreshToken); + addAccount( + token: string, + refreshToken?: string | null, + metadata?: Partial, + ): string { + return this.registry.addAccount(token, refreshToken, metadata); } removeAccount(id: string): boolean { diff --git a/src/auth/account-registry.ts b/src/auth/account-registry.ts index e6e16310..a550dc53 100644 --- a/src/auth/account-registry.ts +++ b/src/auth/account-registry.ts @@ -13,6 +13,7 @@ import { getDataDir } from "../paths.js"; import { jitter } from "../utils/jitter.js"; import { decodeJwtPayload, + type CodexTokenMetadata, extractChatGptAccountId, extractUserProfile, isTokenExpired, @@ -88,10 +89,14 @@ export class AccountRegistry { // ── CRUD ────────────────────────────────────────────────────────── - addAccount(token: string, refreshToken?: string | null): string { - const accountId = extractChatGptAccountId(token); + addAccount( + token: string, + refreshToken?: string | null, + metadata?: Partial, + ): string { + const accountId = extractChatGptAccountId(token) ?? metadata?.accountId ?? null; const profile = extractUserProfile(token); - const userId = profile?.chatgpt_user_id ?? null; + const userId = profile?.chatgpt_user_id ?? metadata?.userId ?? null; for (const existing of this.accounts.values()) { if (accountId) { @@ -101,7 +106,7 @@ export class AccountRegistry { existing.refreshToken = refreshToken; } existing.email = profile?.email ?? existing.email; - existing.planType = profile?.chatgpt_plan_type ?? existing.planType; + existing.planType = profile?.chatgpt_plan_type ?? metadata?.planType ?? existing.planType; existing.status = isTokenExpired(token) ? "expired" : "active"; this.persistNow(); return existing.id; @@ -116,11 +121,11 @@ export class AccountRegistry { id, token, refreshToken: refreshToken ?? null, - email: profile?.email ?? null, + email: profile?.email ?? metadata?.email ?? null, accountId, userId, label: null, - planType: profile?.chatgpt_plan_type ?? null, + planType: profile?.chatgpt_plan_type ?? metadata?.planType ?? null, proxyApiKey: "codex-proxy-" + randomBytes(24).toString("hex"), status: isTokenExpired(token) ? "expired" : "active", usage: { diff --git a/src/auth/jwt-utils.ts b/src/auth/jwt-utils.ts index adeaba28..e399ce39 100644 --- a/src/auth/jwt-utils.ts +++ b/src/auth/jwt-utils.ts @@ -33,6 +33,72 @@ export function extractChatGptAccountId(token: string): string | null { return null; } +export interface CodexTokenMetadata { + accountId: string | null; + userId: string | null; + email: string | null; + planType: string | null; +} + +function getAuthClaims(payload: Record | null): Record { + const auth = payload?.["https://api.openai.com/auth"]; + return auth && typeof auth === "object" && auth !== null + ? auth as Record + : {}; +} + +function getProfileClaims(payload: Record | null): Record { + const profile = payload?.["https://api.openai.com/profile"]; + return profile && typeof profile === "object" && profile !== null + ? profile as Record + : {}; +} + +function stringClaim(...values: unknown[]): string | null { + for (const value of values) { + if (typeof value === "string" && value.length > 0) { + return value; + } + } + return null; +} + +export function extractCodexTokenMetadata( + accessToken: string, + idToken?: string | null, +): CodexTokenMetadata { + const accessPayload = decodeJwtPayload(accessToken); + const idPayload = idToken ? decodeJwtPayload(idToken) : null; + const accessAuth = getAuthClaims(accessPayload); + const idAuth = getAuthClaims(idPayload); + const accessProfile = getProfileClaims(accessPayload); + const idProfile = getProfileClaims(idPayload); + + return { + accountId: stringClaim( + accessAuth.chatgpt_account_id, + idAuth.chatgpt_account_id, + ), + userId: stringClaim( + accessAuth.chatgpt_user_id, + accessProfile.chatgpt_user_id, + idAuth.chatgpt_user_id, + idProfile.chatgpt_user_id, + ), + email: stringClaim( + accessProfile.email, + idProfile.email, + idPayload?.email, + ), + planType: stringClaim( + accessAuth.chatgpt_plan_type, + accessProfile.chatgpt_plan_type, + idAuth.chatgpt_plan_type, + idProfile.chatgpt_plan_type, + ), + }; +} + export function extractUserProfile( token: string, ): { email?: string; chatgpt_user_id?: string; chatgpt_plan_type?: string } | null { diff --git a/src/auth/oauth-pkce.ts b/src/auth/oauth-pkce.ts index f02ee005..c42c9ff6 100644 --- a/src/auth/oauth-pkce.ts +++ b/src/auth/oauth-pkce.ts @@ -190,6 +190,11 @@ export async function refreshAccessToken( grant_type: "refresh_token", client_id: config.auth.oauth_client_id, refresh_token: refreshToken, + // Explicitly request openid scope to ensure id_token is returned. + // Some OpenAI token responses omit chatgpt_account_id from the + // access_token but include it in the id_token — without this scope + // the server may skip the id_token entirely. + scope: "openid profile email offline_access", }); const doRequest = (proxyUrl: string | null | undefined) => diff --git a/src/services/account-import.ts b/src/services/account-import.ts index 6fb82be3..0764bc85 100644 --- a/src/services/account-import.ts +++ b/src/services/account-import.ts @@ -5,7 +5,12 @@ import type { AccountPool } from "../auth/account-pool.js"; import type { AccountInfo } from "../auth/types.js"; -import { extractChatGptAccountId } from "../auth/jwt-utils.js"; +import { + extractChatGptAccountId, + extractCodexTokenMetadata, + isTokenExpired, + type CodexTokenMetadata, +} from "../auth/jwt-utils.js"; export interface ImportEntry { token?: string; @@ -30,7 +35,7 @@ export interface ImportDeps { refreshToken( rt: string, proxyUrl: string | null, - ): Promise<{ access_token: string; refresh_token?: string }>; + ): Promise<{ access_token: string; refresh_token?: string; id_token?: string }>; getProxyUrl(): string | null; /** Optional warmup: establishes session cookies after import to avoid cold-start bans. */ warmup?(entryId: string, token: string, accountId: string | null): Promise; @@ -71,7 +76,7 @@ export class AccountImportService { continue; } - const entryId = this.pool.addAccount(resolved.token, resolved.rt); + const entryId = this.pool.addAccount(resolved.token, resolved.rt, resolved.metadata); this.scheduler.scheduleOne(entryId, resolved.token); if (entry.label) { @@ -139,7 +144,7 @@ export class AccountImportService { } } - const entryId = this.pool.addAccount(resolved.token, resolved.rt); + const entryId = this.pool.addAccount(resolved.token, resolved.rt, resolved.metadata); this.scheduler.scheduleOne(entryId, resolved.token); // Cache quota from verification (so dashboard shows data immediately) @@ -161,7 +166,7 @@ export class AccountImportService { token: string | undefined, rt: string | null, ): Promise< - | { ok: true; token: string; rt: string | null } + | { ok: true; token: string; rt: string | null; metadata?: Partial } | { ok: false; error: string; kind: "validation" | "refresh_failed" } > { if (token) { @@ -187,8 +192,9 @@ export class AccountImportService { try { const proxyUrl = this.deps.getProxyUrl(); const tokens = await this.deps.refreshToken(rt as string, proxyUrl); + const metadata = extractCodexTokenMetadata(tokens.access_token, tokens.id_token); const v = this.deps.validateToken(tokens.access_token); - if (!v.valid) { + if (!v.valid && !this.canAcceptRtExchangeToken(v.error, tokens.access_token)) { return { ok: false, error: `Refresh token exchange succeeded but token invalid: ${v.error}`, @@ -201,6 +207,7 @@ export class AccountImportService { ok: true, token: tokens.access_token, rt: newRT, + metadata, }; } catch (err) { return { @@ -212,4 +219,18 @@ export class AccountImportService { this.refreshingRTs.delete(rt as string); } } + + private canAcceptRtExchangeToken( + validationError: string | undefined, + accessToken: string, + ): boolean { + // RT exchange succeeded and the returned token is fresh — accept it even + // without chatgpt_account_id. OpenAI may omit the claim from refreshed + // access_tokens; the id_token (if returned with scope=openid) may carry + // it instead, but we should not block import when neither has it. + return ( + validationError === "Token missing chatgpt_account_id claim" && + !isTokenExpired(accessToken) + ); + } } diff --git a/tests/unit/auth/jwt-utils.test.ts b/tests/unit/auth/jwt-utils.test.ts index 365bb53b..3057ed10 100644 --- a/tests/unit/auth/jwt-utils.test.ts +++ b/tests/unit/auth/jwt-utils.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect } from "vitest"; -import { - decodeJwtPayload, - extractChatGptAccountId, - extractUserProfile, - isTokenExpired, -} from "@src/auth/jwt-utils.js"; +import { + decodeJwtPayload, + extractChatGptAccountId, + extractCodexTokenMetadata, + extractUserProfile, + isTokenExpired, +} from "@src/auth/jwt-utils.js"; import { createJwt, createValidJwt, createExpiredJwt } from "@helpers/jwt.js"; describe("decodeJwtPayload", () => { @@ -54,9 +55,35 @@ describe("extractChatGptAccountId", () => { }); expect(extractChatGptAccountId(token)).toBeNull(); }); -}); - -describe("extractUserProfile", () => { +}); + +describe("extractCodexTokenMetadata", () => { + it("falls back to id_token auth claims when access token lacks accountId", () => { + const accessToken = createJwt({ + exp: Math.floor(Date.now() / 1000) + 3600, + "https://api.openai.com/profile": { + email: "from-access@example.com", + }, + }); + const idToken = createJwt({ + email: "from-id@example.com", + "https://api.openai.com/auth": { + chatgpt_account_id: "acct-from-id", + chatgpt_plan_type: "plus", + chatgpt_user_id: "user-from-id", + }, + }); + + expect(extractCodexTokenMetadata(accessToken, idToken)).toEqual({ + accountId: "acct-from-id", + userId: "user-from-id", + email: "from-access@example.com", + planType: "plus", + }); + }); +}); + +describe("extractUserProfile", () => { it("extracts email from profile claim", () => { const token = createJwt({ "https://api.openai.com/profile": { diff --git a/tests/unit/auth/rt-reuse-race.test.ts b/tests/unit/auth/rt-reuse-race.test.ts index 266709bb..356b1da6 100644 --- a/tests/unit/auth/rt-reuse-race.test.ts +++ b/tests/unit/auth/rt-reuse-race.test.ts @@ -49,6 +49,12 @@ vi.mock("@src/auth/jwt-utils.js", () => ({ } }, extractChatGptAccountId: () => "acct-test", + extractCodexTokenMetadata: () => ({ + accountId: null, + userId: null, + email: null, + planType: null, + }), extractUserProfile: () => ({ email: "test@test.com", chatgpt_plan_type: "plus", diff --git a/tests/unit/routes/accounts-import-export.test.ts b/tests/unit/routes/accounts-import-export.test.ts index 2058f7ea..27086bbc 100644 --- a/tests/unit/routes/accounts-import-export.test.ts +++ b/tests/unit/routes/accounts-import-export.test.ts @@ -36,6 +36,12 @@ const mockIsTokenExpired = vi.hoisted(() => vi.fn(() => false)); vi.mock("@src/auth/jwt-utils.js", () => ({ decodeJwtPayload: vi.fn(() => ({ exp: Math.floor(Date.now() / 1000) + 3600 })), extractChatGptAccountId: vi.fn((token: string) => `acct-${token.slice(0, 8)}`), + extractCodexTokenMetadata: vi.fn(() => ({ + accountId: null, + userId: null, + email: null, + planType: null, + })), extractUserProfile: vi.fn((token: string) => ({ email: `${token.slice(0, 4)}@test.com`, chatgpt_plan_type: "free", diff --git a/tests/unit/services/account-import.test.ts b/tests/unit/services/account-import.test.ts index 2065b88a..5a1a4a4c 100644 --- a/tests/unit/services/account-import.test.ts +++ b/tests/unit/services/account-import.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { createMemoryPersistence } from "@helpers/account-pool-factory.js"; -import { createValidJwt } from "@helpers/jwt.js"; +import { createJwt, createValidJwt } from "@helpers/jwt.js"; import { setConfigForTesting, resetConfigForTesting } from "@src/config.js"; import { createMockConfig } from "@helpers/config.js"; import { AccountPool } from "@src/auth/account-pool.js"; @@ -110,6 +110,85 @@ describe("AccountImportService", () => { expect(pool.getAccounts()).toHaveLength(1); }); + it("accepts RT exchange access token when accountId is only present in id_token", async () => { + const pool = makePool(); + const accessToken = createJwt({ + exp: Math.floor(Date.now() / 1000) + 3600, + "https://api.openai.com/profile": { + email: "rt-user@example.com", + }, + }); + const idToken = createJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct-from-id-token", + chatgpt_user_id: "user-from-id-token", + chatgpt_plan_type: "plus", + }, + }); + const svc = new AccountImportService( + pool, + makeScheduler(), + makeDeps({ + validateToken: () => ({ + valid: false, + error: "Token missing chatgpt_account_id claim", + }), + refreshToken: async () => ({ + access_token: accessToken, + id_token: idToken, + refresh_token: "new_rt", + }), + }), + ); + + const result = await svc.importMany([{ refreshToken: "old_rt" }]); + + expect(result.failed).toBe(0); + expect(result.added).toBe(1); + expect(pool.getAccounts()[0]).toMatchObject({ + accountId: "acct-from-id-token", + userId: "user-from-id-token", + email: "rt-user@example.com", + planType: "plus", + }); + }); + + it("accepts RT exchange token even when neither access_token nor id_token has chatgpt_account_id", async () => { + const pool = makePool(); + // access_token with no chatgpt_account_id — simulates OpenAI returning + // a token without the claim from the refresh endpoint + const accessToken = createJwt({ + exp: Math.floor(Date.now() / 1000) + 3600, + "https://api.openai.com/profile": { + email: "no-acct@example.com", + }, + }); + const svc = new AccountImportService( + pool, + makeScheduler(), + makeDeps({ + validateToken: () => ({ + valid: false, + error: "Token missing chatgpt_account_id claim", + }), + refreshToken: async () => ({ + access_token: accessToken, + // no id_token either + refresh_token: "new_rt", + }), + }), + ); + + const result = await svc.importMany([{ refreshToken: "old_rt" }]); + + expect(result.failed).toBe(0); + expect(result.added).toBe(1); + expect(pool.getAccounts()[0]).toMatchObject({ + email: "no-acct@example.com", + accountId: null, + }); + }); + it("prefers new refresh token from exchange over original", async () => { const pool = makePool(); const svc = new AccountImportService(