From 1c17a34e24c8218b5d9636d7b6004f36faec5767 Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 10 Jun 2026 14:59:29 +0800 Subject: [PATCH 1/4] 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..8aea706f 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` 时,优先从 `id_token` 回填账号元数据;若仍缺失但 token 未过期,也允许导入继续完成,避免 RT-only 导入失败(`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( From 1988baacf537b4bfbfab9948be1aea50cd820a5e Mon Sep 17 00:00:00 2001 From: jason Date: Thu, 11 Jun 2026 13:04:22 +0800 Subject: [PATCH 2/4] test: cover RT import without account id --- src/auth/account-registry.ts | 28 +++++++- src/services/account-import.ts | 21 +++--- tests/_helpers/jwt.ts | 25 ++++---- tests/unit/services/account-import.test.ts | 74 ++++++++++++++++++++++ 4 files changed, 126 insertions(+), 22 deletions(-) diff --git a/src/auth/account-registry.ts b/src/auth/account-registry.ts index a550dc53..d8c1a416 100644 --- a/src/auth/account-registry.ts +++ b/src/auth/account-registry.ts @@ -33,6 +33,17 @@ function safeEqual(a: string, b: string): boolean { return timingSafeEqual(bufA, bufB); } +function sameAccountWithoutAccountId( + existing: AccountEntry, + userId: string | null, + email: string | null, +): boolean { + if (existing.accountId !== null) return false; + if (userId && existing.userId === userId) return true; + if (email && existing.email === email) return true; + return false; +} + type ResettableQuotaWindow = { used_percent: number | null; reset_at: number | null; @@ -97,6 +108,8 @@ export class AccountRegistry { const accountId = extractChatGptAccountId(token) ?? metadata?.accountId ?? null; const profile = extractUserProfile(token); const userId = profile?.chatgpt_user_id ?? metadata?.userId ?? null; + const email = profile?.email ?? metadata?.email ?? null; + const planType = profile?.chatgpt_plan_type ?? metadata?.planType ?? null; for (const existing of this.accounts.values()) { if (accountId) { @@ -111,7 +124,16 @@ export class AccountRegistry { this.persistNow(); return existing.id; } - } else if (existing.token === token) { + } else if (sameAccountWithoutAccountId(existing, userId, email) || existing.token === token) { + existing.token = token; + if (typeof refreshToken === "string" && refreshToken.length > 0) { + existing.refreshToken = refreshToken; + } + existing.email = email ?? existing.email; + existing.userId = userId ?? existing.userId; + existing.planType = planType ?? existing.planType; + existing.status = isTokenExpired(token) ? "expired" : "active"; + this.persistNow(); return existing.id; } } @@ -121,11 +143,11 @@ export class AccountRegistry { id, token, refreshToken: refreshToken ?? null, - email: profile?.email ?? metadata?.email ?? null, + email, accountId, userId, label: null, - planType: profile?.chatgpt_plan_type ?? metadata?.planType ?? null, + planType, proxyApiKey: "codex-proxy-" + randomBytes(24).toString("hex"), status: isTokenExpired(token) ? "expired" : "active", usage: { diff --git a/src/services/account-import.ts b/src/services/account-import.ts index 0764bc85..fa286073 100644 --- a/src/services/account-import.ts +++ b/src/services/account-import.ts @@ -177,6 +177,10 @@ export class AccountImportService { return { ok: true, token, rt }; } + if (!rt) { + return { ok: false, error: "Refresh token is required", kind: "validation" }; + } + // Refresh-token-only path — check if this RT already belongs to an existing account const existing = this.pool.getAllEntries().find((a) => a.refreshToken === rt); if (existing) { @@ -184,17 +188,18 @@ export class AccountImportService { } // Prevent concurrent refresh of the same RT (e.g. duplicate entries in import file) - if (this.refreshingRTs.has(rt as string)) { + if (this.refreshingRTs.has(rt)) { return { ok: false, error: "Duplicate RT in import batch (skipped to protect token)", kind: "refresh_failed" }; } - this.refreshingRTs.add(rt as string); + this.refreshingRTs.add(rt); 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 && !this.canAcceptRtExchangeToken(v.error, tokens.access_token)) { + const tokens = await this.deps.refreshToken(rt, proxyUrl); + const accessToken = tokens.access_token.trim(); + const metadata = extractCodexTokenMetadata(accessToken, tokens.id_token); + const v = this.deps.validateToken(accessToken); + if (!v.valid && !this.canAcceptRtExchangeToken(v.error, accessToken)) { return { ok: false, error: `Refresh token exchange succeeded but token invalid: ${v.error}`, @@ -205,7 +210,7 @@ export class AccountImportService { const newRT = tokens.refresh_token ?? null; return { ok: true, - token: tokens.access_token, + token: accessToken, rt: newRT, metadata, }; @@ -216,7 +221,7 @@ export class AccountImportService { kind: "refresh_failed", }; } finally { - this.refreshingRTs.delete(rt as string); + this.refreshingRTs.delete(rt); } } diff --git a/tests/_helpers/jwt.ts b/tests/_helpers/jwt.ts index 99293844..fa1b12d5 100644 --- a/tests/_helpers/jwt.ts +++ b/tests/_helpers/jwt.ts @@ -34,22 +34,25 @@ export function createJwt(claims: JwtClaims = {}): string { } /** Create a valid non-expired JWT with standard Codex claims. */ -export function createValidJwt(overrides: { - accountId?: string; +export function createValidJwt(overrides: { + accountId?: string; /** chatgpt_user_id — team members share accountId but differ by userId */ userId?: string; email?: string; planType?: string; expInSeconds?: number; -} = {}): string { - const exp = Math.floor(Date.now() / 1000) + (overrides.expInSeconds ?? 3600); - return createJwt({ - exp, - "https://api.openai.com/auth": { - chatgpt_account_id: overrides.accountId ?? "acct-test-123", - chatgpt_plan_type: overrides.planType ?? "free", - ...(overrides.userId !== undefined ? { chatgpt_user_id: overrides.userId } : {}), - }, +} = {}): string { + const exp = Math.floor(Date.now() / 1000) + (overrides.expInSeconds ?? 3600); + const accountId = Object.prototype.hasOwnProperty.call(overrides, "accountId") + ? overrides.accountId + : "acct-test-123"; + return createJwt({ + exp, + "https://api.openai.com/auth": { + ...(accountId !== undefined ? { chatgpt_account_id: accountId } : {}), + chatgpt_plan_type: overrides.planType ?? "free", + ...(overrides.userId !== undefined ? { chatgpt_user_id: overrides.userId } : {}), + }, "https://api.openai.com/profile": { email: overrides.email ?? "test@example.com", }, diff --git a/tests/unit/services/account-import.test.ts b/tests/unit/services/account-import.test.ts index 5a1a4a4c..fd3f2fd7 100644 --- a/tests/unit/services/account-import.test.ts +++ b/tests/unit/services/account-import.test.ts @@ -110,6 +110,32 @@ describe("AccountImportService", () => { expect(pool.getAccounts()).toHaveLength(1); }); + it("accepts RT-exchanged token with no chatgpt_account_id claim", async () => { + const pool = makePool(); + const jwtWithoutAccountId = createValidJwt({ accountId: undefined }); + const svc = new AccountImportService( + pool, + makeScheduler(), + makeDeps({ + validateToken: () => ({ + valid: false, + error: "Token missing chatgpt_account_id claim", + }), + refreshToken: async () => ({ + access_token: jwtWithoutAccountId, + refresh_token: "new_rt", + }), + }), + ); + + const result = await svc.importMany([{ refreshToken: "some_rt" }]); + + expect(result.added).toBe(1); + expect(result.failed).toBe(0); + expect(result.errors).toHaveLength(0); + expect(pool.getAccounts()[0].accountId).toBeNull(); + }); + it("accepts RT exchange access token when accountId is only present in id_token", async () => { const pool = makePool(); const accessToken = createJwt({ @@ -227,6 +253,54 @@ describe("AccountImportService", () => { expect(entries[0].refreshToken).toBeNull(); }); + it("updates null-accountId entry instead of duplicating after token refresh changes token", async () => { + const pool = makePool(); + let refreshCount = 0; + const tokens = [ + createValidJwt({ + accountId: undefined, + userId: "same-user", + email: "same-user@example.com", + expInSeconds: 3600, + }), + createValidJwt({ + accountId: undefined, + userId: "same-user", + email: "same-user@example.com", + expInSeconds: 7200, + }), + ]; + const svc = new AccountImportService( + pool, + makeScheduler(), + makeDeps({ + validateToken: () => ({ + valid: false, + error: "Token missing chatgpt_account_id claim", + }), + refreshToken: async () => ({ + access_token: tokens[refreshCount++], + refresh_token: `new_rt_${refreshCount}`, + }), + }), + ); + + const first = await svc.importMany([{ refreshToken: "old_rt_1" }]); + const second = await svc.importMany([{ refreshToken: "old_rt_2" }]); + + expect(first.added).toBe(1); + expect(second.updated).toBe(1); + expect(second.added).toBe(0); + expect(pool.getAccounts()).toHaveLength(1); + expect(pool.getAllEntries()[0]).toMatchObject({ + token: tokens[1], + refreshToken: "new_rt_2", + accountId: null, + userId: "same-user", + email: "same-user@example.com", + }); + }); + it("counts failed when refresh exchange throws", async () => { const svc = new AccountImportService( makePool(), From 1e29b051123274e0b7b5d85d0c0ce1028853ec20 Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 12 Jun 2026 14:07:36 +0800 Subject: [PATCH 3/4] docs: update changelog for RT import fix --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aea706f..72a8c819 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ - 更新 `README_EN.md` 中过时的模型推荐说明以匹配最新的模型别名映射(`README_EN.md`) +### Fixed + +- RT-only 导入兼容性:Refresh Token 兑换的 access token 缺少 `chatgpt_account_id` 时,优先从 `id_token` 回填账号元数据;若仍缺失但 token 未过期也允许导入继续(accountId 置 null)。新增 `extractCodexTokenMetadata()` 统一提取双 token 元数据。(#674) +- OAuth refresh 请求新增 `scope` 参数确保服务端返回 `id_token`。(#674) + ### Added - Web 前端测试接入 CI 门禁:`web/` 是独立 package(非 root workspace)、用自己的 vitest(jsdom),其组件测试此前不被 root `npm test` 收录、不在任何自动化里跑。新增 root `test:web` 脚本(`cd web && npx vitest run`)和 `ci-quality.yml` 独立 `frontend-tests` job(`cd web && npm ci` + vitest),把现存 7 个 web 测试文件 16 用例纳入 PR 门禁;并给 `web/vite.config.ts` 补 `preact/devtools` / `preact/debug` alias——`@preact/preset-vite` 会把这两个 import 注入 `shared/` 文件,干净 `npm ci`(CI)从 web/ 外解析不到会在 import 阶段直接炸(本地因根 node_modules 有 preact 兜底而测不出)(`package.json`、`.github/workflows/ci-quality.yml`、`web/vite.config.ts`) From 2eaf15c445d0b62579b2e5e766cd05156c94ffff Mon Sep 17 00:00:00 2001 From: jason Date: Thu, 18 Jun 2026 09:38:47 +0800 Subject: [PATCH 4/4] refactor: bypass validateToken for RT exchange instead of error-string match Replace the fragile canAcceptRtExchangeToken() check (which matched on the exact "Token missing chatgpt_account_id claim" error string) with a dedicated validateRtExchangeToken() that only verifies the token is decodable and not expired. RT-exchanged tokens legitimately omit chatgpt_account_id, so the strict validateToken path should not gate them at all. Co-Authored-By: Claude Opus 4.8 --- src/services/account-import.ts | 29 ++++++++++--------- .../routes/accounts-import-export.test.ts | 27 +++++++++++++++++ tests/unit/services/account-import.test.ts | 16 ---------- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/src/services/account-import.ts b/src/services/account-import.ts index fa286073..c70423bd 100644 --- a/src/services/account-import.ts +++ b/src/services/account-import.ts @@ -6,6 +6,7 @@ import type { AccountPool } from "../auth/account-pool.js"; import type { AccountInfo } from "../auth/types.js"; import { + decodeJwtPayload, extractChatGptAccountId, extractCodexTokenMetadata, isTokenExpired, @@ -198,8 +199,11 @@ export class AccountImportService { const tokens = await this.deps.refreshToken(rt, proxyUrl); const accessToken = tokens.access_token.trim(); const metadata = extractCodexTokenMetadata(accessToken, tokens.id_token); - const v = this.deps.validateToken(accessToken); - if (!v.valid && !this.canAcceptRtExchangeToken(v.error, accessToken)) { + // Bypass validateToken for RT exchange — the strict chatgpt_account_id check + // would reject tokens that OpenAI legitimately returns without that claim. + // Only verify the token is decodable and not expired. + const v = this.validateRtExchangeToken(accessToken); + if (!v.valid) { return { ok: false, error: `Refresh token exchange succeeded but token invalid: ${v.error}`, @@ -225,17 +229,14 @@ export class AccountImportService { } } - 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) - ); + private validateRtExchangeToken(token: string): { valid: boolean; error?: string } { + const payload = decodeJwtPayload(token); + if (!payload) { + return { valid: false, error: "Invalid JWT format — could not decode payload" }; + } + if (isTokenExpired(token)) { + return { valid: false, error: "Token is expired" }; + } + return { valid: true }; } } diff --git a/tests/unit/routes/accounts-import-export.test.ts b/tests/unit/routes/accounts-import-export.test.ts index 27086bbc..06d25349 100644 --- a/tests/unit/routes/accounts-import-export.test.ts +++ b/tests/unit/routes/accounts-import-export.test.ts @@ -523,4 +523,31 @@ describe("account import/export", () => { pool2.destroy(); }); + + it("POST /auth/accounts/import with refreshToken succeeds even if token lacks accountId", async () => { + const { refreshAccessToken } = await import("@src/auth/oauth-pkce.js"); + (refreshAccessToken as ReturnType).mockResolvedValueOnce({ + access_token: "tokenNoAccountId12345", + refresh_token: "rt_returned_by_exchange", + }); + const { extractChatGptAccountId } = await import("@src/auth/jwt-utils.js"); + (extractChatGptAccountId as ReturnType).mockReturnValueOnce(null); + + const res = await app.request("/auth/accounts/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accounts: [{ refreshToken: "rt_lacks_account_id" }], + }), + }); + + expect(res.status).toBe(200); + const data = await res.json() as { added: number; failed: number }; + expect(data.added).toBe(1); + expect(data.failed).toBe(0); + + const entry = pool.getAllEntries()[0]; + expect(entry.token).toBe("tokenNoAccountId12345"); + expect(entry.accountId).toBeNull(); + }); }); diff --git a/tests/unit/services/account-import.test.ts b/tests/unit/services/account-import.test.ts index fd3f2fd7..60216bfe 100644 --- a/tests/unit/services/account-import.test.ts +++ b/tests/unit/services/account-import.test.ts @@ -117,10 +117,6 @@ describe("AccountImportService", () => { pool, makeScheduler(), makeDeps({ - validateToken: () => ({ - valid: false, - error: "Token missing chatgpt_account_id claim", - }), refreshToken: async () => ({ access_token: jwtWithoutAccountId, refresh_token: "new_rt", @@ -155,10 +151,6 @@ describe("AccountImportService", () => { pool, makeScheduler(), makeDeps({ - validateToken: () => ({ - valid: false, - error: "Token missing chatgpt_account_id claim", - }), refreshToken: async () => ({ access_token: accessToken, id_token: idToken, @@ -193,10 +185,6 @@ describe("AccountImportService", () => { pool, makeScheduler(), makeDeps({ - validateToken: () => ({ - valid: false, - error: "Token missing chatgpt_account_id claim", - }), refreshToken: async () => ({ access_token: accessToken, // no id_token either @@ -274,10 +262,6 @@ describe("AccountImportService", () => { pool, makeScheduler(), makeDeps({ - validateToken: () => ({ - valid: false, - error: "Token missing chatgpt_account_id claim", - }), refreshToken: async () => ({ access_token: tokens[refreshCount++], refresh_token: `new_rt_${refreshCount}`,