Files
GuJumpgate/background/local-cli-proxy-api.js
T

464 lines
16 KiB
JavaScript

(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,
};
});