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 { MOBILE_CONFIG } from "./config";
|
||||||
import CustomScrollbar from "./components/CustomScrollbar";
|
import CustomScrollbar from "./components/CustomScrollbar";
|
||||||
import { initPerformanceTracking } from "~/lib/performance-tracking";
|
import { initPerformanceTracking } from "~/lib/performance-tracking";
|
||||||
|
import { tokenRefreshManager } from "~/lib/token-refresh";
|
||||||
|
|
||||||
function AppLayout(props: { children: any }) {
|
function AppLayout(props: { children: any }) {
|
||||||
const {
|
const {
|
||||||
@@ -190,6 +191,16 @@ function AppLayout(props: { children: any }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
onMount(() => {
|
||||||
|
// Start token refresh monitoring
|
||||||
|
tokenRefreshManager.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
// Cleanup token refresh on unmount
|
||||||
|
tokenRefreshManager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
useNavigate,
|
useNavigate,
|
||||||
useLocation,
|
useLocation,
|
||||||
query,
|
query,
|
||||||
createAsync
|
createAsync,
|
||||||
|
revalidate
|
||||||
} from "@solidjs/router";
|
} from "@solidjs/router";
|
||||||
import { BREAKPOINTS } from "~/config";
|
import { BREAKPOINTS } from "~/config";
|
||||||
import { getRequestEvent } from "solid-js/web";
|
import { getRequestEvent } from "solid-js/web";
|
||||||
@@ -51,6 +52,13 @@ const getUserState = query(async () => {
|
|||||||
};
|
};
|
||||||
}, "bars-user-state");
|
}, "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 {
|
function formatDomainName(url: string): string {
|
||||||
const domain = url.split("://")[1]?.split(":")[0] ?? url;
|
const domain = url.split("://")[1]?.split(":")[0] ?? url;
|
||||||
const withoutWww = domain.replace(/^www\./i, "");
|
const withoutWww = domain.replace(/^www\./i, "");
|
||||||
|
|||||||
@@ -7,17 +7,88 @@
|
|||||||
// AUTHENTICATION & SESSION
|
// 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 = {
|
export const AUTH_CONFIG = {
|
||||||
JWT_EXPIRY: "14d" as const,
|
// Access Token (JWT in cookie)
|
||||||
JWT_EXPIRY_SHORT: "12h" as const,
|
ACCESS_TOKEN_EXPIRY: "15m" as const, // 15 minutes (short-lived)
|
||||||
SESSION_COOKIE_MAX_AGE: 60 * 60 * 24 * 14,
|
ACCESS_TOKEN_EXPIRY_DEV: "2m" as const, // 1 hour in dev for convenience
|
||||||
REMEMBER_ME_MAX_AGE: 60 * 60 * 24 * 14,
|
|
||||||
|
// 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,
|
CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14,
|
||||||
EMAIL_LOGIN_LINK_EXPIRY: "15m" as const,
|
EMAIL_LOGIN_LINK_EXPIRY: "15m" as const,
|
||||||
EMAIL_VERIFICATION_LINK_EXPIRY: "15m" as const,
|
EMAIL_VERIFICATION_LINK_EXPIRY: "15m" as const,
|
||||||
LINEAGE_JWT_EXPIRY: "14d" as const
|
LINEAGE_JWT_EXPIRY: "14d" as const
|
||||||
} 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
|
// RATE LIMITING
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -223,3 +294,14 @@ export const AUDIT_CONFIG = {
|
|||||||
DEFAULT_QUERY_LIMIT: 100,
|
DEFAULT_QUERY_LIMIT: 100,
|
||||||
MAX_RETENTION_DAYS: 90
|
MAX_RETENTION_DAYS: 90
|
||||||
} as const;
|
} 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,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
token_family 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')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
expires_at TEXT NOT NULL,
|
expires_at TEXT NOT NULL,
|
||||||
|
access_token_expires_at TEXT NOT NULL,
|
||||||
last_used TEXT NOT NULL DEFAULT (datetime('now')),
|
last_used TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
ip_address TEXT,
|
ip_address TEXT,
|
||||||
user_agent TEXT,
|
user_agent TEXT,
|
||||||
revoked INTEGER DEFAULT 0,
|
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_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_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: `
|
PasswordResetToken: `
|
||||||
CREATE TABLE 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.
|
* 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
|
query
|
||||||
} from "@solidjs/router";
|
} from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { revalidateUserState } from "~/components/Bars";
|
||||||
import { getEvent } from "vinxi/http";
|
import { getEvent } from "vinxi/http";
|
||||||
import GoogleLogo from "~/components/icons/GoogleLogo";
|
import GoogleLogo from "~/components/icons/GoogleLogo";
|
||||||
import GitHub from "~/components/icons/GitHub";
|
import GitHub from "~/components/icons/GitHub";
|
||||||
@@ -205,6 +206,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (response.ok && result.result?.data?.success) {
|
if (response.ok && result.result?.data?.success) {
|
||||||
setShowPasswordSuccess(true);
|
setShowPasswordSuccess(true);
|
||||||
|
revalidateUserState(); // Refresh user state in sidebar
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigate("/account", { replace: true });
|
navigate("/account", { replace: true });
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,8 @@ export type AuditEventType =
|
|||||||
| "security.rate_limit.exceeded"
|
| "security.rate_limit.exceeded"
|
||||||
| "security.csrf.failed"
|
| "security.csrf.failed"
|
||||||
| "security.suspicious.activity"
|
| "security.suspicious.activity"
|
||||||
| "admin.action";
|
| "admin.action"
|
||||||
|
| "system.session_cleanup";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audit log entry structure
|
* 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