UNPKG

sdk-simple-auth

Version:

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

1,296 lines (1,286 loc) 116 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var react = require('react'); /** * Enhanced TokenExtractor with flexible data preservation * Mantiene todos los datos originales del backend sin pérdida de información */ class TokenExtractor { /** * Búsqueda profunda optimizada con paths específicos */ static deepSearchByPaths(obj, searchPaths) { if (!obj || typeof obj !== 'object') return null; // Generar cache key único const cacheKey = `paths_${JSON.stringify(searchPaths)}_${this.generateObjectSignature(obj)}`; const cached = this.searchCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { return cached.value; } for (const path of searchPaths) { if (path === '') { // Buscar en la raíz const result = this.extractFromRoot(obj); if (result) { this.searchCache.set(cacheKey, { value: result, timestamp: Date.now() }); return result; } continue; } const value = this.getNestedValue(obj, path); if (value && typeof value === 'object') { this.searchCache.set(cacheKey, { value, timestamp: Date.now() }); return value; } } this.searchCache.set(cacheKey, { value: null, timestamp: Date.now() }); return null; } /** * Extrae datos de usuario desde la raíz del objeto */ static extractFromRoot(obj) { // Verificar si el objeto raíz tiene campos de usuario válidos const userFields = ['id', '_id', 'email', 'name', 'firstName', 'usuario']; const hasUserField = userFields.some(field => obj[field] !== undefined); if (hasUserField) { return obj; } return null; } /** * Obtiene valor anidado usando dot notation */ static getNestedValue(obj, path) { return path.split('.').reduce((current, key) => { return current && current[key] !== undefined ? current[key] : null; }, obj); } /** * Genera una signatura única del objeto para cache */ static generateObjectSignature(obj) { try { const keys = Object.keys(obj).sort().slice(0, 10); // Primeras 10 keys para performance return keys.join(','); } catch { return 'unknown'; } } /** * Búsqueda mejorada de campos específicos */ static enhancedDeepSearch(obj, keys, maxDepth = 5) { if (!obj || typeof obj !== 'object' || maxDepth <= 0) return null; // Búsqueda en nivel actual (caso más común) for (const key of keys) { if (obj.hasOwnProperty(key) && obj[key] !== null && obj[key] !== undefined) { return obj[key]; } } // Búsqueda en niveles anidados const queue = []; for (const [key, value] of Object.entries(obj)) { if (value && typeof value === 'object' && !Array.isArray(value)) { queue.push({ obj: value, depth: 1 }); } } while (queue.length > 0) { const { obj: currentObj, depth } = queue.shift(); if (depth >= maxDepth) continue; for (const key of keys) { if (currentObj.hasOwnProperty(key) && currentObj[key] !== null && currentObj[key] !== undefined) { return currentObj[key]; } } for (const [key, value] of Object.entries(currentObj)) { if (value && typeof value === 'object' && !Array.isArray(value)) { queue.push({ obj: value, depth: depth + 1 }); } } } return null; } /** * Normalización mejorada de tiempo de expiración con soporte para múltiples formatos */ static normalizeExpirationTime(expiresValue) { if (!expiresValue) return undefined; // Número: puede ser timestamp o segundos relativos if (typeof expiresValue === 'number') { // Si es un timestamp (mayor a 1609459200 = 1 Jan 2021) if (expiresValue > 1609459200) { const now = Math.floor(Date.now() / 1000); return Math.max(0, expiresValue - now); } // Si no, son segundos relativos return Math.max(0, expiresValue); } // String: intentar parsear como fecha o timestamp if (typeof expiresValue === 'string') { // Formato de fecha (ISO, Laravel Sanctum, etc.) if (expiresValue.includes('T') || expiresValue.includes('-') || 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); } } // Timestamp como string const timestampSeconds = parseInt(expiresValue); if (!isNaN(timestampSeconds)) { if (timestampSeconds > 1609459200) { const now = Math.floor(Date.now() / 1000); return Math.max(0, timestampSeconds - now); } return Math.max(0, timestampSeconds); } } console.warn('Could not parse expiration time:', expiresValue, typeof expiresValue); return undefined; } /** * Extracción mejorada de tokens con soporte para múltiples formatos */ static extractTokens(response) { const accessToken = this.enhancedDeepSearch(response, this.TOKEN_KEYS); const refreshToken = this.enhancedDeepSearch(response, this.REFRESH_TOKEN_KEYS); const tokenType = this.enhancedDeepSearch(response, this.TOKEN_TYPE_KEYS); if (!accessToken) { throw new Error('No access token found in response'); } // Extraer múltiples formatos de expiración const expiresIn = this.extractExpirationTime(response); const expiresAt = this.enhancedDeepSearch(response, ['expires_at', 'expiresAt', 'rt_expires_at']); const tokens = { accessToken, refreshToken, expiresIn, expiresAt, tokenType: tokenType || 'Bearer', // NUEVO: Preservar respuesta original para debugging _originalTokenResponse: response }; return tokens; } /** * Extracción de tiempo de expiración mejorada */ static extractExpirationTime(response) { const expiresValue = this.enhancedDeepSearch(response, this.EXPIRES_KEYS); return this.normalizeExpirationTime(expiresValue); } /** * Extracción flexible de usuario con preservación completa de datos */ static extractUser(response) { // Buscar datos de usuario usando paths específicos const userData = this.deepSearchByPaths(response, this.USER_SEARCH_PATHS); if (!userData || typeof userData !== 'object') { // Fallback: intentar construir desde campos dispersos return this.buildUserFromScatteredFields(response); } // Detectar tipo de backend basado en estructura const backendType = this.detectBackendType(response, userData); // Extraer campos estándar con mapeo flexible const standardUser = this.mapToStandardUser(userData); // CLAVE: Preservar TODOS los datos originales const enhancedUser = { ...standardUser, // Preservar campos originales que no están en el mapping ...this.preserveUnmappedFields(userData, standardUser), // Metadatos para debugging y flexibilidad _originalUserResponse: response, _backendType: backendType }; return enhancedUser; } /** * Mapea campos de usuario a formato estándar */ static mapToStandardUser(userData) { return { id: this.findUserField(userData, ['_id', 'id', 'user_id', 'userId']) || 'unknown', email: this.findUserField(userData, ['email', 'user_email', 'userEmail', 'correo']), name: this.buildUserName(userData), // Campos adicionales comunes firstName: this.findUserField(userData, ['firstName', 'first_name', 'nombre']), lastName: this.findUserField(userData, ['lastName', 'last_name', 'apellido']), role: this.findUserField(userData, ['role', 'rol', 'roles', 'authorities']), permissions: this.extractPermissions(userData), isActive: this.findUserField(userData, ['isActive', 'is_active', 'active', 'activo']), profile: this.findUserField(userData, ['profile', 'perfil', 'profileData']) }; } /** * Construye el nombre completo del usuario */ static buildUserName(userData) { const firstName = this.findUserField(userData, ['firstName', 'first_name', 'nombre']); const lastName = this.findUserField(userData, ['lastName', 'last_name', 'apellido']); const fullName = this.findUserField(userData, ['name', 'fullName', 'full_name', 'nombreCompleto']); const email = this.findUserField(userData, ['email', 'correo']); if (fullName) return fullName; if (firstName && lastName) return `${firstName} ${lastName}`; if (firstName) return firstName; if (email) return email; return 'Usuario'; } /** * Busca un campo usando múltiples nombres posibles */ static findUserField(userData, fieldNames) { for (const field of fieldNames) { if (userData[field] !== undefined && userData[field] !== null) { return userData[field]; } } return undefined; } /** * Extrae permisos en formato array */ static extractPermissions(userData) { const perms = this.findUserField(userData, ['permissions', 'permisos', 'authorities', 'roles']); if (Array.isArray(perms)) return perms; if (typeof perms === 'string') return [perms]; return []; } /** * Preserva campos que no están en el mapping estándar */ static preserveUnmappedFields(originalData, mappedData) { const preserved = {}; const standardFields = new Set(Object.keys(mappedData)); for (const [key, value] of Object.entries(originalData)) { // Preservar si no está en campos estándar y no es un campo interno if (!standardFields.has(key) && !key.startsWith('_')) { preserved[key] = value; } } return preserved; } /** * Detecta el tipo de backend basado en la estructura de respuesta */ static detectBackendType(response, userData) { // Laravel Sanctum if (response.resultado?.data || response.token?.includes?.('|')) { return 'laravel-sanctum'; } // Node.js/Express estándar if (response.data?.user || response.success !== undefined) { return 'node-express'; } // JWT puro if (response.accessToken && !response.data) { return 'jwt-standard'; } return 'unknown'; } /** * Construye usuario desde campos dispersos cuando no hay estructura clara */ static buildUserFromScatteredFields(response) { const id = this.enhancedDeepSearch(response, ['id', '_id', 'user_id', 'userId']); const email = this.enhancedDeepSearch(response, ['email', 'user_email', 'userEmail']); const name = this.enhancedDeepSearch(response, ['name', 'username', 'user_name', 'fullName']); if (!id && !email && !name) { // Último recurso: extraer desde JWT token const token = this.enhancedDeepSearch(response, this.TOKEN_KEYS); if (token) { const userFromToken = this.parseUserFromToken(token); if (userFromToken) { return { ...userFromToken, _originalUserResponse: response, _backendType: 'jwt-parsed' }; } } return null; } return { id: id || 'unknown', email, name: name || email || 'Usuario', _originalUserResponse: response, _backendType: 'scattered-fields' }; } /** * Parsea información de usuario desde JWT token */ static parseUserFromToken(token) { try { // Skip tokens de Sanctum (contienen |) if (token.includes('|')) return null; const parts = token.split('.'); if (parts.length !== 3) return null; const base64Payload = parts[1]; const paddedBase64 = base64Payload.padEnd(base64Payload.length + (4 - (base64Payload.length % 4)) % 4, '='); const payload = JSON.parse(atob(paddedBase64)); return { id: payload.sub || payload.user_id || payload.id || payload.userId || 'unknown', name: payload.name || payload.username || payload.firstName || payload.email || 'Usuario', email: payload.email, firstName: payload.firstName || payload.first_name, lastName: payload.lastName || payload.last_name, role: payload.role || payload.roles?.[0], permissions: payload.permissions || payload.authorities || [], // Preservar todos los campos del JWT ...payload }; } catch (error) { console.warn('Error parsing user from JWT:', error); return null; } } /** * Método de debug mejorado con más información */ static debugResponse(response, depth = 0) { const indent = ' '.repeat(depth); console.group(`${indent}🔍 Enhanced Response Analysis (depth: ${depth})`); if (response && typeof response === 'object') { console.log(`${indent}📊 Object keys:`, Object.keys(response)); // Analizar estructura para diferentes backends const backendType = this.detectBackendType(response, response); console.log(`${indent}🔧 Detected backend type:`, backendType); // Tokens detectados const possibleTokens = Object.keys(response).filter(key => this.TOKEN_KEYS.some(tokenKey => key.toLowerCase().includes(tokenKey.toLowerCase()))); if (possibleTokens.length > 0) { console.log(`${indent}🔑 Possible token fields:`, possibleTokens); } // Usuarios detectados const possibleUsers = Object.keys(response).filter(key => ['user', 'data', 'profile', 'account'].some(userKey => key.toLowerCase().includes(userKey.toLowerCase()))); if (possibleUsers.length > 0) { console.log(`${indent}👤 Possible user fields:`, possibleUsers); } // Paths de búsqueda console.log(`${indent}🔍 Searching in paths:`, this.USER_SEARCH_PATHS); if (depth < 2) { for (const [key, value] of Object.entries(response)) { if (value && typeof value === 'object' && !Array.isArray(value)) { console.log(`${indent}📂 Analyzing nested object: ${key}`); this.debugResponse(value, depth + 1); } } } } else { console.log(`${indent}📝 Primitive value:`, typeof response, response); } console.groupEnd(); } /** * Limpiar cache (llamar periódicamente para evitar memory leaks) */ static clearCache() { this.searchCache.clear(); } /** * Test de extracción con diferentes formatos */ static testExtraction(response) { console.group('🧪 Testing Token and User Extraction'); try { console.log('📥 Original response:', response); // Test token extraction console.log('🔑 Testing token extraction...'); const tokens = this.extractTokens(response); console.log('✅ Extracted tokens:', tokens); // Test user extraction console.log('👤 Testing user extraction...'); const user = this.extractUser(response); console.log('✅ Extracted user:', user); console.log('🎉 Extraction test completed successfully!'); } catch (error) { console.error('❌ Extraction test failed:', error); } console.groupEnd(); } } TokenExtractor.TOKEN_KEYS = [ 'accessToken', 'access_token', 'token', 'authToken', 'auth_token', 'bearerToken', 'bearer_token', 'jwt', 'jwtToken', 'jwt_token' ]; TokenExtractor.REFRESH_TOKEN_KEYS = [ 'refreshToken', 'refresh_token', 'renewToken', 'renew_token', 'rt_expires_at' // Para manejar casos donde el refresh viene con metadata ]; TokenExtractor.EXPIRES_KEYS = [ 'expiresIn', 'expires_in', 'exp', 'expiration', 'expires_at', 'expiresAt', 'expiry', 'expiry_time', 'expiryTime', 'valid_until', 'validUntil', 'rt_expires_at' // Para Sanctum Laravel ]; TokenExtractor.TOKEN_TYPE_KEYS = [ 'tokenType', 'token_type', 'type', 'authType', 'auth_type' ]; TokenExtractor.USER_SEARCH_PATHS = [ 'data.user', // Node.js/Express estándar 'resultado.data', // Laravel Sanctum 'user', // JWT o respuesta directa 'data', // Respuesta con data wrapper 'profile', // Algunos sistemas usan profile 'userInfo', // Otros usan userInfo 'account', // Account-based systems '' // Raíz del objeto ]; // Cache mejorado con TTL TokenExtractor.searchCache = new Map(); TokenExtractor.CACHE_TTL = 5000; 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(); } } } 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(); }); } } /** * 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)); } } catch (error) { console.error('Error updating last refresh time:', error); } } /** * Clear only token storage */ async clearTokens() { try { await Promise.all([ this.storageAdapter.removeItem(this.config.tokenKey), this.storageAdapter.removeItem(this.config.refreshTokenKey) ]); console.debug('Tokens cleared successfully'); } catch (error) { console.error('Error clearing tokens:', error); } } /** * Clear only user storage */ async clearUser() { try { await this.storageAdapter.removeItem(this.config.userKey); console.debug('User data cleared successfully'); } catch (error) { console.error('Error clearing user data:', error); } } /** * Clear all authentication storage */ async clearAll() { try { await Promise.all([ this.clearTokens(), this.clearUser() ]); console.debug('All authentication data cleared successfully'); } catch (error) { console.error('Error clearing all storage:', error); } } /** * Check if storage contains valid session data */ async hasValidSession() { try { const tokens = await this.getStoredTokens(); const user = await this.getStoredUser(); return !!(tokens?.accessToken && user?.id); } catch { return false; } } /** * Generate unique session ID */ generateSessionId() { return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } /** * Get current session ID */ async getCurrentSessionId() { try { const metadata = await this.getTokenMetadata(); return metadata?.sessionId || null; } catch { return null; } } /** * Migrate from old storage format to new format */ async migrateStorage() { try { const metadata = await this.getTokenMetadata(); // If no version info, this is legacy storage if (!metadata?.version) { console.log('Migrating legacy storage format...'); const tokens = await this.getStoredTokens(); const user = await this.getStoredUser(); if (tokens && user) { // Re-store with new format await this.storeTokens(tokens); await this.storeUser(user); console.log('Storage migration completed successfully'); } } } catch (error) { console.error('Error during storage migration:', error); } } /** * Get storage adapter for advanced operations */ getAdapter() { return this.storageAdapter; } /** * Get storage configuration */ getConfig() { return this.config; } } 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; } } /** * 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(); } /** * 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, }, { 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, }); } 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); // NUEVO: Si el servidor rechaza el refresh token, limpiar storage if (errorMessage.includes('inválidos') || errorMessage.includes('invalid') || errorMessage.includes('expired') || errorMessage.includes('requerido')) { console.warn('Refresh token seems invalid, 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.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) { console.error('Error processing refresh response:', error); // Debug the response structure TokenExtractor.debugResponse(response); 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();