// 拿 RT —— 纯 HTTP 复刻 OpenAI Codex OAuth 授权流程以换取 refresh_token // 参考实现:https://github.com/Ttungx/codex_auto_register (codex/protocol_keygen.py) // // 这些账号没有 OpenAI 密码 → 使用「无密码 / 邮箱验证码」登录(passwordless),不提交任何密码。 // 复刻的关键步骤(零浏览器,全部走 fetch): // 步骤1 GET /oauth/authorize → 拿 login_session cookie(PKCE + state) // 步骤2 POST /api/accounts/authorize/continue → 提交邮箱(需 openai-sentinel-token) // 步骤3 GET /api/accounts/email-otp/send → 触发发送邮箱登录验证码(无需 sentinel) // 步骤4 从账号自己的 Outlook 邮箱收取验证码 → POST /api/accounts/email-otp/validate 提交 // 步骤4.5 (可选)手机验证:使用 smscode.gg 拿号 + 收码(当前 OpenAI 流程通常不触发,见文末说明) // 步骤5 consent:GET 授权页 → POST /api/accounts/workspace/select // → GET /api/oauth/oauth2/auth(+login_verifier) → /api/accounts/consent?consent_challenge= // → oauth2/auth(+consent_verifier) → ?code=(必要时中间再 organization/select) // 步骤6 POST /oauth/token → 换取 access_token / refresh_token // // 反爬 Sentinel PoW:sentinel.openai.com 的 openai-sentinel-token 由逆向的 FNV-1a PoW 生成。 // 注意:官方仓库 V1 已标注“因 API 变更弃用”,Sentinel 算法/端点会被 OpenAI 轮换, // 因此对线上端点该 token 可能被拒;每一步 HTTP 状态都会记录,便于观察失败点。 import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { execFile } from 'node:child_process'; import { fileURLToPath } from 'node:url'; // 【回归修复 2026-07-28】本文件必须使用与 Node 内置全局 fetch 版本一致的 undici: // - 项目为配合 probe.js 的 TLS 改造,把顶层依赖 `undici` 升到了 ^8.9.0(远新于当前 // Node 运行时内置的 7.21.0)。用这个新版 undici 自带的 fetch/ProxyAgent 直接请求 // auth.openai.com 会被 Cloudflare 判定为非常规客户端指纹,稳定返回 403(已用 // tls.peet.ws 同类手法实测复现:undici@8.9.0 直连/走代理均 403,Node 内置全局 // fetch 与 undici@7.21.0 均为 302 正常)——这正是本轮"拿 RT 出现 403"回归的根因。 // - 同时不能直接把 Node 全局 fetch 和顶层 undici@8.9.0 的 ProxyAgent 混用,两者内部 // undici 版本不一致会抛 "invalid onRequestStart method"(历史"代理路径报错"正是 // 这个)。 // 解法:单独钉死一份与 Node 内置版本完全一致的 undici(别名 undici-legacy, // package.json 里 `"undici-legacy": "npm:undici@=7.21.0"`),只在本文件(拿 RT / // 接码链路)使用,与 probe.js 的 undici@8.9.0(提链/TLS 改造)互不影响,也不使用 // setGlobalDispatcher,无任何全局副作用。 import { fetch as undiciFetch, ProxyAgent as UndiciProxyAgent } from 'undici-legacy'; import { generateFingerprint, randomFullName, randomAdultBirthdate } from './fingerprint.js'; import { fetchPlusInbox, extractCode } from './plus-mailbox.js'; import * as smscode from './smscode.js'; import * as smsbower from './smsbower.js'; import * as herosms from './hero-sms.js'; const OAUTH_ISSUER = 'https://auth.openai.com'; const OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; const OAUTH_REDIRECT_URI = 'http://localhost:1455/auth/callback'; const OAUTH_SCOPE = 'openid profile email offline_access'; const SENTINEL_REQ_URL = 'https://sentinel.openai.com/backend-api/sentinel/req'; // 仅作兜底默认值:正常路径下每个账号会用 generateFingerprint() 产生独立自洽的 UA, // 全程透传(见 runGetRefreshToken)。此常量只在极少数未持有 fp 的独立调用里用到。 const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36'; // ==================== Sentinel PoW(逆向自 sentinel SDK) ==================== function fnv1a32(text) { let h = 2166136261 >>> 0; for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; } h ^= h >>> 16; h = Math.imul(h, 2246822507) >>> 0; h ^= h >>> 13; h = Math.imul(h, 3266489909) >>> 0; h ^= h >>> 16; return (h >>> 0).toString(16).padStart(8, '0'); } class SentinelTokenGenerator { // fp:本账号的指纹对象(generateFingerprint());缺省时退回写死默认值,保证向后兼容。 constructor(deviceId, fp) { this.deviceId = deviceId || crypto.randomUUID(); this.sid = crypto.randomUUID(); this.fp = fp || null; this.MAX_ATTEMPTS = 500000; } _getConfig() { const now = new Date(); const dateStr = now.toUTCString().replace('GMT', 'GMT+0000 (Coordinated Universal Time)'); const navProps = ['vendorSub', 'productSub', 'vendor', 'maxTouchPoints', 'scheduling', 'userActivation', 'doNotTrack', 'plugins', 'mimeTypes', 'hardwareConcurrency', 'cookieEnabled', 'mediaDevices', 'permissions', 'locks']; const navProp = navProps[Math.floor(Math.random() * navProps.length)]; const perfNow = 1000 + Math.random() * 49000; // 指纹关键字段(屏幕/UA/语言/并发数)改由 fp 提供,使 sentinel PoW 里携带的浏览器画像 // 与 HTTP 头、jsdom turnstile 解算保持同一套,跨账号各不相同。 const fp = this.fp; return [ fp ? fp.screenStr : '1920x1080', dateStr, 4294705152, Math.random(), fp ? fp.userAgent : USER_AGENT, 'https://sentinel.openai.com/sentinel/20260124ceb8/sdk.js', null, null, fp ? fp.language : 'en-US', fp ? fp.languages : 'en-US,en', Math.random(), `${navProp}\u2212undefined`, ['location', 'implementation', 'URL', 'documentURI', 'compatMode'][Math.floor(Math.random() * 5)], ['Object', 'Function', 'Array', 'Number', 'parseFloat', 'undefined'][Math.floor(Math.random() * 6)], perfNow, this.sid, '', fp ? fp.hardwareConcurrency : [4, 8, 12, 16][Math.floor(Math.random() * 4)], Date.now() - perfNow, ]; } _b64(cfg) { return Buffer.from(JSON.stringify(cfg), 'utf8').toString('base64'); } generateToken(seed, difficulty) { seed = seed == null ? String(Math.random()) : seed; difficulty = difficulty || '0'; const start = Date.now(); const cfg = this._getConfig(); for (let i = 0; i < this.MAX_ATTEMPTS; i++) { cfg[3] = i; cfg[9] = Math.round(Date.now() - start); const data = this._b64(cfg); const hex = fnv1a32(seed + data); if (hex.slice(0, difficulty.length) <= difficulty) return 'gAAAAAB' + data + '~S'; } return 'gAAAAAB' + this._b64(cfg) + '~S'; } generateRequirementsToken() { const cfg = this._getConfig(); cfg[3] = 1; cfg[9] = Math.round(5 + Math.random() * 45); return 'gAAAAAC' + this._b64(cfg); } } async function buildSentinelToken(jar, deviceId, flow, proxy, fp) { const gen = new SentinelTokenGenerator(deviceId, fp); const pToken = gen.generateRequirementsToken(); let challenge; try { const res = await req('POST', SENTINEL_REQ_URL, { jar, proxy, raw: JSON.stringify({ p: pToken, id: deviceId, flow }), headers: sentinelReqHeaders(fp), }); challenge = res.json(); } catch { return null; } if (!challenge) return null; const cValue = challenge.token || ''; const pow = challenge.proofofwork || {}; const pValue = pow.required && pow.seed ? gen.generateToken(pow.seed, pow.difficulty || '0') : gen.generateRequirementsToken(); return JSON.stringify({ p: pValue, t: '', c: cValue, id: deviceId, flow }); } // ==================== Sentinel Turnstile VM(jsdom 求解 t / so) ==================== // 登录/拿 RT 流程对空 t 宽容;但注册(user/register / create_account)严格校验 turnstile。 // 复用 pay/ 下已部署的 sentinel_sdk_full.js + gen_token_jsdom.js(jsdom 跑真 SDK 的 VM), // 由 requirements token 作为 XOR key 解 dx,产出 t(turnstile)与 so(session-observer)。 const _GET_RT_DIR = path.dirname(fileURLToPath(import.meta.url)); const PAY_DIR = path.resolve(_GET_RT_DIR, '..', '..', 'pay'); const GEN_TOKEN_JSDOM = path.join(PAY_DIR, 'gen_token_jsdom.js'); function solveSentinelVm(chatReq, cachedProof, flow, deviceId, fp) { return new Promise((resolve) => { let tmp; try { tmp = path.join(os.tmpdir(), `sentinel_${crypto.randomUUID()}.json`); // 把本账号指纹一并写入:gen_token_jsdom.js 会据此设置 jsdom 的 navigator/screen, // 让 turnstile(t)/so 的求解环境与 p-token、HTTP 头用的是同一套画像。 fs.writeFileSync(tmp, JSON.stringify({ chatReq, flow, deviceId, cachedProof, fingerprint: fp || null })); } catch { resolve({ t: null, so: null }); return; } execFile(process.execPath, [GEN_TOKEN_JSDOM, tmp], { cwd: PAY_DIR, timeout: 30000, maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => { try { fs.unlinkSync(tmp); } catch { /* ignore */ } if (err && !stdout) { resolve({ t: null, so: null }); return; } const marker = '=== JSON_OUTPUT ==='; const idx = (stdout || '').indexOf(marker); if (idx < 0) { resolve({ t: null, so: null }); return; } try { const data = JSON.parse(stdout.slice(idx + marker.length).trim()); resolve({ t: data && data.t ? data.t : null, so: data && data.so ? data.so : null }); } catch { resolve({ t: null, so: null }); } }); }); } // 生成「完整」sentinel token(含 turnstile t)以及配套 so-token(会话观测),一次 /sentinel/req // 派生(challenge 单次有效,p/t/so 必须同源)。返回 { token: , so: }。 async function buildSentinelBundle(jar, deviceId, flow, proxy, fp) { const gen = new SentinelTokenGenerator(deviceId, fp); const pToken = gen.generateRequirementsToken(); let challenge; // /sentinel/req 走代理时偶发 fetch failed(代理 session 抖动):重试 3 次再放弃。 for (let attempt = 1; attempt <= 3 && !challenge; attempt++) { try { const res = await req('POST', SENTINEL_REQ_URL, { jar, proxy, raw: JSON.stringify({ p: pToken, id: deviceId, flow }), headers: sentinelReqHeaders(fp), }); challenge = res.json(); } catch (e) { if (attempt >= 3) return { token: null, so: null, error: String((e && e.message) || e) }; await new Promise((r) => setTimeout(r, 800 * attempt)); } } if (!challenge) return { token: null, so: null }; const cValue = challenge.token || ''; const pow = challenge.proofofwork || {}; const pValue = pow.required && pow.seed ? gen.generateToken(pow.seed, pow.difficulty || '0') : gen.generateRequirementsToken(); const tokenObj = { p: pValue, c: cValue, id: deviceId, flow }; let soStr = null; const turnstile = challenge.turnstile || {}; const soInfo = challenge.so || {}; if (turnstile.dx || soInfo.required) { const vm = await solveSentinelVm(challenge, pToken, flow, deviceId, fp); if (vm.t) tokenObj.t = vm.t; if (vm.so && soInfo.required) soStr = JSON.stringify({ so: vm.so, c: cValue, id: deviceId, flow }); } return { token: JSON.stringify(tokenObj), so: soStr }; } // ==================== PKCE ==================== function generatePkce() { const verifier = crypto.randomBytes(64).toString('base64url'); const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); return { verifier, challenge }; } function datadogTrace() { const traceId = String(BigInt('0x' + crypto.randomBytes(8).toString('hex'))); const parentId = String(BigInt('0x' + crypto.randomBytes(8).toString('hex'))); const traceHex = BigInt(traceId).toString(16).padStart(16, '0'); const parentHex = BigInt(parentId).toString(16).padStart(16, '0'); return { traceparent: `00-0000000000000000${traceHex}-${parentHex}-01`, tracestate: 'dd=s:1;o:rum', 'x-datadog-origin': 'rum', 'x-datadog-parent-id': parentId, 'x-datadog-sampling-priority': '1', 'x-datadog-trace-id': traceId, }; } // ==================== Cookie Jar + 手动重定向 fetch ==================== class Jar { constructor() { this.map = new Map(); } ingest(res) { let set = []; if (typeof res.headers.getSetCookie === 'function') set = res.headers.getSetCookie(); else { const s = res.headers.get('set-cookie'); if (s) set = [s]; } for (const line of set) { const first = line.split(';')[0]; const eq = first.indexOf('='); if (eq < 0) continue; const name = first.slice(0, eq).trim(); const value = first.slice(eq + 1).trim(); if (name) this.map.set(name, value); } } set(name, value) { this.map.set(name, value); } header() { return Array.from(this.map.entries()).map(([k, v]) => `${k}=${v}`).join('; '); } } async function getProxyAgent(proxy) { if (!proxy || !proxy.host || !proxy.port) return null; const auth = proxy.username ? `${encodeURIComponent(proxy.username)}:${encodeURIComponent(proxy.password || '')}@` : ''; return new UndiciProxyAgent(`http://${auth}${proxy.host}:${proxy.port}`); } // 单次请求(不自动跟随重定向),带 cookie 注入/回收 async function req(method, url, { jar, headers = {}, body, raw, proxy, timeoutMs = 30000 } = {}) { const h = { ...headers }; if (jar) { const c = jar.header(); if (c) h.cookie = c; } let payload; if (raw !== undefined) payload = raw; else if (body !== undefined) { payload = JSON.stringify(body); if (!h['content-type']) h['content-type'] = 'application/json'; } const opts = { method, headers: h, body: payload, redirect: 'manual' }; const agent = await getProxyAgent(proxy); if (agent) opts.dispatcher = agent; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeoutMs); opts.signal = ctrl.signal; let res; try { res = await undiciFetch(url, opts); } finally { clearTimeout(timer); } if (jar) jar.ingest(res); const text = await res.text(); return { status: res.status, location: res.headers.get('location') || '', headers: res.headers, text, json() { try { return JSON.parse(text); } catch { return {}; } }, }; } // 跟随重定向链,直到拿到 redirect_uri?code= 或无法再跳。 // 覆盖 Codex consent 实测链: // oauth2/auth(+login_verifier) → /api/accounts/consent?consent_challenge= // → oauth2/auth(+consent_verifier) → localhost:1455/auth/callback?code= // 也接受 JSON 体里的 continue_url / redirect_url(部分中间 API 会这样返回)。 async function follow(url, { jar, headers, proxy, max = 15, log } = {}) { const say = (level, msg) => { if (typeof log === 'function') log(level, msg); }; let current = url; let last = null; for (let i = 0; i < max; i++) { if (current.startsWith(OAUTH_REDIRECT_URI) || (current.includes('code=') && /localhost:1455/i.test(current))) { return { last, code: extractCodeFromUrl(current), location: current }; } last = await req('GET', current, { jar, headers, proxy }); const loc = last.location ? absAuthUrl(last.location) : ''; say('info', ` follow[${i}] ${last.status} ${(current.length > 120 ? current.slice(0, 120) + '…' : current)}`); if (loc && (loc.includes('code=') || loc.startsWith(OAUTH_REDIRECT_URI))) { return { last, code: extractCodeFromUrl(loc), location: loc }; } if (last.status >= 300 && last.status < 400 && loc) { current = loc; continue; } // 200 JSON 里也可能带下一跳(consent / oauth 中间态) if (last.status === 200) { const next = pickContinueUrl(last.json()); if (next) { const abs = absAuthUrl(next); if (abs.includes('code=') || abs.startsWith(OAUTH_REDIRECT_URI)) { return { last, code: extractCodeFromUrl(abs), location: abs }; } if (abs !== current) { current = abs; continue; } } } break; } return { last, code: extractCodeFromUrl(current), location: current }; } function extractCodeFromUrl(url) { if (!url || !url.includes('code=')) return null; try { return new URL(url, OAUTH_ISSUER).searchParams.get('code'); } catch { return null; } } function absAuthUrl(url) { if (!url) return ''; if (url.startsWith('http://') || url.startsWith('https://')) return url; return `${OAUTH_ISSUER}${url.startsWith('/') ? url : `/${url}`}`; } function pickContinueUrl(obj) { if (!obj || typeof obj !== 'object') return ''; for (const key of ['continue_url', 'redirect_url', 'url', 'continueUrl', 'redirectUrl']) { const v = obj[key]; if (typeof v === 'string' && v) return v; } const data = obj.data; if (data && typeof data === 'object') { for (const key of ['continue_url', 'redirect_url', 'url', 'continueUrl', 'redirectUrl']) { const v = data[key]; if (typeof v === 'string' && v) return v; } } return ''; } // Flask 签名 cookie:.. —— 只解 payload function decodeSignedCookiePayload(raw) { if (!raw) return null; let val = String(raw).trim(); try { val = decodeURIComponent(val); } catch { /* keep raw */ } if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { val = val.slice(1, -1); } const part = val.includes('.') ? val.split('.')[0] : val; const pad = '='.repeat((4 - (part.length % 4)) % 4); try { const json = Buffer.from(part + pad, 'base64url').toString('utf8'); const data = JSON.parse(json); return data && typeof data === 'object' ? data : null; } catch { try { const json = Buffer.from(part + pad, 'base64').toString('utf8'); const data = JSON.parse(json); return data && typeof data === 'object' ? data : null; } catch { return null; } } } function extractWorkspaceIdFromJar(jar) { for (const [name, value] of jar.map.entries()) { if (!String(name).includes('oai-client-auth-session')) continue; const data = decodeSignedCookiePayload(value); if (!data) continue; const workspaces = data.workspaces || (data.client_auth_session && data.client_auth_session.workspaces) || []; if (Array.isArray(workspaces) && workspaces[0] && workspaces[0].id) { return String(workspaces[0].id); } } return ''; } function extractLoginVerifierFromJar(jar) { const ls = jar.map.get('login_session'); if (!ls) return ''; const data = decodeSignedCookiePayload(ls); if (!data) return ''; return String(data.login_challenge || data.login_verifier || ''); } function pickOrgsFromWorkspaceBody(body) { if (!body || typeof body !== 'object') return []; const fromData = body.data && Array.isArray(body.data.orgs) ? body.data.orgs : null; const fromTop = Array.isArray(body.orgs) ? body.orgs : null; const list = fromData || fromTop || []; return list.filter((o) => o && typeof o === 'object'); } function pickPersonalOrFirstOrg(orgs) { if (!orgs || !orgs.length) return null; const personal = orgs.find((o) => String(o.kind || '').toLowerCase() === 'personal'); return personal || orgs[0]; } // Codex consent 真正的“Authorize”动作(对照 chromewebdata.har / auth.openai.com.har): // 1) GET consent 页(刷新 oai-client-auth-session) // 2) POST /api/accounts/workspace/select { workspace_id } // 3) (若需)POST /api/accounts/organization/select { org_id, project_id? } // 4) GET /api/oauth/oauth2/auth?...&login_verifier=… // → 302 /api/accounts/consent?consent_challenge=… // → 302 oauth2/auth?...&consent_verifier=… // → 303 localhost:1455/auth/callback?code= // 注意:步骤 4 是浏览器前端在 workspace/select 之后必走的;不能只靠 GET consent HTML。 async function submitConsentAndExtractCode({ jar, deviceId, proxy, consentUrl, challenge, state, fp }, { log } = {}) { const say = (level, msg) => { if (typeof log === 'function') log(level, msg); }; const consentReferer = consentUrl.includes('/consent') ? consentUrl : `${OAUTH_ISSUER}/sign-in-with-chatgpt/codex/consent`; const cookieNames = [...jar.map.keys()]; say('info', ` cookies:has_session=${cookieNames.includes('oai-client-auth-session') ? 'Y' : 'N'} login_session=${cookieNames.includes('login_session') ? 'Y' : 'N'} total=${cookieNames.length}`); // 1) GET consent HTML —— 可能已带 code(少见),并刷新 session cookie say('info', ` GET consent 页:${consentUrl.slice(0, 100)}`); const stepGet = await follow(consentUrl, { jar, headers: { ...navHeaders(fp), referer: `${OAUTH_ISSUER}/` }, proxy, log: say, }); if (stepGet.code) { say('ok', ' consent GET 重定向链已直接给出 authorization code'); return stepGet.code; } say('info', ` consent GET 未直接给 code(停在 ${String(stepGet.location || '').slice(0, 120)})`); // 2) 取 workspace_id:cookie 优先,否则 session_dump let workspaceId = extractWorkspaceIdFromJar(jar); let workspaceSrc = workspaceId ? 'cookie' : ''; if (!workspaceId) { say('info', ' cookie 无 workspace_id,尝试 GET /api/accounts/client_auth_session_dump'); const dump = await req('GET', `${OAUTH_ISSUER}/api/accounts/client_auth_session_dump`, { jar, proxy, headers: { accept: 'application/json', 'user-agent': fp ? fp.userAgent : USER_AGENT, ...(fp ? { 'sec-ch-ua': fp.secChUa, 'sec-ch-ua-mobile': fp.secChUaMobile, 'sec-ch-ua-platform': fp.secChUaPlatform } : {}), referer: `${OAUTH_ISSUER}/email-verification`, 'oai-device-id': deviceId, }, }); say('info', ` session_dump 状态:${dump.status} body=${dump.text.slice(0, 160)}`); const dj = dump.json(); const workspaces = dj.workspaces || (dj.client_auth_session && dj.client_auth_session.workspaces) || []; if (Array.isArray(workspaces) && workspaces[0] && workspaces[0].id) { workspaceId = String(workspaces[0].id); workspaceSrc = 'session_dump'; } if (!workspaceId) { workspaceId = extractWorkspaceIdFromJar(jar); if (workspaceId) workspaceSrc = 'cookie-after-dump'; } } say('info', ` workspace_id=${workspaceId ? workspaceId.slice(0, 8) + '…' : '(无)'} source=${workspaceSrc || '(none)'}`); let authCode = null; let lastHint = stepGet.location || consentUrl; if (workspaceId) { say('info', ` POST /api/accounts/workspace/select(workspace_id=${workspaceId.slice(0, 8)}…)`); const rws = await req('POST', `${OAUTH_ISSUER}/api/accounts/workspace/select`, { jar, proxy, headers: apiHeaders(fp, deviceId, consentReferer), body: { workspace_id: workspaceId }, }); say('info', ` workspace/select 状态:${rws.status} loc=${(rws.location || '(无)').slice(0, 120)} body=${rws.text.slice(0, 200)}`); if (rws.status >= 300 && rws.status < 400 && rws.location) { const loc = absAuthUrl(rws.location); lastHint = loc; authCode = extractCodeFromUrl(loc); if (!authCode) { const followed = await follow(loc, { jar, headers: { ...navHeaders(fp), referer: consentReferer }, proxy, log: say, }); authCode = followed.code; lastHint = followed.location || lastHint; } } else if (rws.status === 200 || rws.status === 201 || rws.status === 204) { const wsBody = rws.json(); const wsNext = pickContinueUrl(wsBody); const orgs = pickOrgsFromWorkspaceBody(wsBody); const pageType = (wsBody.page && wsBody.page.type) || ''; say('info', ` workspace/select page.type=${pageType || '(空)'} continue_url=${(wsNext || '(空)').slice(0, 120)} orgs=${orgs.length}`); authCode = extractCodeFromUrl(wsNext); if (!authCode && wsNext && (wsNext.startsWith(OAUTH_REDIRECT_URI) || wsNext.includes('code='))) { authCode = extractCodeFromUrl(absAuthUrl(wsNext)); } // 需要选 organization(Codex 多 org / personal) const needOrg = !authCode && ( orgs.length > 0 || /\/codex\/organization/i.test(wsNext) || pageType === 'organization_select' || pageType === 'organization' ); if (needOrg) { const org = pickPersonalOrFirstOrg(orgs); if (!org || !org.id) { say('warn', ' 需要 organization/select 但未拿到 org_id'); } else { const orgBody = { org_id: String(org.id) }; const projects = Array.isArray(org.projects) ? org.projects : []; if (projects[0] && projects[0].id) orgBody.project_id = String(projects[0].id); else if (org.default_project_id) orgBody.project_id = String(org.default_project_id); else if (org.project_id) orgBody.project_id = String(org.project_id); const orgReferer = wsNext ? absAuthUrl(wsNext) : consentReferer; if (wsNext && /\/codex\/organization/i.test(wsNext)) { await req('GET', absAuthUrl(wsNext), { jar, proxy, headers: { ...navHeaders(fp), referer: consentReferer }, }).catch(() => {}); } say('info', ` POST /api/accounts/organization/select body=${JSON.stringify(orgBody)}`); const rorg = await req('POST', `${OAUTH_ISSUER}/api/accounts/organization/select`, { jar, proxy, headers: apiHeaders(fp, deviceId, orgReferer), body: orgBody, }); say('info', ` organization/select 状态:${rorg.status} loc=${(rorg.location || '(无)').slice(0, 120)} body=${rorg.text.slice(0, 200)}`); if (rorg.status >= 300 && rorg.status < 400 && rorg.location) { const loc = absAuthUrl(rorg.location); lastHint = loc; authCode = extractCodeFromUrl(loc); if (!authCode) { const followed = await follow(loc, { jar, headers: { ...navHeaders(fp), referer: orgReferer }, proxy, log: say, }); authCode = followed.code; lastHint = followed.location || lastHint; } } else if (rorg.status === 200 || rorg.status === 201) { const orgNext = pickContinueUrl(rorg.json()); authCode = extractCodeFromUrl(orgNext); if (!authCode && orgNext) { const followed = await follow(absAuthUrl(orgNext), { jar, headers: { ...navHeaders(fp), referer: orgReferer }, proxy, log: say, }); authCode = followed.code; lastHint = followed.location || lastHint; } } else { say('warn', ` organization/select 失败:${rorg.text.slice(0, 160)}`); } } } if (!authCode && wsNext) { const followed = await follow(absAuthUrl(wsNext), { jar, headers: { ...navHeaders(fp), referer: consentReferer }, proxy, log: say, }); authCode = followed.code; lastHint = followed.location || lastHint; } } else { say('warn', ` workspace/select 失败:${rws.text.slice(0, 160)}`); } } else { say('warn', ' 未找到 workspace_id,将直接尝试 oauth2/auth(login_verifier)'); } // 3) HAR 主路径:workspace/select 之后必须 chase oauth2/auth(+login_verifier) // → /api/accounts/consent?consent_challenge= → consent_verifier → ?code= if (!authCode && challenge && state) { const loginVerifier = extractLoginVerifierFromJar(jar); const params = { client_id: OAUTH_CLIENT_ID, code_challenge: challenge, code_challenge_method: 'S256', codex_cli_simplified_flow: 'true', id_token_add_organizations: 'true', redirect_uri: OAUTH_REDIRECT_URI, response_type: 'code', scope: OAUTH_SCOPE, state, }; if (loginVerifier) { params.login_verifier = loginVerifier; say('info', ` GET /api/oauth/oauth2/auth(login_verifier=${loginVerifier.slice(0, 12)}…)`); } else { params.prompt = 'login'; say('info', ' GET /api/oauth/oauth2/auth(无 login_verifier,带 prompt=login)'); } const oauth2Url = `${OAUTH_ISSUER}/api/oauth/oauth2/auth?${new URLSearchParams(params)}`; const followed = await follow(oauth2Url, { jar, headers: { ...navHeaders(fp), referer: consentReferer }, proxy, max: 15, log: say, }); authCode = followed.code; lastHint = followed.location || lastHint; if (authCode) { say('ok', ` oauth2/auth 链提取到 code(经 consent_challenge 完成授权)`); } else { say('warn', ` oauth2/auth 链未拿到 code(停在 ${String(lastHint).slice(0, 140)})`); } } if (!authCode) { say('error', ` consent 最终失败,最后 URL:${String(lastHint).slice(0, 200)}`); } return authCode; } // 导航(HTML)请求头:UA / accept-language / sec-ch-ua 全部取自本账号指纹并保持一致, // 真实 Chrome 在导航请求也会带这几个 client-hint,缺失或与 UA 矛盾本身就是特征。 function navHeaders(fp) { return { accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 'accept-language': fp ? fp.acceptLanguage : 'en-US,en;q=0.9', 'user-agent': fp ? fp.userAgent : USER_AGENT, ...(fp ? { 'sec-ch-ua': fp.secChUa, 'sec-ch-ua-mobile': fp.secChUaMobile, 'sec-ch-ua-platform': fp.secChUaPlatform } : {}), 'upgrade-insecure-requests': '1', }; } // XHR/JSON 请求头:同理,sec-ch-ua 三件套必须与 UA/平台自洽。 function apiHeaders(fp, deviceId, referer) { return { accept: 'application/json', 'accept-language': fp ? fp.acceptLanguage : 'en-US,en;q=0.9', 'content-type': 'application/json', origin: OAUTH_ISSUER, referer, 'user-agent': fp ? fp.userAgent : USER_AGENT, ...(fp ? { 'sec-ch-ua': fp.secChUa, 'sec-ch-ua-mobile': fp.secChUaMobile, 'sec-ch-ua-platform': fp.secChUaPlatform } : {}), 'oai-device-id': deviceId, ...datadogTrace(), }; } // sentinel/req 的头:UA 也跟随账号指纹,保持与 p-token 内 UA 一致。 function sentinelReqHeaders(fp) { return { 'content-type': 'text/plain;charset=UTF-8', referer: 'https://sentinel.openai.com/backend-api/sentinel/frame.html', origin: 'https://sentinel.openai.com', 'user-agent': fp ? fp.userAgent : USER_AGENT, ...(fp ? { 'sec-ch-ua': fp.secChUa, 'sec-ch-ua-mobile': fp.secChUaMobile, 'sec-ch-ua-platform': fp.secChUaPlatform } : {}), }; } // ==================== 从微软邮箱轮询读取 OTP ==================== // 稳健解析邮件收件时间:Graph=ISO8601,IMAP=RFC2822 Date 头,均可被 Date.parse 处理。 function parseReceivedTs(m) { const raw = m && m.received_at; if (!raw) return 0; const t = Date.parse(raw); return Number.isNaN(t) ? 0 : t; } // OTP 邮件打分:OpenAI 每次会同时发「login code」和「verification code」两封, // 而 OAuth 的 email_otp_verification 步骤要的是「verification code」那封; // 分数越高越优先。0 表示不像 OpenAI 验证码邮件。 function scoreOtpEmail(m) { const from = ((m && m.from_addr) || '').toLowerCase(); const subj = ((m && m.subject) || '').toLowerCase(); const isOpenAi = /openai\.com|openai|chatgpt/.test(from) || /openai|chatgpt/.test(subj); if (!isOpenAi) return 0; if (/verification code/.test(subj)) return 4; // 最匹配 email-otp/validate if (/verification/.test(subj)) return 3; if (/login code/.test(subj)) return 2; // 无密码登录码,非本步骤 if (/\bcode\b/.test(subj)) return 1; return 1; } // 轮询账号自己的 Outlook 收件箱,只接受“新鲜”(在触发发送之后到达)的验证码, // 优先 OpenAI/ChatGPT 登录邮件;找不到新鲜码则超时报错,绝不提交旧码。 // baselineTs:调用 email-otp/send 之前记录的时间戳(ms);sinceSkewMs:允许的时钟偏差。 async function waitEmailOtp(account, { timeoutMs = 180000, intervalMs = 4000, baselineTs = 0, sinceSkewMs = 90000, log, fetchInbox } = {}) { const readInbox = fetchInbox || ((acc, limit) => fetchPlusInbox(acc, limit)); const start = Date.now(); const threshold = baselineTs ? baselineTs - sinceSkewMs : 0; if (threshold) log && log('info', `仅接受收件时间 ≥ ${new Date(threshold).toISOString()} 的验证码`); let attempt = 0; let lateFallback = null; // 泛化 "code" 邮件(score=1,非 login/verification),仅超时前兜底 while (Date.now() - start < timeoutMs) { attempt++; try { const { messages } = await readInbox(account, 15); // 收集所有“新鲜且含验证码”的候选,按 (打分, 收件时间) 取最优 const candidates = []; for (const m of messages || []) { const ts = parseReceivedTs(m); const fresh = !threshold || ts >= threshold; const code = m.extracted_code || extractCode(`${m.subject || ''} ${m.body || m.snippet || ''}`); if (!code || !fresh) continue; candidates.push({ code, m, ts, score: scoreOtpEmail(m) }); } // OpenAI 每次会同时发 login code(score=2) 和 verification code(score>=3), // 而 email-otp/validate 只认 verification code(login code 提交必 401)。 // 因此只在拿到 verification(score>=3)时立即返回;否则继续等待它到达。 const verif = candidates .filter((c) => c.score >= 3) .sort((a, b) => (b.score - a.score) || (b.ts - a.ts))[0]; if (verif) { log && log('ok', `第 ${attempt} 次查收:命中新鲜 verification 验证码 ${verif.code}(${verif.m.from_addr || '?'} / “${verif.m.subject || ''}” / ${verif.m.received_at || '?'})`); return verif.code; } // 记录泛化兜底(既非 login 也非 verification 的 code 邮件) const generic = candidates.filter((c) => c.score === 1).sort((a, b) => b.ts - a.ts)[0]; if (generic && !lateFallback) lateFallback = generic; const sawLogin = candidates.some((c) => c.score === 2); log && log('info', `第 ${attempt} 次查收:${sawLogin ? '仅见到 login code,' : ''}等待 verification 验证码,${Math.round(intervalMs / 1000)}s 后重试…`); } catch (e) { log && log('warn', `第 ${attempt} 次查收失败(将重试):${e.message}`); } await sleep(intervalMs); } if (lateFallback) { log && log('warn', `超时前使用兜底验证码 ${lateFallback.code}(${lateFallback.m.subject || ''})`); return lateFallback.code; } return null; } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } // ==================== 手机验证(add-phone:由我们提供号码,OpenAI 下发短信) ==================== // 把 smscode 返回的号码规整为 E.164(如 919330904913 → +919330904913)。 function formatE164(phone) { const digits = String(phone || '').replace(/[^\d]/g, ''); return digits ? `+${digits}` : ''; } // 取消订单以退款;未满 2 分钟(CANCEL_TOO_EARLY)则交由到期自动退款,不阻塞。mod 为选中的接码模块。 async function safeCancel(mod, cfg, orderId, say) { if (orderId == null) return; try { const r = await mod.cancelOrder(cfg, orderId, { log: (l, m) => say(l, m) }); if (r && r.earlyCancelDenied) { say('info', `订单 ${orderId} 未满 2 分钟暂不能取消(EARLY_CANCEL_DENIED),将到期自动退款`); } else { say('info', `已取消订单 ${orderId}(退款)`); } } catch (e) { const msg = String(e && e.message || e); if (String(e.code) === 'CANCEL_TOO_EARLY' || /EARLY_CANCEL_DENIED|CANCEL_TOO_EARLY/i.test(msg)) { say('info', `订单 ${orderId} 未满 2 分钟暂不能取消,将到期自动退款`); } else { say('warn', `取消订单 ${orderId} 失败:${msg}`); } } } // 手机验证主逻辑(带换号重试):按 settings.sms.provider 选 smscode/smsbower/herosms 模块, // 拿号 → 提交号码让 OpenAI 发短信 → 轮询接码平台收码 → 提交校验。 // 失败/超时则取消当前号并换新号重试,最多 MAX 次。成功返回新的 continue_url。 // 端点(已据真实浏览器 HAR 校正并实测): // 发送:POST /api/accounts/add-phone/send body { phone_number, channel } referer /add-phone // —— 真实浏览器必带 channel("sms"|"whatsapp");缺 channel 时 OpenAI 可能默认走 WhatsApp, // 导致接码平台的手机号永远收不到短信(历史 200 却无码的根因)。此处强制 channel="sms"。 // 校验:POST /api/accounts/phone-otp/validate body { code } referer /phone-verification // 无需 sentinel(与 email-otp 一致,HAR 确认 add-phone/validate 均不带 sentinel-token)。 // 多国择优(测试用):cfg.countryCandidates = [{id,name,price}] 或 [id,...],每次换一个国家拿号。 async function verifyPhoneWithSms({ jar, deviceId, proxy, sms, startPageUrl, fp }, { log } = {}) { const say = (level, msg) => { if (typeof log === 'function') log(level, msg); }; // 按 provider 选择接码模块与其专属配置;pollTimeout 为各平台共享 const MODS = { smscode, smsbower, herosms }; const LABELS = { smscode: 'smscode.gg', smsbower: 'smsbower.app', herosms: 'hero-sms.com' }; const provider = (sms && MODS[sms.provider]) ? sms.provider : 'smscode'; const mod = MODS[provider]; const cfg = (sms && sms[provider]) || {}; const label = LABELS[provider]; if (!cfg.apiKey) throw new Error(`流程要求手机验证,但未配置接码 API Key(${label})`); say('info', `手机验证:使用接码平台 ${label}`); const channel = cfg.channel || 'sms'; // add-phone/send 的投递渠道;接码收短信必须 sms // 多国候选:有则按候选逐个换国家拿号,否则用单一 cfg.country/countryId const candRaw = Array.isArray(cfg.countryCandidates) ? cfg.countryCandidates : null; const candidates = (candRaw && candRaw.length) ? candRaw.map((c) => (c && typeof c === 'object' ? c : { id: c })) : null; const MAX = candidates ? candidates.length : Math.max(1, Number(sms.maxAttempts) || 3); const perAttemptTimeout = Math.max(Number(sms.pollTimeout) || 0, 120000); // 短信可能 30~90s,单次至少等 120s let lastErr = null; // 先访问 add-phone / select-channel 页以对齐后续 cookie(失败可忽略) await req('GET', startPageUrl, { jar, proxy, headers: { ...navHeaders(fp), referer: `${OAUTH_ISSUER}/email-verification` }, }).catch(() => {}); for (let attempt = 1; attempt <= MAX; attempt++) { let orderId = null; // 本次尝试所用配置:多国候选时覆盖 country/countryId let attemptCfg = cfg; if (candidates) { const c = candidates[attempt - 1]; attemptCfg = { ...cfg, country: c.id, countryId: c.id }; say('info', `第 ${attempt}/${MAX} 次:国家 ${c.name || c.id}(id=${c.id}${c.price != null ? `, 价${c.price}` : ''}${c.stock != null ? `, 库存${c.stock}` : ''})`); } try { say('info', `第 ${attempt}/${MAX} 次:通过 ${label} 拿号…`); const acq = await mod.acquireNumber(attemptCfg, { log: (l, m) => say(l, ' ' + m) }); orderId = acq.orderId; const e164 = formatE164(acq.phone); say('info', `提交手机号 ${e164}(channel=${channel})→ POST /api/accounts/add-phone/send`); const rsend = await req('POST', `${OAUTH_ISSUER}/api/accounts/add-phone/send`, { jar, proxy, headers: apiHeaders(fp, deviceId, `${OAUTH_ISSUER}/add-phone`), body: { phone_number: e164, channel }, }); say('info', `add-phone/send 状态:${rsend.status}`); if (rsend.status >= 400) throw new Error(`OpenAI 拒绝手机号:HTTP ${rsend.status} ${rsend.text.slice(0, 160)}`); say('info', `等待短信验证码(最长 ${Math.round(perAttemptTimeout / 1000)}s)…`); const otp = await mod.pollOtp(attemptCfg, orderId, { timeoutMs: perAttemptTimeout, log: (l, m) => say(l, ' ' + m) }); say('info', `提交短信验证码 → POST /api/accounts/phone-otp/validate`); const rval = await req('POST', `${OAUTH_ISSUER}/api/accounts/phone-otp/validate`, { jar, proxy, headers: apiHeaders(fp, deviceId, `${OAUTH_ISSUER}/phone-verification`), body: { code: otp.code }, }); say('info', `phone-otp/validate 状态:${rval.status}`); if (rval.status !== 200) throw new Error(`短信验证码校验失败:HTTP ${rval.status} ${rval.text.slice(0, 120)}`); await mod.finishOrder(attemptCfg, orderId).catch(() => {}); say('ok', '手机验证完成'); const dv = rval.json(); return dv.continue_url || ''; } catch (e) { lastErr = e; const msg = String(e && e.message || e); say('warn', `第 ${attempt}/${MAX} 次手机验证失败:${msg}`); await safeCancel(mod, attemptCfg, orderId, say); // 账号已停用:再换号也无意义,立刻停,避免白烧号码 if (/deleted or deactivated/i.test(msg) || (/\b403\b/.test(msg) && /deactivated/i.test(msg))) { throw new Error(`账号已停用,停止手机重试:${msg.slice(0, 160)}`); } if (attempt < MAX) say('info', `第 ${attempt + 1}/${MAX} 次换号重试…`); } } throw new Error(`手机验证在 ${MAX} 次换号后仍失败:${lastErr ? lastErr.message : '未知错误'}`); } // ==================== 主流程 ==================== // account: { email, password, clientId, refreshToken } // opts: { settings, proxy, log } // 返回结构化结果,区分三种情况: // 成功 { ok: true, refreshToken, clientId, accessToken, idToken } // 封号 { ok: false, banned: true, reason: 'account_deactivated', error } —— 403「deleted or deactivated」 // 其它失败(可重试,如手机 400 suspicious/in-use、consent 失败等) // { ok: false, banned: false, error } export async function getRefreshToken(account, opts = {}) { const say = (level, msg) => { if (typeof opts.log === 'function') opts.log(level, msg, account.email); }; try { const result = await runGetRefreshToken(account, opts); return { ok: true, ...result }; } catch (e) { const msg = String((e && e.message) || e); // 封号信号:403「account deleted or deactivated」(可能出现在邮箱 OTP 校验、手机验证或 authorize 环节) const banned = /deleted or deactivated/i.test(msg) || /账号已停用/.test(msg) || (/\b403\b/.test(msg) && /deactivat/i.test(msg)); if (banned) { say('error', '检测到 403 账号已停用/删除,标注为封号(不可用)'); return { ok: false, banned: true, reason: 'account_deactivated', registered: !!opts.__registered, error: msg }; } // registered:true 表示 OpenAI 账号已建(user/register 成功)但后续失败 → 上游据此把邮箱标 burned return { ok: false, banned: false, registered: !!opts.__registered, error: msg }; } } // 实际执行 OAuth 授权换 RT 的内部实现(失败以 throw 抛出,由 getRefreshToken 归类)。 async function runGetRefreshToken(account, opts = {}) { const { settings = {}, proxy = null, log } = opts; const say = (level, msg) => { if (typeof log === 'function') log(level, msg, account.email); }; const sms = settings.sms || {}; const jar = new Jar(); const deviceId = crypto.randomUUID(); // 每个账号(每次 flow)生成一套独立且自洽的指纹,全程复用:UA / sec-ch-ua / 屏幕 / 语言 / // navigator 在 HTTP 头、sentinel p-token、jsdom turnstile 解算里保持同一套,账号间彼此不同。 const fp = generateFingerprint(); jar.set('oai-did', deviceId); say('info', ` 本次指纹:UA=${fp.userAgent.slice(0, 58)}… platform=${fp.secChUaPlatform} screen=${fp.screenStr} lang=${fp.language}`); const { verifier, challenge } = generatePkce(); const state = crypto.randomBytes(32).toString('base64url'); const authorizeUrl = `${OAUTH_ISSUER}/oauth/authorize?` + new URLSearchParams({ response_type: 'code', client_id: OAUTH_CLIENT_ID, redirect_uri: OAUTH_REDIRECT_URI, scope: OAUTH_SCOPE, code_challenge: challenge, code_challenge_method: 'S256', state, }).toString(); // 步骤1:GET /oauth/authorize say('info', '[步骤1] GET /oauth/authorize(初始化 OAuth 会话)'); const step1 = await follow(authorizeUrl, { jar, headers: navHeaders(fp), proxy }); say('info', ` authorize 结果状态:${step1.last ? step1.last.status : '?'}`); if (!jar.map.has('login_session')) say('warn', ' 未获得 login_session cookie(可能被反爬拦截)'); // 注册模式(协议注册):对齐真实浏览器 signup 抓包——authorize/continue 带 screen_hint=signup, // 之后先 POST user/register 建号(设密码),再走 email-otp/send+validate、create_account。 const isReg = !!opts.register; // 步骤2:POST authorize/continue(提交邮箱) say('info', `[步骤2] POST /api/accounts/authorize/continue(提交邮箱${isReg ? ' · signup' : ''})`); let sentinel2; if (isReg) { const b2 = await buildSentinelBundle(jar, deviceId, 'authorize_continue', proxy, fp); sentinel2 = b2.token; if (!sentinel2 && b2.error) say('warn', ` sentinel/req 失败原因:${String(b2.error).slice(0, 160)}`); } else { sentinel2 = await buildSentinelToken(jar, deviceId, 'authorize_continue', proxy, fp); } if (!sentinel2) { say('error', ' 获取 sentinel token 失败(authorize_continue)'); throw new Error('sentinel token 获取失败'); } const h2 = { ...apiHeaders(fp, deviceId, `${OAUTH_ISSUER}/${isReg ? 'create-account' : 'log-in'}`), 'openai-sentinel-token': sentinel2 }; const continueBody = { username: { kind: 'email', value: account.email } }; if (isReg) continueBody.screen_hint = 'signup'; const r2 = await req('POST', `${OAUTH_ISSUER}/api/accounts/authorize/continue`, { jar, proxy, headers: h2, body: continueBody, }); say('info', ` authorize/continue 状态:${r2.status}`); if (r2.status !== 200) throw new Error(`提交邮箱失败:HTTP ${r2.status} ${r2.text.slice(0, 160)}`); // 这些账号没有 OpenAI 密码,走无密码(邮箱验证码)登录: // authorize/continue 之后不提交密码,直接触发并校验邮箱登录验证码。 let cont = r2.json(); let continueUrl = cont.continue_url || ''; let pageType = (cont.page && cont.page.type) || ''; say('info', ` authorize/continue 返回 page.type=${pageType || '(空)'}`); if (!isReg && !opts.fetchInbox && (!account.clientId || !account.refreshToken)) { throw new Error('无密码登录需读取 Outlook 邮箱验证码,但该账号缺少 clientId/refreshToken'); } // 步骤2.5(仅注册):POST /api/accounts/user/register 建号并设置密码 if (isReg) { if (!opts.password) throw new Error('注册需要提供密码(opts.password)'); say('info', '[步骤2.5] POST /api/accounts/user/register(建号 + 设置密码)'); const regBundle = await buildSentinelBundle(jar, deviceId, 'username_password_create', proxy, fp); const sReg = regBundle.token; if (!sReg) { say('error', ' 获取 sentinel token 失败(username_password_create)'); throw new Error('sentinel token 获取失败'); } const hReg = { ...apiHeaders(fp, deviceId, `${OAUTH_ISSUER}/create-account/password`), 'openai-sentinel-token': sReg }; if (regBundle.so) hReg['openai-sentinel-so-token'] = regBundle.so; const rReg = await req('POST', `${OAUTH_ISSUER}/api/accounts/user/register`, { jar, proxy, headers: hReg, body: { username: account.email, password: opts.password }, }); say('info', ` user/register 状态:${rReg.status}`); if (rReg.status !== 200) throw new Error(`建号失败:HTTP ${rReg.status} ${rReg.text.slice(0, 200)}`); // 标记:OpenAI 账号此刻已被创建。后续任一步骤失败都要把邮箱标记 burned, // 避免下次再用同一邮箱注册命中"账号已存在"。opts 按引用传入,getRefreshToken 的 // catch 分支据此回传 registered 标志。 opts.__registered = true; try { const dReg = rReg.json(); if (dReg && dReg.continue_url) continueUrl = dReg.continue_url; if (dReg && dReg.page && dReg.page.type) pageType = dReg.page.type; } catch { /* 无 JSON 体也可继续 */ } } // 步骤3:请求邮箱登录验证码(无密码 / passwordless) // 发送前记录基线时间,用于只接受此后到达的“新鲜”验证码 const otpBaselineTs = Date.now(); say('info', '[步骤3] GET /api/accounts/email-otp/send(请求邮箱验证码)'); const otpReferer = `${OAUTH_ISSUER}/${isReg ? 'create-account' : 'log-in'}`; const rsend = await req('GET', `${OAUTH_ISSUER}/api/accounts/email-otp/send`, { jar, proxy, headers: { ...navHeaders(fp), referer: otpReferer }, }); say('info', ` email-otp/send 状态:${rsend.status}`); if (rsend.status >= 400) throw new Error(`请求邮箱验证码失败:HTTP ${rsend.status} ${rsend.text.slice(0, 160)}`); // 触达 email-verification 页以对齐后续 cookie(失败可忽略) await req('GET', `${OAUTH_ISSUER}/email-verification`, { jar, proxy, headers: { ...navHeaders(fp), referer: `${OAUTH_ISSUER}/log-in` }, }).catch(() => {}); // 步骤4:从 Outlook 收取验证码并提交 say('info', '[步骤4] 从 Outlook 收取邮箱验证码…'); const code = await waitEmailOtp(account, { timeoutMs: 180000, baselineTs: otpBaselineTs, log: (l, m) => say(l, ' ' + m), fetchInbox: opts.fetchInbox, }); if (!code) throw new Error('邮箱验证码等待超时(未收到新鲜验证码)'); say('info', ' 提交验证码 POST /api/accounts/email-otp/validate'); const hVal = apiHeaders(fp, deviceId, `${OAUTH_ISSUER}/email-verification`); if (isReg) { const valBundle = await buildSentinelBundle(jar, deviceId, 'email_otp_validate', proxy, fp); if (valBundle.token) hVal['openai-sentinel-token'] = valBundle.token; if (valBundle.so) hVal['openai-sentinel-so-token'] = valBundle.so; } const rv = await req('POST', `${OAUTH_ISSUER}/api/accounts/email-otp/validate`, { jar, proxy, headers: hVal, body: { code }, }); say('info', ` email-otp/validate 状态:${rv.status}`); if (rv.status !== 200) throw new Error(`邮箱验证码校验失败:HTTP ${rv.status} ${rv.text.slice(0, 160)}`); const dv = rv.json(); continueUrl = dv.continue_url || continueUrl; pageType = (dv.page && dv.page.type) || pageType; // 步骤4.x:注册收尾状态机 —— 严格按服务端返回的 page.type / continue_url 决定下一步, // 而不是写死顺序。真实抓包(8.3chatgpt.com.har)存在两条分支: // A) validate → about-you(create_account) → consent // B) validate → add_phone(手机验证) → (可能再 about-you) → consent // 之前写死「isReg 必做 create_account」会在服务端要求 add_phone 时得到 400,并污染后续 consent。 say('info', `[步骤4] 收尾状态机开始:page.type=${pageType || '(空)'}, continue_url=${String(continueUrl).slice(0, 90) || '(空)'}`); let didAboutYou = false; for (let step = 0; step < 6; step++) { const ct = String(continueUrl || ''); const pt = String(pageType || '').toLowerCase(); const isPhone = /add[_-]?phone|phone[_-]?verification|phone-otp/i.test(pt) || /add-phone|phone-otp|phone-verification/i.test(ct); const isAboutYou = /about[_-]?you/i.test(pt) || pt === 'create_account' || /about-you/i.test(ct); const isConsent = /consent|oauth2\/auth|sign-in-with-chatgpt|workspace|organization|login_verifier|consent_challenge/i.test(ct); if (isPhone) { say('info', `[步骤4.a] 服务端要求手机验证(page.type=${pageType || '(空)'})`); const startPageUrl = ct.startsWith('http') ? ct : (ct ? `${OAUTH_ISSUER}${ct}` : `${OAUTH_ISSUER}/add-phone`); const nextUrl = await verifyPhoneWithSms( { jar, deviceId, proxy, sms, startPageUrl, fp }, { log: (l, m) => say(l, ' ' + m) }, ); continueUrl = nextUrl || ''; pageType = ''; say('info', ` 手机验证后 continue_url=${String(continueUrl).slice(0, 90) || '(空)'}`); continue; } if (isAboutYou && !didAboutYou) { say('info', '[步骤4.b] 服务端要求 about-you(补充资料)→ create_account'); continueUrl = await doCreateAccount(); didAboutYou = true; continue; } if (isConsent || ct) break; // 未知 / 空状态:注册模式且尚未补资料时兜底补一次 about-you,否则进入 consent if (isReg && !didAboutYou) { say('info', '[步骤4.b] 状态未知,注册模式兜底执行 create_account(about-you)'); continueUrl = await doCreateAccount(); didAboutYou = true; continue; } break; } // create_account:提交 name/birthdate(带 oauth_create_account 的 sentinel token+so),返回新的 continue_url async function doCreateAccount() { // 姓名从扩充后的数十×数十池随机组合、生日按 18~48 岁随机,避免旧逻辑(6×6 名 + 1995~2002) // 造成的高度雷同被风控聚类。 const fullName = randomFullName(); const bd = randomAdultBirthdate(); const hAcc = apiHeaders(fp, deviceId, `${OAUTH_ISSUER}/about-you`); const accBundle = await buildSentinelBundle(jar, deviceId, 'oauth_create_account', proxy, fp); if (accBundle.token) hAcc['openai-sentinel-token'] = accBundle.token; if (accBundle.so) hAcc['openai-sentinel-so-token'] = accBundle.so; say('info', ` create_account 身份:name=${fullName} birthdate=${bd}`); const rc = await req('POST', `${OAUTH_ISSUER}/api/accounts/create_account`, { jar, proxy, headers: hAcc, body: { name: fullName, birthdate: bd }, }); say('info', ` create_account 状态:${rc.status}`); if (rc.status !== 200) say('warn', ` create_account 响应:${rc.text.slice(0, 200)}`); let dc = {}; try { dc = rc.json() || {}; } catch { /* 空体 */ } if (dc.page && dc.page.type) pageType = dc.page.type; return dc.continue_url || `${OAUTH_ISSUER}/sign-in-with-chatgpt/codex/consent`; } // 步骤5:consent → 提取 authorization code // HAR 主路径:create_account 直接返回 oauth2/auth?login_verifier=… 的 continue_url, // 顺着它 follow 一路 302/303:consent_challenge → consent_verifier → redirect_uri?code=。 if (!continueUrl) continueUrl = `${OAUTH_ISSUER}/sign-in-with-chatgpt/codex/consent`; const finalUrl = String(continueUrl).startsWith('http') ? String(continueUrl) : `${OAUTH_ISSUER}${String(continueUrl).startsWith('/') ? continueUrl : `/${continueUrl}`}`; let authCode = null; if (/oauth2\/auth|consent_challenge|login_verifier/i.test(finalUrl)) { say('info', `[步骤5] 跟随服务端 continue_url 直取 code:${finalUrl.slice(0, 110)}…`); const r = await follow(finalUrl, { jar, headers: { ...navHeaders(fp), referer: `${OAUTH_ISSUER}/about-you` }, proxy, max: 15, log: (l, m) => say(l, ' ' + m), }); if (r && r.code) { authCode = r.code; say('ok', ' 已从 continue_url 链路提取 code'); } else say('warn', ` 直取失败,落点=${String(r && r.location || '').slice(0, 120)}`); } if (!authCode) { say('info', '[步骤5b] 回退 workspace/select + oauth2/auth(consent_challenge) 提取 code'); let consentUrl = finalUrl; if (!/consent|sign-in-with-chatgpt|workspace|organization/i.test(consentUrl)) { consentUrl = `${OAUTH_ISSUER}/sign-in-with-chatgpt/codex/consent`; } authCode = await submitConsentAndExtractCode( { jar, deviceId, proxy, consentUrl, challenge, state, fp }, { log: (l, m) => say(l, m) }, ); } if (!authCode) { throw new Error(`未能提取 authorization code(continue_url=${finalUrl.slice(0, 120)})`); } say('ok', ` 已提取 authorization code(${authCode.slice(0, 8)}…)`); // 步骤6:POST /oauth/token 换取 tokens say('info', '[步骤6] POST /oauth/token(换取 refresh_token)'); const rt = await req('POST', `${OAUTH_ISSUER}/oauth/token`, { jar, proxy, headers: { 'content-type': 'application/x-www-form-urlencoded', 'user-agent': fp.userAgent }, raw: new URLSearchParams({ grant_type: 'authorization_code', code: authCode, redirect_uri: OAUTH_REDIRECT_URI, client_id: OAUTH_CLIENT_ID, code_verifier: verifier, }).toString(), }); say('info', ` /oauth/token 状态:${rt.status}`); if (rt.status !== 200) throw new Error(`换取 token 失败:HTTP ${rt.status} ${rt.text.slice(0, 160)}`); const tok = rt.json(); if (!tok.refresh_token) throw new Error('token 响应缺少 refresh_token'); say('ok', ' 已获取 refresh_token(第一阶段)'); const phase1 = { refreshToken: tok.refresh_token, clientId: OAUTH_CLIENT_ID, accessToken: tok.access_token || '', idToken: tok.id_token || '', deviceId, // 回传本次会话的指纹,供二阶段/自检里的 refresh_token 换取沿用同一套 UA(与本会话 device_id 自洽)。 fp, }; // 第二阶段(SUB / sub2api):注册完成后,用【全新 device_id + 全新会话】对该账号再跑一次 // 独立的 codex 登录(邮箱验证码),换取与第一阶段【不同】的 codex refreshToken 作为 SUB。 // 硬性约束(用户明确要求): // · 全新 device_id:runGetRefreshToken 每次都新建 jar + deviceId,天然满足独立会话/设备; // · 绝不假标:只有真正拿到「独立且能换 AT」的 codex RT 才 subOk=true; // · 删除「回退复用第一阶段 RT 当 SUB」的假标兜底,拿不到就 subOk=false。 let sub = { subOk: false }; if (isReg) { say('info', '获取SUB中'); try { sub = await doSubViaFreshLogin(account, opts, phase1); } catch (e) { sub = { subOk: false, subError: String((e && e.message) || e) }; } if (sub.subOk) say('ok', 'SUB获取成功'); else say('warn', 'SUB获取失败'); } return { ...phase1, ...sub }; } // 对外证明:用一个【全新 device_id】把某个 codex refreshToken 换成 access_token, // 换到即证明「该 RT 是可登录 CODEX 的独立 codex 凭据」。用于硬证据/自检。 export async function proveCodexRt(refreshToken, proxy = null) { const deviceId = crypto.randomUUID(); const r = await refreshGrant(refreshToken, deviceId, proxy); return { ok: !!r.ok, deviceId, accessToken: r.accessToken || '', newRefreshToken: r.refreshToken || '', status: r.status }; } // 用 refresh_token 换一次 access_token,证明该 codex RT 确实可登录 CODEX(全程用同一 device_id)。 // fp 缺省时临时生成一套,使这次刷新的 UA 也是自洽随机值,而非写死同一 UA。 async function refreshGrant(refreshToken, deviceId, proxy, fp) { const ua = (fp && fp.userAgent) || generateFingerprint().userAgent; try { const r = await req('POST', `${OAUTH_ISSUER}/oauth/token`, { proxy, headers: { 'content-type': 'application/x-www-form-urlencoded', 'user-agent': ua, 'oai-device-id': deviceId }, raw: new URLSearchParams({ grant_type: 'refresh_token', client_id: OAUTH_CLIENT_ID, refresh_token: refreshToken, scope: OAUTH_SCOPE, }).toString(), }); if (r.status === 200) { const t = r.json(); if (t.access_token) return { ok: true, accessToken: t.access_token, refreshToken: t.refresh_token || refreshToken }; } return { ok: false, status: r.status }; } catch (e) { return { ok: false, error: String((e && e.message) || e) }; } } // 第二阶段:以【独立 device_id + 独立会话】对已建账号做一次全新的 codex 登录(邮箱验证码), // 得到与第一阶段【不同】的 codex refreshToken;再用它换一次 AT 证明确实可登录 CODEX。 // 不同会话/设备 + 真登录 → 不再撞 login_verifier already used。拿不到 → subOk=false(绝不假标)。 async function doSubViaFreshLogin(account, opts, phase1 = {}) { // 内部登录细节静默(log 传空),二阶段只对外输出高层中文进度,保持日志清爽。 const r = await runGetRefreshToken( { email: account.email, password: opts.password }, { settings: opts.settings, proxy: opts.proxy, fetchInbox: opts.fetchInbox, register: false, log: () => {} }, ); const subRt = r && r.refreshToken; const subDeviceId = r && r.deviceId; if (!subRt) return { subOk: false, subError: '未取得独立 codex RT' }; if (phase1 && subRt === phase1.refreshToken) return { subOk: false, subError: 'SUB RT 与一阶段相同' }; // 硬校验:用二阶段【自己那套 device_id + 指纹】把 SUB RT 换成 AT,换到才算真 SUB(绝不假标)。 const proof = await refreshGrant(subRt, subDeviceId, opts.proxy, r && r.fp); if (!proof.ok) return { subOk: false, subError: 'SUB RT 无法换 AT' }; return { subOk: true, subClientId: OAUTH_CLIENT_ID, subRefreshToken: subRt, subAccessToken: proof.accessToken || (r && r.accessToken) || '', subDeviceId, }; }