UNPKG

sdk-simple-auth

Version:

Universal JavaScript/TypeScript authentication SDK with multi-backend support, automatic token refresh, and React integration

1,289 lines (1,276 loc) 143 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var react = require('react'); /** * AxiosInterceptorManager - Gestiona interceptores de Axios para inyección automática * de tokens y manejo de errores de autenticación */ class AxiosInterceptorManager { constructor(axiosInstance, callbacks) { this.requestInterceptorId = null; this.responseInterceptorId = null; // Concurrency handling this.isRefreshing = false; this.failedQueue = []; this.axiosInstance = axiosInstance; this.getAccessToken = callbacks.getAccessToken; this.onSessionInvalid = callbacks.onSessionInvalid; this.onTokenRefresh = callbacks.onTokenRefresh; } /** * Configurar interceptores de Axios */ setup(options = {}) { const { autoInjectToken = true, handleAuthErrors = true } = options; // Verificar que sea una instancia de Axios válida if (!this.isAxiosInstance(this.axiosInstance)) { console.warn('AxiosInterceptorManager: Invalid Axios instance provided'); return; } // Request interceptor - Inyectar token automáticamente if (autoInjectToken) { this.requestInterceptorId = this.axiosInstance.interceptors.request.use(async (config) => { try { const token = await this.getAccessToken(); if (token) { // Solo inyectar si no hay Authorization header ya configurado if (!config.headers.Authorization) { config.headers.Authorization = `Bearer ${token}`; console.debug('AxiosInterceptor: Token injected automatically'); } } } catch (error) { console.error('AxiosInterceptor: Error getting access token:', error); } return config; }, (error) => { return Promise.reject(error); }); console.debug('AxiosInterceptor: Request interceptor configured'); } // Response interceptor - Manejar errores de autenticación if (handleAuthErrors) { this.responseInterceptorId = this.axiosInstance.interceptors.response.use((response) => { return response; }, async (error) => { const originalRequest = error.config; const status = error?.response?.status; // Detectar errores de autenticación (401) if (status === 401) { if (!this.onTokenRefresh) { this.handleSessionInvalid(status); return Promise.reject(error); } if (this.isRefreshing) { // Si ya se está refrescando, encolar la petición console.debug('AxiosInterceptor: Refresh in progress, queuing request'); return new Promise((resolve, reject) => { this.failedQueue.push({ resolve, reject }); }) .then((token) => { originalRequest.headers.Authorization = `Bearer ${token}`; return this.axiosInstance.request(originalRequest); }) .catch((err) => { return Promise.reject(err); }); } this.isRefreshing = true; console.debug('AxiosInterceptor: Authentication error (401), starting refresh...'); try { await this.onTokenRefresh(); const newToken = await this.getAccessToken(); if (!newToken) { throw new Error('No token available after refresh'); } console.debug('AxiosInterceptor: Token refreshed successfully'); // Procesar cola con el nuevo token this.processQueue(null, newToken); // Reintentar la petición original originalRequest.headers.Authorization = `Bearer ${newToken}`; return this.axiosInstance.request(originalRequest); } catch (refreshError) { console.error('AxiosInterceptor: Token refresh failed:', refreshError); this.processQueue(refreshError, null); this.handleSessionInvalid(status); return Promise.reject(refreshError); } finally { this.isRefreshing = false; } } // Otros errores de autenticación (403, 422) que no requieren refresh if (status === 422 || status === 403) { console.warn(`AxiosInterceptor: Auth error (${status}), checking session...`); // Opcional: Podríamos validar sesión aquí también } return Promise.reject(error); }); console.debug('AxiosInterceptor: Response interceptor configured'); } console.log('✅ Axios interceptors configured successfully'); } /** * Procesar cola de peticiones fallidas */ processQueue(error, token = null) { console.debug(`AxiosInterceptor: Processing queue (${this.failedQueue.length} requests)`); this.failedQueue.forEach((prom) => { if (error) { prom.reject(error); } else { prom.resolve(token); } }); this.failedQueue = []; } /** * Manejar sesión inválida */ handleSessionInvalid(status) { console.warn(`AxiosInterceptor: Session invalid (HTTP ${status}), triggering logout`); // Llamar callback de sesión inválida this.onSessionInvalid(); } /** * Remover interceptores */ remove() { if (!this.axiosInstance?.interceptors) { return; } if (this.requestInterceptorId !== null) { this.axiosInstance.interceptors.request.eject(this.requestInterceptorId); this.requestInterceptorId = null; console.debug('AxiosInterceptor: Request interceptor removed'); } if (this.responseInterceptorId !== null) { this.axiosInstance.interceptors.response.eject(this.responseInterceptorId); this.responseInterceptorId = null; console.debug('AxiosInterceptor: Response interceptor removed'); } console.log('Axios interceptors removed'); } /** * Verificar si es una instancia válida de Axios */ isAxiosInstance(instance) { return (instance && instance.interceptors && typeof instance.interceptors.request?.use === 'function' && typeof instance.interceptors.response?.use === 'function' && typeof instance.request === 'function'); } /** * Verificar si los interceptores están activos */ isActive() { return this.requestInterceptorId !== null || this.responseInterceptorId !== null; } /** * Obtener información de estado */ getStatus() { return { isActive: this.isActive(), hasRequestInterceptor: this.requestInterceptorId !== null, hasResponseInterceptor: this.responseInterceptorId !== null }; } } class TokenHandler { static detectTokenType(token) { if (!token || typeof token !== 'string') { return 'opaque'; } // Token de Sanctum: contiene pipe (|) if (token.includes('|')) { return 'sanctum'; } // Token JWT: tiene 3 partes separadas por puntos const parts = token.split('.'); if (parts.length === 3) { try { // Verificar que la segunda parte se puede decodificar const base64Payload = parts[1]; const paddedBase64 = base64Payload.padEnd(base64Payload.length + (4 - (base64Payload.length % 4)) % 4, '='); atob(paddedBase64); return 'jwt'; } catch { return 'opaque'; } } return 'opaque'; } /** * Extrae información del token según su tipo */ static parseToken(token) { const type = this.detectTokenType(token); switch (type) { case 'jwt': return this.parseJWTToken(token); case 'sanctum': return this.parseSanctumToken(token); default: return this.parseOpaqueToken(token); } } static parseJWTToken(token) { try { const parts = token.split('.'); const base64Payload = parts[1]; const paddedBase64 = base64Payload.padEnd(base64Payload.length + (4 - (base64Payload.length % 4)) % 4, '='); const decodedPayload = atob(paddedBase64); const payload = JSON.parse(decodedPayload); return { type: 'jwt', payload, exp: payload.exp, isValid: payload.exp ? payload.exp > Math.floor(Date.now() / 1000) : true }; } catch (error) { console.error('Error parsing JWT token:', error); return { type: 'jwt', isValid: false }; } } static parseSanctumToken(token) { const parts = token.split('|'); return { type: 'sanctum', tokenId: parts[0], payload: { tokenId: parts[0], hash: parts[1] }, isValid: true // Los tokens de Sanctum no contienen info de expiración }; } static parseOpaqueToken(_) { return { type: 'opaque', isValid: true // Tokens opacos requieren validación externa }; } } // Clase para normalizar fechas de expiración class ExpirationHandler { /** * Normaliza diferentes formatos de fecha de expiración a segundos desde ahora */ static normalizeExpiration(expiresValue) { if (!expiresValue) return undefined; // Si ya es un número, asumimos que son segundos if (typeof expiresValue === 'number') { return expiresValue; } if (typeof expiresValue === 'string') { // Intentar parsear como fecha ISO (formato de Sanctum) if (expiresValue.includes('T') || expiresValue.includes('-')) { const date = new Date(expiresValue); if (!isNaN(date.getTime())) { const now = Date.now(); const expiresMs = date.getTime(); const secondsUntilExpiry = Math.floor((expiresMs - now) / 1000); return Math.max(0, secondsUntilExpiry); } } // Intentar parsear como timestamp Unix const timestampSeconds = parseInt(expiresValue); if (!isNaN(timestampSeconds) && timestampSeconds > 1000000000) { const now = Math.floor(Date.now() / 1000); return Math.max(0, timestampSeconds - now); } // Intentar parsear como número en string const numberValue = parseInt(expiresValue); if (!isNaN(numberValue)) { return numberValue; } } console.warn('Could not parse expiration time:', expiresValue); return undefined; } /** * Calcula cuándo expira un token basándose en múltiples fuentes */ static calculateExpiration(token, expiresIn, expiresAt, storedAt) { // 1. Priorizar expiresAt si está disponible if (expiresAt) { return this.normalizeExpiration(expiresAt); } // 2. Usar expiresIn si está disponible if (expiresIn) { return this.normalizeExpiration(expiresIn); } // 3. Intentar extraer del token mismo (JWT) const tokenInfo = TokenHandler.parseToken(token); if (tokenInfo.type === 'jwt' && tokenInfo.exp) { const now = Math.floor(Date.now() / 1000); return Math.max(0, tokenInfo.exp - now); } // 4. Si es un token almacenado con timestamp, calcular basándose en tiempo transcurrido if (storedAt && expiresIn) { const now = Math.floor(Date.now() / 1000); const timeElapsed = now - storedAt; return Math.max(0, expiresIn - timeElapsed); } return undefined; } } /** * Logger utility for the SDK * Allows enabling/disabling debug logs globally */ class Logger { /** * Enable or disable debug mode */ static setDebugMode(enabled) { this.debugMode = enabled; } /** * Log a debug message (only if debug mode is enabled) */ static debug(message, ...args) { if (this.debugMode) { console.debug(`${this.prefix} [Debug] ${message}`, ...args); } } /** * Log an info message (only if debug mode is enabled) */ static log(message, ...args) { if (this.debugMode) { console.log(`${this.prefix} ${message}`, ...args); } } /** * Log a warning message (always visible, but formatted) */ static warn(message, ...args) { console.warn(`${this.prefix} [Warn] ${message}`, ...args); } /** * Log an error message (always visible, but formatted) */ static error(message, ...args) { console.error(`${this.prefix} [Error] ${message}`, ...args); } } Logger.debugMode = false; Logger.prefix = '[AuthSDK]'; // TokenExtractor actualizado para manejar mejor los diferentes formatos class TokenExtractor$1 { static deepSearch(obj, keys) { if (!obj || typeof obj !== 'object') return null; for (const key of keys) { if (obj.hasOwnProperty(key) && obj[key] !== null && obj[key] !== undefined) { return obj[key]; } } for (const value of Object.values(obj)) { if (value && typeof value === 'object') { const result = this.deepSearch(value, keys); if (result) return result; } } return null; } static extractTokens(response) { const accessToken = this.deepSearch(response, this.TOKEN_KEYS); const refreshToken = this.deepSearch(response, this.REFRESH_TOKEN_KEYS); const tokenType = this.deepSearch(response, this.TOKEN_TYPE_KEYS); // Buscar diferentes tipos de información de expiración const expiresIn = this.deepSearch(response, this.EXPIRES_KEYS); const expiresAt = this.deepSearch(response, this.EXPIRES_AT_KEYS); if (!accessToken) { throw new Error('No access token found in response'); } // Calcular expiración usando el nuevo handler const calculatedExpiresIn = ExpirationHandler.calculateExpiration(accessToken, expiresIn, expiresAt); return { accessToken, refreshToken, expiresIn: calculatedExpiresIn, expiresAt, // Mantener el valor original también tokenType: tokenType || 'Bearer', }; } static extractUser(response) { const userData = this.deepSearch(response, this.USER_KEYS); if (userData && typeof userData === 'object') { return { id: userData.id || userData.user_id || userData.userId || 'unknown', name: userData.name || userData.username || userData.full_name || userData.email || 'User', email: userData.email || userData.userEmail, ...userData // Incluir campos adicionales como sucursales }; } // Buscar campos de usuario en el nivel raíz const name = this.deepSearch(response, ['name', 'username', 'full_name', 'email']); if (name) { return { id: this.deepSearch(response, ['id', 'user_id', 'userId']) || 'unknown', name, email: this.deepSearch(response, ['email', 'user_email', 'userEmail']), // Buscar campos adicionales como sucursales sucursales: this.deepSearch(response, ['sucursales', 'branches', 'offices']) }; } return null; } } // ... (mantener los arrays de claves existentes) TokenExtractor$1.TOKEN_KEYS = [ 'accessToken', 'access_token', 'token', 'authToken', 'auth_token', 'bearerToken', 'bearer_token', 'jwt', 'jwtToken', 'jwt_token' ]; TokenExtractor$1.REFRESH_TOKEN_KEYS = [ 'refreshToken', 'refresh_token', 'renewToken', 'renew_token' ]; TokenExtractor$1.EXPIRES_KEYS = [ 'expiresIn', 'expires_in', 'exp', 'expiration', 'expires_at', 'expiresAt', 'expiry', 'expiry_time', 'expiryTime', 'valid_until', 'validUntil' ]; TokenExtractor$1.EXPIRES_AT_KEYS = [ 'expires_at', 'expiresAt', 'expiration', 'expiry_time', 'expiryTime', 'valid_until', 'validUntil', 'rt_expires_at', 'rtExpiresAt' ]; TokenExtractor$1.TOKEN_TYPE_KEYS = [ 'tokenType', 'token_type', 'type', 'authType', 'auth_type' ]; TokenExtractor$1.USER_KEYS = [ 'user', 'userData', 'user_data', 'profile', 'userProfile', 'user_profile', 'data', 'userInfo', 'user_info', 'account', 'accountData', 'account_data', 'name', 'username', 'userName', 'email', 'userEmail', 'user_email', 'userId', 'id', 'full_name', 'fullName' ]; /** * Enhanced RefreshManager with automatic session renewal and retry logic */ class RefreshManager { constructor(config, storageManager, httpClient, callbacks) { // Refresh state management this.refreshTimer = null; this.isRefreshing = false; this.refreshPromise = null; this.refreshAttempts = 0; this.lastRefreshTime = 0; this.config = config; this.storageManager = storageManager; this.httpClient = httpClient; this.onTokenRefresh = callbacks?.onTokenRefresh; this.onRefreshError = callbacks?.onRefreshError; this.onSessionRenewed = callbacks?.onSessionRenewed; } /** * Schedule automatic token refresh based on expiration time */ scheduleTokenRefresh(tokens) { if (!this.config.tokenRefresh.enabled || !tokens.refreshToken) { console.debug('Token refresh disabled or no refresh token available'); return; } this.clearRefreshTimer(); const expiresInSeconds = ExpirationHandler.calculateExpiration(tokens.accessToken, tokens.expiresIn, tokens.expiresAt); if (!expiresInSeconds) { console.warn('No expiration info available, using default scheduling'); // Use default scheduling based on token type const tokenInfo = TokenHandler.parseToken(tokens.accessToken); const defaultExpiration = tokenInfo.type === 'sanctum' ? 24 * 60 * 60 : 60 * 60; const bufferMs = this.config.tokenRefresh.bufferTime * 1000; const timeUntilRefresh = (defaultExpiration * 1000) - bufferMs; if (timeUntilRefresh > 0) { this.scheduleRefreshTimer(timeUntilRefresh); } return; } // NUEVO: Validación de tiempo mínimo para evitar refresh inmediato const minimumLifetime = this.config.tokenRefresh.minimumTokenLifetime || 300; if (expiresInSeconds < minimumLifetime) { console.warn(`Token expires in ${expiresInSeconds}s (less than minimum ${minimumLifetime}s), using grace period`); // Usar período de gracia para tokens de corta duración const gracePeriod = (this.config.tokenRefresh.gracePeriod || 60) * 1000; this.scheduleRefreshTimer(gracePeriod); console.debug(`Token refresh scheduled with grace period in ${gracePeriod / 1000}s`); return; } const bufferMs = this.config.tokenRefresh.bufferTime * 1000; const expiresMs = expiresInSeconds * 1000; const timeUntilRefresh = expiresMs - bufferMs; // NUEVO: Tiempo mínimo de espera para evitar refresh inmediato const minimumWaitTime = 30000; // 30 segundos mínimo const actualWaitTime = Math.max(timeUntilRefresh, minimumWaitTime); if (actualWaitTime > 0) { this.scheduleRefreshTimer(actualWaitTime); // console.debug(`Token refresh scheduled in ${Math.floor(actualWaitTime / 1000)}s`); } else { console.warn('Token expires very soon, but skipping immediate refresh to avoid loops'); } } /** * Perform token refresh with enhanced session management */ async refreshTokens() { if (!this.config.tokenRefresh.enabled) { throw new Error('Token refresh is disabled'); } // Prevent multiple simultaneous refreshes if (this.isRefreshing && this.refreshPromise) { console.debug('Refresh already in progress, waiting for completion'); return this.refreshPromise; } // Check rate limiting const now = Date.now(); if (now - this.lastRefreshTime < 5000) { // 5 second minimum between refreshes throw new Error('Refresh rate limit exceeded'); } // NUEVO: Reset attempts if enough time has passed const timeSinceLastAttempt = now - this.lastRefreshTime; if (timeSinceLastAttempt > 60000) { // 1 minute this.refreshAttempts = 0; } // Check retry limit if (this.refreshAttempts >= this.config.tokenRefresh.maxRetries) { console.error('Maximum refresh attempts exceeded, stopping automatic refresh'); this.refreshAttempts = 0; throw new Error('Maximum refresh attempts exceeded'); } this.isRefreshing = true; this.refreshAttempts++; this.refreshPromise = this.performRefresh(); try { const tokens = await this.refreshPromise; this.refreshAttempts = 0; // Reset on success this.lastRefreshTime = now; return tokens; } catch (error) { console.error(`Refresh attempt ${this.refreshAttempts} failed:`, error); // NUEVO: Only schedule retry if we haven't exceeded max retries if (this.refreshAttempts < this.config.tokenRefresh.maxRetries) { const retryDelay = Math.min(2000 * this.refreshAttempts, 30000); // Cap at 30s console.log(`Scheduling retry ${this.refreshAttempts + 1}/${this.config.tokenRefresh.maxRetries} in ${retryDelay}ms`); setTimeout(() => { // Only retry if we still have a refresh token this.storageManager.getStoredTokens().then(tokens => { if (tokens?.refreshToken) { this.refreshTokens().catch(console.error); } else { console.warn('No refresh token available for retry, stopping attempts'); this.refreshAttempts = 0; } }); }, retryDelay); } else { console.error('Max retries exceeded, stopping refresh attempts'); this.refreshAttempts = 0; } throw error; } finally { this.isRefreshing = false; this.refreshPromise = null; } } /** * Check if token should be refreshed based on expiration */ shouldRefreshToken(token) { if (!this.config.tokenRefresh.enabled) { return false; } const tokenInfo = TokenHandler.parseToken(token); if (tokenInfo.type === 'jwt' && tokenInfo.exp) { const now = Math.floor(Date.now() / 1000); const timeUntilExpiry = tokenInfo.exp - now; return timeUntilExpiry < this.config.tokenRefresh.bufferTime; } // For non-JWT tokens, we can't determine synchronously without stored metadata // Return false to avoid automatic refresh, manual refresh can still be triggered return false; } /** * Async version to check if token should be refreshed (for non-JWT tokens) */ async shouldRefreshTokenAsync(token) { if (!this.config.tokenRefresh.enabled) { return false; } const tokenInfo = TokenHandler.parseToken(token); if (tokenInfo.type === 'jwt' && tokenInfo.exp) { const now = Math.floor(Date.now() / 1000); const timeUntilExpiry = tokenInfo.exp - now; return timeUntilExpiry < this.config.tokenRefresh.bufferTime; } // For non-JWT tokens, check stored metadata return this.shouldRefreshBasedOnMetadata(); } /** * Clear refresh timer */ clearRefreshTimer() { if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = null; console.debug('Refresh timer cleared'); } } /** * Get refresh status information */ getRefreshStatus() { return { isRefreshing: this.isRefreshing, refreshAttempts: this.refreshAttempts, lastRefreshTime: this.lastRefreshTime, nextRefreshScheduled: this.refreshTimer !== null }; } /** * Force refresh regardless of timing */ async forceRefresh() { this.clearRefreshTimer(); this.refreshAttempts = 0; // Reset attempts for forced refresh return this.refreshTokens(); } /** * Detect if error is an authentication error (401, 403, etc) */ isAuthenticationError(error) { // Check for Axios error format if (error?.response?.status) { const status = error.response.status; return status === 401 || status === 403; } // Check for Fetch error (our default httpClient) if (error?.message) { const msg = error.message.toLowerCase(); return msg.includes('401') || msg.includes('403') || msg.includes('unauthorized') || msg.includes('unauthenticated'); } return false; } /** * Private method to perform the actual refresh */ async performRefresh() { const storedTokens = await this.storageManager.getStoredTokens(); const refreshToken = storedTokens?.refreshToken; if (!refreshToken) { throw new Error('No refresh token available'); } console.debug('Performing token refresh...'); try { const url = `${this.config.authServiceUrl}${this.config.endpoints.refresh}`; const tokenInfo = TokenHandler.parseToken(refreshToken); console.debug('Refresh token info:', { type: tokenInfo.type, url, hasRefreshToken: !!refreshToken, refreshTokenLength: refreshToken.length }); let response; if (tokenInfo.type === 'sanctum') { // For Sanctum tokens, send in Authorization header console.debug('Using Sanctum refresh method (Authorization header)'); response = await this.httpClient.post(url, { refresh_token: refreshToken, refreshToken: refreshToken }, { headers: { Authorization: `Bearer ${refreshToken}` } }); } else { // For JWT and other tokens, send in body console.debug('Using JWT refresh method (body only)'); response = await this.httpClient.post(url, { refresh_token: refreshToken, refreshToken: refreshToken }); } const newTokens = this.processRefreshResponse(response, refreshToken); // Store updated tokens with session renewal await this.storageManager.storeTokens(newTokens); await this.storageManager.updateLastRefreshTime(); // Schedule next refresh this.scheduleTokenRefresh(newTokens); // Notify callbacks this.onTokenRefresh?.(newTokens); this.onSessionRenewed?.(newTokens); console.debug('Token refresh completed successfully'); return newTokens; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Token refresh failed'; console.error('Token refresh failed:', errorMessage); // Detectar si es un error de autenticación (401, 403) const isAuthError = this.isAuthenticationError(error); // Si el servidor rechaza el refresh token, limpiar storage if (isAuthError || errorMessage.includes('inválidos') || errorMessage.includes('invalid') || errorMessage.includes('expired') || errorMessage.includes('requerido') || errorMessage.includes('Unauthorized') || errorMessage.includes('Unauthenticated')) { console.warn('Refresh token invalid or expired, clearing authentication data'); await this.storageManager.clearAll(); // Reset refresh attempts to stop retry loops this.refreshAttempts = this.config.tokenRefresh.maxRetries; } this.onRefreshError?.(error instanceof Error ? error : new Error(errorMessage)); throw error; } } /** * Process refresh response and extract tokens */ processRefreshResponse(response, originalRefreshToken) { try { const tokens = TokenExtractor$1.extractTokens(response); // Preserve original refresh token if no new one is provided if (!tokens.refreshToken) { tokens.refreshToken = originalRefreshToken; console.debug('Using original refresh token (no new token provided)'); } return tokens; } catch (error) { Logger.error('Error processing refresh response:', error); throw new Error('Invalid refresh response format'); } } /** * Schedule refresh timer with error handling */ scheduleRefreshTimer(timeUntilRefresh) { try { this.refreshTimer = setTimeout(() => { console.debug('Automatic refresh triggered by timer'); this.refreshTokens().catch((error) => { console.error('Automatic refresh failed:', error); this.onRefreshError?.(error); }); }, timeUntilRefresh); } catch (error) { console.error('Error scheduling refresh timer:', error); } } /** * Check if token should be refreshed based on stored metadata */ async shouldRefreshBasedOnMetadata() { try { const metadata = await this.storageManager.getTokenMetadata(); const storedTokens = await this.storageManager.getStoredTokens(); if (!metadata?.storedAt || !storedTokens?.expiresIn) { return false; } const now = Math.floor(Date.now() / 1000); const timeElapsed = now - metadata.storedAt; const timeUntilExpiry = storedTokens.expiresIn - timeElapsed; return timeUntilExpiry < this.config.tokenRefresh.bufferTime; } catch (error) { console.error('Error checking refresh metadata:', error); return false; } } /** * Reset refresh state (useful for logout) */ reset() { this.clearRefreshTimer(); this.isRefreshing = false; this.refreshPromise = null; this.refreshAttempts = 0; this.lastRefreshTime = 0; console.debug('Refresh manager reset'); } /** * Check if refresh is currently possible */ async canRefresh() { try { const storedTokens = await this.storageManager.getStoredTokens(); return !!(this.config.tokenRefresh.enabled && storedTokens?.refreshToken && !this.isRefreshing); } catch { return false; } } } /** * SessionValidator - Maneja la validación automática de sesiones * cuando la app regresa del background o se reactiva */ class SessionValidator { constructor(config, onValidationRequired) { this.lastActivityTime = Date.now(); this.isListening = false; this.config = { enabled: true, validateOnFocus: true, validateOnVisibility: true, maxInactivityTime: 300, // 5 minutos por defecto autoLogoutOnInvalid: true, ...config }; this.onValidationRequired = onValidationRequired; } /** * Iniciar listeners de eventos del DOM */ startListening() { if (this.isListening || !this.config.enabled) { return; } // Solo funciona en entorno browser if (typeof window === 'undefined' || typeof document === 'undefined') { console.debug('SessionValidator: Not a browser environment, skipping'); return; } // 1. Listener de visibilitychange (cuando cambia de pestaña o minimiza) if (this.config.validateOnVisibility) { this.visibilityListener = () => this.handleVisibilityChange(); document.addEventListener('visibilitychange', this.visibilityListener); console.debug('SessionValidator: visibilitychange listener added'); } // 2. Listener de focus (cuando la ventana obtiene foco) if (this.config.validateOnFocus) { this.focusListener = () => this.handleWindowFocus(); window.addEventListener('focus', this.focusListener); console.debug('SessionValidator: focus listener added'); } // 3. Listener de pageshow (cuando la página se muestra desde caché) this.pageShowListener = (event) => this.handlePageShow(event); window.addEventListener('pageshow', this.pageShowListener); console.debug('SessionValidator: pageshow listener added'); this.isListening = true; console.debug('SessionValidator: Started listening for app lifecycle events'); } /** * Detener listeners de eventos */ stopListening() { if (!this.isListening) { return; } if (typeof window === 'undefined' || typeof document === 'undefined') { return; } if (this.visibilityListener) { document.removeEventListener('visibilitychange', this.visibilityListener); } if (this.focusListener) { window.removeEventListener('focus', this.focusListener); } if (this.pageShowListener) { window.removeEventListener('pageshow', this.pageShowListener); } this.isListening = false; console.debug('SessionValidator: Stopped listening for app lifecycle events'); } /** * Manejar cambio de visibilidad (pestaña activa/inactiva) */ async handleVisibilityChange() { if (document.visibilityState === 'visible') { console.debug('SessionValidator: App became visible'); await this.validateIfNeeded('visibility'); } else { console.debug('SessionValidator: App became hidden'); // Actualizar tiempo de última actividad this.lastActivityTime = Date.now(); } } /** * Manejar foco de ventana */ async handleWindowFocus() { console.debug('SessionValidator: Window gained focus'); await this.validateIfNeeded('focus'); } /** * Manejar pageshow (incluyendo back/forward cache) */ async handlePageShow(event) { if (event.persisted) { // Página restaurada desde cache (usuario usó back button) console.debug('SessionValidator: Page restored from cache'); await this.validateIfNeeded('pageshow-cached'); } else { console.debug('SessionValidator: Page loaded normally'); this.lastActivityTime = Date.now(); } } /** * Validar sesión si es necesario */ async validateIfNeeded(trigger) { const now = Date.now(); const inactiveTime = (now - this.lastActivityTime) / 1000; // en segundos console.debug(`SessionValidator: Validation triggered by ${trigger}`); console.debug(`SessionValidator: Inactive for ${Math.floor(inactiveTime)}s (max: ${this.config.maxInactivityTime}s)`); // Solo validar si ha pasado suficiente tiempo de inactividad if (inactiveTime < this.config.maxInactivityTime) { console.debug('SessionValidator: Inactivity time below threshold, skipping validation'); this.lastActivityTime = now; return; } console.debug('SessionValidator: Performing session validation...'); try { const isValid = await this.onValidationRequired(); if (isValid) { console.debug('SessionValidator: Session is valid'); this.lastActivityTime = now; } else { console.warn('SessionValidator: Session is invalid'); // El callback ya manejará el logout si autoLogoutOnInvalid está habilitado } } catch (error) { console.error('SessionValidator: Validation error:', error); } } /** * Actualizar tiempo de última actividad manualmente */ updateLastActivity() { this.lastActivityTime = Date.now(); } /** * Obtener información de estado */ getStatus() { const now = Date.now(); return { isListening: this.isListening, lastActivityTime: this.lastActivityTime, inactiveSeconds: Math.floor((now - this.lastActivityTime) / 1000) }; } /** * Forzar validación inmediata */ async forceValidation() { console.debug('SessionValidator: Forcing immediate validation'); try { const isValid = await this.onValidationRequired(); if (isValid) { this.lastActivityTime = Date.now(); } return isValid; } catch (error) { console.error('SessionValidator: Force validation error:', error); return false; } } /** * Verificar si está disponible en el entorno actual */ static isSupported() { return typeof window !== 'undefined' && typeof document !== 'undefined'; } } class IndexedDBAdapter { constructor(dbName = 'AuthSDK', dbVersion = 1, storeName = 'auth_data') { this.db = null; this.dbName = dbName; this.dbVersion = dbVersion; this.storeName = storeName; } async openDB() { if (this.db) { return this.db; } return new Promise((resolve, reject) => { if (typeof window === 'undefined' || !window.indexedDB) { reject(new Error('IndexedDB not supported')); return; } const request = window.indexedDB.open(this.dbName, this.dbVersion); request.onerror = () => reject(request.error); request.onsuccess = () => { this.db = request.result; resolve(this.db); }; request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains(this.storeName)) { db.createObjectStore(this.storeName, { keyPath: 'key' }); } }; }); } async setItem(key, value) { const db = await this.openDB(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], 'readwrite'); const store = transaction.objectStore(this.storeName); const request = store.put({ key, value }); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(); }); } async getItem(key) { try { const db = await this.openDB(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], 'readonly'); const store = transaction.objectStore(this.storeName); const request = store.get(key); request.onerror = () => reject(request.error); request.onsuccess = () => { const result = request.result; resolve(result ? result.value : null); }; }); } catch (error) { console.error('Error getting item from IndexedDB:', error); return null; } } async removeItem(key) { const db = await this.openDB(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], 'readwrite'); const store = transaction.objectStore(this.storeName); const request = store.delete(key); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(); }); } async clear() { const db = await this.openDB(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], 'readwrite'); const store = transaction.objectStore(this.storeName); const request = store.clear(); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(); }); } } class LocalStorageAdapter { async setItem(key, value) { if (typeof window !== 'undefined') { localStorage.setItem(key, value); } } async getItem(key) { if (typeof window !== 'undefined') { return localStorage.getItem(key); } return null; } async removeItem(key) { if (typeof window !== 'undefined') { localStorage.removeItem(key); } } async clear() { if (typeof window !== 'undefined') { localStorage.clear(); } } } /** * Enhanced StorageManager with session persistence and automatic cleanup */ class StorageManager { constructor(config) { this.config = config; this.storageAdapter = this.createStorageAdapter(); } createStorageAdapter() { const storageType = this.config.type || 'indexedDB'; if (storageType === 'localStorage') { return new LocalStorageAdapter(); } else { return new IndexedDBAdapter(this.config.dbName, this.config.dbVersion, this.config.storeName); } } /** * Store tokens with enhanced metadata for session management */ async storeTokens(tokens) { try { const now = Math.floor(Date.now() / 1000); const tokenData = { ...tokens, storedAt: now, lastRefreshed: now, version: '1.2.3', // SDK version for migration purposes sessionId: this.generateSessionId() }; await this.storageAdapter.setItem(this.config.tokenKey, JSON.stringify(tokenData)); // Store refresh token separately for security if (tokens.refreshToken) { await this.storageAdapter.setItem(this.config.refreshTokenKey, tokens.refreshToken); } console.debug('Tokens stored successfully with session metadata'); } catch (error) { console.error('Error storing tokens:', error); throw new Error('Failed to store authentication tokens'); } } /** * Store user information with session tracking */ async storeUser(user) { try { const userData = { ...user, lastUpdated: Math.floor(Date.now() / 1000), sessionId: await this.getCurrentSessionId() }; await this.storageAdapter.setItem(this.config.userKey, JSON.stringify(userData)); console.debug('User data stored successfully'); } catch (error) { console.error('Error storing user:', error); throw new Error('Failed to store user information'); } } /** * Retrieve stored tokens with validation and cleanup */ async getStoredTokens() { try { const tokenDataStr = await this.storageAdapter.getItem(this.config.tokenKey); if (!tokenDataStr) { return null; } let tokenData; try { tokenData = JSON.parse(tokenDataStr); } catch { // Handle legacy string-only tokens console.warn('Legacy token format detected, migrating...'); const refreshToken = await this.storageAdapter.getItem(this.config.refreshTokenKey); return { accessToken: tokenDataStr, refreshToken: refreshToken || undefined, }; } // Validate token data structure if (!tokenData.accessToken) { console.warn('Invalid token data structure, clearing storage'); await this.clearTokens(); return null; } // Calculate remaining time for tokens with expiration if (tokenData.expiresIn && tokenData.storedAt) { const now = Math.floor(Date.now() / 1000); const timeElapsed = now - tokenData.storedAt; const remainingTime = Math.max(0, tokenData.expiresIn - timeElapsed); tokenData.expiresIn = remainingTime; } // Get refresh token from separate storage const refreshToken = await this.storageAdapter.getItem(this.config.refreshTokenKey); return { accessToken: tokenData.accessToken, refreshToken: refreshToken || tokenData.refreshToken, expiresIn: tokenData.expiresIn, expiresAt: tokenData.expiresAt, tokenType: tokenData.tokenType, }; } catch (error) { console.error('Error retrieving stored tokens:', error); await this.clearTokens(); // Clean up corrupted data return null; } } /** * Retrieve stored user information */ async getStoredUser() { try { const userData = await this.storageAdapter.getItem(this.config.userKey); if (!userData) { return null; } try { const user = JSON.parse(userData); // Validate basic user structure if (!user.id && !user.email) { console.warn('Invalid user data structure, clearing storage'); await this.clearUser(); return null; } return user; } catch { console.warn('Corrupted user data, clearing storage'); await this.clearUser(); return null; } } catch (error) { console.error('Error retrieving stored user:', error); return null; } } /** * Get token metadata for validation and session management */ async getTokenMetadata() { try { const tokenDataStr = await this.storageAdapter.getItem(this.config.tokenKey); if (!tokenDataStr) { return null; } const tokenData = JSON.parse(tokenDataStr); return { storedAt: tokenData.storedAt, lastRefreshed: tokenData.lastRefreshed, sessionId: tokenData.sessionId, version: tokenData.version }; } catch (error) { console.error('Error getting token metadata:', error); return null; } } /** * Update last refresh timestamp when tokens are renewed */ async updateLastRefreshTime() { try { const tokenDataStr = await this.storageAdapter.getItem(this.config.tokenKey); if (tokenDataStr) { const tokenData = JSON.parse(tokenDataStr); tokenData.lastRefreshed = Math.floor(Date.now() / 1000); await this.storageAdapter.setItem(this.config.tokenKey, JSON.stringify(tokenData)); }