UNPKG

nextjs-django-client

Version:

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

1,487 lines (1,479 loc) 279 kB
'use strict'; var jsxRuntime = require('react/jsx-runtime'); var React = require('react'); var Cookies = require('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 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: '', });