updating auth token security

This commit is contained in:
Michael Freno
2026-01-06 23:11:19 -05:00
parent 4dd3a44711
commit 08a9ad35af
10 changed files with 1373 additions and 140 deletions

View File

@@ -18,6 +18,7 @@ import { createWindowWidth, isMobile } from "~/lib/resize-utils";
import { MOBILE_CONFIG } from "./config"; import { MOBILE_CONFIG } from "./config";
import CustomScrollbar from "./components/CustomScrollbar"; import CustomScrollbar from "./components/CustomScrollbar";
import { initPerformanceTracking } from "~/lib/performance-tracking"; import { initPerformanceTracking } from "~/lib/performance-tracking";
import { tokenRefreshManager } from "~/lib/token-refresh";
function AppLayout(props: { children: any }) { function AppLayout(props: { children: any }) {
const { const {
@@ -190,6 +191,16 @@ function AppLayout(props: { children: any }) {
} }
export default function App() { export default function App() {
onMount(() => {
// Start token refresh monitoring
tokenRefreshManager.start();
});
onCleanup(() => {
// Cleanup token refresh on unmount
tokenRefreshManager.stop();
});
return ( return (
<MetaProvider> <MetaProvider>
<ErrorBoundary <ErrorBoundary

View File

@@ -15,7 +15,8 @@ import {
useNavigate, useNavigate,
useLocation, useLocation,
query, query,
createAsync createAsync,
revalidate
} from "@solidjs/router"; } from "@solidjs/router";
import { BREAKPOINTS } from "~/config"; import { BREAKPOINTS } from "~/config";
import { getRequestEvent } from "solid-js/web"; import { getRequestEvent } from "solid-js/web";
@@ -51,6 +52,13 @@ const getUserState = query(async () => {
}; };
}, "bars-user-state"); }, "bars-user-state");
/**
* Call this function after login/logout to refresh the user state in the sidebar
*/
export function revalidateUserState() {
revalidate(getUserState.key);
}
function formatDomainName(url: string): string { function formatDomainName(url: string): string {
const domain = url.split("://")[1]?.split(":")[0] ?? url; const domain = url.split("://")[1]?.split(":")[0] ?? url;
const withoutWww = domain.replace(/^www\./i, ""); const withoutWww = domain.replace(/^www\./i, "");

View File

@@ -7,17 +7,88 @@
// AUTHENTICATION & SESSION // AUTHENTICATION & SESSION
// ============================================================ // ============================================================
/**
* AUTHENTICATION & SESSION CONFIGURATION
*
* Security Model:
* - Access tokens: Short-lived (15m), contain user identity, stored in httpOnly cookie
* - Refresh tokens: Long-lived (7-90d), opaque tokens for getting new access tokens
* - Token rotation: Each refresh invalidates old token and issues new pair
* - Breach detection: Reusing invalidated token revokes entire token family
*
* Timing Decisions:
* - 15m access: Balance between security (short exposure) and UX (not too frequent refreshes)
* - 7d refresh: Conservative default, users re-auth weekly
* - 90d remember: Extended convenience for trusted devices
* - 5s reuse window: Handles race conditions in distributed systems
*
* References:
* - OWASP: https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
* - RFC 6819: https://datatracker.ietf.org/doc/html/rfc6819#section-5.2
*/
export const AUTH_CONFIG = { export const AUTH_CONFIG = {
JWT_EXPIRY: "14d" as const, // Access Token (JWT in cookie)
JWT_EXPIRY_SHORT: "12h" as const, ACCESS_TOKEN_EXPIRY: "15m" as const, // 15 minutes (short-lived)
SESSION_COOKIE_MAX_AGE: 60 * 60 * 24 * 14, ACCESS_TOKEN_EXPIRY_DEV: "2m" as const, // 1 hour in dev for convenience
REMEMBER_ME_MAX_AGE: 60 * 60 * 24 * 14,
// Refresh Token (opaque token in separate cookie)
REFRESH_TOKEN_EXPIRY_SHORT: "7d" as const, // 7 days (no remember me)
REFRESH_TOKEN_EXPIRY_LONG: "90d" as const, // 90 days (remember me)
// Cookie MaxAge (in seconds - must match token lifetime)
ACCESS_COOKIE_MAX_AGE: 15 * 60, // 15 minutes
ACCESS_COOKIE_MAX_AGE_DEV: 60 * 60, // 1 hour in dev
REFRESH_COOKIE_MAX_AGE_SHORT: 60 * 60 * 24 * 7, // 7 days
REFRESH_COOKIE_MAX_AGE_LONG: 60 * 60 * 24 * 90, // 90 days
// Legacy (keep for backwards compatibility during migration)
JWT_EXPIRY: "15m" as const, // Deprecated: use ACCESS_TOKEN_EXPIRY
JWT_EXPIRY_SHORT: "15m" as const, // Deprecated
SESSION_COOKIE_MAX_AGE: 60 * 60 * 24 * 7, // Deprecated
REMEMBER_ME_MAX_AGE: 60 * 60 * 24 * 90, // Deprecated
// Security Settings
REFRESH_TOKEN_ROTATION_ENABLED: true, // Enable token rotation
MAX_ROTATION_COUNT: 100, // Max rotations before forcing re-login
REFRESH_TOKEN_REUSE_WINDOW_MS: 5000, // 5s grace period for race conditions
// Session Cleanup (serverless-friendly opportunistic cleanup)
SESSION_CLEANUP_INTERVAL_HOURS: 24, // Check for cleanup every 24 hours
SESSION_CLEANUP_RETENTION_DAYS: 90, // Keep revoked sessions for 90 days (audit)
// Other Auth Settings
CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14, CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14,
EMAIL_LOGIN_LINK_EXPIRY: "15m" as const, EMAIL_LOGIN_LINK_EXPIRY: "15m" as const,
EMAIL_VERIFICATION_LINK_EXPIRY: "15m" as const, EMAIL_VERIFICATION_LINK_EXPIRY: "15m" as const,
LINEAGE_JWT_EXPIRY: "14d" as const LINEAGE_JWT_EXPIRY: "14d" as const
} as const; } as const;
/**
* Get access token expiry based on environment
*/
export function getAccessTokenExpiry(): string {
return process.env.NODE_ENV === "production"
? AUTH_CONFIG.ACCESS_TOKEN_EXPIRY
: AUTH_CONFIG.ACCESS_TOKEN_EXPIRY_DEV;
}
/**
* Get access cookie maxAge based on environment (in seconds)
*/
export function getAccessCookieMaxAge(): number {
return process.env.NODE_ENV === "production"
? AUTH_CONFIG.ACCESS_COOKIE_MAX_AGE
: AUTH_CONFIG.ACCESS_COOKIE_MAX_AGE_DEV;
}
/**
* Type helper for token expiry strings
*/
export type TokenExpiry =
| typeof AUTH_CONFIG.ACCESS_TOKEN_EXPIRY
| typeof AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_SHORT
| typeof AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG;
// ============================================================ // ============================================================
// RATE LIMITING // RATE LIMITING
// ============================================================ // ============================================================
@@ -223,3 +294,14 @@ export const AUDIT_CONFIG = {
DEFAULT_QUERY_LIMIT: 100, DEFAULT_QUERY_LIMIT: 100,
MAX_RETENTION_DAYS: 90 MAX_RETENTION_DAYS: 90
} as const; } as const;
// ============================================================
// SESSION CLEANUP
// ============================================================
export const SESSION_CLEANUP_CONFIG = {
ENABLED: true,
INTERVAL_HOURS: 24,
RETENTION_DAYS: 90,
RUN_ON_STARTUP: true
} as const;

View File

@@ -15,21 +15,29 @@ export const model: { [key: string]: string } = {
); );
`, `,
Session: ` Session: `
CREATE TABLE Session CREATE TABLE Session
( (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
token_family TEXT NOT NULL, token_family TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')), refresh_token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL, parent_session_id TEXT,
last_used TEXT NOT NULL DEFAULT (datetime('now')), rotation_count INTEGER DEFAULT 0,
ip_address TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')),
user_agent TEXT, expires_at TEXT NOT NULL,
revoked INTEGER DEFAULT 0, access_token_expires_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE last_used TEXT NOT NULL DEFAULT (datetime('now')),
); ip_address TEXT,
CREATE INDEX IF NOT EXISTS idx_session_user_id ON Session (user_id); user_agent TEXT,
CREATE INDEX IF NOT EXISTS idx_session_expires_at ON Session (expires_at); 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: ` PasswordResetToken: `
CREATE TABLE PasswordResetToken CREATE TABLE PasswordResetToken

View File

@@ -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 (&shy;) for manual hyphenation. Uses actual characters for Typewriter compatibility. * Inserts soft hyphens (&shy;) for manual hyphenation. Uses actual characters for Typewriter compatibility.
*/ */

153
src/lib/token-refresh.ts Normal file
View File

@@ -0,0 +1,153 @@
/**
* Token Refresh Manager
* Handles automatic token refresh before expiry
*/
import { api } from "~/lib/api";
import { getClientCookie } from "~/lib/cookies.client";
import { getTimeUntilExpiry } from "~/lib/client-utils";
class TokenRefreshManager {
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
private isRefreshing = false;
private refreshThresholdMs = 2 * 60 * 1000; // Refresh 2 minutes before expiry
private isStarted = false;
private visibilityChangeHandler: (() => void) | null = null;
/**
* Start monitoring token and auto-refresh before expiry
*/
start(): void {
if (typeof window === "undefined") return; // Server-side bail
if (this.isStarted) return; // Already started, prevent duplicate listeners
this.isStarted = true;
this.scheduleNextRefresh();
// Re-check on visibility change (user returns to tab)
this.visibilityChangeHandler = () => {
if (document.visibilityState === "visible") {
this.scheduleNextRefresh();
}
};
document.addEventListener("visibilitychange", this.visibilityChangeHandler);
}
/**
* Stop monitoring and clear timers
*/
stop(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
if (this.visibilityChangeHandler) {
document.removeEventListener(
"visibilitychange",
this.visibilityChangeHandler
);
this.visibilityChangeHandler = null;
}
this.isStarted = false;
}
/**
* Schedule next refresh based on token expiry
*/
private scheduleNextRefresh(): void {
this.stop(); // Clear existing timer
const token = getClientCookie("userIDToken");
if (!token) {
// No token found - user not logged in, nothing to refresh
return;
}
const timeUntilExpiry = getTimeUntilExpiry(token);
if (!timeUntilExpiry) {
console.warn("Token expired or invalid, attempting refresh now");
this.refreshNow();
return;
}
// Schedule refresh before expiry
const timeUntilRefresh = Math.max(
0,
timeUntilExpiry - this.refreshThresholdMs
);
console.log(
`[Token Refresh] Token expires in ${Math.round(timeUntilExpiry / 1000)}s, ` +
`scheduling refresh in ${Math.round(timeUntilRefresh / 1000)}s`
);
this.refreshTimer = setTimeout(() => {
this.refreshNow();
}, timeUntilRefresh);
}
/**
* Perform token refresh immediately
*/
async refreshNow(): Promise<boolean> {
if (this.isRefreshing) {
console.log("[Token Refresh] Refresh already in progress, skipping");
return false;
}
this.isRefreshing = true;
try {
console.log("[Token Refresh] Refreshing access token...");
const result = await api.auth.refreshToken.mutate({
rememberMe: false // Maintain existing rememberMe state
});
if (result.success) {
console.log("[Token Refresh] Token refreshed successfully");
this.scheduleNextRefresh(); // Schedule next refresh
return true;
} else {
console.error("[Token Refresh] Token refresh failed:", result);
this.handleRefreshFailure();
return false;
}
} catch (error) {
console.error("[Token Refresh] Token refresh error:", error);
this.handleRefreshFailure();
return false;
} finally {
this.isRefreshing = false;
}
}
/**
* Handle refresh failure (redirect to login)
*/
private handleRefreshFailure(): void {
console.warn("[Token Refresh] Token refresh failed, redirecting to login");
// Store current URL for redirect after login
const currentPath = window.location.pathname + window.location.search;
if (currentPath !== "/login") {
sessionStorage.setItem("redirectAfterLogin", currentPath);
}
// Redirect to login
window.location.href = "/login";
}
}
// Singleton instance
export const tokenRefreshManager = new TokenRefreshManager();
/**
* Manually trigger token refresh (can be called from UI)
* @returns Promise<boolean> success status
*/
export async function manualRefresh(): Promise<boolean> {
return tokenRefreshManager.refreshNow();
}

View File

@@ -7,6 +7,7 @@ import {
query query
} from "@solidjs/router"; } from "@solidjs/router";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { revalidateUserState } from "~/components/Bars";
import { getEvent } from "vinxi/http"; import { getEvent } from "vinxi/http";
import GoogleLogo from "~/components/icons/GoogleLogo"; import GoogleLogo from "~/components/icons/GoogleLogo";
import GitHub from "~/components/icons/GitHub"; import GitHub from "~/components/icons/GitHub";
@@ -205,6 +206,7 @@ export default function LoginPage() {
if (response.ok && result.result?.data?.success) { if (response.ok && result.result?.data?.success) {
setShowPasswordSuccess(true); setShowPasswordSuccess(true);
revalidateUserState(); // Refresh user state in sidebar
setTimeout(() => { setTimeout(() => {
navigate("/account", { replace: true }); navigate("/account", { replace: true });
}, 500); }, 500);

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,8 @@ export type AuditEventType =
| "security.rate_limit.exceeded" | "security.rate_limit.exceeded"
| "security.csrf.failed" | "security.csrf.failed"
| "security.suspicious.activity" | "security.suspicious.activity"
| "admin.action"; | "admin.action"
| "system.session_cleanup";
/** /**
* Audit log entry structure * Audit log entry structure

181
src/server/token-cleanup.ts Normal file
View File

@@ -0,0 +1,181 @@
import { ConnectionFactory } from "~/server/utils";
import { logAuditEvent } from "~/server/audit";
import { AUTH_CONFIG } from "~/config";
/**
* Cleanup expired and revoked sessions
* Keeps sessions for audit purposes up to retention limit
* @param retentionDays - How long to keep revoked sessions (default 90)
* @returns Cleanup statistics
*/
export async function cleanupExpiredSessions(
retentionDays: number = AUTH_CONFIG.SESSION_CLEANUP_RETENTION_DAYS
): Promise<{
expiredDeleted: number;
revokedDeleted: number;
totalDeleted: number;
}> {
const conn = ConnectionFactory();
const retentionDate = new Date();
retentionDate.setDate(retentionDate.getDate() - retentionDays);
try {
// Step 1: Delete expired sessions (hard delete)
const expiredResult = await conn.execute({
sql: `DELETE FROM Session
WHERE expires_at < datetime('now')
AND created_at < ?`,
args: [retentionDate.toISOString()]
});
// Step 2: Delete old revoked sessions (keep recent for audit)
const revokedResult = await conn.execute({
sql: `DELETE FROM Session
WHERE revoked = 1
AND created_at < ?`,
args: [retentionDate.toISOString()]
});
const stats = {
expiredDeleted: Number(expiredResult.rowsAffected) || 0,
revokedDeleted: Number(revokedResult.rowsAffected) || 0,
totalDeleted:
(Number(expiredResult.rowsAffected) || 0) +
(Number(revokedResult.rowsAffected) || 0)
};
console.log(
`Session cleanup completed: ${stats.totalDeleted} sessions deleted ` +
`(${stats.expiredDeleted} expired, ${stats.revokedDeleted} revoked)`
);
// Log cleanup event
await logAuditEvent({
eventType: "system.session_cleanup",
eventData: stats,
success: true
});
return stats;
} catch (error) {
console.error("Session cleanup failed:", error);
await logAuditEvent({
eventType: "system.session_cleanup",
eventData: { error: String(error) },
success: false
});
throw error;
}
}
/**
* Cleanup orphaned parent session references
* Remove parent_session_id references to deleted sessions
*/
export async function cleanupOrphanedReferences(): Promise<number> {
const conn = ConnectionFactory();
const result = await conn.execute({
sql: `UPDATE Session
SET parent_session_id = NULL
WHERE parent_session_id IS NOT NULL
AND parent_session_id NOT IN (
SELECT id FROM Session
)`
});
const orphansFixed = Number(result.rowsAffected) || 0;
if (orphansFixed > 0) {
console.log(`Fixed ${orphansFixed} orphaned parent_session_id references`);
}
return orphansFixed;
}
/**
* Get session statistics for monitoring
*/
export async function getSessionStats(): Promise<{
total: number;
active: number;
expired: number;
revoked: number;
avgRotationCount: number;
}> {
const conn = ConnectionFactory();
const totalResult = await conn.execute({
sql: "SELECT COUNT(*) as count FROM Session"
});
const activeResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM Session
WHERE revoked = 0 AND expires_at > datetime('now')`
});
const expiredResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM Session
WHERE expires_at < datetime('now')`
});
const revokedResult = await conn.execute({
sql: "SELECT COUNT(*) as count FROM Session WHERE revoked = 1"
});
const rotationResult = await conn.execute({
sql: "SELECT AVG(rotation_count) as avg FROM Session WHERE revoked = 0"
});
return {
total: Number(totalResult.rows[0]?.count) || 0,
active: Number(activeResult.rows[0]?.count) || 0,
expired: Number(expiredResult.rows[0]?.count) || 0,
revoked: Number(revokedResult.rows[0]?.count) || 0,
avgRotationCount: Number(rotationResult.rows[0]?.avg) || 0
};
}
/**
* Opportunistic cleanup trigger
* Runs cleanup if it hasn't been run recently (serverless-friendly)
* Uses a simple timestamp check to avoid running too frequently
*/
let lastCleanupTime = 0;
export async function opportunisticCleanup(): Promise<void> {
const now = Date.now();
const minIntervalMs =
AUTH_CONFIG.SESSION_CLEANUP_INTERVAL_HOURS * 60 * 60 * 1000;
// Only run if enough time has passed since last cleanup
if (now - lastCleanupTime < minIntervalMs) {
return;
}
// Update timestamp immediately to prevent concurrent runs
lastCleanupTime = now;
try {
console.log("Running opportunistic session cleanup...");
// Run cleanup asynchronously (don't block the request)
Promise.all([cleanupExpiredSessions(), cleanupOrphanedReferences()])
.then(([stats, orphansFixed]) => {
console.log(
`Opportunistic cleanup completed: ${stats.totalDeleted} sessions deleted, ` +
`${orphansFixed} orphaned references fixed`
);
})
.catch((error) => {
console.error("Opportunistic cleanup error:", error);
// Reset timer on failure so we can retry sooner
lastCleanupTime = now - minIntervalMs + 5 * 60 * 1000; // Retry in 5 minutes
});
} catch (error) {
console.error("Opportunistic cleanup trigger error:", error);
// Reset timer on failure
lastCleanupTime = now - minIntervalMs + 5 * 60 * 1000;
}
}