Spaces:
Paused
Paused
| /** | |
| * Authentication middleware for API security | |
| * | |
| * Supports two authentication methods: | |
| * 1. Header-based (X-API-Key) - Used by Electron mode | |
| * 2. Cookie-based (HTTP-only session cookie) - Used by web mode | |
| * | |
| * Auto-generates an API key on first run if none is configured. | |
| */ | |
| import type { Request, Response, NextFunction } from 'express'; | |
| import crypto from 'crypto'; | |
| import path from 'path'; | |
| import * as secureFs from './secure-fs.js'; | |
| import { createLogger } from '@automaker/utils'; | |
| const logger = createLogger('Auth'); | |
| const DATA_DIR = process.env.DATA_DIR || './data'; | |
| const API_KEY_FILE = path.join(DATA_DIR, '.api-key'); | |
| const SESSIONS_FILE = path.join(DATA_DIR, '.sessions'); | |
| const SESSION_COOKIE_NAME = 'automaker_session'; | |
| const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days | |
| const WS_TOKEN_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for WebSocket connection tokens | |
| /** | |
| * Check if an environment variable is set to 'true' | |
| */ | |
| function isEnvTrue(envVar: string | undefined): boolean { | |
| return envVar === 'true'; | |
| } | |
| // Session store - persisted to file for survival across server restarts | |
| const validSessions = new Map<string, { createdAt: number; expiresAt: number }>(); | |
| // Short-lived WebSocket connection tokens (in-memory only, not persisted) | |
| const wsConnectionTokens = new Map<string, { createdAt: number; expiresAt: number }>(); | |
| // Clean up expired WebSocket tokens periodically | |
| setInterval(() => { | |
| const now = Date.now(); | |
| wsConnectionTokens.forEach((data, token) => { | |
| if (data.expiresAt <= now) { | |
| wsConnectionTokens.delete(token); | |
| } | |
| }); | |
| }, 60 * 1000); // Clean up every minute | |
| /** | |
| * Load sessions from file on startup | |
| */ | |
| function loadSessions(): void { | |
| try { | |
| if (secureFs.existsSync(SESSIONS_FILE)) { | |
| const data = secureFs.readFileSync(SESSIONS_FILE, 'utf-8') as string; | |
| const sessions = JSON.parse(data) as Array< | |
| [string, { createdAt: number; expiresAt: number }] | |
| >; | |
| const now = Date.now(); | |
| let loadedCount = 0; | |
| let expiredCount = 0; | |
| for (const [token, session] of sessions) { | |
| // Only load non-expired sessions | |
| if (session.expiresAt > now) { | |
| validSessions.set(token, session); | |
| loadedCount++; | |
| } else { | |
| expiredCount++; | |
| } | |
| } | |
| if (loadedCount > 0 || expiredCount > 0) { | |
| logger.info(`Loaded ${loadedCount} sessions (${expiredCount} expired)`); | |
| } | |
| } | |
| } catch (error) { | |
| logger.warn('Error loading sessions:', error); | |
| } | |
| } | |
| /** | |
| * Save sessions to file (async) | |
| */ | |
| async function saveSessions(): Promise<void> { | |
| try { | |
| await secureFs.mkdir(path.dirname(SESSIONS_FILE), { recursive: true }); | |
| const sessions = Array.from(validSessions.entries()); | |
| await secureFs.writeFile(SESSIONS_FILE, JSON.stringify(sessions), { | |
| encoding: 'utf-8', | |
| mode: 0o600, | |
| }); | |
| } catch (error) { | |
| logger.error('Failed to save sessions:', error); | |
| } | |
| } | |
| // Load existing sessions on startup | |
| loadSessions(); | |
| /** | |
| * Ensure an API key exists - either from env var, file, or generate new one. | |
| * This provides CSRF protection by requiring a secret key for all API requests. | |
| */ | |
| function ensureApiKey(): string { | |
| // First check environment variable (Electron passes it this way) | |
| if (process.env.AUTOMAKER_API_KEY) { | |
| logger.info('Using API key from environment variable'); | |
| return process.env.AUTOMAKER_API_KEY; | |
| } | |
| // Try to read from file | |
| try { | |
| if (secureFs.existsSync(API_KEY_FILE)) { | |
| const key = (secureFs.readFileSync(API_KEY_FILE, 'utf-8') as string).trim(); | |
| if (key) { | |
| logger.info('Loaded API key from file'); | |
| return key; | |
| } | |
| } | |
| } catch (error) { | |
| logger.warn('Error reading API key file:', error); | |
| } | |
| // Generate new key | |
| const newKey = crypto.randomUUID(); | |
| try { | |
| secureFs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true }); | |
| secureFs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 }); | |
| logger.info('Generated new API key'); | |
| } catch (error) { | |
| logger.error('Failed to save API key:', error); | |
| } | |
| return newKey; | |
| } | |
| // API key - always generated/loaded on startup for CSRF protection | |
| const API_KEY = ensureApiKey(); | |
| // Width for log box content (excluding borders) | |
| const BOX_CONTENT_WIDTH = 67; | |
| // Print API key to console for web mode users (unless suppressed for production logging) | |
| if (!isEnvTrue(process.env.AUTOMAKER_HIDE_API_KEY)) { | |
| const autoLoginEnabled = isEnvTrue(process.env.AUTOMAKER_AUTO_LOGIN); | |
| const autoLoginStatus = autoLoginEnabled ? 'enabled (auto-login active)' : 'disabled'; | |
| // Build box lines with exact padding | |
| const header = '🔐 API Key for Web Mode Authentication'.padEnd(BOX_CONTENT_WIDTH); | |
| const line1 = "When accessing via browser, you'll be prompted to enter this key:".padEnd( | |
| BOX_CONTENT_WIDTH | |
| ); | |
| const line2 = API_KEY.padEnd(BOX_CONTENT_WIDTH); | |
| const line3 = 'In Electron mode, authentication is handled automatically.'.padEnd( | |
| BOX_CONTENT_WIDTH | |
| ); | |
| const line4 = `Auto-login (AUTOMAKER_AUTO_LOGIN): ${autoLoginStatus}`.padEnd(BOX_CONTENT_WIDTH); | |
| const tipHeader = '💡 Tips'.padEnd(BOX_CONTENT_WIDTH); | |
| const line5 = 'Set AUTOMAKER_API_KEY env var to use a fixed key'.padEnd(BOX_CONTENT_WIDTH); | |
| const line6 = 'Set AUTOMAKER_AUTO_LOGIN=true to skip the login prompt'.padEnd(BOX_CONTENT_WIDTH); | |
| logger.info(` | |
| ╔═════════════════════════════════════════════════════════════════════╗ | |
| ║ ${header}║ | |
| ╠═════════════════════════════════════════════════════════════════════╣ | |
| ║ ║ | |
| ║ ${line1}║ | |
| ║ ║ | |
| ║ ${line2}║ | |
| ║ ║ | |
| ║ ${line3}║ | |
| ║ ║ | |
| ║ ${line4}║ | |
| ║ ║ | |
| ╠═════════════════════════════════════════════════════════════════════╣ | |
| ║ ${tipHeader}║ | |
| ╠═════════════════════════════════════════════════════════════════════╣ | |
| ║ ${line5}║ | |
| ║ ${line6}║ | |
| ╚═════════════════════════════════════════════════════════════════════╝ | |
| `); | |
| } else { | |
| logger.info('API key banner hidden (AUTOMAKER_HIDE_API_KEY=true)'); | |
| } | |
| /** | |
| * Generate a cryptographically secure session token | |
| */ | |
| function generateSessionToken(): string { | |
| return crypto.randomBytes(32).toString('hex'); | |
| } | |
| /** | |
| * Create a new session and return the token | |
| */ | |
| export async function createSession(): Promise<string> { | |
| const token = generateSessionToken(); | |
| const now = Date.now(); | |
| validSessions.set(token, { | |
| createdAt: now, | |
| expiresAt: now + SESSION_MAX_AGE_MS, | |
| }); | |
| await saveSessions(); // Persist to file | |
| return token; | |
| } | |
| /** | |
| * Validate a session token | |
| * Note: This returns synchronously but triggers async persistence if session expired | |
| */ | |
| export function validateSession(token: string): boolean { | |
| const session = validSessions.get(token); | |
| if (!session) return false; | |
| if (Date.now() > session.expiresAt) { | |
| validSessions.delete(token); | |
| // Fire-and-forget: persist removal asynchronously | |
| saveSessions().catch((err) => logger.error('Error saving sessions:', err)); | |
| return false; | |
| } | |
| return true; | |
| } | |
| /** | |
| * Invalidate a session token | |
| */ | |
| export async function invalidateSession(token: string): Promise<void> { | |
| validSessions.delete(token); | |
| await saveSessions(); // Persist removal | |
| } | |
| /** | |
| * Create a short-lived WebSocket connection token | |
| * Used for initial WebSocket handshake authentication | |
| */ | |
| export function createWsConnectionToken(): string { | |
| const token = generateSessionToken(); | |
| const now = Date.now(); | |
| wsConnectionTokens.set(token, { | |
| createdAt: now, | |
| expiresAt: now + WS_TOKEN_MAX_AGE_MS, | |
| }); | |
| return token; | |
| } | |
| /** | |
| * Validate a WebSocket connection token | |
| * These tokens are single-use and short-lived (5 minutes) | |
| * Token is invalidated immediately after first successful use | |
| */ | |
| export function validateWsConnectionToken(token: string): boolean { | |
| const tokenData = wsConnectionTokens.get(token); | |
| if (!tokenData) return false; | |
| // Always delete the token (single-use) | |
| wsConnectionTokens.delete(token); | |
| // Check if expired | |
| if (Date.now() > tokenData.expiresAt) { | |
| return false; | |
| } | |
| return true; | |
| } | |
| /** | |
| * Validate the API key using timing-safe comparison | |
| * Prevents timing attacks that could leak information about the key | |
| */ | |
| export function validateApiKey(key: string): boolean { | |
| if (!key || typeof key !== 'string') return false; | |
| // Both buffers must be the same length for timingSafeEqual | |
| const keyBuffer = Buffer.from(key); | |
| const apiKeyBuffer = Buffer.from(API_KEY); | |
| // If lengths differ, compare against a dummy to maintain constant time | |
| if (keyBuffer.length !== apiKeyBuffer.length) { | |
| crypto.timingSafeEqual(apiKeyBuffer, apiKeyBuffer); | |
| return false; | |
| } | |
| return crypto.timingSafeEqual(keyBuffer, apiKeyBuffer); | |
| } | |
| /** | |
| * Get session cookie options | |
| */ | |
| export function getSessionCookieOptions(): { | |
| httpOnly: boolean; | |
| secure: boolean; | |
| sameSite: 'strict' | 'lax' | 'none'; | |
| maxAge: number; | |
| path: string; | |
| } { | |
| return { | |
| httpOnly: true, // JavaScript cannot access this cookie | |
| secure: process.env.NODE_ENV === 'production', // HTTPS only in production | |
| sameSite: 'lax', // Sent for same-site requests and top-level navigations, but not cross-origin fetch/XHR | |
| maxAge: SESSION_MAX_AGE_MS, | |
| path: '/', | |
| }; | |
| } | |
| /** | |
| * Get the session cookie name | |
| */ | |
| export function getSessionCookieName(): string { | |
| return SESSION_COOKIE_NAME; | |
| } | |
| /** | |
| * Authentication result type | |
| */ | |
| type AuthResult = | |
| | { authenticated: true } | |
| | { authenticated: false; errorType: 'invalid_api_key' | 'invalid_session' | 'no_auth' }; | |
| /** | |
| * Core authentication check - shared between middleware and status check | |
| * Extracts auth credentials from various sources and validates them | |
| */ | |
| function checkAuthentication( | |
| headers: Record<string, string | string[] | undefined>, | |
| query: Record<string, string | undefined>, | |
| cookies: Record<string, string | undefined> | |
| ): AuthResult { | |
| // Check for API key in header (Electron mode) | |
| const headerKey = headers['x-api-key'] as string | undefined; | |
| if (headerKey) { | |
| if (validateApiKey(headerKey)) { | |
| return { authenticated: true }; | |
| } | |
| return { authenticated: false, errorType: 'invalid_api_key' }; | |
| } | |
| // Check for session token in header (web mode with explicit token) | |
| const sessionTokenHeader = headers['x-session-token'] as string | undefined; | |
| if (sessionTokenHeader) { | |
| if (validateSession(sessionTokenHeader)) { | |
| return { authenticated: true }; | |
| } | |
| return { authenticated: false, errorType: 'invalid_session' }; | |
| } | |
| // Check for API key in query parameter (fallback) | |
| const queryKey = query.apiKey; | |
| if (queryKey) { | |
| if (validateApiKey(queryKey)) { | |
| return { authenticated: true }; | |
| } | |
| return { authenticated: false, errorType: 'invalid_api_key' }; | |
| } | |
| // Check for session token in query parameter (web mode - needed for image loads) | |
| const queryToken = query.token; | |
| if (queryToken) { | |
| if (validateSession(queryToken)) { | |
| return { authenticated: true }; | |
| } | |
| return { authenticated: false, errorType: 'invalid_session' }; | |
| } | |
| // Check for session cookie (web mode) | |
| const sessionToken = cookies[SESSION_COOKIE_NAME]; | |
| if (sessionToken && validateSession(sessionToken)) { | |
| return { authenticated: true }; | |
| } | |
| return { authenticated: false, errorType: 'no_auth' }; | |
| } | |
| /** | |
| * Authentication middleware | |
| * | |
| * Accepts either: | |
| * 1. X-API-Key header (for Electron mode) | |
| * 2. X-Session-Token header (for web mode with explicit token) | |
| * 3. apiKey query parameter (fallback for Electron, cases where headers can't be set) | |
| * 4. token query parameter (fallback for web mode, needed for image loads via CSS/img tags) | |
| * 5. Session cookie (for web mode) | |
| */ | |
| export function authMiddleware(req: Request, res: Response, next: NextFunction): void { | |
| // Allow disabling auth for local/trusted networks | |
| if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) { | |
| next(); | |
| return; | |
| } | |
| const result = checkAuthentication( | |
| req.headers as Record<string, string | string[] | undefined>, | |
| req.query as Record<string, string | undefined>, | |
| (req.cookies || {}) as Record<string, string | undefined> | |
| ); | |
| if (result.authenticated) { | |
| next(); | |
| return; | |
| } | |
| // Return appropriate error based on what failed | |
| switch (result.errorType) { | |
| case 'invalid_api_key': | |
| res.status(403).json({ | |
| success: false, | |
| error: 'Invalid API key.', | |
| }); | |
| break; | |
| case 'invalid_session': | |
| res.status(403).json({ | |
| success: false, | |
| error: 'Invalid or expired session token.', | |
| }); | |
| break; | |
| case 'no_auth': | |
| default: | |
| res.status(401).json({ | |
| success: false, | |
| error: 'Authentication required.', | |
| }); | |
| } | |
| } | |
| /** | |
| * Check if authentication is enabled (always true now) | |
| */ | |
| export function isAuthEnabled(): boolean { | |
| return true; | |
| } | |
| /** | |
| * Get authentication status for health endpoint | |
| */ | |
| export function getAuthStatus(): { enabled: boolean; method: string } { | |
| const disabled = isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH); | |
| return { | |
| enabled: !disabled, | |
| method: disabled ? 'disabled' : 'api_key_or_session', | |
| }; | |
| } | |
| /** | |
| * Check if a request is authenticated (for status endpoint) | |
| */ | |
| export function isRequestAuthenticated(req: Request): boolean { | |
| if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true; | |
| const result = checkAuthentication( | |
| req.headers as Record<string, string | string[] | undefined>, | |
| req.query as Record<string, string | undefined>, | |
| (req.cookies || {}) as Record<string, string | undefined> | |
| ); | |
| return result.authenticated; | |
| } | |
| /** | |
| * Check if raw credentials are authenticated | |
| * Used for WebSocket authentication where we don't have Express request objects | |
| */ | |
| export function checkRawAuthentication( | |
| headers: Record<string, string | string[] | undefined>, | |
| query: Record<string, string | undefined>, | |
| cookies: Record<string, string | undefined> | |
| ): boolean { | |
| if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true; | |
| return checkAuthentication(headers, query, cookies).authenticated; | |
| } | |