/** * ╔═══════════════════════════════════════════════════════════════════════════╗ * ║ JWT AUTHENTICATION MIDDLEWARE ║ * ╠═══════════════════════════════════════════════════════════════════════════╣ * ║ Security Gate for WidgeTDC API ║ * ║ • JWT token validation ║ * ║ • Role-based access control ║ * ║ • API key fallback for service-to-service communication ║ * ╚═══════════════════════════════════════════════════════════════════════════╝ */ import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; // ═══════════════════════════════════════════════════════════════════════════ // Types // ═══════════════════════════════════════════════════════════════════════════ export interface JWTPayload { sub: string; // User ID email?: string; name?: string; roles: string[]; // ['admin', 'user', 'agent', 'service'] iat: number; // Issued at exp: number; // Expiration } export interface AuthenticatedRequest extends Request { user?: JWTPayload; authMethod?: 'jwt' | 'api-key' | 'bypass'; } // ═══════════════════════════════════════════════════════════════════════════ // Configuration // ═══════════════════════════════════════════════════════════════════════════ const JWT_SECRET = process.env.JWT_SECRET || 'widgetdc-dev-secret-change-in-production'; const API_KEY = process.env.API_KEY || 'widgetdc-dev-api-key'; const AUTH_ENABLED = process.env.AUTH_ENABLED !== 'false'; // Default: enabled const DEV_BYPASS = process.env.NODE_ENV === 'development' && process.env.AUTH_BYPASS === 'true'; // Routes that don't require authentication const PUBLIC_ROUTES = [ '/health', '/api/health', '/api/mcp/route', // MCP uses its own auth '/api/mcp/ws', // WebSocket has its own handshake ]; // ═══════════════════════════════════════════════════════════════════════════ // Middleware Functions // ═══════════════════════════════════════════════════════════════════════════ /** * Main authentication middleware * Checks JWT token or API key */ export function authenticate(req: AuthenticatedRequest, res: Response, next: NextFunction): void { // Skip auth in dev bypass mode if (DEV_BYPASS) { req.user = { sub: 'dev-user', email: 'dev@widgetdc.local', name: 'Development User', roles: ['admin', 'user', 'agent', 'service'], iat: Date.now() / 1000, exp: Date.now() / 1000 + 86400 }; req.authMethod = 'bypass'; return next(); } // Skip auth for public routes if (PUBLIC_ROUTES.some(route => req.path.startsWith(route))) { return next(); } // Skip if auth is disabled if (!AUTH_ENABLED) { req.authMethod = 'bypass'; return next(); } // Try JWT token first (Authorization: Bearer ) const authHeader = req.headers.authorization; if (authHeader?.startsWith('Bearer ')) { const token = authHeader.substring(7); try { const payload = jwt.verify(token, JWT_SECRET) as JWTPayload; req.user = payload; req.authMethod = 'jwt'; return next(); } catch (error: any) { if (error.name === 'TokenExpiredError') { res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' }); return; } // Token invalid, try API key } } // Try API key (X-API-Key header or query param) const apiKey = req.headers['x-api-key'] || req.query.api_key; if (apiKey === API_KEY) { req.user = { sub: 'api-service', roles: ['service'], iat: Date.now() / 1000, exp: Date.now() / 1000 + 86400 }; req.authMethod = 'api-key'; return next(); } // No valid auth found res.status(401).json({ error: 'Authentication required', code: 'AUTH_REQUIRED', hint: 'Provide Bearer token or X-API-Key header' }); } /** * Role-based authorization middleware factory * Usage: app.use('/admin', requireRole('admin')) */ export function requireRole(...roles: string[]) { return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => { if (!req.user) { res.status(401).json({ error: 'Not authenticated', code: 'NOT_AUTHENTICATED' }); return; } const hasRole = roles.some(role => req.user!.roles.includes(role)); if (!hasRole) { res.status(403).json({ error: 'Insufficient permissions', code: 'FORBIDDEN', required: roles, actual: req.user.roles }); return; } next(); }; } /** * Optional authentication - populates req.user if token present, but doesn't require it */ export function optionalAuth(req: AuthenticatedRequest, res: Response, next: NextFunction): void { const authHeader = req.headers.authorization; if (authHeader?.startsWith('Bearer ')) { const token = authHeader.substring(7); try { const payload = jwt.verify(token, JWT_SECRET) as JWTPayload; req.user = payload; req.authMethod = 'jwt'; } catch { // Invalid token, continue without user } } next(); } // ═══════════════════════════════════════════════════════════════════════════ // Token Generation (for login endpoints) // ═══════════════════════════════════════════════════════════════════════════ /** * Generate a JWT token for a user */ export function generateToken(payload: Omit, expiresIn: string = '24h'): string { return jwt.sign(payload, JWT_SECRET, { expiresIn } as jwt.SignOptions); } /** * Generate a refresh token (longer lived) */ export function generateRefreshToken(userId: string): string { return jwt.sign( { sub: userId, type: 'refresh' }, JWT_SECRET, { expiresIn: '7d' } ); } /** * Verify a refresh token and return user ID */ export function verifyRefreshToken(token: string): string | null { try { const payload = jwt.verify(token, JWT_SECRET) as any; if (payload.type === 'refresh') { return payload.sub; } return null; } catch { return null; } } // ═══════════════════════════════════════════════════════════════════════════ // Export // ═══════════════════════════════════════════════════════════════════════════ export default { authenticate, requireRole, optionalAuth, generateToken, generateRefreshToken, verifyRefreshToken };