fix: not refreshing token

This commit is contained in:
Michael Freno
2026-01-12 19:09:15 -05:00
parent 4d35935462
commit 700efe5c6c
2 changed files with 91 additions and 2 deletions

View File

@@ -20,6 +20,8 @@ class TokenRefreshManager {
private isRefreshing = false; private isRefreshing = false;
private isStarted = false; private isStarted = false;
private visibilityChangeHandler: (() => void) | null = null; private visibilityChangeHandler: (() => void) | null = null;
private onlineHandler: (() => void) | null = null;
private focusHandler: (() => void) | null = null;
private lastRefreshTime: number | null = null; private lastRefreshTime: number | null = null;
/** /**
@@ -62,6 +64,20 @@ class TokenRefreshManager {
} }
}; };
document.addEventListener("visibilitychange", this.visibilityChangeHandler); 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; 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.isStarted = false;
this.lastRefreshTime = null; // Reset refresh time on stop this.lastRefreshTime = null; // Reset refresh time on stop
} }

View File

@@ -530,6 +530,25 @@ async function restoreSessionFromDB(
return null; 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) // We can't restore the refresh token (it's hashed in DB)
// So we need to generate a new one and rotate the session // 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 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; return newSession;
} catch (error) { } catch (error) {
console.error("[Session Restore] Error restoring session:", error); console.error("[Session Restore] Error restoring session:", error);
@@ -562,6 +589,7 @@ async function restoreSessionFromDB(
/** /**
* Validate session against database * Validate session against database
* Checks if session exists, not revoked, not expired, and refresh token matches * 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 sessionId - Session ID
* @param userId - User ID * @param userId - User ID
* @param refreshToken - Plaintext refresh token * @param refreshToken - Plaintext refresh token
@@ -577,7 +605,7 @@ async function validateSessionInDB(
const tokenHash = hashRefreshToken(refreshToken); const tokenHash = hashRefreshToken(refreshToken);
const result = await conn.execute({ 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 FROM Session
WHERE id = ? AND user_id = ?`, WHERE id = ? AND user_id = ?`,
args: [sessionId, userId] args: [sessionId, userId]
@@ -611,6 +639,41 @@ async function validateSessionInDB(
return false; 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) // Update last_used and last_active_at timestamps (throttled)
// Only update DB if last update was > 5 minutes ago (reduces writes by 95%+) // Only update DB if last update was > 5 minutes ago (reduces writes by 95%+)
updateSessionActivityThrottled(sessionId).catch((err) => updateSessionActivityThrottled(sessionId).catch((err) =>