updating auth token security
This commit is contained in:
11
src/app.tsx
11
src/app.tsx
@@ -18,6 +18,7 @@ import { createWindowWidth, isMobile } from "~/lib/resize-utils";
|
||||
import { MOBILE_CONFIG } from "./config";
|
||||
import CustomScrollbar from "./components/CustomScrollbar";
|
||||
import { initPerformanceTracking } from "~/lib/performance-tracking";
|
||||
import { tokenRefreshManager } from "~/lib/token-refresh";
|
||||
|
||||
function AppLayout(props: { children: any }) {
|
||||
const {
|
||||
@@ -190,6 +191,16 @@ function AppLayout(props: { children: any }) {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
onMount(() => {
|
||||
// Start token refresh monitoring
|
||||
tokenRefreshManager.start();
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
// Cleanup token refresh on unmount
|
||||
tokenRefreshManager.stop();
|
||||
});
|
||||
|
||||
return (
|
||||
<MetaProvider>
|
||||
<ErrorBoundary
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
useNavigate,
|
||||
useLocation,
|
||||
query,
|
||||
createAsync
|
||||
createAsync,
|
||||
revalidate
|
||||
} from "@solidjs/router";
|
||||
import { BREAKPOINTS } from "~/config";
|
||||
import { getRequestEvent } from "solid-js/web";
|
||||
@@ -51,6 +52,13 @@ const getUserState = query(async () => {
|
||||
};
|
||||
}, "bars-user-state");
|
||||
|
||||
/**
|
||||
* Call this function after login/logout to refresh the user state in the sidebar
|
||||
*/
|
||||
export function revalidateUserState() {
|
||||
revalidate(getUserState.key);
|
||||
}
|
||||
|
||||
function formatDomainName(url: string): string {
|
||||
const domain = url.split("://")[1]?.split(":")[0] ?? url;
|
||||
const withoutWww = domain.replace(/^www\./i, "");
|
||||
|
||||
@@ -7,17 +7,88 @@
|
||||
// AUTHENTICATION & SESSION
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* AUTHENTICATION & SESSION CONFIGURATION
|
||||
*
|
||||
* Security Model:
|
||||
* - Access tokens: Short-lived (15m), contain user identity, stored in httpOnly cookie
|
||||
* - Refresh tokens: Long-lived (7-90d), opaque tokens for getting new access tokens
|
||||
* - Token rotation: Each refresh invalidates old token and issues new pair
|
||||
* - Breach detection: Reusing invalidated token revokes entire token family
|
||||
*
|
||||
* Timing Decisions:
|
||||
* - 15m access: Balance between security (short exposure) and UX (not too frequent refreshes)
|
||||
* - 7d refresh: Conservative default, users re-auth weekly
|
||||
* - 90d remember: Extended convenience for trusted devices
|
||||
* - 5s reuse window: Handles race conditions in distributed systems
|
||||
*
|
||||
* References:
|
||||
* - OWASP: https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
|
||||
* - RFC 6819: https://datatracker.ietf.org/doc/html/rfc6819#section-5.2
|
||||
*/
|
||||
export const AUTH_CONFIG = {
|
||||
JWT_EXPIRY: "14d" as const,
|
||||
JWT_EXPIRY_SHORT: "12h" as const,
|
||||
SESSION_COOKIE_MAX_AGE: 60 * 60 * 24 * 14,
|
||||
REMEMBER_ME_MAX_AGE: 60 * 60 * 24 * 14,
|
||||
// Access Token (JWT in cookie)
|
||||
ACCESS_TOKEN_EXPIRY: "15m" as const, // 15 minutes (short-lived)
|
||||
ACCESS_TOKEN_EXPIRY_DEV: "2m" as const, // 1 hour in dev for convenience
|
||||
|
||||
// Refresh Token (opaque token in separate cookie)
|
||||
REFRESH_TOKEN_EXPIRY_SHORT: "7d" as const, // 7 days (no remember me)
|
||||
REFRESH_TOKEN_EXPIRY_LONG: "90d" as const, // 90 days (remember me)
|
||||
|
||||
// Cookie MaxAge (in seconds - must match token lifetime)
|
||||
ACCESS_COOKIE_MAX_AGE: 15 * 60, // 15 minutes
|
||||
ACCESS_COOKIE_MAX_AGE_DEV: 60 * 60, // 1 hour in dev
|
||||
REFRESH_COOKIE_MAX_AGE_SHORT: 60 * 60 * 24 * 7, // 7 days
|
||||
REFRESH_COOKIE_MAX_AGE_LONG: 60 * 60 * 24 * 90, // 90 days
|
||||
|
||||
// Legacy (keep for backwards compatibility during migration)
|
||||
JWT_EXPIRY: "15m" as const, // Deprecated: use ACCESS_TOKEN_EXPIRY
|
||||
JWT_EXPIRY_SHORT: "15m" as const, // Deprecated
|
||||
SESSION_COOKIE_MAX_AGE: 60 * 60 * 24 * 7, // Deprecated
|
||||
REMEMBER_ME_MAX_AGE: 60 * 60 * 24 * 90, // Deprecated
|
||||
|
||||
// Security Settings
|
||||
REFRESH_TOKEN_ROTATION_ENABLED: true, // Enable token rotation
|
||||
MAX_ROTATION_COUNT: 100, // Max rotations before forcing re-login
|
||||
REFRESH_TOKEN_REUSE_WINDOW_MS: 5000, // 5s grace period for race conditions
|
||||
|
||||
// Session Cleanup (serverless-friendly opportunistic cleanup)
|
||||
SESSION_CLEANUP_INTERVAL_HOURS: 24, // Check for cleanup every 24 hours
|
||||
SESSION_CLEANUP_RETENTION_DAYS: 90, // Keep revoked sessions for 90 days (audit)
|
||||
|
||||
// Other Auth Settings
|
||||
CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14,
|
||||
EMAIL_LOGIN_LINK_EXPIRY: "15m" as const,
|
||||
EMAIL_VERIFICATION_LINK_EXPIRY: "15m" as const,
|
||||
LINEAGE_JWT_EXPIRY: "14d" as const
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get access token expiry based on environment
|
||||
*/
|
||||
export function getAccessTokenExpiry(): string {
|
||||
return process.env.NODE_ENV === "production"
|
||||
? AUTH_CONFIG.ACCESS_TOKEN_EXPIRY
|
||||
: AUTH_CONFIG.ACCESS_TOKEN_EXPIRY_DEV;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access cookie maxAge based on environment (in seconds)
|
||||
*/
|
||||
export function getAccessCookieMaxAge(): number {
|
||||
return process.env.NODE_ENV === "production"
|
||||
? AUTH_CONFIG.ACCESS_COOKIE_MAX_AGE
|
||||
: AUTH_CONFIG.ACCESS_COOKIE_MAX_AGE_DEV;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type helper for token expiry strings
|
||||
*/
|
||||
export type TokenExpiry =
|
||||
| typeof AUTH_CONFIG.ACCESS_TOKEN_EXPIRY
|
||||
| typeof AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_SHORT
|
||||
| typeof AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG;
|
||||
|
||||
// ============================================================
|
||||
// RATE LIMITING
|
||||
// ============================================================
|
||||
@@ -223,3 +294,14 @@ export const AUDIT_CONFIG = {
|
||||
DEFAULT_QUERY_LIMIT: 100,
|
||||
MAX_RETENTION_DAYS: 90
|
||||
} as const;
|
||||
|
||||
// ============================================================
|
||||
// SESSION CLEANUP
|
||||
// ============================================================
|
||||
|
||||
export const SESSION_CLEANUP_CONFIG = {
|
||||
ENABLED: true,
|
||||
INTERVAL_HOURS: 24,
|
||||
RETENTION_DAYS: 90,
|
||||
RUN_ON_STARTUP: true
|
||||
} as const;
|
||||
|
||||
@@ -20,16 +20,24 @@ export const model: { [key: string]: string } = {
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
token_family TEXT NOT NULL,
|
||||
refresh_token_hash TEXT NOT NULL,
|
||||
parent_session_id TEXT,
|
||||
rotation_count INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL,
|
||||
access_token_expires_at TEXT NOT NULL,
|
||||
last_used TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
revoked INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
|
||||
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (parent_session_id) REFERENCES Session(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_user_id ON Session (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_expires_at ON Session (expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_token_family ON Session (token_family);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_refresh_token_hash ON Session (refresh_token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_revoked ON Session (revoked);
|
||||
`,
|
||||
PasswordResetToken: `
|
||||
CREATE TABLE PasswordResetToken
|
||||
|
||||
@@ -18,6 +18,47 @@ export async function safeFetch(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT payload without verification (client-side only)
|
||||
* @param token - JWT token string
|
||||
* @returns Decoded payload or null if invalid
|
||||
*/
|
||||
export function decodeJWT(token: string): {
|
||||
id: string;
|
||||
sid: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
} | null {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const payload = JSON.parse(
|
||||
atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"))
|
||||
);
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time until JWT expires (in milliseconds)
|
||||
* @param token - JWT token string
|
||||
* @returns Milliseconds until expiry, or null if invalid/expired
|
||||
*/
|
||||
export function getTimeUntilExpiry(token: string): number | null {
|
||||
const payload = decodeJWT(token);
|
||||
if (!payload || !payload.exp) return null;
|
||||
|
||||
const expiryMs = payload.exp * 1000;
|
||||
const now = Date.now();
|
||||
const timeUntil = expiryMs - now;
|
||||
|
||||
return timeUntil > 0 ? timeUntil : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts soft hyphens (­) for manual hyphenation. Uses actual characters for Typewriter compatibility.
|
||||
*/
|
||||
|
||||
153
src/lib/token-refresh.ts
Normal file
153
src/lib/token-refresh.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Token Refresh Manager
|
||||
* Handles automatic token refresh before expiry
|
||||
*/
|
||||
|
||||
import { api } from "~/lib/api";
|
||||
import { getClientCookie } from "~/lib/cookies.client";
|
||||
import { getTimeUntilExpiry } from "~/lib/client-utils";
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Start monitoring token and auto-refresh before expiry
|
||||
*/
|
||||
start(): void {
|
||||
if (typeof window === "undefined") return; // Server-side bail
|
||||
if (this.isStarted) return; // Already started, prevent duplicate listeners
|
||||
|
||||
this.isStarted = true;
|
||||
this.scheduleNextRefresh();
|
||||
|
||||
// Re-check on visibility change (user returns to tab)
|
||||
this.visibilityChangeHandler = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
this.scheduleNextRefresh();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", this.visibilityChangeHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring and clear timers
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
|
||||
if (this.visibilityChangeHandler) {
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
this.visibilityChangeHandler
|
||||
);
|
||||
this.visibilityChangeHandler = null;
|
||||
}
|
||||
|
||||
this.isStarted = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule next refresh based on token expiry
|
||||
*/
|
||||
private scheduleNextRefresh(): void {
|
||||
this.stop(); // Clear existing timer
|
||||
|
||||
const token = getClientCookie("userIDToken");
|
||||
if (!token) {
|
||||
// No token found - user not logged in, nothing to refresh
|
||||
return;
|
||||
}
|
||||
|
||||
const timeUntilExpiry = getTimeUntilExpiry(token);
|
||||
if (!timeUntilExpiry) {
|
||||
console.warn("Token expired or invalid, attempting refresh now");
|
||||
this.refreshNow();
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule refresh before expiry
|
||||
const timeUntilRefresh = Math.max(
|
||||
0,
|
||||
timeUntilExpiry - this.refreshThresholdMs
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[Token Refresh] Token expires in ${Math.round(timeUntilExpiry / 1000)}s, ` +
|
||||
`scheduling refresh in ${Math.round(timeUntilRefresh / 1000)}s`
|
||||
);
|
||||
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
this.refreshNow();
|
||||
}, timeUntilRefresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform token refresh immediately
|
||||
*/
|
||||
async refreshNow(): Promise<boolean> {
|
||||
if (this.isRefreshing) {
|
||||
console.log("[Token Refresh] Refresh already in progress, skipping");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.isRefreshing = true;
|
||||
|
||||
try {
|
||||
console.log("[Token Refresh] Refreshing access token...");
|
||||
|
||||
const result = await api.auth.refreshToken.mutate({
|
||||
rememberMe: false // Maintain existing rememberMe state
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log("[Token Refresh] Token refreshed successfully");
|
||||
this.scheduleNextRefresh(); // Schedule next refresh
|
||||
return true;
|
||||
} else {
|
||||
console.error("[Token Refresh] Token refresh failed:", result);
|
||||
this.handleRefreshFailure();
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Token Refresh] Token refresh error:", error);
|
||||
this.handleRefreshFailure();
|
||||
return false;
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle refresh failure (redirect to login)
|
||||
*/
|
||||
private handleRefreshFailure(): void {
|
||||
console.warn("[Token Refresh] Token refresh failed, redirecting to login");
|
||||
|
||||
// Store current URL for redirect after login
|
||||
const currentPath = window.location.pathname + window.location.search;
|
||||
if (currentPath !== "/login") {
|
||||
sessionStorage.setItem("redirectAfterLogin", currentPath);
|
||||
}
|
||||
|
||||
// Redirect to login
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const tokenRefreshManager = new TokenRefreshManager();
|
||||
|
||||
/**
|
||||
* Manually trigger token refresh (can be called from UI)
|
||||
* @returns Promise<boolean> success status
|
||||
*/
|
||||
export async function manualRefresh(): Promise<boolean> {
|
||||
return tokenRefreshManager.refreshNow();
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
query
|
||||
} from "@solidjs/router";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { revalidateUserState } from "~/components/Bars";
|
||||
import { getEvent } from "vinxi/http";
|
||||
import GoogleLogo from "~/components/icons/GoogleLogo";
|
||||
import GitHub from "~/components/icons/GitHub";
|
||||
@@ -205,6 +206,7 @@ export default function LoginPage() {
|
||||
|
||||
if (response.ok && result.result?.data?.success) {
|
||||
setShowPasswordSuccess(true);
|
||||
revalidateUserState(); // Refresh user state in sidebar
|
||||
setTimeout(() => {
|
||||
navigate("/account", { replace: true });
|
||||
}, 500);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,8 @@ export type AuditEventType =
|
||||
| "security.rate_limit.exceeded"
|
||||
| "security.csrf.failed"
|
||||
| "security.suspicious.activity"
|
||||
| "admin.action";
|
||||
| "admin.action"
|
||||
| "system.session_cleanup";
|
||||
|
||||
/**
|
||||
* Audit log entry structure
|
||||
|
||||
181
src/server/token-cleanup.ts
Normal file
181
src/server/token-cleanup.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { ConnectionFactory } from "~/server/utils";
|
||||
import { logAuditEvent } from "~/server/audit";
|
||||
import { AUTH_CONFIG } from "~/config";
|
||||
|
||||
/**
|
||||
* Cleanup expired and revoked sessions
|
||||
* Keeps sessions for audit purposes up to retention limit
|
||||
* @param retentionDays - How long to keep revoked sessions (default 90)
|
||||
* @returns Cleanup statistics
|
||||
*/
|
||||
export async function cleanupExpiredSessions(
|
||||
retentionDays: number = AUTH_CONFIG.SESSION_CLEANUP_RETENTION_DAYS
|
||||
): Promise<{
|
||||
expiredDeleted: number;
|
||||
revokedDeleted: number;
|
||||
totalDeleted: number;
|
||||
}> {
|
||||
const conn = ConnectionFactory();
|
||||
const retentionDate = new Date();
|
||||
retentionDate.setDate(retentionDate.getDate() - retentionDays);
|
||||
|
||||
try {
|
||||
// Step 1: Delete expired sessions (hard delete)
|
||||
const expiredResult = await conn.execute({
|
||||
sql: `DELETE FROM Session
|
||||
WHERE expires_at < datetime('now')
|
||||
AND created_at < ?`,
|
||||
args: [retentionDate.toISOString()]
|
||||
});
|
||||
|
||||
// Step 2: Delete old revoked sessions (keep recent for audit)
|
||||
const revokedResult = await conn.execute({
|
||||
sql: `DELETE FROM Session
|
||||
WHERE revoked = 1
|
||||
AND created_at < ?`,
|
||||
args: [retentionDate.toISOString()]
|
||||
});
|
||||
|
||||
const stats = {
|
||||
expiredDeleted: Number(expiredResult.rowsAffected) || 0,
|
||||
revokedDeleted: Number(revokedResult.rowsAffected) || 0,
|
||||
totalDeleted:
|
||||
(Number(expiredResult.rowsAffected) || 0) +
|
||||
(Number(revokedResult.rowsAffected) || 0)
|
||||
};
|
||||
|
||||
console.log(
|
||||
`Session cleanup completed: ${stats.totalDeleted} sessions deleted ` +
|
||||
`(${stats.expiredDeleted} expired, ${stats.revokedDeleted} revoked)`
|
||||
);
|
||||
|
||||
// Log cleanup event
|
||||
await logAuditEvent({
|
||||
eventType: "system.session_cleanup",
|
||||
eventData: stats,
|
||||
success: true
|
||||
});
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error("Session cleanup failed:", error);
|
||||
|
||||
await logAuditEvent({
|
||||
eventType: "system.session_cleanup",
|
||||
eventData: { error: String(error) },
|
||||
success: false
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup orphaned parent session references
|
||||
* Remove parent_session_id references to deleted sessions
|
||||
*/
|
||||
export async function cleanupOrphanedReferences(): Promise<number> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: `UPDATE Session
|
||||
SET parent_session_id = NULL
|
||||
WHERE parent_session_id IS NOT NULL
|
||||
AND parent_session_id NOT IN (
|
||||
SELECT id FROM Session
|
||||
)`
|
||||
});
|
||||
|
||||
const orphansFixed = Number(result.rowsAffected) || 0;
|
||||
if (orphansFixed > 0) {
|
||||
console.log(`Fixed ${orphansFixed} orphaned parent_session_id references`);
|
||||
}
|
||||
|
||||
return orphansFixed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session statistics for monitoring
|
||||
*/
|
||||
export async function getSessionStats(): Promise<{
|
||||
total: number;
|
||||
active: number;
|
||||
expired: number;
|
||||
revoked: number;
|
||||
avgRotationCount: number;
|
||||
}> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const totalResult = await conn.execute({
|
||||
sql: "SELECT COUNT(*) as count FROM Session"
|
||||
});
|
||||
|
||||
const activeResult = await conn.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM Session
|
||||
WHERE revoked = 0 AND expires_at > datetime('now')`
|
||||
});
|
||||
|
||||
const expiredResult = await conn.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM Session
|
||||
WHERE expires_at < datetime('now')`
|
||||
});
|
||||
|
||||
const revokedResult = await conn.execute({
|
||||
sql: "SELECT COUNT(*) as count FROM Session WHERE revoked = 1"
|
||||
});
|
||||
|
||||
const rotationResult = await conn.execute({
|
||||
sql: "SELECT AVG(rotation_count) as avg FROM Session WHERE revoked = 0"
|
||||
});
|
||||
|
||||
return {
|
||||
total: Number(totalResult.rows[0]?.count) || 0,
|
||||
active: Number(activeResult.rows[0]?.count) || 0,
|
||||
expired: Number(expiredResult.rows[0]?.count) || 0,
|
||||
revoked: Number(revokedResult.rows[0]?.count) || 0,
|
||||
avgRotationCount: Number(rotationResult.rows[0]?.avg) || 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Opportunistic cleanup trigger
|
||||
* Runs cleanup if it hasn't been run recently (serverless-friendly)
|
||||
* Uses a simple timestamp check to avoid running too frequently
|
||||
*/
|
||||
let lastCleanupTime = 0;
|
||||
|
||||
export async function opportunisticCleanup(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const minIntervalMs =
|
||||
AUTH_CONFIG.SESSION_CLEANUP_INTERVAL_HOURS * 60 * 60 * 1000;
|
||||
|
||||
// Only run if enough time has passed since last cleanup
|
||||
if (now - lastCleanupTime < minIntervalMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update timestamp immediately to prevent concurrent runs
|
||||
lastCleanupTime = now;
|
||||
|
||||
try {
|
||||
console.log("Running opportunistic session cleanup...");
|
||||
|
||||
// Run cleanup asynchronously (don't block the request)
|
||||
Promise.all([cleanupExpiredSessions(), cleanupOrphanedReferences()])
|
||||
.then(([stats, orphansFixed]) => {
|
||||
console.log(
|
||||
`Opportunistic cleanup completed: ${stats.totalDeleted} sessions deleted, ` +
|
||||
`${orphansFixed} orphaned references fixed`
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Opportunistic cleanup error:", error);
|
||||
// Reset timer on failure so we can retry sooner
|
||||
lastCleanupTime = now - minIntervalMs + 5 * 60 * 1000; // Retry in 5 minutes
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Opportunistic cleanup trigger error:", error);
|
||||
// Reset timer on failure
|
||||
lastCleanupTime = now - minIntervalMs + 5 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user