oof
This commit is contained in:
@@ -1,34 +1,64 @@
|
||||
/**
|
||||
* Token Refresh Manager
|
||||
* Handles automatic token refresh before expiry
|
||||
*
|
||||
* Note: Since access tokens are httpOnly cookies, we can't read them from client JS.
|
||||
* Instead, we schedule refresh based on a fixed interval that aligns with token expiry.
|
||||
*/
|
||||
|
||||
import { api } from "~/lib/api";
|
||||
import { getClientCookie } from "~/lib/cookies.client";
|
||||
import { getTimeUntilExpiry } from "~/lib/client-utils";
|
||||
import { revalidateAuth } from "~/lib/auth-query";
|
||||
|
||||
// Token expiry durations (must match server config)
|
||||
const ACCESS_TOKEN_EXPIRY_MS = import.meta.env.PROD
|
||||
? 15 * 60 * 1000
|
||||
: 2 * 60 * 1000; // 15m prod, 2m dev
|
||||
const REFRESH_THRESHOLD_MS = import.meta.env.PROD ? 2 * 60 * 1000 : 30 * 1000; // 2m prod, 30s dev
|
||||
|
||||
class TokenRefreshManager {
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private isRefreshing = false;
|
||||
private refreshThresholdMs = 2 * 60 * 1000; // Refresh 2 minutes before expiry
|
||||
private isStarted = false;
|
||||
private visibilityChangeHandler: (() => void) | null = null;
|
||||
private lastRefreshTime: number | null = null;
|
||||
|
||||
/**
|
||||
* Start monitoring token and auto-refresh before expiry
|
||||
* Start monitoring and auto-refresh
|
||||
* @param isAuthenticated - Whether user is currently authenticated (from server state)
|
||||
*/
|
||||
start(): void {
|
||||
start(isAuthenticated: boolean = true): void {
|
||||
console.log(
|
||||
`[Token Refresh] start() called - isStarted: ${this.isStarted}, isAuthenticated: ${isAuthenticated}, lastRefreshTime: ${this.lastRefreshTime}`
|
||||
);
|
||||
|
||||
if (typeof window === "undefined") return; // Server-side bail
|
||||
if (this.isStarted) return; // Already started, prevent duplicate listeners
|
||||
|
||||
if (this.isStarted) {
|
||||
console.log(
|
||||
"[Token Refresh] Already started, skipping duplicate start()"
|
||||
);
|
||||
return; // Already started, prevent duplicate listeners
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
console.log("[Token Refresh] Not authenticated, skipping start()");
|
||||
return; // No need to refresh if not authenticated
|
||||
}
|
||||
|
||||
this.isStarted = true;
|
||||
this.lastRefreshTime = Date.now(); // Assume token was just issued
|
||||
console.log(
|
||||
`[Token Refresh] Manager started, lastRefreshTime set to ${this.lastRefreshTime}`
|
||||
);
|
||||
this.scheduleNextRefresh();
|
||||
|
||||
// Re-check on visibility change (user returns to tab)
|
||||
this.visibilityChangeHandler = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
this.scheduleNextRefresh();
|
||||
console.log(
|
||||
"[Token Refresh] Tab became visible, checking token status"
|
||||
);
|
||||
this.checkAndRefreshIfNeeded();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", this.visibilityChangeHandler);
|
||||
@@ -52,23 +82,85 @@ class TokenRefreshManager {
|
||||
}
|
||||
|
||||
this.isStarted = false;
|
||||
this.lastRefreshTime = null; // Reset refresh time on stop
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the last refresh time (call after login or successful refresh)
|
||||
*/
|
||||
reset(): void {
|
||||
console.log(
|
||||
`[Token Refresh] reset() called - isRefreshing: ${this.isRefreshing}`,
|
||||
new Error().stack?.split("\n").slice(1, 4).join("\n") // Show caller
|
||||
);
|
||||
|
||||
// Don't reset if we're currently refreshing (prevents infinite loop)
|
||||
if (this.isRefreshing) {
|
||||
console.log("[Token Refresh] Skipping reset during active refresh");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Token Refresh] Resetting refresh timer, old lastRefreshTime: ${this.lastRefreshTime}`
|
||||
);
|
||||
this.lastRefreshTime = Date.now();
|
||||
console.log(`[Token Refresh] New lastRefreshTime: ${this.lastRefreshTime}`);
|
||||
|
||||
if (this.isStarted) {
|
||||
this.scheduleNextRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token needs refresh based on last refresh time
|
||||
*/
|
||||
private checkAndRefreshIfNeeded(): void {
|
||||
if (!this.lastRefreshTime) {
|
||||
console.log("[Token Refresh] No refresh history, refreshing now");
|
||||
this.refreshNow();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeSinceRefresh = Date.now() - this.lastRefreshTime;
|
||||
const timeUntilExpiry = ACCESS_TOKEN_EXPIRY_MS - timeSinceRefresh;
|
||||
|
||||
if (timeUntilExpiry <= REFRESH_THRESHOLD_MS) {
|
||||
// Token expired or about to expire - refresh immediately
|
||||
console.log(
|
||||
`[Token Refresh] Token likely expired (${Math.round(timeSinceRefresh / 1000)}s since last refresh), refreshing now`
|
||||
);
|
||||
this.refreshNow();
|
||||
} else {
|
||||
// Token still valid - reschedule
|
||||
console.log(
|
||||
`[Token Refresh] Token still valid (~${Math.round(timeUntilExpiry / 1000)}s remaining), rescheduling refresh`
|
||||
);
|
||||
this.scheduleNextRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule next refresh based on token expiry
|
||||
*/
|
||||
private scheduleNextRefresh(): void {
|
||||
this.stop(); // Clear existing timer
|
||||
// Clear existing timer but don't stop the manager
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
|
||||
const token = getClientCookie("userIDToken");
|
||||
if (!token) {
|
||||
// No token found - user not logged in, nothing to refresh
|
||||
if (!this.lastRefreshTime) {
|
||||
console.log("[Token Refresh] No refresh history, cannot schedule");
|
||||
return;
|
||||
}
|
||||
|
||||
const timeUntilExpiry = getTimeUntilExpiry(token);
|
||||
if (!timeUntilExpiry) {
|
||||
console.warn("Token expired or invalid, attempting refresh now");
|
||||
const timeSinceRefresh = Date.now() - this.lastRefreshTime;
|
||||
const timeUntilExpiry = ACCESS_TOKEN_EXPIRY_MS - timeSinceRefresh;
|
||||
|
||||
if (timeUntilExpiry <= REFRESH_THRESHOLD_MS) {
|
||||
console.warn(
|
||||
"[Token Refresh] Token likely expired, attempting refresh now"
|
||||
);
|
||||
this.refreshNow();
|
||||
return;
|
||||
}
|
||||
@@ -76,12 +168,12 @@ class TokenRefreshManager {
|
||||
// Schedule refresh before expiry
|
||||
const timeUntilRefresh = Math.max(
|
||||
0,
|
||||
timeUntilExpiry - this.refreshThresholdMs
|
||||
timeUntilExpiry - REFRESH_THRESHOLD_MS
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[Token Refresh] Token expires in ${Math.round(timeUntilExpiry / 1000)}s, ` +
|
||||
`scheduling refresh in ${Math.round(timeUntilRefresh / 1000)}s`
|
||||
`[Token Refresh] Scheduling refresh in ${Math.round(timeUntilRefresh / 1000)}s ` +
|
||||
`(~${Math.round(timeUntilExpiry / 1000)}s until expiry)`
|
||||
);
|
||||
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
@@ -89,6 +181,16 @@ class TokenRefreshManager {
|
||||
}, timeUntilRefresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rememberMe preference
|
||||
* Since we can't read httpOnly cookies, we default to true and let the server
|
||||
* determine the correct expiry based on the existing session
|
||||
*/
|
||||
private getRememberMePreference(): boolean {
|
||||
// Default to true - server will use the correct expiry from the existing session
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform token refresh immediately
|
||||
*/
|
||||
@@ -103,14 +205,23 @@ class TokenRefreshManager {
|
||||
try {
|
||||
console.log("[Token Refresh] Refreshing access token...");
|
||||
|
||||
// Preserve rememberMe state from existing session
|
||||
const rememberMe = this.getRememberMePreference();
|
||||
console.log(
|
||||
`[Token Refresh] Using rememberMe: ${rememberMe} (from refresh token cookie existence)`
|
||||
);
|
||||
|
||||
const result = await api.auth.refreshToken.mutate({
|
||||
rememberMe: false // Maintain existing rememberMe state
|
||||
rememberMe
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log("[Token Refresh] Token refreshed successfully");
|
||||
revalidateAuth(); // Refresh auth state after token refresh
|
||||
this.lastRefreshTime = Date.now(); // Update refresh time
|
||||
this.scheduleNextRefresh(); // Schedule next refresh
|
||||
|
||||
// Revalidate auth AFTER scheduling to avoid race condition
|
||||
revalidateAuth(); // Refresh auth state after token refresh
|
||||
return true;
|
||||
} else {
|
||||
console.error("[Token Refresh] Token refresh failed:", result);
|
||||
@@ -141,6 +252,23 @@ class TokenRefreshManager {
|
||||
// Redirect to login
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt immediate refresh (for page load when access token expired)
|
||||
* Always attempts refresh - server will reject if no refresh token exists
|
||||
* Returns true if refresh succeeded, false otherwise
|
||||
*
|
||||
* Note: We can't check for httpOnly refresh token from client JavaScript,
|
||||
* so we always attempt and let the server decide if token exists
|
||||
*/
|
||||
async attemptInitialRefresh(): Promise<boolean> {
|
||||
console.log(
|
||||
"[Token Refresh] Attempting initial refresh (server will check for refresh token)"
|
||||
);
|
||||
|
||||
// refreshNow() already calls revalidateAuth() on success
|
||||
return await this.refreshNow();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
Reference in New Issue
Block a user