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.

672 lines (604 loc) 19.6 kB
/** * @fileoverview Authentication Service - JWT token management and API communication * @version 1.0.0 * @since 2025-08-04 * @lastUpdated 2025-08-04 * @module AuthService * @description Singleton service managing user authentication, JWT tokens, and auth-related API calls * @contributors Claude Code Agent * @dependencies JWT token storage, Backend auth API endpoints * @requirements REQ-AUTH-001 (User Authentication System) * @testCoverage Authentication flow, token management, session handling */ export interface User { id: string; username: string; email?: string; role?: string; createdAt?: string; } export interface AuthState { isAuthenticated: boolean; user: User | null; token: string | null; refreshToken: string | null; isLoading: boolean; error: string | null; } export interface LoginCredentials { username: string; password: string; } export interface AuthResponse { success: boolean; token: string; refreshToken: string; user: User; expiresIn: number; } export interface RefreshResponse { success: boolean; token: string; expiresIn: number; } export class AuthService { private static instance: AuthService; private static readonly TOKEN_KEY = 'quiz-auth-token'; private static readonly REFRESH_TOKEN_KEY = 'quiz-refresh-token'; private static readonly USER_KEY = 'quiz-user'; private authState: AuthState; private listeners: Set<(state: AuthState) => void> = new Set(); private refreshTimer: number | null = null; private constructor() { this.authState = this.getInitialState(); this.setupTokenRefresh(); } /** * Get singleton instance of AuthService * * @description Returns the singleton instance, creating it if necessary. * Follows the established pattern used by other services. * * @returns {AuthService} The singleton instance * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-001 (Singleton authentication service) */ static getInstance(): AuthService { if (!AuthService.instance) { AuthService.instance = new AuthService(); } return AuthService.instance; } /** * Get initial authentication state from localStorage * * @description Initializes auth state by checking for stored tokens and user data. * Validates token expiration and clears expired tokens. * * @returns {AuthState} Initial authentication state * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-002 (Persistent authentication state) */ private getInitialState(): AuthState { try { const token = localStorage.getItem(AuthService.TOKEN_KEY); const refreshToken = localStorage.getItem(AuthService.REFRESH_TOKEN_KEY); const userStr = localStorage.getItem(AuthService.USER_KEY); if (token && userStr) { // Check if token is expired if (this.isTokenExpired(token)) { // Clear expired tokens this.clearStoredAuth(); return this.getUnauthenticatedState(); } const user = JSON.parse(userStr); return { isAuthenticated: true, user, token, refreshToken, isLoading: false, error: null, }; } } catch (error) { console.warn('Failed to load auth state from localStorage:', error); this.clearStoredAuth(); } return this.getUnauthenticatedState(); } /** * Get unauthenticated state object * * @description Returns the default state for unauthenticated users. * * @returns {AuthState} Unauthenticated state * * @since 2025-08-04 * @author Claude Code Agent */ private getUnauthenticatedState(): AuthState { return { isAuthenticated: false, user: null, token: null, refreshToken: null, isLoading: false, error: null, }; } /** * Get current authentication state * * @description Returns a copy of the current auth state to prevent mutations. * * @returns {AuthState} Current authentication state * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-003 (Auth state access) */ getAuthState(): AuthState { return { ...this.authState }; } /** * Subscribe to authentication state changes * * @description Allows components to subscribe to auth state changes. * Returns unsubscribe function following the established pattern. * * @param {Function} callback - Function to call when auth state changes * @returns {Function} Unsubscribe function * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-004 (Reactive authentication state) */ subscribe(callback: (state: AuthState) => void): () => void { this.listeners.add(callback); return () => this.listeners.delete(callback); } /** * Update authentication state and notify listeners * * @description Updates the internal auth state and notifies all subscribers. * Follows the reactive pattern used by other services. * * @param {Partial<AuthState>} updates - Partial state updates * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-004 (Reactive state updates) */ private setState(updates: Partial<AuthState>): void { this.authState = { ...this.authState, ...updates }; this.notifyListeners(); } /** * Notify all subscribers of state changes * * @description Calls all registered listeners with the current auth state. * Includes error handling to prevent one listener from breaking others. * * @since 2025-08-04 * @author Claude Code Agent */ private notifyListeners(): void { this.listeners.forEach(callback => { try { callback(this.getAuthState()); } catch (error) { console.error('Error in auth state listener:', error); } }); } /** * Authenticate user with username and password * * @description Sends login request to backend API and handles the response. * Stores tokens and user data on successful authentication. * * @param {LoginCredentials} credentials - Username and password * @returns {Promise<boolean>} Success status * * @throws {Error} When authentication fails or network error occurs * * @example * ```typescript * const success = await authService.login({ * username: 'john@example.com', * password: 'password123' * }); * ``` * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-005 (User login functionality) * @accessibility Provides clear error messages for screen readers */ async login(credentials: LoginCredentials): Promise<boolean> { this.setState({ isLoading: true, error: null }); try { const response = await fetch('/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(credentials), credentials: 'same-origin', // Include cookies for CSRF protection }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.message || `Authentication failed: ${response.status}`); } const data: AuthResponse = await response.json(); if (!data.success || !data.token || !data.user) { throw new Error('Invalid response from authentication server'); } // Store authentication data this.storeAuthData(data); // Update state this.setState({ isAuthenticated: true, user: data.user, token: data.token, refreshToken: data.refreshToken, isLoading: false, error: null, }); // Setup token refresh this.setupTokenRefresh(data.expiresIn); console.log('✅ User authenticated successfully:', data.user.username); return true; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Authentication failed'; console.error('❌ Authentication failed:', errorMessage); this.setState({ isLoading: false, error: errorMessage, }); return false; } } /** * Log out current user * * @description Sends logout request to backend and clears local auth data. * Handles both successful and failed logout attempts gracefully. * * @returns {Promise<void>} * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-006 (User logout functionality) */ async logout(): Promise<void> { try { // Attempt to notify backend of logout if (this.authState.token) { await fetch('/auth/logout', { method: 'POST', headers: { Authorization: `Bearer ${this.authState.token}`, 'Content-Type': 'application/json', }, credentials: 'same-origin', }).catch(error => { console.warn('Failed to notify backend of logout:', error); }); } } finally { // Always clear local auth data regardless of backend response this.clearAuth(); console.log('✅ User logged out successfully'); } } /** * Refresh authentication token * * @description Uses refresh token to get new access token. * Automatically logs out user if refresh fails. * * @returns {Promise<boolean>} Success status * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-007 (Token refresh functionality) */ async refreshToken(): Promise<boolean> { if (!this.authState.refreshToken) { console.warn('No refresh token available'); return false; } try { const response = await fetch('/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ refreshToken: this.authState.refreshToken, }), credentials: 'same-origin', }); if (!response.ok) { throw new Error(`Token refresh failed: ${response.status}`); } const data: RefreshResponse = await response.json(); if (!data.success || !data.token) { throw new Error('Invalid refresh response'); } // Update token localStorage.setItem(AuthService.TOKEN_KEY, data.token); this.setState({ token: data.token }); // Setup next refresh this.setupTokenRefresh(data.expiresIn); console.log('✅ Token refreshed successfully'); return true; } catch (error) { console.error('❌ Token refresh failed:', error); this.clearAuth(); return false; } } /** * Get current user information * * @description Fetches current user data from backend API. * Updates local user data if successful. * * @returns {Promise<User | null>} User data or null if failed * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-008 (User profile access) */ async getCurrentUser(): Promise<User | null> { if (!this.authState.token) { return null; } try { const response = await fetch('/auth/me', { headers: { Authorization: `Bearer ${this.authState.token}`, }, credentials: 'same-origin', }); if (!response.ok) { if (response.status === 401) { // Token expired, try to refresh const refreshed = await this.refreshToken(); if (!refreshed) { return null; } // Retry with new token return this.getCurrentUser(); } throw new Error(`Failed to get user: ${response.status}`); } const data = await response.json(); const user = data.user || data; // Update stored user data localStorage.setItem(AuthService.USER_KEY, JSON.stringify(user)); this.setState({ user }); return user; } catch (error) { console.error('Failed to get current user:', error); return null; } } /** * Check authentication health * * @description Verifies that authentication system is working properly. * Can be used for health checks and debugging. * * @returns {Promise<boolean>} Health status * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-009 (Authentication health check) */ async checkAuthHealth(): Promise<boolean> { try { const response = await fetch('/auth/health'); return response.ok; } catch (error) { console.error('Auth health check failed:', error); return false; } } /** * Store authentication data in localStorage * * @description Safely stores auth tokens and user data in localStorage. * Includes error handling for storage failures. * * @param {AuthResponse} data - Authentication response data * * @since 2025-08-04 * @author Claude Code Agent */ private storeAuthData(data: AuthResponse): void { try { localStorage.setItem(AuthService.TOKEN_KEY, data.token); localStorage.setItem(AuthService.USER_KEY, JSON.stringify(data.user)); if (data.refreshToken) { localStorage.setItem(AuthService.REFRESH_TOKEN_KEY, data.refreshToken); } } catch (error) { console.error('Failed to store auth data:', error); throw new Error('Failed to save authentication data'); } } /** * Clear all authentication data * * @description Removes all auth data from localStorage and resets state. * Cancels any pending token refresh timers. * * @since 2025-08-04 * @author Claude Code Agent */ private clearAuth(): void { this.clearStoredAuth(); if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = null; } this.setState(this.getUnauthenticatedState()); } /** * Clear stored authentication data from localStorage * * @description Removes auth tokens and user data from localStorage. * * @since 2025-08-04 * @author Claude Code Agent */ private clearStoredAuth(): void { localStorage.removeItem(AuthService.TOKEN_KEY); localStorage.removeItem(AuthService.REFRESH_TOKEN_KEY); localStorage.removeItem(AuthService.USER_KEY); } /** * Check if JWT token is expired * * @description Decodes JWT token and checks expiration time. * Includes buffer time to prevent edge cases. * * @param {string} token - JWT token to check * @returns {boolean} Whether token is expired * * @since 2025-08-04 * @author Claude Code Agent */ private isTokenExpired(token: string): boolean { try { const payload = JSON.parse(atob(token.split('.')[1])); const now = Math.floor(Date.now() / 1000); const bufferTime = 60; // 60 seconds buffer return payload.exp < now + bufferTime; } catch (error) { console.warn('Failed to decode token:', error); return true; // Treat invalid tokens as expired } } /** * Setup automatic token refresh * * @description Sets up timer to refresh token before expiration. * Calculates refresh time based on token expiration. * * @param {number} expiresIn - Token expiration time in seconds * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-007 (Automatic token refresh) */ private setupTokenRefresh(expiresIn?: number): void { if (this.refreshTimer) { clearTimeout(this.refreshTimer); } if (!this.authState.refreshToken) { return; } // Default to 15 minutes if no expiration provided const expires = expiresIn || 15 * 60; // Refresh 2 minutes before expiration, or at 80% of token lifetime const refreshTime = Math.min(expires - 120, expires * 0.8) * 1000; if (refreshTime > 0) { this.refreshTimer = window.setTimeout(() => { this.refreshToken().catch(error => { console.error('Automatic token refresh failed:', error); }); }, refreshTime); } } /** * Get authorization header value * * @description Returns formatted Authorization header for API requests. * Returns null if no token is available. * * @returns {string | null} Authorization header value * * @example * ```typescript * const authHeader = authService.getAuthHeader(); * if (authHeader) { * headers.Authorization = authHeader; * } * ``` * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-010 (API request authentication) */ getAuthHeader(): string | null { return this.authState.token ? `Bearer ${this.authState.token}` : null; } /** * Make authenticated API request * * @description Helper method for making API requests with authentication. * Automatically handles token refresh on 401 responses. * * @param {string} url - API endpoint URL * @param {RequestInit} options - Fetch options * @returns {Promise<Response>} Fetch response * * @throws {Error} When request fails or authentication is required * * @example * ```typescript * const response = await authService.authenticatedFetch('/api/data', { * method: 'POST', * body: JSON.stringify(data) * }); * ``` * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-011 (Authenticated API requests) */ async authenticatedFetch(url: string, options: RequestInit = {}): Promise<Response> { const authHeader = this.getAuthHeader(); if (!authHeader) { throw new Error('Authentication required'); } const headers = { ...options.headers, Authorization: authHeader, 'Content-Type': 'application/json', }; const response = await fetch(url, { ...options, headers, credentials: 'same-origin', }); // Handle token expiration if (response.status === 401 && this.authState.refreshToken) { const refreshed = await this.refreshToken(); if (refreshed) { // Retry with new token const newAuthHeader = this.getAuthHeader(); if (newAuthHeader) { headers.Authorization = newAuthHeader; return fetch(url, { ...options, headers, credentials: 'same-origin' }); } } // Refresh failed, user needs to log in again this.clearAuth(); throw new Error('Authentication expired'); } return response; } } // Export singleton instance for compatibility with existing patterns const authService = AuthService.getInstance(); export default authService;