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