Compare commits

..

10 Commits

Author SHA1 Message Date
Michael Freno
cda7784298 fix email link 2026-01-25 09:41:50 -05:00
Michael Freno
b25fc50156 more endpoints for nessa 2026-01-24 19:51:05 -05:00
Michael Freno
d7c91ac6c5 rename 2026-01-24 19:31:14 -05:00
Michael Freno
e6d5b40acd remove test endpoint 2026-01-21 15:50:49 -05:00
Michael Freno
3845c768e2 blog data fixed 2026-01-21 14:04:04 -05:00
Michael Freno
955c856a85 fix: analytics and deprecated warning 2026-01-21 13:58:34 -05:00
Michael Freno
7b60494d6d fix: duplication error 2026-01-21 12:57:58 -05:00
Michael Freno
58d48dac70 checkpoint 2026-01-21 12:22:19 -05:00
Michael Freno
1d8ec7a375 temp 2026-01-21 11:29:07 -05:00
Michael Freno
6b86d175e8 rename 2026-01-21 08:45:14 -05:00
40 changed files with 3115 additions and 4477 deletions

View File

@@ -8,14 +8,14 @@ export interface PostLike {
post_id: string; post_id: string;
} }
export interface SessionDependantLikeProps { export interface AuthenticatedLikeProps {
currentUserID: string | undefined | null; currentUserID: string | undefined | null;
isAuthenticated: boolean; isAuthenticated: boolean;
likes: PostLike[]; likes: PostLike[];
projectID: number; projectID: number;
} }
export default function SessionDependantLike(props: SessionDependantLikeProps) { export default function AuthenticatedLike(props: AuthenticatedLikeProps) {
const [hovering, setHovering] = createSignal(false); const [hovering, setHovering] = createSignal(false);
const [likes, setLikes] = createSignal(props.likes); const [likes, setLikes] = createSignal(props.likes);
const [instantOffset, setInstantOffset] = createSignal(0); const [instantOffset, setInstantOffset] = createSignal(0);

View File

@@ -7,37 +7,21 @@
* *
* Security Model: * Security Model:
* - Access tokens: Short-lived (15m), contain user identity, stored in httpOnly cookie * - 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 * - rememberMe tokens: Long-lived (30d), issued as JWT without refresh tokens
* - Token rotation: Each refresh invalidates old token and issues new pair
* - Breach detection: Reusing invalidated token revokes entire token family
* *
* Cookie Behavior: * 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 * - rememberMe = true: Persistent cookies (with maxAge) - survive browser restart
* *
* Timing Decisions: * Timing Decisions:
* - 15m access: Balance between security (short exposure) and UX (not too frequent refreshes) * - 15m access: Balance between security (short exposure) and UX (not too frequent refreshes)
* - 7d session: DB expiry for non-remember-me (cookie is session-only but accommodates users who keep browser open) * - 30d remember: Extended convenience for trusted devices
* - 90d remember: Extended convenience for trusted devices (both DB and cookie persist)
* - 5s reuse window: Handles race conditions in distributed systems
*/ */
export const AUTH_CONFIG = { export const AUTH_CONFIG = {
// Access Token (JWT in cookie) // Access Token (JWT in cookie)
ACCESS_TOKEN_EXPIRY: "15m" as const, // 15 minutes (short-lived) ACCESS_TOKEN_EXPIRY: "15m" as const, // 15 minutes (short-lived)
ACCESS_TOKEN_EXPIRY_DEV: "2m" as const, // 2 minutes for faster testing ACCESS_TOKEN_EXPIRY_DEV: "2m" as const, // 2 minutes for faster testing
ACCESS_TOKEN_EXPIRY_LONG: "30d" as const, // rememberMe cookie lifetime
// 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)
// Other Auth Settings // Other Auth Settings
CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14, 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 * 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 * @returns Seconds as number
*/ */
export function expiryToSeconds(expiry: string): number { export function expiryToSeconds(expiry: string): number {
@@ -71,31 +55,12 @@ export function expiryToSeconds(expiry: string): number {
throw new Error(`Invalid expiry format: ${expiry}`); 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 * Type helper for token expiry strings
*/ */
export type TokenExpiry = export type TokenExpiry =
| typeof AUTH_CONFIG.ACCESS_TOKEN_EXPIRY | typeof AUTH_CONFIG.ACCESS_TOKEN_EXPIRY
| typeof AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_SHORT | typeof AUTH_CONFIG.ACCESS_TOKEN_EXPIRY_LONG;
| typeof AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG;
// ============================================================ // ============================================================
// RATE LIMITING // RATE LIMITING
@@ -151,9 +116,6 @@ export const CACHE_CONFIG = {
MAX_STALE_DATA_MS: 7 * 24 * 60 * 60 * 1000, MAX_STALE_DATA_MS: 7 * 24 * 60 * 60 * 1000,
GIT_ACTIVITY_MAX_STALE_MS: 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 in-memory cache TTL (reduces DB reads)
RATE_LIMIT_CACHE_TTL_MS: 60 * 1000, // 1 minute RATE_LIMIT_CACHE_TTL_MS: 60 * 1000, // 1 minute

View File

@@ -20,7 +20,6 @@ import {
} from "solid-js"; } from "solid-js";
import { createAsync, revalidate } from "@solidjs/router"; import { createAsync, revalidate } from "@solidjs/router";
import { getUserState, type UserState } from "~/lib/auth-query"; import { getUserState, type UserState } from "~/lib/auth-query";
import { tokenRefreshManager } from "~/lib/token-refresh";
interface AuthContextType { interface AuthContextType {
/** Current user state (for UI display) */ /** Current user state (for UI display) */
@@ -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 = { const value: AuthContextType = {
userState: serverAuth, userState: serverAuth,
isAuthenticated, isAuthenticated,

View File

@@ -15,38 +15,6 @@ export const model: { [key: string]: string } = {
locked_until TEXT 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: ` UserProvider: `
CREATE TABLE UserProvider CREATE TABLE UserProvider
( (

View File

@@ -17,23 +17,6 @@ export interface User {
locked_until?: string | null; 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 { export interface UserProvider {
id: string; id: string;
user_id: string; user_id: string;
@@ -165,7 +148,6 @@ export interface VisitorAnalytics {
device_type?: string | null; device_type?: string | null;
browser?: string | null; browser?: string | null;
os?: string | null; os?: string | null;
session_id?: string | null;
duration_ms?: number | null; duration_ms?: number | null;
fcp?: number | null; fcp?: number | null;
lcp?: number | null; lcp?: number | null;

12
src/env/server.ts vendored
View File

@@ -31,9 +31,9 @@ const serverEnvSchema = z.object({
VITE_INFILL_ENDPOINT: z.string().min(1), VITE_INFILL_ENDPOINT: z.string().min(1),
INFILL_BEARER_TOKEN: z.string().min(1), INFILL_BEARER_TOKEN: z.string().min(1),
REDIS_URL: z.string().min(1), REDIS_URL: z.string().min(1),
CAIRN_DB_URL: z.string().min(1), NESSA_DB_URL: z.string().min(1),
CAIRN_DB_TOKEN: z.string().min(1), NESSA_DB_TOKEN: z.string().min(1),
CAIRN_JWT_SECRET: z.string().min(1) NESSA_JWT_SECRET: z.string().min(1)
}); });
export type ServerEnv = z.infer<typeof serverEnvSchema>; export type ServerEnv = z.infer<typeof serverEnvSchema>;
@@ -137,9 +137,9 @@ export const getMissingEnvVars = (): string[] => {
"VITE_GITHUB_CLIENT_ID", "VITE_GITHUB_CLIENT_ID",
"VITE_WEBSOCKET", "VITE_WEBSOCKET",
"REDIS_URL", "REDIS_URL",
"CAIRN_DB_URL", "NESSA_DB_URL",
"CAIRN_DB_TOKEN", "NESSA_DB_TOKEN",
"CAIRN_JWT_SECRET" "NESSA_JWT_SECRET"
]; ];
return requiredServerVars.filter((varName) => isMissingEnvVar(varName)); return requiredServerVars.filter((varName) => isMissingEnvVar(varName));

View File

@@ -86,11 +86,5 @@ export function revalidateAuth() {
// Dispatch event to trigger UI updates (client-side only) // Dispatch event to trigger UI updates (client-side only)
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth-state-changed")); window.dispatchEvent(new CustomEvent("auth-state-changed"));
// Reset token refresh timer when auth state changes
// This ensures the timer is synchronized with fresh tokens
import("~/lib/token-refresh").then(({ tokenRefreshManager }) => {
tokenRefreshManager.reset();
});
} }
} }

View File

@@ -1,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<typeof setTimeout> | 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<boolean> {
if (this.isRefreshing) {
console.log("[Token Refresh] Refresh already in progress, skipping");
return false;
}
this.isRefreshing = true;
try {
console.log("[Token Refresh] Refreshing access token...");
// 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<never>((_, 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<boolean> {
console.log(
"[Token Refresh] Attempting initial refresh (server will check for refresh token)"
);
// refreshNow() already calls revalidateAuth() on success
return await this.refreshNow();
}
}
// Singleton instance
export const tokenRefreshManager = new TokenRefreshManager();

View File

@@ -923,18 +923,6 @@ export default function AccountPage() {
<hr class="mt-8 mb-8" /> <hr class="mt-8 mb-8" />
{/* Active Sessions Section */}
<div class="mx-auto max-w-2xl py-8">
<div class="mb-6 text-center text-2xl font-semibold">
Active Sessions
</div>
<div class="bg-surface0 border-surface1 rounded-lg border px-6 py-4 shadow-sm">
<ActiveSessions userId={userProfile().id} />
</div>
</div>
<hr class="mt-8 mb-8" />
{/* Sign Out Section */} {/* Sign Out Section */}
<div class="mx-auto max-w-md py-4"> <div class="mx-auto max-w-md py-4">
<Button <Button
@@ -1147,156 +1135,3 @@ function LinkedProviders(props: { userId: string }) {
</div> </div>
); );
} }
function ActiveSessions(props: { userId: string }) {
const [sessions, setSessions] = createSignal<any[]>([]);
const [loading, setLoading] = createSignal(true);
const [revokeLoading, setRevokeLoading] = createSignal<string | null>(null);
const loadSessions = async () => {
try {
const response = await fetch("/api/trpc/user.getSessions");
const result = await response.json();
if (response.ok && result.result?.data) {
setSessions(result.result.data);
}
} catch (err) {
console.error("Failed to load sessions:", err);
} finally {
setLoading(false);
}
};
createEffect(() => {
loadSessions();
});
const handleRevoke = async (sessionId: string, isCurrent: boolean) => {
if (isCurrent) {
if (
!confirm(
"This will sign you out of this device. Are you sure you want to continue?"
)
) {
return;
}
} else {
if (!confirm("Are you sure you want to revoke this session?")) {
return;
}
}
setRevokeLoading(sessionId);
try {
const response = await fetch("/api/trpc/user.revokeSession", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId })
});
const result = await response.json();
if (response.ok && result.result?.data?.success) {
if (isCurrent) {
window.location.href = "/login";
} else {
await loadSessions();
alert("Session revoked successfully");
}
} else {
alert(result.error?.message || "Failed to revoke session");
}
} catch (err) {
console.error("Failed to revoke session:", err);
alert("Failed to revoke session");
} finally {
setRevokeLoading(null);
}
};
const formatDate = (dateStr: string) => {
// Database stores UTC time, convert to local timezone
const date = new Date(dateStr + (dateStr.includes("Z") ? "" : "Z"));
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
});
};
const parseUserAgent = (ua: string) => {
const browser =
ua.match(/(Chrome|Firefox|Safari|Edge)\/[\d.]+/)?.[0] ||
"Unknown browser";
const os = ua.match(/(Windows|Mac|Linux|Android|iOS)/)?.[0] || "Unknown OS";
return { browser, os };
};
return (
<div class="space-y-4">
<Show when={loading()}>
<div class="text-center text-sm">Loading sessions...</div>
</Show>
<Show when={!loading() && sessions().length === 0}>
<div class="text-center text-sm">No active sessions found</div>
</Show>
<For each={sessions()}>
{(session) => {
const { browser, os } = parseUserAgent(session.userAgent || "");
return (
<div class="bg-surface1 rounded-lg p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<div class="font-semibold">{browser}</div>
<Show when={session.isCurrent}>
<span class="text-green bg-green/20 rounded px-2 py-0.5 text-xs font-semibold">
Current
</span>
</Show>
</div>
<div class="text-subtext0 mt-1 space-y-1 text-sm">
<div>{os}</div>
<Show when={session.clientIp}>
<div>IP: {session.clientIp}</div>
</Show>
<div>
Last active:{" "}
{formatDate(session.lastActiveAt || session.createdAt)}
</div>
<Show when={session.expiresAt}>
<div class="text-xs">
Expires: {formatDate(session.expiresAt)}
{session.rememberMe !== undefined && (
<span class="text-subtext1 ml-2">
(
{session.rememberMe
? "Remember me"
: "Session-only"}
)
</span>
)}
</div>
</Show>
</div>
</div>
<button
onClick={() =>
handleRevoke(session.sessionId, session.isCurrent)
}
disabled={revokeLoading() === session.sessionId}
class="text-red hover:text-red rounded px-3 py-1 text-sm transition-all hover:brightness-125 disabled:opacity-50"
>
{revokeLoading() === session.sessionId
? "Revoking..."
: "Revoke"}
</button>
</div>
</div>
);
}}
</For>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import type { APIEvent } from "@solidjs/start/server"; import type { APIEvent } from "@solidjs/start/server";
import { appRouter } from "~/server/api/root"; import { createServerCaller } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/utils";
export async function GET(event: APIEvent) { export async function GET(event: APIEvent) {
const url = new URL(event.request.url); const url = new URL(event.request.url);
@@ -31,8 +30,7 @@ export async function GET(event: APIEvent) {
try { try {
console.log("[GitHub OAuth Callback] Creating tRPC caller..."); console.log("[GitHub OAuth Callback] Creating tRPC caller...");
const ctx = await createTRPCContext(event); const caller = await createServerCaller(event);
const caller = appRouter.createCaller(ctx);
console.log("[GitHub OAuth Callback] Calling githubCallback procedure..."); console.log("[GitHub OAuth Callback] Calling githubCallback procedure...");
const result = await caller.auth.githubCallback({ code }); const result = await caller.auth.githubCallback({ code });
@@ -45,7 +43,7 @@ export async function GET(event: APIEvent) {
result.redirectTo 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 // Just redirect - the cookies are already in the response
const redirectUrl = result.redirectTo || "/account"; const redirectUrl = result.redirectTo || "/account";
return new Response(null, { return new Response(null, {

View File

@@ -1,6 +1,5 @@
import type { APIEvent } from "@solidjs/start/server"; import type { APIEvent } from "@solidjs/start/server";
import { appRouter } from "~/server/api/root"; import { createServerCaller } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/utils";
export async function GET(event: APIEvent) { export async function GET(event: APIEvent) {
const url = new URL(event.request.url); const url = new URL(event.request.url);
@@ -31,8 +30,7 @@ export async function GET(event: APIEvent) {
try { try {
console.log("[Google OAuth Callback] Creating tRPC caller..."); console.log("[Google OAuth Callback] Creating tRPC caller...");
const ctx = await createTRPCContext(event); const caller = await createServerCaller(event);
const caller = appRouter.createCaller(ctx);
console.log("[Google OAuth Callback] Calling googleCallback procedure..."); console.log("[Google OAuth Callback] Calling googleCallback procedure...");
const result = await caller.auth.googleCallback({ code }); const result = await caller.auth.googleCallback({ code });
@@ -45,7 +43,7 @@ export async function GET(event: APIEvent) {
result.redirectTo 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 // Just redirect - the cookies are already in the response
const redirectUrl = result.redirectTo || "/account"; const redirectUrl = result.redirectTo || "/account";
return new Response(null, { return new Response(null, {

View File

@@ -1,6 +1,5 @@
import type { APIEvent } from "@solidjs/start/server"; import type { APIEvent } from "@solidjs/start/server";
import { appRouter } from "~/server/api/root"; import { createServerCaller } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/utils";
export async function GET(event: APIEvent) { export async function GET(event: APIEvent) {
const url = new URL(event.request.url); const url = new URL(event.request.url);
@@ -27,8 +26,7 @@ export async function GET(event: APIEvent) {
try { try {
console.log("[Email Login Callback] Creating tRPC caller..."); console.log("[Email Login Callback] Creating tRPC caller...");
// Create tRPC caller to invoke the emailLogin procedure // Create tRPC caller to invoke the emailLogin procedure
const ctx = await createTRPCContext(event); const caller = await createServerCaller(event);
const caller = appRouter.createCaller(ctx);
console.log("[Email Login Callback] Calling emailLogin procedure..."); console.log("[Email Login Callback] Calling emailLogin procedure...");
// Call the email login handler - rememberMe will be read from JWT payload // Call the email login handler - rememberMe will be read from JWT payload
@@ -45,7 +43,7 @@ export async function GET(event: APIEvent) {
result.redirectTo 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 // Just redirect - the cookies are already in the response
const redirectUrl = result.redirectTo || "/account"; const redirectUrl = result.redirectTo || "/account";
return new Response(null, { return new Response(null, {

View File

@@ -1,6 +1,5 @@
import type { APIEvent } from "@solidjs/start/server"; import type { APIEvent } from "@solidjs/start/server";
import { appRouter } from "~/server/api/root"; import { createServerCaller } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/utils";
export async function GET(event: APIEvent) { export async function GET(event: APIEvent) {
const url = new URL(event.request.url); const url = new URL(event.request.url);
@@ -57,20 +56,19 @@ export async function GET(event: APIEvent) {
`, `,
{ {
status: 400, status: 400,
headers: { "Content-Type": "text/html" }, headers: { "Content-Type": "text/html" }
} }
); );
} }
try { try {
// Create tRPC caller to invoke the emailVerification procedure // Create tRPC caller to invoke the emailVerification procedure
const ctx = await createTRPCContext(event); const caller = await createServerCaller(event);
const caller = appRouter.createCaller(ctx);
// Call the email verification handler // Call the email verification handler
const result = await caller.auth.emailVerification({ const result = await caller.auth.emailVerification({
email, email,
token, token
}); });
if (result.success) { if (result.success) {
@@ -129,7 +127,7 @@ export async function GET(event: APIEvent) {
`, `,
{ {
status: 200, status: 200,
headers: { "Content-Type": "text/html" }, headers: { "Content-Type": "text/html" }
} }
); );
} else { } else {
@@ -139,8 +137,10 @@ export async function GET(event: APIEvent) {
console.error("Email verification callback error:", error); console.error("Email verification callback error:", error);
// Check if it's a token expiration error // Check if it's a token expiration error
const errorMessage = error instanceof Error ? error.message : "server_error"; const errorMessage =
const isTokenError = errorMessage.includes("expired") || errorMessage.includes("invalid"); error instanceof Error ? error.message : "server_error";
const isTokenError =
errorMessage.includes("expired") || errorMessage.includes("invalid");
return new Response( return new Response(
` `
@@ -192,7 +192,7 @@ export async function GET(event: APIEvent) {
`, `,
{ {
status: 400, status: 400,
headers: { "Content-Type": "text/html" }, headers: { "Content-Type": "text/html" }
} }
); );
} }

View File

@@ -1,13 +1,11 @@
import type { APIEvent } from "@solidjs/start/server"; import { getEvent } from "vinxi/http";
import { getEvent, clearSession } from "vinxi/http"; import { clearAuthToken } from "~/server/auth";
import { sessionConfig } from "~/server/session-config";
export async function POST() { export async function POST() {
"use server"; "use server";
const event = getEvent()!; const event = getEvent()!;
// Clear Vinxi session clearAuthToken(event);
await clearSession(event, sessionConfig);
return new Response(null, { return new Response(null, {
status: 302, status: 302,

View File

@@ -9,7 +9,7 @@ import {
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { createAsync } from "@solidjs/router"; import { createAsync } from "@solidjs/router";
import { getRequestEvent } from "solid-js/web"; import { getRequestEvent } from "solid-js/web";
import SessionDependantLike from "~/components/blog/SessionDependantLike"; import AuthenticatedLike from "~/components/blog/AuthenticatedLike";
import CommentIcon from "~/components/icons/CommentIcon"; import CommentIcon from "~/components/icons/CommentIcon";
import { Fire } from "~/components/icons/Fire"; import { Fire } from "~/components/icons/Fire";
import CommentSectionWrapper from "~/components/blog/CommentSectionWrapper"; import CommentSectionWrapper from "~/components/blog/CommentSectionWrapper";
@@ -433,10 +433,10 @@ export default function PostPage() {
</a> </a>
<div> <div>
<SessionDependantLike <AuthenticatedLike
currentUserID={postData.userID} currentUserID={postData.userID}
isAuthenticated={postData.isAuthenticated} isAuthenticated={postData.isAuthenticated}
likes={postData.likes as any[]} likes={postData.likes}
projectID={p().id} projectID={p().id}
/> />
</div> </div>

View File

@@ -160,7 +160,7 @@ const routerSections: RouterSection[] = [
router: "auth", router: "auth",
procedure: "signOut", procedure: "signOut",
method: "mutation", method: "mutation",
description: "Clear session cookies and sign out" description: "Clear auth cookie and sign out"
}, },
{ {
name: "GitHub Callback", name: "GitHub Callback",

View File

@@ -14,7 +14,6 @@ export interface AnalyticsEntry {
deviceType?: string | null; deviceType?: string | null;
browser?: string | null; browser?: string | null;
os?: string | null; os?: string | null;
sessionId?: string | null;
durationMs?: number | null; durationMs?: number | null;
fcp?: number | null; fcp?: number | null;
lcp?: number | null; lcp?: number | null;
@@ -62,9 +61,9 @@ async function flushAnalyticsBuffer(): Promise<void> {
await conn.execute({ await conn.execute({
sql: `INSERT INTO VisitorAnalytics ( sql: `INSERT INTO VisitorAnalytics (
id, user_id, path, method, referrer, user_agent, ip_address, 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 fcp, lcp, cls, fid, inp, ttfb, dom_load, load_complete
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [ args: [
uuid(), uuid(),
entry.userId || null, entry.userId || null,
@@ -77,7 +76,6 @@ async function flushAnalyticsBuffer(): Promise<void> {
entry.deviceType || null, entry.deviceType || null,
entry.browser || null, entry.browser || null,
entry.os || null, entry.os || null,
entry.sessionId || null,
entry.durationMs || null, entry.durationMs || null,
entry.fcp || null, entry.fcp || null,
entry.lcp || null, entry.lcp || null,
@@ -202,7 +200,6 @@ export async function queryAnalytics(
device_type: row.device_type as string | null, device_type: row.device_type as string | null,
browser: row.browser as string | null, browser: row.browser as string | null,
os: row.os 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, duration_ms: row.duration_ms as number | null,
created_at: row.created_at as string created_at: row.created_at as string
})); }));

View File

@@ -11,10 +11,11 @@ import { postHistoryRouter } from "./routers/post-history";
import { infillRouter } from "./routers/infill"; import { infillRouter } from "./routers/infill";
import { accountRouter } from "./routers/account"; import { accountRouter } from "./routers/account";
import { downloadsRouter } from "./routers/downloads"; import { downloadsRouter } from "./routers/downloads";
import { remoteDbRouter } from "./routers/remote-db"; import { nessaDbRouter } from "./routers/nessa";
import { appleNotificationsRouter } from "./routers/apple-notifications"; import { appleNotificationsRouter } from "./routers/apple-notifications";
import { createTRPCRouter, createTRPCContext } from "./utils"; import { createTRPCRouter, createTRPCContext, t } from "./utils";
import type { H3Event } from "h3"; import type { H3Event } from "h3";
import type { APIEvent } from "@solidjs/start/server";
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
auth: authRouter, auth: authRouter,
@@ -30,18 +31,30 @@ export const appRouter = createTRPCRouter({
infill: infillRouter, infill: infillRouter,
account: accountRouter, account: accountRouter,
downloads: downloadsRouter, downloads: downloadsRouter,
remoteDb: remoteDbRouter, nessaDb: nessaDbRouter,
appleNotifications: appleNotificationsRouter appleNotifications: appleNotificationsRouter
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;
/** Server-side caller factory using the modern tRPC pattern */
export const createCallerFactory = t.createCallerFactory(appRouter);
/** /**
* Create a server-side caller for tRPC procedures * Create a server-side caller for tRPC procedures from H3Event (vinxi/http getEvent)
* This allows calling tRPC procedures directly on the server with proper context * Used in server functions within route files
*/ */
export const createCaller = async (event: H3Event) => { export const createCaller = async (event: H3Event) => {
const apiEvent = { nativeEvent: event, request: event.node.req } as any; const apiEvent = { nativeEvent: event, request: event.node.req } as any;
const ctx = await createTRPCContext(apiEvent); const ctx = await createTRPCContext(apiEvent);
return appRouter.createCaller(ctx); return createCallerFactory(ctx);
};
/**
* Create a server-side caller for tRPC procedures from APIEvent
* Used in API route handlers
*/
export const createServerCaller = async (event: APIEvent) => {
const ctx = await createTRPCContext(event);
return createCallerFactory(ctx);
}; };

View File

@@ -1,32 +1,7 @@
import { createTRPCRouter, protectedProcedure } from "../utils"; import { createTRPCRouter, protectedProcedure } from "../utils";
import { z } from "zod"; import { z } from "zod";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { import { getProviderSummary, unlinkProvider } from "~/server/provider-helpers";
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;
}
export const accountRouter = createTRPCRouter({ export const accountRouter = createTRPCRouter({
/** /**
@@ -67,17 +42,6 @@ export const accountRouter = createTRPCRouter({
await unlinkProvider(userId, provider); 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 { return {
success: true, success: true,
message: `${provider} authentication unlinked successfully` message: `${provider} authentication unlinked successfully`
@@ -97,159 +61,5 @@ export const accountRouter = createTRPCRouter({
message: "Failed to unlink provider" 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"
});
}
})
}); });

View File

@@ -5,13 +5,32 @@ import {
getAnalyticsSummary, getAnalyticsSummary,
getPathAnalytics, getPathAnalytics,
cleanupOldAnalytics, cleanupOldAnalytics,
logVisit,
getPerformanceStats, getPerformanceStats,
enrichAnalyticsEntry enrichAnalyticsEntry
} from "~/server/analytics"; } from "~/server/analytics";
import { ConnectionFactory } from "~/server/database"; import { ConnectionFactory } from "~/server/database";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { getRequestIP, getCookie } from "vinxi/http"; import { getRequestIP } from "vinxi/http";
/** Safely get a header value from either Fetch API Headers or Node.js IncomingHttpHeaders */
function getHeader(
headers: Record<string, string | string[] | undefined> | Headers | undefined,
name: string
): string | undefined {
if (!headers) return undefined;
// Check if it's a Fetch API Headers object (has .get method)
if (typeof (headers as Headers).get === "function") {
return (headers as Headers).get(name) || undefined;
}
// Otherwise treat as Node.js IncomingHttpHeaders (plain object)
const value = (headers as Record<string, string | string[] | undefined>)[
name.toLowerCase()
];
if (Array.isArray(value)) return value[0];
return value;
}
export const analyticsRouter = createTRPCRouter({ export const analyticsRouter = createTRPCRouter({
logPerformance: publicProcedure logPerformance: publicProcedure
@@ -71,18 +90,13 @@ export const analyticsRouter = createTRPCRouter({
} else { } else {
const req = ctx.event.nativeEvent.node?.req || ctx.event.nativeEvent; const req = ctx.event.nativeEvent.node?.req || ctx.event.nativeEvent;
const userAgent = const userAgent =
req.headers?.["user-agent"] || getHeader(req.headers, "user-agent") ||
ctx.event.request?.headers?.get("user-agent") || getHeader(ctx.event.request?.headers, "user-agent");
undefined;
const referrer = const referrer =
req.headers?.referer || getHeader(req.headers, "referer") ||
req.headers?.referrer || getHeader(req.headers, "referrer") ||
ctx.event.request?.headers?.get("referer") || getHeader(ctx.event.request?.headers, "referer");
undefined;
const ipAddress = getRequestIP(ctx.event.nativeEvent) || undefined; const ipAddress = getRequestIP(ctx.event.nativeEvent) || undefined;
const sessionId =
getCookie(ctx.event.nativeEvent, "session_id") || undefined;
const enriched = enrichAnalyticsEntry({ const enriched = enrichAnalyticsEntry({
userId: ctx.userId, userId: ctx.userId,
path: input.path, path: input.path,
@@ -90,7 +104,6 @@ export const analyticsRouter = createTRPCRouter({
userAgent, userAgent,
referrer, referrer,
ipAddress, ipAddress,
sessionId,
fcp: input.metrics.fcp, fcp: input.metrics.fcp,
lcp: input.metrics.lcp, lcp: input.metrics.lcp,
cls: input.metrics.cls, cls: input.metrics.cls,
@@ -104,9 +117,9 @@ export const analyticsRouter = createTRPCRouter({
await conn.execute({ await conn.execute({
sql: `INSERT INTO VisitorAnalytics ( sql: `INSERT INTO VisitorAnalytics (
id, user_id, path, method, referrer, user_agent, ip_address, 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 fcp, lcp, cls, fid, inp, ttfb, dom_load, load_complete
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [ args: [
uuid(), uuid(),
enriched.userId || null, enriched.userId || null,
@@ -119,7 +132,6 @@ export const analyticsRouter = createTRPCRouter({
enriched.deviceType || null, enriched.deviceType || null,
enriched.browser || null, enriched.browser || null,
enriched.os || null, enriched.os || null,
enriched.sessionId || null,
enriched.durationMs || null, enriched.durationMs || null,
enriched.fcp || null, enriched.fcp || null,
enriched.lcp || null, enriched.lcp || null,

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { appRouter } from "~/server/api/root"; import { createCallerFactory, appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/utils"; import { createTRPCContext } from "~/server/api/utils";
vi.mock("~/server/apple-notification", () => ({ vi.mock("~/server/apple-notification", () => ({
@@ -15,15 +15,12 @@ vi.mock("~/server/apple-notification-store", () => ({
storeAppleNotificationUser: async () => undefined storeAppleNotificationUser: async () => undefined
})); }));
vi.mock("~/server/session-helpers", () => ({
getAuthSession: async () => ({ userId: "admin", isAdmin: true })
}));
describe("apple notification router", () => { describe("apple notification router", () => {
it("verifies and stores notifications", async () => { it("verifies and stores notifications", async () => {
const caller = appRouter.createCaller( const ctx = await createTRPCContext({
await createTRPCContext({ nativeEvent: { node: { req: {} } } } as any) nativeEvent: { node: { req: {} } }
); } as any);
const caller = createCallerFactory(ctx);
const result = await caller.appleNotifications.verifyAndStore.mutate({ const result = await caller.appleNotifications.verifyAndStore.mutate({
signedPayload: "test" signedPayload: "test"

View File

@@ -8,7 +8,6 @@ import {
checkPassword, checkPassword,
checkPasswordSafe checkPasswordSafe
} from "~/server/utils"; } from "~/server/utils";
import { setCookie, getCookie } from "vinxi/http";
import type { User } from "~/db/types"; import type { User } from "~/db/types";
import { import {
linkProvider, linkProvider,
@@ -49,19 +48,25 @@ import {
markPasswordResetTokenUsed markPasswordResetTokenUsed
} from "~/server/security"; } from "~/server/security";
import { logAuditEvent } from "~/server/audit"; import { logAuditEvent } from "~/server/audit";
import { getCookie, setCookie } from "vinxi/http";
import type { H3Event } from "vinxi/http"; import type { H3Event } from "vinxi/http";
import type { Context } from "../utils"; import type { Context } from "../utils";
import { AUTH_CONFIG, NETWORK_CONFIG, COOLDOWN_TIMERS } from "~/config";
import { import {
createAuthSession, AUTH_CONFIG,
getAuthSession, NETWORK_CONFIG,
invalidateAuthSession, COOLDOWN_TIMERS,
rotateAuthSession, expiryToSeconds,
revokeTokenFamily getAccessTokenExpiry
} from "~/server/session-helpers"; } from "~/config";
import { checkAuthStatus } from "~/server/auth"; import {
issueAuthToken,
clearAuthToken,
checkAuthStatus,
verifyAuthToken,
getAuthTokenFromEvent
} from "~/server/auth";
import { v4 as uuidV4 } from "uuid"; import { v4 as uuidV4 } from "uuid";
import { jwtVerify, SignJWT } from "jose"; import { SignJWT, jwtVerify } from "jose";
import { import {
generateLoginLinkEmail, generateLoginLinkEmail,
generatePasswordResetEmail, generatePasswordResetEmail,
@@ -83,9 +88,6 @@ function getH3Event(ctx: Context): H3Event {
} }
// Zod schemas // Zod schemas
const refreshTokenSchema = z.object({
rememberMe: z.boolean().optional().default(false)
});
async function sendEmail(to: string, subject: string, htmlContent: string) { async function sendEmail(to: string, subject: string, htmlContent: string) {
const apiKey = env.SENDINBLUE_KEY; const apiKey = env.SENDINBLUE_KEY;
@@ -124,45 +126,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<string | null> {
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({ export const authRouter = createTRPCRouter({
githubCallback: publicProcedure githubCallback: publicProcedure
.input(z.object({ code: z.string() })) .input(z.object({ code: z.string() }))
@@ -306,16 +269,15 @@ export const authRouter = createTRPCRouter({
} }
} }
const clientIP = getClientIP(getH3Event(ctx)); const event = getH3Event(ctx);
const userAgent = getUserAgent(getH3Event(ctx)); const clientIP = getClientIP(event);
await createAuthSession( const userAgent = getUserAgent(event);
getH3Event(ctx), await issueAuthToken({
event,
userId, userId,
true, // OAuth defaults to remember rememberMe: true
clientIP, });
userAgent setCSRFToken(event);
);
setCSRFToken(getH3Event(ctx));
await logAuditEvent({ await logAuditEvent({
userId, userId,
@@ -518,18 +480,17 @@ export const authRouter = createTRPCRouter({
} }
} }
// Create session with Vinxi (OAuth defaults to remember me) // Issue JWT (OAuth defaults to remember me)
const clientIP = getClientIP(getH3Event(ctx)); const event = getH3Event(ctx);
const userAgent = getUserAgent(getH3Event(ctx)); const clientIP = getClientIP(event);
await createAuthSession( const userAgent = getUserAgent(event);
getH3Event(ctx), await issueAuthToken({
event,
userId, userId,
true, // OAuth defaults to remember rememberMe: true
clientIP, });
userAgent
);
setCSRFToken(getH3Event(ctx)); setCSRFToken(event);
await logAuditEvent({ await logAuditEvent({
userId, userId,
@@ -642,17 +603,16 @@ export const authRouter = createTRPCRouter({
const userId = (res.rows[0] as unknown as User).id; const userId = (res.rows[0] as unknown as User).id;
const clientIP = getClientIP(getH3Event(ctx)); const event = getH3Event(ctx);
const userAgent = getUserAgent(getH3Event(ctx)); const clientIP = getClientIP(event);
const userAgent = getUserAgent(event);
await createAuthSession( await issueAuthToken({
getH3Event(ctx), event,
userId, userId,
rememberMe, rememberMe
clientIP, });
userAgent setCSRFToken(event);
);
setCSRFToken(getH3Event(ctx));
await logAuditEvent({ await logAuditEvent({
userId, userId,
@@ -727,14 +687,6 @@ export const authRouter = createTRPCRouter({
// Check if there's a valid JWT token with this code // Check if there's a valid JWT token with this code
// We need to find the token that was generated for this email // We need to find the token that was generated for this email
// Since we can't store tokens in DB efficiently, we'll verify against the cookie // Since we can't store tokens in DB efficiently, we'll verify against the cookie
const requested = getCookie(getH3Event(ctx), "emailLoginLinkRequested");
if (!requested) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "No login request found. Please request a new code."
});
}
// Get the token from cookie (we'll store it when sending email) // Get the token from cookie (we'll store it when sending email)
const storedToken = getCookie(getH3Event(ctx), "emailLoginToken"); const storedToken = getCookie(getH3Event(ctx), "emailLoginToken");
if (!storedToken) { if (!storedToken) {
@@ -777,16 +729,15 @@ export const authRouter = createTRPCRouter({
const shouldRemember = const shouldRemember =
rememberMe ?? (payload.rememberMe as boolean) ?? false; rememberMe ?? (payload.rememberMe as boolean) ?? false;
const clientIP = getClientIP(getH3Event(ctx)); const event = getH3Event(ctx);
const userAgent = getUserAgent(getH3Event(ctx)); const clientIP = getClientIP(event);
await createAuthSession( const userAgent = getUserAgent(event);
getH3Event(ctx), await issueAuthToken({
event,
userId, userId,
shouldRemember, rememberMe: shouldRemember
clientIP, });
userAgent setCSRFToken(event);
);
setCSRFToken(getH3Event(ctx));
await logAuditEvent({ await logAuditEvent({
userId, userId,
@@ -970,20 +921,19 @@ export const authRouter = createTRPCRouter({
email: email email: email
}); });
// Create session with client info // Issue auth token with client info
const clientIP = getClientIP(getH3Event(ctx)); const event = getH3Event(ctx);
const userAgent = getUserAgent(getH3Event(ctx)); const clientIP = getClientIP(event);
const userAgent = getUserAgent(event);
await createAuthSession( await issueAuthToken({
getH3Event(ctx), event,
userId, userId,
rememberMe ?? true, // Default to persistent sessions for registration rememberMe: rememberMe ?? true
clientIP, });
userAgent
);
// Set CSRF token // Set CSRF token
setCSRFToken(getH3Event(ctx)); setCSRFToken(event);
// Log successful registration // Log successful registration
await logAuditEvent({ await logAuditEvent({
@@ -1138,18 +1088,17 @@ export const authRouter = createTRPCRouter({
// Reset rate limits on successful login // Reset rate limits on successful login
await resetLoginRateLimits(email, clientIP); await resetLoginRateLimits(email, clientIP);
// Create session with Vinxi // Issue JWT for authenticated user
const userAgent = getUserAgent(getH3Event(ctx)); const event = getH3Event(ctx);
await createAuthSession( const userAgent = getUserAgent(event);
getH3Event(ctx), await issueAuthToken({
user.id, event,
rememberMe ?? false, // Default to session cookie (expires on browser close) userId: user.id,
clientIP, rememberMe: rememberMe ?? false
userAgent });
);
// Set CSRF token for authenticated session // Set CSRF token for authenticated user
setCSRFToken(getH3Event(ctx)); setCSRFToken(event);
// Log successful login (wrap in try-catch to ensure it never blocks auth flow) // Log successful login (wrap in try-catch to ensure it never blocks auth flow)
try { try {
@@ -1232,7 +1181,7 @@ export const authRouter = createTRPCRouter({
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ const token = await new SignJWT({
email, email,
rememberMe: rememberMe ?? false, // Default to session cookie (expires on browser close) rememberMe: rememberMe ?? false, // Default to browser cookie
code: loginCode code: loginCode
}) })
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
@@ -1263,7 +1212,7 @@ export const authRouter = createTRPCRouter({
// Store the token in a cookie so it can be verified with the code later // Store the token in a cookie so it can be verified with the code later
setCookie(getH3Event(ctx), "emailLoginToken", token, { setCookie(getH3Event(ctx), "emailLoginToken", token, {
maxAge: COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_COOKIE_MAX_AGE, maxAge: expiryToSeconds(AUTH_CONFIG.EMAIL_LOGIN_LINK_EXPIRY),
httpOnly: true, httpOnly: true,
secure: env.NODE_ENV === "production", secure: env.NODE_ENV === "production",
sameSite: "strict", sameSite: "strict",
@@ -1624,185 +1573,81 @@ export const authRouter = createTRPCRouter({
} }
}), }),
refreshToken: publicProcedure refreshToken: publicProcedure.mutation(async ({ ctx }) => {
.input(refreshTokenSchema) try {
.mutation(async ({ ctx }) => { const event = getH3Event(ctx);
try { const authToken = getAuthTokenFromEvent(event);
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"
});
}
// Step 2: Get client info for rotation
const clientIP = getClientIP(event);
const userAgent = getUserAgent(event);
// Step 3: Rotate session (includes validation, breach detection, cookie update)
const newSession = await rotateAuthSession(
event,
session,
clientIP,
userAgent
);
if (!newSession) {
// Rotation failed - session invalid, reuse detected, or max rotations reached
await invalidateAuthSession(event, session.sessionId);
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Token refresh failed - please login again"
});
}
// Step 4: Force response headers to be sent immediately
// This is critical for Safari to receive the new session cookies
// Safari is very strict about cookie updates from fetch responses
try {
const headers = event.node?.res?.getHeaders?.() || {};
console.log(
"[Token Refresh] Response headers set:",
Object.keys(headers)
);
} catch (e) {
// Headers already sent or not available - that's OK
}
// Step 5: Refresh CSRF token
setCSRFToken(event);
// Step 6: Opportunistic cleanup (serverless-friendly)
import("~/server/token-cleanup")
.then((module) => module.opportunisticCleanup())
.catch((err) => console.error("Opportunistic cleanup failed:", err));
return {
success: true,
message: "Token refreshed successfully",
// Return new session ID for Safari fallback
// If Safari doesn't apply cookies, client can use this to restore
sessionId: newSession.sessionId
};
} catch (error) {
console.error("Token refresh error:", error);
if (error instanceof TRPCError) {
throw error;
}
if (!authToken) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "UNAUTHORIZED",
message: "Token refresh failed" 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 }) => { signOut: publicProcedure.mutation(async ({ ctx }) => {
try { try {
// Step 1: Get current session const event = getH3Event(ctx);
const session = await getAuthSession(getH3Event(ctx)); const auth = await checkAuthStatus(event);
if (session) { if (auth.userId) {
await revokeTokenFamily(session.tokenFamily, "user_logout"); const { ipAddress, userAgent } = getAuditContext(event);
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({ await logAuditEvent({
userId: session.userId, userId: auth.userId,
eventType: "auth.logout", eventType: "auth.logout",
eventData: { sessionId: session.sessionId }, eventData: {},
ipAddress, ipAddress,
userAgent, userAgent,
success: true success: true
}); });
} }
clearAuthToken(event);
} catch (e) { } catch (e) {
console.error("Error during signout:", e); console.error("Error during signout:", e);
// Continue with session clearing even if revocation fails
} }
// Step 4: Clear Vinxi session (clears encrypted cookie)
await invalidateAuthSession(getH3Event(ctx), "");
return { success: true }; 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"
});
}
}) })
}); });

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { appRouter } from "~/server/api/root"; import { createCallerFactory } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/utils"; import { createTRPCContext } from "~/server/api/utils";
// Mock the S3 client and getSignedUrl function // Mock the S3 client and getSignedUrl function
@@ -33,9 +33,8 @@ process.env.VITE_DOWNLOAD_BUCKET_STRING = "test-bucket";
describe("downloads router", () => { describe("downloads router", () => {
it("should return a signed URL for valid asset names", async () => { it("should return a signed URL for valid asset names", async () => {
const caller = appRouter.createCaller( const ctx = await createTRPCContext({ nativeEvent: {} } as any);
await createTRPCContext({ nativeEvent: {} } as any) const caller = createCallerFactory(ctx);
);
const result = await caller.downloads.getDownloadUrl.query({ const result = await caller.downloads.getDownloadUrl.query({
asset_name: "lineage" asset_name: "lineage"
@@ -46,9 +45,8 @@ describe("downloads router", () => {
}); });
it("should throw NOT_FOUND for invalid asset names", async () => { it("should throw NOT_FOUND for invalid asset names", async () => {
const caller = appRouter.createCaller( const ctx = await createTRPCContext({ nativeEvent: {} } as any);
await createTRPCContext({ nativeEvent: {} } as any) const caller = createCallerFactory(ctx);
);
try { try {
await caller.downloads.getDownloadUrl.query({ await caller.downloads.getDownloadUrl.query({

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/utils";
vi.mock("~/server/database", () => ({
CairnConnectionFactory: () => ({
execute: async () => ({ rows: [{ id: "1", email: "test@cairn.app" }] })
})
}));
vi.mock("~/server/cache", () => ({
cache: {
get: async () => null,
set: async () => undefined
}
}));
vi.mock("~/server/session-helpers", () => ({
getAuthSession: async () => ({ userId: "admin", isAdmin: true })
}));
describe("remoteDb router", () => {
it("returns users from remote database", async () => {
const caller = appRouter.createCaller(
await createTRPCContext({ nativeEvent: { node: { req: {} } } } as any)
);
const result = await caller.remoteDb.getUsers.query({ limit: 1, offset: 0 });
expect(result.users.length).toBe(1);
expect(result.users[0].email).toBe("test@cairn.app");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,10 @@
import { createTRPCRouter, publicProcedure } from "../utils"; import { createTRPCRouter, publicProcedure } from "../utils";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils"; import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
import { setCookie } from "vinxi/http";
import type { User } from "~/db/types"; import type { User } from "~/db/types";
import { toUserProfile } from "~/types/user"; import { toUserProfile } from "~/types/user";
import { getUserProviders, unlinkProvider } from "~/server/provider-helpers"; import { getUserProviders, unlinkProvider } from "~/server/provider-helpers";
import { z } from "zod"; 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 { generatePasswordSetEmail } from "~/server/email-templates";
import { formatDeviceDescription } from "~/server/device-utils"; import { formatDeviceDescription } from "~/server/device-utils";
import sendEmail from "~/server/email"; import sendEmail from "~/server/email";
@@ -405,119 +401,5 @@ export const userRouter = createTRPCRouter({
await unlinkProvider(userId, input.provider); await unlinkProvider(userId, input.provider);
return { success: true, message: "Provider unlinked" }; 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" };
}) })
}); });

View File

@@ -1,58 +1,72 @@
import { initTRPC, TRPCError } from "@trpc/server"; import { initTRPC, TRPCError } from "@trpc/server";
import type { APIEvent } from "@solidjs/start/server"; import type { APIEvent } from "@solidjs/start/server";
import { getCookie } from "vinxi/http";
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics"; import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
import { getRequestIP } from "vinxi/http"; import { getRequestIP } from "vinxi/http";
import { getAuthSession } from "~/server/session-helpers"; import { verifyNessaToken } from "~/server/nessa-auth";
import { verifyCairnToken } from "~/server/cairn-auth"; import { getAuthPayloadFromEvent } from "~/server/auth";
export type Context = { export type Context = {
event: APIEvent; event: APIEvent;
userId: string | null; userId: string | null;
isAdmin: boolean; isAdmin: boolean;
cairnUserId: string | null; nessaUserId: string | null;
}; };
/** Safely get a header value from either Fetch API Headers or Node.js IncomingHttpHeaders */
function getHeader(
headers: Record<string, string | string[] | undefined> | Headers | undefined,
name: string
): string | undefined {
if (!headers) return undefined;
// Check if it's a Fetch API Headers object (has .get method)
if (typeof (headers as Headers).get === "function") {
return (headers as Headers).get(name) || undefined;
}
// Otherwise treat as Node.js IncomingHttpHeaders (plain object)
const value = (headers as Record<string, string | string[] | undefined>)[
name.toLowerCase()
];
if (Array.isArray(value)) return value[0];
return value;
}
async function createContextInner(event: APIEvent): Promise<Context> { async function createContextInner(event: APIEvent): Promise<Context> {
// Get auth session from Vinxi encrypted session const payload = await getAuthPayloadFromEvent(event.nativeEvent);
const session = await getAuthSession(event.nativeEvent);
let userId: string | null = null; let userId: string | null = null;
let isAdmin = false; let isAdmin = false;
if (session && session.userId) { if (payload) {
userId = session.userId; userId = payload.sub;
isAdmin = session.isAdmin; isAdmin = payload.isAdmin;
} }
const req = event.nativeEvent.node?.req || event.nativeEvent; const req = event.nativeEvent.node?.req || event.nativeEvent;
const path = req.url || event.request?.url || "unknown"; const path = req.url || event.request?.url || "unknown";
const method = req.method || event.request?.method || "GET"; const method = req.method || event.request?.method || "GET";
const userAgent = const userAgent =
req.headers?.["user-agent"] || getHeader(req.headers, "user-agent") ||
event.request?.headers?.get("user-agent") || getHeader(event.request?.headers, "user-agent");
undefined;
const referrer = const referrer =
req.headers?.referer || getHeader(req.headers, "referer") ||
req.headers?.referrer || getHeader(req.headers, "referrer") ||
event.request?.headers?.get("referer") || getHeader(event.request?.headers, "referer");
undefined;
const ipAddress = getRequestIP(event.nativeEvent) || undefined; const ipAddress = getRequestIP(event.nativeEvent) || undefined;
const sessionId = getCookie(event.nativeEvent, "session_id") || undefined;
const authHeader = const authHeader =
event.request?.headers?.get("authorization") || getHeader(req.headers, "authorization") ||
req.headers?.authorization || getHeader(event.request?.headers, "authorization") ||
req.headers?.Authorization ||
null; null;
let cairnUserId: string | null = null; let nessaUserId: string | null = null;
if (authHeader && authHeader.startsWith("Bearer ")) { if (authHeader && authHeader.startsWith("Bearer ")) {
const token = authHeader.replace("Bearer ", "").trim(); const token = authHeader.replace("Bearer ", "").trim();
try { try {
const payload = await verifyCairnToken(token); const payload = await verifyNessaToken(token);
cairnUserId = payload.sub; nessaUserId = payload.sub;
} catch (error) { } catch (error) {
console.error("Cairn JWT verification failed:", error); console.error("Nessa JWT verification failed:", error);
} }
} }
@@ -65,8 +79,7 @@ async function createContextInner(event: APIEvent): Promise<Context> {
method, method,
userAgent, userAgent,
referrer, referrer,
ipAddress, ipAddress
sessionId
}) })
); );
} }
@@ -75,7 +88,7 @@ async function createContextInner(event: APIEvent): Promise<Context> {
event, event,
userId, userId,
isAdmin, isAdmin,
cairnUserId nessaUserId
}; };
} }
@@ -115,21 +128,21 @@ const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
}); });
}); });
const enforceCairnUser = t.middleware(({ ctx, next }) => { const enforceNessaUser = t.middleware(({ ctx, next }) => {
if (!ctx.cairnUserId) { if (!ctx.nessaUserId) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Cairn authentication required" message: "Nessa authentication required"
}); });
} }
return next({ return next({
ctx: { ctx: {
...ctx, ...ctx,
cairnUserId: ctx.cairnUserId nessaUserId: ctx.nessaUserId
} }
}); });
}); });
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
export const adminProcedure = t.procedure.use(enforceUserIsAdmin); export const adminProcedure = t.procedure.use(enforceUserIsAdmin);
export const cairnProcedure = t.procedure.use(enforceCairnUser); export const nessaProcedure = t.procedure.use(enforceNessaUser);

View File

@@ -24,14 +24,10 @@ export type AuditEventType =
| "auth.oauth.github.failed" | "auth.oauth.github.failed"
| "auth.oauth.google.success" | "auth.oauth.google.success"
| "auth.oauth.google.failed" | "auth.oauth.google.failed"
| "auth.session.revoke"
| "auth.session.revokeAll"
| "security.rate_limit.exceeded" | "security.rate_limit.exceeded"
| "security.csrf.failed" | "security.csrf.failed"
| "security.suspicious.activity" | "security.suspicious.activity"
| "admin.action" | "admin.action";
| "auth.session_created"
| "system.session_cleanup";
/** /**
* Audit log entry structure * Audit log entry structure
@@ -246,7 +242,6 @@ export async function getUserSecuritySummary(
lastLoginAt: string | null; lastLoginAt: string | null;
lastLoginIp: string | null; lastLoginIp: string | null;
uniqueIpCount: number; uniqueIpCount: number;
recentSessions: number;
}> { }> {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
@@ -336,16 +331,6 @@ export async function getUserSecuritySummary(
}); });
const uniqueIpCount = (ipResult.rows[0]?.count as number) || 0; 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 { return {
totalEvents, totalEvents,
successfulEvents, successfulEvents,
@@ -356,8 +341,7 @@ export async function getUserSecuritySummary(
failedLogins, failedLogins,
lastLoginAt: lastLogin?.created_at as string | null, lastLoginAt: lastLogin?.created_at as string | null,
lastLoginIp: lastLogin?.ip_address as string | null, lastLoginIp: lastLogin?.ip_address as string | null,
uniqueIpCount, uniqueIpCount
recentSessions
}; };
} }

View File

@@ -1,8 +1,136 @@
import type { H3Event } from "vinxi/http"; import type { H3Event } from "vinxi/http";
import { getCookie, setCookie } from "vinxi/http";
import { OAuth2Client } from "google-auth-library"; import { OAuth2Client } from "google-auth-library";
import type { Row } from "@libsql/client/web"; import type { Row } from "@libsql/client/web";
import { SignJWT, jwtVerify } from "jose";
import { env } from "~/env/server"; 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<AuthTokenPayload | null> {
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<AuthTokenPayload | null> {
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<string> {
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 * Check authentication status
@@ -15,9 +143,8 @@ export async function checkAuthStatus(event: H3Event): Promise<{
isAdmin: boolean; isAdmin: boolean;
}> { }> {
try { try {
const session = await getAuthSession(event); const payload = await getAuthPayloadFromEvent(event);
if (!payload) {
if (!session || !session.userId) {
return { return {
isAuthenticated: false, isAuthenticated: false,
userId: null, userId: null,
@@ -27,8 +154,8 @@ export async function checkAuthStatus(event: H3Event): Promise<{
return { return {
isAuthenticated: true, isAuthenticated: true,
userId: session.userId, userId: payload.sub,
isAdmin: session.isAdmin isAdmin: payload.isAdmin
}; };
} catch (error) { } catch (error) {
console.error("Auth check error:", 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 * @param event - H3Event
* @returns User ID or null if not authenticated * @returns User ID or null if not authenticated
*/ */
@@ -67,10 +194,8 @@ export async function validateLineageRequest({
const { provider, email } = userRow; const { provider, email } = userRow;
if (provider === "email") { if (provider === "email") {
try { try {
const { jwtVerify } = await import("jose"); const payload = await verifyAuthToken(auth_token);
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); if (!payload || email !== payload.email) {
const { payload } = await jwtVerify(auth_token, secret);
if (email !== payload.email) {
return false; return false;
} }
} catch (err) { } catch (err) {

View File

@@ -1,23 +0,0 @@
import { jwtVerify } from "jose";
import { env } from "~/env/server";
export type CairnAuthPayload = {
sub: string;
exp?: number;
iat?: number;
};
export async function verifyCairnToken(
token: string
): Promise<CairnAuthPayload> {
const secret = new TextEncoder().encode(env.CAIRN_JWT_SECRET);
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"]
});
return {
sub: payload.sub as string,
exp: payload.exp as number | undefined,
iat: payload.iat as number | undefined
};
}

View File

@@ -14,7 +14,7 @@ import {
let mainDBConnection: ReturnType<typeof createClient> | null = null; let mainDBConnection: ReturnType<typeof createClient> | null = null;
let lineageDBConnection: ReturnType<typeof createClient> | null = null; let lineageDBConnection: ReturnType<typeof createClient> | null = null;
let cairnDBConnection: ReturnType<typeof createClient> | null = null; let nessaDBConnection: ReturnType<typeof createClient> | null = null;
export function ConnectionFactory() { export function ConnectionFactory() {
if (!mainDBConnection) { if (!mainDBConnection) {
@@ -38,15 +38,15 @@ export function LineageConnectionFactory() {
return lineageDBConnection; return lineageDBConnection;
} }
export function CairnConnectionFactory() { export function NessaConnectionFactory() {
if (!cairnDBConnection) { if (!nessaDBConnection) {
const config = { const config = {
url: env.CAIRN_DB_URL, url: env.NESSA_DB_URL,
authToken: env.CAIRN_DB_TOKEN authToken: env.NESSA_DB_TOKEN
}; };
cairnDBConnection = createClient(config); nessaDBConnection = createClient(config);
} }
return cairnDBConnection; return nessaDBConnection;
} }
export async function LineageDBInit() { export async function LineageDBInit() {

View File

@@ -88,7 +88,7 @@ export function formatDeviceDescription(deviceInfo: DeviceInfo): string {
/** /**
* Create a short device fingerprint for comparison * 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 * @param deviceInfo - Device information
* @returns Short fingerprint string * @returns Short fingerprint string
*/ */

View File

@@ -94,7 +94,6 @@
color: #856404; color: #856404;
" "
> >
<li>Revoke all active sessions</li>
<li>Change your password</li> <li>Change your password</li>
<li>Review linked authentication providers</li> <li>Review linked authentication providers</li>
</ul> </ul>

39
src/server/nessa-auth.ts Normal file
View File

@@ -0,0 +1,39 @@
import { SignJWT, jwtVerify } from "jose";
import { env } from "~/env/server";
const NESSA_JWT_EXPIRY = "30d";
export type NessaAuthPayload = {
sub: string;
exp?: number;
iat?: number;
};
export async function verifyNessaToken(
token: string
): Promise<NessaAuthPayload> {
const secret = new TextEncoder().encode(env.NESSA_JWT_SECRET);
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"]
});
if (!payload.sub) {
throw new Error("Missing subject in Nessa JWT");
}
return {
sub: payload.sub as string,
exp: payload.exp as number | undefined,
iat: payload.iat as number | undefined
};
}
export async function signNessaToken(userId: string): Promise<string> {
const secret = new TextEncoder().encode(env.NESSA_JWT_SECRET);
return new SignJWT({})
.setProtectedHeader({ alg: "HS256" })
.setSubject(userId)
.setIssuedAt()
.setExpirationTime(NESSA_JWT_EXPIRY)
.sign(secret);
}

View File

@@ -167,7 +167,7 @@ describe("CSRF Protection", () => {
expect(isValid).toBe(false); expect(isValid).toBe(false);
}); });
it("should prevent token reuse from different session", () => { it("should prevent token reuse from different login", () => {
const token1 = generateCSRFToken(); const token1 = generateCSRFToken();
const token2 = generateCSRFToken(); const token2 = generateCSRFToken();

View File

@@ -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)
};
}

View File

@@ -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<string, number>();
/**
* 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<void> {
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<SessionData> {
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<SessionData>(
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<SessionData | null> {
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<Session<T>>, 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<SessionData>(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<typeof ConnectionFactory>,
sessionId: string,
maxDepth: number = 100
): Promise<any | null> {
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<SessionData | null> {
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<boolean> {
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<void> {
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<void> {
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<boolean> {
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<SessionData | null> {
// 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;
}

View File

@@ -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<void> {
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<number> {
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<boolean> {
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;
}

View File

@@ -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<number> {
const conn = ConnectionFactory();
const result = await conn.execute({
sql: `UPDATE Session
SET parent_session_id = NULL
WHERE parent_session_id IS NOT NULL
AND parent_session_id NOT IN (
SELECT id FROM Session
)`
});
const orphansFixed = Number(result.rowsAffected) || 0;
if (orphansFixed > 0) {
console.log(`Fixed ${orphansFixed} orphaned parent_session_id references`);
}
return orphansFixed;
}
/**
* Get session statistics for monitoring
*/
export async function getSessionStats(): Promise<{
total: number;
active: number;
expired: number;
revoked: number;
avgRotationCount: number;
}> {
const conn = ConnectionFactory();
const totalResult = await conn.execute({
sql: "SELECT COUNT(*) as count FROM Session"
});
const activeResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM Session
WHERE revoked = 0 AND expires_at > datetime('now')`
});
const expiredResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM Session
WHERE expires_at < datetime('now')`
});
const revokedResult = await conn.execute({
sql: "SELECT COUNT(*) as count FROM Session WHERE revoked = 1"
});
const rotationResult = await conn.execute({
sql: "SELECT AVG(rotation_count) as avg FROM Session WHERE revoked = 0"
});
return {
total: Number(totalResult.rows[0]?.count) || 0,
active: Number(activeResult.rows[0]?.count) || 0,
expired: Number(expiredResult.rows[0]?.count) || 0,
revoked: Number(revokedResult.rows[0]?.count) || 0,
avgRotationCount: Number(rotationResult.rows[0]?.avg) || 0
};
}
/**
* Opportunistic cleanup trigger
* Runs cleanup if it hasn't been run recently (serverless-friendly)
* Uses a simple timestamp check to avoid running too frequently
*/
let lastCleanupTime = 0;
export async function opportunisticCleanup(): Promise<void> {
const now = Date.now();
const minIntervalMs =
AUTH_CONFIG.SESSION_CLEANUP_INTERVAL_HOURS * 60 * 60 * 1000;
// Only run if enough time has passed since last cleanup
if (now - lastCleanupTime < minIntervalMs) {
return;
}
// Update timestamp immediately to prevent concurrent runs
lastCleanupTime = now;
try {
console.log("Running opportunistic session cleanup...");
// Run cleanup asynchronously (don't block the request)
Promise.all([cleanupExpiredSessions(), cleanupOrphanedReferences()])
.then(([stats, orphansFixed]) => {
console.log(
`Opportunistic cleanup completed: ${stats.totalDeleted} sessions deleted, ` +
`${orphansFixed} orphaned references fixed`
);
})
.catch((error) => {
console.error("Opportunistic cleanup error:", error);
// Reset timer on failure so we can retry sooner
lastCleanupTime = now - minIntervalMs + 5 * 60 * 1000; // Retry in 5 minutes
});
} catch (error) {
console.error("Opportunistic cleanup trigger error:", error);
// Reset timer on failure
lastCleanupTime = now - minIntervalMs + 5 * 60 * 1000;
}
}