From 8b628a1a764928d5e3bbdc5f0f372d797b2e8fe5 Mon Sep 17 00:00:00 2001 From: dave Date: Sat, 9 May 2026 17:33:00 +0800 Subject: [PATCH] Construct CPA id token when missing --- README.md | 4 +- docs/index.html | 98 ++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4fe7bbe..7141f7e 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ Plus 号可以用此方式导入中转站使用;Free 号的 access token 不 ## 输出格式 -- `CPA`:生成最小 Codex CPA JSON,包含 `type: "codex"`、`access_token`、`session_token`、`email`、`name`、过期时间等可推导字段。 +- `CPA`:生成 Codex CPA auth JSON,包含 `type: "codex"`、`access_token`、`session_token`、`id_token`、`email`、`account_id`、套餐和过期时间等字段;缺少真实 `id_token` 时会根据 session 与 access token claims 构造 CPA 可解析的占位 claims。 - `sub2api`:生成参考 `CPA2sub2API` 项目的 `exported_at/proxies/accounts` 结构,账号平台为 `openai`,类型为 `oauth`。 -ChatGPT Web session 通常不包含 CPA OAuth 文件里常见的 `refresh_token` 和 `id_token`,因此 CPA 输出不会伪造这些字段。 +ChatGPT Web session 通常不包含 CPA OAuth 文件里常见的 `refresh_token`,因此 access token 过期后不能自动刷新。 ## 本地使用 diff --git a/docs/index.html b/docs/index.html index 773e23e..458b218 100644 --- a/docs/index.html +++ b/docs/index.html @@ -636,7 +636,7 @@

- ChatGPT Web session 一般没有 `refresh_token` 和 `id_token`;CPA 输出只包含 access token 和可推导字段。 + ChatGPT Web session 一般没有 `refresh_token`;缺少真实 `id_token` 时会构造 CPA 可解析的占位 claims,实际调用仍依赖 access token。

@@ -765,6 +765,18 @@ return new TextDecoder().decode(bytes); } + function bytesToBase64Url(bytes) { + let binary = ""; + for (let index = 0; index < bytes.length; index += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000)); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + } + + function encodeBase64UrlJson(value) { + return bytesToBase64Url(new TextEncoder().encode(JSON.stringify(value))); + } + function parseJwtPayload(token) { if (typeof token !== "string" || token.trim() === "") { return undefined; @@ -829,6 +841,51 @@ return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); } + function epochSecondsFromValue(value) { + if (value === undefined || value === null || value === "") { + return 0; + } + + const numeric = Number(value); + if (Number.isFinite(numeric)) { + return Math.trunc(numeric > 1e11 ? numeric / 1000 : numeric); + } + + const parsed = Date.parse(String(value)); + return Number.isFinite(parsed) ? Math.trunc(parsed / 1000) : 0; + } + + function buildSyntheticCodexIdToken(email, accountId, planType, userId, expiresAt) { + if (!accountId) { + return undefined; + } + + const now = Math.trunc(Date.now() / 1000); + const authInfo = { chatgpt_account_id: accountId }; + const expires = epochSecondsFromValue(expiresAt) || now + 90 * 24 * 60 * 60; + + if (planType) { + authInfo.chatgpt_plan_type = planType; + } + + if (userId) { + authInfo.chatgpt_user_id = userId; + authInfo.user_id = userId; + } + + const payload = { + iat: now, + exp: expires, + "https://api.openai.com/auth": authInfo, + }; + + if (email) { + payload.email = email; + } + + return `${encodeBase64UrlJson({ alg: "none", typ: "JWT", cpa_synthetic: true })}.${encodeBase64UrlJson(payload)}.`; + } + function getExpiresIn(expiresAt, now = new Date()) { if (!expiresAt) { return undefined; @@ -986,9 +1043,25 @@ record.token?.session_token, record.credentials?.session_token, ); + const refreshToken = firstNonEmpty( + record.refreshToken, + record.refresh_token, + record.token?.refreshToken, + record.token?.refresh_token, + record.credentials?.refresh_token, + ); + const inputIdToken = firstNonEmpty( + record.idToken, + record.id_token, + record.token?.idToken, + record.token?.id_token, + record.credentials?.id_token, + ); const payload = parseJwtPayload(accessToken); + const idPayload = parseJwtPayload(inputIdToken); const auth = getOpenAIAuthSection(payload); + const idAuth = getOpenAIAuthSection(idPayload); const profile = getOpenAIProfileSection(payload); const expiresAt = firstNonEmpty( normalizeTimestamp(record.expires), @@ -1001,6 +1074,7 @@ record.email, record.credentials?.email, profile.email, + idPayload?.email, payload?.email, ); const accountId = firstNonEmpty( @@ -1008,12 +1082,15 @@ record.account_id, record.credentials?.chatgpt_account_id, auth.chatgpt_account_id, + idAuth.chatgpt_account_id, ); const userId = firstNonEmpty( record.user?.id, record.user_id, auth.chatgpt_user_id, auth.user_id, + idAuth.chatgpt_user_id, + idAuth.user_id, ); const planType = firstNonEmpty( record.account?.planType, @@ -1021,22 +1098,33 @@ record.plan_type, record.credentials?.plan_type, auth.chatgpt_plan_type, + idAuth.chatgpt_plan_type, ); const exportedAt = normalizeTimestamp(options.now || new Date()); const expiresIn = getExpiresIn(expiresAt, options.now || new Date()); const sourceName = firstNonEmpty(options.sourceName, "pasted-json"); const name = firstNonEmpty(email, sourceName, "ChatGPT Account"); + const syntheticIdToken = !inputIdToken + ? buildSyntheticCodexIdToken(email, accountId, planType, userId, expiresAt) + : undefined; + const idToken = firstNonEmpty(inputIdToken, syntheticIdToken); const cpa = stripUnavailable({ type: "codex", - access_token: accessToken, - session_token: sessionToken, account_id: accountId, + chatgpt_account_id: accountId, email, name, - expired: expiresAt, - last_refresh: exportedAt, plan_type: planType, + chatgpt_plan_type: planType, + id_token: idToken, + id_token_synthetic: Boolean(syntheticIdToken) || undefined, + access_token: accessToken, + refresh_token: refreshToken, + session_token: sessionToken, + last_refresh: exportedAt, + expired: expiresAt, + disabled: Boolean(record.disabled) || undefined, }); const sub2apiAccount = stripUnavailable({