UNPKG

mcp-quiz-server

Version:

🧠 AI-Powered Quiz Management via Model Context Protocol (MCP) - Create, manage, and take quizzes directly from VS Code, Claude, and other AI agents.

488 lines (439 loc) • 13.7 kB
/** * @fileoverview SSE Authentication Handler - Secure Server-Sent Events with JWT * @version 1.0.0 * @since 2025-08-04 * @lastUpdated 2025-08-04 * @module SSEAuthHandler * @description Utility for handling authenticated Server-Sent Events connections with automatic token refresh * @contributors Claude Code Agent * @dependencies AuthService, EventSource API * @requirements REQ-AUTH-015 (Authenticated SSE connections) * @testCoverage SSE connection management, token refresh, reconnection logic */ import { AuthService } from '../services/AuthService'; export interface SSEAuthOptions { url: string; withCredentials?: boolean; reconnectInterval?: number; maxReconnectAttempts?: number; onMessage?: (event: MessageEvent) => void; onError?: (event: Event) => void; onOpen?: (event: Event) => void; onClose?: (event: Event) => void; onAuthRequired?: () => void; } export interface SSEConnection { eventSource: EventSource | null; isConnected: boolean; reconnectAttempts: number; url: string; } /** * SSE Authentication Handler * * @description Manages authenticated Server-Sent Events connections with automatic * token refresh, reconnection logic, and proper cleanup. Ensures * SSE connections include valid JWT tokens and handles token expiration. * * @example * ```typescript * const sseHandler = new SSEAuthHandler({ * url: '/api/events', * onMessage: (event) => console.log('Received:', event.data), * onAuthRequired: () => authManager.showLoginModal() * }); * * await sseHandler.connect(); * ``` * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-015 (Authenticated SSE handler) * @accessibility Provides connection status announcements for screen readers */ export class SSEAuthHandler { private authService: AuthService; private options: SSEAuthOptions; private connection: SSEConnection; private reconnectTimer: number | null = null; private unsubscribeAuth: (() => void) | null = null; constructor(options: SSEAuthOptions) { this.authService = AuthService.getInstance(); this.options = { withCredentials: true, reconnectInterval: 5000, maxReconnectAttempts: 5, ...options, }; this.connection = { eventSource: null, isConnected: false, reconnectAttempts: 0, url: options.url, }; this.setupAuthSubscription(); } /** * Connect to SSE endpoint with authentication * * @description Establishes authenticated SSE connection with proper headers * * @returns {Promise<boolean>} Connection success status * * @throws {Error} When authentication is required but not available * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-016 (Authenticated SSE connection establishment) */ async connect(): Promise<boolean> { // Check if authentication is required const authState = this.authService.getAuthState(); if (!authState.isAuthenticated || !authState.token) { console.warn('SSE connection requires authentication'); this.options.onAuthRequired?.(); return false; } try { await this.establishConnection(); return true; } catch (error) { console.error('Failed to establish SSE connection:', error); this.options.onError?.(error as Event); return false; } } /** * Disconnect from SSE endpoint * * @description Cleanly closes SSE connection and cleans up resources * * @since 2025-08-04 * @author Claude Code Agent */ disconnect(): void { this.clearReconnectTimer(); if (this.connection.eventSource) { this.connection.eventSource.close(); this.connection.eventSource = null; } this.connection.isConnected = false; this.connection.reconnectAttempts = 0; console.log('SSE connection disconnected'); } /** * Check if currently connected * * @description Returns current connection status * * @returns {boolean} Connection status * * @since 2025-08-04 * @author Claude Code Agent */ isConnected(): boolean { return ( this.connection.isConnected && this.connection.eventSource?.readyState === EventSource.OPEN ); } /** * Get connection status * * @description Returns detailed connection information * * @returns {SSEConnection} Connection details * * @since 2025-08-04 * @author Claude Code Agent */ getConnectionStatus(): SSEConnection { return { ...this.connection }; } /** * Establish SSE connection with authentication * * @description Creates EventSource with authenticated URL * * @throws {Error} When token is invalid or connection fails * * @since 2025-08-04 * @author Claude Code Agent */ private async establishConnection(): Promise<void> { const authHeader = this.authService.getAuthHeader(); if (!authHeader) { throw new Error('No authentication token available'); } // Extract token from Bearer header const token = authHeader.replace('Bearer ', ''); // Create authenticated URL with token as query parameter // Note: EventSource doesn't support custom headers, so we use query param const separator = this.options.url.includes('?') ? '&' : '?'; const authenticatedUrl = `${this.options.url}${separator}token=${encodeURIComponent(token)}`; // Create EventSource connection this.connection.eventSource = new EventSource(authenticatedUrl, { withCredentials: this.options.withCredentials, }); this.setupEventListeners(); } /** * Setup EventSource event listeners * * @description Configures event handlers for SSE connection * * @since 2025-08-04 * @author Claude Code Agent */ private setupEventListeners(): void { if (!this.connection.eventSource) return; this.connection.eventSource.onopen = event => { console.log('SSE connection established'); this.connection.isConnected = true; this.connection.reconnectAttempts = 0; this.clearReconnectTimer(); this.options.onOpen?.(event); }; this.connection.eventSource.onmessage = event => { this.options.onMessage?.(event); }; this.connection.eventSource.onerror = event => { console.error('SSE connection error:', event); this.connection.isConnected = false; // Check if it's an authentication error if (this.connection.eventSource?.readyState === EventSource.CLOSED) { this.handleConnectionError(); } this.options.onError?.(event); }; // Custom event handlers this.setupCustomEventHandlers(); } /** * Setup custom event type handlers * * @description Configures handlers for specific SSE event types * * @since 2025-08-04 * @author Claude Code Agent */ private setupCustomEventHandlers(): void { if (!this.connection.eventSource) return; // Handle authentication errors this.connection.eventSource.addEventListener('auth-error', event => { console.warn('SSE authentication error:', event); this.handleAuthError(); }); // Handle token refresh notifications this.connection.eventSource.addEventListener('token-refresh', event => { console.log('Token refresh requested by server'); this.handleTokenRefresh(); }); // Handle connection close notifications this.connection.eventSource.addEventListener('close', event => { console.log('Server requested connection close'); this.disconnect(); this.options.onClose?.(event); }); } /** * Handle connection errors with reconnection logic * * @description Manages reconnection attempts with exponential backoff * * @since 2025-08-04 * @author Claude Code Agent */ private handleConnectionError(): void { if (this.connection.reconnectAttempts >= (this.options.maxReconnectAttempts || 5)) { console.error('Max reconnection attempts reached'); this.options.onAuthRequired?.(); return; } this.connection.reconnectAttempts++; const delay = this.options.reconnectInterval! * Math.pow(2, this.connection.reconnectAttempts - 1); console.log( `Attempting to reconnect in ${delay}ms (attempt ${this.connection.reconnectAttempts})` ); this.reconnectTimer = window.setTimeout(() => { this.reconnect(); }, delay); } /** * Handle authentication errors * * @description Responds to SSE authentication failures * * @since 2025-08-04 * @author Claude Code Agent */ private handleAuthError(): void { console.warn('SSE authentication failed, requesting login'); this.disconnect(); this.options.onAuthRequired?.(); } /** * Handle token refresh requests * * @description Refreshes token and reconnects SSE * * @since 2025-08-04 * @author Claude Code Agent */ private async handleTokenRefresh(): Promise<void> { try { const refreshed = await this.authService.refreshToken(); if (refreshed) { console.log('Token refreshed, reconnecting SSE'); await this.reconnect(); } else { console.error('Token refresh failed'); this.handleAuthError(); } } catch (error) { console.error('Token refresh error:', error); this.handleAuthError(); } } /** * Reconnect to SSE endpoint * * @description Attempts to reestablish SSE connection * * @returns {Promise<boolean>} Reconnection success status * * @since 2025-08-04 * @author Claude Code Agent */ private async reconnect(): Promise<boolean> { console.log('Attempting SSE reconnection'); // Close existing connection if (this.connection.eventSource) { this.connection.eventSource.close(); } try { await this.establishConnection(); return true; } catch (error) { console.error('SSE reconnection failed:', error); this.handleConnectionError(); return false; } } /** * Setup authentication service subscription * * @description Monitors auth state changes and manages connection accordingly * * @since 2025-08-04 * @author Claude Code Agent */ private setupAuthSubscription(): void { this.unsubscribeAuth = this.authService.subscribe(authState => { if (!authState.isAuthenticated && this.connection.isConnected) { console.log('User logged out, disconnecting SSE'); this.disconnect(); } }); } /** * Clear reconnection timer * * @description Cancels pending reconnection attempts * * @since 2025-08-04 * @author Claude Code Agent */ private clearReconnectTimer(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } } /** * Add custom event listener * * @description Adds listener for specific SSE event types * * @param {string} type - Event type name * @param {Function} listener - Event handler function * * @since 2025-08-04 * @author Claude Code Agent */ addEventListener(type: string, listener: (event: MessageEvent) => void): void { this.connection.eventSource?.addEventListener(type, listener); } /** * Remove custom event listener * * @description Removes listener for specific SSE event types * * @param {string} type - Event type name * @param {Function} listener - Event handler function * * @since 2025-08-04 * @author Claude Code Agent */ removeEventListener(type: string, listener: (event: MessageEvent) => void): void { this.connection.eventSource?.removeEventListener(type, listener); } /** * Clean up resources * * @description Cleans up all resources and subscriptions * * @since 2025-08-04 * @author Claude Code Agent */ destroy(): void { this.disconnect(); this.unsubscribeAuth?.(); this.clearReconnectTimer(); } } /** * Create authenticated SSE connection * * @description Factory function for creating authenticated SSE connections * * @param {SSEAuthOptions} options - SSE connection options * @returns {SSEAuthHandler} SSE handler instance * * @example * ```typescript * const sseConnection = createAuthenticatedSSE({ * url: '/api/quiz-events', * onMessage: (event) => { * const data = JSON.parse(event.data); * console.log('Quiz event:', data); * } * }); * * await sseConnection.connect(); * ``` * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-017 (SSE factory function) */ export function createAuthenticatedSSE(options: SSEAuthOptions): SSEAuthHandler { return new SSEAuthHandler(options); } /** * SSE Event Types * * @description Common SSE event type constants for type safety * * @since 2025-08-04 * @author Claude Code Agent */ export const SSEEventTypes = { QUIZ_UPDATE: 'quiz-update', USER_PROGRESS: 'user-progress', SYSTEM_NOTIFICATION: 'system-notification', AUTH_ERROR: 'auth-error', TOKEN_REFRESH: 'token-refresh', CONNECTION_CLOSE: 'close', } as const; export type SSEEventType = (typeof SSEEventTypes)[keyof typeof SSEEventTypes];