This commit is contained in:
Michael Freno
2026-01-07 14:37:40 -05:00
parent 5d34da2647
commit 041b2f8dc2
13 changed files with 674 additions and 157 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -55,6 +55,7 @@
"jose": "^6.1.3", "jose": "^6.1.3",
"mermaid": "^11.12.2", "mermaid": "^11.12.2",
"motion": "^12.23.26", "motion": "^12.23.26",
"redis": "^5.10.0",
"solid-js": "^1.9.5", "solid-js": "^1.9.5",
"solid-tiptap": "^0.8.0", "solid-tiptap": "^0.8.0",
"uuid": "^13.0.0", "uuid": "^13.0.0",

View File

@@ -192,16 +192,6 @@ 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

View File

@@ -1,8 +1,3 @@
/**
* Application Configuration
* Central location for all configurable values including timeouts, limits, durations, etc.
*/
// ============================================================ // ============================================================
// AUTHENTICATION & SESSION // AUTHENTICATION & SESSION
// ============================================================ // ============================================================
@@ -16,31 +11,26 @@
* - Token rotation: Each refresh invalidates old token and issues new pair * - Token rotation: Each refresh invalidates old token and issues new pair
* - Breach detection: Reusing invalidated token revokes entire token family * - Breach detection: Reusing invalidated token revokes entire token family
* *
* Cookie Behavior:
* - rememberMe = false: Session cookies (no maxAge) - expire when browser closes
* - rememberMe = true: Persistent cookies (with maxAge) - survive browser restart
*
* Timing Decisions: * Timing Decisions:
* - 15m access: Balance between security (short exposure) and UX (not too frequent refreshes) * - 15m access: Balance between security (short exposure) and UX (not too frequent refreshes)
* - 7d refresh: Conservative default, users re-auth weekly * - 1d session: DB cleanup for session-only logins (cookie expires on browser close anyway)
* - 90d remember: Extended convenience for trusted devices * - 90d remember: Extended convenience for trusted devices (both DB and cookie persist)
* - 5s reuse window: Handles race conditions in distributed systems * - 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 = {
// Access Token (JWT in cookie) // Access Token (JWT in cookie)
ACCESS_TOKEN_EXPIRY: "15m" as const, // 15 minutes (short-lived) ACCESS_TOKEN_EXPIRY: "15m" as const, // 15 minutes (short-lived)
ACCESS_TOKEN_EXPIRY_DEV: "3m" as const, // 3 minutes for testing ACCESS_TOKEN_EXPIRY_DEV: "2m" as const, // 2 minutes for faster testing
// Refresh Token (opaque token in separate cookie) // Refresh Token (opaque token in separate cookie)
REFRESH_TOKEN_EXPIRY_SHORT: "7d" as const, // 7 days (no remember me) REFRESH_TOKEN_EXPIRY_SHORT: "1d" as const, // 1 day (DB expiry, cookie is session-only - non-remember me)
REFRESH_TOKEN_EXPIRY_LONG: "90d" as const, // 90 days (remember me) REFRESH_TOKEN_EXPIRY_LONG: "90d" as const, // 90 days (remember me - both DB and cookie persist)
// 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
// Security Settings
REFRESH_TOKEN_ROTATION_ENABLED: true, // Enable token rotation REFRESH_TOKEN_ROTATION_ENABLED: true, // Enable token rotation
MAX_ROTATION_COUNT: 100, // Max rotations before forcing re-login MAX_ROTATION_COUNT: 100, // Max rotations before forcing re-login
REFRESH_TOKEN_REUSE_WINDOW_MS: 5000, // 5s grace period for race conditions REFRESH_TOKEN_REUSE_WINDOW_MS: 5000, // 5s grace period for race conditions
@@ -65,13 +55,38 @@ export function getAccessTokenExpiry(): string {
: AUTH_CONFIG.ACCESS_TOKEN_EXPIRY_DEV; : AUTH_CONFIG.ACCESS_TOKEN_EXPIRY_DEV;
} }
/**
* Convert expiry string to seconds for cookie Max-Age
* @param expiry - Expiry string like "15m", "7d", "90d"
* @returns Seconds as number
*/
export function expiryToSeconds(expiry: string): number {
if (expiry.endsWith("m")) {
return parseInt(expiry) * 60;
} else if (expiry.endsWith("h")) {
return parseInt(expiry) * 60 * 60;
} else if (expiry.endsWith("d")) {
return parseInt(expiry) * 60 * 60 * 24;
}
throw new Error(`Invalid expiry format: ${expiry}`);
}
/** /**
* Get access cookie maxAge based on environment (in seconds) * Get access cookie maxAge based on environment (in seconds)
*/ */
export function getAccessCookieMaxAge(): number { export function getAccessCookieMaxAge(): number {
return process.env.NODE_ENV === "production" return expiryToSeconds(getAccessTokenExpiry());
? AUTH_CONFIG.ACCESS_COOKIE_MAX_AGE }
: AUTH_CONFIG.ACCESS_COOKIE_MAX_AGE_DEV;
/**
* Get refresh cookie maxAge based on rememberMe preference (in seconds)
*/
export function getRefreshCookieMaxAge(rememberMe: boolean): number {
return expiryToSeconds(
rememberMe
? AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG
: AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_SHORT
);
} }
/** /**

View File

@@ -14,11 +14,13 @@ import {
createSignal, createSignal,
onMount, onMount,
onCleanup, onCleanup,
createEffect,
Accessor, Accessor,
ParentComponent ParentComponent
} from "solid-js"; } from "solid-js";
import { createAsync, revalidate } from "@solidjs/router"; import { createAsync, revalidate } from "@solidjs/router";
import { getUserState, type UserState } from "~/lib/auth-query"; import { getUserState, type UserState } from "~/lib/auth-query";
import { tokenRefreshManager } from "~/lib/token-refresh";
interface AuthContextType { interface AuthContextType {
/** Current user state (for UI display) */ /** Current user state (for UI display) */
@@ -67,6 +69,9 @@ export const AuthProvider: ParentComponent = (props) => {
setRefreshTrigger((prev) => prev + 1); // Trigger re-fetch 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.) // Listen for auth refresh events from external sources (token refresh, etc.)
onMount(() => { onMount(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@@ -91,6 +96,41 @@ export const AuthProvider: ParentComponent = (props) => {
const isAdmin = () => serverAuth()?.privilegeLevel === "admin"; const isAdmin = () => serverAuth()?.privilegeLevel === "admin";
const isEmailVerified = () => serverAuth()?.emailVerified ?? false; 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 = { const value: AuthContextType = {
userState: serverAuth, userState: serverAuth,
isAuthenticated, isAuthenticated,

6
src/env/server.ts vendored
View File

@@ -31,7 +31,8 @@ const serverEnvSchema = z.object({
VITE_GITHUB_CLIENT_ID: z.string().min(1), VITE_GITHUB_CLIENT_ID: z.string().min(1),
VITE_WEBSOCKET: z.string().min(1), VITE_WEBSOCKET: z.string().min(1),
VITE_INFILL_ENDPOINT: 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<typeof serverEnvSchema>; export type ServerEnv = z.infer<typeof serverEnvSchema>;
@@ -135,7 +136,8 @@ export const getMissingEnvVars = (): string[] => {
"VITE_GOOGLE_CLIENT_ID", "VITE_GOOGLE_CLIENT_ID",
"VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE", "VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE",
"VITE_GITHUB_CLIENT_ID", "VITE_GITHUB_CLIENT_ID",
"VITE_WEBSOCKET" "VITE_WEBSOCKET",
"REDIS_URL"
]; ];
return requiredServerVars.filter((varName) => isMissingEnvVar(varName)); return requiredServerVars.filter((varName) => isMissingEnvVar(varName));

View File

@@ -28,9 +28,61 @@ export const getUserState = query(async (): Promise<UserState> => {
"use server"; "use server";
const { getPrivilegeLevel, getUserID } = await import("~/server/auth"); const { getPrivilegeLevel, getUserID } = await import("~/server/auth");
const { ConnectionFactory } = await import("~/server/utils"); const { ConnectionFactory } = await import("~/server/utils");
const { getCookie, setCookie } = await import("vinxi/http");
const event = getRequestEvent()!; 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) { if (!userId) {
return { return {
@@ -82,5 +134,11 @@ export function revalidateAuth() {
// Dispatch browser event to trigger UI updates (client-side only) // Dispatch browser event to trigger UI updates (client-side only)
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth-state-changed")); 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();
});
} }
} }

View File

@@ -1,34 +1,64 @@
/** /**
* Token Refresh Manager * Token Refresh Manager
* Handles automatic token refresh before expiry * 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 { api } from "~/lib/api";
import { getClientCookie } from "~/lib/cookies.client";
import { getTimeUntilExpiry } from "~/lib/client-utils";
import { revalidateAuth } from "~/lib/auth-query"; 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 { class TokenRefreshManager {
private refreshTimer: ReturnType<typeof setTimeout> | null = null; private refreshTimer: ReturnType<typeof setTimeout> | null = null;
private isRefreshing = false; private isRefreshing = false;
private refreshThresholdMs = 2 * 60 * 1000; // Refresh 2 minutes before expiry
private isStarted = false; private isStarted = false;
private visibilityChangeHandler: (() => void) | null = null; 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 (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.isStarted = true;
this.lastRefreshTime = Date.now(); // Assume token was just issued
console.log(
`[Token Refresh] Manager started, lastRefreshTime set to ${this.lastRefreshTime}`
);
this.scheduleNextRefresh(); this.scheduleNextRefresh();
// Re-check on visibility change (user returns to tab) // Re-check on visibility change (user returns to tab)
this.visibilityChangeHandler = () => { this.visibilityChangeHandler = () => {
if (document.visibilityState === "visible") { if (document.visibilityState === "visible") {
this.scheduleNextRefresh(); console.log(
"[Token Refresh] Tab became visible, checking token status"
);
this.checkAndRefreshIfNeeded();
} }
}; };
document.addEventListener("visibilitychange", this.visibilityChangeHandler); document.addEventListener("visibilitychange", this.visibilityChangeHandler);
@@ -52,23 +82,85 @@ class TokenRefreshManager {
} }
this.isStarted = false; 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 * Schedule next refresh based on token expiry
*/ */
private scheduleNextRefresh(): void { 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 (!this.lastRefreshTime) {
if (!token) { console.log("[Token Refresh] No refresh history, cannot schedule");
// No token found - user not logged in, nothing to refresh
return; return;
} }
const timeUntilExpiry = getTimeUntilExpiry(token); const timeSinceRefresh = Date.now() - this.lastRefreshTime;
if (!timeUntilExpiry) { const timeUntilExpiry = ACCESS_TOKEN_EXPIRY_MS - timeSinceRefresh;
console.warn("Token expired or invalid, attempting refresh now");
if (timeUntilExpiry <= REFRESH_THRESHOLD_MS) {
console.warn(
"[Token Refresh] Token likely expired, attempting refresh now"
);
this.refreshNow(); this.refreshNow();
return; return;
} }
@@ -76,12 +168,12 @@ class TokenRefreshManager {
// Schedule refresh before expiry // Schedule refresh before expiry
const timeUntilRefresh = Math.max( const timeUntilRefresh = Math.max(
0, 0,
timeUntilExpiry - this.refreshThresholdMs timeUntilExpiry - REFRESH_THRESHOLD_MS
); );
console.log( console.log(
`[Token Refresh] Token expires in ${Math.round(timeUntilExpiry / 1000)}s, ` + `[Token Refresh] Scheduling refresh in ${Math.round(timeUntilRefresh / 1000)}s ` +
`scheduling refresh in ${Math.round(timeUntilRefresh / 1000)}s` `(~${Math.round(timeUntilExpiry / 1000)}s until expiry)`
); );
this.refreshTimer = setTimeout(() => { this.refreshTimer = setTimeout(() => {
@@ -89,6 +181,16 @@ class TokenRefreshManager {
}, timeUntilRefresh); }, 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 * Perform token refresh immediately
*/ */
@@ -103,14 +205,23 @@ class TokenRefreshManager {
try { try {
console.log("[Token Refresh] Refreshing access token..."); 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({ const result = await api.auth.refreshToken.mutate({
rememberMe: false // Maintain existing rememberMe state rememberMe
}); });
if (result.success) { if (result.success) {
console.log("[Token Refresh] Token refreshed successfully"); 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 this.scheduleNextRefresh(); // Schedule next refresh
// Revalidate auth AFTER scheduling to avoid race condition
revalidateAuth(); // Refresh auth state after token refresh
return true; return true;
} else { } else {
console.error("[Token Refresh] Token refresh failed:", result); console.error("[Token Refresh] Token refresh failed:", result);
@@ -141,6 +252,23 @@ class TokenRefreshManager {
// Redirect to login // Redirect to login
window.location.href = "/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<boolean> {
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 // Singleton instance

View File

@@ -164,7 +164,7 @@ export default function Home() {
</div> </div>
</div> </div>
</div> </div>
<div class="flex justify-between text-center"> <div class="flex justify-between pb-8 text-center">
<Typewriter <Typewriter
speed={120} speed={120}
class="mx-auto max-w-3/4 pt-8 md:max-w-1/2" class="mx-auto max-w-3/4 pt-8 md:max-w-1/2"

View File

@@ -39,6 +39,7 @@ import {
checkAccountLockout, checkAccountLockout,
recordFailedLogin, recordFailedLogin,
resetFailedAttempts, resetFailedAttempts,
resetLoginRateLimits,
createPasswordResetToken, createPasswordResetToken,
validatePasswordResetToken, validatePasswordResetToken,
markPasswordResetTokenUsed markPasswordResetTokenUsed
@@ -51,7 +52,8 @@ import {
NETWORK_CONFIG, NETWORK_CONFIG,
COOLDOWN_TIMERS, COOLDOWN_TIMERS,
getAccessTokenExpiry, getAccessTokenExpiry,
getAccessCookieMaxAge getAccessCookieMaxAge,
getRefreshCookieMaxAge
} from "~/config"; } from "~/config";
import { randomBytes, createHash, timingSafeEqual } from "crypto"; import { randomBytes, createHash, timingSafeEqual } from "crypto";
@@ -177,6 +179,7 @@ async function validateRefreshToken(
*/ */
async function invalidateSession(sessionId: string): Promise<void> { async function invalidateSession(sessionId: string): Promise<void> {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
console.log(`[Session] Invalidating session ${sessionId}`);
await conn.execute({ await conn.execute({
sql: "UPDATE Session SET revoked = 1 WHERE id = ?", sql: "UPDATE Session SET revoked = 1 WHERE id = ?",
args: [sessionId] args: [sessionId]
@@ -202,6 +205,9 @@ async function revokeTokenFamily(
}); });
// Revoke all sessions in family // Revoke all sessions in family
console.log(
`[Token Family] Revoking entire family ${tokenFamily} (reason: ${reason}). Sessions affected: ${sessions.rows.length}`
);
await conn.execute({ await conn.execute({
sql: "UPDATE Session SET revoked = 1 WHERE token_family = ?", sql: "UPDATE Session SET revoked = 1 WHERE token_family = ?",
args: [tokenFamily] args: [tokenFamily]
@@ -255,14 +261,14 @@ async function detectTokenReuse(sessionId: string): Promise<boolean> {
// Grace period for race conditions (e.g., slow network, retries) // Grace period for race conditions (e.g., slow network, retries)
if (timeSinceRotation < AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS) { if (timeSinceRotation < AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS) {
console.warn( 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; return false;
} }
// Reuse detected outside grace period - this is a breach! // Reuse detected outside grace period - this is a breach!
console.error( 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 // Get token family and revoke entire family
@@ -316,28 +322,49 @@ async function rotateRefreshToken(
refreshToken: string; refreshToken: string;
sessionId: string; sessionId: string;
} | null> { } | null> {
console.log(`[Token Rotation] Starting rotation for session ${oldSessionId}`);
// Step 1: Validate old refresh token // Step 1: Validate old refresh token
const oldSession = await validateRefreshToken(oldRefreshToken, oldSessionId); const oldSession = await validateRefreshToken(oldRefreshToken, oldSessionId);
if (!oldSession) { if (!oldSession) {
console.warn("Invalid refresh token during rotation"); console.warn(
`[Token Rotation] Invalid refresh token during rotation for session ${oldSessionId}`
);
return null; return null;
} }
console.log(
`[Token Rotation] Refresh token validated for session ${oldSessionId}`
);
// Step 2: Detect token reuse (breach detection) // Step 2: Detect token reuse (breach detection)
const reuseDetected = await detectTokenReuse(oldSessionId); const reuseDetected = await detectTokenReuse(oldSessionId);
if (reuseDetected) { if (reuseDetected) {
console.error(
`[Token Rotation] Token reuse detected for session ${oldSessionId}`
);
// Token family already revoked by detectTokenReuse // Token family already revoked by detectTokenReuse
return null; return null;
} }
console.log(
`[Token Rotation] No token reuse detected for session ${oldSessionId}`
);
// Step 3: Check rotation limit // Step 3: Check rotation limit
if (oldSession.rotation_count >= AUTH_CONFIG.MAX_ROTATION_COUNT) { 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); await invalidateSession(oldSessionId);
return null; return null;
} }
console.log(
`[Token Rotation] Rotation count OK (${oldSession.rotation_count}/${AUTH_CONFIG.MAX_ROTATION_COUNT})`
);
// Step 4: Generate new tokens // Step 4: Generate new tokens
const newRefreshToken = generateRefreshToken(); const newRefreshToken = generateRefreshToken();
const refreshExpiry = rememberMe const refreshExpiry = rememberMe
@@ -549,28 +576,42 @@ function setAuthCookies(
rememberMe: boolean = false rememberMe: boolean = false
) { ) {
// Access token cookie (short-lived, always same duration) // Access token cookie (short-lived, always same duration)
const accessMaxAge = getAccessCookieMaxAge(); // Session cookies (no maxAge) vs persistent cookies (with maxAge)
const accessCookieOptions: any = {
setCookie(event, ACCESS_TOKEN_COOKIE_NAME, accessToken, {
maxAge: accessMaxAge,
path: "/", path: "/",
httpOnly: true, httpOnly: true,
secure: env.NODE_ENV === "production", secure: env.NODE_ENV === "production",
sameSite: "strict" sameSite: "strict"
}); };
// Refresh token cookie (long-lived, varies based on rememberMe) if (rememberMe) {
const refreshMaxAge = rememberMe // Persistent cookie - survives browser restart
? AUTH_CONFIG.REFRESH_COOKIE_MAX_AGE_LONG accessCookieOptions.maxAge = getAccessCookieMaxAge();
: AUTH_CONFIG.REFRESH_COOKIE_MAX_AGE_SHORT; }
// else: session cookie - expires when browser closes (no maxAge)
setCookie(event, REFRESH_TOKEN_COOKIE_NAME, refreshToken, { setCookie(event, ACCESS_TOKEN_COOKIE_NAME, accessToken, accessCookieOptions);
maxAge: refreshMaxAge,
// Refresh token cookie (varies based on rememberMe)
const refreshCookieOptions: any = {
path: "/", path: "/",
httpOnly: true, httpOnly: true,
secure: env.NODE_ENV === "production", secure: env.NODE_ENV === "production",
sameSite: "strict" 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 // CSRF token for authenticated session
setCSRFToken(event); 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<boolean> {
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({ export const authRouter = createTRPCRouter({
githubCallback: publicProcedure githubCallback: publicProcedure
.input(z.object({ code: z.string() })) .input(z.object({ code: z.string() }))
@@ -1405,6 +1559,9 @@ export const authRouter = createTRPCRouter({
// Reset failed attempts on successful login // Reset failed attempts on successful login
await resetFailedAttempts(user.id); await resetFailedAttempts(user.id);
// Reset rate limits on successful login
await resetLoginRateLimits(email, clientIP);
// Determine token expiry based on rememberMe // Determine token expiry based on rememberMe
const accessExpiry = getAccessTokenExpiry(); // Always 15m const accessExpiry = getAccessTokenExpiry(); // Always 15m
const refreshExpiry = rememberMe const refreshExpiry = rememberMe
@@ -2098,37 +2255,46 @@ export const authRouter = createTRPCRouter({
} }
// Step 6: Set new access token cookie // 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( setCookie(
getH3Event(ctx), getH3Event(ctx),
ACCESS_TOKEN_COOKIE_NAME, ACCESS_TOKEN_COOKIE_NAME,
rotated.accessToken, rotated.accessToken,
{ accessCookieOptions
maxAge: accessCookieMaxAge, );
// Step 7: Set new refresh token cookie
const refreshCookieOptions: any = {
path: "/", path: "/",
httpOnly: true, httpOnly: true,
secure: env.NODE_ENV === "production", secure: env.NODE_ENV === "production",
sameSite: "strict" sameSite: "strict"
} };
);
// Step 7: Set new refresh token cookie if (rememberMe) {
const refreshCookieMaxAge = rememberMe // Persistent cookie - long-lived (90 days)
? AUTH_CONFIG.REFRESH_COOKIE_MAX_AGE_LONG refreshCookieOptions.maxAge = getRefreshCookieMaxAge(true);
: AUTH_CONFIG.REFRESH_COOKIE_MAX_AGE_SHORT; }
// else: session cookie - expires when browser closes (no maxAge)
setCookie( setCookie(
getH3Event(ctx), getH3Event(ctx),
REFRESH_TOKEN_COOKIE_NAME, REFRESH_TOKEN_COOKIE_NAME,
rotated.refreshToken, rotated.refreshToken,
{ refreshCookieOptions
maxAge: refreshCookieMaxAge,
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "strict"
}
); );
// Step 8: Refresh CSRF token // Step 8: Refresh CSRF token
@@ -2245,6 +2411,10 @@ export const authRouter = createTRPCRouter({
maxAge: 0, maxAge: 0,
path: "/" path: "/"
}); });
setCookie(getH3Event(ctx), "csrf-token", "", {
maxAge: 0,
path: "/"
});
// Step 4: Log signout event // Step 4: Log signout event
if (userId) { if (userId) {

View File

@@ -413,7 +413,7 @@ export const databaseRouter = createTRPCRouter({
await conn.execute(tagQuery); await conn.execute(tagQuery);
} }
cache.deleteByPrefix("blog-"); await cache.deleteByPrefix("blog-");
return { data: results.lastInsertRowid }; return { data: results.lastInsertRowid };
} catch (error) { } catch (error) {
@@ -529,7 +529,7 @@ export const databaseRouter = createTRPCRouter({
await conn.execute(tagQuery); await conn.execute(tagQuery);
} }
cache.deleteByPrefix("blog-"); await cache.deleteByPrefix("blog-");
return { data: results.lastInsertRowid }; return { data: results.lastInsertRowid };
} catch (error) { } catch (error) {
@@ -565,7 +565,7 @@ export const databaseRouter = createTRPCRouter({
args: [input.id] args: [input.id]
}); });
cache.deleteByPrefix("blog-"); await cache.deleteByPrefix("blog-");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {

View File

@@ -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<T> { import { createClient } from "redis";
data: T; import { env } from "~/env/server";
timestamp: number;
let redisClient: ReturnType<typeof createClient> | 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<string, CacheEntry<any>> = new Map(); * Redis-backed cache interface
*/
export const cache = {
async get<T>(key: string): Promise<T | null> {
try {
const client = await getRedisClient();
const value = await client.get(key);
get<T>(key: string, ttlMs: number): T | null { if (!value) {
const entry = this.cache.get(key);
if (!entry) return null;
const now = Date.now();
if (now - entry.timestamp > ttlMs) {
this.cache.delete(key);
return null; return null;
} }
return entry.data as T; return JSON.parse(value) as T;
} catch (error) {
console.error(`Cache get error for key "${key}":`, error);
return null;
} }
},
getStale<T>(key: string): T | null { async set<T>(key: string, data: T, ttlMs: number): Promise<void> {
const entry = this.cache.get(key); try {
return entry ? (entry.data as T) : null; const client = await getRedisClient();
} const value = JSON.stringify(data);
has(key: string): boolean { // Redis SET with EX (expiry in seconds)
return this.cache.has(key); await client.set(key, value, {
} EX: Math.ceil(ttlMs / 1000)
set<T>(key: string, data: T): void {
this.cache.set(key, {
data,
timestamp: Date.now()
}); });
} catch (error) {
console.error(`Cache set error for key "${key}":`, error);
} }
},
clear(): void { async delete(key: string): Promise<void> {
this.cache.clear(); try {
const client = await getRedisClient();
await client.del(key);
} catch (error) {
console.error(`Cache delete error for key "${key}":`, error);
} }
},
delete(key: string): void { async deleteByPrefix(prefix: string): Promise<void> {
this.cache.delete(key); try {
} const client = await getRedisClient();
const keys = await client.keys(`${prefix}*`);
deleteByPrefix(prefix: string): void { if (keys.length > 0) {
for (const key of this.cache.keys()) { await client.del(keys);
if (key.startsWith(prefix)) {
this.cache.delete(key);
} }
} catch (error) {
console.error(
`Cache deleteByPrefix error for prefix "${prefix}":`,
error
);
} }
} },
}
export const cache = new SimpleCache(); async clear(): Promise<void> {
try {
const client = await getRedisClient();
await client.flushDb();
} catch (error) {
console.error("Cache clear error:", error);
}
},
async has(key: string): Promise<boolean> {
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;
}
}
};
/**
* Execute function with Redis caching
*/
export async function withCache<T>( export async function withCache<T>(
key: string, key: string,
ttlMs: number, ttlMs: number,
fn: () => Promise<T> fn: () => Promise<T>
): Promise<T> { ): Promise<T> {
const cached = cache.get<T>(key, ttlMs); const cached = await cache.get<T>(key);
if (cached !== null) { if (cached !== null) {
return cached; return cached;
} }
const result = await fn(); const result = await fn();
cache.set(key, result); await cache.set(key, result, ttlMs);
return result; 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<T>( export async function withCacheAndStale<T>(
key: string, key: string,
@@ -82,36 +171,36 @@ export async function withCacheAndStale<T>(
logErrors?: boolean; logErrors?: boolean;
} = {} } = {}
): Promise<T> { ): Promise<T> {
const { maxStaleMs = CACHE_CONFIG.MAX_STALE_DATA_MS, logErrors = true } = const { maxStaleMs = 7 * 24 * 60 * 60 * 1000, logErrors = true } = options;
options;
const cached = cache.get<T>(key, ttlMs); // Try fresh cache
const cached = await cache.get<T>(key);
if (cached !== null) { if (cached !== null) {
return cached; return cached;
} }
try { try {
// Execute function
const result = await fn(); 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; return result;
} catch (error) { } catch (error) {
if (logErrors) { if (logErrors) {
console.error(`Error fetching data for cache key "${key}":`, error); console.error(`Error fetching data for cache key "${key}":`, error);
} }
const stale = cache.getStale<T>(key); // Try stale cache with longer TTL key
if (stale !== null) { const staleKey = `${key}:stale`;
const entry = (cache as any).cache.get(key); const staleData = await cache.get<T>(staleKey);
const age = Date.now() - entry.timestamp;
if (age <= maxStaleMs) { if (staleData !== null) {
if (logErrors) { if (logErrors) {
console.log( console.log(`Serving stale data for cache key "${key}"`);
`Serving stale data for cache key "${key}" (age: ${Math.round(age / 1000 / 60)}m)`
);
}
return stale;
} }
return staleData;
} }
throw error; throw error;

View File

@@ -200,9 +200,11 @@ export async function clearRateLimitStore(): Promise<void> {
} }
/** /**
* 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<void> {
try { try {
const { ConnectionFactory } = await import("./database"); const { ConnectionFactory } = await import("./database");
const conn = ConnectionFactory(); const conn = ConnectionFactory();
@@ -212,9 +214,10 @@ setInterval(async () => {
args: [now] args: [now]
}); });
} catch (error) { } catch (error) {
// Silent fail - cleanup is opportunistic
console.error("Failed to cleanup expired rate limits:", error); console.error("Failed to cleanup expired rate limits:", error);
} }
}, RATE_LIMIT_CLEANUP_INTERVAL_MS); }
/** /**
* Get client IP address from request headers * Get client IP address from request headers
@@ -274,6 +277,11 @@ export async function checkRateLimit(
const now = Date.now(); const now = Date.now();
const resetAt = new Date(now + windowMs); 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({ const result = await conn.execute({
sql: "SELECT id, count, reset_at FROM RateLimit WHERE identifier = ?", sql: "SELECT id, count, reset_at FROM RateLimit WHERE identifier = ?",
args: [identifier] args: [identifier]
@@ -506,6 +514,22 @@ export async function resetFailedAttempts(userId: string): Promise<void> {
}); });
} }
/**
* Reset login rate limits on successful login
*/
export async function resetLoginRateLimits(
email: string,
clientIP: string
): Promise<void> {
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; export const PASSWORD_RESET_CONFIG = CONFIG_PASSWORD_RESET;
/** /**