import GuJumpgate snapshot from GitHub
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
(function attachBackgroundLocalCliProxyApi(root, factory) {
|
||||
root.MultiPageBackgroundLocalCliProxyApi = factory();
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundLocalCliProxyApiModule() {
|
||||
const AUTH_URL = 'https://auth.openai.com/oauth/authorize';
|
||||
const TOKEN_URL = 'https://auth.openai.com/oauth/token';
|
||||
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
||||
const REDIRECT_URI = 'http://localhost:1455/auth/callback';
|
||||
const DEFAULT_RELATIVE_AUTH_DIR = '.cli-proxy-api';
|
||||
|
||||
function normalizeString(value = '') {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function ensureTextEncoder() {
|
||||
if (typeof TextEncoder === 'function') {
|
||||
return new TextEncoder();
|
||||
}
|
||||
throw new Error('当前环境缺少 TextEncoder,无法生成 PKCE。');
|
||||
}
|
||||
|
||||
function getCryptoLike(explicitCrypto = null) {
|
||||
const candidate = explicitCrypto || globalThis.crypto || null;
|
||||
if (candidate && typeof candidate.getRandomValues === 'function' && candidate.subtle) {
|
||||
return candidate;
|
||||
}
|
||||
throw new Error('当前环境缺少 Web Crypto,无法执行本地 OAuth 模块。');
|
||||
}
|
||||
|
||||
function getFetchLike(explicitFetch = null) {
|
||||
const candidate = explicitFetch || globalThis.fetch || null;
|
||||
if (typeof candidate === 'function') {
|
||||
return candidate.bind(globalThis);
|
||||
}
|
||||
throw new Error('当前环境缺少 fetch,无法交换 OAuth token。');
|
||||
}
|
||||
|
||||
function getSessionConverter(explicitConverter = null) {
|
||||
const candidate = explicitConverter
|
||||
|| globalThis.MultiPageSessionToJsonConverter
|
||||
|| (typeof self !== 'undefined' ? self.MultiPageSessionToJsonConverter : null)
|
||||
|| null;
|
||||
if (candidate && typeof candidate.convertSessionJson === 'function') {
|
||||
return candidate;
|
||||
}
|
||||
throw new Error('session-to-json 转换模块未加载,无法生成本地 auth json。');
|
||||
}
|
||||
|
||||
function bytesToBase64Url(bytes) {
|
||||
if (!(bytes instanceof Uint8Array)) {
|
||||
throw new Error('bytesToBase64Url 需要 Uint8Array 输入。');
|
||||
}
|
||||
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return Buffer.from(bytes).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
let binary = '';
|
||||
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
async function sha256Bytes(value, cryptoLike) {
|
||||
const encoder = ensureTextEncoder();
|
||||
const digest = await cryptoLike.subtle.digest('SHA-256', encoder.encode(String(value || '')));
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
async function sha256Hex(value, cryptoLike) {
|
||||
const bytes = await sha256Bytes(value, cryptoLike);
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function generateRandomState(options = {}) {
|
||||
const cryptoLike = getCryptoLike(options.crypto);
|
||||
const bytes = new Uint8Array(16);
|
||||
cryptoLike.getRandomValues(bytes);
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function generatePkceCodes(options = {}) {
|
||||
const cryptoLike = getCryptoLike(options.crypto);
|
||||
const bytes = new Uint8Array(96);
|
||||
cryptoLike.getRandomValues(bytes);
|
||||
const codeVerifier = bytesToBase64Url(bytes);
|
||||
const codeChallenge = bytesToBase64Url(await sha256Bytes(codeVerifier, cryptoLike));
|
||||
return {
|
||||
codeVerifier,
|
||||
codeChallenge,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlanTypeForFilename(planType = '') {
|
||||
const parts = normalizeString(planType)
|
||||
.split(/[^A-Za-z0-9]+/g)
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
return parts.join('-');
|
||||
}
|
||||
|
||||
function resolvePathSeparator(basePath = '') {
|
||||
return String(basePath || '').includes('\\') ? '\\' : '/';
|
||||
}
|
||||
|
||||
function sanitizeRelativeDir(input = DEFAULT_RELATIVE_AUTH_DIR) {
|
||||
const normalized = normalizeString(input || DEFAULT_RELATIVE_AUTH_DIR);
|
||||
if (!normalized) {
|
||||
return DEFAULT_RELATIVE_AUTH_DIR;
|
||||
}
|
||||
|
||||
const segments = normalized
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (segments.some((segment) => segment === '.' || segment === '..')) {
|
||||
throw new Error('relativeAuthDir 不能包含 . 或 .. 路径段。');
|
||||
}
|
||||
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function joinPath(basePath, ...parts) {
|
||||
const separator = resolvePathSeparator(basePath);
|
||||
const normalizedBase = normalizeString(basePath).replace(/[\\/]+$/g, '');
|
||||
const resultParts = [normalizedBase];
|
||||
|
||||
for (const rawPart of parts) {
|
||||
const normalized = String(rawPart || '')
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
if (normalized.length) {
|
||||
resultParts.push(normalized.join(separator));
|
||||
}
|
||||
}
|
||||
|
||||
return resultParts.join(separator);
|
||||
}
|
||||
|
||||
function buildAuthUrl({
|
||||
state,
|
||||
codeChallenge,
|
||||
clientId = CLIENT_ID,
|
||||
redirectUri = REDIRECT_URI,
|
||||
} = {}) {
|
||||
const normalizedState = normalizeString(state);
|
||||
const normalizedChallenge = normalizeString(codeChallenge);
|
||||
if (!normalizedState) {
|
||||
throw new Error('生成 OAuth 地址失败:缺少 state。');
|
||||
}
|
||||
if (!normalizedChallenge) {
|
||||
throw new Error('生成 OAuth 地址失败:缺少 PKCE code_challenge。');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('client_id', normalizeString(clientId) || CLIENT_ID);
|
||||
params.set('response_type', 'code');
|
||||
params.set('redirect_uri', normalizeString(redirectUri) || REDIRECT_URI);
|
||||
params.set('scope', 'openid email profile offline_access');
|
||||
params.set('state', normalizedState);
|
||||
params.set('code_challenge', normalizedChallenge);
|
||||
params.set('code_challenge_method', 'S256');
|
||||
params.set('prompt', 'login');
|
||||
params.set('id_token_add_organizations', 'true');
|
||||
params.set('codex_cli_simplified_flow', 'true');
|
||||
return `${AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function parseOAuthCallback(callbackUrl, expectedState = '') {
|
||||
const rawInput = normalizeString(callbackUrl);
|
||||
if (!rawInput) {
|
||||
throw new Error('缺少 OAuth callback 地址。');
|
||||
}
|
||||
|
||||
let candidate = rawInput;
|
||||
if (!candidate.includes('://')) {
|
||||
if (candidate.startsWith('?')) {
|
||||
candidate = `http://localhost${candidate}`;
|
||||
} else if (candidate.includes('=') && !candidate.includes('/')) {
|
||||
candidate = `http://localhost/?${candidate}`;
|
||||
} else {
|
||||
candidate = `http://${candidate}`;
|
||||
}
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch {
|
||||
throw new Error('OAuth callback 地址格式无效。');
|
||||
}
|
||||
|
||||
const query = parsed.searchParams;
|
||||
let code = normalizeString(query.get('code'));
|
||||
let state = normalizeString(query.get('state'));
|
||||
let error = normalizeString(query.get('error'));
|
||||
let errorDescription = normalizeString(query.get('error_description'));
|
||||
|
||||
if (parsed.hash) {
|
||||
const fragment = new URLSearchParams(parsed.hash.replace(/^#/, ''));
|
||||
code = code || normalizeString(fragment.get('code'));
|
||||
state = state || normalizeString(fragment.get('state'));
|
||||
error = error || normalizeString(fragment.get('error'));
|
||||
errorDescription = errorDescription || normalizeString(fragment.get('error_description'));
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw new Error(errorDescription ? `OAuth 回调失败:${errorDescription}` : `OAuth 回调失败:${error}`);
|
||||
}
|
||||
if (!code) {
|
||||
throw new Error('OAuth callback 缺少 code。');
|
||||
}
|
||||
|
||||
const normalizedExpectedState = normalizeString(expectedState);
|
||||
if (normalizedExpectedState && state !== normalizedExpectedState) {
|
||||
throw new Error(`OAuth state 不匹配:expected ${normalizedExpectedState}, got ${state || '(empty)'}`);
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
state,
|
||||
callbackUrl: parsed.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildCredentialFileName(authJson, options = {}) {
|
||||
const cryptoLike = getCryptoLike(options.crypto);
|
||||
const email = normalizeString(authJson?.email);
|
||||
if (!email) {
|
||||
throw new Error('生成本地 auth 文件名失败:json 中缺少 email。');
|
||||
}
|
||||
|
||||
const planType = normalizePlanTypeForFilename(authJson?.plan_type || authJson?.chatgpt_plan_type);
|
||||
const accountId = normalizeString(authJson?.account_id || authJson?.chatgpt_account_id);
|
||||
const hashAccountId = accountId ? (await sha256Hex(accountId, cryptoLike)).slice(0, 8) : '';
|
||||
|
||||
if (!planType) {
|
||||
return `codex-${email}.json`;
|
||||
}
|
||||
if (planType === 'team') {
|
||||
return `codex-${hashAccountId}-${email}-${planType}.json`;
|
||||
}
|
||||
return `codex-${email}-${planType}.json`;
|
||||
}
|
||||
|
||||
function createLocalCliProxyApi(deps = {}) {
|
||||
const fetchLike = getFetchLike(deps.fetch);
|
||||
const cryptoLike = getCryptoLike(deps.crypto);
|
||||
const sessionConverter = getSessionConverter(deps.sessionToJsonConverter);
|
||||
const ensureDirectory = typeof deps.ensureDirectory === 'function' ? deps.ensureDirectory : null;
|
||||
const writeTextFile = typeof deps.writeTextFile === 'function' ? deps.writeTextFile : null;
|
||||
|
||||
async function createAuthorizationRequest(options = {}) {
|
||||
const oauthState = normalizeString(options.state) || await generateRandomState({ crypto: cryptoLike });
|
||||
const pkceCodes = options.pkceCodes?.codeVerifier && options.pkceCodes?.codeChallenge
|
||||
? {
|
||||
codeVerifier: normalizeString(options.pkceCodes.codeVerifier),
|
||||
codeChallenge: normalizeString(options.pkceCodes.codeChallenge),
|
||||
}
|
||||
: await generatePkceCodes({ crypto: cryptoLike });
|
||||
|
||||
return {
|
||||
oauthState,
|
||||
redirectUri: normalizeString(options.redirectUri) || REDIRECT_URI,
|
||||
pkceCodes,
|
||||
oauthUrl: buildAuthUrl({
|
||||
state: oauthState,
|
||||
codeChallenge: pkceCodes.codeChallenge,
|
||||
clientId: options.clientId || CLIENT_ID,
|
||||
redirectUri: options.redirectUri || REDIRECT_URI,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function exchangeCodeForTokens(options = {}) {
|
||||
const code = normalizeString(options.code);
|
||||
const redirectUri = normalizeString(options.redirectUri) || REDIRECT_URI;
|
||||
const codeVerifier = normalizeString(options.pkceCodes?.codeVerifier);
|
||||
if (!code) {
|
||||
throw new Error('token 交换失败:缺少 OAuth code。');
|
||||
}
|
||||
if (!codeVerifier) {
|
||||
throw new Error('token 交换失败:缺少 PKCE code_verifier。');
|
||||
}
|
||||
|
||||
const form = new URLSearchParams();
|
||||
form.set('grant_type', 'authorization_code');
|
||||
form.set('client_id', normalizeString(options.clientId) || CLIENT_ID);
|
||||
form.set('code', code);
|
||||
form.set('redirect_uri', redirectUri);
|
||||
form.set('code_verifier', codeVerifier);
|
||||
|
||||
const response = await fetchLike(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: form.toString(),
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`token exchange failed with status ${response.status}: ${rawText}`);
|
||||
}
|
||||
|
||||
let payload = {};
|
||||
try {
|
||||
payload = JSON.parse(rawText || '{}');
|
||||
} catch {
|
||||
throw new Error('token 交换失败:返回内容不是合法 JSON。');
|
||||
}
|
||||
|
||||
const accessToken = normalizeString(payload.access_token);
|
||||
const refreshToken = normalizeString(payload.refresh_token);
|
||||
const idToken = normalizeString(payload.id_token);
|
||||
const expiresIn = Number(payload.expires_in);
|
||||
if (!accessToken) {
|
||||
throw new Error('token 交换失败:缺少 access_token。');
|
||||
}
|
||||
|
||||
const expiresAt = Number.isFinite(expiresIn) && expiresIn > 0
|
||||
? new Date(Date.now() + expiresIn * 1000).toISOString()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
idToken,
|
||||
tokenType: normalizeString(payload.token_type),
|
||||
expiresIn: Number.isFinite(expiresIn) ? expiresIn : undefined,
|
||||
expiresAt,
|
||||
rawPayload: payload,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildAuthJsonArtifact(options = {}) {
|
||||
const accessToken = normalizeString(options.accessToken || options.access_token);
|
||||
if (!accessToken) {
|
||||
throw new Error('生成本地 auth json 失败:缺少 accessToken。');
|
||||
}
|
||||
|
||||
const sourceSession = options.session && typeof options.session === 'object' && !Array.isArray(options.session)
|
||||
? options.session
|
||||
: {};
|
||||
const sessionRecord = {
|
||||
...sourceSession,
|
||||
type: 'codex',
|
||||
accessToken,
|
||||
refreshToken: normalizeString(options.refreshToken || options.refresh_token || sourceSession.refreshToken || sourceSession.refresh_token),
|
||||
idToken: normalizeString(options.idToken || options.id_token || sourceSession.idToken || sourceSession.id_token),
|
||||
sessionToken: normalizeString(options.sessionToken || options.session_token || sourceSession.sessionToken || sourceSession.session_token),
|
||||
expiresAt: options.expiresAt || options.expires_at || sourceSession.expiresAt || sourceSession.expires,
|
||||
email: options.email || sourceSession.email || sourceSession.user?.email,
|
||||
account_id: options.accountId || options.account_id || sourceSession.account?.id,
|
||||
user_id: options.userId || options.user_id || sourceSession.user?.id,
|
||||
plan_type: options.planType || options.plan_type || sourceSession.account?.planType || sourceSession.account?.plan_type,
|
||||
};
|
||||
|
||||
const converted = sessionConverter.convertSessionJson(sessionRecord, {
|
||||
lastRefresh: options.lastRefresh,
|
||||
now: options.now || new Date(),
|
||||
sourceName: normalizeString(options.sourceName) || 'CLIProxyAPI Local OAuth',
|
||||
});
|
||||
|
||||
const authJson = converted.output;
|
||||
const fileName = await buildCredentialFileName(authJson, { crypto: cryptoLike });
|
||||
const pluginDir = normalizeString(options.pluginDir);
|
||||
if (!pluginDir) {
|
||||
throw new Error('生成本地 auth json 失败:缺少 pluginDir。');
|
||||
}
|
||||
|
||||
const relativeAuthDir = sanitizeRelativeDir(options.relativeAuthDir || DEFAULT_RELATIVE_AUTH_DIR);
|
||||
const directoryPath = joinPath(pluginDir, relativeAuthDir);
|
||||
const filePath = joinPath(directoryPath, fileName);
|
||||
const jsonText = `${JSON.stringify(authJson, null, 2)}\n`;
|
||||
|
||||
return {
|
||||
provider: 'codex',
|
||||
fileName,
|
||||
directoryPath,
|
||||
filePath,
|
||||
relativeAuthDir,
|
||||
authJson,
|
||||
jsonText,
|
||||
warnings: Array.isArray(converted.warnings) ? converted.warnings.slice() : [],
|
||||
};
|
||||
}
|
||||
|
||||
async function saveAuthJsonArtifact(artifact) {
|
||||
if (!artifact || typeof artifact !== 'object') {
|
||||
throw new Error('保存本地 auth json 失败:artifact 无效。');
|
||||
}
|
||||
if (!writeTextFile) {
|
||||
throw new Error('保存本地 auth json 失败:未提供 writeTextFile。');
|
||||
}
|
||||
if (!normalizeString(artifact.filePath)) {
|
||||
throw new Error('保存本地 auth json 失败:缺少 filePath。');
|
||||
}
|
||||
|
||||
if (ensureDirectory) {
|
||||
await ensureDirectory(artifact.directoryPath);
|
||||
}
|
||||
await writeTextFile(artifact.filePath, artifact.jsonText);
|
||||
|
||||
return {
|
||||
...artifact,
|
||||
saved: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function exchangeCallbackToAuthArtifact(options = {}) {
|
||||
const callback = parseOAuthCallback(options.callbackUrl, options.expectedState);
|
||||
const tokenBundle = await exchangeCodeForTokens({
|
||||
code: callback.code,
|
||||
pkceCodes: options.pkceCodes,
|
||||
redirectUri: options.redirectUri,
|
||||
clientId: options.clientId,
|
||||
});
|
||||
|
||||
return buildAuthJsonArtifact({
|
||||
...tokenBundle,
|
||||
pluginDir: options.pluginDir,
|
||||
relativeAuthDir: options.relativeAuthDir,
|
||||
sourceName: options.sourceName,
|
||||
now: options.now,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
AUTH_URL,
|
||||
TOKEN_URL,
|
||||
CLIENT_ID,
|
||||
REDIRECT_URI,
|
||||
DEFAULT_RELATIVE_AUTH_DIR,
|
||||
buildAuthJsonArtifact,
|
||||
createAuthorizationRequest,
|
||||
exchangeCallbackToAuthArtifact,
|
||||
exchangeCodeForTokens,
|
||||
parseOAuthCallback,
|
||||
saveAuthJsonArtifact,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
AUTH_URL,
|
||||
TOKEN_URL,
|
||||
CLIENT_ID,
|
||||
REDIRECT_URI,
|
||||
DEFAULT_RELATIVE_AUTH_DIR,
|
||||
buildAuthUrl,
|
||||
buildCredentialFileName,
|
||||
createLocalCliProxyApi,
|
||||
generatePkceCodes,
|
||||
generateRandomState,
|
||||
normalizePlanTypeForFilename,
|
||||
parseOAuthCallback,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user