checkpoint

This commit is contained in:
Michael Freno
2026-01-21 12:22:19 -05:00
parent 1d8ec7a375
commit 58d48dac70
29 changed files with 287 additions and 2594 deletions

View File

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

View File

@@ -1,32 +1,7 @@
import { createTRPCRouter, protectedProcedure } from "../utils";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import {
getUserProviders,
unlinkProvider,
getProviderSummary
} from "~/server/provider-helpers";
import {
getUserActiveSessions,
revokeUserSession,
revokeOtherUserSessions,
getSessionCountByDevice
} from "~/server/session-management";
import { getAuthSession } from "~/server/session-helpers";
import { logAuditEvent } from "~/server/audit";
import { getAuditContext } from "~/server/security";
import type { H3Event } from "vinxi/http";
import type { Context } from "../utils";
/**
* Extract H3Event from Context
*/
function getH3Event(ctx: Context): H3Event {
if (ctx.event && "nativeEvent" in ctx.event && ctx.event.nativeEvent) {
return ctx.event.nativeEvent as H3Event;
}
return ctx.event as unknown as H3Event;
}
import { getProviderSummary, unlinkProvider } from "~/server/provider-helpers";
export const accountRouter = createTRPCRouter({
/**
@@ -67,17 +42,6 @@ export const accountRouter = createTRPCRouter({
await unlinkProvider(userId, provider);
// Log audit event
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId,
eventType: "auth.provider.unlinked",
eventData: { provider },
ipAddress,
userAgent,
success: true
});
return {
success: true,
message: `${provider} authentication unlinked successfully`
@@ -97,159 +61,5 @@ export const accountRouter = createTRPCRouter({
message: "Failed to unlink provider"
});
}
}),
/**
* Get all active sessions for current user
*/
getActiveSessions: protectedProcedure.query(async ({ ctx }) => {
try {
const userId = ctx.userId!;
const sessions = await getUserActiveSessions(userId);
// Mark current session
const currentSession = await getAuthSession(getH3Event(ctx));
const currentSessionId = currentSession?.sessionId;
const sessionsWithCurrent = sessions.map((session) => ({
...session,
current: session.sessionId === currentSessionId
}));
return {
success: true,
sessions: sessionsWithCurrent
};
} catch (error) {
console.error("Error fetching active sessions:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch active sessions"
});
}
}),
/**
* Get session statistics by device type
*/
getSessionStats: protectedProcedure.query(async ({ ctx }) => {
try {
const userId = ctx.userId!;
const stats = await getSessionCountByDevice(userId);
return {
success: true,
stats
};
} catch (error) {
console.error("Error fetching session stats:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch session stats"
});
}
}),
/**
* Revoke a specific session
*/
revokeSession: protectedProcedure
.input(
z.object({
sessionId: z.string()
})
)
.mutation(async ({ input, ctx }) => {
try {
const userId = ctx.userId!;
const { sessionId } = input;
await revokeUserSession(userId, sessionId);
// Log audit event
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId,
eventType: "auth.session_revoked",
eventData: { sessionId, reason: "user_request" },
ipAddress,
userAgent,
success: true
});
return {
success: true,
message: "Session revoked successfully"
};
} catch (error) {
console.error("Error revoking session:", error);
if (error instanceof Error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error.message
});
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to revoke session"
});
}
}),
/**
* Revoke all other sessions (keep current session active)
*/
revokeOtherSessions: protectedProcedure.mutation(async ({ ctx }) => {
try {
const userId = ctx.userId!;
// Get current session
const currentSession = await getAuthSession(getH3Event(ctx));
if (!currentSession) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "No active session found"
});
}
const revokedCount = await revokeOtherUserSessions(
userId,
currentSession.sessionId
);
// Log audit event
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId,
eventType: "auth.sessions_bulk_revoked",
eventData: {
revokedCount,
keptSession: currentSession.sessionId,
reason: "user_request"
},
ipAddress,
userAgent,
success: true
});
return {
success: true,
message: `${revokedCount} session(s) revoked successfully`,
revokedCount
};
} catch (error) {
console.error("Error revoking other sessions:", error);
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to revoke sessions"
});
}
})
})
});

View File

@@ -11,7 +11,7 @@ import {
} from "~/server/analytics";
import { ConnectionFactory } from "~/server/database";
import { v4 as uuid } from "uuid";
import { getRequestIP, getCookie } from "vinxi/http";
import { getRequestIP } from "vinxi/http";
export const analyticsRouter = createTRPCRouter({
logPerformance: publicProcedure
@@ -80,9 +80,6 @@ export const analyticsRouter = createTRPCRouter({
ctx.event.request?.headers?.get("referer") ||
undefined;
const ipAddress = getRequestIP(ctx.event.nativeEvent) || undefined;
const sessionId =
getCookie(ctx.event.nativeEvent, "session_id") || undefined;
const enriched = enrichAnalyticsEntry({
userId: ctx.userId,
path: input.path,
@@ -90,7 +87,6 @@ export const analyticsRouter = createTRPCRouter({
userAgent,
referrer,
ipAddress,
sessionId,
fcp: input.metrics.fcp,
lcp: input.metrics.lcp,
cls: input.metrics.cls,
@@ -104,9 +100,9 @@ export const analyticsRouter = createTRPCRouter({
await conn.execute({
sql: `INSERT INTO VisitorAnalytics (
id, user_id, path, method, referrer, user_agent, ip_address,
country, device_type, browser, os, session_id, duration_ms,
country, device_type, browser, os, duration_ms,
fcp, lcp, cls, fid, inp, ttfb, dom_load, load_complete
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
uuid(),
enriched.userId || null,
@@ -119,7 +115,6 @@ export const analyticsRouter = createTRPCRouter({
enriched.deviceType || null,
enriched.browser || null,
enriched.os || null,
enriched.sessionId || null,
enriched.durationMs || null,
enriched.fcp || null,
enriched.lcp || null,

View File

@@ -15,10 +15,6 @@ vi.mock("~/server/apple-notification-store", () => ({
storeAppleNotificationUser: async () => undefined
}));
vi.mock("~/server/session-helpers", () => ({
getAuthSession: async () => ({ userId: "admin", isAdmin: true })
}));
describe("apple notification router", () => {
it("verifies and stores notifications", async () => {
const caller = appRouter.createCaller(

View File

@@ -8,7 +8,6 @@ import {
checkPassword,
checkPasswordSafe
} from "~/server/utils";
import { setCookie, getCookie } from "vinxi/http";
import type { User } from "~/db/types";
import {
linkProvider,
@@ -51,17 +50,22 @@ import {
import { logAuditEvent } from "~/server/audit";
import type { H3Event } from "vinxi/http";
import type { Context } from "../utils";
import { AUTH_CONFIG, NETWORK_CONFIG, COOLDOWN_TIMERS } from "~/config";
import {
createAuthSession,
getAuthSession,
invalidateAuthSession,
rotateAuthSession,
revokeTokenFamily
} from "~/server/session-helpers";
import { checkAuthStatus } from "~/server/auth";
AUTH_CONFIG,
NETWORK_CONFIG,
COOLDOWN_TIMERS,
expiryToSeconds,
getAccessTokenExpiry
} from "~/config";
import {
issueAuthToken,
clearAuthToken,
checkAuthStatus,
verifyAuthToken,
getAuthTokenFromEvent
} from "~/server/auth";
import { v4 as uuidV4 } from "uuid";
import { jwtVerify, SignJWT } from "jose";
import { SignJWT } from "jose";
import {
generateLoginLinkEmail,
generatePasswordResetEmail,
@@ -83,9 +87,6 @@ function getH3Event(ctx: Context): H3Event {
}
// Zod schemas
const refreshTokenSchema = z.object({
rememberMe: z.boolean().optional().default(false)
});
async function sendEmail(to: string, subject: string, htmlContent: string) {
const apiKey = env.SENDINBLUE_KEY;
@@ -124,45 +125,6 @@ async function sendEmail(to: string, subject: string, htmlContent: string) {
);
}
/**
* Attempt server-side token refresh for SSR
* Called from getUserState() when access token is expired but refresh token exists
* @param event - H3Event from SSR
* @param refreshToken - Refresh token from httpOnly cookie (unused, kept for API compatibility)
* @returns userId if refresh succeeded, null otherwise
*/
export async function attemptTokenRefresh(
event: H3Event,
refreshToken: string
): Promise<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({
githubCallback: publicProcedure
.input(z.object({ code: z.string() }))
@@ -306,16 +268,15 @@ export const authRouter = createTRPCRouter({
}
}
const clientIP = getClientIP(getH3Event(ctx));
const userAgent = getUserAgent(getH3Event(ctx));
await createAuthSession(
getH3Event(ctx),
const event = getH3Event(ctx);
const clientIP = getClientIP(event);
const userAgent = getUserAgent(event);
await issueAuthToken({
event,
userId,
true, // OAuth defaults to remember
clientIP,
userAgent
);
setCSRFToken(getH3Event(ctx));
rememberMe: true
});
setCSRFToken(event);
await logAuditEvent({
userId,
@@ -518,18 +479,17 @@ export const authRouter = createTRPCRouter({
}
}
// Create session with Vinxi (OAuth defaults to remember me)
const clientIP = getClientIP(getH3Event(ctx));
const userAgent = getUserAgent(getH3Event(ctx));
await createAuthSession(
getH3Event(ctx),
// Issue JWT (OAuth defaults to remember me)
const event = getH3Event(ctx);
const clientIP = getClientIP(event);
const userAgent = getUserAgent(event);
await issueAuthToken({
event,
userId,
true, // OAuth defaults to remember
clientIP,
userAgent
);
rememberMe: true
});
setCSRFToken(getH3Event(ctx));
setCSRFToken(event);
await logAuditEvent({
userId,
@@ -642,17 +602,16 @@ export const authRouter = createTRPCRouter({
const userId = (res.rows[0] as unknown as User).id;
const clientIP = getClientIP(getH3Event(ctx));
const userAgent = getUserAgent(getH3Event(ctx));
const event = getH3Event(ctx);
const clientIP = getClientIP(event);
const userAgent = getUserAgent(event);
await createAuthSession(
getH3Event(ctx),
await issueAuthToken({
event,
userId,
rememberMe,
clientIP,
userAgent
);
setCSRFToken(getH3Event(ctx));
rememberMe
});
setCSRFToken(event);
await logAuditEvent({
userId,
@@ -777,16 +736,15 @@ export const authRouter = createTRPCRouter({
const shouldRemember =
rememberMe ?? (payload.rememberMe as boolean) ?? false;
const clientIP = getClientIP(getH3Event(ctx));
const userAgent = getUserAgent(getH3Event(ctx));
await createAuthSession(
getH3Event(ctx),
const event = getH3Event(ctx);
const clientIP = getClientIP(event);
const userAgent = getUserAgent(event);
await issueAuthToken({
event,
userId,
shouldRemember,
clientIP,
userAgent
);
setCSRFToken(getH3Event(ctx));
rememberMe: shouldRemember
});
setCSRFToken(event);
await logAuditEvent({
userId,
@@ -970,20 +928,19 @@ export const authRouter = createTRPCRouter({
email: email
});
// Create session with client info
const clientIP = getClientIP(getH3Event(ctx));
const userAgent = getUserAgent(getH3Event(ctx));
// Issue auth token with client info
const event = getH3Event(ctx);
const clientIP = getClientIP(event);
const userAgent = getUserAgent(event);
await createAuthSession(
getH3Event(ctx),
await issueAuthToken({
event,
userId,
rememberMe ?? true, // Default to persistent sessions for registration
clientIP,
userAgent
);
rememberMe: rememberMe ?? true
});
// Set CSRF token
setCSRFToken(getH3Event(ctx));
setCSRFToken(event);
// Log successful registration
await logAuditEvent({
@@ -1138,18 +1095,17 @@ export const authRouter = createTRPCRouter({
// Reset rate limits on successful login
await resetLoginRateLimits(email, clientIP);
// Create session with Vinxi
const userAgent = getUserAgent(getH3Event(ctx));
await createAuthSession(
getH3Event(ctx),
user.id,
rememberMe ?? false, // Default to session cookie (expires on browser close)
clientIP,
userAgent
);
// Issue JWT for authenticated user
const event = getH3Event(ctx);
const userAgent = getUserAgent(event);
await issueAuthToken({
event,
userId: user.id,
rememberMe: rememberMe ?? false
});
// Set CSRF token for authenticated session
setCSRFToken(getH3Event(ctx));
// Set CSRF token for authenticated user
setCSRFToken(event);
// Log successful login (wrap in try-catch to ensure it never blocks auth flow)
try {
@@ -1232,7 +1188,7 @@ export const authRouter = createTRPCRouter({
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({
email,
rememberMe: rememberMe ?? false, // Default to session cookie (expires on browser close)
rememberMe: rememberMe ?? false, // Default to browser session cookie
code: loginCode
})
.setProtectedHeader({ alg: "HS256" })
@@ -1624,72 +1580,66 @@ export const authRouter = createTRPCRouter({
}
}),
refreshToken: publicProcedure
.input(refreshTokenSchema)
.mutation(async ({ ctx }) => {
try {
const event = getH3Event(ctx);
// Step 1: Get current session from Vinxi
const session = await getAuthSession(event);
if (!session) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "No valid session found"
});
}
const authToken = getCookie(event, authCookieName);
if (!authToken) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "No valid token found"
});
}
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const { payload } = await jwtVerify(authToken, secret);
if (!payload.sub) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid token"
});
}
await issueAuthToken({
event,
userId: payload.sub as string,
rememberMe: ctx.input.rememberMe ?? false
});
setCSRFToken(event);
return {
success: true,
message: "Token refreshed successfully"
};
} catch (error) {
console.error("Token refresh error:", error);
if (error instanceof TRPCError) {
throw error;
}
refreshToken: publicProcedure.mutation(async ({ ctx }) => {
try {
const event = getH3Event(ctx);
const authToken = getAuthTokenFromEvent(event);
if (!authToken) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Token refresh failed"
code: "UNAUTHORIZED",
message: "No valid token found"
});
}
}),
const payload = await verifyAuthToken(authToken);
if (!payload) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid token"
});
}
const expiresIn = payload.exp
? payload.exp - Math.floor(Date.now() / 1000)
: 0;
const shortExpiry = expiryToSeconds(getAccessTokenExpiry());
await issueAuthToken({
event,
userId: payload.sub,
rememberMe: expiresIn > shortExpiry
});
setCSRFToken(event);
return {
success: true,
message: "Token refreshed successfully"
};
} catch (error) {
console.error("Token refresh error:", error);
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Token refresh failed"
});
}
}),
signOut: publicProcedure.mutation(async ({ ctx }) => {
try {
const auth = await checkAuthStatus(getH3Event(ctx));
const event = getH3Event(ctx);
const auth = await checkAuthStatus(event);
if (auth.userId) {
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
const { ipAddress, userAgent } = getAuditContext(event);
await logAuditEvent({
userId: auth.userId,
eventType: "auth.logout",
@@ -1699,87 +1649,12 @@ export const authRouter = createTRPCRouter({
success: true
});
}
clearAuthToken(event);
} catch (e) {
console.error("Error during signout:", e);
}
clearAuthToken(getH3Event(ctx));
return { success: true };
}),
// Admin endpoints for session management
cleanupSessions: publicProcedure.mutation(async ({ ctx }) => {
// Get user ID to check admin status
const userId = ctx.userId;
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication required"
});
}
// Import cleanup functions
const { cleanupExpiredSessions, cleanupOrphanedReferences } =
await import("~/server/token-cleanup");
try {
// Run cleanup
const stats = await cleanupExpiredSessions();
const orphansFixed = await cleanupOrphanedReferences();
// Log admin action
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId,
eventType: "admin.session_cleanup",
eventData: {
sessionsDeleted: stats.totalDeleted,
orphansFixed
},
ipAddress,
userAgent,
success: true
});
return {
success: true,
sessionsDeleted: stats.totalDeleted,
expiredDeleted: stats.expiredDeleted,
revokedDeleted: stats.revokedDeleted,
orphansFixed
};
} catch (error) {
console.error("Manual cleanup failed:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Cleanup failed"
});
}
}),
getSessionStats: publicProcedure.query(async ({ ctx }) => {
// Get user ID to check admin status
const userId = ctx.userId;
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication required"
});
}
// Import stats function
const { getSessionStats } = await import("~/server/token-cleanup");
try {
const stats = await getSessionStats();
return stats;
} catch (error) {
console.error("Failed to get session stats:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to retrieve stats"
});
}
})
});

View File

@@ -1,14 +1,10 @@
import { createTRPCRouter, publicProcedure } from "../utils";
import { TRPCError } from "@trpc/server";
import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
import { setCookie } from "vinxi/http";
import type { User } from "~/db/types";
import { toUserProfile } from "~/types/user";
import { getUserProviders, unlinkProvider } from "~/server/provider-helpers";
import { z } from "zod";
import { getAuthSession } from "~/server/session-helpers";
import { logAuditEvent } from "~/server/audit";
import { getClientIP, getUserAgent } from "~/server/security";
import { generatePasswordSetEmail } from "~/server/email-templates";
import { formatDeviceDescription } from "~/server/device-utils";
import sendEmail from "~/server/email";
@@ -405,119 +401,5 @@ export const userRouter = createTRPCRouter({
await unlinkProvider(userId, input.provider);
return { success: true, message: "Provider unlinked" };
}),
getSessions: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.userId;
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated"
});
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: `SELECT id, token_family, created_at, expires_at, last_active_at,
rotation_count, ip_address, user_agent
FROM Session
WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now')
ORDER BY last_active_at DESC`,
args: [userId]
});
// Get current session to mark it
const currentSession = await getAuthSession(ctx.event as any);
return res.rows.map((row: any) => {
// Infer rememberMe from expires_at duration
// If expires_at is > 2 days from creation, it's a remember-me session
const createdAt = new Date(row.created_at);
const expiresAt = new Date(row.expires_at);
const durationMs = expiresAt.getTime() - createdAt.getTime();
const rememberMe = durationMs > 2 * 24 * 60 * 60 * 1000; // > 2 days
return {
sessionId: row.id,
tokenFamily: row.token_family,
createdAt: row.created_at,
expiresAt: row.expires_at,
lastActiveAt: row.last_active_at,
rotationCount: row.rotation_count,
clientIp: row.ip_address,
userAgent: row.user_agent,
rememberMe,
isCurrent: currentSession?.sessionId === row.id
};
});
}),
revokeSession: publicProcedure
.input(
z.object({
sessionId: z.string()
})
)
.mutation(async ({ input, ctx }) => {
const userId = ctx.userId;
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated"
});
}
const conn = ConnectionFactory();
// Verify session belongs to this user
const sessionCheck = await conn.execute({
sql: "SELECT user_id, token_family FROM Session WHERE id = ?",
args: [input.sessionId]
});
if (sessionCheck.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Session not found"
});
}
const session = sessionCheck.rows[0] as any;
if (session.user_id !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Cannot revoke another user's session"
});
}
// Revoke the entire token family (all sessions on this device)
await conn.execute({
sql: "UPDATE Session SET revoked = 1 WHERE token_family = ?",
args: [session.token_family]
});
// Log audit event
const h3Event = ctx.event.nativeEvent
? ctx.event.nativeEvent
: (ctx.event as any);
const clientIP = getClientIP(h3Event);
const userAgent = getUserAgent(h3Event);
await logAuditEvent({
userId,
eventType: "auth.session_revoked",
eventData: {
sessionId: input.sessionId,
tokenFamily: session.token_family,
reason: "user_revoked"
},
ipAddress: clientIP,
userAgent,
success: true
});
return { success: true, message: "Session revoked" };
})
});

View File

@@ -1,10 +1,9 @@
import { initTRPC, TRPCError } from "@trpc/server";
import type { APIEvent } from "@solidjs/start/server";
import { getCookie } from "vinxi/http";
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
import { getRequestIP } from "vinxi/http";
import { getAuthSession } from "~/server/session-helpers";
import { verifyCairnToken } from "~/server/cairn-auth";
import { getAuthPayloadFromEvent } from "~/server/auth";
export type Context = {
event: APIEvent;
@@ -14,15 +13,14 @@ export type Context = {
};
async function createContextInner(event: APIEvent): Promise<Context> {
// Get auth session from Vinxi encrypted session
const session = await getAuthSession(event.nativeEvent);
const payload = await getAuthPayloadFromEvent(event.nativeEvent);
let userId: string | null = null;
let isAdmin = false;
if (session && session.userId) {
userId = session.userId;
isAdmin = session.isAdmin;
if (payload) {
userId = payload.sub;
isAdmin = payload.isAdmin;
}
const req = event.nativeEvent.node?.req || event.nativeEvent;
@@ -38,7 +36,6 @@ async function createContextInner(event: APIEvent): Promise<Context> {
event.request?.headers?.get("referer") ||
undefined;
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
const sessionId = getCookie(event.nativeEvent, "session_id") || undefined;
const authHeader =
event.request?.headers?.get("authorization") ||
req.headers?.authorization ||
@@ -65,8 +62,7 @@ async function createContextInner(event: APIEvent): Promise<Context> {
method,
userAgent,
referrer,
ipAddress,
sessionId
ipAddress
})
);
}

View File

@@ -24,14 +24,10 @@ export type AuditEventType =
| "auth.oauth.github.failed"
| "auth.oauth.google.success"
| "auth.oauth.google.failed"
| "auth.session.revoke"
| "auth.session.revokeAll"
| "security.rate_limit.exceeded"
| "security.csrf.failed"
| "security.suspicious.activity"
| "admin.action"
| "auth.session_created"
| "system.session_cleanup";
| "admin.action";
/**
* Audit log entry structure
@@ -246,7 +242,6 @@ export async function getUserSecuritySummary(
lastLoginAt: string | null;
lastLoginIp: string | null;
uniqueIpCount: number;
recentSessions: number;
}> {
const conn = ConnectionFactory();
@@ -336,16 +331,6 @@ export async function getUserSecuritySummary(
});
const uniqueIpCount = (ipResult.rows[0]?.count as number) || 0;
const sessionResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog
WHERE user_id = ?
AND event_type = 'auth.login.success'
AND success = 1
AND created_at >= datetime('now', '-1 day')`,
args: [userId]
});
const recentSessions = (sessionResult.rows[0]?.count as number) || 0;
return {
totalEvents,
successfulEvents,
@@ -356,8 +341,7 @@ export async function getUserSecuritySummary(
failedLogins,
lastLoginAt: lastLogin?.created_at as string | null,
lastLoginIp: lastLogin?.ip_address as string | null,
uniqueIpCount,
recentSessions
uniqueIpCount
};
}

View File

@@ -1,8 +1,136 @@
import type { H3Event } from "vinxi/http";
import { getCookie, setCookie } from "vinxi/http";
import { OAuth2Client } from "google-auth-library";
import type { Row } from "@libsql/client/web";
import { SignJWT, jwtVerify } from "jose";
import { env } from "~/env/server";
import { getAuthSession } from "./session-helpers";
import { ConnectionFactory } from "./database";
import { AUTH_CONFIG, expiryToSeconds, getAccessTokenExpiry } from "~/config";
export const authCookieName = "auth_token";
type AuthTokenPayload = {
sub: string;
email: string | null;
isAdmin: boolean;
iat?: number;
exp?: number;
};
function getAuthCookieOptions(rememberMe: boolean) {
return {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
maxAge: rememberMe
? expiryToSeconds(AUTH_CONFIG.ACCESS_TOKEN_EXPIRY_LONG)
: undefined
};
}
function getAuthHeaderToken(event: H3Event): string | null {
const requestHeader = event.request?.headers?.get?.("authorization") || null;
const eventHeader = event.headers
? typeof (event.headers as any).get === "function"
? (event.headers as any).get("authorization")
: (event.headers as any).authorization
: null;
const nodeHeader = event.node?.req?.headers?.authorization || null;
const header = requestHeader || eventHeader || nodeHeader || null;
if (!header) return null;
const normalized = header.trim();
if (!normalized.toLowerCase().startsWith("bearer ")) return null;
return normalized.slice("Bearer ".length).trim();
}
export function getAuthTokenFromEvent(event: H3Event): string | null {
return getCookie(event, authCookieName) || getAuthHeaderToken(event);
}
export async function verifyAuthToken(
token: string
): Promise<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
@@ -15,9 +143,8 @@ export async function checkAuthStatus(event: H3Event): Promise<{
isAdmin: boolean;
}> {
try {
const session = await getAuthSession(event);
if (!session || !session.userId) {
const payload = await getAuthPayloadFromEvent(event);
if (!payload) {
return {
isAuthenticated: false,
userId: null,
@@ -27,8 +154,8 @@ export async function checkAuthStatus(event: H3Event): Promise<{
return {
isAuthenticated: true,
userId: session.userId,
isAdmin: session.isAdmin
userId: payload.sub,
isAdmin: payload.isAdmin
};
} catch (error) {
console.error("Auth check error:", error);
@@ -41,7 +168,7 @@ export async function checkAuthStatus(event: H3Event): Promise<{
}
/**
* Get user ID from session
* Get user ID from auth token
* @param event - H3Event
* @returns User ID or null if not authenticated
*/
@@ -67,10 +194,8 @@ export async function validateLineageRequest({
const { provider, email } = userRow;
if (provider === "email") {
try {
const { jwtVerify } = await import("jose");
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const { payload } = await jwtVerify(auth_token, secret);
if (email !== payload.email) {
const payload = await verifyAuthToken(auth_token);
if (!payload || email !== payload.email) {
return false;
}
} catch (err) {

View File

@@ -88,7 +88,7 @@ export function formatDeviceDescription(deviceInfo: DeviceInfo): string {
/**
* Create a short device fingerprint for comparison
* Not cryptographic, just for grouping similar sessions
* Not cryptographic, just for grouping similar logins
* @param deviceInfo - Device information
* @returns Short fingerprint string
*/

View File

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

View File

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

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