diff --git a/src/app.tsx b/src/app.tsx index 2e2499f..f8b92c2 100644 --- a/src/app.tsx +++ b/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 ( { }; }, "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, ""); diff --git a/src/config.ts b/src/config.ts index a239f64..e6b1a6f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; diff --git a/src/db/create.ts b/src/db/create.ts index 29dba25..4a9635a 100644 --- a/src/db/create.ts +++ b/src/db/create.ts @@ -15,21 +15,29 @@ export const model: { [key: string]: string } = { ); `, Session: ` - CREATE TABLE Session - ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - token_family TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - 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 - ); - 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 TABLE Session + ( + 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 (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 diff --git a/src/lib/client-utils.ts b/src/lib/client-utils.ts index 98eae26..db67207 100644 --- a/src/lib/client-utils.ts +++ b/src/lib/client-utils.ts @@ -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. */ diff --git a/src/lib/token-refresh.ts b/src/lib/token-refresh.ts new file mode 100644 index 0000000..aa23c1c --- /dev/null +++ b/src/lib/token-refresh.ts @@ -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 | 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 { + 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 success status + */ +export async function manualRefresh(): Promise { + return tokenRefreshManager.refreshNow(); +} diff --git a/src/routes/login/index.tsx b/src/routes/login/index.tsx index 4779cf4..0c0aabe 100644 --- a/src/routes/login/index.tsx +++ b/src/routes/login/index.tsx @@ -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); diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts index 90cac7d..84c70e4 100644 --- a/src/server/api/routers/auth.ts +++ b/src/server/api/routers/auth.ts @@ -46,7 +46,14 @@ import { import { logAuditEvent } from "~/server/audit"; import type { H3Event } from "vinxi/http"; import type { Context } from "../utils"; -import { AUTH_CONFIG, NETWORK_CONFIG, COOLDOWN_TIMERS } from "~/config"; +import { + AUTH_CONFIG, + NETWORK_CONFIG, + COOLDOWN_TIMERS, + getAccessTokenExpiry, + getAccessCookieMaxAge +} from "~/config"; +import { randomBytes, createHash, timingSafeEqual } from "crypto"; /** * Safely extract H3Event from Context @@ -62,6 +69,339 @@ function getH3Event(ctx: Context): H3Event { return ctx.event as unknown as H3Event; } +// Cookie name constants +const REFRESH_TOKEN_COOKIE_NAME = "refreshToken" as const; +const ACCESS_TOKEN_COOKIE_NAME = "userIDToken" as const; + +// Zod schemas +const refreshTokenSchema = z.object({ + rememberMe: z.boolean().optional().default(false) +}); + +/** + * Generate a cryptographically secure refresh token + * @returns Base64URL-encoded random token (32 bytes = 256 bits) + */ +function generateRefreshToken(): string { + return randomBytes(32).toString("base64url"); +} + +/** + * Hash refresh token for storage (one-way hash) + * Using SHA-256 since refresh tokens are high-entropy random values + * @param token - Plaintext refresh token + * @returns Hex-encoded hash + */ +function hashRefreshToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +/** + * Validate refresh token against database + * Uses timing-safe comparison to prevent timing attacks + * @param token - Plaintext refresh token from client + * @param sessionId - Session ID to validate against + * @returns Session record if valid, null otherwise + */ +async function validateRefreshToken( + token: string, + sessionId: string +): Promise<{ + id: string; + user_id: string; + token_family: string; + parent_session_id: string | null; + rotation_count: number; + expires_at: string; + revoked: number; +} | null> { + const conn = ConnectionFactory(); + const tokenHash = hashRefreshToken(token); + + try { + const result = await conn.execute({ + sql: `SELECT id, user_id, token_family, parent_session_id, + rotation_count, expires_at, revoked, refresh_token_hash + FROM Session + WHERE id = ?`, + args: [sessionId] + }); + + if (result.rows.length === 0) { + return null; + } + + const session = result.rows[0]; + const storedHash = session.refresh_token_hash as string; + + // Timing-safe comparison to prevent timing attacks + if ( + !timingSafeEqual( + Buffer.from(tokenHash, "hex"), + Buffer.from(storedHash, "hex") + ) + ) { + return null; + } + + // Check if revoked + if (session.revoked === 1) { + return null; + } + + // Check if expired + const expiresAt = new Date(session.expires_at as string); + if (expiresAt < new Date()) { + return null; + } + + return { + id: session.id as string, + user_id: session.user_id as string, + token_family: session.token_family as string, + parent_session_id: session.parent_session_id as string | null, + rotation_count: session.rotation_count as number, + expires_at: session.expires_at as string, + revoked: session.revoked as number + }; + } catch (error) { + console.error("Refresh token validation error:", error); + return null; + } +} + +/** + * Invalidate a specific session + * Sets revoked flag without deleting (for audit trail) + * @param sessionId - Session ID to invalidate + */ +async function invalidateSession(sessionId: string): Promise { + const conn = ConnectionFactory(); + await conn.execute({ + sql: "UPDATE Session SET revoked = 1 WHERE id = ?", + args: [sessionId] + }); +} + +/** + * Revoke all sessions in a token family + * Used when breach is detected (token reuse) + * @param tokenFamily - Token family ID to revoke + * @param reason - Reason for revocation (for audit) + */ +async function revokeTokenFamily( + tokenFamily: string, + reason: string = "breach_detected" +): Promise { + const conn = ConnectionFactory(); + + // Get all sessions in family for audit log + const sessions = await conn.execute({ + sql: "SELECT id, user_id FROM Session WHERE token_family = ? AND revoked = 0", + args: [tokenFamily] + }); + + // Revoke all sessions in family + await conn.execute({ + sql: "UPDATE Session SET revoked = 1 WHERE token_family = ?", + args: [tokenFamily] + }); + + // Log audit events for each affected session + for (const session of sessions.rows) { + await logAuditEvent({ + userId: session.user_id as string, + eventType: "auth.token_family_revoked", + eventData: { + tokenFamily, + sessionId: session.id as string, + reason + }, + success: true + }); + } + + console.warn(`Token family ${tokenFamily} revoked: ${reason}`); +} + +/** + * Detect if a token is being reused after rotation + * Implements grace period for race conditions + * @param sessionId - Session ID being validated + * @returns true if reuse detected (and revocation occurred), false otherwise + */ +async function detectTokenReuse(sessionId: string): Promise { + const conn = ConnectionFactory(); + + // Check if this session has already been rotated (has child session) + 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) { + // No child session, this is legitimate first use + return false; + } + + 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 for race conditions (e.g., slow network, retries) + if (timeSinceRotation < AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS) { + console.warn( + `Token reuse within grace period (${timeSinceRotation}ms), allowing` + ); + return false; + } + + // Reuse detected outside grace period - this is a breach! + console.error( + `Token reuse detected! Session ${sessionId} rotated ${timeSinceRotation}ms ago` + ); + + // Get token family and revoke entire family + const sessionInfo = await conn.execute({ + sql: "SELECT token_family, user_id FROM Session WHERE id = ?", + args: [sessionId] + }); + + if (sessionInfo.rows.length > 0) { + const tokenFamily = sessionInfo.rows[0].token_family as string; + const userId = sessionInfo.rows[0].user_id as string; + + await revokeTokenFamily(tokenFamily, "token_reuse_detected"); + + // Log critical security event + await logAuditEvent({ + userId, + eventType: "auth.token_reuse_detected", + eventData: { + sessionId, + tokenFamily, + timeSinceRotation + }, + success: false + }); + + return true; + } + + return false; +} + +/** + * Rotate refresh token: invalidate old, issue new tokens + * Implements automatic breach detection + * @param oldRefreshToken - Current refresh token from client + * @param oldSessionId - Current session ID from JWT + * @param rememberMe - Whether to extend session lifetime + * @param ipAddress - Client IP address for new session + * @param userAgent - Client user agent for new session + * @returns New tokens or null if rotation fails + */ +async function rotateRefreshToken( + oldRefreshToken: string, + oldSessionId: string, + rememberMe: boolean, + ipAddress: string, + userAgent: string +): Promise<{ + accessToken: string; + refreshToken: string; + sessionId: string; +} | null> { + // Step 1: Validate old refresh token + const oldSession = await validateRefreshToken(oldRefreshToken, oldSessionId); + + if (!oldSession) { + console.warn("Invalid refresh token during rotation"); + return null; + } + + // Step 2: Detect token reuse (breach detection) + const reuseDetected = await detectTokenReuse(oldSessionId); + if (reuseDetected) { + // Token family already revoked by detectTokenReuse + return null; + } + + // Step 3: Check rotation limit + if (oldSession.rotation_count >= AUTH_CONFIG.MAX_ROTATION_COUNT) { + console.warn(`Max rotation count reached for session ${oldSessionId}`); + await invalidateSession(oldSessionId); + return null; + } + + // Step 4: Generate new tokens + const newRefreshToken = generateRefreshToken(); + const refreshExpiry = rememberMe + ? AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG + : AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_SHORT; + + // Step 5: Create new session (linked to old via parent_session_id) + const { sessionId: newSessionId, tokenFamily } = await createSession( + oldSession.user_id, + refreshExpiry, + ipAddress, + userAgent, + newRefreshToken, + oldSessionId, // parent session for audit trail + oldSession.token_family // reuse family + ); + + // Step 6: Create new access token + const newAccessToken = await createJWT( + oldSession.user_id, + newSessionId, + getAccessTokenExpiry() + ); + + // Step 7: Invalidate old session (after new one is created successfully) + await invalidateSession(oldSessionId); + + // Step 8: Log rotation event + await logAuditEvent({ + userId: oldSession.user_id, + eventType: "auth.token_rotated", + eventData: { + oldSessionId, + newSessionId, + tokenFamily, + rotationCount: oldSession.rotation_count + 1 + }, + success: true + }); + + return { + accessToken: newAccessToken, + refreshToken: newRefreshToken, + sessionId: newSessionId + }; +} + +/** + * Extract session ID from access token (JWT) + * @param accessToken - JWT access token + * @returns Session ID or null if invalid + */ +async function getSessionIdFromToken( + accessToken: string +): Promise { + try { + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); + const { payload } = await jwtVerify(accessToken, secret); + return (payload.sid as string) || null; + } catch (error) { + return null; + } +} + /** * Create JWT with session tracking * @param userId - User ID @@ -86,23 +426,31 @@ async function createJWT( } /** - * Create a new session in the database + * Create a new session in the database with refresh token support * @param userId - User ID - * @param expiresIn - Session expiration (e.g., "14d", "12h") + * @param expiresIn - Refresh token expiration (e.g., "7d", "90d") * @param ipAddress - Client IP address * @param userAgent - Client user agent string - * @returns Session ID + * @param refreshToken - Plaintext refresh token to hash and store + * @param parentSessionId - ID of parent session if this is a rotation (null for new sessions) + * @param tokenFamily - Token family UUID for rotation chain (generated if null) + * @returns Object with sessionId and tokenFamily */ async function createSession( userId: string, expiresIn: string, ipAddress: string, - userAgent: string -): Promise { + userAgent: string, + refreshToken: string, + parentSessionId: string | null = null, + tokenFamily: string | null = null +): Promise<{ sessionId: string; tokenFamily: string }> { const conn = ConnectionFactory(); const sessionId = uuidV4(); + const family = tokenFamily || uuidV4(); + const tokenHash = hashRefreshToken(refreshToken); - // Calculate expiration timestamp + // Calculate refresh token expiration const expiresAt = new Date(); if (expiresIn.endsWith("d")) { const days = parseInt(expiresIn); @@ -110,43 +458,121 @@ async function createSession( } else if (expiresIn.endsWith("h")) { const hours = parseInt(expiresIn); expiresAt.setHours(expiresAt.getHours() + hours); + } else if (expiresIn.endsWith("m")) { + const minutes = parseInt(expiresIn); + expiresAt.setMinutes(expiresAt.getMinutes() + minutes); + } + + // Calculate access token expiry (always shorter than refresh token) + const accessExpiresAt = new Date(); + const accessExpiry = getAccessTokenExpiry(); + if (accessExpiry.endsWith("m")) { + const minutes = parseInt(accessExpiry); + accessExpiresAt.setMinutes(accessExpiresAt.getMinutes() + minutes); + } else if (accessExpiry.endsWith("h")) { + const hours = parseInt(accessExpiry); + accessExpiresAt.setHours(accessExpiresAt.getHours() + hours); + } + + // Get rotation count from parent if exists + let rotationCount = 0; + if (parentSessionId) { + const parentResult = await conn.execute({ + sql: "SELECT rotation_count FROM Session WHERE id = ?", + args: [parentSessionId] + }); + if (parentResult.rows.length > 0) { + rotationCount = (parentResult.rows[0].rotation_count as number) + 1; + } } await conn.execute({ - sql: `INSERT INTO Session (id, user_id, token_family, expires_at, ip_address, user_agent) - VALUES (?, ?, ?, ?, ?, ?)`, + sql: `INSERT INTO Session + (id, user_id, token_family, refresh_token_hash, parent_session_id, + rotation_count, expires_at, access_token_expires_at, ip_address, user_agent) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, args: [ sessionId, userId, - uuidV4(), // token_family for future refresh token rotation + family, + tokenHash, + parentSessionId, + rotationCount, expiresAt.toISOString(), + accessExpiresAt.toISOString(), ipAddress, userAgent ] }); + return { sessionId, tokenFamily: family }; +} + +/** + * TEMPORARY WRAPPER: Backward compatibility for old session creation + * TODO: Remove this after migrating all callers to use refresh tokens (Tasks 05-06) + * @deprecated Use createSession with refresh tokens instead + */ +async function createSessionLegacy( + userId: string, + expiresIn: string, + ipAddress: string, + userAgent: string +): Promise { + // Generate a temporary refresh token for legacy sessions + const refreshToken = generateRefreshToken(); + const { sessionId } = await createSession( + userId, + expiresIn, + ipAddress, + userAgent, + refreshToken + ); return sessionId; } /** * Helper to set authentication cookies including CSRF token */ +/** + * Helper to set authentication cookies including CSRF token + * Sets both access token (short-lived) and refresh token (long-lived) + * @param event - H3Event + * @param accessToken - JWT access token + * @param refreshToken - Refresh token + * @param rememberMe - Whether to use extended refresh token expiry + */ function setAuthCookies( event: any, - token: string, - options: { maxAge?: number } = {} + accessToken: string, + refreshToken: string, + rememberMe: boolean = false ) { - const cookieOptions: any = { + // Access token cookie (short-lived, always same duration) + const accessMaxAge = getAccessCookieMaxAge(); + + setCookie(event, ACCESS_TOKEN_COOKIE_NAME, accessToken, { + maxAge: accessMaxAge, path: "/", httpOnly: true, secure: env.NODE_ENV === "production", - sameSite: "strict", - ...options - }; + sameSite: "strict" + }); - setCookie(event, "userIDToken", token, cookieOptions); + // Refresh token cookie (long-lived, varies based on rememberMe) + const refreshMaxAge = rememberMe + ? AUTH_CONFIG.REFRESH_COOKIE_MAX_AGE_LONG + : AUTH_CONFIG.REFRESH_COOKIE_MAX_AGE_SHORT; - // Set CSRF token for authenticated session + setCookie(event, REFRESH_TOKEN_COOKIE_NAME, refreshToken, { + maxAge: refreshMaxAge, + path: "/", + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict" + }); + + // CSRF token for authenticated session setCSRFToken(event); } @@ -315,28 +741,34 @@ export const authRouter = createTRPCRouter({ } } + // Determine token expiry (OAuth defaults to long expiry for better UX) + const accessExpiry = getAccessTokenExpiry(); + const refreshExpiry = AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG; + + // Generate refresh token + const refreshToken = generateRefreshToken(); + // Create session with client info const clientIP = getClientIP(getH3Event(ctx)); const userAgent = getUserAgent(getH3Event(ctx)); - const sessionId = await createSession( + const { sessionId } = await createSession( userId, - AUTH_CONFIG.JWT_EXPIRY, + refreshExpiry, clientIP, - userAgent + userAgent, + refreshToken ); - const token = await createJWT(userId, sessionId); + // Create access token + const accessToken = await createJWT(userId, sessionId, accessExpiry); - setCookie(getH3Event(ctx), "userIDToken", token, { - maxAge: AUTH_CONFIG.SESSION_COOKIE_MAX_AGE, - path: "/", - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "strict" - }); - - // Set CSRF token for authenticated session - setCSRFToken(getH3Event(ctx)); + // Set cookies + setAuthCookies( + getH3Event(ctx), + accessToken, + refreshToken, + true // OAuth defaults to remember + ); // Log successful OAuth login await logAuditEvent({ @@ -497,28 +929,34 @@ export const authRouter = createTRPCRouter({ } } + // Determine token expiry (OAuth defaults to long expiry for better UX) + const accessExpiry = getAccessTokenExpiry(); + const refreshExpiry = AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG; + + // Generate refresh token + const refreshToken = generateRefreshToken(); + // Create session with client info const clientIP = getClientIP(getH3Event(ctx)); const userAgent = getUserAgent(getH3Event(ctx)); - const sessionId = await createSession( + const { sessionId } = await createSession( userId, - AUTH_CONFIG.JWT_EXPIRY, + refreshExpiry, clientIP, - userAgent + userAgent, + refreshToken ); - const token = await createJWT(userId, sessionId); + // Create access token + const accessToken = await createJWT(userId, sessionId, accessExpiry); - setCookie(getH3Event(ctx), "userIDToken", token, { - maxAge: AUTH_CONFIG.SESSION_COOKIE_MAX_AGE, - path: "/", - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "strict" - }); - - // Set CSRF token for authenticated session - setCSRFToken(getH3Event(ctx)); + // Set cookies + setAuthCookies( + getH3Event(ctx), + accessToken, + refreshToken, + true // OAuth defaults to remember + ); // Log successful OAuth login await logAuditEvent({ @@ -616,36 +1054,36 @@ export const authRouter = createTRPCRouter({ const userId = (res.rows[0] as unknown as User).id; + // Determine token expiry based on rememberMe + const accessExpiry = getAccessTokenExpiry(); + const refreshExpiry = rememberMe + ? AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG + : AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_SHORT; + + // Generate refresh token + const refreshToken = generateRefreshToken(); + // Create session with client info const clientIP = getClientIP(getH3Event(ctx)); const userAgent = getUserAgent(getH3Event(ctx)); - const expiresIn = rememberMe - ? AUTH_CONFIG.JWT_EXPIRY - : AUTH_CONFIG.JWT_EXPIRY_SHORT; - const sessionId = await createSession( + const { sessionId } = await createSession( userId, - expiresIn, + refreshExpiry, clientIP, - userAgent + userAgent, + refreshToken ); - const userToken = await createJWT(userId, sessionId, expiresIn); + // Create access token + const accessToken = await createJWT(userId, sessionId, accessExpiry); - const cookieOptions: any = { - path: "/", - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "strict" - }; - - if (rememberMe) { - cookieOptions.maxAge = AUTH_CONFIG.REMEMBER_ME_MAX_AGE; - } - - setCookie(getH3Event(ctx), "userIDToken", userToken, cookieOptions); - - // Set CSRF token for authenticated session - setCSRFToken(getH3Event(ctx)); + // Set cookies + setAuthCookies( + getH3Event(ctx), + accessToken, + refreshToken, + rememberMe || false + ); // Log successful email link login await logAuditEvent({ @@ -788,28 +1226,34 @@ export const authRouter = createTRPCRouter({ args: [userId, email, passwordHash, "email"] }); + // Determine token expiry (registration defaults to short expiry) + const accessExpiry = getAccessTokenExpiry(); + const refreshExpiry = AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_SHORT; // Default to 7 days + + // Generate refresh token + const refreshToken = generateRefreshToken(); + // Create session with client info const clientIP = getClientIP(getH3Event(ctx)); const userAgent = getUserAgent(getH3Event(ctx)); - const sessionId = await createSession( + const { sessionId } = await createSession( userId, - AUTH_CONFIG.JWT_EXPIRY, + refreshExpiry, clientIP, - userAgent + userAgent, + refreshToken ); - const token = await createJWT(userId, sessionId); + // Create access token + const accessToken = await createJWT(userId, sessionId, accessExpiry); - setCookie(getH3Event(ctx), "userIDToken", token, { - maxAge: AUTH_CONFIG.SESSION_COOKIE_MAX_AGE, - path: "/", - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "strict" - }); - - // Set CSRF token for authenticated session - setCSRFToken(getH3Event(ctx)); + // Set cookies + setAuthCookies( + getH3Event(ctx), + accessToken, + refreshToken, + false // Registration defaults to non-remember + ); // Log successful registration await logAuditEvent({ @@ -961,36 +1405,35 @@ export const authRouter = createTRPCRouter({ // Reset failed attempts on successful login await resetFailedAttempts(user.id); - const expiresIn = rememberMe - ? AUTH_CONFIG.JWT_EXPIRY - : AUTH_CONFIG.JWT_EXPIRY_SHORT; + // Determine token expiry based on rememberMe + const accessExpiry = getAccessTokenExpiry(); // Always 15m + const refreshExpiry = rememberMe + ? AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG + : AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_SHORT; + + // Create refresh token + const refreshToken = generateRefreshToken(); // Create session with client info (reuse clientIP from rate limiting) const userAgent = getUserAgent(getH3Event(ctx)); - const sessionId = await createSession( + const { sessionId } = await createSession( user.id, - expiresIn, + refreshExpiry, clientIP, - userAgent + userAgent, + refreshToken ); - const token = await createJWT(user.id, sessionId, expiresIn); + // Create access token (short-lived) + const accessToken = await createJWT(user.id, sessionId, accessExpiry); - const cookieOptions: any = { - path: "/", - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "strict" - }; - - if (rememberMe) { - cookieOptions.maxAge = AUTH_CONFIG.REMEMBER_ME_MAX_AGE; - } - - setCookie(getH3Event(ctx), "userIDToken", token, cookieOptions); - - // Set CSRF token for authenticated session - setCSRFToken(getH3Event(ctx)); + // Set both tokens in cookies with proper maxAge + setAuthCookies( + getH3Event(ctx), + accessToken, + refreshToken, + rememberMe || false + ); // Log successful login (wrap in try-catch to ensure it never blocks auth flow) try { @@ -1558,40 +2001,343 @@ export const authRouter = createTRPCRouter({ } }), + refreshToken: publicProcedure + .input(refreshTokenSchema) + .mutation(async ({ ctx, input }) => { + const { rememberMe } = input; + + try { + // Step 1: Get current access token from cookie + const currentAccessToken = getCookie( + getH3Event(ctx), + ACCESS_TOKEN_COOKIE_NAME + ); + if (!currentAccessToken) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "No access token found" + }); + } + + // Step 2: Extract session ID from access token (even if expired) + let sessionId: string | null = null; + try { + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); + // Don't verify expiration, we expect it might be expired + const { payload } = await jwtVerify(currentAccessToken, secret, { + clockTolerance: 60 * 60 * 24 // 24h tolerance for expired tokens + }); + sessionId = payload.sid as string; + } catch (error) { + // If we can't even decode, try manual parsing + const parts = currentAccessToken.split("."); + if (parts.length === 3) { + try { + const payloadBase64 = parts[1]; + const payload = JSON.parse( + Buffer.from(payloadBase64, "base64url").toString() + ); + sessionId = payload.sid; + } catch (e) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid access token format" + }); + } + } + } + + if (!sessionId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Could not extract session ID from token" + }); + } + + // Step 3: Get refresh token from cookie + const refreshToken = getCookie( + getH3Event(ctx), + REFRESH_TOKEN_COOKIE_NAME + ); + if (!refreshToken) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "No refresh token found" + }); + } + + // Step 4: Get client info for new session + const clientIP = getClientIP(getH3Event(ctx)); + const userAgent = getUserAgent(getH3Event(ctx)); + + // Step 5: Rotate tokens (includes all validation and breach detection) + const rotated = await rotateRefreshToken( + refreshToken, + sessionId, + rememberMe, + clientIP, + userAgent + ); + + if (!rotated) { + // Rotation failed - could be invalid token, reuse detected, etc. + // Clear cookies to force re-login + setCookie(getH3Event(ctx), ACCESS_TOKEN_COOKIE_NAME, "", { + maxAge: 0, + path: "/" + }); + setCookie(getH3Event(ctx), REFRESH_TOKEN_COOKIE_NAME, "", { + maxAge: 0, + path: "/" + }); + + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Token refresh failed - please login again" + }); + } + + // Step 6: Set new access token cookie + const accessCookieMaxAge = getAccessCookieMaxAge(); + + setCookie( + getH3Event(ctx), + ACCESS_TOKEN_COOKIE_NAME, + rotated.accessToken, + { + maxAge: accessCookieMaxAge, + path: "/", + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict" + } + ); + + // Step 7: Set new refresh token cookie + const refreshCookieMaxAge = rememberMe + ? AUTH_CONFIG.REFRESH_COOKIE_MAX_AGE_LONG + : AUTH_CONFIG.REFRESH_COOKIE_MAX_AGE_SHORT; + + setCookie( + getH3Event(ctx), + REFRESH_TOKEN_COOKIE_NAME, + rotated.refreshToken, + { + maxAge: refreshCookieMaxAge, + path: "/", + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict" + } + ); + + // Step 8: Refresh CSRF token + setCSRFToken(getH3Event(ctx)); + + // Step 9: Opportunistic cleanup (serverless-friendly) + // Run asynchronously without blocking the response + import("~/server/token-cleanup") + .then((module) => module.opportunisticCleanup()) + .catch((err) => console.error("Opportunistic cleanup failed:", err)); + + return { + success: true, + message: "Token refreshed successfully" + }; + } catch (error) { + // Log error but don't expose details to client + console.error("Token refresh error:", error); + + if (error instanceof TRPCError) { + throw error; + } + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Token refresh failed" + }); + } + }), + signOut: publicProcedure.mutation(async ({ ctx }) => { - // Try to get user ID for audit log before clearing cookies let userId: string | null = null; + let tokenFamily: string | null = null; + let sessionId: string | null = null; + try { + // Step 1: Get user ID and token family from access token const token = getCookie(getH3Event(ctx), "userIDToken"); if (token) { - const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); - const { payload } = await jwtVerify(token, secret); - userId = payload.id as string; + try { + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); + const { payload } = await jwtVerify(token, secret, { + clockTolerance: 60 * 60 * 24 // Allow expired tokens + }); + userId = payload.id as string; + sessionId = payload.sid as string; + + // Get token family from session + if (sessionId) { + const conn = ConnectionFactory(); + const sessionResult = await conn.execute({ + sql: "SELECT token_family FROM Session WHERE id = ?", + args: [sessionId] + }); + + if (sessionResult.rows.length > 0) { + tokenFamily = sessionResult.rows[0].token_family as string; + } + } + } catch (e) { + // Token verification failed, try to decode without verification + try { + const parts = token.split("."); + if (parts.length === 3) { + const payload = JSON.parse( + Buffer.from(parts[1], "base64url").toString() + ); + userId = payload.id; + sessionId = payload.sid; + + // Still try to get token family + if (sessionId) { + const conn = ConnectionFactory(); + const sessionResult = await conn.execute({ + sql: "SELECT token_family FROM Session WHERE id = ?", + args: [sessionId] + }); + + if (sessionResult.rows.length > 0) { + tokenFamily = sessionResult.rows[0].token_family as string; + } + } + } + } catch (decodeError) { + console.error("Could not decode token for signout:", decodeError); + } + } + } + + // Step 2: Revoke entire token family if found + if (tokenFamily) { + await revokeTokenFamily(tokenFamily, "user_logout"); + console.log(`Token family ${tokenFamily} revoked on signout`); + } else if (sessionId) { + // Fallback: revoke just this session if family not found + await invalidateSession(sessionId); + console.log(`Session ${sessionId} invalidated on signout`); } } catch (e) { - // Ignore token verification errors during signout + console.error("Error during signout token revocation:", e); + // Continue with cookie clearing even if revocation fails } + // Step 3: Clear all auth cookies setCookie(getH3Event(ctx), "userIDToken", "", { maxAge: 0, path: "/" }); + setCookie(getH3Event(ctx), "refreshToken", "", { + maxAge: 0, + path: "/" + }); setCookie(getH3Event(ctx), "emailToken", "", { maxAge: 0, path: "/" }); - // Log signout - const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); - await logAuditEvent({ - userId, - eventType: "auth.logout", - eventData: {}, - ipAddress, - userAgent, - success: true - }); + // Step 4: Log signout event + if (userId) { + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); + await logAuditEvent({ + userId, + eventType: "auth.signout", + eventData: { + sessionId: sessionId || "unknown", + tokenFamily: tokenFamily || "unknown", + method: "manual" + }, + ipAddress, + userAgent, + success: true + }); + } return { success: true }; + }), + + // Admin endpoints for session management + cleanupSessions: publicProcedure.mutation(async ({ ctx }) => { + // Get user ID to check admin status + const userId = await getUserID(getH3Event(ctx)); + if (!userId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Authentication required" + }); + } + + // Import cleanup functions + const { cleanupExpiredSessions, cleanupOrphanedReferences } = + await import("~/server/token-cleanup"); + + try { + // Run cleanup + const stats = await cleanupExpiredSessions(); + const orphansFixed = await cleanupOrphanedReferences(); + + // Log admin action + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); + await logAuditEvent({ + userId, + eventType: "admin.session_cleanup", + eventData: { + sessionsDeleted: stats.totalDeleted, + orphansFixed + }, + ipAddress, + userAgent, + success: true + }); + + return { + success: true, + sessionsDeleted: stats.totalDeleted, + expiredDeleted: stats.expiredDeleted, + revokedDeleted: stats.revokedDeleted, + orphansFixed + }; + } catch (error) { + console.error("Manual cleanup failed:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Cleanup failed" + }); + } + }), + + getSessionStats: publicProcedure.query(async ({ ctx }) => { + // Get user ID to check admin status + const userId = await getUserID(getH3Event(ctx)); + if (!userId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Authentication required" + }); + } + + // Import stats function + const { getSessionStats } = await import("~/server/token-cleanup"); + + try { + const stats = await getSessionStats(); + return stats; + } catch (error) { + console.error("Failed to get session stats:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to retrieve stats" + }); + } }) }); diff --git a/src/server/audit.ts b/src/server/audit.ts index 366d37d..be445a6 100644 --- a/src/server/audit.ts +++ b/src/server/audit.ts @@ -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 diff --git a/src/server/token-cleanup.ts b/src/server/token-cleanup.ts new file mode 100644 index 0000000..640c2a6 --- /dev/null +++ b/src/server/token-cleanup.ts @@ -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 { + 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 { + 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; + } +}