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
JavaScript
// 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
};