dr / auth.js
hequ's picture
Upload 15 files
afb7d7b verified
import fs from 'fs';
import path from 'path';
import os from 'os';
import fetch from 'node-fetch';
import { logDebug, logError, logInfo } from './logger.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'
let authFilePath = null;
let factoryApiKey = null; // 单密钥(兼容旧行为)
let factoryApiKeys = []; // 多密钥轮询列表
let factoryKeyIndex = 0; // 轮询指针
// 本服务对外提供的 API Key 访问控制(用于保护 /v1/* 入口)
let accessKeys = null; // Set<string> 或 null(未启用鉴权)
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: FACTORY_API_KEY > refresh token mechanism > client authorization
*/
function loadAuthConfig() {
// 1. Check FACTORY_API_KEY environment variable (highest priority)
const factoryKey = process.env.FACTORY_API_KEY;
if (factoryKey && factoryKey.trim() !== '') {
// 支持多密钥:逗号、分号、空白分隔
factoryApiKeys = factoryKey
.split(/[\s,;]+/)
.map(k => k.trim())
.filter(Boolean);
if (factoryApiKeys.length > 1) {
logInfo(`Using FACTORY_API_KEY rotation with ${factoryApiKeys.length} keys`);
} else {
logInfo('Using fixed API key from FACTORY_API_KEY environment variable');
}
factoryApiKey = factoryApiKeys[0] || factoryKey.trim();
factoryKeyIndex = 0;
authSource = 'factory_key';
return { type: 'factory_key', value: factoryApiKey };
}
// 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 response = await fetch(REFRESH_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData.toString()
});
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 === 'factory_key') {
// FACTORY_API_KEY 模式:固定或轮询
if (factoryApiKeys.length > 1) {
logInfo(`Auth initialized: FACTORY_API_KEY rotation (${factoryApiKeys.length} keys)`);
} else {
logInfo('Auth system initialized with fixed API key');
}
} else if (authConfig.type === 'refresh') {
// Using refresh token mechanism
currentRefreshToken = authConfig.value;
// Always refresh on startup to get fresh token
await refreshApiKey();
logInfo('Auth system initialized with refresh token mechanism');
} else {
// Using client authorization, no setup needed
logInfo('Auth system initialized for client authorization mode');
}
logInfo('Auth system initialized successfully');
// 载入对外访问的 API Key 列表(用于入站鉴权)
loadAccessKeysFromEnv();
if (accessKeys && accessKeys.size > 0) {
logInfo(`Inbound API Key enforcement enabled (${accessKeys.size} key(s))`);
} else {
logInfo('Inbound API Key not configured; API is publicly accessible');
}
} catch (error) {
logError('Failed to initialize auth system', error);
throw error;
}
}
/**
* Get API key based on configured authorization method
* @param {string} clientAuthorization - Authorization header from client request (optional)
*/
export async function getApiKey(clientAuthorization = null) {
// Priority 1: FACTORY_API_KEY environment variable
if (authSource === 'factory_key' && (factoryApiKey || factoryApiKeys.length > 0)) {
// 轮询选择密钥(若仅1个则等价于固定密钥)
if (factoryApiKeys.length > 0) {
const key = factoryApiKeys[factoryKeyIndex % factoryApiKeys.length];
factoryKeyIndex = (factoryKeyIndex + 1) % factoryApiKeys.length;
return `Bearer ${key}`;
}
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 FACTORY_API_KEY, refresh token, or provide client authorization.');
}
/**
* 从环境变量加载对外访问的 API Key 列表
* 支持:
* - ACCESS_KEYS: 多个密钥,逗号/分号/空白分隔
* - ACCESS_KEY: 单个密钥
* 默认:未配置则不启用鉴权(保持向后兼容);在公开部署时建议务必配置。
*/
function loadAccessKeysFromEnv() {
const multi = process.env.ACCESS_KEYS;
const single = process.env.ACCESS_KEY;
let keys = [];
if (multi && multi.trim() !== '') {
keys = multi.split(/[\s,;]+/).map(k => k.trim()).filter(Boolean);
} else if (single && single.trim() !== '') {
keys = [single.trim()];
}
accessKeys = keys.length > 0 ? new Set(keys) : null;
}
/**
* 提取客户端传入的访问密钥
* 支持:
* - 请求头 X-API-Key: <key>
* - Authorization: Bearer <key> | Api-Key <key> | Key <key>
* - 查询参数 api_key=<key>(可选,尽量用请求头)
*/
function extractClientAccessKey(req) {
const hdrKey = req.headers['x-api-key'] || req.headers['x-api_key'];
if (typeof hdrKey === 'string' && hdrKey.trim() !== '') {
return hdrKey.trim();
}
const auth = req.headers['authorization'];
if (typeof auth === 'string' && auth.trim() !== '') {
const m = auth.match(/^(Bearer|Api-Key|Key)\s+(.+)$/i);
if (m && m[2]) return m[2].trim();
}
if (req.query && typeof req.query.api_key === 'string' && req.query.api_key.trim() !== '') {
return req.query.api_key.trim();
}
return null;
}
/**
* 入站 API Key 鉴权中间件(保护 /v1/*)
* - 如未配置 ACCESS_KEYS/ACCESS_KEY,则放行且记录提醒日志
* - 如已配置,则要求客户端提供有效 Key,否则 401
*/
export function accessKeyMiddleware(req, res, next) {
// 仅在配置了密钥时启用
if (!accessKeys || accessKeys.size === 0) {
return next();
}
const key = extractClientAccessKey(req);
if (!key) {
res.setHeader('WWW-Authenticate', 'Bearer realm="droid2api"');
return res.status(401).json({
error: 'unauthorized',
message: 'Missing API key. Provide X-API-Key or Authorization: Bearer.'
});
}
if (!accessKeys.has(key)) {
return res.status(401).json({
error: 'unauthorized',
message: 'Invalid API key'
});
}
// 通过校验
return next();
}