UNPKG

@kya-os/mcp-i

Version:

The TypeScript MCP framework with identity features built-in

273 lines (272 loc) 10.6 kB
"use strict"; /** * Handshake and Session Management for XMCP-I Runtime * * Handles handshake enforcement, session management, and nonce validation * according to requirements 4.5-4.9 and 19.1-19.2. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.defaultSessionManager = exports.SessionManager = void 0; exports.createHandshakeRequest = createHandshakeRequest; exports.validateHandshakeFormat = validateHandshakeFormat; const crypto_1 = require("crypto"); const memory_nonce_cache_1 = require("../cache/memory-nonce-cache"); /** * Session manager class */ class SessionManager { config; sessions = new Map(); constructor(config = {}) { this.config = { timestampSkewSeconds: parseInt(process.env.XMCP_I_TS_SKEW_SEC || "120"), sessionTtlMinutes: parseInt(process.env.XMCP_I_SESSION_TTL_MIN || "30"), // absoluteSessionLifetime: undefined, // Disabled by default (omit to use undefined) nonceCache: new memory_nonce_cache_1.MemoryNonceCache(), ...config, }; // Warn about multi-instance deployments with memory cache (only in production or if explicitly enabled) if (this.config.nonceCache instanceof memory_nonce_cache_1.MemoryNonceCache && (process.env.NODE_ENV === "production" || process.env.MCPI_WARN_MEMORY_CACHE === "true")) { console.warn("Warning: Using MemoryNonceCache - not suitable for multi-instance deployments. " + "Consider using Redis, DynamoDB, or Cloudflare KV for production."); } } /** * Set server DID for session creation * Called after identity is loaded */ setServerDid(serverDid) { this.config.serverDid = serverDid; } /** * Validate handshake and create or retrieve session * Requirements: 4.5, 4.6, 4.7, 4.8, 4.9 */ async validateHandshake(request) { try { // Validate timestamp (±120s clock skew by default) const now = Math.floor(Date.now() / 1000); const timeDiff = Math.abs(now - request.timestamp); if (timeDiff > this.config.timestampSkewSeconds) { return { success: false, error: { code: "XMCP_I_EHANDSHAKE", message: `Timestamp outside acceptable range (±${this.config.timestampSkewSeconds}s)`, remediation: `Check NTP sync on client and server. Current server time: ${now}, received: ${request.timestamp}, diff: ${timeDiff}s. Adjust XMCP_I_TS_SKEW_SEC if needed.`, }, }; } // Validate nonce (must be unique within session window) const nonceExists = await this.config.nonceCache.has(request.nonce, request.agentDid); if (nonceExists) { return { success: false, error: { code: "XMCP_I_EHANDSHAKE", message: "Nonce already used (replay attack prevention)", remediation: "Generate a new unique nonce for each request", }, }; } // Add nonce to cache with TTL >= session TTL const nonceTtlSeconds = this.config.sessionTtlMinutes * 60 + 60; // Session TTL + 1 minute buffer await this.config.nonceCache.add(request.nonce, nonceTtlSeconds, request.agentDid); // Generate session ID const sessionId = this.generateSessionId(); const clientInfo = this.buildClientInfo(request); // Create session context // Phase 5: Sessions start anonymous until OAuth completes const session = { sessionId, audience: request.audience, nonce: request.nonce, timestamp: request.timestamp, createdAt: now, lastActivity: now, ttlMinutes: this.config.sessionTtlMinutes, identityState: "anonymous", // Phase 5: Anonymous until OAuth agentDid: request.agentDid, // Pass through agent DID for delegation verification ...(this.config.serverDid && { serverDid: this.config.serverDid }), // Include server DID if provided ...(clientInfo && { clientInfo }), }; // Store session this.sessions.set(sessionId, session); return { success: true, session, }; } catch (error) { return { success: false, error: { code: "XMCP_I_EHANDSHAKE", message: `Handshake validation failed: ${error instanceof Error ? error.message : "Unknown error"}`, }, }; } } /** * Get session by ID and update last activity */ async getSession(sessionId) { const session = this.sessions.get(sessionId); if (!session) { return null; } const now = Math.floor(Date.now() / 1000); // Check if session has expired (idle timeout) const idleTimeSeconds = now - session.lastActivity; const maxIdleSeconds = session.ttlMinutes * 60; if (idleTimeSeconds > maxIdleSeconds) { this.sessions.delete(sessionId); return null; } // Check absolute session lifetime if configured if (this.config.absoluteSessionLifetime) { const sessionAgeSeconds = now - session.createdAt; const maxAgeSeconds = this.config.absoluteSessionLifetime * 60; if (sessionAgeSeconds > maxAgeSeconds) { this.sessions.delete(sessionId); return null; } } // Update last activity session.lastActivity = now; this.sessions.set(sessionId, session); return session; } /** * Generate a unique session ID */ generateSessionId() { const timestamp = Date.now().toString(36); const random = (0, crypto_1.randomBytes)(8).toString("hex"); return `sess_${timestamp}_${random}`; } /** * Generate a deterministic client identifier when the client * does not provide one during the handshake. */ generateClientId() { return `client_${(0, crypto_1.randomBytes)(6).toString("hex")}`; } /** * Normalize string fields from handshake metadata */ normalizeClientInfoString(value) { if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } /** * Build MCP client metadata for the session when provided during handshake */ buildClientInfo(request) { const hasMetadata = !!request.clientInfo || typeof request.clientProtocolVersion === "string" || request.clientCapabilities !== undefined; if (!hasMetadata) { return undefined; } const source = request.clientInfo; return { name: this.normalizeClientInfoString(source?.name) ?? "unknown", title: this.normalizeClientInfoString(source?.title), version: this.normalizeClientInfoString(source?.version), platform: this.normalizeClientInfoString(source?.platform), vendor: this.normalizeClientInfoString(source?.vendor), persistentId: this.normalizeClientInfoString(source?.persistentId), clientId: this.normalizeClientInfoString(source?.clientId) ?? this.generateClientId(), protocolVersion: this.normalizeClientInfoString(request.clientProtocolVersion), capabilities: request.clientCapabilities, }; } /** * Generate a cryptographically secure nonce */ static generateNonce() { return (0, crypto_1.randomBytes)(16).toString("base64url"); // 128-bit nonce } /** * Cleanup expired sessions and nonces */ async cleanup() { const now = Math.floor(Date.now() / 1000); // Clean up expired sessions for (const [sessionId, session] of this.sessions.entries()) { const idleTimeSeconds = now - session.lastActivity; const maxIdleSeconds = session.ttlMinutes * 60; let expired = idleTimeSeconds > maxIdleSeconds; // Check absolute lifetime if (!expired && this.config.absoluteSessionLifetime) { const sessionAgeSeconds = now - session.createdAt; const maxAgeSeconds = this.config.absoluteSessionLifetime * 60; expired = sessionAgeSeconds > maxAgeSeconds; } if (expired) { this.sessions.delete(sessionId); } } // Clean up expired nonces await this.config.nonceCache.cleanup(); } /** * Get session statistics */ getStats() { return { activeSessions: this.sessions.size, config: { timestampSkewSeconds: this.config.timestampSkewSeconds, sessionTtlMinutes: this.config.sessionTtlMinutes, absoluteSessionLifetime: this.config.absoluteSessionLifetime, cacheType: this.config.nonceCache.constructor.name, }, }; } /** * Clear all sessions (useful for testing) */ clearSessions() { this.sessions.clear(); } } exports.SessionManager = SessionManager; /** * Default session manager instance */ exports.defaultSessionManager = new SessionManager(); /** * Utility functions */ /** * Create a handshake request */ function createHandshakeRequest(audience) { return { nonce: SessionManager.generateNonce(), audience, timestamp: Math.floor(Date.now() / 1000), }; } /** * Validate handshake request format */ function validateHandshakeFormat(request) { return (typeof request === "object" && request !== null && typeof request.nonce === "string" && request.nonce.length > 0 && typeof request.audience === "string" && request.audience.length > 0 && typeof request.timestamp === "number" && request.timestamp > 0 && Number.isInteger(request.timestamp)); }