checkpoint
This commit is contained in:
@@ -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"
|
||||
});
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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" };
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user