UNPKG

manage-token-sessions

Version:

A flexible token session manager for handling access/refresh token pairs with automatic refresh and cross-domain support

812 lines (804 loc) 26 kB
// src/storage/cookieStorage.ts import Cookies from "js-cookie"; // src/types/index.ts var TokenSessionError = class extends Error { constructor(message, code) { super(message); this.code = code; this.name = "TokenSessionError"; } }; var RefreshTokenError = class extends TokenSessionError { constructor(message, originalError) { super(message, "REFRESH_TOKEN_ERROR"); this.originalError = originalError; this.name = "RefreshTokenError"; } }; var InvalidTokenError = class extends TokenSessionError { constructor(message) { super(message, "INVALID_TOKEN_ERROR"); this.name = "InvalidTokenError"; } }; var StorageError = class extends TokenSessionError { constructor(message, originalError) { super(message, "STORAGE_ERROR"); this.originalError = originalError; this.name = "StorageError"; } }; var DuplicateHandlerError = class extends TokenSessionError { constructor(message) { super(message, "DUPLICATE_HANDLER_ERROR"); this.name = "DuplicateHandlerError"; } }; // src/storage/cookieStorage.ts var CookieStorageAdapter = class { constructor(defaultOptions = {}) { this.defaultOptions = { path: "/", secure: typeof window !== "undefined" && window.location.protocol === "https:", sameSite: "lax", ...defaultOptions }; } get(key) { try { if (typeof window === "undefined") { return null; } return Cookies.get(key) || null; } catch (error) { throw new StorageError( `Failed to get cookie: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error : void 0 ); } } set(key, value, options) { try { if (typeof window === "undefined") { throw new StorageError("Cookies are not available in this environment"); } const mergedOptions = { ...this.defaultOptions, ...options }; const cookieOptions = {}; if (mergedOptions.domain) { cookieOptions.domain = mergedOptions.domain; } if (mergedOptions.path) { cookieOptions.path = mergedOptions.path; } if (mergedOptions.secure !== void 0) { cookieOptions.secure = mergedOptions.secure; } if (mergedOptions.sameSite) { cookieOptions.sameSite = mergedOptions.sameSite; } if (mergedOptions.expires) { if (mergedOptions.expires instanceof Date) { cookieOptions.expires = mergedOptions.expires; } else if (typeof mergedOptions.expires === "number") { cookieOptions.expires = mergedOptions.expires; } } else if (mergedOptions.ttl) { cookieOptions.expires = mergedOptions.ttl / (24 * 60 * 60); } Cookies.set(key, value, cookieOptions); } catch (error) { throw new StorageError( `Failed to set cookie: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error : void 0 ); } } remove(key, options) { try { if (typeof window === "undefined") { return; } const mergedOptions = { ...this.defaultOptions, ...options }; const cookieOptions = {}; if (mergedOptions.domain) { cookieOptions.domain = mergedOptions.domain; } if (mergedOptions.path) { cookieOptions.path = mergedOptions.path; } Cookies.remove(key, cookieOptions); } catch (error) { throw new StorageError( `Failed to remove cookie: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error : void 0 ); } } }; // src/storage/localStorage.ts var LocalStorageAdapter = class { get(key) { try { if (typeof window === "undefined" || !window.localStorage) { return null; } return window.localStorage.getItem(key); } catch (error) { throw new StorageError( `Failed to get item from localStorage: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error : void 0 ); } } set(key, value, _options) { try { if (typeof window === "undefined" || !window.localStorage) { throw new StorageError("localStorage is not available"); } window.localStorage.setItem(key, value); } catch (error) { throw new StorageError( `Failed to set item in localStorage: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error : void 0 ); } } remove(key, _options) { try { if (typeof window === "undefined" || !window.localStorage) { return; } window.localStorage.removeItem(key); } catch (error) { throw new StorageError( `Failed to remove item from localStorage: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error : void 0 ); } } }; // src/storage/memoryStorage.ts var MemoryStorageAdapter = class { constructor() { this.storage = /* @__PURE__ */ new Map(); } get(key) { try { const item = this.storage.get(key); if (!item) { return null; } if (item.expiresAt && Date.now() > item.expiresAt) { this.storage.delete(key); return null; } return item.value; } catch (error) { throw new StorageError( `Failed to get item from memory storage: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error : void 0 ); } } set(key, value, options) { try { let expiresAt; if (options?.ttl) { expiresAt = Date.now() + options.ttl * 1e3; } else if (options?.expires) { if (options.expires instanceof Date) { expiresAt = options.expires.getTime(); } else if (typeof options.expires === "number") { expiresAt = Date.now() + options.expires * 24 * 60 * 60 * 1e3; } } this.storage.set(key, { value, expiresAt }); } catch (error) { throw new StorageError( `Failed to set item in memory storage: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error : void 0 ); } } remove(key, _options) { try { this.storage.delete(key); } catch (error) { throw new StorageError( `Failed to remove item from memory storage: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error : void 0 ); } } /** * Clear all stored items (useful for testing) */ clear() { this.storage.clear(); } /** * Get all keys (useful for testing) */ keys() { return Array.from(this.storage.keys()); } }; // src/storage/sessionStorage.ts var SessionStorageAdapter = class { get(key) { try { if (typeof window === "undefined" || !window.sessionStorage) { return null; } return window.sessionStorage.getItem(key); } catch (error) { throw new StorageError( `Failed to get item from sessionStorage: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error : void 0 ); } } set(key, value, _options) { try { if (typeof window === "undefined" || !window.sessionStorage) { throw new StorageError("sessionStorage is not available"); } window.sessionStorage.setItem(key, value); } catch (error) { throw new StorageError( `Failed to set item in sessionStorage: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error : void 0 ); } } remove(key, _options) { try { if (typeof window === "undefined" || !window.sessionStorage) { return; } window.sessionStorage.removeItem(key); } catch (error) { throw new StorageError( `Failed to remove item from sessionStorage: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error : void 0 ); } } }; // src/utils/jwt.ts function base64UrlDecode(str) { let base64 = str.replace(/-/g, "+").replace(/_/g, "/"); while (base64.length % 4) { base64 += "="; } try { if (typeof window !== "undefined" && window.atob) { return window.atob(base64); } if (typeof Buffer !== "undefined") { return Buffer.from(base64, "base64").toString("utf-8"); } throw new Error("No base64 decode method available"); } catch (error) { throw new InvalidTokenError( `Failed to decode base64: ${error instanceof Error ? error.message : "Unknown error"}` ); } } function decodeJWT(token) { if (!token || typeof token !== "string") { throw new InvalidTokenError("Token must be a non-empty string"); } const parts = token.split("."); if (parts.length !== 3) { throw new InvalidTokenError("Invalid JWT format: token must have 3 parts separated by dots"); } const [headerPart, payloadPart, signaturePart] = parts; if (!headerPart || !payloadPart || !signaturePart) { throw new InvalidTokenError("Invalid JWT format: all parts must be non-empty"); } try { const headerJson = base64UrlDecode(headerPart); const header = JSON.parse(headerJson); const payloadJson = base64UrlDecode(payloadPart); const payload = JSON.parse(payloadJson); return { header, payload, signature: signaturePart }; } catch (error) { if (error instanceof InvalidTokenError) { throw error; } throw new InvalidTokenError( `Failed to decode JWT: ${error instanceof Error ? error.message : "Unknown error"}` ); } } function getTokenExpiration(token) { try { const decoded = decodeJWT(token); return decoded.payload.exp || null; } catch (_error) { return null; } } function isTokenExpired(token, bufferSeconds = 0) { const exp = getTokenExpiration(token); if (!exp) { return true; } const now = Math.floor(Date.now() / 1e3); return exp - bufferSeconds <= now; } function getTimeUntilExpiration(token) { const exp = getTokenExpiration(token); if (!exp) { return null; } const now = Math.floor(Date.now() / 1e3); const timeRemaining = exp - now; return Math.max(0, timeRemaining); } function validateTokenClaims(token, requiredClaims = []) { try { const decoded = decodeJWT(token); for (const claim of requiredClaims) { if (!(claim in decoded.payload)) { return false; } } return true; } catch (_error) { return false; } } // src/TokenSessionManager.ts var DEFAULT_CONFIG = { storageKey: "@token-sessions@", debug: false // Debug logging disabled by default }; var TokenSessionManager = class { constructor(config) { /** * Promise cache for in-flight refresh requests * Prevents race conditions when multiple requests try to refresh simultaneously */ this.refreshPromise = null; this.config = { ...DEFAULT_CONFIG, storage: new LocalStorageAdapter(), ...config }; this.log("constructor", "TokenSessionManager initialized successfully"); } /** * Debug logger - logs detailed information when debug mode is enabled */ log(context, message, data) { if (!this.config.debug) { return; } const timestamp = (/* @__PURE__ */ new Date()).toISOString(); const prefix = `[TokenSessionManager][${timestamp}][${context}]`; if (data) { console.log(`${prefix} ${message}`, data); } else { console.log(`${prefix} ${message}`); } } /** * Start a new session with the provided token pair */ async startSession(tokens, metadata) { this.log("startSession", "Starting new session with provided tokens"); try { const now = Math.floor(Date.now() / 1e3); let expiresAt; if (this.config.customSessionExpirationTime) { expiresAt = now + this.config.customSessionExpirationTime; this.log("startSession", "Using custom session expiration time", { customSessionExpirationTime: this.config.customSessionExpirationTime, expiresAt, expiresInSeconds: this.config.customSessionExpirationTime, expiresInMinutes: Math.floor(this.config.customSessionExpirationTime / 60), expiresAtDate: new Date(expiresAt * 1e3).toISOString() }); } else { expiresAt = this.extractExpirationTime(tokens.accessToken); const expiresInSeconds = expiresAt - now; this.log("startSession", "Extracted token expiration", { expiresAt, expiresInSeconds, expiresInMinutes: Math.floor(expiresInSeconds / 60), expiresAtDate: new Date(expiresAt * 1e3).toISOString() }); } const session = { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt, metadata }; this.log("startSession", "Storing session in storage", { storageKey: this.config.storageKey, hasMetadata: !!metadata }); await this.storeSession(session); this.log("startSession", "Session started successfully"); this.config.onSessionStarted?.(session); } catch (error) { this.log("startSession", "Error starting session", { error: error instanceof Error ? error.message : String(error) }); const sessionError = error instanceof Error ? error : new Error("Unknown error starting session"); this.config.onSessionError?.(sessionError); throw sessionError; } } /** * Get the current access token, automatically refreshing if expired * This is the main method that handles all token refresh logic * Uses promise deduplication to prevent race conditions when multiple calls happen simultaneously */ async getCurrentAccessToken() { this.log("getCurrentAccessToken", "Getting current access token"); try { if (this.refreshPromise) { this.log( "getCurrentAccessToken", "Refresh already in progress, returning existing promise" ); return await this.refreshPromise; } const session = await this.getStoredSession(); if (!session) { this.log("getCurrentAccessToken", "No session found in storage"); return null; } const now = Math.floor(Date.now() / 1e3); const isExpired = session.expiresAt <= now; this.log("getCurrentAccessToken", "Session found", { expiresAt: session.expiresAt, timeUntilExpiry: session.expiresAt - now, isExpired, expiresAtDate: new Date(session.expiresAt * 1e3).toISOString() }); if (isExpired) { if (this.refreshPromise) { this.log( "getCurrentAccessToken", "Refresh started by another call, returning existing promise" ); return await this.refreshPromise; } this.log( "getCurrentAccessToken", "Token is expired, starting refresh process" ); this.refreshPromise = this.performRefresh(session, now); try { const result = await this.refreshPromise; return result; } finally { this.refreshPromise = null; } } this.log( "getCurrentAccessToken", "Token is still valid, returning current access token" ); return session.accessToken; } catch (error) { this.log("getCurrentAccessToken", "Error getting access token", { error: error instanceof Error ? error.message : String(error) }); console.error(error); console.trace(error); this.config.onSessionError?.( error instanceof Error ? error : new Error("Unknown error getting access token") ); return null; } } /** * Performs the actual token refresh operation * Separated into its own method to enable promise caching */ async performRefresh(session, now) { this.log("performRefresh", "Executing token refresh"); try { const newTokens = await this.config.refreshTokenFn( session.refreshToken ); this.log( "performRefresh", "Refresh successful, extracting new expiration" ); let newExpiresAt; if (this.config.customSessionExpirationTime) { const refreshNow = Math.floor(Date.now() / 1e3); newExpiresAt = refreshNow + this.config.customSessionExpirationTime; this.log("performRefresh", "Using custom session expiration time after refresh", { customSessionExpirationTime: this.config.customSessionExpirationTime, newExpiresAt, newExpiresAtDate: new Date(newExpiresAt * 1e3).toISOString(), newExpiresInSeconds: this.config.customSessionExpirationTime, newExpiresInMinutes: Math.floor(this.config.customSessionExpirationTime / 60) }); } else { newExpiresAt = this.extractExpirationTime(newTokens.accessToken); const newExpiresInSeconds = newExpiresAt - now; this.log("performRefresh", "New token expiration extracted", { newExpiresAt, newExpiresAtDate: new Date(newExpiresAt * 1e3).toISOString(), newExpiresInSeconds, newExpiresInMinutes: Math.floor(newExpiresInSeconds / 60) }); } const updatedSession = { ...session, accessToken: newTokens.accessToken, refreshToken: newTokens.refreshToken, expiresAt: newExpiresAt }; this.log("performRefresh", "Storing updated session"); await this.storeSession(updatedSession); this.log("performRefresh", "Token refresh completed successfully"); this.config.onSessionRefreshed?.(updatedSession); return updatedSession.accessToken; } catch (error) { console.error(error); console.trace(error); this.log("performRefresh", "Token refresh failed", { error: error instanceof Error ? error.message : String(error) }); const refreshError = error instanceof RefreshTokenError ? error : new RefreshTokenError( `Token refresh failed: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error : void 0 ); this.config.onRefreshError?.(refreshError); this.log("performRefresh", "Clearing session after failed refresh"); await this.clearStoredSession(); this.config.onSessionExpired?.(); return null; } } /** * Get the current session data */ async getCurrentSession() { try { return await this.getStoredSession(); } catch (error) { this.log("getCurrentSession", "Error getting session", { error: error instanceof Error ? error.message : String(error) }); console.error(error); console.trace(error); this.config.onSessionError?.( error instanceof Error ? error : new Error("Unknown error getting session") ); return null; } } /** * End the current session and clean up */ async endSession() { this.log("endSession", "Ending session"); try { this.refreshPromise = null; this.log("endSession", "Clearing session from storage"); await this.clearStoredSession(); this.log("endSession", "Session ended successfully"); this.config.onSessionExpired?.(); } catch (error) { this.log("endSession", "Error ending session", { error: error instanceof Error ? error.message : String(error) }); this.config.onSessionError?.( error instanceof Error ? error : new Error("Unknown error ending session") ); throw error; } } /** * Check if there's an active session */ async hasActiveSession() { try { const session = await this.getStoredSession(); if (!session) { return false; } const now = Math.floor(Date.now() / 1e3); return session.expiresAt > now; } catch (error) { return false; } } /** * Destroy the session manager and clean up resources */ async destroy() { this.log("destroy", "Destroying TokenSessionManager"); this.refreshPromise = null; } /** * Register a handler for session started events * @throws {DuplicateHandlerError} if a handler is already registered */ registerOnSessionStarted(handler) { if (this.config.onSessionStarted) { throw new DuplicateHandlerError( "onSessionStarted handler is already registered. Only one handler can be registered at a time." ); } this.config.onSessionStarted = handler; } /** * Register a handler for session refreshed events * @throws {DuplicateHandlerError} if a handler is already registered */ registerOnSessionRefreshed(handler) { if (this.config.onSessionRefreshed) { throw new DuplicateHandlerError( "onSessionRefreshed handler is already registered. Only one handler can be registered at a time." ); } this.config.onSessionRefreshed = handler; } /** * Register a handler for session expired events * @throws {DuplicateHandlerError} if a handler is already registered */ registerOnSessionExpired(handler) { if (this.config.onSessionExpired) { throw new DuplicateHandlerError( "onSessionExpired handler is already registered. Only one handler can be registered at a time." ); } this.config.onSessionExpired = handler; } /** * Register a handler for session error events * @throws {DuplicateHandlerError} if a handler is already registered */ registerOnSessionError(handler) { if (this.config.onSessionError) { throw new DuplicateHandlerError( "onSessionError handler is already registered. Only one handler can be registered at a time." ); } this.config.onSessionError = handler; } /** * Register a handler for refresh error events * @throws {DuplicateHandlerError} if a handler is already registered */ registerOnRefreshError(handler) { if (this.config.onRefreshError) { throw new DuplicateHandlerError( "onRefreshError handler is already registered. Only one handler can be registered at a time." ); } this.config.onRefreshError = handler; } /** * Extract expiration time from access token */ extractExpirationTime(accessToken) { const exp = getTokenExpiration(accessToken); if (!exp) { throw new InvalidTokenError( "Access token does not contain expiration claim" ); } return exp; } /** * Store session data */ async storeSession(session) { this.log("storeSession", "Storing session in storage", { storageKey: this.config.storageKey, expiresAt: session.expiresAt, hasMetadata: !!session.metadata }); try { const serialized = JSON.stringify(session); await this.config.storage.set(this.config.storageKey, serialized); this.log("storeSession", "Session stored successfully"); } catch (error) { this.log("storeSession", "Failed to store session", { error: error instanceof Error ? error.message : String(error) }); throw new StorageError( `Failed to store session: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error : void 0 ); } } /** * Retrieve stored session data */ async getStoredSession() { this.log("getStoredSession", "Retrieving session from storage", { storageKey: this.config.storageKey }); try { const serialized = await this.config.storage.get(this.config.storageKey); if (!serialized) { this.log("getStoredSession", "No session found in storage"); return null; } const session = JSON.parse(serialized); if (!session.accessToken || !session.refreshToken || !session.expiresAt) { this.log( "getStoredSession", "Invalid session data structure found in storage" ); throw new StorageError("Invalid session data structure"); } const now = Math.floor(Date.now() / 1e3); const timeUntilExpiry = session.expiresAt - now; this.log("getStoredSession", "Session retrieved successfully", { expiresAt: session.expiresAt, timeUntilExpiry, hasMetadata: !!session.metadata }); return session; } catch (error) { this.log("getStoredSession", "Failed to retrieve session", { error: error instanceof Error ? error.message : String(error) }); if (error instanceof StorageError) { throw error; } throw new StorageError( `Failed to retrieve session: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error : void 0 ); } } /** * Clear stored session data */ async clearStoredSession() { this.log("clearStoredSession", "Clearing session from storage", { storageKey: this.config.storageKey }); try { await this.config.storage.remove(this.config.storageKey); this.log("clearStoredSession", "Session cleared successfully"); } catch (error) { this.log("clearStoredSession", "Failed to clear session", { error: error instanceof Error ? error.message : String(error) }); throw new StorageError( `Failed to clear session: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error : void 0 ); } } }; export { CookieStorageAdapter, DuplicateHandlerError, InvalidTokenError, LocalStorageAdapter, MemoryStorageAdapter, RefreshTokenError, SessionStorageAdapter, StorageError, TokenSessionError, TokenSessionManager, decodeJWT, getTimeUntilExpiration, getTokenExpiration, isTokenExpired, validateTokenClaims };