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