UNPKG

mcp-web-ui

Version:

Ultra-lightweight vanilla JavaScript framework for MCP servers - Zero dependencies, perfect security, 2-3KB bundle size

327 lines 12.3 kB
import jwt from 'jsonwebtoken'; import crypto from 'crypto'; /** * MongoDB-based token registry for ephemeral web UI sessions * Handles secure token generation, validation, and automatic cleanup */ export class TokenRegistry { db; collection; serverCollection; jwtSecret; logger; constructor(db, options = {}) { this.db = db; this.collection = db.collection('ephemeral_webui_sessions'); this.serverCollection = db.collection('registered_mcp_servers'); this.jwtSecret = options.jwtSecret || process.env.JWT_SECRET || crypto.randomBytes(32).toString('hex'); this.logger = options.logger; this.setupIndexes(); } /** * Setup MongoDB indexes for efficient queries and TTL */ async setupIndexes() { try { // TTL index for automatic cleanup await this.collection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }); // Performance indexes await this.collection.createIndex({ token: 1 }, { unique: true }); await this.collection.createIndex({ userId: 1 }); await this.collection.createIndex({ serverName: 1 }); await this.collection.createIndex({ sessionKey: 1 }, { unique: true }); // Composite key index await this.collection.createIndex({ createdAt: 1 }); this.log('info', '[TokenRegistry] MongoDB indexes created successfully'); } catch (error) { this.log('error', '[TokenRegistry] Failed to create indexes:', error); } } /** * Generate composite session key for proper session isolation * Format: userId:serverName:serverType */ generateSessionKey(userId, serverName, serverType) { const safeServerType = serverType || 'mcp-webui'; return `${userId}:${serverName}:${safeServerType}`; } /** * Find an existing active session for a user and server using composite key */ async findActiveSession(userId, serverName, serverType) { try { const sessionKey = this.generateSessionKey(userId, serverName, serverType); const session = await this.collection.findOne({ sessionKey, expiresAt: { $gt: new Date() } // Only active (non-expired) sessions }); if (session) { // Update last accessed time await this.collection.updateOne({ token: session.token }, { $set: { lastAccessedAt: new Date() } }); this.log('info', `[TokenRegistry] Found existing session for composite key ${sessionKey}`, { token: session.token.substring(0, 16) + '...', createdAt: session.createdAt.toISOString(), expiresAt: session.expiresAt.toISOString() }); } else { this.log('info', `[TokenRegistry] No existing session found for composite key ${sessionKey}`); } return session; } catch (error) { this.log('error', `[TokenRegistry] Failed to find active session: ${error}`); return null; } } /** * Create a new ephemeral session with secure token */ async createSession(options) { const { userId, serverName, serverType, backend, ttlMinutes = 30, scopes = ['view', 'interact'], metadata = {} } = options; const now = new Date(); const expiresAt = new Date(now.getTime() + ttlMinutes * 60 * 1000); const sessionKey = this.generateSessionKey(userId, serverName, serverType); // Generate secure token with embedded metadata const tokenPayload = { userId, serverName, serverType: serverType || 'mcp-webui', sessionId: crypto.randomUUID(), iat: Math.floor(now.getTime() / 1000), exp: Math.floor(expiresAt.getTime() / 1000) }; const token = jwt.sign(tokenPayload, this.jwtSecret, { algorithm: 'HS256' }); const session = { token, userId, serverName, serverType: serverType || 'mcp-webui', sessionKey, backend, createdAt: now, expiresAt, lastAccessedAt: now, scopes, metadata }; await this.collection.insertOne(session); this.log('info', `[TokenRegistry] Created session for composite key ${sessionKey}`, { token: token.substring(0, 16) + '...', backend: backend.type === 'tcp' ? `${backend.host}:${backend.port}` : backend.socketPath, expiresAt: expiresAt.toISOString() }); return session; } /** * Validate token and return session info */ async validateToken(token) { try { // First, try to find the session in the database (for UUID tokens) const session = await this.collection.findOne({ token }); if (!session) { this.log('warn', `[TokenRegistry] Session not found for token: ${token.substring(0, 16)}...`); return null; } // Check if session has expired if (session.expiresAt < new Date()) { this.log('warn', `[TokenRegistry] Session expired for user ${session.userId}`); await this.collection.deleteOne({ token }); return null; } // If it's a JWT token, also verify the signature if (token.includes('.')) { // JWT tokens have dots try { jwt.verify(token, this.jwtSecret); } catch (jwtError) { this.log('warn', `[TokenRegistry] Invalid JWT signature for token: ${token.substring(0, 16)}...`); return null; } } // Update last accessed timestamp await this.collection.updateOne({ token }, { $set: { lastAccessedAt: new Date() } }); return session; } catch (error) { this.log('error', `[TokenRegistry] Token validation error:`, error); return null; } } /** * Extend session expiration */ async extendSession(token, additionalMinutes = 30) { const session = await this.validateToken(token); if (!session) { return false; } const newExpiresAt = new Date(Date.now() + additionalMinutes * 60 * 1000); const result = await this.collection.updateOne({ token }, { $set: { expiresAt: newExpiresAt, lastAccessedAt: new Date() } }); if (result.modifiedCount > 0) { this.log('info', `[TokenRegistry] Extended session for user ${session.userId} by ${additionalMinutes} minutes`); return true; } return false; } /** * Revoke a specific session */ async revokeSession(token) { const result = await this.collection.deleteOne({ token }); if (result.deletedCount > 0) { this.log('info', `[TokenRegistry] Revoked session: ${token.substring(0, 16)}...`); return true; } return false; } /** * Revoke all sessions for a user */ async revokeUserSessions(userId) { const result = await this.collection.deleteMany({ userId }); if (result.deletedCount > 0) { this.log('info', `[TokenRegistry] Revoked ${result.deletedCount} sessions for user ${userId}`); } return result.deletedCount; } /** * Get active sessions for a user */ async getUserSessions(userId) { return await this.collection .find({ userId, expiresAt: { $gt: new Date() } }) .sort({ createdAt: -1 }) .toArray(); } /** * Get session statistics */ async getStats() { const now = new Date(); const activeSessions = await this.collection .find({ expiresAt: { $gt: now } }) .toArray(); const stats = { totalActiveSessions: activeSessions.length, sessionsByServer: {}, sessionsByUser: {}, oldestSession: null, newestSession: null }; if (activeSessions.length > 0) { // Group by server activeSessions.forEach(session => { stats.sessionsByServer[session.serverName] = (stats.sessionsByServer[session.serverName] || 0) + 1; stats.sessionsByUser[session.userId] = (stats.sessionsByUser[session.userId] || 0) + 1; }); // Find oldest and newest const sortedByCreated = activeSessions.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); stats.oldestSession = sortedByCreated[0].createdAt; stats.newestSession = sortedByCreated[sortedByCreated.length - 1].createdAt; } return stats; } /** * Cleanup expired sessions (manual cleanup, TTL should handle this automatically) */ async cleanupExpiredSessions() { const result = await this.collection.deleteMany({ expiresAt: { $lt: new Date() } }); if (result.deletedCount > 0) { this.log('info', `[TokenRegistry] Cleaned up ${result.deletedCount} expired sessions`); } return result.deletedCount; } /** * Register an MCP server with its backend configuration */ async registerServer(serverName, backend, metadata = {}) { const now = new Date(); const serverRegistration = { serverName, backend, registeredAt: now, lastHeartbeat: now, metadata }; // Upsert (update if exists, insert if doesn't) await this.serverCollection.replaceOne({ serverName }, serverRegistration, { upsert: true }); this.log('info', `[TokenRegistry] Registered server: ${serverName}`, { backend: backend.type === 'tcp' ? `${backend.host}:${backend.port}` : backend.socketPath, metadata }); } /** * Get registered server information */ async getRegisteredServer(serverName) { return await this.serverCollection.findOne({ serverName }); } /** * Update server heartbeat */ async updateServerHeartbeat(serverName) { const result = await this.serverCollection.updateOne({ serverName }, { $set: { lastHeartbeat: new Date() } }); return result.modifiedCount > 0; } /** * Get all registered servers */ async getRegisteredServers() { return await this.serverCollection.find({}).toArray(); } /** * Unregister a server */ async unregisterServer(serverName) { const result = await this.serverCollection.deleteOne({ serverName }); if (result.deletedCount > 0) { this.log('info', `[TokenRegistry] Unregistered server: ${serverName}`); } return result.deletedCount > 0; } /** * Clean up stale server registrations (no heartbeat for X minutes) */ async cleanupStaleServers(staleMinutes = 10) { const staleThreshold = new Date(Date.now() - staleMinutes * 60 * 1000); const result = await this.serverCollection.deleteMany({ lastHeartbeat: { $lt: staleThreshold } }); if (result.deletedCount > 0) { this.log('info', `[TokenRegistry] Cleaned up ${result.deletedCount} stale server registrations`); } return result.deletedCount; } /** * Simple logging utility */ log(level, message, data) { if (this.logger) { this.logger(level, message, data); } else { const timestamp = new Date().toISOString(); console.error(`[${timestamp}][${level.toUpperCase()}] ${message}`); if (data && process.env.DEBUG) { console.error(JSON.stringify(data, null, 2)); } } } } //# sourceMappingURL=TokenRegistry.js.map