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
text/typescript
/**
* @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];