464 lines
16 KiB
JavaScript
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,
|
|
};
|
|
});
|