| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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; |
| const WS_TOKEN_MAX_AGE_MS = 5 * 60 * 1000; |
|
|
| |
| |
| |
| function isEnvTrue(envVar: string | undefined): boolean { |
| return envVar === 'true'; |
| } |
|
|
| |
| const validSessions = new Map<string, { createdAt: number; expiresAt: number }>(); |
|
|
| |
| const wsConnectionTokens = new Map<string, { createdAt: number; expiresAt: number }>(); |
|
|
| |
| setInterval(() => { |
| const now = Date.now(); |
| wsConnectionTokens.forEach((data, token) => { |
| if (data.expiresAt <= now) { |
| wsConnectionTokens.delete(token); |
| } |
| }); |
| }, 60 * 1000); |
|
|
| |
| |
| |
| 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) { |
| |
| 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); |
| } |
| } |
|
|
| |
| |
| |
| 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); |
| } |
| } |
|
|
| |
| loadSessions(); |
|
|
| |
| |
| |
| |
| function ensureApiKey(): string { |
| |
| if (process.env.AUTOMAKER_API_KEY) { |
| logger.info('Using API key from environment variable'); |
| return process.env.AUTOMAKER_API_KEY; |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| const API_KEY = ensureApiKey(); |
|
|
| |
| const BOX_CONTENT_WIDTH = 67; |
|
|
| |
| if (!isEnvTrue(process.env.AUTOMAKER_HIDE_API_KEY)) { |
| const autoLoginEnabled = isEnvTrue(process.env.AUTOMAKER_AUTO_LOGIN); |
| const autoLoginStatus = autoLoginEnabled ? 'enabled (auto-login active)' : 'disabled'; |
|
|
| |
| 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)'); |
| } |
|
|
| |
| |
| |
| function generateSessionToken(): string { |
| return crypto.randomBytes(32).toString('hex'); |
| } |
|
|
| |
| |
| |
| 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(); |
| return token; |
| } |
|
|
| |
| |
| |
| |
| export function validateSession(token: string): boolean { |
| const session = validSessions.get(token); |
| if (!session) return false; |
|
|
| if (Date.now() > session.expiresAt) { |
| validSessions.delete(token); |
| |
| saveSessions().catch((err) => logger.error('Error saving sessions:', err)); |
| return false; |
| } |
|
|
| return true; |
| } |
|
|
| |
| |
| |
| export async function invalidateSession(token: string): Promise<void> { |
| validSessions.delete(token); |
| await saveSessions(); |
| } |
|
|
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| export function validateWsConnectionToken(token: string): boolean { |
| const tokenData = wsConnectionTokens.get(token); |
| if (!tokenData) return false; |
|
|
| |
| wsConnectionTokens.delete(token); |
|
|
| |
| if (Date.now() > tokenData.expiresAt) { |
| return false; |
| } |
|
|
| return true; |
| } |
|
|
| |
| |
| |
| |
| export function validateApiKey(key: string): boolean { |
| if (!key || typeof key !== 'string') return false; |
|
|
| |
| const keyBuffer = Buffer.from(key); |
| const apiKeyBuffer = Buffer.from(API_KEY); |
|
|
| |
| if (keyBuffer.length !== apiKeyBuffer.length) { |
| crypto.timingSafeEqual(apiKeyBuffer, apiKeyBuffer); |
| return false; |
| } |
|
|
| return crypto.timingSafeEqual(keyBuffer, apiKeyBuffer); |
| } |
|
|
| |
| |
| |
| export function getSessionCookieOptions(): { |
| httpOnly: boolean; |
| secure: boolean; |
| sameSite: 'strict' | 'lax' | 'none'; |
| maxAge: number; |
| path: string; |
| } { |
| return { |
| httpOnly: true, |
| secure: process.env.NODE_ENV === 'production', |
| sameSite: 'lax', |
| maxAge: SESSION_MAX_AGE_MS, |
| path: '/', |
| }; |
| } |
|
|
| |
| |
| |
| export function getSessionCookieName(): string { |
| return SESSION_COOKIE_NAME; |
| } |
|
|
| |
| |
| |
| type AuthResult = |
| | { authenticated: true } |
| | { authenticated: false; errorType: 'invalid_api_key' | 'invalid_session' | 'no_auth' }; |
|
|
| |
| |
| |
| |
| function checkAuthentication( |
| headers: Record<string, string | string[] | undefined>, |
| query: Record<string, string | undefined>, |
| cookies: Record<string, string | undefined> |
| ): AuthResult { |
| |
| const headerKey = headers['x-api-key'] as string | undefined; |
| if (headerKey) { |
| if (validateApiKey(headerKey)) { |
| return { authenticated: true }; |
| } |
| return { authenticated: false, errorType: 'invalid_api_key' }; |
| } |
|
|
| |
| const sessionTokenHeader = headers['x-session-token'] as string | undefined; |
| if (sessionTokenHeader) { |
| if (validateSession(sessionTokenHeader)) { |
| return { authenticated: true }; |
| } |
| return { authenticated: false, errorType: 'invalid_session' }; |
| } |
|
|
| |
| const queryKey = query.apiKey; |
| if (queryKey) { |
| if (validateApiKey(queryKey)) { |
| return { authenticated: true }; |
| } |
| return { authenticated: false, errorType: 'invalid_api_key' }; |
| } |
|
|
| |
| const queryToken = query.token; |
| if (queryToken) { |
| if (validateSession(queryToken)) { |
| return { authenticated: true }; |
| } |
| return { authenticated: false, errorType: 'invalid_session' }; |
| } |
|
|
| |
| const sessionToken = cookies[SESSION_COOKIE_NAME]; |
| if (sessionToken && validateSession(sessionToken)) { |
| return { authenticated: true }; |
| } |
|
|
| return { authenticated: false, errorType: 'no_auth' }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function authMiddleware(req: Request, res: Response, next: NextFunction): void { |
| |
| 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; |
| } |
|
|
| |
| 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.', |
| }); |
| } |
| } |
|
|
| |
| |
| |
| export function isAuthEnabled(): boolean { |
| return true; |
| } |
|
|
| |
| |
| |
| 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', |
| }; |
| } |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| 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; |
| } |
|
|