mirror of
https://wget.la/https://github.com/432539/gpt
synced 2026-08-17 03:26:59 +08:00
只开源「GPT 账号注册机 / 管理」(account-manager):账号列表/导入导出、检测/批量检测、 拿 RT、协议/浏览器注册等注册机能力。批量直开 / 协议支付 / 提取链接 / 直卡直开 等其它功能 不含实现代码,仅通过 iframe 内嵌外链访问。真实机密均由 .gitignore 忽略,仅提供脱敏示例配置。
1231 lines
60 KiB
JavaScript
1231 lines
60 KiB
JavaScript
// 拿 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: <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', ` 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,
|
||
};
|
||
}
|