From 700efe5c6cd6524d75af19d3bdc15cd166baa1f8 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 12 Jan 2026 19:09:15 -0500 Subject: [PATCH] fix: not refreshing token --- src/lib/token-refresh.ts | 26 ++++++++++++++ src/server/session-helpers.ts | 67 +++++++++++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/lib/token-refresh.ts b/src/lib/token-refresh.ts index 14811c5..e16d186 100644 --- a/src/lib/token-refresh.ts +++ b/src/lib/token-refresh.ts @@ -20,6 +20,8 @@ class TokenRefreshManager { private isRefreshing = false; private isStarted = false; private visibilityChangeHandler: (() => void) | null = null; + private onlineHandler: (() => void) | null = null; + private focusHandler: (() => void) | null = null; private lastRefreshTime: number | null = null; /** @@ -62,6 +64,20 @@ class TokenRefreshManager { } }; document.addEventListener("visibilitychange", this.visibilityChangeHandler); + + // Re-check on network reconnection (device was offline) + this.onlineHandler = () => { + console.log("[Token Refresh] Network reconnected, checking token status"); + this.checkAndRefreshIfNeeded(); + }; + window.addEventListener("online", this.onlineHandler); + + // Re-check on window focus (device was asleep or user switched apps) + this.focusHandler = () => { + console.log("[Token Refresh] Window focused, checking token status"); + this.checkAndRefreshIfNeeded(); + }; + window.addEventListener("focus", this.focusHandler); } /** @@ -81,6 +97,16 @@ class TokenRefreshManager { this.visibilityChangeHandler = null; } + if (this.onlineHandler) { + window.removeEventListener("online", this.onlineHandler); + this.onlineHandler = null; + } + + if (this.focusHandler) { + window.removeEventListener("focus", this.focusHandler); + this.focusHandler = null; + } + this.isStarted = false; this.lastRefreshTime = null; // Reset refresh time on stop } diff --git a/src/server/session-helpers.ts b/src/server/session-helpers.ts index 60f08c4..c31db08 100644 --- a/src/server/session-helpers.ts +++ b/src/server/session-helpers.ts @@ -530,6 +530,25 @@ async function restoreSessionFromDB( return null; } + // Check if this session has already been rotated (has a child session) + // If so, we cannot restore from it - user must create a new session + const childCheck = await conn.execute({ + sql: `SELECT id FROM Session WHERE parent_session_id = ? LIMIT 1`, + args: [sessionId] + }); + + if (childCheck.rows.length > 0) { + console.log( + "[Session Restore] Session has already been rotated - cannot restore" + ); + // Revoke this session to ensure it's marked as used + await conn.execute({ + sql: "UPDATE Session SET revoked = 1 WHERE id = ?", + args: [sessionId] + }); + return null; + } + // We can't restore the refresh token (it's hashed in DB) // So we need to generate a new one and rotate the session @@ -551,7 +570,15 @@ async function restoreSessionFromDB( dbSession.token_family as string // Reuse family ); - console.log("[Session Restore] Successfully created new session"); + // Mark parent session as revoked now that we've rotated it + await conn.execute({ + sql: "UPDATE Session SET revoked = 1 WHERE id = ?", + args: [sessionId] + }); + + console.log( + "[Session Restore] Successfully created new session and revoked parent" + ); return newSession; } catch (error) { console.error("[Session Restore] Error restoring session:", error); @@ -562,6 +589,7 @@ async function restoreSessionFromDB( /** * Validate session against database * Checks if session exists, not revoked, not expired, and refresh token matches + * Also validates that no child sessions exist (indicating this session was rotated) * @param sessionId - Session ID * @param userId - User ID * @param refreshToken - Plaintext refresh token @@ -577,7 +605,7 @@ async function validateSessionInDB( const tokenHash = hashRefreshToken(refreshToken); const result = await conn.execute({ - sql: `SELECT revoked, expires_at, refresh_token_hash + sql: `SELECT revoked, expires_at, refresh_token_hash, token_family FROM Session WHERE id = ? AND user_id = ?`, args: [sessionId, userId] @@ -611,6 +639,41 @@ async function validateSessionInDB( return false; } + // CRITICAL: Check if this session has been rotated (has a child session) + // If a child exists, check if we're within the grace period for cookie propagation + // This handles SSR/serverless cases where client may not have received new cookies yet + const childCheck = await conn.execute({ + sql: `SELECT id, created_at FROM Session + WHERE parent_session_id = ? + ORDER BY created_at DESC + LIMIT 1`, + args: [sessionId] + }); + + if (childCheck.rows.length > 0) { + // This session has been rotated - check grace period + const childSession = childCheck.rows[0]; + const childCreatedAt = new Date(childSession.created_at as string); + const now = new Date(); + const timeSinceRotation = now.getTime() - childCreatedAt.getTime(); + + // Grace period allows client to receive and use new cookies from rotation + // This is critical for SSR/serverless where response cookies may be delayed + if (timeSinceRotation >= AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS) { + // Grace period expired - parent session should no longer be used + // This indicates either token theft or client failed to update cookies + console.log( + `[Session Validation] Parent session used ${Math.round(timeSinceRotation / 1000)}s after rotation (grace period expired)` + ); + return false; + } + + // Within grace period - allow parent session use while cookies propagate + console.log( + `[Session Validation] Parent session used ${Math.round(timeSinceRotation / 1000)}s after rotation (within grace period)` + ); + } + // Update last_used and last_active_at timestamps (throttled) // Only update DB if last update was > 5 minutes ago (reduces writes by 95%+) updateSessionActivityThrottled(sessionId).catch((err) =>