/** * TokenManager - Robust token lifecycle management for field apps * * Features: * - 3-tier storage (PB authStore → IndexedDB → localStorage) * - Continuous background refresh (every 20 min or at 80% expiry) * - Network resilience (exponential backoff retry queue) * - Never forces logout on token expiry * - Cross-tab sync via IndexedDB */ class TokenManager { constructor(pbInstance) { this.pb = pbInstance; this.token = null; this.expiresAt = null; this.isRefreshing = false; this.refreshTimer = null; this.retryQueue = []; this.retryAttempts = 0; this.maxRetries = 5; this.DB_NAME = 'JobInfoDB'; this.STORE_NAME = 'auth'; this.TOKEN_KEY = 'graphToken'; this.EXPIRY_KEY = 'graphTokenExpiresAt'; this.REFRESH_INTERVAL = 20 * 60 * 1000; // 20 minutes this.EXPIRY_THRESHOLD = 0.8; // Refresh at 80% expiry } /** * Initialize: Load token from all storage tiers */ async init() { console.log('[TokenManager] Initializing...'); // Try IndexedDB first (most reliable for offline) this.token = await this.getFromIndexedDB(this.TOKEN_KEY); this.expiresAt = await this.getFromIndexedDB(this.EXPIRY_KEY); // Fallback to localStorage if (!this.token) { this.token = localStorage.getItem('graphAccessToken'); this.expiresAt = localStorage.getItem('graphTokenExpiresAt'); } // Fallback to PB authStore if (!this.token && this.pb?.authStore?.isValid) { const meta = this.pb.authStore.model?.meta || {}; this.token = meta.graphAccessToken || meta.graph_token || meta.graphToken; this.expiresAt = meta.graphTokenExpiresAt; } if (this.token) { console.log('[TokenManager] Token loaded from storage, expires:', new Date(this.expiresAt).toISOString()); } else { console.log('[TokenManager] No token found in any storage tier'); } // Start refresh cycle this.startRefreshCycle(); } /** * Get token with intelligent fallback * Returns token if available, null only if genuinely needed to re-authenticate */ async getToken() { // If we have a valid token, return it if (this.token && this.isTokenValid()) { return this.token; } // Try to refresh if we have a PB session if (this.pb?.authStore?.isValid && !this.isRefreshing) { const refreshed = await this.refreshToken(); if (refreshed) { return this.token; } } // If we have an expired token but no network, use stale token anyway // (APIs may still accept it, and we avoid unnecessary re-auth prompts) if (this.token && !navigator.onLine) { console.log('[TokenManager] Using stale token (offline)'); return this.token; } // Only return null if we truly have nothing return null; } /** * Set token (syncs to all storage tiers) */ async setToken(token, expiresAt) { this.token = token; this.expiresAt = expiresAt || new Date(Date.now() + 3600000).toISOString(); // Default 1 hour console.log('[TokenManager] Storing token, expires:', new Date(this.expiresAt).toISOString()); // Sync to all storage tiers atomically await Promise.all([ this.setInIndexedDB(this.TOKEN_KEY, token), this.setInIndexedDB(this.EXPIRY_KEY, this.expiresAt), this.setInLocalStorage('graphAccessToken', token), this.setInLocalStorage('graphTokenExpiresAt', this.expiresAt), ]); // Update PB authStore metadata if (this.pb?.authStore?.model) { this.pb.authStore.model.meta = { ...(this.pb.authStore.model.meta || {}), graphAccessToken: token, graphTokenExpiresAt: this.expiresAt, }; } } /** * Check if token is still valid (80% threshold to trigger refresh) */ isTokenValid() { if (!this.token || !this.expiresAt) return false; const now = Date.now(); const expiry = new Date(this.expiresAt).getTime(); const timeToExpiry = expiry - now; const totalLifetime = expiry - (new Date(this.expiresAt).getTime() - 3600000); // Assume 1hr lifetime // Token is valid if we're before the refresh threshold (80%) return timeToExpiry > totalLifetime * (1 - this.EXPIRY_THRESHOLD); } /** * Refresh token from PB */ async refreshToken() { if (this.isRefreshing) { console.log('[TokenManager] Refresh already in progress'); return this.token; // Return existing token while refresh ongoing } if (!this.pb?.authStore?.isValid) { console.log('[TokenManager] PB session invalid, cannot refresh'); return null; } this.isRefreshing = true; try { console.log('[TokenManager] Refreshing token...'); const authData = await this.pb.collection('users').authRefresh(); const meta = authData?.meta || {}; const newToken = meta.graphAccessToken || meta.graph_token || meta.graphToken; const newExpiry = meta.graphTokenExpiresAt || new Date(Date.now() + 3600000).toISOString(); if (newToken) { await this.setToken(newToken, newExpiry); console.log('[TokenManager] Token refreshed successfully'); this.retryAttempts = 0; // Reset retry counter on success return newToken; } else { console.warn('[TokenManager] No token in refresh response'); return null; } } catch (err) { console.error('[TokenManager] Refresh failed:', err.message); this.queueRefreshRetry(); return this.token; // Return stale token while retrying } finally { this.isRefreshing = false; } } /** * Queue retry with exponential backoff */ queueRefreshRetry() { if (this.retryAttempts >= this.maxRetries) { console.error('[TokenManager] Max refresh retries reached'); return; } this.retryAttempts++; const backoffMs = Math.pow(2, this.retryAttempts - 1) * 1000; // 1s, 2s, 4s, 8s, 16s console.log(`[TokenManager] Retry ${this.retryAttempts}/${this.maxRetries} in ${backoffMs}ms`); setTimeout(() => { this.refreshToken().catch(err => { console.error('[TokenManager] Retry failed:', err.message); }); }, backoffMs); } /** * Start continuous refresh cycle */ startRefreshCycle() { if (this.refreshTimer) clearInterval(this.refreshTimer); // Initial refresh if token is close to expiry if (this.token && !this.isTokenValid()) { this.refreshToken(); } // Refresh every REFRESH_INTERVAL this.refreshTimer = setInterval(() => { if (this.pb?.authStore?.isValid) { this.refreshToken().catch(err => { console.error('[TokenManager] Scheduled refresh failed:', err.message); }); } }, this.REFRESH_INTERVAL); console.log('[TokenManager] Refresh cycle started (every', this.REFRESH_INTERVAL / 1000, 'seconds)'); } /** * Stop refresh cycle (e.g., on logout) */ stopRefreshCycle() { if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null; } } /** * Clear token from all storage tiers */ async clearToken() { this.token = null; this.expiresAt = null; this.retryAttempts = 0; await Promise.all([ this.deleteFromIndexedDB(this.TOKEN_KEY), this.deleteFromIndexedDB(this.EXPIRY_KEY), this.deleteFromLocalStorage('graphAccessToken'), this.deleteFromLocalStorage('graphTokenExpiresAt'), ]); console.log('[TokenManager] Token cleared from all storage'); } /** * IndexedDB helpers */ async getIndexedDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(this.DB_NAME, 1); req.onupgradeneeded = () => { req.result.createObjectStore(this.STORE_NAME); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async getFromIndexedDB(key) { try { const db = await this.getIndexedDB(); return new Promise((resolve, reject) => { const tx = db.transaction(this.STORE_NAME, 'readonly'); const req = tx.objectStore(this.STORE_NAME).get(key); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } catch (err) { console.warn('[TokenManager] IndexedDB read failed:', err.message); return null; } } async setInIndexedDB(key, value) { try { const db = await this.getIndexedDB(); return new Promise((resolve, reject) => { const tx = db.transaction(this.STORE_NAME, 'readwrite'); const req = tx.objectStore(this.STORE_NAME).put(value, key); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); } catch (err) { console.warn('[TokenManager] IndexedDB write failed:', err.message); } } async deleteFromIndexedDB(key) { try { const db = await this.getIndexedDB(); return new Promise((resolve) => { const tx = db.transaction(this.STORE_NAME, 'readwrite'); tx.objectStore(this.STORE_NAME).delete(key); resolve(); }); } catch (err) { console.warn('[TokenManager] IndexedDB delete failed:', err.message); } } /** * localStorage helpers (fallback) */ setInLocalStorage(key, value) { try { localStorage.setItem(key, value); } catch (err) { console.warn('[TokenManager] localStorage write failed:', err.message); } } deleteFromLocalStorage(key) { try { localStorage.removeItem(key); } catch (err) { console.warn('[TokenManager] localStorage delete failed:', err.message); } } /** * Network status listener */ onNetworkRecover() { console.log('[TokenManager] Network recovered, refreshing token...'); this.refreshToken(); } } // Export for use if (typeof module !== 'undefined' && module.exports) { module.exports = TokenManager; }