UNPKG

nextjs-django-client

Version:

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

1,449 lines (1,442 loc) 185 kB
'use strict'; var jsxRuntime = require('react/jsx-runtime'); var React = require('react'); var Cookies = require('js-cookie'); 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 auth configuration for Django REST Framework */ function createDjangoRestFrameworkConfig(baseURL, overrides) { return { provider: 'django-rest-framework', baseAuthURL: baseURL, useEmailAsUsername: false, ...overrides, }; } /** * Create auth configuration for Django Allauth */ function createDjangoAllauthConfig(baseURL, overrides) { return { provider: 'django-allauth', baseAuthURL: baseURL, useEmailAsUsername: false, ...overrides, }; } /** * Create auth configuration for Firebase */ function createFirebaseConfig(projectId, overrides) { return { provider: 'firebase', baseAuthURL: `https://identitytoolkit.googleapis.com/v1/accounts`, useEmailAsUsername: true, endpoints: { login: `:signInWithPassword?key=${projectId}`, refresh: `:token?key=${projectId}`, logout: '', // Firebase doesn't have a logout endpoint user: `:lookup?key=${projectId}`, register: `:signUp?key=${projectId}`, passwordReset: `:sendOobCode?key=${projectId}`, passwordResetConfirm: `:resetPassword?key=${projectId}`, emailVerification: `:sendOobCode?key=${projectId}`, changePassword: `:update?key=${projectId}`, }, ...overrides, }; } /** * Create auth configuration for Auth0 */ function createAuth0Config(domain, clientId, overrides) { return { provider: 'auth0', baseAuthURL: `https://${domain}`, useEmailAsUsername: true, fieldMapping: { username: 'username', password: 'password', email: 'email', }, responseMapping: { user: 'user', accessToken: 'access_token', refreshToken: 'refresh_token', }, // Auth0 specific configuration ...overrides, auth0: { domain, clientId, ...(overrides?.auth0?.audience && { audience: overrides.auth0.audience }), scope: overrides?.auth0?.scope || 'openid profile email', ...overrides?.auth0, }, }; } /** * Create auth configuration for Supabase */ function createSupabaseConfig(projectUrl, anonKey, overrides) { return { provider: 'supabase', baseAuthURL: projectUrl, useEmailAsUsername: true, ...overrides, supabase: { projectUrl, anonKey, ...overrides?.supabase, }, }; } /** * Create custom auth configuration */ function createCustomAuthConfig(baseURL, config) { return { provider: 'custom', baseAuthURL: baseURL, ...config, }; } /** * 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); } /** * Validate auth configuration */ function validateAuthConfig(config) { const errors = []; if (!config.baseAuthURL && config.provider !== 'custom') { errors.push('baseAuthURL is required'); } if (config.provider === 'firebase') { if (!config.endpoints?.login?.includes('key=')) { errors.push('Firebase configuration requires API key in endpoints'); } } if (config.provider === 'auth0') { if (!config.auth0?.domain || !config.auth0?.clientId) { errors.push('Auth0 configuration requires domain and clientId'); } } if (config.provider === 'supabase') { if (!config.supabase?.projectUrl || !config.supabase?.anonKey) { errors.push('Supabase configuration requires projectUrl and anonKey'); } } return { isValid: errors.length === 0, errors, }; } /** * Get provider-specific configuration examples */ function getProviderExamples() { return { 'django-rest-framework': ` // Django REST Framework with JWT const config = createDjangoRestFrameworkConfig('https://api.example.com', { endpoints: { login: '/api/auth/login/', refresh: '/api/auth/refresh/', } });`, 'django-allauth': ` // Django Allauth const config = createDjangoAllauthConfig('https://api.example.com', { useEmailAsUsername: true, });`, 'firebase': ` // Firebase Authentication const config = createFirebaseConfig('your-api-key', { // Additional Firebase config });`, 'auth0': ` // Auth0 const config = createAuth0Config('your-domain.auth0.com', 'your-client-id', { auth0: { audience: 'https://your-api.com', scope: 'openid profile email', } });`, 'supabase': ` // Supabase const config = createSupabaseConfig( 'https://your-project.supabase.co', 'your-anon-key' );`, 'custom': ` // Custom Authentication const config = createCustomAuthConfig('https://api.example.com', { endpoints: { login: '/custom/login', refresh: '/custom/refresh', }, fieldMapping: { username: 'email', password: 'pwd', } });`, }; } /** * Auto-detect auth provider from URL patterns */ function detectAuthProvider(baseURL) { const url = baseURL.toLowerCase(); if (url.includes('supabase.co')) { return 'supabase'; } if (url.includes('auth0.com')) { return 'auth0'; } if (url.includes('googleapis.com') || url.includes('firebase')) { return 'firebase'; } // Default to Django REST Framework for unknown URLs return 'django-rest-framework'; } 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.getCurrentUser(); const refreshedTokens = this.tokenStorage.getTokens(); if (refreshedTokens) { this.startAutoRefresh(refreshedTokens.access); } return { user, tokens: refreshedTokens }; } catch (refreshError) { // Refresh failed, clear tokens this.tokenStorage.clearTokens(); this.apiClient.setDefaultHeaders({ Authorization: '', }); this.stopAutoRefresh(); return { user: null, tokens: null }; } } else { // Auto-refresh disabled, clear tokens this.tokenStorage.clearTokens(); this.apiClient.setDefaultHeaders({ Authorization: '', }); return { user: null, tokens: null }; } } } /** * Start automatic token refresh */ startAutoRefresh(accessToken) { this.tokenManager.scheduleRefresh(accessToken, () => this.refreshToken()); } /** * Stop automatic token refresh */ stopAutoRefresh() { this.tokenManager.clearRefreshTimer(); } /** * Check if current token needs refresh */ async checkAndRefreshToken() { const accessToken = this.tokenStorage.getAccessToken(); if (!accessToken) { return false; } const tokenInfo = this.tokenManager.parseToken(accessToken); if (!tokenInfo) { return false; } if (this.tokenManager.isTokenExpired(tokenInfo)) { try { await this.refreshToken(); return true; } catch (error) { // Token refresh failed, user needs to re-authenticate await this.logout(); return false; } } if (this.tokenManager.shouldRefreshToken(tokenInfo)) { try { await this.tokenManager.executeRefresh(() => this.refreshToken()); return true; } catch (error) { console.warn('Proactive token refresh failed:', error); // Don't logout on proactive refresh failure, token might still be valid return false; } } return false; } /** * Get time until current token expires */ getTokenExpiryTime() { const accessToken = this.tokenStorage.getAccessToken(); if (!accessToken) { return 0; } return this.tokenManager.getTimeUntilExpiry(accessToken); } /** * Get endpoint manager for external use */ getEndpointManager() { return this.endpointManager; } /** * Update endpoint configuration */ updateEndpoints(updates) { if (updates.endpoints) { this.endpointManager.updateEndpoints(updates.endpoints); } if (updates.fieldMapping) { this.endpointManager.updateFieldMapping(updates.fieldMapping); } if (updates.responseMapping) { this.endpointManager.updateResponseMapping(updates.responseMapping); } } /** * Cleanup resources */ destroy() { this.tokenManager.destroy(); } } // Auth context const AuthContext = React.createContext(null); // Auth reducer function authReducer(state, action) { switch (action.type) { case 'AUTH_START': return { ...state, isLoading: true, error: null, }; case 'AUTH_