| import fs from 'fs'; |
| import path from 'path'; |
| import os from 'os'; |
| import fetch from 'node-fetch'; |
| import { logDebug, logError, logInfo } from './logger.js'; |
|
|
|
|
| let currentApiKey = null; |
| let currentRefreshToken = null; |
| let lastRefreshTime = null; |
| let clientId = null; |
| let authSource = null; |
| let authFilePath = null; |
| let factoryApiKey = null; |
| let factoryApiKeys = []; |
| let factoryKeyIndex = 0; |
|
|
| |
| let accessKeys = null; |
|
|
| const REFRESH_URL = 'https://api.workos.com/user_management/authenticate';
|
| const REFRESH_INTERVAL_HOURS = 6;
|
| const TOKEN_VALID_HOURS = 8;
|
|
|
| |
| |
| |
| |
| |
|
|
| function generateULID() {
|
|
|
| const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
|
|
|
| const timestamp = Date.now();
|
|
|
|
|
| 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);
|
| }
|
|
|
|
|
| let randomPart = '';
|
| for (let i = 0; i < 16; i++) {
|
| const rand = Math.floor(Math.random() * 32);
|
| randomPart += ENCODING[rand];
|
| }
|
|
|
| return time + randomPart;
|
| }
|
|
|
| |
| |
|
|
| function generateClientId() {
|
| const ulid = generateULID();
|
| return `client_01${ulid}`;
|
| }
|
|
|
| |
| |
| |
|
|
| function loadAuthConfig() { |
|
|
| 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 }; |
| } |
|
|
|
|
| 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() };
|
| }
|
|
|
|
|
| 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;
|
|
|
|
|
| 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);
|
| }
|
|
|
|
|
| logInfo('No auth configuration found, will use client authorization headers');
|
| authSource = 'client';
|
| return { type: 'client', value: null };
|
| } |
|
|
| |
| |
|
|
| 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 {
|
|
|
| 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();
|
|
|
|
|
| currentApiKey = data.access_token;
|
| currentRefreshToken = data.refresh_token;
|
| lastRefreshTime = Date.now();
|
|
|
|
|
| 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}`);
|
| }
|
|
|
|
|
| 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;
|
| }
|
| }
|
|
|
| |
| |
|
|
| function saveTokens(accessToken, refreshToken) {
|
| try {
|
| const authData = {
|
| access_token: accessToken,
|
| refresh_token: refreshToken,
|
| last_updated: new Date().toISOString()
|
| };
|
|
|
|
|
| const dir = path.dirname(authFilePath);
|
| if (!fs.existsSync(dir)) {
|
| fs.mkdirSync(dir, { recursive: true });
|
| }
|
|
|
|
|
| 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);
|
| }
|
| }
|
|
|
| |
| |
|
|
| function shouldRefresh() {
|
| if (!lastRefreshTime) {
|
| return true;
|
| }
|
|
|
| const hoursSinceRefresh = (Date.now() - lastRefreshTime) / (1000 * 60 * 60);
|
| return hoursSinceRefresh >= REFRESH_INTERVAL_HOURS;
|
| }
|
|
|
| |
| |
|
|
| export async function initializeAuth() { |
| try { |
| const authConfig = loadAuthConfig(); |
|
|
| if (authConfig.type === 'factory_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') {
|
|
|
| currentRefreshToken = authConfig.value;
|
|
|
|
|
| await refreshApiKey();
|
| logInfo('Auth system initialized with refresh token mechanism');
|
| } else {
|
|
|
| logInfo('Auth system initialized for client authorization mode');
|
| }
|
|
|
| logInfo('Auth system initialized successfully'); |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
|
|
| export async function getApiKey(clientAuthorization = null) { |
|
|
| if (authSource === 'factory_key' && (factoryApiKey || factoryApiKeys.length > 0)) { |
| |
| if (factoryApiKeys.length > 0) { |
| const key = factoryApiKeys[factoryKeyIndex % factoryApiKeys.length]; |
| factoryKeyIndex = (factoryKeyIndex + 1) % factoryApiKeys.length; |
| return `Bearer ${key}`; |
| } |
| return `Bearer ${factoryApiKey}`; |
| } |
|
|
|
|
| if (authSource === 'env' || authSource === 'file') {
|
|
|
| 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}`;
|
| }
|
|
|
|
|
| if (clientAuthorization) {
|
| logDebug('Using client authorization header');
|
| return clientAuthorization;
|
| }
|
|
|
|
|
| throw new Error('No authorization available. Please configure FACTORY_API_KEY, refresh token, or provide client authorization.'); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| 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(); |
| } |
|
|