Files
gpt/account-manager/node/get-rt.js
T
432539 fef45134d5 Initial commit: GPT 账号注册机 (open source)
只开源「GPT 账号注册机 / 管理」(account-manager):账号列表/导入导出、检测/批量检测、
拿 RT、协议/浏览器注册等注册机能力。批量直开 / 协议支付 / 提取链接 / 直卡直开 等其它功能
不含实现代码,仅通过 iframe 内嵌外链访问。真实机密均由 .gitignore 忽略,仅提供脱敏示例配置。
2026-08-05 14:54:32 +08:00

1231 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 拿 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 cookiePKCE + 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 consentGET 授权页 → 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 PoWsentinel.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 直连/走代理均 403Node 内置全局
// 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 VMjsdom 求解 t / so ====================
// 登录/拿 RT 流程对空 t 宽容;但注册(user/register / create_account)严格校验 turnstile。
// 复用 pay/ 下已部署的 sentinel_sdk_full.js + gen_token_jsdom.jsjsdom 跑真 SDK 的 VM),
// 由 requirements token 作为 XOR key 解 dx,产出 tturnstile)与 sosession-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: <json串>, so: <json串|null> }。
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<b64url_payload>.<ts>.<sig> —— 只解 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', ` cookieshas_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_idcookie 优先,否则 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/selectworkspace_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));
}
// 需要选 organizationCodex 多 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/authlogin_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/authlogin_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=ISO8601IMAP=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 codelogin code 提交必 401)。
// 因此只在拿到 verificationscore>=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();
// 步骤1GET /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;
// 步骤2POST 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_accountabout-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`;
}
// 步骤5consent → 提取 authorization code
// HAR 主路径:create_account 直接返回 oauth2/auth?login_verifier=… 的 continue_url
// 顺着它 follow 一路 302/303consent_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 codecontinue_url=${finalUrl.slice(0, 120)}`);
}
say('ok', ` 已提取 authorization code${authCode.slice(0, 8)}…)`);
// 步骤6POST /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_idrunGetRefreshToken 每次都新建 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,
};
}