diff --git a/bun.lockb b/bun.lockb index 686ed1c..caa13fd 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 9bc5a6d..ed14721 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "jose": "^6.1.3", "mermaid": "^11.12.2", "motion": "^12.23.26", + "redis": "^5.10.0", "solid-js": "^1.9.5", "solid-tiptap": "^0.8.0", "uuid": "^13.0.0", diff --git a/src/app.tsx b/src/app.tsx index 634f189..75ed738 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -192,16 +192,6 @@ 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 ( { setRefreshTrigger((prev) => prev + 1); // Trigger re-fetch }; + // Server-side refresh in getUserState() handles auto-signin during SSR + // No client-side fallback needed - server handles everything with httpOnly cookies + // Listen for auth refresh events from external sources (token refresh, etc.) onMount(() => { if (typeof window === "undefined") return; @@ -91,6 +96,41 @@ export const AuthProvider: ParentComponent = (props) => { const isAdmin = () => serverAuth()?.privilegeLevel === "admin"; const isEmailVerified = () => serverAuth()?.emailVerified ?? false; + // Start/stop token refresh manager based on auth state + let previousAuth: boolean | undefined = undefined; + createEffect(() => { + const authenticated = isAuthenticated(); + + console.log( + `[AuthContext] createEffect triggered - authenticated: ${authenticated}, previousAuth: ${previousAuth}` + ); + + // Only act if auth state actually changed + if (authenticated === previousAuth) { + console.log("[AuthContext] Auth state unchanged, skipping"); + return; + } + + previousAuth = authenticated; + + if (authenticated) { + console.log( + "[AuthContext] User authenticated, starting token refresh manager" + ); + tokenRefreshManager.start(true); + } else { + console.log( + "[AuthContext] User not authenticated, stopping token refresh manager" + ); + tokenRefreshManager.stop(); + } + }); + + // Cleanup on unmount + onCleanup(() => { + tokenRefreshManager.stop(); + }); + const value: AuthContextType = { userState: serverAuth, isAuthenticated, diff --git a/src/env/server.ts b/src/env/server.ts index 897fcd6..be01087 100644 --- a/src/env/server.ts +++ b/src/env/server.ts @@ -31,7 +31,8 @@ const serverEnvSchema = z.object({ VITE_GITHUB_CLIENT_ID: z.string().min(1), VITE_WEBSOCKET: z.string().min(1), VITE_INFILL_ENDPOINT: z.string().min(1), - INFILL_BEARER_TOKEN: z.string().min(1) + INFILL_BEARER_TOKEN: z.string().min(1), + REDIS_URL: z.string().min(1) }); export type ServerEnv = z.infer; @@ -135,7 +136,8 @@ export const getMissingEnvVars = (): string[] => { "VITE_GOOGLE_CLIENT_ID", "VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE", "VITE_GITHUB_CLIENT_ID", - "VITE_WEBSOCKET" + "VITE_WEBSOCKET", + "REDIS_URL" ]; return requiredServerVars.filter((varName) => isMissingEnvVar(varName)); diff --git a/src/lib/auth-query.ts b/src/lib/auth-query.ts index e3f3d96..6d665a9 100644 --- a/src/lib/auth-query.ts +++ b/src/lib/auth-query.ts @@ -28,9 +28,61 @@ export const getUserState = query(async (): Promise => { "use server"; const { getPrivilegeLevel, getUserID } = await import("~/server/auth"); const { ConnectionFactory } = await import("~/server/utils"); + const { getCookie, setCookie } = await import("vinxi/http"); const event = getRequestEvent()!; - const privilegeLevel = await getPrivilegeLevel(event.nativeEvent); - const userId = await getUserID(event.nativeEvent); + + let privilegeLevel = await getPrivilegeLevel(event.nativeEvent); + let userId = await getUserID(event.nativeEvent); + + // If no userId but refresh token exists, attempt server-side token refresh + // Use a flag cookie to prevent infinite loops (only try once per request) + if (!userId) { + const refreshToken = getCookie(event.nativeEvent, "refreshToken"); + const refreshAttempted = getCookie(event.nativeEvent, "_refresh_attempted"); + + if (refreshToken && !refreshAttempted) { + console.log( + "[Auth-Query] Access token expired but refresh token exists, attempting server-side refresh" + ); + + // Set flag to prevent retry loops (expires immediately, just for this request) + setCookie(event.nativeEvent, "_refresh_attempted", "1", { + maxAge: 1, + path: "/", + httpOnly: true + }); + + try { + // Import token rotation function + const { attemptTokenRefresh } = + await import("~/server/api/routers/auth"); + + // Attempt to refresh tokens server-side + const refreshed = await attemptTokenRefresh( + event.nativeEvent, + refreshToken + ); + + if (refreshed) { + console.log("[Auth-Query] Server-side token refresh successful"); + // Re-check auth state with new tokens + privilegeLevel = await getPrivilegeLevel(event.nativeEvent); + userId = await getUserID(event.nativeEvent); + } else { + console.log("[Auth-Query] Server-side token refresh failed"); + } + } catch (error) { + console.error( + "[Auth-Query] Error during server-side token refresh:", + error + ); + } + } else if (refreshAttempted) { + console.log( + "[Auth-Query] Refresh already attempted this request, skipping" + ); + } + } if (!userId) { return { @@ -82,5 +134,11 @@ export function revalidateAuth() { // Dispatch browser event to trigger UI updates (client-side only) if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("auth-state-changed")); + + // Reset token refresh timer when auth state changes + // This ensures the timer is synchronized with fresh tokens + import("~/lib/token-refresh").then(({ tokenRefreshManager }) => { + tokenRefreshManager.reset(); + }); } } diff --git a/src/lib/token-refresh.ts b/src/lib/token-refresh.ts index 467ca28..3248a5b 100644 --- a/src/lib/token-refresh.ts +++ b/src/lib/token-refresh.ts @@ -1,34 +1,64 @@ /** * Token Refresh Manager * Handles automatic token refresh before expiry + * + * Note: Since access tokens are httpOnly cookies, we can't read them from client JS. + * Instead, we schedule refresh based on a fixed interval that aligns with token expiry. */ import { api } from "~/lib/api"; -import { getClientCookie } from "~/lib/cookies.client"; -import { getTimeUntilExpiry } from "~/lib/client-utils"; import { revalidateAuth } from "~/lib/auth-query"; +// Token expiry durations (must match server config) +const ACCESS_TOKEN_EXPIRY_MS = import.meta.env.PROD + ? 15 * 60 * 1000 + : 2 * 60 * 1000; // 15m prod, 2m dev +const REFRESH_THRESHOLD_MS = import.meta.env.PROD ? 2 * 60 * 1000 : 30 * 1000; // 2m prod, 30s dev + 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; + private lastRefreshTime: number | null = null; /** - * Start monitoring token and auto-refresh before expiry + * Start monitoring and auto-refresh + * @param isAuthenticated - Whether user is currently authenticated (from server state) */ - start(): void { + start(isAuthenticated: boolean = true): void { + console.log( + `[Token Refresh] start() called - isStarted: ${this.isStarted}, isAuthenticated: ${isAuthenticated}, lastRefreshTime: ${this.lastRefreshTime}` + ); + if (typeof window === "undefined") return; // Server-side bail - if (this.isStarted) return; // Already started, prevent duplicate listeners + + if (this.isStarted) { + console.log( + "[Token Refresh] Already started, skipping duplicate start()" + ); + return; // Already started, prevent duplicate listeners + } + + if (!isAuthenticated) { + console.log("[Token Refresh] Not authenticated, skipping start()"); + return; // No need to refresh if not authenticated + } this.isStarted = true; + this.lastRefreshTime = Date.now(); // Assume token was just issued + console.log( + `[Token Refresh] Manager started, lastRefreshTime set to ${this.lastRefreshTime}` + ); this.scheduleNextRefresh(); // Re-check on visibility change (user returns to tab) this.visibilityChangeHandler = () => { if (document.visibilityState === "visible") { - this.scheduleNextRefresh(); + console.log( + "[Token Refresh] Tab became visible, checking token status" + ); + this.checkAndRefreshIfNeeded(); } }; document.addEventListener("visibilitychange", this.visibilityChangeHandler); @@ -52,23 +82,85 @@ class TokenRefreshManager { } this.isStarted = false; + this.lastRefreshTime = null; // Reset refresh time on stop + } + + /** + * Reset the last refresh time (call after login or successful refresh) + */ + reset(): void { + console.log( + `[Token Refresh] reset() called - isRefreshing: ${this.isRefreshing}`, + new Error().stack?.split("\n").slice(1, 4).join("\n") // Show caller + ); + + // Don't reset if we're currently refreshing (prevents infinite loop) + if (this.isRefreshing) { + console.log("[Token Refresh] Skipping reset during active refresh"); + return; + } + + console.log( + `[Token Refresh] Resetting refresh timer, old lastRefreshTime: ${this.lastRefreshTime}` + ); + this.lastRefreshTime = Date.now(); + console.log(`[Token Refresh] New lastRefreshTime: ${this.lastRefreshTime}`); + + if (this.isStarted) { + this.scheduleNextRefresh(); + } + } + + /** + * Check if token needs refresh based on last refresh time + */ + private checkAndRefreshIfNeeded(): void { + if (!this.lastRefreshTime) { + console.log("[Token Refresh] No refresh history, refreshing now"); + this.refreshNow(); + return; + } + + const timeSinceRefresh = Date.now() - this.lastRefreshTime; + const timeUntilExpiry = ACCESS_TOKEN_EXPIRY_MS - timeSinceRefresh; + + if (timeUntilExpiry <= REFRESH_THRESHOLD_MS) { + // Token expired or about to expire - refresh immediately + console.log( + `[Token Refresh] Token likely expired (${Math.round(timeSinceRefresh / 1000)}s since last refresh), refreshing now` + ); + this.refreshNow(); + } else { + // Token still valid - reschedule + console.log( + `[Token Refresh] Token still valid (~${Math.round(timeUntilExpiry / 1000)}s remaining), rescheduling refresh` + ); + this.scheduleNextRefresh(); + } } /** * Schedule next refresh based on token expiry */ private scheduleNextRefresh(): void { - this.stop(); // Clear existing timer + // Clear existing timer but don't stop the manager + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } - const token = getClientCookie("userIDToken"); - if (!token) { - // No token found - user not logged in, nothing to refresh + if (!this.lastRefreshTime) { + console.log("[Token Refresh] No refresh history, cannot schedule"); return; } - const timeUntilExpiry = getTimeUntilExpiry(token); - if (!timeUntilExpiry) { - console.warn("Token expired or invalid, attempting refresh now"); + const timeSinceRefresh = Date.now() - this.lastRefreshTime; + const timeUntilExpiry = ACCESS_TOKEN_EXPIRY_MS - timeSinceRefresh; + + if (timeUntilExpiry <= REFRESH_THRESHOLD_MS) { + console.warn( + "[Token Refresh] Token likely expired, attempting refresh now" + ); this.refreshNow(); return; } @@ -76,12 +168,12 @@ class TokenRefreshManager { // Schedule refresh before expiry const timeUntilRefresh = Math.max( 0, - timeUntilExpiry - this.refreshThresholdMs + timeUntilExpiry - REFRESH_THRESHOLD_MS ); console.log( - `[Token Refresh] Token expires in ${Math.round(timeUntilExpiry / 1000)}s, ` + - `scheduling refresh in ${Math.round(timeUntilRefresh / 1000)}s` + `[Token Refresh] Scheduling refresh in ${Math.round(timeUntilRefresh / 1000)}s ` + + `(~${Math.round(timeUntilExpiry / 1000)}s until expiry)` ); this.refreshTimer = setTimeout(() => { @@ -89,6 +181,16 @@ class TokenRefreshManager { }, timeUntilRefresh); } + /** + * Get rememberMe preference + * Since we can't read httpOnly cookies, we default to true and let the server + * determine the correct expiry based on the existing session + */ + private getRememberMePreference(): boolean { + // Default to true - server will use the correct expiry from the existing session + return true; + } + /** * Perform token refresh immediately */ @@ -103,14 +205,23 @@ class TokenRefreshManager { try { console.log("[Token Refresh] Refreshing access token..."); + // Preserve rememberMe state from existing session + const rememberMe = this.getRememberMePreference(); + console.log( + `[Token Refresh] Using rememberMe: ${rememberMe} (from refresh token cookie existence)` + ); + const result = await api.auth.refreshToken.mutate({ - rememberMe: false // Maintain existing rememberMe state + rememberMe }); if (result.success) { console.log("[Token Refresh] Token refreshed successfully"); - revalidateAuth(); // Refresh auth state after token refresh + this.lastRefreshTime = Date.now(); // Update refresh time this.scheduleNextRefresh(); // Schedule next refresh + + // Revalidate auth AFTER scheduling to avoid race condition + revalidateAuth(); // Refresh auth state after token refresh return true; } else { console.error("[Token Refresh] Token refresh failed:", result); @@ -141,6 +252,23 @@ class TokenRefreshManager { // Redirect to login window.location.href = "/login"; } + + /** + * Attempt immediate refresh (for page load when access token expired) + * Always attempts refresh - server will reject if no refresh token exists + * Returns true if refresh succeeded, false otherwise + * + * Note: We can't check for httpOnly refresh token from client JavaScript, + * so we always attempt and let the server decide if token exists + */ + async attemptInitialRefresh(): Promise { + console.log( + "[Token Refresh] Attempting initial refresh (server will check for refresh token)" + ); + + // refreshNow() already calls revalidateAuth() on success + return await this.refreshNow(); + } } // Singleton instance diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 62d27fd..07ded4f 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -164,7 +164,7 @@ export default function Home() { -
+
{ const conn = ConnectionFactory(); + console.log(`[Session] Invalidating session ${sessionId}`); await conn.execute({ sql: "UPDATE Session SET revoked = 1 WHERE id = ?", args: [sessionId] @@ -202,6 +205,9 @@ async function revokeTokenFamily( }); // Revoke all sessions in family + console.log( + `[Token Family] Revoking entire family ${tokenFamily} (reason: ${reason}). Sessions affected: ${sessions.rows.length}` + ); await conn.execute({ sql: "UPDATE Session SET revoked = 1 WHERE token_family = ?", args: [tokenFamily] @@ -255,14 +261,14 @@ async function detectTokenReuse(sessionId: string): Promise { // 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` + `[Token Reuse] Within grace period (${timeSinceRotation}ms < ${AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS}ms), allowing for session ${sessionId}` ); return false; } // Reuse detected outside grace period - this is a breach! console.error( - `Token reuse detected! Session ${sessionId} rotated ${timeSinceRotation}ms ago` + `[Token Reuse] BREACH DETECTED! Session ${sessionId} rotated ${timeSinceRotation}ms ago (grace period: ${AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS}ms). Child session: ${childSession.id}` ); // Get token family and revoke entire family @@ -316,28 +322,49 @@ async function rotateRefreshToken( refreshToken: string; sessionId: string; } | null> { + console.log(`[Token Rotation] Starting rotation for session ${oldSessionId}`); + // Step 1: Validate old refresh token const oldSession = await validateRefreshToken(oldRefreshToken, oldSessionId); if (!oldSession) { - console.warn("Invalid refresh token during rotation"); + console.warn( + `[Token Rotation] Invalid refresh token during rotation for session ${oldSessionId}` + ); return null; } + console.log( + `[Token Rotation] Refresh token validated for session ${oldSessionId}` + ); + // Step 2: Detect token reuse (breach detection) const reuseDetected = await detectTokenReuse(oldSessionId); if (reuseDetected) { + console.error( + `[Token Rotation] Token reuse detected for session ${oldSessionId}` + ); // Token family already revoked by detectTokenReuse return null; } + console.log( + `[Token Rotation] No token reuse detected for session ${oldSessionId}` + ); + // Step 3: Check rotation limit if (oldSession.rotation_count >= AUTH_CONFIG.MAX_ROTATION_COUNT) { - console.warn(`Max rotation count reached for session ${oldSessionId}`); + console.warn( + `[Token Rotation] Max rotation count reached for session ${oldSessionId}` + ); await invalidateSession(oldSessionId); return null; } + console.log( + `[Token Rotation] Rotation count OK (${oldSession.rotation_count}/${AUTH_CONFIG.MAX_ROTATION_COUNT})` + ); + // Step 4: Generate new tokens const newRefreshToken = generateRefreshToken(); const refreshExpiry = rememberMe @@ -549,28 +576,42 @@ function setAuthCookies( rememberMe: boolean = false ) { // Access token cookie (short-lived, always same duration) - const accessMaxAge = getAccessCookieMaxAge(); - - setCookie(event, ACCESS_TOKEN_COOKIE_NAME, accessToken, { - maxAge: accessMaxAge, + // Session cookies (no maxAge) vs persistent cookies (with maxAge) + const accessCookieOptions: any = { path: "/", httpOnly: true, secure: env.NODE_ENV === "production", sameSite: "strict" - }); + }; - // 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; + if (rememberMe) { + // Persistent cookie - survives browser restart + accessCookieOptions.maxAge = getAccessCookieMaxAge(); + } + // else: session cookie - expires when browser closes (no maxAge) - setCookie(event, REFRESH_TOKEN_COOKIE_NAME, refreshToken, { - maxAge: refreshMaxAge, + setCookie(event, ACCESS_TOKEN_COOKIE_NAME, accessToken, accessCookieOptions); + + // Refresh token cookie (varies based on rememberMe) + const refreshCookieOptions: any = { path: "/", httpOnly: true, secure: env.NODE_ENV === "production", sameSite: "strict" - }); + }; + + if (rememberMe) { + // Persistent cookie - long-lived (90 days) + refreshCookieOptions.maxAge = getRefreshCookieMaxAge(true); + } + // else: session cookie - expires when browser closes (no maxAge) + + setCookie( + event, + REFRESH_TOKEN_COOKIE_NAME, + refreshToken, + refreshCookieOptions + ); // CSRF token for authenticated session setCSRFToken(event); @@ -613,6 +654,119 @@ async function sendEmail(to: string, subject: string, htmlContent: string) { ); } +/** + * Attempt server-side token refresh for SSR + * Called from getUserState() when access token is expired but refresh token exists + * @param event - H3Event from SSR + * @param refreshToken - Refresh token from httpOnly cookie + * @returns true if refresh succeeded, false otherwise + */ +export async function attemptTokenRefresh( + event: H3Event, + refreshToken: string +): Promise { + try { + // Step 1: Find session by refresh token hash + // (Access token may not exist if user closed browser and returned later) + const conn = ConnectionFactory(); + const tokenHash = hashRefreshToken(refreshToken); + + const sessionResult = await conn.execute({ + sql: `SELECT id, user_id, expires_at, revoked + FROM Session + WHERE refresh_token_hash = ? + AND revoked = 0`, + args: [tokenHash] + }); + + if (sessionResult.rows.length === 0) { + console.warn( + "[Token Refresh SSR] No valid session found for refresh token" + ); + return false; + } + + const session = sessionResult.rows[0]; + const sessionId = session.id as string; + + // Check if session is expired + const expiresAt = new Date(session.expires_at as string); + if (expiresAt < new Date()) { + console.warn("[Token Refresh SSR] Session expired"); + return false; + } + + // Step 2: Determine rememberMe from existing session + const now = new Date(); + const daysUntilExpiry = + (expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24); + // If expires in > 30 days, assume rememberMe was true + const rememberMe = daysUntilExpiry > 30; + + // Step 3: Get client info + const clientIP = getClientIP(event); + const userAgent = getUserAgent(event); + + // Step 4: Rotate tokens + console.log(`[Token Refresh SSR] Rotating tokens for session ${sessionId}`); + const rotated = await rotateRefreshToken( + refreshToken, + sessionId, + rememberMe, + clientIP, + userAgent + ); + + if (!rotated) { + console.warn("[Token Refresh SSR] Token rotation failed"); + return false; + } + + // Step 5: Set new cookies + const accessCookieOptions: any = { + path: "/", + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict" + }; + + if (rememberMe) { + accessCookieOptions.maxAge = getAccessCookieMaxAge(); + } + + setCookie( + event, + ACCESS_TOKEN_COOKIE_NAME, + rotated.accessToken, + accessCookieOptions + ); + + const refreshCookieOptions: any = { + path: "/", + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict" + }; + + if (rememberMe) { + refreshCookieOptions.maxAge = getRefreshCookieMaxAge(true); + } + + setCookie( + event, + REFRESH_TOKEN_COOKIE_NAME, + rotated.refreshToken, + refreshCookieOptions + ); + + console.log("[Token Refresh SSR] Token refresh successful"); + return true; + } catch (error) { + console.error("[Token Refresh SSR] Error:", error); + return false; + } +} + export const authRouter = createTRPCRouter({ githubCallback: publicProcedure .input(z.object({ code: z.string() })) @@ -1405,6 +1559,9 @@ export const authRouter = createTRPCRouter({ // Reset failed attempts on successful login await resetFailedAttempts(user.id); + // Reset rate limits on successful login + await resetLoginRateLimits(email, clientIP); + // Determine token expiry based on rememberMe const accessExpiry = getAccessTokenExpiry(); // Always 15m const refreshExpiry = rememberMe @@ -2098,37 +2255,46 @@ export const authRouter = createTRPCRouter({ } // Step 6: Set new access token cookie - const accessCookieMaxAge = getAccessCookieMaxAge(); + // Session cookies (no maxAge) vs persistent cookies (with maxAge) + const accessCookieOptions: any = { + path: "/", + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict" + }; + + if (rememberMe) { + // Persistent cookie - survives browser restart + accessCookieOptions.maxAge = getAccessCookieMaxAge(); + } + // else: session cookie - expires when browser closes (no maxAge) setCookie( getH3Event(ctx), ACCESS_TOKEN_COOKIE_NAME, rotated.accessToken, - { - maxAge: accessCookieMaxAge, - path: "/", - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "strict" - } + accessCookieOptions ); // Step 7: Set new refresh token cookie - const refreshCookieMaxAge = rememberMe - ? AUTH_CONFIG.REFRESH_COOKIE_MAX_AGE_LONG - : AUTH_CONFIG.REFRESH_COOKIE_MAX_AGE_SHORT; + const refreshCookieOptions: any = { + path: "/", + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict" + }; + + if (rememberMe) { + // Persistent cookie - long-lived (90 days) + refreshCookieOptions.maxAge = getRefreshCookieMaxAge(true); + } + // else: session cookie - expires when browser closes (no maxAge) setCookie( getH3Event(ctx), REFRESH_TOKEN_COOKIE_NAME, rotated.refreshToken, - { - maxAge: refreshCookieMaxAge, - path: "/", - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "strict" - } + refreshCookieOptions ); // Step 8: Refresh CSRF token @@ -2245,6 +2411,10 @@ export const authRouter = createTRPCRouter({ maxAge: 0, path: "/" }); + setCookie(getH3Event(ctx), "csrf-token", "", { + maxAge: 0, + path: "/" + }); // Step 4: Log signout event if (userId) { diff --git a/src/server/api/routers/database.ts b/src/server/api/routers/database.ts index d015e39..54bb1a3 100644 --- a/src/server/api/routers/database.ts +++ b/src/server/api/routers/database.ts @@ -413,7 +413,7 @@ export const databaseRouter = createTRPCRouter({ await conn.execute(tagQuery); } - cache.deleteByPrefix("blog-"); + await cache.deleteByPrefix("blog-"); return { data: results.lastInsertRowid }; } catch (error) { @@ -529,7 +529,7 @@ export const databaseRouter = createTRPCRouter({ await conn.execute(tagQuery); } - cache.deleteByPrefix("blog-"); + await cache.deleteByPrefix("blog-"); return { data: results.lastInsertRowid }; } catch (error) { @@ -565,7 +565,7 @@ export const databaseRouter = createTRPCRouter({ args: [input.id] }); - cache.deleteByPrefix("blog-"); + await cache.deleteByPrefix("blog-"); return { success: true }; } catch (error) { diff --git a/src/server/cache.ts b/src/server/cache.ts index 32e1351..103ceb7 100644 --- a/src/server/cache.ts +++ b/src/server/cache.ts @@ -1,77 +1,166 @@ -import { CACHE_CONFIG } from "~/config"; +/** + * Redis-backed Cache for Serverless + * + * Uses Redis for persistent caching across serverless invocations. + * Redis provides: + * - Fast in-memory storage + * - Built-in TTL expiration (automatic cleanup) + * - Persistence across function invocations + * - Native support in Vercel and other platforms + */ -interface CacheEntry { - data: T; - timestamp: number; +import { createClient } from "redis"; +import { env } from "~/env/server"; + +let redisClient: ReturnType | null = null; +let isConnecting = false; +let connectionError: Error | null = null; + +/** + * Get or create Redis client (singleton pattern) + */ +async function getRedisClient() { + if (redisClient && redisClient.isOpen) { + return redisClient; + } + + if (isConnecting) { + // Wait for existing connection attempt + await new Promise((resolve) => setTimeout(resolve, 100)); + return getRedisClient(); + } + + if (connectionError) { + throw connectionError; + } + + try { + isConnecting = true; + redisClient = createClient({ url: env.REDIS_URL }); + + redisClient.on("error", (err) => { + console.error("Redis Client Error:", err); + connectionError = err; + }); + + await redisClient.connect(); + isConnecting = false; + connectionError = null; + return redisClient; + } catch (error) { + isConnecting = false; + connectionError = error as Error; + console.error("Failed to connect to Redis:", error); + throw error; + } } -class SimpleCache { - private cache: Map> = new Map(); +/** + * Redis-backed cache interface + */ +export const cache = { + async get(key: string): Promise { + try { + const client = await getRedisClient(); + const value = await client.get(key); - get(key: string, ttlMs: number): T | null { - const entry = this.cache.get(key); - if (!entry) return null; + if (!value) { + return null; + } - const now = Date.now(); - if (now - entry.timestamp > ttlMs) { - this.cache.delete(key); + return JSON.parse(value) as T; + } catch (error) { + console.error(`Cache get error for key "${key}":`, error); return null; } + }, - return entry.data as T; - } + async set(key: string, data: T, ttlMs: number): Promise { + try { + const client = await getRedisClient(); + const value = JSON.stringify(data); - getStale(key: string): T | null { - const entry = this.cache.get(key); - return entry ? (entry.data as T) : null; - } + // Redis SET with EX (expiry in seconds) + await client.set(key, value, { + EX: Math.ceil(ttlMs / 1000) + }); + } catch (error) { + console.error(`Cache set error for key "${key}":`, error); + } + }, - has(key: string): boolean { - return this.cache.has(key); - } + async delete(key: string): Promise { + try { + const client = await getRedisClient(); + await client.del(key); + } catch (error) { + console.error(`Cache delete error for key "${key}":`, error); + } + }, - set(key: string, data: T): void { - this.cache.set(key, { - data, - timestamp: Date.now() - }); - } + async deleteByPrefix(prefix: string): Promise { + try { + const client = await getRedisClient(); + const keys = await client.keys(`${prefix}*`); - clear(): void { - this.cache.clear(); - } - - delete(key: string): void { - this.cache.delete(key); - } - - deleteByPrefix(prefix: string): void { - for (const key of this.cache.keys()) { - if (key.startsWith(prefix)) { - this.cache.delete(key); + if (keys.length > 0) { + await client.del(keys); } + } catch (error) { + console.error( + `Cache deleteByPrefix error for prefix "${prefix}":`, + error + ); + } + }, + + async clear(): Promise { + try { + const client = await getRedisClient(); + await client.flushDb(); + } catch (error) { + console.error("Cache clear error:", error); + } + }, + + async has(key: string): Promise { + try { + const client = await getRedisClient(); + const exists = await client.exists(key); + return exists === 1; + } catch (error) { + console.error(`Cache has error for key "${key}":`, error); + return false; } } -} +}; -export const cache = new SimpleCache(); +/** + * Execute function with Redis caching + */ export async function withCache( key: string, ttlMs: number, fn: () => Promise ): Promise { - const cached = cache.get(key, ttlMs); + const cached = await cache.get(key); if (cached !== null) { return cached; } const result = await fn(); - cache.set(key, result); + await cache.set(key, result, ttlMs); return result; } /** - * Returns stale data if fetch fails, with optional stale time limit + * Execute function with Redis caching and stale data fallback + * + * Strategy: + * 1. Try to get fresh cached data (within TTL) + * 2. If not found, execute function + * 3. If function fails, try to get stale data (ignore TTL) + * 4. Store result with TTL for future requests */ export async function withCacheAndStale( key: string, @@ -82,36 +171,36 @@ export async function withCacheAndStale( logErrors?: boolean; } = {} ): Promise { - const { maxStaleMs = CACHE_CONFIG.MAX_STALE_DATA_MS, logErrors = true } = - options; + const { maxStaleMs = 7 * 24 * 60 * 60 * 1000, logErrors = true } = options; - const cached = cache.get(key, ttlMs); + // Try fresh cache + const cached = await cache.get(key); if (cached !== null) { return cached; } try { + // Execute function const result = await fn(); - cache.set(key, result); + await cache.set(key, result, ttlMs); + // Also store with longer TTL for stale fallback + const staleKey = `${key}:stale`; + await cache.set(staleKey, result, maxStaleMs); return result; } catch (error) { if (logErrors) { console.error(`Error fetching data for cache key "${key}":`, error); } - const stale = cache.getStale(key); - if (stale !== null) { - const entry = (cache as any).cache.get(key); - const age = Date.now() - entry.timestamp; + // Try stale cache with longer TTL key + const staleKey = `${key}:stale`; + const staleData = await cache.get(staleKey); - if (age <= maxStaleMs) { - if (logErrors) { - console.log( - `Serving stale data for cache key "${key}" (age: ${Math.round(age / 1000 / 60)}m)` - ); - } - return stale; + if (staleData !== null) { + if (logErrors) { + console.log(`Serving stale data for cache key "${key}"`); } + return staleData; } throw error; diff --git a/src/server/security.ts b/src/server/security.ts index a85689d..45adb66 100644 --- a/src/server/security.ts +++ b/src/server/security.ts @@ -200,9 +200,11 @@ export async function clearRateLimitStore(): Promise { } /** - * Cleanup expired rate limit entries every 5 minutes + * Opportunistic cleanup of expired rate limit entries + * Called probabilistically during rate limit checks (serverless-friendly) + * Note: setInterval is not reliable in serverless environments */ -setInterval(async () => { +async function cleanupExpiredRateLimits(): Promise { try { const { ConnectionFactory } = await import("./database"); const conn = ConnectionFactory(); @@ -212,9 +214,10 @@ setInterval(async () => { args: [now] }); } catch (error) { + // Silent fail - cleanup is opportunistic console.error("Failed to cleanup expired rate limits:", error); } -}, RATE_LIMIT_CLEANUP_INTERVAL_MS); +} /** * Get client IP address from request headers @@ -274,6 +277,11 @@ export async function checkRateLimit( const now = Date.now(); const resetAt = new Date(now + windowMs); + // Opportunistic cleanup (10% chance) - serverless-friendly + if (Math.random() < 0.1) { + cleanupExpiredRateLimits().catch(() => {}); // Fire and forget + } + const result = await conn.execute({ sql: "SELECT id, count, reset_at FROM RateLimit WHERE identifier = ?", args: [identifier] @@ -506,6 +514,22 @@ export async function resetFailedAttempts(userId: string): Promise { }); } +/** + * Reset login rate limits on successful login + */ +export async function resetLoginRateLimits( + email: string, + clientIP: string +): Promise { + const { ConnectionFactory } = await import("./database"); + const conn = ConnectionFactory(); + + await conn.execute({ + sql: "DELETE FROM RateLimit WHERE identifier IN (?, ?)", + args: [`login:ip:${clientIP}`, `login:email:${email}`] + }); +} + export const PASSWORD_RESET_CONFIG = CONFIG_PASSWORD_RESET; /**