- 新增 add-account.js OAuth 批量授权辅助工具 - 新增 account-manager.js 多账号管理模块(加权轮询、自动刷新、健康度统计) - 新增 accounts.json.example 配置示例 - 修改 auth.js 支持多账号模式(优先检测 accounts.json)
384 lines
12 KiB
JavaScript
384 lines
12 KiB
JavaScript
import fs from 'fs';
|
||
import path from 'path';
|
||
import os from 'os';
|
||
import fetch from 'node-fetch';
|
||
import { logDebug, logError, logInfo } from './logger.js';
|
||
import { getNextProxyAgent } from './proxy-manager.js';
|
||
import { getAccountManager, initializeAccountManager } from './account-manager.js';
|
||
|
||
// State management for API key and refresh
|
||
let currentApiKey = null;
|
||
let currentRefreshToken = null;
|
||
let lastRefreshTime = null;
|
||
let clientId = null;
|
||
let authSource = null; // 'env' or 'file' or 'factory_key' or 'client' or 'multi_account'
|
||
let authFilePath = null;
|
||
let factoryApiKey = null; // From FACTORY_API_KEY environment variable
|
||
let multiAccountMode = false; // 是否启用多账号模式
|
||
let lastSelectedAccountId = null; // 记录最后选择的账号ID,用于结果回调
|
||
|
||
const REFRESH_URL = 'https://api.workos.com/user_management/authenticate';
|
||
const REFRESH_INTERVAL_HOURS = 6; // Refresh every 6 hours
|
||
const TOKEN_VALID_HOURS = 8; // Token valid for 8 hours
|
||
|
||
/**
|
||
* Generate a ULID (Universally Unique Lexicographically Sortable Identifier)
|
||
* Format: 26 characters using Crockford's Base32
|
||
* First 10 chars: timestamp (48 bits)
|
||
* Last 16 chars: random (80 bits)
|
||
*/
|
||
function generateULID() {
|
||
// Crockford's Base32 alphabet (no I, L, O, U to avoid confusion)
|
||
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||
|
||
// Get timestamp in milliseconds
|
||
const timestamp = Date.now();
|
||
|
||
// Encode timestamp to 10 characters
|
||
let time = '';
|
||
let ts = timestamp;
|
||
for (let i = 9; i >= 0; i--) {
|
||
const mod = ts % 32;
|
||
time = ENCODING[mod] + time;
|
||
ts = Math.floor(ts / 32);
|
||
}
|
||
|
||
// Generate 16 random characters
|
||
let randomPart = '';
|
||
for (let i = 0; i < 16; i++) {
|
||
const rand = Math.floor(Math.random() * 32);
|
||
randomPart += ENCODING[rand];
|
||
}
|
||
|
||
return time + randomPart;
|
||
}
|
||
|
||
/**
|
||
* Generate a client ID in format: client_01{ULID}
|
||
*/
|
||
function generateClientId() {
|
||
const ulid = generateULID();
|
||
return `client_01${ulid}`;
|
||
}
|
||
|
||
/**
|
||
* Load auth configuration with priority system
|
||
* Priority: accounts.json (multi-account) > FACTORY_API_KEY > refresh token mechanism > client authorization
|
||
*/
|
||
function loadAuthConfig() {
|
||
// 0. Check accounts.json for multi-account mode (highest priority)
|
||
const accountsPath = path.join(process.cwd(), 'accounts.json');
|
||
try {
|
||
if (fs.existsSync(accountsPath)) {
|
||
const accountsContent = fs.readFileSync(accountsPath, 'utf-8');
|
||
const accountsData = JSON.parse(accountsContent);
|
||
|
||
if (accountsData.accounts && accountsData.accounts.length > 0) {
|
||
const activeAccounts = accountsData.accounts.filter(acc => acc.status === 'active');
|
||
if (activeAccounts.length > 0) {
|
||
logInfo(`Found accounts.json with ${activeAccounts.length} active account(s), enabling multi-account mode`);
|
||
multiAccountMode = true;
|
||
authSource = 'multi_account';
|
||
return { type: 'multi_account', value: accountsPath };
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logError('Error reading accounts.json', error);
|
||
}
|
||
|
||
// 1. Check FACTORY_API_KEY environment variable
|
||
const factoryKey = process.env.FACTORY_API_KEY;
|
||
if (factoryKey && factoryKey.trim() !== '') {
|
||
logInfo('Using fixed API key from FACTORY_API_KEY environment variable');
|
||
factoryApiKey = factoryKey.trim();
|
||
authSource = 'factory_key';
|
||
return { type: 'factory_key', value: factoryKey.trim() };
|
||
}
|
||
|
||
// 2. Check refresh token mechanism (DROID_REFRESH_KEY)
|
||
const envRefreshKey = process.env.DROID_REFRESH_KEY;
|
||
if (envRefreshKey && envRefreshKey.trim() !== '') {
|
||
logInfo('Using refresh token from DROID_REFRESH_KEY environment variable');
|
||
authSource = 'env';
|
||
authFilePath = path.join(process.cwd(), 'auth.json');
|
||
return { type: 'refresh', value: envRefreshKey.trim() };
|
||
}
|
||
|
||
// 3. Check ~/.factory/auth.json
|
||
const homeDir = os.homedir();
|
||
const factoryAuthPath = path.join(homeDir, '.factory', 'auth.json');
|
||
|
||
try {
|
||
if (fs.existsSync(factoryAuthPath)) {
|
||
const authContent = fs.readFileSync(factoryAuthPath, 'utf-8');
|
||
const authData = JSON.parse(authContent);
|
||
|
||
if (authData.refresh_token && authData.refresh_token.trim() !== '') {
|
||
logInfo('Using refresh token from ~/.factory/auth.json');
|
||
authSource = 'file';
|
||
authFilePath = factoryAuthPath;
|
||
|
||
// Also load access_token if available
|
||
if (authData.access_token) {
|
||
currentApiKey = authData.access_token.trim();
|
||
}
|
||
|
||
return { type: 'refresh', value: authData.refresh_token.trim() };
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logError('Error reading ~/.factory/auth.json', error);
|
||
}
|
||
|
||
// 4. No configured auth found - will use client authorization
|
||
logInfo('No auth configuration found, will use client authorization headers');
|
||
authSource = 'client';
|
||
return { type: 'client', value: null };
|
||
}
|
||
|
||
/**
|
||
* Refresh API key using refresh token
|
||
*/
|
||
async function refreshApiKey() {
|
||
if (!currentRefreshToken) {
|
||
throw new Error('No refresh token available');
|
||
}
|
||
|
||
if (!clientId) {
|
||
clientId = 'client_01HNM792M5G5G1A2THWPXKFMXB';
|
||
logDebug(`Using fixed client ID: ${clientId}`);
|
||
}
|
||
|
||
logInfo('Refreshing API key...');
|
||
|
||
try {
|
||
// Create form data
|
||
const formData = new URLSearchParams();
|
||
formData.append('grant_type', 'refresh_token');
|
||
formData.append('refresh_token', currentRefreshToken);
|
||
formData.append('client_id', clientId);
|
||
|
||
const proxyAgentInfo = getNextProxyAgent(REFRESH_URL);
|
||
const fetchOptions = {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded'
|
||
},
|
||
body: formData.toString()
|
||
};
|
||
|
||
if (proxyAgentInfo?.agent) {
|
||
fetchOptions.agent = proxyAgentInfo.agent;
|
||
}
|
||
|
||
const response = await fetch(REFRESH_URL, fetchOptions);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
throw new Error(`Failed to refresh token: ${response.status} ${errorText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
// Update tokens
|
||
currentApiKey = data.access_token;
|
||
currentRefreshToken = data.refresh_token;
|
||
lastRefreshTime = Date.now();
|
||
|
||
// Log user info
|
||
if (data.user) {
|
||
logInfo(`Authenticated as: ${data.user.email} (${data.user.first_name} ${data.user.last_name})`);
|
||
logInfo(`User ID: ${data.user.id}`);
|
||
logInfo(`Organization ID: ${data.organization_id}`);
|
||
}
|
||
|
||
// Save tokens to file
|
||
saveTokens(data.access_token, data.refresh_token);
|
||
|
||
logInfo(`New Refresh-Key: ${currentRefreshToken}`);
|
||
logInfo('API key refreshed successfully');
|
||
return data.access_token;
|
||
|
||
} catch (error) {
|
||
logError('Failed to refresh API key', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Save tokens to appropriate file
|
||
*/
|
||
function saveTokens(accessToken, refreshToken) {
|
||
try {
|
||
const authData = {
|
||
access_token: accessToken,
|
||
refresh_token: refreshToken,
|
||
last_updated: new Date().toISOString()
|
||
};
|
||
|
||
// Ensure directory exists
|
||
const dir = path.dirname(authFilePath);
|
||
if (!fs.existsSync(dir)) {
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
}
|
||
|
||
// If saving to ~/.factory/auth.json, preserve other fields
|
||
if (authSource === 'file' && fs.existsSync(authFilePath)) {
|
||
try {
|
||
const existingData = JSON.parse(fs.readFileSync(authFilePath, 'utf-8'));
|
||
Object.assign(authData, existingData, {
|
||
access_token: accessToken,
|
||
refresh_token: refreshToken,
|
||
last_updated: authData.last_updated
|
||
});
|
||
} catch (error) {
|
||
logError('Error reading existing auth file, will overwrite', error);
|
||
}
|
||
}
|
||
|
||
fs.writeFileSync(authFilePath, JSON.stringify(authData, null, 2), 'utf-8');
|
||
logDebug(`Tokens saved to ${authFilePath}`);
|
||
|
||
} catch (error) {
|
||
logError('Failed to save tokens', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if API key needs refresh (older than 6 hours)
|
||
*/
|
||
function shouldRefresh() {
|
||
if (!lastRefreshTime) {
|
||
return true;
|
||
}
|
||
|
||
const hoursSinceRefresh = (Date.now() - lastRefreshTime) / (1000 * 60 * 60);
|
||
return hoursSinceRefresh >= REFRESH_INTERVAL_HOURS;
|
||
}
|
||
|
||
/**
|
||
* Initialize auth system - load auth config and setup initial API key if needed
|
||
*/
|
||
export async function initializeAuth() {
|
||
try {
|
||
const authConfig = loadAuthConfig();
|
||
|
||
if (authConfig.type === 'multi_account') {
|
||
// Multi-account mode - initialize AccountManager
|
||
const loaded = await initializeAccountManager();
|
||
if (loaded) {
|
||
const manager = getAccountManager();
|
||
logInfo(`Auth system initialized with multi-account mode (${manager.getAccountCount()} accounts)`);
|
||
} else {
|
||
logError('Failed to initialize AccountManager, falling back to client authorization');
|
||
authSource = 'client';
|
||
multiAccountMode = false;
|
||
}
|
||
} else if (authConfig.type === 'factory_key') {
|
||
// Using fixed FACTORY_API_KEY, no refresh needed
|
||
logInfo('Auth system initialized with fixed API key');
|
||
} else if (authConfig.type === 'refresh') {
|
||
// Using refresh token mechanism
|
||
currentRefreshToken = authConfig.value;
|
||
|
||
// Try to refresh on startup to get fresh token
|
||
try {
|
||
await refreshApiKey();
|
||
logInfo('Auth system initialized with refresh token mechanism');
|
||
} catch (refreshError) {
|
||
logError('Failed to refresh token on startup, falling back to client authorization', refreshError);
|
||
authSource = 'client';
|
||
logInfo('Auth system fallback to client authorization mode');
|
||
}
|
||
} else {
|
||
// Using client authorization, no setup needed
|
||
logInfo('Auth system initialized for client authorization mode');
|
||
}
|
||
|
||
logInfo('Auth system initialized successfully');
|
||
} catch (error) {
|
||
logError('Failed to initialize auth system', error);
|
||
// Don't throw error, allow server to start with client authorization
|
||
authSource = 'client';
|
||
logInfo('Auth system fallback to client authorization mode');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get API key based on configured authorization method
|
||
* @param {string} clientAuthorization - Authorization header from client request (optional)
|
||
*/
|
||
export async function getApiKey(clientAuthorization = null) {
|
||
// Priority 0: Multi-account mode
|
||
if (authSource === 'multi_account' && multiAccountMode) {
|
||
const manager = getAccountManager();
|
||
const account = manager.selectAccount();
|
||
lastSelectedAccountId = account.id;
|
||
|
||
const accessToken = await manager.getAccessToken(account);
|
||
return `Bearer ${accessToken}`;
|
||
}
|
||
|
||
// Priority 1: FACTORY_API_KEY environment variable
|
||
if (authSource === 'factory_key' && factoryApiKey) {
|
||
return `Bearer ${factoryApiKey}`;
|
||
}
|
||
|
||
// Priority 2: Refresh token mechanism
|
||
if (authSource === 'env' || authSource === 'file') {
|
||
// Check if we need to refresh
|
||
if (shouldRefresh()) {
|
||
logInfo('API key needs refresh (6+ hours old)');
|
||
await refreshApiKey();
|
||
}
|
||
|
||
if (!currentApiKey) {
|
||
throw new Error('No API key available from refresh token mechanism.');
|
||
}
|
||
|
||
return `Bearer ${currentApiKey}`;
|
||
}
|
||
|
||
// Priority 3: Client authorization header
|
||
if (clientAuthorization) {
|
||
logDebug('Using client authorization header');
|
||
return clientAuthorization;
|
||
}
|
||
|
||
// No authorization available
|
||
throw new Error('No authorization available. Please configure accounts.json, FACTORY_API_KEY, refresh token, or provide client authorization.');
|
||
}
|
||
|
||
/**
|
||
* Record authentication result for multi-account mode
|
||
* @param {string} endpoint - The API endpoint called
|
||
* @param {boolean} success - Whether the request was successful
|
||
* @param {number} statusCode - HTTP status code
|
||
*/
|
||
export function recordAuthResult(endpoint, success, statusCode) {
|
||
if (authSource === 'multi_account' && multiAccountMode && lastSelectedAccountId) {
|
||
const manager = getAccountManager();
|
||
manager.recordResult(lastSelectedAccountId, endpoint, success, statusCode);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get auth status for /status endpoint
|
||
*/
|
||
export function getAuthStatus() {
|
||
if (authSource === 'multi_account' && multiAccountMode) {
|
||
const manager = getAccountManager();
|
||
return {
|
||
mode: 'multi_account',
|
||
...manager.getStatus()
|
||
};
|
||
}
|
||
|
||
return {
|
||
mode: authSource,
|
||
multiAccountMode: false
|
||
};
|
||
}
|
||
|