UNPKG

nextjs-django-client

Version:

A comprehensive, type-safe SDK for seamlessly integrating Next.js 15+ applications with Django REST Framework backends

1,431 lines (1,425 loc) 152 kB
import { jsx } from 'react/jsx-runtime'; import { createContext, useReducer, useEffect, useContext, useState, useCallback } from 'react'; import Cookies from 'js-cookie'; class ApiClient { baseURL; defaultHeaders; timeout; interceptors = []; constructor(config) { this.baseURL = config.baseURL.replace(/\/$/, ''); this.defaultHeaders = config.defaultHeaders ?? {}; this.timeout = config.timeout ?? 30000; // Note: retries and retryDelay are available in config but not currently used } /** * GET request */ async get(endpoint, options) { return this.request({ method: 'GET', url: endpoint, ...options, }); } /** * POST request */ async post(endpoint, data, options) { return this.request({ method: 'POST', url: endpoint, data, ...options, }); } /** * PUT request */ async put(endpoint, data, options) { return this.request({ method: 'PUT', url: endpoint, data, ...options, }); } /** * PATCH request */ async patch(endpoint, data, options) { return this.request({ method: 'PATCH', url: endpoint, data, ...options, }); } /** * DELETE request */ async delete(endpoint, options) { return this.request({ method: 'DELETE', url: endpoint, ...options, }); } /** * File upload with progress tracking */ async upload(endpoint, file, options) { const formData = file instanceof FormData ? file : new FormData(); if (file instanceof File) { formData.append('file', file); } return this.request({ method: 'POST', url: endpoint, data: formData, ...options, headers: { ...options?.headers, // Don't set Content-Type for FormData, let browser set it with boundary }, }); } /** * File download with progress tracking */ async download(endpoint, options) { const response = await this.request({ method: 'GET', url: endpoint, ...options, }); return response; } /** * Batch requests */ async batch(requests) { const promises = requests.map(async (request) => { try { const data = await this.request({ method: request.method, url: request.endpoint, data: request.data, ...request.options, }); return { success: true, data, status: 200, // We'll enhance this when we have proper response handling }; } catch (error) { const apiError = error; return { success: false, error: apiError, status: apiError.status ?? 500, }; } }); return Promise.all(promises); } /** * Stream data (placeholder for future implementation) */ async *stream(endpoint, options) { // This is a placeholder implementation // In a real implementation, this would handle streaming responses const response = await this.get(endpoint, options); if (Array.isArray(response)) { for (const item of response) { yield item; } } } /** * Set base URL */ setBaseURL(url) { this.baseURL = url.replace(/\/$/, ''); } /** * Set default headers */ setDefaultHeaders(headers) { this.defaultHeaders = { ...this.defaultHeaders, ...headers }; } /** * Add an interceptor */ addInterceptor(interceptor) { this.interceptors.push(interceptor); return this.interceptors.length - 1; } /** * Remove an interceptor by index */ removeInterceptor(index) { if (index >= 0 && index < this.interceptors.length) { this.interceptors.splice(index, 1); } } /** * Clear all interceptors */ clearInterceptors() { this.interceptors = []; } /** * Core request method */ async request(config) { let requestConfig = { ...config }; // Apply request interceptors for (const interceptor of this.interceptors) { if (interceptor.request) { requestConfig = await interceptor.request(requestConfig); } } const url = this.buildURL(requestConfig.url, requestConfig.params); const headers = this.buildHeaders(requestConfig.headers); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { let response = await this.executeRequest(url, { method: requestConfig.method, headers, body: this.buildBody(requestConfig.data, headers), signal: requestConfig.signal ?? controller.signal, }); // Apply response interceptors for (const interceptor of this.interceptors) { if (interceptor.response) { response = await interceptor.response(response); } } clearTimeout(timeoutId); return await this.handleResponse(response); } catch (error) { clearTimeout(timeoutId); // Apply error interceptors let handledError = this.handleError(error); for (const interceptor of this.interceptors) { if (interceptor.error) { try { handledError = await interceptor.error(handledError); } catch (interceptorError) { // If interceptor throws, check if it's a retry signal if (interceptorError.isRetry) { // Retry the request with the original config return this.request(interceptorError.originalRequest); } throw interceptorError; } } } throw handledError; } } buildURL(endpoint, params) { const url = endpoint.startsWith('http') ? endpoint : `${this.baseURL}${endpoint}`; if (!params) return url; const searchParams = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { searchParams.append(key, String(value)); }); return `${url}?${searchParams.toString()}`; } buildHeaders(customHeaders) { return { 'Content-Type': 'application/json', ...this.defaultHeaders, ...customHeaders, }; } buildBody(data, headers) { if (!data) return null; if (data instanceof FormData) { // Remove Content-Type header for FormData to let browser set it with boundary delete headers['Content-Type']; return data; } if (headers['Content-Type']?.includes('application/json')) { return JSON.stringify(data); } return String(data); } async executeRequest(url, init) { return fetch(url, init); } async handleResponse(response) { if (!response.ok) { throw await this.createApiError(response); } const contentType = response.headers.get('content-type'); if (contentType?.includes('application/json')) { return response.json(); } if (contentType?.includes('text/')) { return response.text(); } return response.blob(); } async createApiError(response) { let errorData; try { const contentType = response.headers.get('content-type'); if (contentType?.includes('application/json')) { errorData = await response.json(); } else { errorData = await response.text(); } } catch { errorData = 'Unknown error'; } const error = new Error(`HTTP ${response.status}: ${response.statusText}`); error.status = response.status; error.statusText = response.statusText; error.response = { data: errorData, status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers.entries()), }; return error; } handleError(error) { if (error instanceof Error && 'status' in error) { return error; } const apiError = new Error('Network error'); apiError.status = 0; return apiError; } } const DEFAULT_ACCESS_TOKEN_KEY = 'access_token'; const DEFAULT_REFRESH_TOKEN_KEY = 'refresh_token'; const DEFAULT_CSRF_TOKEN_KEY = 'csrf_token'; /** * Token storage utility that handles both localStorage and secure cookies */ class TokenStorage { useSecureStorage; options; accessTokenKey; refreshTokenKey; csrfTokenKey; constructor(useSecureStorage = true, options = {}) { this.useSecureStorage = useSecureStorage; this.options = { secure: true, sameSite: 'strict', path: '/', maxAge: 86400, // 24 hours default prefix: '', domain: '', httpOnly: false, ...options, }; // Set up token keys with optional prefix const prefix = this.options.prefix; this.accessTokenKey = prefix ? `${prefix}_${DEFAULT_ACCESS_TOKEN_KEY}` : DEFAULT_ACCESS_TOKEN_KEY; this.refreshTokenKey = prefix ? `${prefix}_${DEFAULT_REFRESH_TOKEN_KEY}` : DEFAULT_REFRESH_TOKEN_KEY; this.csrfTokenKey = prefix ? `${prefix}_${DEFAULT_CSRF_TOKEN_KEY}` : DEFAULT_CSRF_TOKEN_KEY; } /** * Store authentication tokens */ setTokens(tokens) { if (this.useSecureStorage && typeof window !== 'undefined') { // Use secure cookies for production const cookieOptions = { secure: this.options.secure, sameSite: this.options.sameSite, domain: this.options.domain, path: this.options.path, }; Cookies.set(this.accessTokenKey, tokens.access, { ...cookieOptions, maxAge: this.options.maxAge, }); Cookies.set(this.refreshTokenKey, tokens.refresh, { ...cookieOptions, maxAge: this.options.maxAge * 7, // Refresh token lasts 7x longer }); } else { // Fallback to localStorage for development if (typeof window !== 'undefined') { localStorage.setItem(this.accessTokenKey, tokens.access); localStorage.setItem(this.refreshTokenKey, tokens.refresh); } } } /** * Get stored authentication tokens */ getTokens() { let accessToken; let refreshToken; if (this.useSecureStorage && typeof window !== 'undefined') { accessToken = Cookies.get(this.accessTokenKey); refreshToken = Cookies.get(this.refreshTokenKey); } else { if (typeof window !== 'undefined') { accessToken = localStorage.getItem(this.accessTokenKey) ?? undefined; refreshToken = localStorage.getItem(this.refreshTokenKey) ?? undefined; } } if (accessToken && refreshToken) { return { access: accessToken, refresh: refreshToken, }; } return null; } /** * Get only the access token */ getAccessToken() { if (this.useSecureStorage && typeof window !== 'undefined') { return Cookies.get(this.accessTokenKey) ?? null; } else { if (typeof window !== 'undefined') { return localStorage.getItem(this.accessTokenKey); } } return null; } /** * Get only the refresh token */ getRefreshToken() { if (this.useSecureStorage && typeof window !== 'undefined') { return Cookies.get(this.refreshTokenKey) ?? null; } else { if (typeof window !== 'undefined') { return localStorage.getItem(this.refreshTokenKey); } } return null; } /** * Update only the access token (used during refresh) */ setAccessToken(token) { if (this.useSecureStorage && typeof window !== 'undefined') { Cookies.set(this.accessTokenKey, token, { secure: this.options.secure, sameSite: this.options.sameSite, domain: this.options.domain, path: this.options.path, maxAge: this.options.maxAge, }); } else { if (typeof window !== 'undefined') { localStorage.setItem(this.accessTokenKey, token); } } } /** * Clear all stored tokens */ clearTokens() { if (this.useSecureStorage && typeof window !== 'undefined') { const removeOptions = { domain: this.options.domain, path: this.options.path, }; Cookies.remove(this.accessTokenKey, removeOptions); Cookies.remove(this.refreshTokenKey, removeOptions); Cookies.remove(this.csrfTokenKey, removeOptions); } else { if (typeof window !== 'undefined') { localStorage.removeItem(this.accessTokenKey); localStorage.removeItem(this.refreshTokenKey); localStorage.removeItem(this.csrfTokenKey); } } } /** * Set CSRF token */ setCsrfToken(token) { if (this.useSecureStorage && typeof window !== 'undefined') { Cookies.set(this.csrfTokenKey, token, { secure: this.options.secure, sameSite: this.options.sameSite, domain: this.options.domain, path: this.options.path, maxAge: this.options.maxAge, }); } else { if (typeof window !== 'undefined') { localStorage.setItem(this.csrfTokenKey, token); } } } /** * Get CSRF token */ getCsrfToken() { if (this.useSecureStorage && typeof window !== 'undefined') { return Cookies.get(this.csrfTokenKey) ?? null; } else { if (typeof window !== 'undefined') { return localStorage.getItem(this.csrfTokenKey); } } return null; } /** * Check if tokens exist */ hasTokens() { return this.getTokens() !== null; } } /** * Create secure token storage configuration for production */ function createSecureStorageConfig(options = {}) { return { secure: true, sameSite: 'strict', path: '/', maxAge: 900, // 15 minutes for access token httpOnly: false, // Can't be true for client-side access ...options, }; } /** * Create development token storage configuration */ function createDevelopmentStorageConfig(options = {}) { return { secure: false, sameSite: 'lax', path: '/', maxAge: 3600, // 1 hour for development httpOnly: false, ...options, }; } /** * Create token storage with environment-specific defaults */ function createTokenStorage(useSecureStorage, options) { const isProduction = typeof process !== 'undefined' && process.env.NODE_ENV === 'production'; const shouldUseSecure = useSecureStorage ?? isProduction; const defaultOptions = shouldUseSecure ? createSecureStorageConfig(options) : createDevelopmentStorageConfig(options); return new TokenStorage(shouldUseSecure, defaultOptions); } // Default instance createTokenStorage(); /** * Utility class for managing JWT tokens and their expiration */ class TokenManager { config; refreshTimer = null; refreshPromise = null; constructor(config) { this.config = config; } /** * Parse JWT token to extract expiration information */ parseToken(token) { try { const parts = token.split('.'); if (parts.length !== 3) { return null; } const payload = JSON.parse(atob(parts[1])); const now = Math.floor(Date.now() / 1000); return { token, expiresAt: payload.exp || 0, issuedAt: payload.iat || now, }; } catch (error) { console.warn('Failed to parse JWT token:', error); return null; } } /** * Check if token needs refresh based on expiration and threshold */ shouldRefreshToken(tokenInfo) { const now = Math.floor(Date.now() / 1000); const timeUntilExpiry = tokenInfo.expiresAt - now; return timeUntilExpiry <= this.config.refreshThreshold; } /** * Check if token is expired */ isTokenExpired(tokenInfo) { const now = Math.floor(Date.now() / 1000); return now >= tokenInfo.expiresAt; } /** * Schedule automatic token refresh */ scheduleRefresh(accessToken, refreshCallback) { this.clearRefreshTimer(); const tokenInfo = this.parseToken(accessToken); if (!tokenInfo) { console.warn('Cannot schedule refresh for invalid token'); return; } const now = Math.floor(Date.now() / 1000); const timeUntilRefresh = Math.max(0, tokenInfo.expiresAt - now - this.config.refreshThreshold); this.refreshTimer = setTimeout(async () => { try { await this.executeRefresh(refreshCallback); } catch (error) { console.error('Scheduled token refresh failed:', error); } }, timeUntilRefresh * 1000); } /** * Execute token refresh with retry logic */ async executeRefresh(refreshCallback) { // If refresh is already in progress, return the existing promise if (this.refreshPromise) { return this.refreshPromise; } this.refreshPromise = this.performRefreshWithRetry(refreshCallback); try { const newToken = await this.refreshPromise; return newToken; } finally { this.refreshPromise = null; } } /** * Perform refresh with retry logic */ async performRefreshWithRetry(refreshCallback) { let lastError = null; for (let attempt = 1; attempt <= this.config.maxRetries; attempt++) { try { const newToken = await refreshCallback(); // Schedule next refresh for the new token this.scheduleRefresh(newToken, refreshCallback); return newToken; } catch (error) { lastError = error; console.warn(`Token refresh attempt ${attempt} failed:`, error); if (attempt < this.config.maxRetries) { await this.delay(this.config.retryDelay * attempt); } } } throw lastError || new Error('Token refresh failed after all retries'); } /** * Clear the refresh timer */ clearRefreshTimer() { if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = null; } } /** * Get time until token expires (in seconds) */ getTimeUntilExpiry(token) { const tokenInfo = this.parseToken(token); if (!tokenInfo) { return 0; } const now = Math.floor(Date.now() / 1000); return Math.max(0, tokenInfo.expiresAt - now); } /** * Utility method to delay execution */ delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Cleanup resources */ destroy() { this.clearRefreshTimer(); this.refreshPromise = null; } } /** * Predefined endpoint configurations for popular auth providers */ const AUTH_PROVIDER_CONFIGS = { 'django-rest-framework': { endpoints: { login: '/auth/login/', refresh: '/auth/refresh/', logout: '/auth/logout/', user: '/auth/user/', register: '/auth/register/', passwordReset: '/auth/password/reset/', passwordResetConfirm: '/auth/password/reset/confirm/', emailVerification: '/auth/email/verify/', changePassword: '/auth/password/change/', }, fieldMapping: { username: 'username', password: 'password', email: 'email', firstName: 'first_name', lastName: 'last_name', confirmPassword: 'password2', refreshToken: 'refresh', }, responseMapping: { user: 'user', accessToken: 'access', refreshToken: 'refresh', permissions: 'permissions', roles: 'groups', }, }, 'django-allauth': { endpoints: { login: '/auth/login/', refresh: '/auth/token/refresh/', logout: '/auth/logout/', user: '/auth/user/', register: '/auth/registration/', passwordReset: '/auth/password/reset/', passwordResetConfirm: '/auth/password/reset/confirm/', emailVerification: '/auth/registration/verify-email/', changePassword: '/auth/password/change/', }, fieldMapping: { username: 'username', password: 'password1', email: 'email', firstName: 'first_name', lastName: 'last_name', confirmPassword: 'password2', refreshToken: 'refresh_token', }, responseMapping: { user: 'user', accessToken: 'access_token', refreshToken: 'refresh_token', permissions: 'user.user_permissions', roles: 'user.groups', }, }, 'firebase': { endpoints: { login: '/auth/signin', refresh: '/auth/refresh', logout: '/auth/signout', user: '/auth/user', register: '/auth/signup', passwordReset: '/auth/password-reset', passwordResetConfirm: '/auth/password-reset/confirm', emailVerification: '/auth/email-verification', changePassword: '/auth/change-password', }, fieldMapping: { username: 'email', password: 'password', email: 'email', firstName: 'displayName', lastName: 'displayName', confirmPassword: 'confirmPassword', refreshToken: 'refreshToken', }, responseMapping: { user: 'user', accessToken: 'idToken', refreshToken: 'refreshToken', permissions: 'customClaims.permissions', roles: 'customClaims.roles', }, }, 'auth0': { endpoints: { login: '/oauth/token', refresh: '/oauth/token', logout: '/v2/logout', user: '/userinfo', register: '/dbconnections/signup', passwordReset: '/dbconnections/change_password', passwordResetConfirm: '/dbconnections/change_password', emailVerification: '/api/v2/jobs/verification-email', changePassword: '/dbconnections/change_password', }, fieldMapping: { username: 'username', password: 'password', email: 'email', firstName: 'given_name', lastName: 'family_name', confirmPassword: 'password_confirmation', refreshToken: 'refresh_token', }, responseMapping: { user: 'user', accessToken: 'access_token', refreshToken: 'refresh_token', permissions: 'permissions', roles: 'roles', }, }, 'supabase': { endpoints: { login: '/auth/v1/token?grant_type=password', refresh: '/auth/v1/token?grant_type=refresh_token', logout: '/auth/v1/logout', user: '/auth/v1/user', register: '/auth/v1/signup', passwordReset: '/auth/v1/recover', passwordResetConfirm: '/auth/v1/recover', emailVerification: '/auth/v1/verify', changePassword: '/auth/v1/user', }, fieldMapping: { username: 'email', password: 'password', email: 'email', firstName: 'user_metadata.first_name', lastName: 'user_metadata.last_name', confirmPassword: 'password_confirm', refreshToken: 'refresh_token', }, responseMapping: { user: 'user', accessToken: 'access_token', refreshToken: 'refresh_token', permissions: 'user.app_metadata.permissions', roles: 'user.app_metadata.roles', }, }, 'custom': { endpoints: { login: '/login', refresh: '/refresh', logout: '/logout', user: '/user', register: '/register', passwordReset: '/password-reset', passwordResetConfirm: '/password-reset/confirm', emailVerification: '/email-verification', changePassword: '/change-password', }, fieldMapping: { username: 'username', password: 'password', email: 'email', firstName: 'firstName', lastName: 'lastName', confirmPassword: 'confirmPassword', refreshToken: 'refreshToken', }, responseMapping: { user: 'user', accessToken: 'accessToken', refreshToken: 'refreshToken', permissions: 'permissions', roles: 'roles', }, }, }; /** * Endpoint configuration manager */ class EndpointManager { provider; baseURL; endpoints; fieldMapping; responseMapping; constructor(provider = 'django-rest-framework', baseURL = '', customEndpoints, customFieldMapping, customResponseMapping) { this.provider = provider; this.baseURL = baseURL.replace(/\/$/, ''); const config = AUTH_PROVIDER_CONFIGS[provider]; this.endpoints = { ...config.endpoints, ...customEndpoints }; this.fieldMapping = { ...config.fieldMapping, ...customFieldMapping }; this.responseMapping = { ...config.responseMapping, ...customResponseMapping }; } /** * Get full URL for an endpoint */ getEndpointURL(endpoint) { const path = this.endpoints[endpoint]; if (!path) { throw new Error(`Endpoint '${endpoint}' not configured for provider '${this.provider}'`); } // If path is already a full URL, return as-is if (path.startsWith('http')) { return path; } return `${this.baseURL}${path}`; } /** * Get field name for a logical field */ getFieldName(field) { const fieldName = this.fieldMapping[field]; if (!fieldName) { throw new Error(`Field '${field}' not configured for provider '${this.provider}'`); } return fieldName; } /** * Get response field path for a logical field */ getResponseField(field) { const fieldPath = this.responseMapping[field]; if (!fieldPath || typeof fieldPath !== 'string') { throw new Error(`Response field '${field}' not configured for provider '${this.provider}'. Available fields: ${Object.keys(this.responseMapping).join(', ')}`); } return fieldPath; } /** * Extract value from response using configured mapping */ extractFromResponse(response, field) { try { // Validate field parameter if (!field || typeof field !== 'string') { console.warn('extractFromResponse: invalid field parameter:', field); return undefined; } const fieldPath = this.getResponseField(field); // Validate fieldPath if (!fieldPath || typeof fieldPath !== 'string') { console.warn(`extractFromResponse: invalid fieldPath for field '${field}':`, fieldPath); return undefined; } // Handle different response structures let responseData = response; // Validate response if (!response) { console.warn('extractFromResponse: response is null or undefined'); return undefined; } // If response has a 'data' property, use that (common API pattern) if (response && typeof response === 'object' && 'data' in response) { responseData = response.data; } // Validate responseData before extraction if (!responseData) { console.warn('extractFromResponse: responseData is null or undefined after processing'); return undefined; } const value = this.getNestedValue(responseData, fieldPath); // Debug logging for token extraction issues if ((field === 'accessToken' || field === 'refreshToken') && !value) { console.warn(`Failed to extract ${field} from response:`, { field, fieldPath, responseData: typeof responseData === 'object' ? JSON.stringify(responseData, null, 2) : responseData, originalResponse: typeof response === 'object' ? JSON.stringify(response, null, 2) : response }); } return value; } catch (error) { console.error(`Error extracting ${field} from response:`, error, { field, response: typeof response === 'object' ? JSON.stringify(response, null, 2) : response }); return undefined; } } /** * Map request data using field mapping */ mapRequestData(data) { const mapped = {}; for (const [logicalField, value] of Object.entries(data)) { if (logicalField in this.fieldMapping) { const actualField = this.getFieldName(logicalField); mapped[actualField] = value; } else { // Pass through unmapped fields mapped[logicalField] = value; } } return mapped; } /** * Get all configured endpoints */ getAllEndpoints() { return { ...this.endpoints }; } /** * Update endpoint configuration */ updateEndpoints(updates) { this.endpoints = { ...this.endpoints, ...updates }; } /** * Update field mapping */ updateFieldMapping(updates) { this.fieldMapping = { ...this.fieldMapping, ...updates }; } /** * Update response mapping */ updateResponseMapping(updates) { this.responseMapping = { ...this.responseMapping, ...updates }; } /** * Get provider information */ getProvider() { return this.provider; } /** * Helper to get nested value from object using dot notation */ getNestedValue(obj, path) { // Handle null/undefined object if (!obj) { return undefined; } // Handle non-string path if (typeof path !== 'string') { console.warn('getNestedValue: path must be a string, received:', typeof path, path); return undefined; } // Handle empty path if (!path) { return obj; } try { // Ensure path is a string before calling split const pathString = String(path); return pathString.split('.').reduce((current, key) => { // Handle null/undefined current value if (current === null || current === undefined) { return undefined; } // Handle array access with numeric keys if (Array.isArray(current) && /^\d+$/.test(key)) { const index = parseInt(key, 10); return index < current.length ? current[index] : undefined; } // Handle object property access if (typeof current === 'object' && key in current) { return current[key]; } return undefined; }, obj); } catch (error) { console.error('Error in getNestedValue:', error, 'path:', path, 'obj:', obj); return undefined; } } } /** * Create endpoint manager from auth config */ function createEndpointManager(config) { return new EndpointManager(config.provider || 'django-rest-framework', config.baseAuthURL || '', config.endpoints, config.fieldMapping, config.responseMapping); } class AuthService { apiClient; config; tokenStorage; tokenManager; endpointManager; constructor(apiClient, config = {}) { this.apiClient = apiClient; this.config = this.normalizeConfig(config); this.endpointManager = createEndpointManager(this.config); // Handle tokenStorage configuration const tokenStorageConfig = this.config.tokenStorage || {}; this.tokenStorage = createTokenStorage(this.config.secureTokens ?? tokenStorageConfig.secure ?? true, { prefix: tokenStorageConfig.prefix || 'django_client', maxAge: tokenStorageConfig.maxAge || this.config.accessTokenLifetime || 300, ...(tokenStorageConfig.secure !== undefined && { secure: tokenStorageConfig.secure }), ...(tokenStorageConfig.sameSite && { sameSite: tokenStorageConfig.sameSite }), ...(tokenStorageConfig.domain && { domain: tokenStorageConfig.domain }), ...(tokenStorageConfig.path && { path: tokenStorageConfig.path }), }); // Handle autoRefresh configuration const autoRefreshConfig = typeof this.config.autoRefresh === 'object' ? this.config.autoRefresh : { enabled: this.config.autoRefresh !== false }; this.tokenManager = new TokenManager({ refreshThreshold: autoRefreshConfig.threshold || this.config.refreshThreshold || 60, maxRetries: autoRefreshConfig.maxRetries || this.config.maxRefreshRetries || 3, retryDelay: autoRefreshConfig.retryDelay || this.config.refreshRetryDelay || 1000, }); } /** * Normalize configuration to handle both current and documented formats */ normalizeConfig(config) { const normalized = this.getDefaultConfig(config); // Handle fieldMappings (documented format) -> fieldMapping + responseMapping if (config.fieldMappings) { // Split fieldMappings into fieldMapping and responseMapping const fieldMapping = {}; const responseMapping = {}; for (const [key, value] of Object.entries(config.fieldMappings)) { if (['accessToken', 'refreshToken', 'permissions', 'roles'].includes(key)) { responseMapping[key] = value; } else { fieldMapping[key] = value; } } normalized.fieldMapping = { ...normalized.fieldMapping, ...fieldMapping }; normalized.responseMapping = { ...normalized.responseMapping, ...responseMapping }; } return normalized; } getDefaultConfig(config) { return { // Provider and endpoint configuration provider: 'django-rest-framework', baseAuthURL: '', endpoints: {}, fieldMapping: {}, responseMapping: {}, // Legacy fields for backward compatibility loginEndpoint: '/auth/login/', refreshEndpoint: '/auth/refresh/', logoutEndpoint: '/auth/logout/', userEndpoint: '/auth/user/', usernameField: 'username', passwordField: 'password', // Auth configuration useEmailAsUsername: false, tokenPrefix: 'Bearer', accessTokenLifetime: 300, refreshTokenLifetime: 86400, autoRefresh: true, refreshThreshold: 60, maxRefreshRetries: 3, refreshRetryDelay: 1000, csrfEnabled: true, secureTokens: true, validateUser: () => true, transformUser: (user) => user, ...config, }; } /** * Login with credentials */ async login(credentials) { // Map credentials using endpoint manager const loginData = { password: credentials.password, }; // Use email or username based on configuration if (this.config.useEmailAsUsername && credentials.email) { loginData.username = credentials.email; } else if (credentials.username) { loginData.username = credentials.username; } else if (credentials.email) { loginData.username = credentials.email; } else { throw new Error('Username or email is required'); } // Map the request data using endpoint manager const mappedData = this.endpointManager.mapRequestData(loginData); try { const response = await this.apiClient.post(this.endpointManager.getEndpointURL('login'), mappedData); // Extract tokens and user data using endpoint manager const accessToken = this.endpointManager.extractFromResponse(response, 'accessToken'); const refreshToken = this.endpointManager.extractFromResponse(response, 'refreshToken'); const userData = this.endpointManager.extractFromResponse(response, 'user'); // Validate tokens if (!accessToken) { throw new Error('Access token not found in login response. Check your auth configuration and server response format.'); } if (!refreshToken) { console.warn('Refresh token not found in login response. Auto-refresh may not work properly.'); } const tokens = { access: accessToken, refresh: refreshToken, }; // Validate and transform user data if (this.config.validateUser && !this.config.validateUser(userData)) { throw new Error('Invalid user data received from server'); } const user = (this.config.transformUser ? this.config.transformUser(userData) : userData); // Store tokens this.tokenStorage.setTokens(tokens); // Set authorization header for future requests this.apiClient.setDefaultHeaders({ Authorization: `${this.config.tokenPrefix || 'Bearer'} ${tokens.access}`, }); // Start auto-refresh if enabled if (this.config.autoRefresh) { this.startAutoRefresh(tokens.access); } return { user, tokens }; } catch (error) { // Enhanced error handling with proper HTTP status code extraction let errorMessage = 'Login failed'; let statusCode; if (error instanceof Error) { // Check if it's an HTTP error with status code const httpError = error; if (httpError.response?.status) { statusCode = httpError.response.status; } else if (httpError.status) { statusCode = httpError.status; } else if (httpError.code) { statusCode = parseInt(httpError.code, 10); } // Extract meaningful error message from response if (httpError.response?.data) { const responseData = httpError.response.data; if (typeof responseData === 'string') { errorMessage = responseData; } else if (responseData.detail) { errorMessage = responseData.detail; } else if (responseData.message) { errorMessage = responseData.message; } else if (responseData.error) { errorMessage = responseData.error; } else if (responseData.non_field_errors && Array.isArray(responseData.non_field_errors)) { errorMessage = responseData.non_field_errors.join(', '); } } // Provide user-friendly messages for common HTTP status codes if (statusCode === 401) { errorMessage = 'Invalid email/username or password. Please check your credentials and try again.'; } else if (statusCode === 429) { errorMessage = 'Too many login attempts. Please wait before trying again.'; } else if (statusCode === 403) { errorMessage = 'Your account is not authorized to access this application.'; } else if (statusCode === 404) { errorMessage = 'Login service not found. Please contact support.'; } else if (statusCode && statusCode >= 500) { errorMessage = 'Server error. Please try again later or contact support.'; } else if (!errorMessage || errorMessage === 'Login failed') { errorMessage = error.message || 'Login failed. Please try again.'; } } // Create enhanced error with status code const enhancedError = new Error(errorMessage); if (statusCode) { enhancedError.status = statusCode; enhancedError.code = statusCode; } throw enhancedError; } } /** * Logout user */ async logout() { try { // Call logout endpoint if refresh token exists const refreshToken = this.tokenStorage.getRefreshToken(); if (refreshToken) { const logoutData = this.endpointManager.mapRequestData({ refreshToken: refreshToken, }); await this.apiClient.post(this.endpointManager.getEndpointURL('logout'), logoutData); } } catch (error) { // Continue with logout even if server call fails console.warn('Logout endpoint failed:', error); } finally { // Always clear local tokens and headers this.tokenStorage.clearTokens(); this.apiClient.setDefaultHeaders({ Authorization: '', }); // Stop auto-refresh this.stopAutoRefresh(); } } /** * Refresh access token */ async refreshToken() { const refreshToken = this.tokenStorage.getRefreshToken(); if (!refreshToken) { throw new Error('No refresh token available'); } try { const refreshData = this.endpointManager.mapRequestData({ refreshToken: refreshToken, }); const response = await this.apiClient.post(this.endpointManager.getEndpointURL('refresh'), refreshData); // Extract new access token using endpoint manager const newAccessToken = this.endpointManager.extractFromResponse(response, 'accessToken'); // Update stored access token this.tokenStorage.setAccessToken(newAccessToken); // Update authorization header this.apiClient.setDefaultHeaders({ Authorization: `${this.config.tokenPrefix || 'Bearer'} ${newAccessToken}`, }); // Restart auto-refresh with new token if enabled if (this.config.autoRefresh) { this.startAutoRefresh(newAccessToken); } return newAccessToken; } catch (error) { // If refresh fails, clear all tokens this.tokenStorage.clearTokens(); this.apiClient.setDefaultHeaders({ Authorization: '', }); throw new Error(`Token refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Get current user data */ async getCurrentUser() { try { const response = await this.apiClient.get(this.endpointManager.getEndpointURL('user')); // Extract user data using endpoint manager const userData = this.endpointManager.extractFromResponse(response, 'user'); if (this.config.validateUser && !this.config.validateUser(userData)) { throw new Error('Invalid user data received from server'); } return (this.config.transformUser ? this.config.transformUser(userData) : userData); } catch (error) { throw new Error(`Failed to get user data: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Update user data */ async updateUser(userData) { try { // Map user data using endpoint manager const mappedData = this.endpointManager.mapRequestData(userData); const response = await this.apiClient.patch(this.endpointManager.getEndpointURL('user'), mappedData); // Extract user data using endpoint manager const updatedUserData = this.endpointManager.extractFromResponse(response, 'user'); if (this.config.validateUser && !this.config.validateUser(updatedUserData)) { throw new Error('Invalid user data received from server'); } return (this.config.transformUser ? this.config.transformUser(updatedUserData) : updatedUserData); } catch (error) { throw new Error(`Failed to update user: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Check if user is authenticated */ isAuthenticated() { return this.tokenStorage.hasTokens(); } /** * Get stored tokens */ getTokens() { return this.tokenStorage.getTokens(); } /** * Get access token for server-side usage */ getServerAccessToken() { return this.tokenStorage.getAccessToken(); } /** * Set CSRF token */ setCsrfToken(token) { this.tokenStorage.setCsrfToken(token); } /** * Get CSRF token */ getCsrfToken() { return this.tokenStorage.getCsrfToken(); } /** * Initialize authentication state (check for existing tokens) */ async initialize() { let tokens = this.tokenStorage.getTokens(); if (!tokens) { return { user: null, tokens: null }; } // Set authorization header this.apiClient.setDefaultHeaders({ Authorization: `${this.config.tokenPrefix || 'Bearer'} ${tokens.access}`, }); try { // Check if token needs refresh before making API call if (this.config.autoRefresh) { await this.checkAndRefreshToken(); // Get updated tokens after potential refresh tokens = this.tokenStorage.getTokens(); if (!tokens) { return { user: null, tokens: null }; } } // Verify token is still valid by fetching user data const user = await this.getCurrentUser(); // Start auto-refresh if enabled and not already started if (this.config.autoRefresh && tokens) { this.startAutoRefresh(tokens.access); } return { user, tokens }; } catch (error) { // Token might be expired, try to refresh if (this.config.autoRefresh) { try { await this.refreshToken(); const user = await this.getCurr