diff --git a/.gitignore b/.gitignore index 7db022d..f1280a4 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,7 @@ app.config.timestamp_*.js .classpath *.launch .settings/ -tasks +#tasks # Temp gitignore diff --git a/src/config.ts b/src/config.ts index ab3dd77..0d04906 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,37 +7,21 @@ * * 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 + * - rememberMe tokens: Long-lived (30d), issued as JWT without refresh tokens * * Cookie Behavior: - * - rememberMe = false: Session cookies (no maxAge) - expire when browser closes + * - rememberMe = false: Browser-session cookies (no maxAge) - expire when browser closes * - rememberMe = true: Persistent cookies (with maxAge) - survive browser restart * * Timing Decisions: * - 15m access: Balance between security (short exposure) and UX (not too frequent refreshes) - * - 7d session: DB expiry for non-remember-me (cookie is session-only but accommodates users who keep browser open) - * - 90d remember: Extended convenience for trusted devices (both DB and cookie persist) - * - 5s reuse window: Handles race conditions in distributed systems + * - 30d remember: Extended convenience for trusted devices */ export const AUTH_CONFIG = { // Access Token (JWT in cookie) ACCESS_TOKEN_EXPIRY: "15m" as const, // 15 minutes (short-lived) ACCESS_TOKEN_EXPIRY_DEV: "2m" as const, // 2 minutes for faster testing - - // Refresh Token (opaque token in separate cookie) - REFRESH_TOKEN_EXPIRY_SHORT: "7d" as const, // 7 days (DB expiry for non-remember me - accommodates users who keep browser open) - REFRESH_TOKEN_EXPIRY_LONG: "90d" as const, // 90 days (remember me - both DB and cookie persist) - - // Security Settings - REFRESH_TOKEN_ROTATION_ENABLED: true, // Enable token rotation - MAX_ROTATION_COUNT: 1000, // Max rotations before forcing re-login (1000 * 15m = 10.4 days in prod, 1000 * 2m = 33 hours in dev) - 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) + ACCESS_TOKEN_EXPIRY_LONG: "30d" as const, // rememberMe cookie lifetime // Other Auth Settings CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14, @@ -57,7 +41,7 @@ export function getAccessTokenExpiry(): string { /** * Convert expiry string to seconds for cookie Max-Age - * @param expiry - Expiry string like "15m", "7d", "90d" + * @param expiry - Expiry string like "15m", "30d" * @returns Seconds as number */ export function expiryToSeconds(expiry: string): number { @@ -71,31 +55,12 @@ export function expiryToSeconds(expiry: string): number { throw new Error(`Invalid expiry format: ${expiry}`); } -/** - * Get access cookie maxAge based on environment (in seconds) - */ -export function getAccessCookieMaxAge(): number { - return expiryToSeconds(getAccessTokenExpiry()); -} - -/** - * 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 - ); -} - /** * 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; + | typeof AUTH_CONFIG.ACCESS_TOKEN_EXPIRY_LONG; // ============================================================ // RATE LIMITING @@ -151,9 +116,6 @@ export const CACHE_CONFIG = { MAX_STALE_DATA_MS: 7 * 24 * 60 * 60 * 1000, GIT_ACTIVITY_MAX_STALE_MS: 24 * 60 * 60 * 1000, - // Session activity tracking - only update DB if last update was > threshold - SESSION_ACTIVITY_UPDATE_THRESHOLD_MS: 5 * 60 * 1000, // 5 minutes - // Rate limit in-memory cache TTL (reduces DB reads) RATE_LIMIT_CACHE_TTL_MS: 60 * 1000, // 1 minute diff --git a/src/context/auth.tsx b/src/context/auth.tsx index 6068dbc..4e302f4 100644 --- a/src/context/auth.tsx +++ b/src/context/auth.tsx @@ -20,7 +20,6 @@ import { } from "solid-js"; import { createAsync, revalidate } from "@solidjs/router"; import { getUserState, type UserState } from "~/lib/auth-query"; -import { tokenRefreshManager } from "~/lib/token-refresh"; interface AuthContextType { /** Current user state (for UI display) */ @@ -87,41 +86,6 @@ export const AuthProvider: ParentComponent = (props) => { }); }); - // 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/db/create.ts b/src/db/create.ts index 45363a6..a367f2e 100644 --- a/src/db/create.ts +++ b/src/db/create.ts @@ -15,38 +15,6 @@ export const model: { [key: string]: string } = { locked_until TEXT ); `, - Session: ` - CREATE TABLE Session - ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - token_family TEXT NOT NULL, - refresh_token_hash TEXT NOT NULL, - parent_session_id TEXT, - rotation_count INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - expires_at TEXT NOT NULL, - access_token_expires_at TEXT NOT NULL, - last_used TEXT NOT NULL DEFAULT (datetime('now')), - ip_address TEXT, - user_agent TEXT, - revoked INTEGER DEFAULT 0, - device_name TEXT, - device_type TEXT, - browser TEXT, - os TEXT, - last_active_at TEXT DEFAULT (datetime('now')), - 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); - CREATE INDEX IF NOT EXISTS idx_session_last_active ON Session (last_active_at); - CREATE INDEX IF NOT EXISTS idx_session_user_active ON Session (user_id, revoked, last_active_at); - `, UserProvider: ` CREATE TABLE UserProvider ( diff --git a/src/db/types.ts b/src/db/types.ts index e2716a8..759ac8d 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -17,23 +17,6 @@ export interface User { locked_until?: string | null; } -export interface Session { - id: string; - user_id: string; - token_family: string; - created_at: string; - expires_at: string; - last_used: string; - ip_address?: string | null; - user_agent?: string | null; - revoked: number; - device_name?: string | null; - device_type?: string | null; - browser?: string | null; - os?: string | null; - last_active_at?: string | null; -} - export interface UserProvider { id: string; user_id: string; @@ -165,7 +148,6 @@ export interface VisitorAnalytics { device_type?: string | null; browser?: string | null; os?: string | null; - session_id?: string | null; duration_ms?: number | null; fcp?: number | null; lcp?: number | null; diff --git a/src/lib/auth-query.ts b/src/lib/auth-query.ts index 2ba2fd2..4fce8e0 100644 --- a/src/lib/auth-query.ts +++ b/src/lib/auth-query.ts @@ -86,11 +86,5 @@ export function revalidateAuth() { // Dispatch 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 deleted file mode 100644 index 778d33d..0000000 --- a/src/lib/token-refresh.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * 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 { 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 isStarted = false; - private visibilityChangeHandler: (() => void) | null = null; - private onlineHandler: (() => void) | null = null; - private focusHandler: (() => void) | null = null; - private lastRefreshTime: number | null = null; - private lastCheckTime: number = 0; - - /** - * Start monitoring and auto-refresh - * @param isAuthenticated - Whether user is currently authenticated (from server state) - */ - 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) { - 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") { - console.log( - "[Token Refresh] Tab became visible, checking token status" - ); - this.checkAndRefreshIfNeeded(); - } - }; - document.addEventListener("visibilitychange", this.visibilityChangeHandler); - - // Re-check on network reconnection (device was offline) - this.onlineHandler = () => { - console.log("[Token Refresh] Network reconnected, checking token status"); - this.checkAndRefreshIfNeeded(); - }; - window.addEventListener("online", this.onlineHandler); - - // Re-check on window focus (device was asleep or user switched apps) - // Debounce to prevent Safari from firing this too frequently - this.focusHandler = () => { - const now = Date.now(); - const timeSinceLastCheck = now - this.lastCheckTime; - - // Debounce: only check if last check was >1s ago (prevents Safari spam) - if (timeSinceLastCheck < 1000) { - console.log("[Token Refresh] Window focused but debouncing (Safari)"); - return; - } - - this.lastCheckTime = now; - console.log("[Token Refresh] Window focused, checking token status"); - this.checkAndRefreshIfNeeded(); - }; - window.addEventListener("focus", this.focusHandler); - } - - /** - * 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; - } - - if (this.onlineHandler) { - window.removeEventListener("online", this.onlineHandler); - this.onlineHandler = null; - } - - if (this.focusHandler) { - window.removeEventListener("focus", this.focusHandler); - this.focusHandler = null; - } - - 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 { - // Clear existing timer but don't stop the manager - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - this.refreshTimer = null; - } - - if (!this.lastRefreshTime) { - console.log("[Token Refresh] No refresh history, cannot schedule"); - return; - } - - 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; - } - - // Schedule refresh before expiry - const timeUntilRefresh = Math.max( - 0, - timeUntilExpiry - REFRESH_THRESHOLD_MS - ); - - console.log( - `[Token Refresh] Scheduling refresh in ${Math.round(timeUntilRefresh / 1000)}s ` + - `(~${Math.round(timeUntilExpiry / 1000)}s until expiry)` - ); - - this.refreshTimer = setTimeout(() => { - this.refreshNow(); - }, 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 - */ - async refreshNow(): Promise { - if (this.isRefreshing) { - console.log("[Token Refresh] Refresh already in progress, skipping"); - return false; - } - - this.isRefreshing = true; - - try { - console.log("[Token Refresh] Refreshing access token..."); - - // 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 Promise.race([ - api.auth.refreshToken.mutate({ - rememberMe - }), - new Promise((_, reject) => - setTimeout(() => reject(new Error("Token refresh timeout")), 10000) - ) - ]); - - if (result.success) { - console.log("[Token Refresh] Token refreshed successfully"); - 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); - this.handleRefreshFailure(); - return false; - } - } catch (error) { - console.error("[Token Refresh] Token refresh error:", error); - - // Don't redirect on timeout - might be deployment in progress - const isTimeout = - error instanceof Error && error.message.includes("timeout"); - if (isTimeout) { - console.warn( - "[Token Refresh] Timeout - server might be deploying, will retry on schedule" - ); - this.scheduleNextRefresh(); - return false; - } - - 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"; - } - - /** - * 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 -export const tokenRefreshManager = new TokenRefreshManager(); diff --git a/src/routes/account.tsx b/src/routes/account.tsx index 4307f86..0e4dc9b 100644 --- a/src/routes/account.tsx +++ b/src/routes/account.tsx @@ -923,18 +923,6 @@ export default function AccountPage() {
- {/* Active Sessions Section */} -
-
- Active Sessions -
-
- -
-
- -
- {/* Sign Out Section */}
-
- - ); - }} - - - ); -} diff --git a/src/routes/api/auth/callback/github.ts b/src/routes/api/auth/callback/github.ts index 557e243..5788871 100644 --- a/src/routes/api/auth/callback/github.ts +++ b/src/routes/api/auth/callback/github.ts @@ -45,7 +45,7 @@ export async function GET(event: APIEvent) { result.redirectTo ); - // Vinxi's updateSession already set the cookie headers automatically + // Auth handler already set cookie headers // Just redirect - the cookies are already in the response const redirectUrl = result.redirectTo || "/account"; return new Response(null, { diff --git a/src/routes/api/auth/callback/google.ts b/src/routes/api/auth/callback/google.ts index 96811da..1171269 100644 --- a/src/routes/api/auth/callback/google.ts +++ b/src/routes/api/auth/callback/google.ts @@ -45,7 +45,7 @@ export async function GET(event: APIEvent) { result.redirectTo ); - // Vinxi's updateSession already set the cookie headers automatically + // Auth handler already set cookie headers // Just redirect - the cookies are already in the response const redirectUrl = result.redirectTo || "/account"; return new Response(null, { diff --git a/src/routes/api/auth/email-login-callback.ts b/src/routes/api/auth/email-login-callback.ts index 70f10ce..7a61390 100644 --- a/src/routes/api/auth/email-login-callback.ts +++ b/src/routes/api/auth/email-login-callback.ts @@ -45,7 +45,7 @@ export async function GET(event: APIEvent) { result.redirectTo ); - // Vinxi's updateSession already set the cookie headers automatically + // Auth handler already set cookie headers // Just redirect - the cookies are already in the response const redirectUrl = result.redirectTo || "/account"; return new Response(null, { diff --git a/src/routes/api/auth/signout.ts b/src/routes/api/auth/signout.ts index f0d5197..76c624f 100644 --- a/src/routes/api/auth/signout.ts +++ b/src/routes/api/auth/signout.ts @@ -1,13 +1,11 @@ -import type { APIEvent } from "@solidjs/start/server"; -import { getEvent, clearSession } from "vinxi/http"; -import { sessionConfig } from "~/server/session-config"; +import { getEvent } from "vinxi/http"; +import { clearAuthToken } from "~/server/auth"; export async function POST() { "use server"; const event = getEvent()!; - // Clear Vinxi session - await clearSession(event, sessionConfig); + clearAuthToken(event); return new Response(null, { status: 302, diff --git a/src/routes/test.tsx b/src/routes/test.tsx index ef1049e..e23d9cf 100644 --- a/src/routes/test.tsx +++ b/src/routes/test.tsx @@ -160,7 +160,7 @@ const routerSections: RouterSection[] = [ router: "auth", procedure: "signOut", method: "mutation", - description: "Clear session cookies and sign out" + description: "Clear auth cookies and sign out" }, { name: "GitHub Callback", diff --git a/src/server/analytics.ts b/src/server/analytics.ts index bf7fa0b..7af4130 100644 --- a/src/server/analytics.ts +++ b/src/server/analytics.ts @@ -14,7 +14,6 @@ export interface AnalyticsEntry { deviceType?: string | null; browser?: string | null; os?: string | null; - sessionId?: string | null; durationMs?: number | null; fcp?: number | null; lcp?: number | null; @@ -62,9 +61,9 @@ async function flushAnalyticsBuffer(): Promise { await conn.execute({ sql: `INSERT INTO VisitorAnalytics ( id, user_id, path, method, referrer, user_agent, ip_address, - country, device_type, browser, os, session_id, duration_ms, + country, device_type, browser, os, duration_ms, fcp, lcp, cls, fid, inp, ttfb, dom_load, load_complete - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, args: [ uuid(), entry.userId || null, @@ -77,7 +76,6 @@ async function flushAnalyticsBuffer(): Promise { entry.deviceType || null, entry.browser || null, entry.os || null, - entry.sessionId || null, entry.durationMs || null, entry.fcp || null, entry.lcp || null, @@ -202,7 +200,6 @@ export async function queryAnalytics( device_type: row.device_type as string | null, browser: row.browser as string | null, os: row.os as string | null, - session_id: row.session_id as string | null, duration_ms: row.duration_ms as number | null, created_at: row.created_at as string })); diff --git a/src/server/api/routers/account.ts b/src/server/api/routers/account.ts index 874756b..12d6976 100644 --- a/src/server/api/routers/account.ts +++ b/src/server/api/routers/account.ts @@ -1,32 +1,7 @@ import { createTRPCRouter, protectedProcedure } from "../utils"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { - getUserProviders, - unlinkProvider, - getProviderSummary -} from "~/server/provider-helpers"; -import { - getUserActiveSessions, - revokeUserSession, - revokeOtherUserSessions, - getSessionCountByDevice -} from "~/server/session-management"; -import { getAuthSession } from "~/server/session-helpers"; -import { logAuditEvent } from "~/server/audit"; -import { getAuditContext } from "~/server/security"; -import type { H3Event } from "vinxi/http"; -import type { Context } from "../utils"; - -/** - * Extract H3Event from Context - */ -function getH3Event(ctx: Context): H3Event { - if (ctx.event && "nativeEvent" in ctx.event && ctx.event.nativeEvent) { - return ctx.event.nativeEvent as H3Event; - } - return ctx.event as unknown as H3Event; -} +import { getProviderSummary, unlinkProvider } from "~/server/provider-helpers"; export const accountRouter = createTRPCRouter({ /** @@ -67,17 +42,6 @@ export const accountRouter = createTRPCRouter({ await unlinkProvider(userId, provider); - // Log audit event - const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); - await logAuditEvent({ - userId, - eventType: "auth.provider.unlinked", - eventData: { provider }, - ipAddress, - userAgent, - success: true - }); - return { success: true, message: `${provider} authentication unlinked successfully` @@ -97,159 +61,5 @@ export const accountRouter = createTRPCRouter({ message: "Failed to unlink provider" }); } - }), - - /** - * Get all active sessions for current user - */ - getActiveSessions: protectedProcedure.query(async ({ ctx }) => { - try { - const userId = ctx.userId!; - const sessions = await getUserActiveSessions(userId); - - // Mark current session - const currentSession = await getAuthSession(getH3Event(ctx)); - const currentSessionId = currentSession?.sessionId; - - const sessionsWithCurrent = sessions.map((session) => ({ - ...session, - current: session.sessionId === currentSessionId - })); - - return { - success: true, - sessions: sessionsWithCurrent - }; - } catch (error) { - console.error("Error fetching active sessions:", error); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch active sessions" - }); - } - }), - - /** - * Get session statistics by device type - */ - getSessionStats: protectedProcedure.query(async ({ ctx }) => { - try { - const userId = ctx.userId!; - const stats = await getSessionCountByDevice(userId); - - return { - success: true, - stats - }; - } catch (error) { - console.error("Error fetching session stats:", error); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch session stats" - }); - } - }), - - /** - * Revoke a specific session - */ - revokeSession: protectedProcedure - .input( - z.object({ - sessionId: z.string() - }) - ) - .mutation(async ({ input, ctx }) => { - try { - const userId = ctx.userId!; - const { sessionId } = input; - - await revokeUserSession(userId, sessionId); - - // Log audit event - const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); - await logAuditEvent({ - userId, - eventType: "auth.session_revoked", - eventData: { sessionId, reason: "user_request" }, - ipAddress, - userAgent, - success: true - }); - - return { - success: true, - message: "Session revoked successfully" - }; - } catch (error) { - console.error("Error revoking session:", error); - - if (error instanceof Error) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: error.message - }); - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to revoke session" - }); - } - }), - - /** - * Revoke all other sessions (keep current session active) - */ - revokeOtherSessions: protectedProcedure.mutation(async ({ ctx }) => { - try { - const userId = ctx.userId!; - - // Get current session - const currentSession = await getAuthSession(getH3Event(ctx)); - if (!currentSession) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "No active session found" - }); - } - - const revokedCount = await revokeOtherUserSessions( - userId, - currentSession.sessionId - ); - - // Log audit event - const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); - await logAuditEvent({ - userId, - eventType: "auth.sessions_bulk_revoked", - eventData: { - revokedCount, - keptSession: currentSession.sessionId, - reason: "user_request" - }, - ipAddress, - userAgent, - success: true - }); - - return { - success: true, - message: `${revokedCount} session(s) revoked successfully`, - revokedCount - }; - } catch (error) { - console.error("Error revoking other sessions:", error); - - if (error instanceof TRPCError) { - throw error; - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to revoke sessions" - }); - } - }) + }) }); diff --git a/src/server/api/routers/analytics.ts b/src/server/api/routers/analytics.ts index 8e9af09..f62f671 100644 --- a/src/server/api/routers/analytics.ts +++ b/src/server/api/routers/analytics.ts @@ -11,7 +11,7 @@ import { } from "~/server/analytics"; import { ConnectionFactory } from "~/server/database"; import { v4 as uuid } from "uuid"; -import { getRequestIP, getCookie } from "vinxi/http"; +import { getRequestIP } from "vinxi/http"; export const analyticsRouter = createTRPCRouter({ logPerformance: publicProcedure @@ -80,9 +80,6 @@ export const analyticsRouter = createTRPCRouter({ ctx.event.request?.headers?.get("referer") || undefined; const ipAddress = getRequestIP(ctx.event.nativeEvent) || undefined; - const sessionId = - getCookie(ctx.event.nativeEvent, "session_id") || undefined; - const enriched = enrichAnalyticsEntry({ userId: ctx.userId, path: input.path, @@ -90,7 +87,6 @@ export const analyticsRouter = createTRPCRouter({ userAgent, referrer, ipAddress, - sessionId, fcp: input.metrics.fcp, lcp: input.metrics.lcp, cls: input.metrics.cls, @@ -104,9 +100,9 @@ export const analyticsRouter = createTRPCRouter({ await conn.execute({ sql: `INSERT INTO VisitorAnalytics ( id, user_id, path, method, referrer, user_agent, ip_address, - country, device_type, browser, os, session_id, duration_ms, + country, device_type, browser, os, duration_ms, fcp, lcp, cls, fid, inp, ttfb, dom_load, load_complete - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, args: [ uuid(), enriched.userId || null, @@ -119,7 +115,6 @@ export const analyticsRouter = createTRPCRouter({ enriched.deviceType || null, enriched.browser || null, enriched.os || null, - enriched.sessionId || null, enriched.durationMs || null, enriched.fcp || null, enriched.lcp || null, diff --git a/src/server/api/routers/apple-notifications.test.ts b/src/server/api/routers/apple-notifications.test.ts index 599d8f2..583a3c3 100644 --- a/src/server/api/routers/apple-notifications.test.ts +++ b/src/server/api/routers/apple-notifications.test.ts @@ -15,10 +15,6 @@ vi.mock("~/server/apple-notification-store", () => ({ storeAppleNotificationUser: async () => undefined })); -vi.mock("~/server/session-helpers", () => ({ - getAuthSession: async () => ({ userId: "admin", isAdmin: true }) -})); - describe("apple notification router", () => { it("verifies and stores notifications", async () => { const caller = appRouter.createCaller( diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts index d2790a4..460cf7b 100644 --- a/src/server/api/routers/auth.ts +++ b/src/server/api/routers/auth.ts @@ -8,7 +8,6 @@ import { checkPassword, checkPasswordSafe } from "~/server/utils"; -import { setCookie, getCookie } from "vinxi/http"; import type { User } from "~/db/types"; import { linkProvider, @@ -51,17 +50,22 @@ import { import { logAuditEvent } from "~/server/audit"; import type { H3Event } from "vinxi/http"; import type { Context } from "../utils"; -import { AUTH_CONFIG, NETWORK_CONFIG, COOLDOWN_TIMERS } from "~/config"; import { - createAuthSession, - getAuthSession, - invalidateAuthSession, - rotateAuthSession, - revokeTokenFamily -} from "~/server/session-helpers"; -import { checkAuthStatus } from "~/server/auth"; + AUTH_CONFIG, + NETWORK_CONFIG, + COOLDOWN_TIMERS, + expiryToSeconds, + getAccessTokenExpiry +} from "~/config"; +import { + issueAuthToken, + clearAuthToken, + checkAuthStatus, + verifyAuthToken, + getAuthTokenFromEvent +} from "~/server/auth"; import { v4 as uuidV4 } from "uuid"; -import { jwtVerify, SignJWT } from "jose"; +import { SignJWT } from "jose"; import { generateLoginLinkEmail, generatePasswordResetEmail, @@ -83,9 +87,6 @@ function getH3Event(ctx: Context): H3Event { } // Zod schemas -const refreshTokenSchema = z.object({ - rememberMe: z.boolean().optional().default(false) -}); async function sendEmail(to: string, subject: string, htmlContent: string) { const apiKey = env.SENDINBLUE_KEY; @@ -124,45 +125,6 @@ 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 (unused, kept for API compatibility) - * @returns userId if refresh succeeded, null otherwise - */ -export async function attemptTokenRefresh( - event: H3Event, - refreshToken: string -): Promise { - try { - // Step 1: Get current session from Vinxi - const session = await getAuthSession(event); - if (!session) { - return null; - } - - // Step 2: Get client info for rotation - const clientIP = getClientIP(event); - const userAgent = getUserAgent(event); - - const newSession = await rotateAuthSession( - event, - session, - clientIP, - userAgent - ); - - if (!newSession) { - return null; - } - - return newSession.userId; - } catch (error) { - return null; - } -} - export const authRouter = createTRPCRouter({ githubCallback: publicProcedure .input(z.object({ code: z.string() })) @@ -306,16 +268,15 @@ export const authRouter = createTRPCRouter({ } } - const clientIP = getClientIP(getH3Event(ctx)); - const userAgent = getUserAgent(getH3Event(ctx)); - await createAuthSession( - getH3Event(ctx), + const event = getH3Event(ctx); + const clientIP = getClientIP(event); + const userAgent = getUserAgent(event); + await issueAuthToken({ + event, userId, - true, // OAuth defaults to remember - clientIP, - userAgent - ); - setCSRFToken(getH3Event(ctx)); + rememberMe: true + }); + setCSRFToken(event); await logAuditEvent({ userId, @@ -518,18 +479,17 @@ export const authRouter = createTRPCRouter({ } } - // Create session with Vinxi (OAuth defaults to remember me) - const clientIP = getClientIP(getH3Event(ctx)); - const userAgent = getUserAgent(getH3Event(ctx)); - await createAuthSession( - getH3Event(ctx), + // Issue JWT (OAuth defaults to remember me) + const event = getH3Event(ctx); + const clientIP = getClientIP(event); + const userAgent = getUserAgent(event); + await issueAuthToken({ + event, userId, - true, // OAuth defaults to remember - clientIP, - userAgent - ); + rememberMe: true + }); - setCSRFToken(getH3Event(ctx)); + setCSRFToken(event); await logAuditEvent({ userId, @@ -642,17 +602,16 @@ export const authRouter = createTRPCRouter({ const userId = (res.rows[0] as unknown as User).id; - const clientIP = getClientIP(getH3Event(ctx)); - const userAgent = getUserAgent(getH3Event(ctx)); + const event = getH3Event(ctx); + const clientIP = getClientIP(event); + const userAgent = getUserAgent(event); - await createAuthSession( - getH3Event(ctx), + await issueAuthToken({ + event, userId, - rememberMe, - clientIP, - userAgent - ); - setCSRFToken(getH3Event(ctx)); + rememberMe + }); + setCSRFToken(event); await logAuditEvent({ userId, @@ -777,16 +736,15 @@ export const authRouter = createTRPCRouter({ const shouldRemember = rememberMe ?? (payload.rememberMe as boolean) ?? false; - const clientIP = getClientIP(getH3Event(ctx)); - const userAgent = getUserAgent(getH3Event(ctx)); - await createAuthSession( - getH3Event(ctx), + const event = getH3Event(ctx); + const clientIP = getClientIP(event); + const userAgent = getUserAgent(event); + await issueAuthToken({ + event, userId, - shouldRemember, - clientIP, - userAgent - ); - setCSRFToken(getH3Event(ctx)); + rememberMe: shouldRemember + }); + setCSRFToken(event); await logAuditEvent({ userId, @@ -970,20 +928,19 @@ export const authRouter = createTRPCRouter({ email: email }); - // Create session with client info - const clientIP = getClientIP(getH3Event(ctx)); - const userAgent = getUserAgent(getH3Event(ctx)); + // Issue auth token with client info + const event = getH3Event(ctx); + const clientIP = getClientIP(event); + const userAgent = getUserAgent(event); - await createAuthSession( - getH3Event(ctx), + await issueAuthToken({ + event, userId, - rememberMe ?? true, // Default to persistent sessions for registration - clientIP, - userAgent - ); + rememberMe: rememberMe ?? true + }); // Set CSRF token - setCSRFToken(getH3Event(ctx)); + setCSRFToken(event); // Log successful registration await logAuditEvent({ @@ -1138,18 +1095,17 @@ export const authRouter = createTRPCRouter({ // Reset rate limits on successful login await resetLoginRateLimits(email, clientIP); - // Create session with Vinxi - const userAgent = getUserAgent(getH3Event(ctx)); - await createAuthSession( - getH3Event(ctx), - user.id, - rememberMe ?? false, // Default to session cookie (expires on browser close) - clientIP, - userAgent - ); + // Issue JWT for authenticated user + const event = getH3Event(ctx); + const userAgent = getUserAgent(event); + await issueAuthToken({ + event, + userId: user.id, + rememberMe: rememberMe ?? false + }); - // Set CSRF token for authenticated session - setCSRFToken(getH3Event(ctx)); + // Set CSRF token for authenticated user + setCSRFToken(event); // Log successful login (wrap in try-catch to ensure it never blocks auth flow) try { @@ -1232,7 +1188,7 @@ export const authRouter = createTRPCRouter({ const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); const token = await new SignJWT({ email, - rememberMe: rememberMe ?? false, // Default to session cookie (expires on browser close) + rememberMe: rememberMe ?? false, // Default to browser session cookie code: loginCode }) .setProtectedHeader({ alg: "HS256" }) @@ -1624,72 +1580,66 @@ export const authRouter = createTRPCRouter({ } }), - refreshToken: publicProcedure - .input(refreshTokenSchema) - .mutation(async ({ ctx }) => { - try { - const event = getH3Event(ctx); - - // Step 1: Get current session from Vinxi - const session = await getAuthSession(event); - if (!session) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "No valid session found" - }); - } - - const authToken = getCookie(event, authCookieName); - - if (!authToken) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "No valid token found" - }); - } - - const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); - const { payload } = await jwtVerify(authToken, secret); - - if (!payload.sub) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid token" - }); - } - - await issueAuthToken({ - event, - userId: payload.sub as string, - rememberMe: ctx.input.rememberMe ?? false - }); - - setCSRFToken(event); - - return { - success: true, - message: "Token refreshed successfully" - }; - } catch (error) { - console.error("Token refresh error:", error); - - if (error instanceof TRPCError) { - throw error; - } + refreshToken: publicProcedure.mutation(async ({ ctx }) => { + try { + const event = getH3Event(ctx); + const authToken = getAuthTokenFromEvent(event); + if (!authToken) { throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Token refresh failed" + code: "UNAUTHORIZED", + message: "No valid token found" }); } - }), + + const payload = await verifyAuthToken(authToken); + + if (!payload) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid token" + }); + } + + const expiresIn = payload.exp + ? payload.exp - Math.floor(Date.now() / 1000) + : 0; + + const shortExpiry = expiryToSeconds(getAccessTokenExpiry()); + + await issueAuthToken({ + event, + userId: payload.sub, + rememberMe: expiresIn > shortExpiry + }); + + setCSRFToken(event); + + return { + success: true, + message: "Token refreshed successfully" + }; + } catch (error) { + console.error("Token refresh error:", error); + + if (error instanceof TRPCError) { + throw error; + } + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Token refresh failed" + }); + } + }), signOut: publicProcedure.mutation(async ({ ctx }) => { try { - const auth = await checkAuthStatus(getH3Event(ctx)); + const event = getH3Event(ctx); + const auth = await checkAuthStatus(event); if (auth.userId) { - const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); + const { ipAddress, userAgent } = getAuditContext(event); await logAuditEvent({ userId: auth.userId, eventType: "auth.logout", @@ -1699,87 +1649,12 @@ export const authRouter = createTRPCRouter({ success: true }); } + + clearAuthToken(event); } catch (e) { console.error("Error during signout:", e); } - clearAuthToken(getH3Event(ctx)); - return { success: true }; - }), - - // Admin endpoints for session management - cleanupSessions: publicProcedure.mutation(async ({ ctx }) => { - // Get user ID to check admin status - const userId = ctx.userId; - if (!userId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Authentication required" - }); - } - - // Import cleanup functions - const { cleanupExpiredSessions, cleanupOrphanedReferences } = - await import("~/server/token-cleanup"); - - try { - // Run cleanup - const stats = await cleanupExpiredSessions(); - const orphansFixed = await cleanupOrphanedReferences(); - - // Log admin action - const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); - await logAuditEvent({ - userId, - eventType: "admin.session_cleanup", - eventData: { - sessionsDeleted: stats.totalDeleted, - orphansFixed - }, - ipAddress, - userAgent, - success: true - }); - - return { - success: true, - sessionsDeleted: stats.totalDeleted, - expiredDeleted: stats.expiredDeleted, - revokedDeleted: stats.revokedDeleted, - orphansFixed - }; - } catch (error) { - console.error("Manual cleanup failed:", error); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Cleanup failed" - }); - } - }), - - getSessionStats: publicProcedure.query(async ({ ctx }) => { - // Get user ID to check admin status - const userId = ctx.userId; - if (!userId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Authentication required" - }); - } - - // Import stats function - const { getSessionStats } = await import("~/server/token-cleanup"); - - try { - const stats = await getSessionStats(); - return stats; - } catch (error) { - console.error("Failed to get session stats:", error); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to retrieve stats" - }); - } }) }); diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index b8325df..abd88e7 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -1,14 +1,10 @@ import { createTRPCRouter, publicProcedure } from "../utils"; import { TRPCError } from "@trpc/server"; import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils"; -import { setCookie } from "vinxi/http"; import type { User } from "~/db/types"; import { toUserProfile } from "~/types/user"; import { getUserProviders, unlinkProvider } from "~/server/provider-helpers"; import { z } from "zod"; -import { getAuthSession } from "~/server/session-helpers"; -import { logAuditEvent } from "~/server/audit"; -import { getClientIP, getUserAgent } from "~/server/security"; import { generatePasswordSetEmail } from "~/server/email-templates"; import { formatDeviceDescription } from "~/server/device-utils"; import sendEmail from "~/server/email"; @@ -405,119 +401,5 @@ export const userRouter = createTRPCRouter({ await unlinkProvider(userId, input.provider); return { success: true, message: "Provider unlinked" }; - }), - - getSessions: publicProcedure.query(async ({ ctx }) => { - const userId = ctx.userId; - - if (!userId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Not authenticated" - }); - } - - const conn = ConnectionFactory(); - const res = await conn.execute({ - sql: `SELECT id, token_family, created_at, expires_at, last_active_at, - rotation_count, ip_address, user_agent - FROM Session - WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now') - ORDER BY last_active_at DESC`, - args: [userId] - }); - - // Get current session to mark it - const currentSession = await getAuthSession(ctx.event as any); - - return res.rows.map((row: any) => { - // Infer rememberMe from expires_at duration - // If expires_at is > 2 days from creation, it's a remember-me session - const createdAt = new Date(row.created_at); - const expiresAt = new Date(row.expires_at); - const durationMs = expiresAt.getTime() - createdAt.getTime(); - const rememberMe = durationMs > 2 * 24 * 60 * 60 * 1000; // > 2 days - - return { - sessionId: row.id, - tokenFamily: row.token_family, - createdAt: row.created_at, - expiresAt: row.expires_at, - lastActiveAt: row.last_active_at, - rotationCount: row.rotation_count, - clientIp: row.ip_address, - userAgent: row.user_agent, - rememberMe, - isCurrent: currentSession?.sessionId === row.id - }; - }); - }), - - revokeSession: publicProcedure - .input( - z.object({ - sessionId: z.string() - }) - ) - .mutation(async ({ input, ctx }) => { - const userId = ctx.userId; - - if (!userId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Not authenticated" - }); - } - - const conn = ConnectionFactory(); - - // Verify session belongs to this user - const sessionCheck = await conn.execute({ - sql: "SELECT user_id, token_family FROM Session WHERE id = ?", - args: [input.sessionId] - }); - - if (sessionCheck.rows.length === 0) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Session not found" - }); - } - - const session = sessionCheck.rows[0] as any; - if (session.user_id !== userId) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Cannot revoke another user's session" - }); - } - - // Revoke the entire token family (all sessions on this device) - await conn.execute({ - sql: "UPDATE Session SET revoked = 1 WHERE token_family = ?", - args: [session.token_family] - }); - - // Log audit event - const h3Event = ctx.event.nativeEvent - ? ctx.event.nativeEvent - : (ctx.event as any); - const clientIP = getClientIP(h3Event); - const userAgent = getUserAgent(h3Event); - - await logAuditEvent({ - userId, - eventType: "auth.session_revoked", - eventData: { - sessionId: input.sessionId, - tokenFamily: session.token_family, - reason: "user_revoked" - }, - ipAddress: clientIP, - userAgent, - success: true - }); - - return { success: true, message: "Session revoked" }; }) }); diff --git a/src/server/api/utils.ts b/src/server/api/utils.ts index 4939320..20847c9 100644 --- a/src/server/api/utils.ts +++ b/src/server/api/utils.ts @@ -1,10 +1,9 @@ import { initTRPC, TRPCError } from "@trpc/server"; import type { APIEvent } from "@solidjs/start/server"; -import { getCookie } from "vinxi/http"; import { logVisit, enrichAnalyticsEntry } from "~/server/analytics"; import { getRequestIP } from "vinxi/http"; -import { getAuthSession } from "~/server/session-helpers"; import { verifyCairnToken } from "~/server/cairn-auth"; +import { getAuthPayloadFromEvent } from "~/server/auth"; export type Context = { event: APIEvent; @@ -14,15 +13,14 @@ export type Context = { }; async function createContextInner(event: APIEvent): Promise { - // Get auth session from Vinxi encrypted session - const session = await getAuthSession(event.nativeEvent); + const payload = await getAuthPayloadFromEvent(event.nativeEvent); let userId: string | null = null; let isAdmin = false; - if (session && session.userId) { - userId = session.userId; - isAdmin = session.isAdmin; + if (payload) { + userId = payload.sub; + isAdmin = payload.isAdmin; } const req = event.nativeEvent.node?.req || event.nativeEvent; @@ -38,7 +36,6 @@ async function createContextInner(event: APIEvent): Promise { event.request?.headers?.get("referer") || undefined; const ipAddress = getRequestIP(event.nativeEvent) || undefined; - const sessionId = getCookie(event.nativeEvent, "session_id") || undefined; const authHeader = event.request?.headers?.get("authorization") || req.headers?.authorization || @@ -65,8 +62,7 @@ async function createContextInner(event: APIEvent): Promise { method, userAgent, referrer, - ipAddress, - sessionId + ipAddress }) ); } diff --git a/src/server/audit.ts b/src/server/audit.ts index ace1fef..e48e522 100644 --- a/src/server/audit.ts +++ b/src/server/audit.ts @@ -24,14 +24,10 @@ export type AuditEventType = | "auth.oauth.github.failed" | "auth.oauth.google.success" | "auth.oauth.google.failed" - | "auth.session.revoke" - | "auth.session.revokeAll" | "security.rate_limit.exceeded" | "security.csrf.failed" | "security.suspicious.activity" - | "admin.action" - | "auth.session_created" - | "system.session_cleanup"; + | "admin.action"; /** * Audit log entry structure @@ -246,7 +242,6 @@ export async function getUserSecuritySummary( lastLoginAt: string | null; lastLoginIp: string | null; uniqueIpCount: number; - recentSessions: number; }> { const conn = ConnectionFactory(); @@ -336,16 +331,6 @@ export async function getUserSecuritySummary( }); const uniqueIpCount = (ipResult.rows[0]?.count as number) || 0; - const sessionResult = await conn.execute({ - sql: `SELECT COUNT(*) as count FROM AuditLog - WHERE user_id = ? - AND event_type = 'auth.login.success' - AND success = 1 - AND created_at >= datetime('now', '-1 day')`, - args: [userId] - }); - const recentSessions = (sessionResult.rows[0]?.count as number) || 0; - return { totalEvents, successfulEvents, @@ -356,8 +341,7 @@ export async function getUserSecuritySummary( failedLogins, lastLoginAt: lastLogin?.created_at as string | null, lastLoginIp: lastLogin?.ip_address as string | null, - uniqueIpCount, - recentSessions + uniqueIpCount }; } diff --git a/src/server/auth.ts b/src/server/auth.ts index 43d2e80..4c108ba 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -1,8 +1,136 @@ import type { H3Event } from "vinxi/http"; +import { getCookie, setCookie } from "vinxi/http"; import { OAuth2Client } from "google-auth-library"; import type { Row } from "@libsql/client/web"; +import { SignJWT, jwtVerify } from "jose"; import { env } from "~/env/server"; -import { getAuthSession } from "./session-helpers"; +import { ConnectionFactory } from "./database"; +import { AUTH_CONFIG, expiryToSeconds, getAccessTokenExpiry } from "~/config"; + +export const authCookieName = "auth_token"; + +type AuthTokenPayload = { + sub: string; + email: string | null; + isAdmin: boolean; + iat?: number; + exp?: number; +}; + +function getAuthCookieOptions(rememberMe: boolean) { + return { + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "lax" as const, + path: "/", + maxAge: rememberMe + ? expiryToSeconds(AUTH_CONFIG.ACCESS_TOKEN_EXPIRY_LONG) + : undefined + }; +} + +function getAuthHeaderToken(event: H3Event): string | null { + const requestHeader = event.request?.headers?.get?.("authorization") || null; + const eventHeader = event.headers + ? typeof (event.headers as any).get === "function" + ? (event.headers as any).get("authorization") + : (event.headers as any).authorization + : null; + const nodeHeader = event.node?.req?.headers?.authorization || null; + const header = requestHeader || eventHeader || nodeHeader || null; + if (!header) return null; + const normalized = header.trim(); + if (!normalized.toLowerCase().startsWith("bearer ")) return null; + return normalized.slice("Bearer ".length).trim(); +} + +export function getAuthTokenFromEvent(event: H3Event): string | null { + return getCookie(event, authCookieName) || getAuthHeaderToken(event); +} + +export async function verifyAuthToken( + token: string +): Promise { + try { + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); + const { payload } = await jwtVerify(token, secret); + if (!payload.sub) { + return null; + } + return { + sub: payload.sub as string, + email: (payload.email as string | null) ?? null, + isAdmin: (payload.isAdmin as boolean) ?? false, + iat: payload.iat, + exp: payload.exp + }; + } catch (error) { + console.error("Auth token verification failed:", error); + return null; + } +} + +export async function getAuthPayloadFromEvent( + event: H3Event +): Promise { + const token = getAuthTokenFromEvent(event); + if (!token) return null; + return verifyAuthToken(token); +} + +export async function issueAuthToken({ + event, + userId, + rememberMe +}: { + event: H3Event; + userId: string; + rememberMe: boolean; +}): Promise { + const conn = ConnectionFactory(); + const result = await conn.execute({ + sql: "SELECT email, is_admin FROM User WHERE id = ?", + args: [userId] + }); + + if (result.rows.length === 0) { + throw new Error("User not found"); + } + + const row = result.rows[0] as { email?: string | null; is_admin?: number }; + const isAdmin = row.is_admin === 1; + const email = row.email ?? null; + + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); + const expiry = rememberMe + ? AUTH_CONFIG.ACCESS_TOKEN_EXPIRY_LONG + : getAccessTokenExpiry(); + + const token = await new SignJWT({ email, isAdmin }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(userId) + .setIssuedAt() + .setExpirationTime(expiry) + .sign(secret); + + setCookie(event, authCookieName, token, getAuthCookieOptions(rememberMe)); + + return token; +} + +export function clearAuthToken(event: H3Event): void { + setCookie(event, authCookieName, "", { + ...getAuthCookieOptions(true), + maxAge: 0 + }); + setCookie(event, "csrf-token", "", { + httpOnly: false, + secure: env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 0 + }); +} /** * Check authentication status @@ -15,9 +143,8 @@ export async function checkAuthStatus(event: H3Event): Promise<{ isAdmin: boolean; }> { try { - const session = await getAuthSession(event); - - if (!session || !session.userId) { + const payload = await getAuthPayloadFromEvent(event); + if (!payload) { return { isAuthenticated: false, userId: null, @@ -27,8 +154,8 @@ export async function checkAuthStatus(event: H3Event): Promise<{ return { isAuthenticated: true, - userId: session.userId, - isAdmin: session.isAdmin + userId: payload.sub, + isAdmin: payload.isAdmin }; } catch (error) { console.error("Auth check error:", error); @@ -41,7 +168,7 @@ export async function checkAuthStatus(event: H3Event): Promise<{ } /** - * Get user ID from session + * Get user ID from auth token * @param event - H3Event * @returns User ID or null if not authenticated */ @@ -67,10 +194,8 @@ export async function validateLineageRequest({ const { provider, email } = userRow; if (provider === "email") { try { - const { jwtVerify } = await import("jose"); - const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); - const { payload } = await jwtVerify(auth_token, secret); - if (email !== payload.email) { + const payload = await verifyAuthToken(auth_token); + if (!payload || email !== payload.email) { return false; } } catch (err) { diff --git a/src/server/device-utils.ts b/src/server/device-utils.ts index 2bfc41f..df076ea 100644 --- a/src/server/device-utils.ts +++ b/src/server/device-utils.ts @@ -88,7 +88,7 @@ export function formatDeviceDescription(deviceInfo: DeviceInfo): string { /** * Create a short device fingerprint for comparison - * Not cryptographic, just for grouping similar sessions + * Not cryptographic, just for grouping similar logins * @param deviceInfo - Device information * @returns Short fingerprint string */ diff --git a/src/server/email-templates/new-device-login.html b/src/server/email-templates/new-device-login.html index 8e737b7..9354f9a 100644 --- a/src/server/email-templates/new-device-login.html +++ b/src/server/email-templates/new-device-login.html @@ -94,7 +94,6 @@ color: #856404; " > -
  • Revoke all active sessions
  • Change your password
  • Review linked authentication providers
  • diff --git a/src/server/security/csrf.test.ts b/src/server/security/csrf.test.ts index a5bd3d3..43a1a97 100644 --- a/src/server/security/csrf.test.ts +++ b/src/server/security/csrf.test.ts @@ -167,7 +167,7 @@ describe("CSRF Protection", () => { expect(isValid).toBe(false); }); - it("should prevent token reuse from different session", () => { + it("should prevent token reuse from different login", () => { const token1 = generateCSRFToken(); const token2 = generateCSRFToken(); diff --git a/src/server/session-config.ts b/src/server/session-config.ts deleted file mode 100644 index 0db9a13..0000000 --- a/src/server/session-config.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { SessionConfig } from "vinxi/http"; -import { AUTH_CONFIG, expiryToSeconds } from "~/config"; - -/** - * Session data stored in encrypted cookie - * This is synced with database Session table for serverless persistence - */ -export interface SessionData { - /** User ID */ - userId: string; - /** Session ID for database lookup and revocation */ - sessionId: string; - /** Token family for rotation chain tracking */ - tokenFamily: string; - /** Whether user is admin (cached from DB) */ - isAdmin: boolean; - /** Refresh token for rotation (opaque, hashed in DB) */ - refreshToken: string; - /** Remember me preference for session duration */ - rememberMe: boolean; -} - -/** - * Get session password directly from process.env - * This avoids any bundler-time substitution issues with the validated env object - */ -function getSessionPassword(): string { - // Read directly from process.env at runtime, not from bundled env object - const password = process.env.JWT_SECRET_KEY; - if (!password || password.trim() === "") { - console.error( - `[SessionConfig] JWT_SECRET_KEY missing from process.env! Keys available:`, - Object.keys(process.env) - .filter((k) => k.includes("JWT") || k.includes("SECRET")) - .join(", ") || "none matching JWT/SECRET" - ); - throw new Error( - `JWT_SECRET_KEY is empty at runtime. Ensure it is set as a runtime environment variable in Vercel (not just build-time).` - ); - } - return password; -} - -/** - * Get session config with runtime password validation - * Returns a fresh config each time to ensure env vars are read at call time, - * not at module load time (important for serverless cold starts) - */ -export function getSessionConfig(): SessionConfig { - return { - password: getSessionPassword(), - name: "session", - cookie: { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/" - } - }; -} - -/** - * Vinxi session configuration - * Using a getter ensures password is evaluated at access time, not module load time - */ -export const sessionConfig: SessionConfig = { - get password() { - return getSessionPassword(); - }, - name: "session", - cookie: { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/" - } -}; - -/** - * Get session cookie options with appropriate maxAge - * @param rememberMe - Whether to use extended session duration - */ -export function getSessionCookieOptions(rememberMe: boolean) { - return { - ...sessionConfig.cookie, - maxAge: rememberMe - ? expiryToSeconds(AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG) - : undefined // Session cookie (expires on browser close) - }; -} diff --git a/src/server/session-helpers.ts b/src/server/session-helpers.ts deleted file mode 100644 index 0ab9069..0000000 --- a/src/server/session-helpers.ts +++ /dev/null @@ -1,873 +0,0 @@ -import { v4 as uuidV4 } from "uuid"; -import { createHash, randomBytes, timingSafeEqual } from "crypto"; -import type { H3Event } from "vinxi/http"; -import { - clearSession, - getSession, - getCookie, - setCookie, - updateSession -} from "vinxi/http"; -import { ConnectionFactory } from "./database"; -import { env } from "~/env/server"; -import { AUTH_CONFIG, expiryToSeconds, CACHE_CONFIG } from "~/config"; -import { logAuditEvent } from "./audit"; -import type { SessionData } from "./session-config"; -import { sessionConfig, getSessionConfig } from "./session-config"; -import { getDeviceInfo } from "./device-utils"; -import { cache } from "./cache"; - -/** - * In-memory throttle for session activity updates - * Tracks last update time per session to avoid excessive DB writes - * In serverless, this is per-instance, but that's fine - updates are best-effort - */ -const sessionUpdateTimestamps = new Map(); - -/** - * Update session activity (last_used, last_active_at) with throttling - * Only updates DB if > SESSION_ACTIVITY_UPDATE_THRESHOLD_MS since last update - * Reduces 6,210 writes/period to ~60-100 writes (95%+ reduction) - * - * Security: Still secure - session validation happens every request (DB read) - * UX: Session activity timestamps within 5min accuracy is acceptable - * - * @param sessionId - Session ID to update - */ -async function updateSessionActivityThrottled( - sessionId: string -): Promise { - const now = Date.now(); - const lastUpdate = sessionUpdateTimestamps.get(sessionId) || 0; - const timeSinceLastUpdate = now - lastUpdate; - - // Skip DB update if we updated recently - if (timeSinceLastUpdate < CACHE_CONFIG.SESSION_ACTIVITY_UPDATE_THRESHOLD_MS) { - return; - } - - // Update timestamp tracker - sessionUpdateTimestamps.set(sessionId, now); - - // Cleanup old entries (prevent memory leak in long-running instances) - if (sessionUpdateTimestamps.size > 1000) { - const oldestAllowed = - now - 2 * CACHE_CONFIG.SESSION_ACTIVITY_UPDATE_THRESHOLD_MS; - for (const [sid, timestamp] of sessionUpdateTimestamps.entries()) { - if (timestamp < oldestAllowed) { - sessionUpdateTimestamps.delete(sid); - } - } - } - - // Perform DB update - const conn = ConnectionFactory(); - await conn.execute({ - sql: "UPDATE Session SET last_used = datetime('now'), last_active_at = datetime('now') WHERE id = ?", - args: [sessionId] - }); -} - -/** - * Generate a cryptographically secure refresh token - * @returns Base64URL-encoded random token (32 bytes = 256 bits) - */ -export function generateRefreshToken(): string { - return randomBytes(32).toString("base64url"); -} - -/** - * Hash refresh token for storage (one-way hash) - * Using SHA-256 since refresh tokens are high-entropy random values - * @param token - Plaintext refresh token - * @returns Hex-encoded hash - */ -export function hashRefreshToken(token: string): string { - return createHash("sha256").update(token).digest("hex"); -} - -/** - * Create a new session in database and Vinxi session - * @param event - H3Event - * @param userId - User ID - * @param rememberMe - Whether to use extended session duration - * @param ipAddress - Client IP address - * @param userAgent - Client user agent string - * @param parentSessionId - ID of parent session if this is a rotation (null for new sessions) - * @param tokenFamily - Token family UUID for rotation chain (generated if null) - * @returns Session data - */ -export async function createAuthSession( - event: H3Event, - userId: string, - rememberMe: boolean, - ipAddress: string, - userAgent: string, - parentSessionId: string | null = null, - tokenFamily: string | null = null -): Promise { - const conn = ConnectionFactory(); - - // Fetch is_admin from database - const userResult = await conn.execute({ - sql: "SELECT is_admin FROM User WHERE id = ?", - args: [userId] - }); - - if (userResult.rows.length === 0) { - throw new Error(`User not found: ${userId}`); - } - - const isAdmin = userResult.rows[0].is_admin === 1; - - const sessionId = uuidV4(); - const family = tokenFamily || uuidV4(); - const refreshToken = generateRefreshToken(); - const tokenHash = hashRefreshToken(refreshToken); - - // Parse device information - const deviceInfo = getDeviceInfo(event); - - // Calculate refresh token expiration - const refreshExpiry = rememberMe - ? AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG - : AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_SHORT; - - const expiresAt = new Date(); - if (refreshExpiry.endsWith("d")) { - const days = parseInt(refreshExpiry); - expiresAt.setDate(expiresAt.getDate() + days); - } else if (refreshExpiry.endsWith("h")) { - const hours = parseInt(refreshExpiry); - expiresAt.setHours(expiresAt.getHours() + hours); - } - - // Calculate access token expiry - const accessExpiresAt = new Date(); - const accessExpiry = - env.NODE_ENV === "production" - ? AUTH_CONFIG.ACCESS_TOKEN_EXPIRY - : AUTH_CONFIG.ACCESS_TOKEN_EXPIRY_DEV; - - if (accessExpiry.endsWith("m")) { - const minutes = parseInt(accessExpiry); - accessExpiresAt.setMinutes(accessExpiresAt.getMinutes() + minutes); - } else if (accessExpiry.endsWith("h")) { - const hours = parseInt(accessExpiry); - accessExpiresAt.setHours(accessExpiresAt.getHours() + hours); - } - - // Get rotation count from parent if exists - let rotationCount = 0; - if (parentSessionId) { - const parentResult = await conn.execute({ - sql: "SELECT rotation_count FROM Session WHERE id = ?", - args: [parentSessionId] - }); - if (parentResult.rows.length > 0) { - rotationCount = (parentResult.rows[0].rotation_count as number) + 1; - } - } - - // Insert session into database with device metadata - await conn.execute({ - sql: `INSERT INTO Session - (id, user_id, token_family, refresh_token_hash, parent_session_id, - rotation_count, expires_at, access_token_expires_at, ip_address, user_agent, - device_name, device_type, browser, os, last_active_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`, - args: [ - sessionId, - userId, - family, - tokenHash, - parentSessionId, - rotationCount, - expiresAt.toISOString(), - accessExpiresAt.toISOString(), - ipAddress, - userAgent, - deviceInfo.deviceName || null, - deviceInfo.deviceType || null, - deviceInfo.browser || null, - deviceInfo.os || null - ] - }); - - // Create session data - const sessionData: SessionData = { - userId, - sessionId, - tokenFamily: family, - isAdmin, - refreshToken, - rememberMe - }; - - console.log("[Session Create] Creating session with data:", { - userId, - sessionId, - isAdmin, - hasRefreshToken: !!refreshToken, - rememberMe - }); - - // Update Vinxi session with dynamic maxAge based on rememberMe - // Use getSessionConfig() to ensure password is read at runtime - const baseConfig = getSessionConfig(); - const configWithMaxAge = { - ...baseConfig, - maxAge: rememberMe - ? expiryToSeconds(AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG) - : undefined // Session cookie (expires on browser close) - }; - - const session = await updateSession(event, configWithMaxAge, sessionData); - - // Explicitly seal/flush the session to ensure cookie is written - // This is important in serverless environments where response might stream early - const { sealSession } = await import("vinxi/http"); - await sealSession(event, configWithMaxAge); - - setCookie(event, "session_id", sessionId, { - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: configWithMaxAge.maxAge - }); - - try { - const cookieName = sessionConfig.name || "session"; - const cookieValue = getCookie(event, cookieName); - - const verifySession = await getSession( - event, - configWithMaxAge - ); - } catch (verifyError) { - console.error("[Session Create] Failed to verify session:", verifyError); - } - - // Log audit event - await logAuditEvent({ - userId, - eventType: "auth.session_created", - eventData: { - sessionId, - tokenFamily: family, - rememberMe, - parentSessionId, - deviceName: deviceInfo.deviceName, - deviceType: deviceInfo.deviceType - }, - success: true - }); - - return sessionData; -} - -/** - * Get current session from Vinxi and validate against database - * @param event - H3Event - * @param skipUpdate - If true, don't update the session cookie (for SSR contexts) - * @returns Session data or null if invalid/expired - */ -export async function getAuthSession( - event: H3Event, - skipUpdate = false -): Promise { - try { - // In SSR contexts where headers may already be sent, use unsealSession directly - if (skipUpdate) { - const { unsealSession } = await import("vinxi/http"); - const cookieName = sessionConfig.name || "session"; - const cookieValue = getCookie(event, cookieName); - - if (!cookieValue) { - return null; - } - - try { - // unsealSession returns Partial>, not T directly - const session = await unsealSession(event, sessionConfig, cookieValue); - - if (!session?.data || typeof session.data !== "object") { - const sessionIdCookie = getCookie(event, "session_id"); - if (sessionIdCookie) { - const restored = await restoreSessionFromDB(event, sessionIdCookie); - if (restored) { - return restored; - } - } - return null; - } - - const data = session.data as SessionData; - - if (!data.userId || !data.sessionId) { - const sessionIdCookie = getCookie(event, "session_id"); - - if (sessionIdCookie) { - const restored = await restoreSessionFromDB(event, sessionIdCookie); - if (restored) { - return restored; - } else { - } - } - - return null; - } - - // Validate session against database - const isValid = await validateSessionInDB( - data.sessionId, - data.userId, - data.refreshToken - ); - - return isValid ? data : null; - } catch (err) { - console.error( - "[Session Get] Error in skipUpdate path (likely decryption failure):", - err - ); - // If decryption failed (after server restart), try DB restoration - const sessionIdCookie = getCookie(event, "session_id"); - if (sessionIdCookie) { - const restored = await restoreSessionFromDB(event, sessionIdCookie); - if (restored) { - return restored; - } else { - } - } - return null; - } - } - - // Normal path - allow session updates - - const session = await getSession(event, sessionConfig); - - if (!session.data || !session.data.userId || !session.data.sessionId) { - // Fallback: Try to restore from DB using session_id cookie - const sessionIdCookie = getCookie(event, "session_id"); - - if (sessionIdCookie) { - const restored = await restoreSessionFromDB(event, sessionIdCookie); - if (restored) { - return restored; - } - } - - return null; - } - - // Validate session against database - const isValid = await validateSessionInDB( - session.data.sessionId, - session.data.userId, - session.data.refreshToken - ); - - if (!isValid) { - // Clear invalid session - wrap in try/catch for headers-sent error - try { - await clearSession(event, sessionConfig); - } catch (clearError: any) { - // If headers already sent, we can't clear the cookie, but that's OK - // The session is invalid in DB anyway - if (clearError?.code !== "ERR_HTTP_HEADERS_SENT") { - throw clearError; - } - } - return null; - } - - return session.data; - } catch (error: any) { - // If headers already sent, we can't read the session cookie properly - // This can happen in SSR when response streaming has started - if (error?.code === "ERR_HTTP_HEADERS_SENT") { - // Retry with skipUpdate - return getAuthSession(event, true); - } - console.error("Error getting auth session:", error); - return null; - } -} - -/** - * Find the latest valid session in a rotation chain - * Recursively follows child sessions until finding the most recent one - * @param conn - Database connection - * @param sessionId - Starting session ID - * @param maxDepth - Maximum depth to traverse (prevents infinite loops) - * @returns Latest session row or null if chain is invalid - */ -async function findLatestSessionInChain( - conn: ReturnType, - sessionId: string, - maxDepth: number = 100 -): Promise { - if (maxDepth <= 0) { - return null; - } - - // Get the current session - const result = await conn.execute({ - sql: `SELECT id, user_id, token_family, revoked, expires_at - FROM Session - WHERE id = ?`, - args: [sessionId] - }); - - if (result.rows.length === 0) { - return null; - } - - const currentSession = result.rows[0]; - - // Check if this session has been rotated (has a child) - const childCheck = await conn.execute({ - sql: `SELECT id FROM Session - WHERE parent_session_id = ? - ORDER BY created_at DESC - LIMIT 1`, - args: [sessionId] - }); - - if (childCheck.rows.length > 0) { - return findLatestSessionInChain( - conn, - childCheck.rows[0].id as string, - maxDepth - 1 - ); - } - - if (currentSession.revoked === 1) { - return null; - } - - const expiresAt = new Date(currentSession.expires_at as string); - if (expiresAt < new Date()) { - return null; - } - - return currentSession; -} - -/** - * Restore session from database when cookie data is empty/corrupt - * This provides a fallback mechanism for session recovery - * @param event - H3Event - * @param sessionId - Session ID from fallback cookie - * @returns Session data or null if cannot restore - */ -async function restoreSessionFromDB( - event: H3Event, - sessionId: string -): Promise { - try { - const conn = ConnectionFactory(); - - const { getRequestIP } = await import("vinxi/http"); - const ipAddress = getRequestIP(event) || "unknown"; - const userAgent = event.node?.req?.headers["user-agent"] || "unknown"; - - const result = await conn.execute({ - sql: `SELECT s.id, s.user_id, s.token_family, s.refresh_token_hash, - s.revoked, s.expires_at, u.is_admin - FROM Session s - JOIN User u ON s.user_id = u.id - WHERE s.id = ?`, - args: [sessionId] - }); - - if (result.rows.length === 0) { - return null; - } - - const dbSession = result.rows[0]; - - const expiresAt = new Date(dbSession.expires_at as string); - if (expiresAt < new Date()) { - return null; - } - - // Check if this session has already been rotated (has a child session) - // If so, follow the chain to find the latest valid session - // We check this BEFORE checking if revoked because revoked parents can have valid children - const childCheck = await conn.execute({ - sql: `SELECT id, revoked, expires_at, refresh_token_hash - FROM Session - WHERE parent_session_id = ? - ORDER BY created_at DESC - LIMIT 1`, - args: [sessionId] - }); - - if (childCheck.rows.length > 0) { - const latestSession = await findLatestSessionInChain( - conn, - childCheck.rows[0].id as string - ); - - if (!latestSession) { - return null; - } - - const newSession = await createAuthSession( - event, - latestSession.user_id as string, - true, // Assume rememberMe=true for restoration - ipAddress, - userAgent, - latestSession.id as string, // Parent is the latest session - latestSession.token_family as string // Reuse family - ); - - await conn.execute({ - sql: "UPDATE Session SET revoked = 1 WHERE id = ?", - args: [latestSession.id] - }); - - return newSession; - } - - // No children - this is the current session - // Validate it's not revoked (if no children, revoked = invalid) - if (dbSession.revoked === 1) { - return null; - } - - // We can't restore the refresh token (it's hashed in DB) - - const newSession = await createAuthSession( - event, - dbSession.user_id as string, - true, // Assume rememberMe=true for restoration - ipAddress, - userAgent, - sessionId, // Parent session - dbSession.token_family as string // Reuse family - ); - - // Mark parent session as revoked now that we've rotated it - await conn.execute({ - sql: "UPDATE Session SET revoked = 1 WHERE id = ?", - args: [sessionId] - }); - - return newSession; - } catch (error) { - console.error("[Session Restore] Error restoring session:", error); - return null; - } -} - -/** - * Validate session against database - * Checks if session exists, not revoked, not expired, and refresh token matches - * Also validates that no child sessions exist (indicating this session was rotated) - * @param sessionId - Session ID - * @param userId - User ID - * @param refreshToken - Plaintext refresh token - * @returns true if valid, false otherwise - */ -async function validateSessionInDB( - sessionId: string, - userId: string, - refreshToken: string -): Promise { - try { - const conn = ConnectionFactory(); - const tokenHash = hashRefreshToken(refreshToken); - - const result = await conn.execute({ - sql: `SELECT revoked, expires_at, refresh_token_hash, token_family - FROM Session - WHERE id = ? AND user_id = ?`, - args: [sessionId, userId] - }); - - if (result.rows.length === 0) { - return false; - } - - const session = result.rows[0]; - - // Check if revoked - if (session.revoked === 1) { - return false; - } - - // Check if expired - const expiresAt = new Date(session.expires_at as string); - if (expiresAt < new Date()) { - return false; - } - - // Validate refresh token hash (timing-safe comparison) - const storedHash = session.refresh_token_hash as string; - if ( - !timingSafeEqual( - Buffer.from(tokenHash, "hex"), - Buffer.from(storedHash, "hex") - ) - ) { - return false; - } - - // CRITICAL: Check if this session has been rotated (has a child session) - // If a child exists, check if we're within the grace period for cookie propagation - // This handles SSR/serverless cases where client may not have received new cookies yet - const childCheck = await conn.execute({ - sql: `SELECT id, created_at FROM Session - WHERE parent_session_id = ? - ORDER BY created_at DESC - LIMIT 1`, - args: [sessionId] - }); - - if (childCheck.rows.length > 0) { - // This session has been rotated - check grace period - const childSession = childCheck.rows[0]; - const childCreatedAt = new Date(childSession.created_at as string); - const now = new Date(); - const timeSinceRotation = now.getTime() - childCreatedAt.getTime(); - - // Grace period allows client to receive and use new cookies from rotation - // This is critical for SSR/serverless where response cookies may be delayed - if (timeSinceRotation >= AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS) { - return false; - } - } - - // Update last_used and last_active_at timestamps (throttled) - // Only update DB if last update was > 5 minutes ago (reduces writes by 95%+) - updateSessionActivityThrottled(sessionId).catch((err) => - console.error("Failed to update session timestamps:", err) - ); - - return true; - } catch (error) { - console.error("Session validation error:", error); - return false; - } -} - -/** - * Invalidate a specific session in database and clear Vinxi session - * @param event - H3Event - * @param sessionId - Session ID to invalidate - */ -export async function invalidateAuthSession( - event: H3Event, - sessionId: string -): Promise { - const conn = ConnectionFactory(); - - await conn.execute({ - sql: "UPDATE Session SET revoked = 1 WHERE id = ?", - args: [sessionId] - }); - - await clearSession(event, sessionConfig); - - // Also clear the session_id fallback cookie - setCookie(event, "session_id", "", { - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: 0 // Expire immediately - }); -} - -/** - * Revoke all sessions in a token family - * Used when breach is detected (token reuse) - * @param tokenFamily - Token family ID to revoke - * @param reason - Reason for revocation (for audit) - */ -export async function revokeTokenFamily( - tokenFamily: string, - reason: string = "breach_detected" -): Promise { - const conn = ConnectionFactory(); - - // Get all sessions in family for audit log - const sessions = await conn.execute({ - sql: "SELECT id, user_id FROM Session WHERE token_family = ? AND revoked = 0", - args: [tokenFamily] - }); - - // Revoke all sessions in family - - await conn.execute({ - sql: "UPDATE Session SET revoked = 1 WHERE token_family = ?", - args: [tokenFamily] - }); - - // Log audit events for each affected session - for (const session of sessions.rows) { - await logAuditEvent({ - userId: session.user_id as string, - eventType: "auth.token_family_revoked", - eventData: { - tokenFamily, - sessionId: session.id as string, - reason - }, - success: true - }); - } -} - -/** - * Detect if a token is being reused after rotation - * Implements grace period for race conditions - * @param sessionId - Session ID being validated - * @returns true if reuse detected (and revocation occurred), false otherwise - */ -export async function detectTokenReuse(sessionId: string): Promise { - const conn = ConnectionFactory(); - - // Check if this session has already been rotated (has child session) - const childCheck = await conn.execute({ - sql: `SELECT id, created_at FROM Session - WHERE parent_session_id = ? - ORDER BY created_at DESC - LIMIT 1`, - args: [sessionId] - }); - - if (childCheck.rows.length === 0) { - // No child session, this is legitimate first use - return false; - } - - const childSession = childCheck.rows[0]; - const childCreatedAt = new Date(childSession.created_at as string); - const now = new Date(); - const timeSinceRotation = now.getTime() - childCreatedAt.getTime(); - - // Grace period for race conditions - if (timeSinceRotation < AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS) { - return false; - } - - // Reuse detected outside grace period - this is a breach! - - // Get token family and revoke entire family - const sessionInfo = await conn.execute({ - sql: "SELECT token_family, user_id FROM Session WHERE id = ?", - args: [sessionId] - }); - - if (sessionInfo.rows.length > 0) { - const tokenFamily = sessionInfo.rows[0].token_family as string; - const userId = sessionInfo.rows[0].user_id as string; - - await revokeTokenFamily(tokenFamily, "token_reuse_detected"); - - // Log critical security event - await logAuditEvent({ - userId, - eventType: "auth.token_reuse_detected", - eventData: { - sessionId, - tokenFamily, - timeSinceRotation - }, - success: false - }); - - return true; - } - - return false; -} - -/** - * Rotate refresh token: invalidate old, issue new tokens - * Implements automatic breach detection - * @param event - H3Event - * @param oldSessionData - Current session data - * @param ipAddress - Client IP address for new session - * @param userAgent - Client user agent for new session - * @returns New session data or null if rotation fails - */ -export async function rotateAuthSession( - event: H3Event, - oldSessionData: SessionData, - ipAddress: string, - userAgent: string -): Promise { - // Validate old session exists in DB - const isValid = await validateSessionInDB( - oldSessionData.sessionId, - oldSessionData.userId, - oldSessionData.refreshToken - ); - - if (!isValid) { - return null; - } - - // Detect token reuse (breach detection) - const reuseDetected = await detectTokenReuse(oldSessionData.sessionId); - if (reuseDetected) { - return null; - } - - // Check rotation limit - const conn = ConnectionFactory(); - const sessionCheck = await conn.execute({ - sql: "SELECT rotation_count FROM Session WHERE id = ?", - args: [oldSessionData.sessionId] - }); - - if (sessionCheck.rows.length === 0) { - return null; - } - - const rotationCount = sessionCheck.rows[0].rotation_count as number; - if (rotationCount >= AUTH_CONFIG.MAX_ROTATION_COUNT) { - await invalidateAuthSession(event, oldSessionData.sessionId); - return null; - } - - // Create new session (linked to old via parent_session_id) - const newSessionData = await createAuthSession( - event, - oldSessionData.userId, - oldSessionData.rememberMe, - ipAddress, - userAgent, - oldSessionData.sessionId, // parent session - oldSessionData.tokenFamily // reuse family - ); - - // Invalidate old session - await conn.execute({ - sql: "UPDATE Session SET revoked = 1 WHERE id = ?", - args: [oldSessionData.sessionId] - }); - - // Log rotation event - await logAuditEvent({ - userId: oldSessionData.userId, - eventType: "auth.token_rotated", - eventData: { - oldSessionId: oldSessionData.sessionId, - newSessionId: newSessionData.sessionId, - tokenFamily: oldSessionData.tokenFamily, - rotationCount: rotationCount + 1 - }, - success: true - }); - - return newSessionData; -} diff --git a/src/server/session-management.ts b/src/server/session-management.ts deleted file mode 100644 index 96b4907..0000000 --- a/src/server/session-management.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { ConnectionFactory } from "./database"; -import type { Session } from "~/db/types"; -import { formatDeviceDescription } from "./device-utils"; - -/** - * Get all active sessions for a user - * @param userId - User ID - * @returns Array of active sessions with formatted device info - */ -export async function getUserActiveSessions(userId: string): Promise< - Array<{ - sessionId: string; - deviceDescription: string; - deviceType?: string; - browser?: string; - os?: string; - ipAddress?: string; - lastActive: string; - createdAt: string; - current: boolean; - }> -> { - const conn = ConnectionFactory(); - - const result = await conn.execute({ - sql: `SELECT - id, device_name, device_type, browser, os, - ip_address, last_active_at, created_at, token_family - FROM Session - WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now') - ORDER BY last_active_at DESC`, - args: [userId] - }); - - return result.rows.map((row: any) => { - const deviceInfo = { - deviceName: row.device_name, - deviceType: row.device_type, - browser: row.browser, - os: row.os - }; - - return { - sessionId: row.id, - deviceDescription: formatDeviceDescription(deviceInfo), - deviceType: row.device_type, - browser: row.browser, - os: row.os, - ipAddress: row.ip_address, - lastActive: row.last_active_at, - createdAt: row.created_at, - current: false // Will be set by caller if needed - }; - }); -} - -/** - * Revoke a specific session (not entire token family) - * Useful for "logout from this device" functionality - * @param userId - User ID (for verification) - * @param sessionId - Session ID to revoke - * @throws Error if session not found or doesn't belong to user - */ -export async function revokeUserSession( - userId: string, - sessionId: string -): Promise { - const conn = ConnectionFactory(); - - // Verify session belongs to user - const verifyResult = await conn.execute({ - sql: "SELECT user_id FROM Session WHERE id = ?", - args: [sessionId] - }); - - if (verifyResult.rows.length === 0) { - throw new Error("Session not found"); - } - - const sessionUserId = (verifyResult.rows[0] as any).user_id; - if (sessionUserId !== userId) { - throw new Error("Session does not belong to this user"); - } - - // Revoke the session - await conn.execute({ - sql: "UPDATE Session SET revoked = 1 WHERE id = ?", - args: [sessionId] - }); -} - -/** - * Revoke all sessions for a user EXCEPT the current one - * Useful for "logout from all other devices" - * @param userId - User ID - * @param currentSessionId - Current session ID to keep active - * @returns Number of sessions revoked - */ -export async function revokeOtherUserSessions( - userId: string, - currentSessionId: string -): Promise { - const conn = ConnectionFactory(); - - const result = await conn.execute({ - sql: "UPDATE Session SET revoked = 1 WHERE user_id = ? AND id != ? AND revoked = 0", - args: [userId, currentSessionId] - }); - - return (result as any).rowsAffected || 0; -} - -/** - * Get session count by device type for a user - * @param userId - User ID - * @returns Object with counts by device type - */ -export async function getSessionCountByDevice(userId: string): Promise<{ - desktop: number; - mobile: number; - tablet: number; - unknown: number; - total: number; -}> { - const conn = ConnectionFactory(); - - const result = await conn.execute({ - sql: `SELECT - device_type, - COUNT(*) as count - FROM Session - WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now') - GROUP BY device_type`, - args: [userId] - }); - - const counts = { - desktop: 0, - mobile: 0, - tablet: 0, - unknown: 0, - total: 0 - }; - - for (const row of result.rows) { - const deviceType = (row as any).device_type; - const count = (row as any).count; - - if (deviceType === "desktop") { - counts.desktop = count; - } else if (deviceType === "mobile") { - counts.mobile = count; - } else if (deviceType === "tablet") { - counts.tablet = count; - } else { - counts.unknown = count; - } - - counts.total += count; - } - - return counts; -} - -/** - * Check if a specific device fingerprint already has an active session - * Can be used to show "You're already logged in on this device" messages - * @param userId - User ID - * @param deviceType - Device type - * @param browser - Browser name - * @param os - OS name - * @returns true if device has active session - */ -export async function hasActiveSessionOnDevice( - userId: string, - deviceType?: string, - browser?: string, - os?: string -): Promise { - const conn = ConnectionFactory(); - - const result = await conn.execute({ - sql: `SELECT id FROM Session - WHERE user_id = ? - AND device_type = ? - AND browser = ? - AND os = ? - AND revoked = 0 - AND expires_at > datetime('now') - LIMIT 1`, - args: [userId, deviceType || null, browser || null, os || null] - }); - - return result.rows.length > 0; -} diff --git a/src/server/token-cleanup.ts b/src/server/token-cleanup.ts deleted file mode 100644 index 640c2a6..0000000 --- a/src/server/token-cleanup.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { ConnectionFactory } from "~/server/utils"; -import { logAuditEvent } from "~/server/audit"; -import { AUTH_CONFIG } from "~/config"; - -/** - * Cleanup expired and revoked sessions - * Keeps sessions for audit purposes up to retention limit - * @param retentionDays - How long to keep revoked sessions (default 90) - * @returns Cleanup statistics - */ -export async function cleanupExpiredSessions( - retentionDays: number = AUTH_CONFIG.SESSION_CLEANUP_RETENTION_DAYS -): Promise<{ - expiredDeleted: number; - revokedDeleted: number; - totalDeleted: number; -}> { - const conn = ConnectionFactory(); - const retentionDate = new Date(); - retentionDate.setDate(retentionDate.getDate() - retentionDays); - - try { - // Step 1: Delete expired sessions (hard delete) - const expiredResult = await conn.execute({ - sql: `DELETE FROM Session - WHERE expires_at < datetime('now') - AND created_at < ?`, - args: [retentionDate.toISOString()] - }); - - // Step 2: Delete old revoked sessions (keep recent for audit) - const revokedResult = await conn.execute({ - sql: `DELETE FROM Session - WHERE revoked = 1 - AND created_at < ?`, - args: [retentionDate.toISOString()] - }); - - const stats = { - expiredDeleted: Number(expiredResult.rowsAffected) || 0, - revokedDeleted: Number(revokedResult.rowsAffected) || 0, - totalDeleted: - (Number(expiredResult.rowsAffected) || 0) + - (Number(revokedResult.rowsAffected) || 0) - }; - - console.log( - `Session cleanup completed: ${stats.totalDeleted} sessions deleted ` + - `(${stats.expiredDeleted} expired, ${stats.revokedDeleted} revoked)` - ); - - // Log cleanup event - await logAuditEvent({ - eventType: "system.session_cleanup", - eventData: stats, - success: true - }); - - return stats; - } catch (error) { - console.error("Session cleanup failed:", error); - - await logAuditEvent({ - eventType: "system.session_cleanup", - eventData: { error: String(error) }, - success: false - }); - - throw error; - } -} - -/** - * Cleanup orphaned parent session references - * Remove parent_session_id references to deleted sessions - */ -export async function cleanupOrphanedReferences(): Promise { - const conn = ConnectionFactory(); - - const result = await conn.execute({ - sql: `UPDATE Session - SET parent_session_id = NULL - WHERE parent_session_id IS NOT NULL - AND parent_session_id NOT IN ( - SELECT id FROM Session - )` - }); - - const orphansFixed = Number(result.rowsAffected) || 0; - if (orphansFixed > 0) { - console.log(`Fixed ${orphansFixed} orphaned parent_session_id references`); - } - - return orphansFixed; -} - -/** - * Get session statistics for monitoring - */ -export async function getSessionStats(): Promise<{ - total: number; - active: number; - expired: number; - revoked: number; - avgRotationCount: number; -}> { - const conn = ConnectionFactory(); - - const totalResult = await conn.execute({ - sql: "SELECT COUNT(*) as count FROM Session" - }); - - const activeResult = await conn.execute({ - sql: `SELECT COUNT(*) as count FROM Session - WHERE revoked = 0 AND expires_at > datetime('now')` - }); - - const expiredResult = await conn.execute({ - sql: `SELECT COUNT(*) as count FROM Session - WHERE expires_at < datetime('now')` - }); - - const revokedResult = await conn.execute({ - sql: "SELECT COUNT(*) as count FROM Session WHERE revoked = 1" - }); - - const rotationResult = await conn.execute({ - sql: "SELECT AVG(rotation_count) as avg FROM Session WHERE revoked = 0" - }); - - return { - total: Number(totalResult.rows[0]?.count) || 0, - active: Number(activeResult.rows[0]?.count) || 0, - expired: Number(expiredResult.rows[0]?.count) || 0, - revoked: Number(revokedResult.rows[0]?.count) || 0, - avgRotationCount: Number(rotationResult.rows[0]?.avg) || 0 - }; -} - -/** - * Opportunistic cleanup trigger - * Runs cleanup if it hasn't been run recently (serverless-friendly) - * Uses a simple timestamp check to avoid running too frequently - */ -let lastCleanupTime = 0; - -export async function opportunisticCleanup(): Promise { - const now = Date.now(); - const minIntervalMs = - AUTH_CONFIG.SESSION_CLEANUP_INTERVAL_HOURS * 60 * 60 * 1000; - - // Only run if enough time has passed since last cleanup - if (now - lastCleanupTime < minIntervalMs) { - return; - } - - // Update timestamp immediately to prevent concurrent runs - lastCleanupTime = now; - - try { - console.log("Running opportunistic session cleanup..."); - - // Run cleanup asynchronously (don't block the request) - Promise.all([cleanupExpiredSessions(), cleanupOrphanedReferences()]) - .then(([stats, orphansFixed]) => { - console.log( - `Opportunistic cleanup completed: ${stats.totalDeleted} sessions deleted, ` + - `${orphansFixed} orphaned references fixed` - ); - }) - .catch((error) => { - console.error("Opportunistic cleanup error:", error); - // Reset timer on failure so we can retry sooner - lastCleanupTime = now - minIntervalMs + 5 * 60 * 1000; // Retry in 5 minutes - }); - } catch (error) { - console.error("Opportunistic cleanup trigger error:", error); - // Reset timer on failure - lastCleanupTime = now - minIntervalMs + 5 * 60 * 1000; - } -}