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
JavaScript
'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();