@joinmeow/cognito-passwordless-auth
Version:
Passwordless authentication with Amazon Cognito: FIDO2 (WebAuthn, support for Passkeys)
788 lines (787 loc) • 34.7 kB
JavaScript
/**
* Copyright Amazon.com, Inc. and its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License"). You
* may not use this file except in compliance with the License. A copy of
* the License is located at
*
* http://aws.amazon.com/apache2.0/
*
* or in the "license" file accompanying this file. This file is
* distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
* ANY KIND, either express or implied. See the License for the specific
* language governing permissions and limitations under the License.
*/
import { configure, getTokenEndpoint } from "./config.js";
import { retrieveTokens, retrieveTokensForRefresh } from "./storage.js";
import { getTokensFromRefreshToken, initiateAuth } from "./cognito-api.js";
import { setTimeoutWallClock } from "./util.js";
import { processTokens } from "./common.js";
import { parseJwtPayload } from "./util.js";
import { withStorageLock, LockTimeoutError } from "./lock.js";
// Per-user refresh state to prevent conflicts
const refreshStateMap = new Map();
// Get or create refresh state for a user
function getRefreshState(username) {
if (!username) {
// For operations without a username (like initial page load),
// create a temporary state that won't interfere with user-specific states
return { isRefreshing: false };
}
let state = refreshStateMap.get(username);
if (!state) {
state = { isRefreshing: false };
refreshStateMap.set(username, state);
}
return state;
}
// Clear refresh state for a user
function clearRefreshState(username) {
const key = username || "default";
refreshStateMap.delete(key);
}
// Track cleanup functions
let watchdogCleanup;
let visibilityChangeListener;
let autoCleanupHandler;
// Max consecutive refresh failures before giving up
const MAX_CONSECUTIVE_REFRESH_FAILURES = 5;
let consecutiveRefreshFailures = 0;
// Generate unique tab ID for this tab
const TAB_ID = typeof globalThis.crypto !== "undefined" && globalThis.crypto.randomUUID
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
/**
* Refresh coordination using a probabilistic approach with timestamp tracking
*
* Since localStorage doesn't provide atomic operations, we use a combination of:
* 1. Timestamp-based coordination to prevent refresh storms
* 2. Random jitter to reduce collision probability
* 3. Simple last-write-wins semantics
*/
async function shouldAttemptRefresh() {
try {
const { storage, clientId } = configure();
const tokens = await retrieveTokens();
if (!tokens?.username)
return false;
const attemptKey = `Passwordless.${clientId}.${tokens.username}.lastRefreshAttempt`;
const REFRESH_WINDOW_MS = 5000; // Don't refresh if another tab did within 5s
const RANDOM_JITTER_MAX_MS = 1000; // Increased to 1 second for better collision avoidance
// Add random jitter to reduce collision probability
const jitter = Math.floor(Math.random() * RANDOM_JITTER_MAX_MS);
await new Promise((resolve) => setTimeout(resolve, jitter));
const now = Date.now();
// Check if another tab recently attempted refresh
const lastAttemptValue = await storage.getItem(attemptKey);
if (lastAttemptValue) {
// Parse the timestamp, handling various formats for robustness
let lastAttemptTime = null;
// Try parsing as "timestamp:tabId" format
const match = lastAttemptValue.match(/^(\d+):/);
if (match) {
lastAttemptTime = parseInt(match[1], 10);
}
else if (/^\d+$/.test(lastAttemptValue)) {
// Fallback: plain timestamp
lastAttemptTime = parseInt(lastAttemptValue, 10);
}
// Check if the last attempt is recent and valid
if (lastAttemptTime && !isNaN(lastAttemptTime)) {
const timeSinceLastAttempt = now - lastAttemptTime;
if (timeSinceLastAttempt < REFRESH_WINDOW_MS) {
logDebug(`Another tab attempted refresh ${timeSinceLastAttempt}ms ago, skipping`);
return false;
}
}
// If we can't parse the value, treat it as stale and proceed
}
// Record our attempt timestamp
// We don't need to verify this write succeeded - if multiple tabs write
// at the same time, that's okay as long as they all see a recent timestamp
const ourValue = `${now}:${TAB_ID}`;
await storage.setItem(attemptKey, ourValue);
logDebug(`Tab ${TAB_ID} proceeding with refresh attempt`);
return true;
}
catch (err) {
// If storage fails, don't attempt refresh to avoid uncoordinated refreshes
logDebug("Error checking refresh coordination, skipping refresh:", err);
return false;
}
}
/**
* Clear the refresh attempt lock after successful refresh
*/
async function clearRefreshAttemptLock() {
try {
const { storage, clientId } = configure();
const tokens = await retrieveTokens();
if (!tokens?.username)
return;
const attemptKey = `Passwordless.${clientId}.${tokens.username}.lastRefreshAttempt`;
await storage.removeItem(attemptKey);
logDebug(`Tab ${TAB_ID} cleared refresh attempt lock`);
}
catch (err) {
// Non-critical error, just log it
logDebug("Error clearing refresh attempt lock:", err);
}
}
/**
* Mark refresh as completed with retry logic
*/
async function markRefreshCompleted() {
const maxRetries = 3;
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const { storage, clientId } = configure();
const tokens = await retrieveTokens();
if (!tokens?.username)
return;
const completedKey = `Passwordless.${clientId}.${tokens.username}.lastRefreshCompleted`;
await storage.setItem(completedKey, Date.now().toString());
// Also clear the attempt lock since refresh is complete
await clearRefreshAttemptLock();
logDebug(`Tab ${TAB_ID} marked refresh as completed`);
return; // Success
}
catch (err) {
lastError = err;
logDebug(`Error marking refresh completed (attempt ${attempt}/${maxRetries}):`, err);
if (attempt < maxRetries) {
// Wait before retry with exponential backoff
await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
}
}
}
// Log final failure but don't throw - this is supplementary
logDebug("Failed to mark refresh completed after retries:", lastError);
}
// Basic browser environment detection
function isBrowserEnvironment() {
return (typeof globalThis !== "undefined" &&
typeof globalThis.document !== "undefined");
}
// Simplified document visibility check
function isDocumentVisible() {
if (!isBrowserEnvironment())
return true;
return !globalThis.document.hidden;
}
// Handle visibility change for browser environments
async function handleVisibilityChange() {
logDebug(`visibilitychange event: document.hidden=${globalThis.document.hidden}`);
if (!isDocumentVisible())
return;
const tokens = await retrieveTokensForRefresh();
if (!tokens?.expireAt)
return;
const state = getRefreshState(tokens.username);
// If already refreshing or has a timer, trust it
if (state.isRefreshing || state.refreshTimer) {
logDebug("handleVisibilityChange: refresh already in progress or scheduled, skipping");
return;
}
const timeUntilExpiry = tokens.expireAt.getTime() - Date.now();
// Only intervene if tokens are about to expire and nothing is scheduled
if (timeUntilExpiry < 5 * 60 * 1000) {
logDebug(`handleVisibilityChange: tokens expiring in ${Math.round(timeUntilExpiry / 1000)}s, scheduling refresh`);
void scheduleRefresh();
}
}
// Simple debug helper
function logDebug(message, error) {
const { debug } = configure();
if (!debug)
return;
if (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
debug(message, errorMsg);
}
else {
debug(message);
}
}
// Extract original implementation into a helper
async function scheduleRefreshUnlocked({ abort, tokensCb, isRefreshingCb, } = {}) {
// Get current tokens first to determine username
// Use retrieveTokensForRefresh to include expired tokens
const tokens = await retrieveTokensForRefresh();
if (abort?.aborted)
return;
if (!tokens?.expireAt || !tokens?.refreshToken) {
logDebug("No valid tokens found, skipping refresh scheduling");
// Don't clear tokens here - let other mechanisms handle expired/missing tokens
return;
}
const username = tokens.username;
const state = getRefreshState(username);
// Skip if already scheduling
if (state.isRefreshing) {
logDebug("Token refresh already in progress, skipping");
return;
}
// Skip if we already have a timer scheduled for the future
if (state.refreshTimer && state.nextRefreshTime) {
const timeUntilScheduledRefresh = state.nextRefreshTime - Date.now();
if (timeUntilScheduledRefresh > 0) {
logDebug(`Refresh already scheduled in ${Math.round(timeUntilScheduledRefresh / 60000)} minutes, skipping`);
return;
}
}
try {
// Clear any existing timer
const clearExistingTimer = () => {
if (state.refreshTimer) {
state.refreshTimer();
state.refreshTimer = undefined;
state.nextRefreshTime = undefined;
}
};
clearExistingTimer();
const tokenExpiryTime = tokens.expireAt.valueOf();
const currentTime = Date.now();
const timeUntilExpiry = tokenExpiryTime - currentTime;
// If token is already expired or expires very soon, refresh immediately
if (timeUntilExpiry <= 60000) {
logDebug(`Token expires in ${Math.round(timeUntilExpiry / 1000)}s, refreshing immediately`);
try {
await refreshTokens({
abort,
tokensCb,
isRefreshingCb,
tokens,
force: true,
});
// Mark as completed
await markRefreshCompleted();
// processTokens already handles scheduling the next refresh,
// so we don't need to do it here
}
catch (err) {
logDebug("Failed to refresh token:", err);
}
return;
}
// Standard case: schedule refresh with dynamic buffer
let refreshDelay;
try {
if (tokens.accessToken) {
const payload = parseJwtPayload(tokens.accessToken);
if (payload.iat && payload.exp) {
const actualLifetime = (payload.exp - payload.iat) * 1000;
const bufferTime = Math.max(60000, Math.min(0.3 * actualLifetime, 15 * 60 * 1000));
refreshDelay = Math.max(0, timeUntilExpiry - bufferTime);
logDebug(`Using dynamic refresh timing: token lifetime=${Math.round(actualLifetime / 60000)}min, ` +
`buffer=${Math.round(bufferTime / 60000)}min, delay=${Math.round(refreshDelay / 60000)}min`);
}
else {
throw new Error("Missing iat or exp claims");
}
}
else {
throw new Error("No access token available");
}
}
catch (err) {
refreshDelay = Math.max(0, timeUntilExpiry / 2);
logDebug(`Using fallback refresh timing (half remaining lifetime): delay=${Math.round(refreshDelay / 60000)}min`, err);
}
const desiredFireTime = Date.now() + refreshDelay;
state.nextRefreshTime = desiredFireTime;
state.lastScheduleTime = Date.now();
const minutesUntilRefresh = Math.round(refreshDelay / (60 * 1000));
logDebug(`Scheduling token refresh in ${minutesUntilRefresh} minutes`);
state.refreshTimer = setTimeoutWallClock(async () => {
state.refreshTimer = undefined;
state.nextRefreshTime = undefined;
try {
const latestTokens = await retrieveTokensForRefresh();
await refreshTokens({
abort,
tokensCb,
isRefreshingCb,
tokens: latestTokens,
});
}
catch (err) {
logDebug("Error during scheduled refresh:", err);
consecutiveRefreshFailures++;
if (consecutiveRefreshFailures >= MAX_CONSECUTIVE_REFRESH_FAILURES) {
logDebug(`Max refresh failures (${MAX_CONSECUTIVE_REFRESH_FAILURES}) reached, giving up`);
consecutiveRefreshFailures = 0; // Reset for next time
return;
}
// Exponential backoff: 30s, 60s, 120s, 240s
const backoffMs = Math.min(30000 * Math.pow(2, consecutiveRefreshFailures - 1), 240000);
logDebug(`Scheduling retry ${consecutiveRefreshFailures}/${MAX_CONSECUTIVE_REFRESH_FAILURES} in ${backoffMs / 1000}s`);
setTimeoutWallClock(() => {
void scheduleRefreshUnlocked({ abort, tokensCb, isRefreshingCb });
}, backoffMs);
}
}, refreshDelay);
abort?.addEventListener("abort", () => {
if (state.refreshTimer) {
state.refreshTimer();
state.refreshTimer = undefined;
logDebug("Refresh scheduling aborted");
}
}, { once: true });
}
catch (err) {
logDebug("Error scheduling refresh:", err);
}
}
// Simplified wrapper with per-user lock
export async function scheduleRefresh(args = {}) {
const { clientId, debug } = configure();
const tokens0 = await retrieveTokens();
const userIdentifier = tokens0?.username;
if (!userIdentifier) {
debug?.("scheduleRefresh: no user, running unlocked");
return scheduleRefreshUnlocked(args);
}
const lockKey = `Passwordless.${clientId}.${userIdentifier}.refreshLock`;
debug?.("scheduleRefresh: waiting for lock", lockKey);
try {
const result = await withStorageLock(lockKey, async () => {
debug?.("scheduleRefresh: lock acquired", lockKey);
return scheduleRefreshUnlocked(args);
}, undefined, args.abort);
debug?.("scheduleRefresh: lock released", lockKey);
return result;
}
catch (err) {
if (err instanceof LockTimeoutError) {
debug?.("scheduleRefresh: could not acquire lock, another tab is handling refresh");
// This is fine - another tab is already refreshing
return;
}
// Re-throw other errors
throw err;
}
}
/**
* Refresh tokens using OAuth token endpoint
*/
async function refreshTokensViaOAuth({ refreshToken, abort, }) {
const cfg = configure();
const { debug, clientId } = cfg;
debug?.("Using OAuth token endpoint for refresh token flow");
const tokenEndpoint = getTokenEndpoint();
debug?.(`Using OAuth token endpoint: ${tokenEndpoint}`);
const body = new URLSearchParams({
grant_type: "refresh_token",
client_id: clientId,
refresh_token: refreshToken,
});
debug?.("Sending OAuth token refresh request");
try {
const res = await cfg.fetch(tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
signal: abort,
});
if (!res.ok) {
const errorResponse = await res
.json()
.catch(() => ({ error: "Unknown error" }));
debug?.("OAuth token refresh failed:", errorResponse);
throw new Error(`OAuth token refresh failed: ${typeof errorResponse === "object" && errorResponse !== null
? "error_description" in errorResponse
? String(errorResponse.error_description)
: "error" in errorResponse
? String(errorResponse.error)
: "Unknown error"
: "Unknown error"}`);
}
const json = (await res.json());
debug?.(`OAuth token refresh successful - Access token: ${json.access_token ? "present" : "missing"}, ID token: ${json.id_token ? "present" : "missing"}, Refresh token: ${json.refresh_token ? "present" : "missing"}, Expires in: ${json.expires_in}s`);
return {
accessToken: json.access_token,
idToken: json.id_token,
refreshToken: json.refresh_token,
expiresIn: json.expires_in,
};
}
catch (error) {
debug?.("OAuth token refresh error:", error instanceof Error ? error.message : String(error));
throw error;
}
}
/**
* Refresh tokens using the refresh token
*/
export async function refreshTokens({ abort, tokensCb, isRefreshingCb, tokens, force = false, } = {}) {
const { clientId } = configure();
let userIdentifier = tokens?.username;
if (!userIdentifier) {
const storedTokens = await retrieveTokens();
userIdentifier = storedTokens?.username;
}
if (!userIdentifier) {
throw new Error("Cannot determine user identity for refresh lock");
}
const lockKey = `Passwordless.${clientId}.${userIdentifier}.refreshLock`;
const doRefresh = async () => {
// Get state for this user
const state = getRefreshState(userIdentifier);
if (state.isRefreshing && !force) {
logDebug("Token refresh already in progress");
throw new Error("Token refresh already in progress");
}
// Check if another tab is about to refresh or just did
if (!force && !(await shouldAttemptRefresh())) {
logDebug("Another tab is handling refresh, skipping");
throw new Error("Another tab is handling refresh");
}
try {
state.isRefreshing = true;
isRefreshingCb?.(true);
if (!tokens) {
tokens = await retrieveTokens();
}
const refreshToken = tokens?.refreshToken;
const username = tokens?.username;
const deviceKey = tokens?.deviceKey;
const expireAt = tokens?.expireAt;
const authMethod = tokens?.authMethod;
if (!refreshToken || !username) {
throw new Error("Cannot refresh without refresh token and username");
}
if (expireAt) {
const timeUntilExpiry = expireAt.valueOf() - Date.now();
if (timeUntilExpiry > 0) {
logDebug(force
? `Force refreshing token that expires in ${Math.round(timeUntilExpiry / 1000)}s`
: `Refreshing token (at half expiration time) that expires in ${Math.round(timeUntilExpiry / 1000)}s`);
}
else {
logDebug(`Refreshing expired token (${Math.abs(Math.round(timeUntilExpiry / 1000))}s ago)`);
}
}
let tokensFromRefresh;
try {
const { debug, useGetTokensFromRefreshToken } = configure();
let authResult;
if (authMethod === "REDIRECT") {
debug?.("Using OAuth token endpoint for refresh since auth method is REDIRECT");
const oauthResult = await refreshTokensViaOAuth({
refreshToken,
deviceKey,
abort,
});
authResult = {
AuthenticationResult: {
AccessToken: oauthResult.accessToken,
IdToken: oauthResult.idToken,
RefreshToken: oauthResult.refreshToken,
ExpiresIn: oauthResult.expiresIn,
TokenType: "Bearer",
},
};
}
else {
if (useGetTokensFromRefreshToken) {
debug?.(`Using Cognito GetTokensFromRefreshToken API (authMethod: ${authMethod || "unknown"})`);
let lastError;
let currentRefreshToken = refreshToken;
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
authResult = await getTokensFromRefreshToken({
refreshToken: currentRefreshToken,
deviceKey,
abort,
});
break;
}
catch (err) {
lastError = err;
if (err instanceof Error &&
err.name === "RefreshTokenReuseException") {
debug?.("Refresh token reuse detected; retrying with latest stored refresh token");
const latestStored = await retrieveTokens();
const latestToken = latestStored?.refreshToken;
if (latestToken && latestToken !== currentRefreshToken) {
currentRefreshToken = latestToken;
continue;
}
else {
throw err;
}
}
else if (attempt < maxRetries &&
err instanceof Error &&
(err.name === "NetworkError" ||
err.message.includes("fetch") ||
err.message.includes("network") ||
err.message.includes("timeout"))) {
debug?.(`Transient network error on attempt ${attempt}/${maxRetries}, retrying:`, err.message);
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
continue;
}
else {
throw err;
}
}
}
if (!authResult) {
throw (lastError || new Error("Failed to refresh tokens after retries"));
}
}
else {
debug?.("Using InitiateAuth REFRESH_TOKEN flow");
authResult = await initiateAuth({
authflow: "REFRESH_TOKEN",
authParameters: { REFRESH_TOKEN: refreshToken },
deviceKey,
abort,
});
}
}
let expireAt;
try {
const { exp } = parseJwtPayload(authResult.AuthenticationResult.AccessToken);
expireAt = new Date(exp * 1000);
}
catch {
expireAt = new Date(Date.now() + authResult.AuthenticationResult.ExpiresIn * 1000);
}
tokensFromRefresh = {
accessToken: authResult.AuthenticationResult.AccessToken,
...(authResult.AuthenticationResult.IdToken && {
idToken: authResult.AuthenticationResult.IdToken,
}),
expireAt,
username,
refreshToken: authResult.AuthenticationResult.RefreshToken ?? refreshToken,
...(deviceKey && { deviceKey }),
...(authMethod && { authMethod }),
};
logDebug(`Token refreshed; new refresh token received: ${authResult.AuthenticationResult.RefreshToken ? "yes" : "no"}, expires in ${authResult.AuthenticationResult.ExpiresIn}s`);
}
catch (error) {
logDebug("Token refresh failed:", error);
state.lastRefreshTime = Date.now();
// Clear the attempt lock on error so other tabs can retry
await clearRefreshAttemptLock();
throw error;
}
let processedTokens;
try {
processedTokens = (await processTokens(tokensFromRefresh, abort));
state.lastRefreshTime = Date.now();
// Call tokensCb first - if it fails, we don't want to mark as completed
if (tokensCb) {
await tokensCb(processedTokens);
}
// Only mark as completed after everything succeeds
await markRefreshCompleted();
// Reset failure counter on success
consecutiveRefreshFailures = 0;
}
catch (error) {
// If anything fails after we got new tokens, we need to clear the attempt lock
// so other tabs can retry
logDebug("Error during token processing or callback:", error);
await clearRefreshAttemptLock();
throw error;
}
return processedTokens;
}
finally {
state.isRefreshing = false;
isRefreshingCb?.(false);
}
};
const { debug } = configure();
if (force) {
debug?.("refreshTokens: force=true, bypassing lock", lockKey);
return doRefresh();
}
debug?.("refreshTokens: waiting for lock", lockKey);
try {
const result = await withStorageLock(lockKey, async () => {
debug?.("refreshTokens: lock acquired", lockKey);
return doRefresh();
}, undefined, abort);
debug?.("refreshTokens: lock released", lockKey);
return result;
}
catch (err) {
if (err instanceof LockTimeoutError ||
(err instanceof Error &&
err.message === "Another tab is handling refresh")) {
debug?.(err instanceof LockTimeoutError
? "refreshTokens: could not acquire lock, another process is refreshing"
: "refreshTokens: another tab is handling refresh (coordination check)");
// Wait for the other tab's refresh to complete
// Increased to handle slow network conditions
const waitTime = 2000; // 2 seconds
debug?.(`refreshTokens: waiting ${waitTime}ms for other tab's refresh to complete`);
// Store the current token state before waiting
const tokensBeforeWait = await retrieveTokens();
const accessTokenBeforeWait = tokensBeforeWait?.accessToken;
await new Promise((resolve) => setTimeout(resolve, waitTime));
// Check if tokens were actually refreshed by comparing the access token
const currentTokens = await retrieveTokens();
// If the access token changed, it means a refresh occurred
if (currentTokens?.accessToken &&
currentTokens.accessToken !== accessTokenBeforeWait) {
debug?.("refreshTokens: tokens were refreshed by another tab (access token changed)");
if (currentTokens.expireAt &&
currentTokens.refreshToken &&
currentTokens.username) {
const refreshedTokens = {
accessToken: currentTokens.accessToken,
...(currentTokens.idToken && { idToken: currentTokens.idToken }),
expireAt: currentTokens.expireAt,
username: currentTokens.username,
refreshToken: currentTokens.refreshToken,
...(currentTokens.deviceKey && {
deviceKey: currentTokens.deviceKey,
}),
...(currentTokens.authMethod && {
authMethod: currentTokens.authMethod,
}),
};
if (tokensCb) {
await tokensCb(refreshedTokens);
}
return refreshedTokens;
}
else {
debug?.("refreshTokens: tokens were refreshed but missing required fields", {
hasExpireAt: !!currentTokens.expireAt,
hasRefreshToken: !!currentTokens.refreshToken,
hasUsername: !!currentTokens.username,
});
throw new Error("Tokens were refreshed by another tab but are incomplete");
}
}
else {
debug?.("refreshTokens: tokens were NOT refreshed by another tab (access token unchanged)");
throw new Error("Another refresh in progress and no valid tokens available");
}
}
throw err;
}
}
/**
* Force an immediate token refresh
*/
export async function forceRefreshTokens(args) {
logDebug("Forcing immediate token refresh");
// Get username to clear the right timer
const tokens = await retrieveTokens();
const username = tokens?.username;
const state = getRefreshState(username);
if (state.refreshTimer) {
state.refreshTimer();
state.refreshTimer = undefined;
}
const refreshed = await refreshTokens({
...(args ?? {}),
force: true,
});
void scheduleRefresh({ ...args });
return refreshed;
}
// Initialize visibility change listener for browser environments
if (isBrowserEnvironment()) {
// Create named handler for proper cleanup
const visibilityHandler = () => {
void handleVisibilityChange();
};
// eslint-disable-next-line no-restricted-globals
globalThis.document.addEventListener("visibilitychange", visibilityHandler);
// Store cleanup function
visibilityChangeListener = () => {
globalThis.document.removeEventListener("visibilitychange", visibilityHandler);
};
// AUTO-CLEANUP: Clean up on page unload/hide
autoCleanupHandler = () => {
logDebug("Auto-cleanup triggered on page unload/hide");
cleanupRefreshSystem();
};
globalThis.addEventListener("beforeunload", autoCleanupHandler);
globalThis.addEventListener("pagehide", autoCleanupHandler);
// For SPA navigation - cleanup on unload
if (typeof globalThis.addEventListener === "function") {
globalThis.addEventListener("unload", autoCleanupHandler);
}
// Simplified watchdog with cleanup support
const WATCHDOG_INTERVAL_MS = 5 * 60 * 1000;
const startWatchdog = () => {
const cleanup = setTimeoutWallClock(() => {
void (async () => {
logDebug(`Watchdog tick at ${new Date().toISOString()}`);
// Check all users' refresh states
const tokens = await retrieveTokensForRefresh();
const username = tokens?.username;
const state = getRefreshState(username);
if (!state.refreshTimer && isDocumentVisible()) {
const lastRefresh = state.lastRefreshTime || 0;
if (Date.now() - lastRefresh > WATCHDOG_INTERVAL_MS) {
logDebug("Watchdog is triggering a refresh check");
void (async () => {
if (await shouldAttemptRefresh()) {
void scheduleRefresh();
}
})();
}
}
// Only continue if cleanup hasn't been called
if (watchdogCleanup) {
watchdogCleanup = startWatchdog();
}
})();
}, WATCHDOG_INTERVAL_MS);
return cleanup;
};
watchdogCleanup = startWatchdog();
}
/**
* Clean up all refresh-related timers and event listeners.
* Call this when unmounting the application or switching users.
* @param username - Optional username to clean up specific user state
*/
export function cleanupRefreshSystem(username) {
logDebug("Cleaning up refresh system");
// Clean up visibility change listener
if (visibilityChangeListener) {
visibilityChangeListener();
visibilityChangeListener = undefined;
}
// Clean up watchdog timer
if (watchdogCleanup) {
watchdogCleanup();
watchdogCleanup = undefined;
}
// Clean up auto-cleanup listeners (prevent memory leaks)
if (autoCleanupHandler && isBrowserEnvironment()) {
globalThis.removeEventListener("beforeunload", autoCleanupHandler);
globalThis.removeEventListener("pagehide", autoCleanupHandler);
globalThis.removeEventListener("unload", autoCleanupHandler);
autoCleanupHandler = undefined;
}
// Get the appropriate refresh state
const state = getRefreshState(username);
// Clean up any active refresh timer
if (state.refreshTimer) {
state.refreshTimer();
state.refreshTimer = undefined;
state.nextRefreshTime = undefined;
}
// Reset refresh state
state.isRefreshing = false;
state.lastRefreshTime = undefined;
// Clear user-specific state from the map
if (username) {
clearRefreshState(username);
}
}