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

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