Files

319 lines
9.2 KiB
JavaScript

/**
* 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) {
return this.token; // Return existing token while refresh ongoing
}
if (!this.pb?.authStore?.isValid) {
return null;
}
this.isRefreshing = true;
try {
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);
this.retryAttempts = 0;
return newToken;
} else {
// PB authRefresh doesn't return Graph tokens - this is expected
// Token must be obtained during initial OAuth flow
return this.token; // Return existing token if we have one
}
} catch (err) {
console.error('[TokenManager] PB authRefresh failed:', err.message);
return this.token; // Return stale token on error
} finally {
this.isRefreshing = false;
}
}
/**
* Queue retry with exponential backoff
*/
queueRefreshRetry() {
// Disable retry queue since PB authRefresh won't return Graph tokens
// Graph tokens must be obtained through the initial OAuth flow
return;
}
/**
* 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;
}