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