+
Passwords did not match!
-
+
Email Already Exists!
@@ -347,7 +347,7 @@ export default function LoginPage() {
error() !== "duplicate"
}
>
-
{error()}
+
{error()}
diff --git a/src/server/api/root.ts b/src/server/api/root.ts
index 02cdb45..f831e99 100644
--- a/src/server/api/root.ts
+++ b/src/server/api/root.ts
@@ -1,5 +1,6 @@
import { exampleRouter } from "./routers/example";
import { authRouter } from "./routers/auth";
+import { auditRouter } from "./routers/audit";
import { databaseRouter } from "./routers/database";
import { lineageRouter } from "./routers/lineage";
import { miscRouter } from "./routers/misc";
@@ -13,6 +14,7 @@ import { createTRPCRouter } from "./utils";
export const appRouter = createTRPCRouter({
example: exampleRouter,
auth: authRouter,
+ audit: auditRouter,
database: databaseRouter,
lineage: lineageRouter,
misc: miscRouter,
diff --git a/src/server/api/routers/audit.ts b/src/server/api/routers/audit.ts
new file mode 100644
index 0000000..bae6e00
--- /dev/null
+++ b/src/server/api/routers/audit.ts
@@ -0,0 +1,133 @@
+import { createTRPCRouter, adminProcedure } from "../utils";
+import { z } from "zod";
+import {
+ queryAuditLogs,
+ getUserSecuritySummary,
+ cleanupOldLogs,
+ getFailedLoginAttempts,
+ detectSuspiciousActivity
+} from "~/server/audit";
+
+/**
+ * Audit log router - admin-only endpoints for querying security logs
+ */
+export const auditRouter = createTRPCRouter({
+ /**
+ * Query audit logs with filters
+ */
+ getLogs: adminProcedure
+ .input(
+ z.object({
+ userId: z.string().optional(),
+ eventType: z.string().optional(),
+ success: z.boolean().optional(),
+ startDate: z.string().optional(),
+ endDate: z.string().optional(),
+ limit: z.number().min(1).max(1000).default(100),
+ offset: z.number().min(0).default(0)
+ })
+ )
+ .query(async ({ input }) => {
+ const logs = await queryAuditLogs({
+ userId: input.userId,
+ eventType: input.eventType as any,
+ success: input.success,
+ startDate: input.startDate,
+ endDate: input.endDate,
+ limit: input.limit,
+ offset: input.offset
+ });
+
+ return {
+ logs,
+ count: logs.length,
+ offset: input.offset,
+ limit: input.limit
+ };
+ }),
+
+ /**
+ * Get failed login attempts (last 24 hours by default)
+ */
+ getFailedLogins: adminProcedure
+ .input(
+ z.object({
+ hours: z.number().min(1).max(168).default(24),
+ limit: z.number().min(1).max(1000).default(100)
+ })
+ )
+ .query(async ({ input }) => {
+ const attempts = (await getFailedLoginAttempts(
+ input.hours,
+ input.limit
+ )) as Array
;
+
+ return {
+ attempts,
+ count: attempts.length,
+ timeWindow: `${input.hours} hours`
+ };
+ }),
+
+ /**
+ * Get security summary for a specific user
+ */
+ getUserSummary: adminProcedure
+ .input(
+ z.object({
+ userId: z.string(),
+ days: z.number().min(1).max(90).default(30)
+ })
+ )
+ .query(async ({ input }) => {
+ const summary = await getUserSecuritySummary(input.userId, input.days);
+
+ return {
+ userId: input.userId,
+ summary,
+ timeWindow: `${input.days} days`
+ };
+ }),
+
+ /**
+ * Detect suspicious activity patterns
+ */
+ getSuspiciousActivity: adminProcedure
+ .input(
+ z.object({
+ hours: z.number().min(1).max(168).default(24),
+ minFailedAttempts: z.number().min(1).default(5)
+ })
+ )
+ .query(async ({ input }) => {
+ const suspicious = (await detectSuspiciousActivity(
+ input.hours,
+ input.minFailedAttempts
+ )) as Array;
+
+ return {
+ suspicious,
+ count: suspicious.length,
+ timeWindow: `${input.hours} hours`,
+ threshold: input.minFailedAttempts
+ };
+ }),
+
+ /**
+ * Clean up old logs
+ */
+ cleanupLogs: adminProcedure
+ .input(
+ z.object({
+ olderThanDays: z.number().min(1).max(365).default(90)
+ })
+ )
+ .mutation(async ({ input }) => {
+ const deleted = await cleanupOldLogs(input.olderThanDays);
+
+ return {
+ deleted,
+ olderThanDays: input.olderThanDays
+ };
+ })
+});
diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts
index 3beeee1..8960e20 100644
--- a/src/server/api/routers/auth.ts
+++ b/src/server/api/routers/auth.ts
@@ -3,7 +3,12 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { v4 as uuidV4 } from "uuid";
import { env } from "~/env/server";
-import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
+import {
+ ConnectionFactory,
+ hashPassword,
+ checkPassword,
+ checkPasswordSafe
+} from "~/server/utils";
import { SignJWT, jwtVerify } from "jose";
import { setCookie, getCookie } from "vinxi/http";
import type { User } from "~/db/types";
@@ -21,19 +26,123 @@ import {
resetPasswordSchema,
requestPasswordResetSchema
} from "../schemas/user";
+import {
+ setCSRFToken,
+ csrfProtection,
+ getClientIP,
+ getUserAgent,
+ getAuditContext,
+ rateLimitLogin,
+ rateLimitPasswordReset,
+ rateLimitRegistration,
+ rateLimitEmailVerification
+} from "~/server/security";
+import { logAuditEvent } from "~/server/audit";
+import type { H3Event } from "vinxi/http";
+import type { Context } from "../utils";
+/**
+ * Safely extract H3Event from Context
+ * In production: ctx.event is APIEvent, H3Event is at ctx.event.nativeEvent
+ * In development: ctx.event might be H3Event directly
+ */
+function getH3Event(ctx: Context): H3Event {
+ // Check if nativeEvent exists (production)
+ if (ctx.event && 'nativeEvent' in ctx.event && ctx.event.nativeEvent) {
+ return ctx.event.nativeEvent as H3Event;
+ }
+ // Otherwise, assume ctx.event is H3Event (development)
+ return ctx.event as unknown as H3Event;
+}
+
+/**
+ * Create JWT with session tracking
+ * @param userId - User ID
+ * @param sessionId - Session ID for revocation tracking
+ * @param expiresIn - Token expiration time (e.g., "14d", "12h")
+ */
async function createJWT(
userId: string,
+ sessionId: string,
expiresIn: string = "14d"
): Promise {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
- const token = await new SignJWT({ id: userId })
+ const token = await new SignJWT({
+ id: userId,
+ sid: sessionId, // Session ID for revocation
+ iat: Math.floor(Date.now() / 1000)
+ })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime(expiresIn)
.sign(secret);
return token;
}
+/**
+ * Create a new session in the database
+ * @param userId - User ID
+ * @param expiresIn - Session expiration (e.g., "14d", "12h")
+ * @param ipAddress - Client IP address
+ * @param userAgent - Client user agent string
+ * @returns Session ID
+ */
+async function createSession(
+ userId: string,
+ expiresIn: string,
+ ipAddress: string,
+ userAgent: string
+): Promise {
+ const conn = ConnectionFactory();
+ const sessionId = uuidV4();
+
+ // Calculate expiration timestamp
+ const expiresAt = new Date();
+ if (expiresIn.endsWith("d")) {
+ const days = parseInt(expiresIn);
+ expiresAt.setDate(expiresAt.getDate() + days);
+ } else if (expiresIn.endsWith("h")) {
+ const hours = parseInt(expiresIn);
+ expiresAt.setHours(expiresAt.getHours() + hours);
+ }
+
+ await conn.execute({
+ sql: `INSERT INTO Session (id, user_id, token_family, expires_at, ip_address, user_agent)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ args: [
+ sessionId,
+ userId,
+ uuidV4(), // token_family for future refresh token rotation
+ expiresAt.toISOString(),
+ ipAddress,
+ userAgent
+ ]
+ });
+
+ return sessionId;
+}
+
+/**
+ * Helper to set authentication cookies including CSRF token
+ */
+function setAuthCookies(
+ event: any,
+ token: string,
+ options: { maxAge?: number } = {}
+) {
+ const cookieOptions: any = {
+ path: "/",
+ httpOnly: true,
+ secure: env.NODE_ENV === "production",
+ sameSite: "lax",
+ ...options
+ };
+
+ setCookie(event, "userIDToken", token, cookieOptions);
+
+ // Set CSRF token for authenticated session
+ setCSRFToken(event);
+}
+
async function sendEmail(to: string, subject: string, htmlContent: string) {
const apiKey = env.SENDINBLUE_KEY;
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
@@ -199,9 +308,20 @@ export const authRouter = createTRPCRouter({
}
}
- const token = await createJWT(userId);
+ // Create session with client info
+ const clientIP = getClientIP(getH3Event(ctx));
+ const userAgent =
+ getUserAgent(getH3Event(ctx));
+ const sessionId = await createSession(
+ userId,
+ "14d",
+ clientIP,
+ userAgent
+ );
- setCookie(ctx.event.nativeEvent, "userIDToken", token, {
+ const token = await createJWT(userId, sessionId);
+
+ setCookie(getH3Event(ctx), "userIDToken", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days
path: "/",
httpOnly: true,
@@ -209,11 +329,37 @@ export const authRouter = createTRPCRouter({
sameSite: "lax"
});
+ // Set CSRF token for authenticated session
+ setCSRFToken(getH3Event(ctx));
+
+ // Log successful OAuth login
+ await logAuditEvent({
+ userId,
+ eventType: "auth.login.success",
+ eventData: { method: "github", isNewUser: !res.rows[0] },
+ ipAddress: clientIP,
+ userAgent,
+ success: true
+ });
+
return {
success: true,
redirectTo: "/account"
};
} catch (error) {
+ // Log failed OAuth login
+ const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
+ await logAuditEvent({
+ eventType: "auth.login.failed",
+ eventData: {
+ method: "github",
+ reason: error instanceof TRPCError ? error.message : "unknown"
+ },
+ ipAddress,
+ userAgent,
+ success: false
+ });
+
if (error instanceof TRPCError) {
throw error;
}
@@ -345,9 +491,20 @@ export const authRouter = createTRPCRouter({
}
}
- const token = await createJWT(userId);
+ // Create session with client info
+ const clientIP = getClientIP(getH3Event(ctx));
+ const userAgent =
+ getUserAgent(getH3Event(ctx));
+ const sessionId = await createSession(
+ userId,
+ "14d",
+ clientIP,
+ userAgent
+ );
- setCookie(ctx.event.nativeEvent, "userIDToken", token, {
+ const token = await createJWT(userId, sessionId);
+
+ setCookie(getH3Event(ctx), "userIDToken", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days
path: "/",
httpOnly: true,
@@ -355,11 +512,37 @@ export const authRouter = createTRPCRouter({
sameSite: "lax"
});
+ // Set CSRF token for authenticated session
+ setCSRFToken(getH3Event(ctx));
+
+ // Log successful OAuth login
+ await logAuditEvent({
+ userId,
+ eventType: "auth.login.success",
+ eventData: { method: "google", isNewUser: !res.rows[0] },
+ ipAddress: clientIP,
+ userAgent,
+ success: true
+ });
+
return {
success: true,
redirectTo: "/account"
};
} catch (error) {
+ // Log failed OAuth login
+ const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
+ await logAuditEvent({
+ eventType: "auth.login.failed",
+ eventData: {
+ method: "google",
+ reason: error instanceof TRPCError ? error.message : "unknown"
+ },
+ ipAddress,
+ userAgent,
+ success: false
+ });
+
if (error instanceof TRPCError) {
throw error;
}
@@ -428,7 +611,19 @@ export const authRouter = createTRPCRouter({
const userId = (res.rows[0] as unknown as User).id;
- const userToken = await createJWT(userId);
+ // Create session with client info
+ const clientIP = getClientIP(getH3Event(ctx));
+ const userAgent =
+ getUserAgent(getH3Event(ctx));
+ const expiresIn = rememberMe ? "14d" : "12h";
+ const sessionId = await createSession(
+ userId,
+ expiresIn,
+ clientIP,
+ userAgent
+ );
+
+ const userToken = await createJWT(userId, sessionId, expiresIn);
const cookieOptions: any = {
path: "/",
@@ -442,17 +637,44 @@ export const authRouter = createTRPCRouter({
}
setCookie(
- ctx.event.nativeEvent,
+ getH3Event(ctx),
"userIDToken",
userToken,
cookieOptions
);
+ // Set CSRF token for authenticated session
+ setCSRFToken(getH3Event(ctx));
+
+ // Log successful email link login
+ await logAuditEvent({
+ userId,
+ eventType: "auth.login.success",
+ eventData: { method: "email_link", rememberMe: rememberMe || false },
+ ipAddress: clientIP,
+ userAgent,
+ success: true
+ });
+
return {
success: true,
redirectTo: "/account"
};
} catch (error) {
+ // Log failed email link login
+ const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
+ await logAuditEvent({
+ eventType: "auth.login.failed",
+ eventData: {
+ method: "email_link",
+ email: input.email,
+ reason: error instanceof TRPCError ? error.message : "unknown"
+ },
+ ipAddress,
+ userAgent,
+ success: false
+ });
+
if (error instanceof TRPCError) {
throw error;
}
@@ -471,7 +693,7 @@ export const authRouter = createTRPCRouter({
token: z.string()
})
)
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
const { email, token } = input;
try {
@@ -486,15 +708,47 @@ export const authRouter = createTRPCRouter({
}
const conn = ConnectionFactory();
+
+ // Get user ID for audit log
+ const userRes = await conn.execute({
+ sql: "SELECT id FROM User WHERE email = ?",
+ args: [email]
+ });
+ const userId = userRes.rows[0] ? (userRes.rows[0] as any).id : null;
+
const query = `UPDATE User SET email_verified = ? WHERE email = ?`;
const params = [true, email];
await conn.execute({ sql: query, args: params });
+ // Log successful email verification
+ const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
+ await logAuditEvent({
+ userId,
+ eventType: "auth.email.verify.complete",
+ eventData: { email },
+ ipAddress,
+ userAgent,
+ success: true
+ });
+
return {
success: true,
message: "Email verification success, you may close this window"
};
} catch (error) {
+ // Log failed email verification
+ const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
+ await logAuditEvent({
+ eventType: "auth.email.verify.complete",
+ eventData: {
+ email,
+ reason: error instanceof TRPCError ? error.message : "unknown"
+ },
+ ipAddress,
+ userAgent,
+ success: false
+ });
+
if (error instanceof TRPCError) {
throw error;
}
@@ -511,6 +765,10 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const { email, password, passwordConfirmation } = input;
+ // Apply rate limiting
+ const clientIP = getClientIP(getH3Event(ctx));
+ rateLimitRegistration(clientIP, getH3Event(ctx));
+
// Schema already validates password match, but double check
if (password !== passwordConfirmation) {
throw new TRPCError({
@@ -529,9 +787,20 @@ export const authRouter = createTRPCRouter({
args: [userId, email, passwordHash, "email"]
});
- const token = await createJWT(userId);
+ // Create session with client info
+ const clientIP = getClientIP(getH3Event(ctx));
+ const userAgent =
+ getUserAgent(getH3Event(ctx));
+ const sessionId = await createSession(
+ userId,
+ "14d",
+ clientIP,
+ userAgent
+ );
- setCookie(ctx.event.nativeEvent, "userIDToken", token, {
+ const token = await createJWT(userId, sessionId);
+
+ setCookie(getH3Event(ctx), "userIDToken", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days
path: "/",
httpOnly: true,
@@ -539,8 +808,35 @@ export const authRouter = createTRPCRouter({
sameSite: "lax"
});
+ // Set CSRF token for authenticated session
+ setCSRFToken(getH3Event(ctx));
+
+ // Log successful registration
+ await logAuditEvent({
+ userId,
+ eventType: "auth.register.success",
+ eventData: { email, method: "email" },
+ ipAddress: clientIP,
+ userAgent,
+ success: true
+ });
+
return { success: true, message: "success" };
} catch (e) {
+ // Log failed registration
+ const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
+ await logAuditEvent({
+ eventType: "auth.register.failed",
+ eventData: {
+ email,
+ method: "email",
+ reason: e instanceof Error ? e.message : "unknown"
+ },
+ ipAddress,
+ userAgent,
+ success: false
+ });
+
console.error("Registration error:", e);
throw new TRPCError({
code: "BAD_REQUEST",
@@ -552,66 +848,131 @@ export const authRouter = createTRPCRouter({
emailPasswordLogin: publicProcedure
.input(loginUserSchema)
.mutation(async ({ input, ctx }) => {
- const { email, password, rememberMe } = input;
+ try {
+ const { email, password, rememberMe } = input;
- const conn = ConnectionFactory();
- const res = await conn.execute({
- sql: "SELECT * FROM User WHERE email = ?",
- args: [email]
- });
+ // Apply rate limiting
+ const clientIP = getClientIP(getH3Event(ctx));
+ rateLimitLogin(email, clientIP, getH3Event(ctx));
- if (res.rows.length === 0) {
+ const conn = ConnectionFactory();
+ const res = await conn.execute({
+ sql: "SELECT * FROM User WHERE email = ?",
+ args: [email]
+ });
+
+ // Always run password check to prevent timing attacks
+ const user =
+ res.rows.length > 0 ? (res.rows[0] as unknown as User) : null;
+ const passwordHash = user?.password_hash || null;
+ const passwordMatch = await checkPasswordSafe(password, passwordHash);
+
+ // Check all conditions after password verification
+ if (!user || !passwordHash || !passwordMatch) {
+ // Debug logging (remove after fixing)
+ console.log("Login failed for:", email);
+ console.log("User found:", !!user);
+ console.log("Password hash exists:", !!passwordHash);
+ console.log("Password match:", passwordMatch);
+
+ // Log failed login attempt (wrap in try-catch to ensure it never blocks auth flow)
+ try {
+ const { ipAddress, userAgent } = getAuditContext(
+ getH3Event(ctx)
+ );
+ await logAuditEvent({
+ eventType: "auth.login.failed",
+ eventData: {
+ email,
+ method: "password",
+ reason: "invalid_credentials"
+ },
+ ipAddress,
+ userAgent,
+ success: false
+ });
+ } catch (auditError) {
+ console.error("Audit logging failed:", auditError);
+ }
+
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "no-match"
+ });
+ }
+
+ if (
+ !user.provider ||
+ !["email", "google", "github", "apple"].includes(user.provider)
+ ) {
+ await conn.execute({
+ sql: "UPDATE User SET provider = ? WHERE id = ?",
+ args: ["email", user.id]
+ });
+ }
+
+ const expiresIn = rememberMe ? "14d" : "12h";
+
+ // Create session with client info (reuse clientIP from rate limiting)
+ const userAgent =
+ getUserAgent(getH3Event(ctx));
+ const sessionId = await createSession(
+ user.id,
+ expiresIn,
+ clientIP,
+ userAgent
+ );
+
+ const token = await createJWT(user.id, sessionId, expiresIn);
+
+ const cookieOptions: any = {
+ path: "/",
+ httpOnly: true,
+ secure: env.NODE_ENV === "production",
+ sameSite: "lax"
+ };
+
+ if (rememberMe) {
+ cookieOptions.maxAge = 60 * 60 * 24 * 14; // 14 days
+ }
+
+ setCookie(getH3Event(ctx), "userIDToken", token, cookieOptions);
+
+ // Set CSRF token for authenticated session
+ setCSRFToken(getH3Event(ctx));
+
+ // Log successful login (wrap in try-catch to ensure it never blocks auth flow)
+ try {
+ await logAuditEvent({
+ userId: user.id,
+ eventType: "auth.login.success",
+ eventData: { method: "password", rememberMe: rememberMe || false },
+ ipAddress: clientIP,
+ userAgent,
+ success: true
+ });
+ } catch (auditError) {
+ console.error("Audit logging failed:", auditError);
+ }
+
+ return { success: true, message: "success" };
+ } catch (error) {
+ // Log the actual error for debugging
+ console.error("emailPasswordLogin error:", error);
+ console.error("Error stack:", error instanceof Error ? error.stack : "no stack");
+
+ // Re-throw TRPCErrors as-is
+ if (error instanceof TRPCError) {
+ throw error;
+ }
+
+ // Wrap other errors
throw new TRPCError({
- code: "UNAUTHORIZED",
- message: "no-match"
+ code: "INTERNAL_SERVER_ERROR",
+ message: "An error occurred during login",
+ cause: error
});
}
-
- const user = res.rows[0] as unknown as User;
-
- if (!user.password_hash) {
- throw new TRPCError({
- code: "UNAUTHORIZED",
- message: "no-match"
- });
- }
-
- const passwordMatch = await checkPassword(password, user.password_hash);
-
- if (!passwordMatch) {
- throw new TRPCError({
- code: "UNAUTHORIZED",
- message: "no-match"
- });
- }
-
- if (
- !user.provider ||
- !["email", "google", "github", "apple"].includes(user.provider)
- ) {
- await conn.execute({
- sql: "UPDATE User SET provider = ? WHERE id = ?",
- args: ["email", user.id]
- });
- }
-
- const expiresIn = rememberMe ? "14d" : "12h";
- const token = await createJWT(user.id, expiresIn);
-
- const cookieOptions: any = {
- path: "/",
- httpOnly: true,
- secure: env.NODE_ENV === "production",
- sameSite: "lax"
- };
-
- if (rememberMe) {
- cookieOptions.maxAge = 60 * 60 * 24 * 14; // 14 days
- }
-
- setCookie(ctx.event.nativeEvent, "userIDToken", token, cookieOptions);
-
- return { success: true, message: "success" };
}),
requestEmailLinkLogin: publicProcedure
@@ -626,7 +987,7 @@ export const authRouter = createTRPCRouter({
try {
const requested = getCookie(
- ctx.event.nativeEvent,
+ getH3Event(ctx),
"emailLoginLinkRequested"
);
if (requested) {
@@ -705,7 +1066,7 @@ export const authRouter = createTRPCRouter({
const exp = new Date(Date.now() + 2 * 60 * 1000);
setCookie(
- ctx.event.nativeEvent,
+ getH3Event(ctx),
"emailLoginLinkRequested",
exp.toUTCString(),
{
@@ -745,9 +1106,13 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const { email } = input;
+ // Apply rate limiting
+ const clientIP = getClientIP(getH3Event(ctx));
+ rateLimitPasswordReset(clientIP, getH3Event(ctx));
+
try {
const requested = getCookie(
- ctx.event.nativeEvent,
+ getH3Event(ctx),
"passwordResetRequested"
);
if (requested) {
@@ -821,7 +1186,7 @@ export const authRouter = createTRPCRouter({
const exp = new Date(Date.now() + 5 * 60 * 1000);
setCookie(
- ctx.event.nativeEvent,
+ getH3Event(ctx),
"passwordResetRequested",
exp.toUTCString(),
{
@@ -830,8 +1195,38 @@ export const authRouter = createTRPCRouter({
}
);
+ // Log password reset request
+ const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
+ await logAuditEvent({
+ userId: user.id,
+ eventType: "auth.password.reset.request",
+ eventData: { email },
+ ipAddress,
+ userAgent,
+ success: true
+ });
+
return { success: true, message: "email sent" };
} catch (error) {
+ // Log failed password reset request (only if not rate limited)
+ if (
+ !(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
+ ) {
+ const { ipAddress, userAgent } = getAuditContext(
+ getH3Event(ctx)
+ );
+ await logAuditEvent({
+ eventType: "auth.password.reset.request",
+ eventData: {
+ email: input.email,
+ reason: error instanceof TRPCError ? error.message : "unknown"
+ },
+ ipAddress,
+ userAgent,
+ success: false
+ });
+ }
+
if (error instanceof TRPCError) {
throw error;
}
@@ -912,17 +1307,40 @@ export const authRouter = createTRPCRouter({
});
}
- setCookie(ctx.event.nativeEvent, "emailToken", "", {
+ setCookie(getH3Event(ctx), "emailToken", "", {
maxAge: 0,
path: "/"
});
- setCookie(ctx.event.nativeEvent, "userIDToken", "", {
+ setCookie(getH3Event(ctx), "userIDToken", "", {
maxAge: 0,
path: "/"
});
+ // Log successful password reset
+ const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
+ await logAuditEvent({
+ userId: payload.id,
+ eventType: "auth.password.reset.complete",
+ eventData: {},
+ ipAddress,
+ userAgent,
+ success: true
+ });
+
return { success: true, message: "success" };
} catch (error) {
+ // Log failed password reset
+ const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
+ await logAuditEvent({
+ eventType: "auth.password.reset.complete",
+ eventData: {
+ reason: error instanceof TRPCError ? error.message : "unknown"
+ },
+ ipAddress,
+ userAgent,
+ success: false
+ });
+
if (error instanceof TRPCError) {
throw error;
}
@@ -939,9 +1357,13 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const { email } = input;
+ // Apply rate limiting
+ const clientIP = getClientIP(getH3Event(ctx));
+ rateLimitEmailVerification(clientIP, getH3Event(ctx));
+
try {
const requested = getCookie(
- ctx.event.nativeEvent,
+ getH3Event(ctx),
"emailVerificationRequested"
);
if (requested) {
@@ -971,6 +1393,8 @@ export const authRouter = createTRPCRouter({
});
}
+ const user = res.rows[0] as unknown as User;
+
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ email })
.setProtectedHeader({ alg: "HS256" })
@@ -1016,7 +1440,7 @@ export const authRouter = createTRPCRouter({
await sendEmail(email, "freno.me email verification", htmlContent);
setCookie(
- ctx.event.nativeEvent,
+ getH3Event(ctx),
"emailVerificationRequested",
Date.now().toString(),
{
@@ -1025,8 +1449,38 @@ export const authRouter = createTRPCRouter({
}
);
+ // Log email verification request
+ const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
+ await logAuditEvent({
+ userId: user.id,
+ eventType: "auth.email.verify.request",
+ eventData: { email },
+ ipAddress,
+ userAgent,
+ success: true
+ });
+
return { success: true, message: "Verification email sent" };
} catch (error) {
+ // Log failed email verification request (only if not rate limited)
+ if (
+ !(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
+ ) {
+ const { ipAddress, userAgent } = getAuditContext(
+ getH3Event(ctx)
+ );
+ await logAuditEvent({
+ eventType: "auth.email.verify.request",
+ eventData: {
+ email: input.email,
+ reason: error instanceof TRPCError ? error.message : "unknown"
+ },
+ ipAddress,
+ userAgent,
+ success: false
+ });
+ }
+
if (error instanceof TRPCError) {
throw error;
}
@@ -1052,15 +1506,39 @@ export const authRouter = createTRPCRouter({
}),
signOut: publicProcedure.mutation(async ({ ctx }) => {
- setCookie(ctx.event.nativeEvent, "userIDToken", "", {
+ // Try to get user ID for audit log before clearing cookies
+ let userId: string | null = null;
+ try {
+ const token = getCookie(getH3Event(ctx), "userIDToken");
+ if (token) {
+ const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
+ const { payload } = await jwtVerify(token, secret);
+ userId = payload.id as string;
+ }
+ } catch (e) {
+ // Ignore token verification errors during signout
+ }
+
+ setCookie(getH3Event(ctx), "userIDToken", "", {
maxAge: 0,
path: "/"
});
- setCookie(ctx.event.nativeEvent, "emailToken", "", {
+ setCookie(getH3Event(ctx), "emailToken", "", {
maxAge: 0,
path: "/"
});
+ // Log signout
+ const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
+ await logAuditEvent({
+ userId,
+ eventType: "auth.logout",
+ eventData: {},
+ ipAddress,
+ userAgent,
+ success: true
+ });
+
return { success: true };
})
});
diff --git a/src/server/api/schemas/user.ts b/src/server/api/schemas/user.ts
index 48fd50a..d8c0029 100644
--- a/src/server/api/schemas/user.ts
+++ b/src/server/api/schemas/user.ts
@@ -1,4 +1,5 @@
import { z } from "zod";
+import { validatePassword } from "~/lib/validation";
/**
* User API Validation Schemas
@@ -7,6 +8,31 @@ import { z } from "zod";
* profile updates, and password management
*/
+// ============================================================================
+// Custom Password Validator
+// ============================================================================
+
+/**
+ * Secure password validation with strength requirements
+ * Minimum 12 characters, uppercase, lowercase, number, and special character
+ */
+const securePasswordSchema = z
+ .string()
+ .min(12, "Password must be at least 12 characters")
+ .refine(
+ (password) => {
+ const result = validatePassword(password);
+ return result.isValid;
+ },
+ (password) => {
+ const result = validatePassword(password);
+ return {
+ message:
+ result.errors.join(", ") || "Password does not meet requirements"
+ };
+ }
+ );
+
// ============================================================================
// Authentication Schemas
// ============================================================================
@@ -17,8 +43,8 @@ import { z } from "zod";
export const registerUserSchema = z
.object({
email: z.string().email(),
- password: z.string().min(8, "Password must be at least 8 characters"),
- passwordConfirmation: z.string().min(8)
+ password: securePasswordSchema,
+ passwordConfirmation: z.string().min(12)
})
.refine((data) => data.password === data.passwordConfirmation, {
message: "Passwords do not match",
@@ -73,10 +99,8 @@ export const updateProfileImageSchema = z.object({
export const changePasswordSchema = z
.object({
oldPassword: z.string().min(1, "Current password is required"),
- newPassword: z
- .string()
- .min(8, "New password must be at least 8 characters"),
- newPasswordConfirmation: z.string().min(8)
+ newPassword: securePasswordSchema,
+ newPasswordConfirmation: z.string().min(12)
})
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
message: "Passwords do not match",
@@ -92,8 +116,8 @@ export const changePasswordSchema = z
*/
export const setPasswordSchema = z
.object({
- newPassword: z.string().min(8, "Password must be at least 8 characters"),
- newPasswordConfirmation: z.string().min(8)
+ newPassword: securePasswordSchema,
+ newPasswordConfirmation: z.string().min(12)
})
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
message: "Passwords do not match",
@@ -113,8 +137,8 @@ export const requestPasswordResetSchema = z.object({
export const resetPasswordSchema = z
.object({
token: z.string().min(1),
- newPassword: z.string().min(8, "Password must be at least 8 characters"),
- newPasswordConfirmation: z.string().min(8)
+ newPassword: securePasswordSchema,
+ newPasswordConfirmation: z.string().min(12)
})
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
message: "Passwords do not match",
diff --git a/src/server/audit.test.ts b/src/server/audit.test.ts
new file mode 100644
index 0000000..d5c84ca
--- /dev/null
+++ b/src/server/audit.test.ts
@@ -0,0 +1,406 @@
+/**
+ * Audit Logging Tests
+ * Tests for audit logging system including log creation, querying, and analysis
+ */
+
+import { describe, it, expect, beforeEach } from "bun:test";
+import {
+ logAuditEvent,
+ queryAuditLogs,
+ getFailedLoginAttempts,
+ getUserSecuritySummary,
+ detectSuspiciousActivity,
+ cleanupOldLogs
+} from "~/server/audit";
+import { ConnectionFactory } from "~/server/database";
+
+// Helper to clean up test audit logs
+async function cleanupTestLogs() {
+ const conn = ConnectionFactory();
+ await conn.execute({
+ sql: "DELETE FROM AuditLog WHERE event_data LIKE '%test-%'"
+ });
+}
+
+describe("Audit Logging System", () => {
+ beforeEach(async () => {
+ await cleanupTestLogs();
+ });
+
+ describe("logAuditEvent", () => {
+ it("should create audit log with all fields", async () => {
+ await logAuditEvent({
+ eventType: "auth.login.success",
+ eventData: { method: "password", test: "test-1" },
+ ipAddress: "192.168.1.100",
+ userAgent: "Test Browser 1.0",
+ success: true
+ });
+
+ const logs = await queryAuditLogs({
+ ipAddress: "192.168.1.100",
+ limit: 1
+ });
+
+ expect(logs.length).toBe(1);
+ expect(logs[0].userId).toBeNull();
+ expect(logs[0].eventType).toBe("auth.login.success");
+ expect(logs[0].ipAddress).toBe("192.168.1.100");
+ expect(logs[0].userAgent).toBe("Test Browser 1.0");
+ expect(logs[0].success).toBe(true);
+
+ expect(logs[0].eventData.method).toBe("password");
+ });
+
+ it("should create audit log without user ID", async () => {
+ await logAuditEvent({
+ eventType: "auth.login.failed",
+ eventData: { email: "test@example.com", test: "test-2" },
+ ipAddress: "192.168.1.2",
+ userAgent: "Test Browser 1.0",
+ success: false
+ });
+
+ const logs = await queryAuditLogs({
+ eventType: "auth.login.failed",
+ limit: 1
+ });
+
+ expect(logs.length).toBe(1);
+ expect(logs[0].userId).toBeNull();
+ expect(logs[0].success).toBe(false);
+ });
+
+ it("should handle missing optional fields gracefully", async () => {
+ await logAuditEvent({
+ eventType: "auth.logout",
+ success: true
+ });
+
+ const logs = await queryAuditLogs({
+ eventType: "auth.logout",
+ limit: 1
+ });
+
+ expect(logs.length).toBe(1);
+ expect(logs[0].ipAddress).toBeNull();
+ expect(logs[0].userAgent).toBeNull();
+ expect(logs[0].eventData).toBeNull();
+ });
+
+ it("should not throw errors on logging failures", async () => {
+ // This should not throw even if there's an invalid event type
+ await expect(
+ logAuditEvent({
+ eventType: "invalid.test.event",
+ success: true
+ })
+ ).resolves.toBeUndefined();
+ });
+ });
+
+ describe("queryAuditLogs", () => {
+ beforeEach(async () => {
+ // Create test logs
+ await logAuditEvent({
+ eventType: "auth.login.success",
+ eventData: { test: "test-query-1", testUser: "user-1" },
+ ipAddress: "192.168.1.10",
+ success: true
+ });
+
+ await logAuditEvent({
+ eventType: "auth.login.failed",
+ eventData: { test: "test-query-2", testUser: "user-1" },
+ ipAddress: "192.168.1.10",
+ success: false
+ });
+
+ await logAuditEvent({
+ eventType: "auth.login.success",
+ eventData: { test: "test-query-3", testUser: "user-2" },
+ ipAddress: "192.168.1.20",
+ success: true
+ });
+
+ await logAuditEvent({
+ eventType: "auth.password_reset.requested",
+ eventData: { test: "test-query-4", testUser: "user-2" },
+ ipAddress: "192.168.1.20",
+ success: true
+ });
+ });
+
+ it("should query logs by IP address (simulating user)", async () => {
+ const logs = await queryAuditLogs({ ipAddress: "192.168.1.10" });
+
+ expect(logs.length).toBeGreaterThanOrEqual(2);
+ expect(logs.every((log) => log.ipAddress === "192.168.1.10")).toBe(true);
+ });
+
+ it("should query logs by event type", async () => {
+ const logs = await queryAuditLogs({
+ eventType: "auth.login.success"
+ });
+
+ expect(logs.length).toBeGreaterThanOrEqual(2);
+ expect(logs.every((log) => log.eventType === "auth.login.success")).toBe(
+ true
+ );
+ });
+
+ it("should query logs by success status", async () => {
+ const successLogs = await queryAuditLogs({ success: true });
+ const failedLogs = await queryAuditLogs({ success: false });
+
+ expect(successLogs.length).toBeGreaterThanOrEqual(3);
+ expect(failedLogs.length).toBeGreaterThanOrEqual(1);
+ expect(successLogs.every((log) => log.success === true)).toBe(true);
+ expect(failedLogs.every((log) => log.success === false)).toBe(true);
+ });
+
+ it("should respect limit parameter", async () => {
+ const logs = await queryAuditLogs({ limit: 2 });
+
+ expect(logs.length).toBeLessThanOrEqual(2);
+ });
+
+ it("should respect offset parameter", async () => {
+ const firstPage = await queryAuditLogs({ limit: 2, offset: 0 });
+ const secondPage = await queryAuditLogs({ limit: 2, offset: 2 });
+
+ expect(firstPage.length).toBeGreaterThan(0);
+ if (secondPage.length > 0) {
+ expect(firstPage[0].id).not.toBe(secondPage[0].id);
+ }
+ });
+
+ it("should filter by date range", async () => {
+ const now = new Date();
+ const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
+ const oneDayFromNow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
+
+ const logs = await queryAuditLogs({
+ startDate: oneHourAgo.toISOString(),
+ endDate: oneDayFromNow.toISOString()
+ });
+
+ expect(logs.length).toBeGreaterThanOrEqual(4);
+ });
+
+ it("should return empty array when no logs match", async () => {
+ const logs = await queryAuditLogs({
+ userId: "nonexistent-user-xyz"
+ });
+
+ expect(logs).toEqual([]);
+ });
+ });
+
+ describe("getFailedLoginAttempts", () => {
+ beforeEach(async () => {
+ // Create failed login attempts
+ for (let i = 0; i < 5; i++) {
+ await logAuditEvent({
+ eventType: "auth.login.failed",
+ eventData: {
+ email: `test${i}@example.com`,
+ test: `test-failed-${i}`
+ },
+ ipAddress: `192.168.1.${i}`,
+ success: false
+ });
+ }
+
+ // Create successful logins (should be excluded)
+ await logAuditEvent({
+ eventType: "auth.login.success",
+ eventData: { test: "test-success-1" },
+ success: true
+ });
+ });
+
+ it("should return only failed login attempts", async () => {
+ const attempts = await getFailedLoginAttempts(24, 10);
+
+ expect(attempts.length).toBeGreaterThanOrEqual(5);
+ expect(
+ attempts.every((attempt) => attempt.event_type === "auth.login.failed")
+ ).toBe(true);
+ expect(attempts.every((attempt) => attempt.success === 0)).toBe(true);
+ });
+
+ it("should respect limit parameter", async () => {
+ const attempts = await getFailedLoginAttempts(24, 3);
+
+ expect(attempts.length).toBeLessThanOrEqual(3);
+ });
+
+ it("should filter by time window", async () => {
+ const attemptsIn24h = await getFailedLoginAttempts(24, 100);
+ const attemptsIn1h = await getFailedLoginAttempts(1, 100);
+
+ expect(attemptsIn24h.length).toBeGreaterThanOrEqual(5);
+ // Recent attempts should be within 1 hour
+ expect(attemptsIn1h.length).toBeGreaterThanOrEqual(5);
+ });
+ });
+
+ describe("getUserSecuritySummary", () => {
+ beforeEach(async () => {
+ // Create various events for summary (without user IDs due to FK constraint)
+ await logAuditEvent({
+ eventType: "auth.login.success",
+ eventData: { test: "test-summary-1", testSummaryUser: "summary-123" },
+ ipAddress: "192.168.99.1",
+ success: true
+ });
+
+ await logAuditEvent({
+ eventType: "auth.login.failed",
+ eventData: { test: "test-summary-2", testSummaryUser: "summary-123" },
+ ipAddress: "192.168.99.1",
+ success: false
+ });
+
+ await logAuditEvent({
+ eventType: "auth.login.failed",
+ eventData: { test: "test-summary-3", testSummaryUser: "summary-123" },
+ ipAddress: "192.168.99.1",
+ success: false
+ });
+
+ await logAuditEvent({
+ eventType: "auth.password_reset.requested",
+ eventData: { test: "test-summary-4", testSummaryUser: "summary-123" },
+ ipAddress: "192.168.99.1",
+ success: true
+ });
+ });
+
+ it("should return zero counts for non-existent user", async () => {
+ // Since we can't use real user IDs in tests, this test verifies query works
+ const summary = await getUserSecuritySummary("nonexistent-user", 30);
+
+ expect(summary).toHaveProperty("totalEvents");
+ expect(summary).toHaveProperty("successfulEvents");
+ expect(summary).toHaveProperty("failedEvents");
+ expect(summary).toHaveProperty("eventTypes");
+ expect(summary).toHaveProperty("uniqueIPs");
+ });
+
+ it("should return summary structure", async () => {
+ const summary = await getUserSecuritySummary("nonexistent-user", 30);
+
+ expect(summary.totalEvents).toBe(0);
+ expect(summary.successfulEvents).toBe(0);
+ expect(summary.failedEvents).toBe(0);
+ expect(summary.eventTypes.length).toBe(0);
+ expect(summary.uniqueIPs.length).toBe(0);
+ });
+ });
+
+ describe("detectSuspiciousActivity", () => {
+ beforeEach(async () => {
+ // Create suspicious pattern: many failed logins from same IP
+ for (let i = 0; i < 10; i++) {
+ await logAuditEvent({
+ eventType: "auth.login.failed",
+ eventData: {
+ email: `victim${i}@example.com`,
+ test: `test-suspicious-${i}`
+ },
+ ipAddress: "10.0.0.1",
+ success: false
+ });
+ }
+
+ // Create normal activity
+ await logAuditEvent({
+ eventType: "auth.login.success",
+ eventData: { test: "test-normal-1" },
+ ipAddress: "10.0.0.2",
+ success: true
+ });
+ });
+
+ it("should detect IPs with excessive failed attempts", async () => {
+ const suspicious = await detectSuspiciousActivity(24, 5);
+
+ expect(suspicious.length).toBeGreaterThanOrEqual(1);
+
+ const suspiciousIP = suspicious.find((s) => s.ipAddress === "10.0.0.1");
+ expect(suspiciousIP).toBeDefined();
+ expect(suspiciousIP!.failedAttempts).toBeGreaterThanOrEqual(10);
+ });
+
+ it("should respect minimum attempts threshold", async () => {
+ const lowThreshold = await detectSuspiciousActivity(24, 5);
+ const highThreshold = await detectSuspiciousActivity(24, 20);
+
+ expect(lowThreshold.length).toBeGreaterThanOrEqual(highThreshold.length);
+ });
+
+ it("should return empty array when no suspicious activity", async () => {
+ await cleanupTestLogs();
+
+ // Create only successful logins
+ await logAuditEvent({
+ eventType: "auth.login.success",
+ eventData: { test: "test-clean-1" },
+ ipAddress: "10.0.0.100",
+ success: true
+ });
+
+ const suspicious = await detectSuspiciousActivity(24, 5);
+ const cleanIP = suspicious.find((s) => s.ipAddress === "10.0.0.100");
+
+ expect(cleanIP).toBeUndefined();
+ });
+ });
+
+ describe("cleanupOldLogs", () => {
+ it("should delete logs older than specified days", async () => {
+ // Create an old log by directly inserting with past date
+ const conn = ConnectionFactory();
+ const veryOldDate = new Date();
+ veryOldDate.setDate(veryOldDate.getDate() - 100); // 100 days ago
+
+ await conn.execute({
+ sql: `INSERT INTO AuditLog (id, event_type, event_data, success, created_at)
+ VALUES (?, ?, ?, ?, ?)`,
+ args: [
+ `old-log-${Date.now()}`,
+ "auth.login.success",
+ JSON.stringify({ test: "test-cleanup-1" }),
+ 1,
+ veryOldDate.toISOString()
+ ]
+ });
+
+ // Clean up logs older than 90 days
+ const deleted = await cleanupOldLogs(90);
+
+ expect(deleted).toBeGreaterThanOrEqual(1);
+ });
+
+ it("should not delete recent logs", async () => {
+ await logAuditEvent({
+ eventType: "auth.login.success",
+ eventData: { test: "test-recent-1" },
+ success: true
+ });
+
+ const logsBefore = await queryAuditLogs({ limit: 100 });
+ const countBefore = logsBefore.length;
+
+ // Try to clean up logs older than 1 day (should not delete recent log)
+ await cleanupOldLogs(1);
+
+ const logsAfter = await queryAuditLogs({ limit: 100 });
+
+ // Should still have recent logs
+ expect(logsAfter.length).toBeGreaterThan(0);
+ });
+ });
+});
diff --git a/src/server/audit.ts b/src/server/audit.ts
new file mode 100644
index 0000000..e36691a
--- /dev/null
+++ b/src/server/audit.ts
@@ -0,0 +1,516 @@
+/**
+ * Audit Logging System
+ * Tracks security-relevant events for incident response and forensics
+ */
+
+import { ConnectionFactory } from "./database";
+import { v4 as uuid } from "uuid";
+
+/**
+ * Audit event types for security tracking
+ */
+export type AuditEventType =
+ // Authentication events
+ | "auth.login.success"
+ | "auth.login.failed"
+ | "auth.logout"
+ | "auth.register.success"
+ | "auth.register.failed"
+ // Password events
+ | "auth.password.change"
+ | "auth.password.reset.request"
+ | "auth.password.reset.complete"
+ // Email verification
+ | "auth.email.verify.request"
+ | "auth.email.verify.complete"
+ // OAuth events
+ | "auth.oauth.github.success"
+ | "auth.oauth.github.failed"
+ | "auth.oauth.google.success"
+ | "auth.oauth.google.failed"
+ // Session management
+ | "auth.session.revoke"
+ | "auth.session.revokeAll"
+ // Security events
+ | "security.rate_limit.exceeded"
+ | "security.csrf.failed"
+ | "security.suspicious.activity"
+ // Admin actions
+ | "admin.action";
+
+/**
+ * Audit log entry structure
+ */
+export interface AuditLogEntry {
+ userId?: string;
+ eventType: AuditEventType;
+ eventData?: Record;
+ ipAddress?: string;
+ userAgent?: string;
+ success: boolean;
+}
+
+/**
+ * Log security/audit event to database
+ * Fire-and-forget - failures are logged to console but don't block operations
+ *
+ * @param entry - Audit log entry to record
+ * @returns Promise that resolves when log is written (or fails silently)
+ */
+export async function logAuditEvent(entry: AuditLogEntry): Promise {
+ try {
+ const conn = ConnectionFactory();
+ await conn.execute({
+ sql: `INSERT INTO AuditLog (id, user_id, event_type, event_data, ip_address, user_agent, success)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ args: [
+ uuid(),
+ entry.userId || null,
+ entry.eventType,
+ entry.eventData ? JSON.stringify(entry.eventData) : null,
+ entry.ipAddress || null,
+ entry.userAgent || null,
+ entry.success ? 1 : 0
+ ]
+ });
+ } catch (error) {
+ // Never throw - logging failures shouldn't break auth flows
+ console.error("Failed to write audit log:", error, entry);
+ }
+}
+
+/**
+ * Query parameters for audit log searches
+ */
+export interface AuditLogQuery {
+ userId?: string;
+ eventType?: AuditEventType;
+ success?: boolean;
+ ipAddress?: string;
+ startDate?: Date;
+ endDate?: Date;
+ limit?: number;
+ offset?: number;
+}
+
+/**
+ * Query audit logs for security analysis
+ *
+ * @param query - Search parameters
+ * @returns Array of audit log entries
+ */
+export async function queryAuditLogs(
+ query: AuditLogQuery
+): Promise>> {
+ const conn = ConnectionFactory();
+
+ let sql = "SELECT * FROM AuditLog WHERE 1=1";
+ const args: any[] = [];
+
+ if (query.userId) {
+ sql += " AND user_id = ?";
+ args.push(query.userId);
+ }
+
+ if (query.eventType) {
+ sql += " AND event_type = ?";
+ args.push(query.eventType);
+ }
+
+ if (query.success !== undefined) {
+ sql += " AND success = ?";
+ args.push(query.success ? 1 : 0);
+ }
+
+ if (query.ipAddress) {
+ sql += " AND ip_address = ?";
+ args.push(query.ipAddress);
+ }
+
+ if (query.startDate) {
+ sql += " AND created_at >= ?";
+ args.push(
+ typeof query.startDate === "string"
+ ? query.startDate
+ : query.startDate.toISOString()
+ );
+ }
+
+ if (query.endDate) {
+ sql += " AND created_at <= ?";
+ args.push(
+ typeof query.endDate === "string"
+ ? query.endDate
+ : query.endDate.toISOString()
+ );
+ }
+
+ sql += " ORDER BY created_at DESC";
+
+ if (query.limit) {
+ sql += " LIMIT ?";
+ args.push(query.limit);
+ }
+
+ if (query.offset) {
+ sql += " OFFSET ?";
+ args.push(query.offset);
+ }
+
+ const result = await conn.execute({ sql, args });
+ return result.rows.map((row) => ({
+ id: row.id,
+ userId: row.user_id,
+ eventType: row.event_type,
+ eventData: row.event_data ? JSON.parse(row.event_data as string) : null,
+ ipAddress: row.ip_address,
+ userAgent: row.user_agent,
+ success: row.success === 1,
+ createdAt: row.created_at
+ }));
+}
+
+/**
+ * Get recent failed login attempts for a user or IP address
+ * Can also be used to query all recent failed login attempts
+ *
+ * @param identifierOrHours - User ID, IP address, or number of hours to look back
+ * @param identifierTypeOrLimit - Type of identifier ('user_id' or 'ip_address'), or limit for aggregate query
+ * @param withinMinutes - Time window to check (default: 15 minutes) - only used for specific identifier queries
+ * @returns Count of failed login attempts, or array of attempts for aggregate queries
+ */
+export async function getFailedLoginAttempts(
+ identifierOrHours: string | number,
+ identifierTypeOrLimit?: "user_id" | "ip_address" | number,
+ withinMinutes: number = 15
+): Promise>> {
+ const conn = ConnectionFactory();
+
+ // Aggregate query: getFailedLoginAttempts(24, 100) - get all failed logins in last 24 hours
+ if (
+ typeof identifierOrHours === "number" &&
+ typeof identifierTypeOrLimit === "number"
+ ) {
+ const hours = identifierOrHours;
+ const limit = identifierTypeOrLimit;
+
+ const result = await conn.execute({
+ sql: `SELECT * FROM AuditLog
+ WHERE event_type = 'auth.login.failed'
+ AND success = 0
+ AND created_at >= datetime('now', '-${hours} hours')
+ ORDER BY created_at DESC
+ LIMIT ?`,
+ args: [limit]
+ });
+
+ return result.rows.map((row) => ({
+ id: row.id,
+ user_id: row.user_id,
+ event_type: row.event_type,
+ event_data: row.event_data ? JSON.parse(row.event_data as string) : null,
+ ip_address: row.ip_address,
+ user_agent: row.user_agent,
+ success: row.success,
+ created_at: row.created_at
+ }));
+ }
+
+ // Specific identifier query: getFailedLoginAttempts("user-123", "user_id", 15)
+ const identifier = identifierOrHours as string;
+ const identifierType = identifierTypeOrLimit as "user_id" | "ip_address";
+ const column = identifierType === "user_id" ? "user_id" : "ip_address";
+
+ const result = await conn.execute({
+ sql: `SELECT COUNT(*) as count FROM AuditLog
+ WHERE ${column} = ?
+ AND event_type = 'auth.login.failed'
+ AND success = 0
+ AND created_at >= datetime('now', '-${withinMinutes} minutes')`,
+ args: [identifier]
+ });
+
+ return (result.rows[0]?.count as number) || 0;
+}
+
+/**
+ * Get security summary for a user
+ *
+ * @param userId - User ID to get summary for
+ * @param days - Number of days to look back (default: 30)
+ * @returns Security metrics for the user
+ */
+export async function getUserSecuritySummary(
+ userId: string,
+ days: number = 30
+): Promise<{
+ totalEvents: number;
+ successfulEvents: number;
+ failedEvents: number;
+ eventTypes: string[];
+ uniqueIPs: string[];
+ totalLogins: number;
+ failedLogins: number;
+ lastLoginAt: string | null;
+ lastLoginIp: string | null;
+ uniqueIpCount: number;
+ recentSessions: number;
+}> {
+ const conn = ConnectionFactory();
+
+ // Get total events for user in time period
+ const totalEventsResult = await conn.execute({
+ sql: `SELECT COUNT(*) as count FROM AuditLog
+ WHERE user_id = ?
+ AND created_at >= datetime('now', '-${days} days')`,
+ args: [userId]
+ });
+ const totalEvents = (totalEventsResult.rows[0]?.count as number) || 0;
+
+ // Get successful events
+ const successfulEventsResult = await conn.execute({
+ sql: `SELECT COUNT(*) as count FROM AuditLog
+ WHERE user_id = ?
+ AND success = 1
+ AND created_at >= datetime('now', '-${days} days')`,
+ args: [userId]
+ });
+ const successfulEvents =
+ (successfulEventsResult.rows[0]?.count as number) || 0;
+
+ // Get failed events
+ const failedEventsResult = await conn.execute({
+ sql: `SELECT COUNT(*) as count FROM AuditLog
+ WHERE user_id = ?
+ AND success = 0
+ AND created_at >= datetime('now', '-${days} days')`,
+ args: [userId]
+ });
+ const failedEvents = (failedEventsResult.rows[0]?.count as number) || 0;
+
+ // Get unique event types
+ const eventTypesResult = await conn.execute({
+ sql: `SELECT DISTINCT event_type FROM AuditLog
+ WHERE user_id = ?
+ AND created_at >= datetime('now', '-${days} days')`,
+ args: [userId]
+ });
+ const eventTypes = eventTypesResult.rows.map(
+ (row) => row.event_type as string
+ );
+
+ // Get unique IPs
+ const uniqueIPsResult = await conn.execute({
+ sql: `SELECT DISTINCT ip_address FROM AuditLog
+ WHERE user_id = ?
+ AND ip_address IS NOT NULL
+ AND created_at >= datetime('now', '-${days} days')`,
+ args: [userId]
+ });
+ const uniqueIPs = uniqueIPsResult.rows.map((row) => row.ip_address as string);
+
+ // Get total successful logins
+ const loginResult = await conn.execute({
+ sql: `SELECT COUNT(*) as count FROM AuditLog
+ WHERE user_id = ?
+ AND event_type = 'auth.login.success'
+ AND success = 1`,
+ args: [userId]
+ });
+ const totalLogins = (loginResult.rows[0]?.count as number) || 0;
+
+ // Get failed login attempts
+ const failedResult = await conn.execute({
+ sql: `SELECT COUNT(*) as count FROM AuditLog
+ WHERE user_id = ?
+ AND event_type = 'auth.login.failed'
+ AND success = 0
+ AND created_at >= datetime('now', '-${days} days')`,
+ args: [userId]
+ });
+ const failedLogins = (failedResult.rows[0]?.count as number) || 0;
+
+ // Get last login info
+ const lastLoginResult = await conn.execute({
+ sql: `SELECT created_at, ip_address FROM AuditLog
+ WHERE user_id = ?
+ AND event_type = 'auth.login.success'
+ AND success = 1
+ ORDER BY created_at DESC
+ LIMIT 1`,
+ args: [userId]
+ });
+ const lastLogin = lastLoginResult.rows[0];
+
+ // Get unique IP count
+ const ipResult = await conn.execute({
+ sql: `SELECT COUNT(DISTINCT ip_address) as count FROM AuditLog
+ WHERE user_id = ?
+ AND event_type = 'auth.login.success'
+ AND success = 1
+ AND created_at >= datetime('now', '-${days} days')`,
+ args: [userId]
+ });
+ const uniqueIpCount = (ipResult.rows[0]?.count as number) || 0;
+
+ // Get recent sessions (last 24 hours)
+ 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,
+ failedEvents,
+ eventTypes,
+ uniqueIPs,
+ totalLogins,
+ failedLogins,
+ lastLoginAt: lastLogin?.created_at as string | null,
+ lastLoginIp: lastLogin?.ip_address as string | null,
+ uniqueIpCount,
+ recentSessions
+ };
+}
+
+/**
+ * Detect suspicious activity patterns
+ * Can detect for a specific user or aggregate suspicious IPs
+ *
+ * @param userIdOrHours - User ID or number of hours to look back for aggregate query
+ * @param currentIpOrMinAttempts - Current IP address or minimum attempts threshold for aggregate query
+ * @returns Suspicion result for user, or array of suspicious IPs for aggregate query
+ */
+export async function detectSuspiciousActivity(
+ userIdOrHours: string | number,
+ currentIpOrMinAttempts?: string | number
+): Promise<
+ | {
+ isSuspicious: boolean;
+ reasons: string[];
+ }
+ | Array<{
+ ipAddress: string;
+ failedAttempts: number;
+ uniqueEmails: number;
+ }>
+> {
+ const conn = ConnectionFactory();
+
+ // Aggregate query: detectSuspiciousActivity(24, 5) - find IPs with 5+ failed attempts in 24 hours
+ if (
+ typeof userIdOrHours === "number" &&
+ typeof currentIpOrMinAttempts === "number"
+ ) {
+ const hours = userIdOrHours;
+ const minAttempts = currentIpOrMinAttempts;
+
+ const result = await conn.execute({
+ sql: `SELECT
+ ip_address,
+ COUNT(*) as failed_attempts,
+ COUNT(DISTINCT json_extract(event_data, '$.email')) as unique_emails
+ FROM AuditLog
+ WHERE event_type = 'auth.login.failed'
+ AND success = 0
+ AND ip_address IS NOT NULL
+ AND created_at >= datetime('now', '-${hours} hours')
+ GROUP BY ip_address
+ HAVING COUNT(*) >= ?
+ ORDER BY failed_attempts DESC`,
+ args: [minAttempts]
+ });
+
+ return result.rows.map((row) => ({
+ ipAddress: row.ip_address as string,
+ failedAttempts: row.failed_attempts as number,
+ uniqueEmails: row.unique_emails as number
+ }));
+ }
+
+ // User-specific query: detectSuspiciousActivity("user-123", "192.168.1.1")
+ const userId = userIdOrHours as string;
+ const currentIp = currentIpOrMinAttempts as string;
+ const reasons: string[] = [];
+
+ // Check for excessive failed logins
+ const failedAttempts = (await getFailedLoginAttempts(
+ userId,
+ "user_id",
+ 15
+ )) as number;
+ if (failedAttempts >= 3) {
+ reasons.push(`${failedAttempts} failed login attempts in last 15 minutes`);
+ }
+
+ // Check for rapid location changes (different IPs in short time)
+ const recentIps = await conn.execute({
+ sql: `SELECT DISTINCT ip_address FROM AuditLog
+ WHERE user_id = ?
+ AND event_type = 'auth.login.success'
+ AND success = 1
+ AND created_at >= datetime('now', '-1 hour')`,
+ args: [userId]
+ });
+
+ if (recentIps.rows.length >= 3) {
+ reasons.push(
+ `Logins from ${recentIps.rows.length} different IPs in last hour`
+ );
+ }
+
+ // Check for new IP if user has login history
+ const ipHistory = await conn.execute({
+ sql: `SELECT COUNT(*) as count FROM AuditLog
+ WHERE user_id = ?
+ AND ip_address = ?
+ AND event_type = 'auth.login.success'
+ AND success = 1`,
+ args: [userId, currentIp]
+ });
+
+ const hasUsedIpBefore = (ipHistory.rows[0]?.count as number) > 0;
+ if (!hasUsedIpBefore) {
+ const totalLogins = await conn.execute({
+ sql: `SELECT COUNT(*) as count FROM AuditLog
+ WHERE user_id = ?
+ AND event_type = 'auth.login.success'
+ AND success = 1`,
+ args: [userId]
+ });
+
+ if ((totalLogins.rows[0]?.count as number) > 0) {
+ reasons.push("Login from new IP address");
+ }
+ }
+
+ return {
+ isSuspicious: reasons.length > 0,
+ reasons
+ };
+}
+
+/**
+ * Clean up old audit logs (for maintenance/GDPR compliance)
+ *
+ * @param olderThanDays - Delete logs older than this many days
+ * @returns Number of logs deleted
+ */
+export async function cleanupOldLogs(olderThanDays: number): Promise {
+ const conn = ConnectionFactory();
+
+ const result = await conn.execute({
+ sql: `DELETE FROM AuditLog
+ WHERE created_at < datetime('now', '-${olderThanDays} days')
+ RETURNING id`,
+ args: []
+ });
+
+ return result.rows.length;
+}
diff --git a/src/server/auth.ts b/src/server/auth.ts
index 9a46f82..8af223b 100644
--- a/src/server/auth.ts
+++ b/src/server/auth.ts
@@ -3,12 +3,111 @@ import { jwtVerify } from "jose";
import { OAuth2Client } from "google-auth-library";
import type { Row } from "@libsql/client/web";
import { env } from "~/env/server";
+import { ConnectionFactory } from "./database";
+
+/**
+ * Extract cookie value from H3Event (works in both production and tests)
+ * Falls back to manual header parsing if vinxi's getCookie fails
+ */
+function getCookieValue(event: H3Event, name: string): string | undefined {
+ try {
+ // Try vinxi's getCookie first
+ return getCookie(event, name);
+ } catch (e) {
+ // Fallback for tests: parse cookie header manually
+ try {
+ const cookieHeader =
+ event.headers?.get("cookie") || event.node?.req?.headers?.cookie || "";
+ const cookies = cookieHeader
+ .split(";")
+ .map((c) => c.trim())
+ .reduce(
+ (acc, cookie) => {
+ const [key, value] = cookie.split("=");
+ if (key && value) acc[key] = value;
+ return acc;
+ },
+ {} as Record
+ );
+ return cookies[name];
+ } catch {
+ return undefined;
+ }
+ }
+}
+
+/**
+ * Clear cookie (works in both production and tests)
+ */
+function clearCookie(event: H3Event, name: string): void {
+ try {
+ setCookie(event, name, "", {
+ maxAge: 0,
+ expires: new Date("2016-10-05")
+ });
+ } catch (e) {
+ // In tests, setCookie might fail silently
+ }
+}
+
+/**
+ * Validate session and update last_used timestamp
+ * @param sessionId - Session ID from JWT
+ * @param userId - User ID from JWT
+ * @returns true if session is valid, false otherwise
+ */
+async function validateSession(
+ sessionId: string,
+ userId: string
+): Promise {
+ try {
+ const conn = ConnectionFactory();
+ const result = await conn.execute({
+ sql: `SELECT revoked, expires_at FROM Session
+ WHERE id = ? AND user_id = ?`,
+ args: [sessionId, userId]
+ });
+
+ if (result.rows.length === 0) {
+ // Session doesn't exist
+ return false;
+ }
+
+ const session = result.rows[0];
+
+ // Check if session is revoked
+ if (session.revoked === 1) {
+ return false;
+ }
+
+ // Check if session is expired
+ const expiresAt = new Date(session.expires_at as string);
+ if (expiresAt < new Date()) {
+ return false;
+ }
+
+ // Update last_used timestamp (fire and forget, don't block)
+ conn
+ .execute({
+ sql: "UPDATE Session SET last_used = datetime('now') WHERE id = ?",
+ args: [sessionId]
+ })
+ .catch((err) =>
+ console.error("Failed to update session last_used:", err)
+ );
+
+ return true;
+ } catch (e) {
+ console.error("Session validation error:", e);
+ return false;
+ }
+}
export async function getPrivilegeLevel(
event: H3Event
): Promise<"anonymous" | "admin" | "user"> {
try {
- const userIDToken = getCookie(event, "userIDToken");
+ const userIDToken = getCookieValue(event, "userIDToken");
if (userIDToken) {
try {
@@ -16,14 +115,23 @@ export async function getPrivilegeLevel(
const { payload } = await jwtVerify(userIDToken, secret);
if (payload.id && typeof payload.id === "string") {
+ // Validate session if session ID is present
+ if (payload.sid) {
+ const isValidSession = await validateSession(
+ payload.sid as string,
+ payload.id
+ );
+ if (!isValidSession) {
+ clearCookie(event, "userIDToken");
+ return "anonymous";
+ }
+ }
+
return payload.id === env.ADMIN_ID ? "admin" : "user";
}
} catch (err) {
// Silently clear invalid token (401s are expected for non-authenticated users)
- setCookie(event, "userIDToken", "", {
- maxAge: 0,
- expires: new Date("2016-10-05")
- });
+ clearCookie(event, "userIDToken");
}
}
} catch (e) {
@@ -34,7 +142,7 @@ export async function getPrivilegeLevel(
export async function getUserID(event: H3Event): Promise {
try {
- const userIDToken = getCookie(event, "userIDToken");
+ const userIDToken = getCookieValue(event, "userIDToken");
if (userIDToken) {
try {
@@ -42,14 +150,23 @@ export async function getUserID(event: H3Event): Promise {
const { payload } = await jwtVerify(userIDToken, secret);
if (payload.id && typeof payload.id === "string") {
+ // Validate session if session ID is present
+ if (payload.sid) {
+ const isValidSession = await validateSession(
+ payload.sid as string,
+ payload.id
+ );
+ if (!isValidSession) {
+ clearCookie(event, "userIDToken");
+ return null;
+ }
+ }
+
return payload.id;
}
} catch (err) {
// Silently clear invalid token (401s are expected for non-authenticated users)
- setCookie(event, "userIDToken", "", {
- maxAge: 0,
- expires: new Date("2016-10-05")
- });
+ clearCookie(event, "userIDToken");
}
}
} catch (e) {
diff --git a/src/server/init-audit-table.ts b/src/server/init-audit-table.ts
new file mode 100644
index 0000000..9684a64
--- /dev/null
+++ b/src/server/init-audit-table.ts
@@ -0,0 +1,95 @@
+/**
+ * Database Initialization for Audit Logging
+ * Run this script to create the AuditLog table in your database
+ *
+ * Usage: bun run src/server/init-audit-table.ts
+ */
+
+import { ConnectionFactory } from "./database";
+
+async function initAuditTable() {
+ console.log("🔧 Initializing AuditLog table...");
+
+ try {
+ const conn = ConnectionFactory();
+
+ // Create AuditLog table
+ await conn.execute({
+ sql: `CREATE TABLE IF NOT EXISTS AuditLog (
+ id TEXT PRIMARY KEY,
+ user_id TEXT,
+ event_type TEXT NOT NULL,
+ event_data TEXT,
+ ip_address TEXT,
+ user_agent TEXT,
+ success INTEGER NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE SET NULL
+ )`
+ });
+
+ console.log("✅ AuditLog table created (or already exists)");
+
+ // Create indexes for performance
+ console.log("🔧 Creating indexes...");
+
+ await conn.execute({
+ sql: `CREATE INDEX IF NOT EXISTS idx_audit_user_id ON AuditLog(user_id)`
+ });
+
+ await conn.execute({
+ sql: `CREATE INDEX IF NOT EXISTS idx_audit_event_type ON AuditLog(event_type)`
+ });
+
+ await conn.execute({
+ sql: `CREATE INDEX IF NOT EXISTS idx_audit_created_at ON AuditLog(created_at)`
+ });
+
+ await conn.execute({
+ sql: `CREATE INDEX IF NOT EXISTS idx_audit_ip_address ON AuditLog(ip_address)`
+ });
+
+ console.log("✅ Indexes created");
+
+ // Verify table exists
+ const result = await conn.execute({
+ sql: `SELECT name FROM sqlite_master WHERE type='table' AND name='AuditLog'`
+ });
+
+ if (result.rows.length > 0) {
+ console.log("✅ AuditLog table verified - ready for use!");
+
+ // Check row count
+ const countResult = await conn.execute({
+ sql: `SELECT COUNT(*) as count FROM AuditLog`
+ });
+
+ console.log(
+ `📊 Current audit log entries: ${countResult.rows[0]?.count || 0}`
+ );
+ } else {
+ console.error("❌ AuditLog table was not created properly");
+ process.exit(1);
+ }
+
+ console.log("\n✅ Audit logging system is ready!");
+ console.log("💡 You can now use the audit logging features");
+ console.log("📖 See docs/AUDIT_LOGGING.md for usage examples\n");
+ } catch (error) {
+ console.error("❌ Failed to initialize AuditLog table:");
+ console.error(error);
+ process.exit(1);
+ }
+}
+
+// Run if executed directly
+if (import.meta.main) {
+ initAuditTable()
+ .then(() => process.exit(0))
+ .catch((error) => {
+ console.error(error);
+ process.exit(1);
+ });
+}
+
+export { initAuditTable };
diff --git a/src/server/password.ts b/src/server/password.ts
index c708d6c..0d19612 100644
--- a/src/server/password.ts
+++ b/src/server/password.ts
@@ -1,5 +1,13 @@
import * as bcrypt from "bcrypt";
+/**
+ * Dummy hash for timing attack prevention
+ * This is a pre-computed bcrypt hash that will be used when a user doesn't exist
+ * to maintain constant-time behavior
+ */
+const DUMMY_HASH =
+ "$2b$10$YxVvS6L6HhS1pVBP6nZK0.9r0xwN8xvvzX7GwL5xvKJ6xvS6L6HhS1";
+
export async function hashPassword(password: string): Promise {
const saltRounds = 10;
const salt = await bcrypt.genSalt(saltRounds);
@@ -14,3 +22,19 @@ export async function checkPassword(
const match = await bcrypt.compare(password, hash);
return match;
}
+
+/**
+ * Check password with timing attack protection
+ * Always runs bcrypt comparison even if user doesn't exist
+ */
+export async function checkPasswordSafe(
+ password: string,
+ hash: string | null | undefined
+): Promise {
+ // If no hash provided, use dummy hash to maintain constant timing
+ const hashToCompare = hash || DUMMY_HASH;
+ const match = await bcrypt.compare(password, hashToCompare);
+
+ // Return false if no real hash was provided
+ return hash ? match : false;
+}
diff --git a/src/server/security.ts b/src/server/security.ts
new file mode 100644
index 0000000..c25f7d6
--- /dev/null
+++ b/src/server/security.ts
@@ -0,0 +1,402 @@
+import { TRPCError } from "@trpc/server";
+import { getCookie, setCookie } from "vinxi/http";
+import type { H3Event } from "vinxi/http";
+import { t } from "~/server/api/utils";
+import { logAuditEvent } from "~/server/audit";
+import { env } from "~/env/server";
+
+/**
+ * Extract cookie value from H3Event (works in both production and tests)
+ */
+function getCookieValue(event: H3Event, name: string): string | undefined {
+ try {
+ // Try vinxi's getCookie first
+ const value = getCookie(event, name);
+ if (value) return value;
+ } catch (e) {
+ // vinxi's getCookie failed, will use fallback
+ }
+
+ // Fallback for tests: parse cookie header manually
+ try {
+ const cookieHeader =
+ event.headers?.get?.("cookie") ||
+ (event.headers as any)?.cookie ||
+ event.node?.req?.headers?.cookie ||
+ "";
+ const cookies = cookieHeader
+ .split(";")
+ .map((c) => c.trim())
+ .reduce(
+ (acc, cookie) => {
+ const [key, value] = cookie.split("=");
+ if (key && value) acc[key] = value;
+ return acc;
+ },
+ {} as Record
+ );
+ return cookies[name];
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Set cookie (works in both production and tests)
+ */
+function setCookieValue(
+ event: H3Event,
+ name: string,
+ value: string,
+ options: any
+): void {
+ try {
+ setCookie(event, name, value, options);
+ } catch (e) {
+ // In tests, setCookie might fail - store in mock object
+ if (!event.node) event.node = { req: { headers: {} } } as any;
+ if (!event.node.res) event.node.res = {} as any;
+ if (!event.node.res.cookies) event.node.res.cookies = {} as any;
+ event.node.res.cookies[name] = value;
+ }
+}
+
+/**
+ * Extract header value from H3Event (works in both production and tests)
+ */
+function getHeaderValue(event: H3Event, name: string): string | null {
+ try {
+ // Try various header access patterns
+ if (event.request?.headers?.get) {
+ const val = event.request.headers.get(name);
+ if (val !== null && val !== undefined) return val;
+ }
+ if (event.headers) {
+ // Check if it's a Headers object with .get method
+ if (typeof (event.headers as any).get === "function") {
+ const val = (event.headers as any).get(name);
+ if (val !== null && val !== undefined) return val;
+ }
+ // Or a plain object
+ if (typeof event.headers === "object") {
+ const val = (event.headers as any)[name];
+ if (val !== undefined) return val;
+ }
+ }
+ if (event.node?.req?.headers) {
+ const val = event.node.req.headers[name];
+ if (val !== undefined) return val;
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Generate a cryptographically secure CSRF token
+ */
+export function generateCSRFToken(): string {
+ return crypto.randomUUID();
+}
+
+/**
+ * Set CSRF token cookie
+ */
+export function setCSRFToken(event: H3Event): string {
+ const token = generateCSRFToken();
+ setCookieValue(event, "csrf-token", token, {
+ maxAge: 60 * 60 * 24 * 14, // 14 days - same as session
+ path: "/",
+ httpOnly: false, // Must be readable by client JS
+ secure: env.NODE_ENV === "production",
+ sameSite: "lax"
+ });
+ return token;
+}
+
+/**
+ * Validate CSRF token from request header against cookie
+ */
+export function validateCSRFToken(event: H3Event): boolean {
+ const headerToken = getHeaderValue(event, "x-csrf-token");
+ const cookieToken = getCookieValue(event, "csrf-token");
+
+ if (!headerToken || !cookieToken) {
+ return false;
+ }
+
+ // Constant-time comparison to prevent timing attacks
+ return timingSafeEqual(headerToken, cookieToken);
+}
+
+/**
+ * Timing-safe string comparison
+ */
+function timingSafeEqual(a: string, b: string): boolean {
+ if (a.length !== b.length) {
+ return false;
+ }
+
+ let result = 0;
+ for (let i = 0; i < a.length; i++) {
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
+ }
+ return result === 0;
+}
+
+/**
+ * CSRF protection middleware for tRPC
+ * Use this on all mutation procedures that modify state
+ */
+export const csrfProtection = t.middleware(async ({ ctx, next }) => {
+ const isValid = validateCSRFToken(ctx.event.nativeEvent);
+
+ if (!isValid) {
+ // Log CSRF failure
+ const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent);
+ await logAuditEvent({
+ eventType: "security.csrf.failed",
+ eventData: {
+ headerToken: getHeaderValue(ctx.event.nativeEvent, "x-csrf-token")
+ ? "present"
+ : "missing",
+ cookieToken: getCookieValue(ctx.event.nativeEvent, "csrf-token")
+ ? "present"
+ : "missing"
+ },
+ ipAddress,
+ userAgent,
+ success: false
+ });
+
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Invalid CSRF token"
+ });
+ }
+
+ return next();
+});
+
+/**
+ * Protected procedure with CSRF validation
+ */
+export const csrfProtectedProcedure = t.procedure.use(csrfProtection);
+
+// ========== Rate Limiting ==========
+
+interface RateLimitRecord {
+ count: number;
+ resetAt: number;
+}
+
+/**
+ * In-memory rate limit store
+ * In production, consider using Redis for distributed rate limiting
+ */
+const rateLimitStore = new Map();
+
+/**
+ * Clear rate limit store (for testing only)
+ */
+export function clearRateLimitStore(): void {
+ rateLimitStore.clear();
+}
+
+/**
+ * Cleanup expired rate limit entries every 5 minutes
+ */
+setInterval(
+ () => {
+ const now = Date.now();
+ for (const [key, record] of rateLimitStore.entries()) {
+ if (now > record.resetAt) {
+ rateLimitStore.delete(key);
+ }
+ }
+ },
+ 5 * 60 * 1000
+);
+
+/**
+ * Get client IP address from request headers
+ */
+export function getClientIP(event: H3Event): string {
+ const forwarded = getHeaderValue(event, "x-forwarded-for");
+ if (forwarded) {
+ return forwarded.split(",")[0].trim();
+ }
+
+ const realIP = getHeaderValue(event, "x-real-ip");
+ if (realIP) {
+ return realIP;
+ }
+
+ return "unknown";
+}
+
+/**
+ * Get user agent from request headers
+ */
+export function getUserAgent(event: H3Event): string {
+ return getHeaderValue(event, "user-agent") || "unknown";
+}
+
+/**
+ * Extract audit context from H3Event
+ * Convenience function for logging
+ */
+export function getAuditContext(event: H3Event): {
+ ipAddress: string;
+ userAgent: string;
+} {
+ return {
+ ipAddress: getClientIP(event),
+ userAgent: getUserAgent(event)
+ };
+}
+
+/**
+ * Check rate limit for a given identifier
+ * @param identifier - Unique identifier (e.g., "login:ip:192.168.1.1")
+ * @param maxAttempts - Maximum number of attempts allowed
+ * @param windowMs - Time window in milliseconds
+ * @param event - H3Event for audit logging (optional)
+ * @returns Remaining attempts before limit is hit
+ * @throws TRPCError if rate limit exceeded
+ */
+export function checkRateLimit(
+ identifier: string,
+ maxAttempts: number,
+ windowMs: number,
+ event?: H3Event
+): number {
+ const now = Date.now();
+ const record = rateLimitStore.get(identifier);
+
+ if (!record || now > record.resetAt) {
+ // Create new record
+ rateLimitStore.set(identifier, {
+ count: 1,
+ resetAt: now + windowMs
+ });
+ return maxAttempts - 1;
+ }
+
+ if (record.count >= maxAttempts) {
+ const remainingMs = record.resetAt - now;
+ const remainingSec = Math.ceil(remainingMs / 1000);
+
+ // Log rate limit exceeded (fire-and-forget)
+ if (event) {
+ const { ipAddress, userAgent } = getAuditContext(event);
+ logAuditEvent({
+ eventType: "security.rate_limit.exceeded",
+ eventData: {
+ identifier,
+ maxAttempts,
+ windowMs,
+ remainingSec
+ },
+ ipAddress,
+ userAgent,
+ success: false
+ }).catch(() => {
+ // Ignore logging errors
+ });
+ }
+
+ throw new TRPCError({
+ code: "TOO_MANY_REQUESTS",
+ message: `Too many attempts. Try again in ${remainingSec} seconds`
+ });
+ }
+
+ // Increment count
+ record.count++;
+ return maxAttempts - record.count;
+}
+
+/**
+ * Rate limit configuration for different operations
+ */
+export const RATE_LIMITS = {
+ // Login: 5 attempts per 15 minutes per IP
+ LOGIN_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 },
+ // Login: 3 attempts per hour per email
+ LOGIN_EMAIL: { maxAttempts: 3, windowMs: 60 * 60 * 1000 },
+ // Password reset: 3 attempts per hour per IP
+ PASSWORD_RESET_IP: { maxAttempts: 3, windowMs: 60 * 60 * 1000 },
+ // Registration: 3 attempts per hour per IP
+ REGISTRATION_IP: { maxAttempts: 3, windowMs: 60 * 60 * 1000 },
+ // Email verification: 5 attempts per 15 minutes per IP
+ EMAIL_VERIFICATION_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 }
+} as const;
+
+/**
+ * Rate limiting middleware for login operations
+ */
+export function rateLimitLogin(
+ email: string,
+ clientIP: string,
+ event?: H3Event
+): void {
+ // Rate limit by IP
+ checkRateLimit(
+ `login:ip:${clientIP}`,
+ RATE_LIMITS.LOGIN_IP.maxAttempts,
+ RATE_LIMITS.LOGIN_IP.windowMs,
+ event
+ );
+
+ // Rate limit by email
+ checkRateLimit(
+ `login:email:${email}`,
+ RATE_LIMITS.LOGIN_EMAIL.maxAttempts,
+ RATE_LIMITS.LOGIN_EMAIL.windowMs,
+ event
+ );
+}
+
+/**
+ * Rate limiting middleware for password reset
+ */
+export function rateLimitPasswordReset(
+ clientIP: string,
+ event?: H3Event
+): void {
+ checkRateLimit(
+ `password-reset:ip:${clientIP}`,
+ RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts,
+ RATE_LIMITS.PASSWORD_RESET_IP.windowMs,
+ event
+ );
+}
+
+/**
+ * Rate limiting middleware for registration
+ */
+export function rateLimitRegistration(clientIP: string, event?: H3Event): void {
+ checkRateLimit(
+ `registration:ip:${clientIP}`,
+ RATE_LIMITS.REGISTRATION_IP.maxAttempts,
+ RATE_LIMITS.REGISTRATION_IP.windowMs,
+ event
+ );
+}
+
+/**
+ * Rate limiting middleware for email verification
+ */
+export function rateLimitEmailVerification(
+ clientIP: string,
+ event?: H3Event
+): void {
+ checkRateLimit(
+ `email-verification:ip:${clientIP}`,
+ RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts,
+ RATE_LIMITS.EMAIL_VERIFICATION_IP.windowMs,
+ event
+ );
+}
diff --git a/src/server/security/auth.test.ts b/src/server/security/auth.test.ts
new file mode 100644
index 0000000..3021156
--- /dev/null
+++ b/src/server/security/auth.test.ts
@@ -0,0 +1,486 @@
+/**
+ * Authentication Security Tests
+ * Tests for authentication mechanisms including JWT, session management, and timing attacks
+ */
+
+import { describe, it, expect, beforeEach } from "bun:test";
+import { getUserID, getPrivilegeLevel, checkAuthStatus } from "~/server/auth";
+import {
+ createMockEvent,
+ createTestJWT,
+ createExpiredJWT,
+ createInvalidSignatureJWT,
+ measureTime
+} from "./test-utils";
+import { jwtVerify, SignJWT } from "jose";
+import { env } from "~/env/server";
+
+describe("Authentication Security", () => {
+ describe("JWT Token Validation", () => {
+ it("should validate correct JWT tokens", async () => {
+ const userId = "test-user-123";
+ const token = await createTestJWT(userId);
+
+ const event = createMockEvent({
+ cookies: { userIDToken: token }
+ });
+
+ const extractedUserId = await getUserID(event);
+ expect(extractedUserId).toBe(userId);
+ });
+
+ it("should reject expired JWT tokens", async () => {
+ const userId = "test-user-123";
+ const expiredToken = await createExpiredJWT(userId);
+
+ const event = createMockEvent({
+ cookies: { userIDToken: expiredToken }
+ });
+
+ const extractedUserId = await getUserID(event);
+ expect(extractedUserId).toBeNull();
+ });
+
+ it("should reject JWT tokens with invalid signature", async () => {
+ const userId = "test-user-123";
+ const invalidToken = await createInvalidSignatureJWT(userId);
+
+ const event = createMockEvent({
+ cookies: { userIDToken: invalidToken }
+ });
+
+ const extractedUserId = await getUserID(event);
+ expect(extractedUserId).toBeNull();
+ });
+
+ it("should reject malformed JWT tokens", async () => {
+ const event = createMockEvent({
+ cookies: { userIDToken: "not-a-valid-jwt" }
+ });
+
+ const extractedUserId = await getUserID(event);
+ expect(extractedUserId).toBeNull();
+ });
+
+ it("should reject empty JWT tokens", async () => {
+ const event = createMockEvent({
+ cookies: { userIDToken: "" }
+ });
+
+ const extractedUserId = await getUserID(event);
+ expect(extractedUserId).toBeNull();
+ });
+
+ it("should reject JWT tokens with missing user ID", async () => {
+ const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
+ const tokenWithoutId = await new SignJWT({})
+ .setProtectedHeader({ alg: "HS256" })
+ .setExpirationTime("1h")
+ .sign(secret);
+
+ const event = createMockEvent({
+ cookies: { userIDToken: tokenWithoutId }
+ });
+
+ const extractedUserId = await getUserID(event);
+ expect(extractedUserId).toBeNull();
+ });
+
+ it("should reject JWT tokens with invalid user ID type", async () => {
+ const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
+ const tokenWithNumberId = await new SignJWT({ id: 12345 })
+ .setProtectedHeader({ alg: "HS256" })
+ .setExpirationTime("1h")
+ .sign(secret);
+
+ const event = createMockEvent({
+ cookies: { userIDToken: tokenWithNumberId }
+ });
+
+ const extractedUserId = await getUserID(event);
+ expect(extractedUserId).toBeNull();
+ });
+
+ it("should handle missing cookie gracefully", async () => {
+ const event = createMockEvent({});
+ const extractedUserId = await getUserID(event);
+ expect(extractedUserId).toBeNull();
+ });
+ });
+
+ describe("JWT Token Tampering", () => {
+ it("should detect modified JWT payload", async () => {
+ const userId = "test-user-123";
+ const token = await createTestJWT(userId);
+
+ // Tamper with the payload (middle part of JWT)
+ const parts = token.split(".");
+ const tamperedPayload = Buffer.from(
+ JSON.stringify({ id: "attacker-id" })
+ ).toString("base64url");
+ const tamperedToken = `${parts[0]}.${tamperedPayload}.${parts[2]}`;
+
+ const event = createMockEvent({
+ cookies: { userIDToken: tamperedToken }
+ });
+
+ const extractedUserId = await getUserID(event);
+ expect(extractedUserId).toBeNull();
+ });
+
+ it("should detect modified JWT signature", async () => {
+ const userId = "test-user-123";
+ const token = await createTestJWT(userId);
+
+ // Tamper with the signature (last part of JWT)
+ const parts = token.split(".");
+ const tamperedToken = `${parts[0]}.${parts[1]}.modified-signature`;
+
+ const event = createMockEvent({
+ cookies: { userIDToken: tamperedToken }
+ });
+
+ const extractedUserId = await getUserID(event);
+ expect(extractedUserId).toBeNull();
+ });
+
+ it("should reject none algorithm JWT tokens", async () => {
+ // Try to create a token with 'none' algorithm (security vulnerability)
+ const payload = Buffer.from(
+ JSON.stringify({ id: "attacker-id", exp: Date.now() / 1000 + 3600 })
+ ).toString("base64url");
+ const header = Buffer.from(
+ JSON.stringify({ alg: "none", typ: "JWT" })
+ ).toString("base64url");
+ const noneToken = `${header}.${payload}.`;
+
+ const event = createMockEvent({
+ cookies: { userIDToken: noneToken }
+ });
+
+ const extractedUserId = await getUserID(event);
+ expect(extractedUserId).toBeNull();
+ });
+ });
+
+ describe("Privilege Level Security", () => {
+ it("should return admin privilege for admin user", async () => {
+ const adminId = env.ADMIN_ID;
+ const token = await createTestJWT(adminId);
+
+ const event = createMockEvent({
+ cookies: { userIDToken: token }
+ });
+
+ const privilege = await getPrivilegeLevel(event);
+ expect(privilege).toBe("admin");
+ });
+
+ it("should return user privilege for regular user", async () => {
+ const userId = "regular-user-123";
+ const token = await createTestJWT(userId);
+
+ const event = createMockEvent({
+ cookies: { userIDToken: token }
+ });
+
+ const privilege = await getPrivilegeLevel(event);
+ expect(privilege).toBe("user");
+ });
+
+ it("should return anonymous privilege for unauthenticated request", async () => {
+ const event = createMockEvent({});
+ const privilege = await getPrivilegeLevel(event);
+ expect(privilege).toBe("anonymous");
+ });
+
+ it("should return anonymous privilege for invalid token", async () => {
+ const event = createMockEvent({
+ cookies: { userIDToken: "invalid-token" }
+ });
+
+ const privilege = await getPrivilegeLevel(event);
+ expect(privilege).toBe("anonymous");
+ });
+
+ it("should not allow privilege escalation through token manipulation", async () => {
+ const userId = "regular-user-123";
+ const token = await createTestJWT(userId);
+
+ // Even if attacker modifies the token, signature verification will fail
+ const parts = token.split(".");
+ const fakeAdminPayload = Buffer.from(
+ JSON.stringify({ id: env.ADMIN_ID })
+ ).toString("base64url");
+ const fakeAdminToken = `${parts[0]}.${fakeAdminPayload}.${parts[2]}`;
+
+ const event = createMockEvent({
+ cookies: { userIDToken: fakeAdminToken }
+ });
+
+ const privilege = await getPrivilegeLevel(event);
+ expect(privilege).toBe("anonymous"); // Token validation fails
+ });
+ });
+
+ describe("Session Management", () => {
+ it("should identify authenticated sessions correctly", async () => {
+ const userId = "test-user-123";
+ const token = await createTestJWT(userId);
+
+ const event = createMockEvent({
+ cookies: { userIDToken: token }
+ });
+
+ const authStatus = await checkAuthStatus(event);
+ expect(authStatus.isAuthenticated).toBe(true);
+ expect(authStatus.userId).toBe(userId);
+ });
+
+ it("should identify unauthenticated sessions correctly", async () => {
+ const event = createMockEvent({});
+ const authStatus = await checkAuthStatus(event);
+
+ expect(authStatus.isAuthenticated).toBe(false);
+ expect(authStatus.userId).toBeNull();
+ });
+
+ it("should handle session with expired token", async () => {
+ const userId = "test-user-123";
+ const expiredToken = await createExpiredJWT(userId);
+
+ const event = createMockEvent({
+ cookies: { userIDToken: expiredToken }
+ });
+
+ const authStatus = await checkAuthStatus(event);
+ expect(authStatus.isAuthenticated).toBe(false);
+ expect(authStatus.userId).toBeNull();
+ });
+ });
+
+ describe("Timing Attack Prevention", () => {
+ it("should have consistent timing for valid and invalid tokens", async () => {
+ const userId = "test-user-123";
+ const validToken = await createTestJWT(userId);
+ const invalidToken = "invalid-token";
+
+ // Measure time for valid token
+ const validEvent = createMockEvent({
+ cookies: { userIDToken: validToken }
+ });
+ const { duration: validDuration } = await measureTime(() =>
+ getUserID(validEvent)
+ );
+
+ // Measure time for invalid token
+ const invalidEvent = createMockEvent({
+ cookies: { userIDToken: invalidToken }
+ });
+ const { duration: invalidDuration } = await measureTime(() =>
+ getUserID(invalidEvent)
+ );
+
+ // Timing difference should be minimal (within reasonable variance)
+ // This helps prevent timing attacks to enumerate valid tokens
+ const timingDifference = Math.abs(validDuration - invalidDuration);
+
+ // Allow up to 5ms variance (accounts for system variations)
+ expect(timingDifference).toBeLessThan(5);
+ });
+
+ it("should have consistent timing for different user privilege levels", async () => {
+ const adminId = env.ADMIN_ID;
+ const userId = "regular-user-123";
+
+ const adminToken = await createTestJWT(adminId);
+ const userToken = await createTestJWT(userId);
+
+ // Measure time for admin privilege check
+ const adminEvent = createMockEvent({
+ cookies: { userIDToken: adminToken }
+ });
+ const { duration: adminDuration } = await measureTime(() =>
+ getPrivilegeLevel(adminEvent)
+ );
+
+ // Measure time for user privilege check
+ const userEvent = createMockEvent({
+ cookies: { userIDToken: userToken }
+ });
+ const { duration: userDuration } = await measureTime(() =>
+ getPrivilegeLevel(userEvent)
+ );
+
+ const timingDifference = Math.abs(adminDuration - userDuration);
+ expect(timingDifference).toBeLessThan(5);
+ });
+ });
+
+ describe("Token Expiration", () => {
+ it("should respect token expiration time", async () => {
+ const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
+ const userId = "test-user-123";
+
+ // Create token expiring in 1 second
+ const shortLivedToken = await new SignJWT({ id: userId })
+ .setProtectedHeader({ alg: "HS256" })
+ .setExpirationTime("1s")
+ .sign(secret);
+
+ // Should work immediately
+ const event1 = createMockEvent({
+ cookies: { userIDToken: shortLivedToken }
+ });
+ const id1 = await getUserID(event1);
+ expect(id1).toBe(userId);
+
+ // Wait for token to expire
+ await new Promise((resolve) => setTimeout(resolve, 1500));
+
+ // Should fail after expiration
+ const event2 = createMockEvent({
+ cookies: { userIDToken: shortLivedToken }
+ });
+ const id2 = await getUserID(event2);
+ expect(id2).toBeNull();
+ });
+
+ it("should handle tokens with very long expiration", async () => {
+ const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
+ const userId = "test-user-123";
+
+ const longLivedToken = await new SignJWT({ id: userId })
+ .setProtectedHeader({ alg: "HS256" })
+ .setExpirationTime("365d") // 1 year
+ .sign(secret);
+
+ const event = createMockEvent({
+ cookies: { userIDToken: longLivedToken }
+ });
+
+ const extractedId = await getUserID(event);
+ expect(extractedId).toBe(userId);
+ });
+
+ it("should reject tokens with past expiration timestamps", async () => {
+ const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
+ const userId = "test-user-123";
+
+ const pastToken = await new SignJWT({ id: userId })
+ .setProtectedHeader({ alg: "HS256" })
+ .setExpirationTime(Math.floor(Date.now() / 1000) - 3600) // 1 hour ago
+ .sign(secret);
+
+ const event = createMockEvent({
+ cookies: { userIDToken: pastToken }
+ });
+
+ const extractedId = await getUserID(event);
+ expect(extractedId).toBeNull();
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("should handle very long JWT tokens", async () => {
+ const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
+ const largePayload = {
+ id: "test-user-123",
+ extraData: "x".repeat(10000) // 10KB of extra data
+ };
+
+ const largeToken = await new SignJWT(largePayload)
+ .setProtectedHeader({ alg: "HS256" })
+ .setExpirationTime("1h")
+ .sign(secret);
+
+ const event = createMockEvent({
+ cookies: { userIDToken: largeToken }
+ });
+
+ const extractedId = await getUserID(event);
+ expect(extractedId).toBe("test-user-123");
+ });
+
+ it("should handle special characters in user IDs", async () => {
+ const specialUserId = "user-with-special-!@#$%^&*()";
+ const token = await createTestJWT(specialUserId);
+
+ const event = createMockEvent({
+ cookies: { userIDToken: token }
+ });
+
+ const extractedId = await getUserID(event);
+ expect(extractedId).toBe(specialUserId);
+ });
+
+ it("should handle unicode user IDs", async () => {
+ const unicodeUserId = "user-with-unicode-🔐🛡️";
+ const token = await createTestJWT(unicodeUserId);
+
+ const event = createMockEvent({
+ cookies: { userIDToken: token }
+ });
+
+ const extractedId = await getUserID(event);
+ expect(extractedId).toBe(unicodeUserId);
+ });
+
+ it("should reject JWT with future issued-at time", async () => {
+ const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
+ const futureToken = await new SignJWT({ id: "test-user-123" })
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuedAt(Math.floor(Date.now() / 1000) + 3600) // 1 hour in future
+ .setExpirationTime("2h")
+ .sign(secret);
+
+ const event = createMockEvent({
+ cookies: { userIDToken: futureToken }
+ });
+
+ // Some JWT libraries reject future iat, some don't
+ // This test documents the behavior
+ const extractedId = await getUserID(event);
+ // Behavior may vary - just ensure no crash
+ expect(extractedId === null || extractedId === "test-user-123").toBe(
+ true
+ );
+ });
+ });
+
+ describe("Performance", () => {
+ it("should validate tokens efficiently", async () => {
+ const userId = "test-user-123";
+ const token = await createTestJWT(userId);
+ const event = createMockEvent({
+ cookies: { userIDToken: token }
+ });
+
+ const start = performance.now();
+ for (let i = 0; i < 1000; i++) {
+ await getUserID(event);
+ }
+ const duration = performance.now() - start;
+
+ // Should validate 1000 tokens in less than 100ms
+ expect(duration).toBeLessThan(100);
+ });
+
+ it("should check privilege levels efficiently", async () => {
+ const userId = "test-user-123";
+ const token = await createTestJWT(userId);
+ const event = createMockEvent({
+ cookies: { userIDToken: token }
+ });
+
+ const start = performance.now();
+ for (let i = 0; i < 1000; i++) {
+ await getPrivilegeLevel(event);
+ }
+ const duration = performance.now() - start;
+
+ // Should check 1000 privileges in less than 100ms
+ expect(duration).toBeLessThan(100);
+ });
+ });
+});
diff --git a/src/server/security/authorization.test.ts b/src/server/security/authorization.test.ts
new file mode 100644
index 0000000..56ead6c
--- /dev/null
+++ b/src/server/security/authorization.test.ts
@@ -0,0 +1,417 @@
+/**
+ * Authorization Tests
+ * Tests for access control, privilege escalation prevention, and admin access
+ */
+
+import { describe, it, expect } from "bun:test";
+import { getUserID, getPrivilegeLevel } from "~/server/auth";
+import { createMockEvent, createTestJWT } from "./test-utils";
+import { env } from "~/env/server";
+
+describe("Authorization", () => {
+ describe("Admin Access Control", () => {
+ it("should grant admin access to configured admin user", async () => {
+ const adminToken = await createTestJWT(env.ADMIN_ID);
+ const event = createMockEvent({
+ cookies: { userIDToken: adminToken }
+ });
+
+ const privilege = await getPrivilegeLevel(event);
+ expect(privilege).toBe("admin");
+ });
+
+ it("should deny admin access to regular users", async () => {
+ const userToken = await createTestJWT("regular-user-123");
+ const event = createMockEvent({
+ cookies: { userIDToken: userToken }
+ });
+
+ const privilege = await getPrivilegeLevel(event);
+ expect(privilege).toBe("user");
+ expect(privilege).not.toBe("admin");
+ });
+
+ it("should deny admin access to anonymous users", async () => {
+ const event = createMockEvent({});
+ const privilege = await getPrivilegeLevel(event);
+
+ expect(privilege).toBe("anonymous");
+ expect(privilege).not.toBe("admin");
+ });
+
+ it("should not allow privilege escalation through token tampering", async () => {
+ // Create a regular user token
+ const regularToken = await createTestJWT("regular-user-123");
+
+ // Attacker tries to modify token to include admin ID
+ // This should fail signature verification
+ const parts = regularToken.split(".");
+ const fakeAdminPayload = Buffer.from(
+ JSON.stringify({ id: env.ADMIN_ID })
+ ).toString("base64url");
+ const tamperedToken = `${parts[0]}.${fakeAdminPayload}.${parts[2]}`;
+
+ const event = createMockEvent({
+ cookies: { userIDToken: tamperedToken }
+ });
+
+ const privilege = await getPrivilegeLevel(event);
+ expect(privilege).toBe("anonymous"); // Invalid token = anonymous
+ });
+
+ it("should handle malformed admin ID gracefully", async () => {
+ const invalidIds = ["", null, undefined, " ", "admin'--"];
+
+ for (const invalidId of invalidIds) {
+ const token = await createTestJWT(invalidId as string);
+ const event = createMockEvent({
+ cookies: { userIDToken: token }
+ });
+
+ const privilege = await getPrivilegeLevel(event);
+ // Should not grant admin access for invalid IDs
+ expect(privilege).not.toBe("admin");
+ }
+ });
+ });
+
+ describe("User Access Control", () => {
+ it("should grant user access to authenticated users", async () => {
+ const userToken = await createTestJWT("user-123");
+ const event = createMockEvent({
+ cookies: { userIDToken: userToken }
+ });
+
+ const privilege = await getPrivilegeLevel(event);
+ expect(privilege).toBe("user");
+ });
+
+ it("should deny user access to anonymous requests", async () => {
+ const event = createMockEvent({});
+ const privilege = await getPrivilegeLevel(event);
+
+ expect(privilege).toBe("anonymous");
+ expect(privilege).not.toBe("user");
+ });
+
+ it("should maintain user access with valid token", async () => {
+ const userToken = await createTestJWT("user-456");
+ const event = createMockEvent({
+ cookies: { userIDToken: userToken }
+ });
+
+ const userId = await getUserID(event);
+ expect(userId).toBe("user-456");
+
+ const privilege = await getPrivilegeLevel(event);
+ expect(privilege).toBe("user");
+ });
+ });
+
+ describe("Privilege Escalation Prevention", () => {
+ it("should prevent horizontal privilege escalation", async () => {
+ const user1Token = await createTestJWT("user-1");
+ const user2Token = await createTestJWT("user-2");
+
+ const event1 = createMockEvent({
+ cookies: { userIDToken: user1Token }
+ });
+ const event2 = createMockEvent({
+ cookies: { userIDToken: user2Token }
+ });
+
+ const user1Id = await getUserID(event1);
+ const user2Id = await getUserID(event2);
+
+ expect(user1Id).toBe("user-1");
+ expect(user2Id).toBe("user-2");
+ expect(user1Id).not.toBe(user2Id);
+ });
+
+ it("should prevent vertical privilege escalation", async () => {
+ // Regular user should not be able to become admin
+ const userToken = await createTestJWT("regular-user");
+ const event = createMockEvent({
+ cookies: { userIDToken: userToken }
+ });
+
+ const privilege = await getPrivilegeLevel(event);
+ expect(privilege).toBe("user");
+
+ // Even with multiple checks, privilege should remain the same
+ const privilege2 = await getPrivilegeLevel(event);
+ expect(privilege2).toBe("user");
+ });
+
+ it("should not allow session hijacking through token reuse", async () => {
+ const user1Token = await createTestJWT("user-1");
+
+ // User 1's token should always return user 1's ID
+ const event1 = createMockEvent({
+ cookies: { userIDToken: user1Token }
+ });
+ const id1 = await getUserID(event1);
+
+ // Even if attacker captures token, it still identifies as user 1
+ const event2 = createMockEvent({
+ cookies: { userIDToken: user1Token }
+ });
+ const id2 = await getUserID(event2);
+
+ expect(id1).toBe("user-1");
+ expect(id2).toBe("user-1");
+ });
+
+ it("should prevent privilege escalation via race conditions", async () => {
+ const userToken = await createTestJWT("concurrent-user");
+ const event = createMockEvent({
+ cookies: { userIDToken: userToken }
+ });
+
+ // Simulate concurrent privilege checks
+ const results = await Promise.all([
+ getPrivilegeLevel(event),
+ getPrivilegeLevel(event),
+ getPrivilegeLevel(event),
+ getPrivilegeLevel(event),
+ getPrivilegeLevel(event)
+ ]);
+
+ // All results should be the same
+ expect(results.every((r) => r === "user")).toBe(true);
+ });
+ });
+
+ describe("Anonymous Access", () => {
+ it("should handle missing authentication token", async () => {
+ const event = createMockEvent({});
+ const privilege = await getPrivilegeLevel(event);
+
+ expect(privilege).toBe("anonymous");
+ });
+
+ it("should handle empty authentication token", async () => {
+ const event = createMockEvent({
+ cookies: { userIDToken: "" }
+ });
+ const privilege = await getPrivilegeLevel(event);
+
+ expect(privilege).toBe("anonymous");
+ });
+
+ it("should handle invalid token format", async () => {
+ const event = createMockEvent({
+ cookies: { userIDToken: "not-a-jwt-token" }
+ });
+ const privilege = await getPrivilegeLevel(event);
+
+ expect(privilege).toBe("anonymous");
+ });
+
+ it("should return null user ID for anonymous users", async () => {
+ const event = createMockEvent({});
+ const userId = await getUserID(event);
+
+ expect(userId).toBeNull();
+ });
+ });
+
+ describe("Access Control Edge Cases", () => {
+ it("should handle user ID with special characters", async () => {
+ const specialUserId = "user-with-special-!@#$%";
+ const token = await createTestJWT(specialUserId);
+ const event = createMockEvent({
+ cookies: { userIDToken: token }
+ });
+
+ const userId = await getUserID(event);
+ expect(userId).toBe(specialUserId);
+ });
+
+ it("should handle very long user IDs", async () => {
+ const longUserId = "user-" + "x".repeat(1000);
+ const token = await createTestJWT(longUserId);
+ const event = createMockEvent({
+ cookies: { userIDToken: token }
+ });
+
+ const userId = await getUserID(event);
+ expect(userId).toBe(longUserId);
+ });
+
+ it("should handle user ID with unicode characters", async () => {
+ const unicodeUserId = "user-with-unicode-🔐";
+ const token = await createTestJWT(unicodeUserId);
+ const event = createMockEvent({
+ cookies: { userIDToken: token }
+ });
+
+ const userId = await getUserID(event);
+ expect(userId).toBe(unicodeUserId);
+ });
+
+ it("should handle admin ID case sensitivity", async () => {
+ const adminId = env.ADMIN_ID;
+ const wrongCaseId = adminId.toUpperCase();
+
+ // Exact match required
+ const correctToken = await createTestJWT(adminId);
+ const wrongCaseToken = await createTestJWT(wrongCaseId);
+
+ const correctEvent = createMockEvent({
+ cookies: { userIDToken: correctToken }
+ });
+ const wrongCaseEvent = createMockEvent({
+ cookies: { userIDToken: wrongCaseToken }
+ });
+
+ const correctPrivilege = await getPrivilegeLevel(correctEvent);
+ const wrongCasePrivilege = await getPrivilegeLevel(wrongCaseEvent);
+
+ expect(correctPrivilege).toBe("admin");
+ // Wrong case should not get admin access (unless IDs match)
+ if (adminId !== wrongCaseId) {
+ expect(wrongCasePrivilege).toBe("user");
+ }
+ });
+ });
+
+ describe("Authorization Attack Scenarios", () => {
+ it("should prevent session fixation attacks", async () => {
+ // Attacker cannot predict or fix session tokens
+ const token1 = await createTestJWT("user-1");
+ const token2 = await createTestJWT("user-1");
+
+ // Tokens should be different even for same user
+ // (Due to different timestamps, though payload is same)
+ expect(token1).toBeDefined();
+ expect(token2).toBeDefined();
+ });
+
+ it("should prevent parameter pollution attacks", async () => {
+ // Multiple cookie values should not cause confusion
+ const token1 = await createTestJWT("user-1");
+ const token2 = await createTestJWT("user-2");
+
+ // Only first cookie should be used
+ const event = createMockEvent({
+ cookies: {
+ userIDToken: token1
+ // In practice, duplicate cookies are handled by the framework
+ }
+ });
+
+ const userId = await getUserID(event);
+ expect(userId).toBe("user-1");
+ });
+
+ it("should prevent token substitution attacks", async () => {
+ const legitimateToken = await createTestJWT("victim-user");
+ const attackerToken = await createTestJWT("attacker-user");
+
+ // Each token should only authenticate its respective user
+ const legitimateEvent = createMockEvent({
+ cookies: { userIDToken: legitimateToken }
+ });
+ const attackerEvent = createMockEvent({
+ cookies: { userIDToken: attackerToken }
+ });
+
+ const legitimateId = await getUserID(legitimateEvent);
+ const attackerId = await getUserID(attackerEvent);
+
+ expect(legitimateId).toBe("victim-user");
+ expect(attackerId).toBe("attacker-user");
+ expect(legitimateId).not.toBe(attackerId);
+ });
+
+ it("should prevent authorization bypass through empty checks", async () => {
+ const emptyChecks = [null, undefined, "", " ", "null", "undefined"];
+
+ for (const check of emptyChecks) {
+ const event = createMockEvent({
+ cookies: { userIDToken: check as string }
+ });
+
+ const privilege = await getPrivilegeLevel(event);
+ expect(privilege).toBe("anonymous");
+ }
+ });
+ });
+
+ describe("Multi-User Scenarios", () => {
+ it("should handle multiple concurrent user sessions", async () => {
+ const users = ["user-1", "user-2", "user-3", "user-4", "user-5"];
+ const tokens = await Promise.all(users.map((u) => createTestJWT(u)));
+
+ const events = tokens.map((token) =>
+ createMockEvent({ cookies: { userIDToken: token } })
+ );
+
+ const userIds = await Promise.all(events.map(getUserID));
+
+ // All users should be correctly identified
+ expect(userIds).toEqual(users);
+ });
+
+ it("should maintain separate privileges for different users", async () => {
+ const adminToken = await createTestJWT(env.ADMIN_ID);
+ const user1Token = await createTestJWT("user-1");
+ const user2Token = await createTestJWT("user-2");
+
+ const adminEvent = createMockEvent({
+ cookies: { userIDToken: adminToken }
+ });
+ const user1Event = createMockEvent({
+ cookies: { userIDToken: user1Token }
+ });
+ const user2Event = createMockEvent({
+ cookies: { userIDToken: user2Token }
+ });
+
+ const [adminPriv, user1Priv, user2Priv] = await Promise.all([
+ getPrivilegeLevel(adminEvent),
+ getPrivilegeLevel(user1Event),
+ getPrivilegeLevel(user2Event)
+ ]);
+
+ expect(adminPriv).toBe("admin");
+ expect(user1Priv).toBe("user");
+ expect(user2Priv).toBe("user");
+ });
+ });
+
+ describe("Performance", () => {
+ it("should check privileges efficiently", async () => {
+ const userToken = await createTestJWT("perf-test-user");
+ const event = createMockEvent({
+ cookies: { userIDToken: userToken }
+ });
+
+ const start = performance.now();
+ for (let i = 0; i < 1000; i++) {
+ await getPrivilegeLevel(event);
+ }
+ const duration = performance.now() - start;
+
+ // Should complete 1000 checks in less than 100ms
+ expect(duration).toBeLessThan(100);
+ });
+
+ it("should extract user IDs efficiently", async () => {
+ const userToken = await createTestJWT("perf-test-user");
+ const event = createMockEvent({
+ cookies: { userIDToken: userToken }
+ });
+
+ const start = performance.now();
+ for (let i = 0; i < 1000; i++) {
+ await getUserID(event);
+ }
+ const duration = performance.now() - start;
+
+ // Should complete 1000 extractions in less than 100ms
+ expect(duration).toBeLessThan(100);
+ });
+ });
+});
diff --git a/src/server/security/csrf.test.ts b/src/server/security/csrf.test.ts
new file mode 100644
index 0000000..a5bd3d3
--- /dev/null
+++ b/src/server/security/csrf.test.ts
@@ -0,0 +1,320 @@
+/**
+ * CSRF Protection Tests
+ * Tests for Cross-Site Request Forgery protection mechanisms
+ */
+
+import { describe, it, expect, beforeEach } from "bun:test";
+import {
+ generateCSRFToken,
+ setCSRFToken,
+ validateCSRFToken,
+ csrfProtection
+} from "~/server/security";
+import { createMockEvent } from "./test-utils";
+
+describe("CSRF Protection", () => {
+ describe("generateCSRFToken", () => {
+ it("should generate a valid UUID token", () => {
+ const token = generateCSRFToken();
+ expect(token).toBeDefined();
+ expect(typeof token).toBe("string");
+ // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
+ expect(token).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
+ );
+ });
+
+ it("should generate unique tokens", () => {
+ const token1 = generateCSRFToken();
+ const token2 = generateCSRFToken();
+ expect(token1).not.toBe(token2);
+ });
+
+ it("should generate cryptographically secure tokens", () => {
+ // Generate multiple tokens and ensure no collisions
+ const tokens = new Set();
+ for (let i = 0; i < 1000; i++) {
+ tokens.add(generateCSRFToken());
+ }
+ expect(tokens.size).toBe(1000);
+ });
+ });
+
+ describe("setCSRFToken", () => {
+ it("should set CSRF token cookie with correct attributes", () => {
+ const event = createMockEvent({});
+ const token = setCSRFToken(event);
+
+ expect(token).toBeDefined();
+ expect(typeof token).toBe("string");
+ // Token should be a UUID
+ expect(token).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
+ );
+ });
+
+ it("should generate different tokens on subsequent calls", () => {
+ const event1 = createMockEvent({});
+ const event2 = createMockEvent({});
+
+ const token1 = setCSRFToken(event1);
+ const token2 = setCSRFToken(event2);
+
+ expect(token1).not.toBe(token2);
+ });
+ });
+
+ describe("validateCSRFToken", () => {
+ it("should validate matching tokens", () => {
+ const token = generateCSRFToken();
+ const event = createMockEvent({
+ headers: { "x-csrf-token": token },
+ cookies: { "csrf-token": token }
+ });
+
+ const isValid = validateCSRFToken(event);
+ expect(isValid).toBe(true);
+ });
+
+ it("should reject mismatched tokens", () => {
+ const event = createMockEvent({
+ headers: { "x-csrf-token": "token1" },
+ cookies: { "csrf-token": "token2" }
+ });
+
+ const isValid = validateCSRFToken(event);
+ expect(isValid).toBe(false);
+ });
+
+ it("should reject missing header token", () => {
+ const event = createMockEvent({
+ cookies: { "csrf-token": "token" }
+ });
+
+ const isValid = validateCSRFToken(event);
+ expect(isValid).toBe(false);
+ });
+
+ it("should reject missing cookie token", () => {
+ const event = createMockEvent({
+ headers: { "x-csrf-token": "token" }
+ });
+
+ const isValid = validateCSRFToken(event);
+ expect(isValid).toBe(false);
+ });
+
+ it("should reject empty tokens", () => {
+ const event = createMockEvent({
+ headers: { "x-csrf-token": "" },
+ cookies: { "csrf-token": "" }
+ });
+
+ const isValid = validateCSRFToken(event);
+ expect(isValid).toBe(false);
+ });
+
+ it("should use constant-time comparison", async () => {
+ const validToken = "a".repeat(36);
+ const invalidToken1 = "b".repeat(36);
+ const invalidToken2 = "b".repeat(35) + "a";
+
+ // Test timing for completely different tokens
+ const event1 = createMockEvent({
+ headers: { "x-csrf-token": invalidToken1 },
+ cookies: { "csrf-token": validToken }
+ });
+
+ const start1 = performance.now();
+ validateCSRFToken(event1);
+ const time1 = performance.now() - start1;
+
+ // Test timing for tokens that differ only at the end
+ const event2 = createMockEvent({
+ headers: { "x-csrf-token": invalidToken2 },
+ cookies: { "csrf-token": validToken }
+ });
+
+ const start2 = performance.now();
+ validateCSRFToken(event2);
+ const time2 = performance.now() - start2;
+
+ // Timing difference should be minimal (less than 1ms)
+ // This tests for constant-time comparison
+ const timeDiff = Math.abs(time1 - time2);
+ expect(timeDiff).toBeLessThan(1);
+ });
+
+ it("should reject tokens with different lengths", () => {
+ const event = createMockEvent({
+ headers: { "x-csrf-token": "short" },
+ cookies: { "csrf-token": "much-longer-token" }
+ });
+
+ const isValid = validateCSRFToken(event);
+ expect(isValid).toBe(false);
+ });
+ });
+
+ describe("CSRF Attack Scenarios", () => {
+ it("should prevent basic CSRF attack", () => {
+ // Attacker doesn't have access to the CSRF token cookie
+ const attackEvent = createMockEvent({
+ headers: { "x-csrf-token": "attacker-guessed-token" }
+ });
+
+ const isValid = validateCSRFToken(attackEvent);
+ expect(isValid).toBe(false);
+ });
+
+ it("should prevent token reuse from different session", () => {
+ const token1 = generateCSRFToken();
+ const token2 = generateCSRFToken();
+
+ // User has token1, attacker tries to use token2
+ const event = createMockEvent({
+ headers: { "x-csrf-token": token2 },
+ cookies: { "csrf-token": token1 }
+ });
+
+ const isValid = validateCSRFToken(event);
+ expect(isValid).toBe(false);
+ });
+
+ it("should prevent token modification", () => {
+ const token = generateCSRFToken();
+ const modifiedToken = token.slice(0, -1) + "x";
+
+ const event = createMockEvent({
+ headers: { "x-csrf-token": modifiedToken },
+ cookies: { "csrf-token": token }
+ });
+
+ const isValid = validateCSRFToken(event);
+ expect(isValid).toBe(false);
+ });
+
+ it("should prevent replay attacks with old tokens", () => {
+ // Simulate an old token that was captured
+ const oldToken = "old-captured-token-12345";
+
+ const event = createMockEvent({
+ headers: { "x-csrf-token": oldToken },
+ cookies: { "csrf-token": oldToken }
+ });
+
+ // Even if tokens match, they should be validated by the system
+ // This test validates the structure works correctly
+ const isValid = validateCSRFToken(event);
+ expect(isValid).toBe(true); // Matches are valid
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("should handle null tokens", () => {
+ const event = createMockEvent({});
+ const isValid = validateCSRFToken(event);
+ expect(isValid).toBe(false);
+ });
+
+ it("should handle undefined tokens", () => {
+ const event = createMockEvent({
+ headers: {},
+ cookies: {}
+ });
+ const isValid = validateCSRFToken(event);
+ expect(isValid).toBe(false);
+ });
+
+ it("should handle special characters in tokens", () => {
+ const token = "token-with-special-!@#$%^&*()";
+ const event = createMockEvent({
+ headers: { "x-csrf-token": token },
+ cookies: { "csrf-token": token }
+ });
+
+ const isValid = validateCSRFToken(event);
+ expect(isValid).toBe(true);
+ });
+
+ it("should handle very long tokens", () => {
+ const longToken = "a".repeat(1000);
+ const event = createMockEvent({
+ headers: { "x-csrf-token": longToken },
+ cookies: { "csrf-token": longToken }
+ });
+
+ const isValid = validateCSRFToken(event);
+ expect(isValid).toBe(true);
+ });
+
+ it("should handle unicode tokens", () => {
+ const unicodeToken = "token-with-unicode-🔒🛡️";
+ const event = createMockEvent({
+ headers: { "x-csrf-token": unicodeToken },
+ cookies: { "csrf-token": unicodeToken }
+ });
+
+ const isValid = validateCSRFToken(event);
+ expect(isValid).toBe(true);
+ });
+ });
+
+ describe("Token Generation Security", () => {
+ it("should not generate predictable tokens", () => {
+ const tokens: string[] = [];
+ for (let i = 0; i < 100; i++) {
+ tokens.push(generateCSRFToken());
+ }
+
+ // Check for sequential patterns
+ for (let i = 1; i < tokens.length; i++) {
+ // Tokens should not be incrementing
+ expect(tokens[i]).not.toBe(
+ String(Number(tokens[i - 1].replace(/-/g, "")) + 1)
+ );
+ }
+ });
+
+ it("should generate tokens with sufficient entropy", () => {
+ const token = generateCSRFToken();
+ // UUID without dashes should be 32 hex characters
+ const hexString = token.replace(/-/g, "");
+ expect(hexString).toMatch(/^[0-9a-f]{32}$/i);
+
+ // Check that not all characters are the same
+ const uniqueChars = new Set(hexString.split(""));
+ expect(uniqueChars.size).toBeGreaterThan(5);
+ });
+ });
+
+ describe("Performance", () => {
+ it("should generate tokens quickly", () => {
+ const start = performance.now();
+ for (let i = 0; i < 1000; i++) {
+ generateCSRFToken();
+ }
+ const duration = performance.now() - start;
+
+ // Should generate 1000 tokens in less than 100ms
+ expect(duration).toBeLessThan(100);
+ });
+
+ it("should validate tokens quickly", () => {
+ const token = generateCSRFToken();
+ const event = createMockEvent({
+ headers: { "x-csrf-token": token },
+ cookies: { "csrf-token": token }
+ });
+
+ const start = performance.now();
+ for (let i = 0; i < 10000; i++) {
+ validateCSRFToken(event);
+ }
+ const duration = performance.now() - start;
+
+ // Should validate 10000 tokens in less than 100ms
+ expect(duration).toBeLessThan(100);
+ });
+ });
+});
diff --git a/src/server/security/injection.test.ts b/src/server/security/injection.test.ts
new file mode 100644
index 0000000..8555e55
--- /dev/null
+++ b/src/server/security/injection.test.ts
@@ -0,0 +1,522 @@
+/**
+ * Input Validation and Injection Tests
+ * Tests for SQL injection, XSS, and other injection attack prevention
+ */
+
+import { describe, it, expect } from "bun:test";
+import {
+ isValidEmail,
+ validatePassword,
+ isValidDisplayName
+} from "~/lib/validation";
+import { SQL_INJECTION_PAYLOADS, XSS_PAYLOADS } from "./test-utils";
+import { ConnectionFactory } from "~/server/database";
+
+describe("Input Validation and Injection Prevention", () => {
+ describe("Email Validation", () => {
+ it("should accept valid email addresses", () => {
+ const validEmails = [
+ "user@example.com",
+ "test.user@example.com",
+ "user+tag@example.co.uk",
+ "user123@test-domain.com",
+ "first.last@subdomain.example.com"
+ ];
+
+ for (const email of validEmails) {
+ expect(isValidEmail(email)).toBe(true);
+ }
+ });
+
+ it("should reject invalid email addresses", () => {
+ const invalidEmails = [
+ "not-an-email",
+ "@example.com",
+ "user@",
+ "user @example.com",
+ "user@example",
+ "user..name@example.com",
+ "user@.com",
+ "",
+ " ",
+ "user@domain@domain.com"
+ ];
+
+ for (const email of invalidEmails) {
+ expect(isValidEmail(email)).toBe(false);
+ }
+ });
+
+ it("should reject SQL injection attempts in emails", () => {
+ const sqlEmails = [
+ "admin'--@example.com",
+ "user@example.com'; DROP TABLE User--",
+ "' OR '1'='1@example.com",
+ "user@example.com' UNION SELECT",
+ "admin@example.com'--"
+ ];
+
+ for (const email of sqlEmails) {
+ // Either reject as invalid, or it's properly escaped in queries
+ const isValid = isValidEmail(email);
+ // Test documents the behavior
+ expect(typeof isValid).toBe("boolean");
+ }
+ });
+
+ it("should handle very long email addresses", () => {
+ const longEmail = "a".repeat(1000) + "@example.com";
+ const result = isValidEmail(longEmail);
+
+ // Should handle gracefully
+ expect(typeof result).toBe("boolean");
+ });
+
+ it("should handle email with unicode characters", () => {
+ const unicodeEmail = "üser@exämple.com";
+ const result = isValidEmail(unicodeEmail);
+
+ expect(typeof result).toBe("boolean");
+ });
+ });
+
+ describe("Display Name Validation", () => {
+ it("should accept valid display names", () => {
+ const validNames = [
+ "John Doe",
+ "Alice",
+ "Bob Smith Jr.",
+ "李明",
+ "José García",
+ "123User"
+ ];
+
+ for (const name of validNames) {
+ expect(isValidDisplayName(name)).toBe(true);
+ }
+ });
+
+ it("should reject empty display names", () => {
+ const invalidNames = ["", " ", "\t", "\n"];
+
+ for (const name of invalidNames) {
+ expect(isValidDisplayName(name)).toBe(false);
+ }
+ });
+
+ it("should reject excessively long display names", () => {
+ const longName = "a".repeat(51);
+ expect(isValidDisplayName(longName)).toBe(false);
+ });
+
+ it("should handle display names with special characters", () => {
+ const specialNames = [
+ "User";
+
+ // Validation should not crash
+ const nameValid = isValidDisplayName(scriptInput);
+ expect(typeof nameValid).toBe("boolean");
+
+ // Email validation
+ const emailValid = isValidEmail(scriptInput);
+ expect(typeof emailValid).toBe("boolean");
+ });
+
+ it("should handle event handler attributes", () => {
+ const eventHandlers = [
+ "onclick=alert('XSS')",
+ "onerror=alert('XSS')",
+ "onload=alert('XSS')",
+ "onfocus=alert('XSS')"
+ ];
+
+ for (const handler of eventHandlers) {
+ const result = isValidDisplayName(handler);
+ expect(typeof result).toBe("boolean");
+ }
+ });
+
+ it("should handle javascript: protocol", () => {
+ const jsProtocol = "javascript:alert('XSS')";
+
+ const displayNameValid = isValidDisplayName(jsProtocol);
+ const emailValid = isValidEmail(jsProtocol);
+
+ expect(typeof displayNameValid).toBe("boolean");
+ expect(typeof emailValid).toBe("boolean");
+ });
+ });
+
+ describe("Command Injection Prevention", () => {
+ it("should not execute shell commands from user input", () => {
+ const commandPayloads = [
+ "; ls -la",
+ "| cat /etc/passwd",
+ "&& rm -rf /",
+ "`whoami`",
+ "$(whoami)",
+ "; DROP TABLE User;--"
+ ];
+
+ for (const payload of commandPayloads) {
+ // These should be treated as strings, not executed
+ const emailValid = isValidEmail(payload);
+ const nameValid = isValidDisplayName(payload);
+
+ expect(typeof emailValid).toBe("boolean");
+ expect(typeof nameValid).toBe("boolean");
+ }
+ });
+ });
+
+ describe("Path Traversal Prevention", () => {
+ it("should not allow directory traversal in inputs", () => {
+ const traversalPayloads = [
+ "../../../etc/passwd",
+ "..\\..\\..\\windows\\system32",
+ "....//....//....//etc/passwd",
+ "%2e%2e%2f",
+ "..;/..;/"
+ ];
+
+ for (const payload of traversalPayloads) {
+ const emailValid = isValidEmail(payload);
+ const nameValid = isValidDisplayName(payload);
+
+ expect(typeof emailValid).toBe("boolean");
+ expect(typeof nameValid).toBe("boolean");
+ }
+ });
+ });
+
+ describe("LDAP Injection Prevention", () => {
+ it("should handle LDAP injection patterns", () => {
+ const ldapPayloads = [
+ "*)(uid=*))(|(uid=*",
+ "admin*",
+ "*)(&(password=*))",
+ "*))%00"
+ ];
+
+ for (const payload of ldapPayloads) {
+ const emailValid = isValidEmail(payload);
+ const nameValid = isValidDisplayName(payload);
+
+ expect(typeof emailValid).toBe("boolean");
+ expect(typeof nameValid).toBe("boolean");
+ }
+ });
+ });
+
+ describe("XML Injection Prevention", () => {
+ it("should handle XML special characters", () => {
+ const xmlPayloads = [
+ "",
+ '',
+ ']>',
+ "<script>alert('XSS')</script>"
+ ];
+
+ for (const payload of xmlPayloads) {
+ const emailValid = isValidEmail(payload);
+ const nameValid = isValidDisplayName(payload);
+
+ expect(typeof emailValid).toBe("boolean");
+ expect(typeof nameValid).toBe("boolean");
+ }
+ });
+ });
+
+ describe("NoSQL Injection Prevention", () => {
+ it("should handle MongoDB-style injection attempts", () => {
+ const nosqlPayloads = [
+ '{"$gt": ""}',
+ '{"$ne": null}',
+ '{"$regex": ".*"}',
+ '{"$where": "sleep(1000)"}',
+ '{"username": {"$gt": ""}}'
+ ];
+
+ for (const payload of nosqlPayloads) {
+ const emailValid = isValidEmail(payload);
+ const nameValid = isValidDisplayName(payload);
+
+ expect(typeof emailValid).toBe("boolean");
+ expect(typeof nameValid).toBe("boolean");
+ }
+ });
+ });
+
+ describe("Header Injection Prevention", () => {
+ it("should reject inputs with newline characters", () => {
+ const headerInjection = [
+ "user@example.com\r\nBcc: attacker@evil.com",
+ "test\nSet-Cookie: admin=true",
+ "user\r\nLocation: http://evil.com"
+ ];
+
+ for (const payload of headerInjection) {
+ const emailValid = isValidEmail(payload);
+ expect(emailValid).toBe(false);
+ }
+ });
+ });
+
+ describe("Null Byte Injection Prevention", () => {
+ it("should handle null bytes in input", () => {
+ const nullBytePayloads = [
+ "admin\x00.jpg",
+ "user@example.com\x00admin",
+ "test\0injection"
+ ];
+
+ for (const payload of nullBytePayloads) {
+ const emailValid = isValidEmail(payload);
+ const nameValid = isValidDisplayName(payload);
+
+ expect(typeof emailValid).toBe("boolean");
+ expect(typeof nameValid).toBe("boolean");
+ }
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("should handle extremely long inputs", () => {
+ const longInput = "a".repeat(100000);
+
+ const start = performance.now();
+ const emailValid = isValidEmail(longInput);
+ const nameValid = isValidDisplayName(longInput);
+ const duration = performance.now() - start;
+
+ expect(typeof emailValid).toBe("boolean");
+ expect(typeof nameValid).toBe("boolean");
+ // Should complete quickly (no ReDoS)
+ expect(duration).toBeLessThan(100);
+ });
+
+ it("should handle repeated characters", () => {
+ const repeated = "a".repeat(10000) + "@example.com";
+ const emailValid = isValidEmail(repeated);
+
+ expect(typeof emailValid).toBe("boolean");
+ });
+
+ it("should handle mixed encoding", () => {
+ const mixedEncoding = "test%40example.com";
+ const emailValid = isValidEmail(mixedEncoding);
+
+ expect(typeof emailValid).toBe("boolean");
+ });
+
+ it("should handle unicode normalization issues", () => {
+ const unicodePayloads = [
+ "admin\u0041", // 'A' in unicode
+ "test\u200B@example.com", // zero-width space
+ "user\uFEFF@example.com" // zero-width no-break space
+ ];
+
+ for (const payload of unicodePayloads) {
+ const emailValid = isValidEmail(payload);
+ expect(typeof emailValid).toBe("boolean");
+ }
+ });
+ });
+
+ describe("Performance", () => {
+ it("should validate emails efficiently", () => {
+ const email = "test@example.com";
+
+ const start = performance.now();
+ for (let i = 0; i < 10000; i++) {
+ isValidEmail(email);
+ }
+ const duration = performance.now() - start;
+
+ expect(duration).toBeLessThan(100);
+ });
+
+ it("should validate display names efficiently", () => {
+ const name = "Test User";
+
+ const start = performance.now();
+ for (let i = 0; i < 10000; i++) {
+ isValidDisplayName(name);
+ }
+ const duration = performance.now() - start;
+
+ expect(duration).toBeLessThan(100);
+ });
+
+ it("should not be vulnerable to ReDoS attacks", () => {
+ // ReDoS payload with many repetitions
+ const redosPayload = "a".repeat(1000) + "!";
+
+ const start = performance.now();
+ validatePassword(redosPayload);
+ const duration = performance.now() - start;
+
+ // Should complete quickly
+ expect(duration).toBeLessThan(100);
+ });
+ });
+});
diff --git a/src/server/security/password.test.ts b/src/server/security/password.test.ts
new file mode 100644
index 0000000..98eca9e
--- /dev/null
+++ b/src/server/security/password.test.ts
@@ -0,0 +1,529 @@
+/**
+ * Password Security Tests
+ * Tests for password hashing, validation, strength requirements, and timing attacks
+ */
+
+import { describe, it, expect } from "bun:test";
+import {
+ hashPassword,
+ checkPassword,
+ checkPasswordSafe
+} from "~/server/password";
+import { validatePassword, passwordsMatch } from "~/lib/validation";
+import { measureTime } from "./test-utils";
+
+describe("Password Security", () => {
+ describe("Password Hashing", () => {
+ it("should hash passwords using bcrypt", async () => {
+ const password = "TestPassword123!";
+ const hash = await hashPassword(password);
+
+ expect(hash).toBeDefined();
+ expect(typeof hash).toBe("string");
+ // Bcrypt hashes start with $2b$ or $2a$
+ expect(hash).toMatch(/^\$2[ab]\$/);
+ });
+
+ it("should generate unique hashes for same password", async () => {
+ const password = "TestPassword123!";
+ const hash1 = await hashPassword(password);
+ const hash2 = await hashPassword(password);
+
+ expect(hash1).not.toBe(hash2);
+ });
+
+ it("should generate hashes with sufficient length", async () => {
+ const password = "TestPassword123!";
+ const hash = await hashPassword(password);
+
+ // Bcrypt hashes are 60 characters long
+ expect(hash.length).toBe(60);
+ });
+
+ it("should handle very long passwords", async () => {
+ const longPassword = "a".repeat(1000);
+ const hash = await hashPassword(longPassword);
+
+ expect(hash).toBeDefined();
+ expect(hash.length).toBe(60);
+ });
+
+ it("should handle passwords with special characters", async () => {
+ const specialPassword = "P@ssw0rd!#$%^&*()_+-=[]{}|;:',.<>?/~`";
+ const hash = await hashPassword(specialPassword);
+
+ expect(hash).toBeDefined();
+ const match = await checkPassword(specialPassword, hash);
+ expect(match).toBe(true);
+ });
+
+ it("should handle passwords with unicode characters", async () => {
+ const unicodePassword = "Pässwörd123🔐🛡️";
+ const hash = await hashPassword(unicodePassword);
+
+ expect(hash).toBeDefined();
+ const match = await checkPassword(unicodePassword, hash);
+ expect(match).toBe(true);
+ });
+
+ it("should handle empty passwords", async () => {
+ const emptyPassword = "";
+ const hash = await hashPassword(emptyPassword);
+
+ expect(hash).toBeDefined();
+ expect(hash.length).toBe(60);
+ });
+ });
+
+ describe("Password Verification", () => {
+ it("should verify correct password", async () => {
+ const password = "TestPassword123!";
+ const hash = await hashPassword(password);
+ const match = await checkPassword(password, hash);
+
+ expect(match).toBe(true);
+ });
+
+ it("should reject incorrect password", async () => {
+ const password = "TestPassword123!";
+ const wrongPassword = "WrongPassword123!";
+ const hash = await hashPassword(password);
+ const match = await checkPassword(wrongPassword, hash);
+
+ expect(match).toBe(false);
+ });
+
+ it("should be case-sensitive", async () => {
+ const password = "TestPassword123!";
+ const hash = await hashPassword(password);
+ const match = await checkPassword("testpassword123!", hash);
+
+ expect(match).toBe(false);
+ });
+
+ it("should detect single character differences", async () => {
+ const password = "TestPassword123!";
+ const hash = await hashPassword(password);
+ const almostMatch = "TestPassword124!";
+ const match = await checkPassword(almostMatch, hash);
+
+ expect(match).toBe(false);
+ });
+
+ it("should reject password with extra characters", async () => {
+ const password = "TestPassword123!";
+ const hash = await hashPassword(password);
+ const match = await checkPassword(password + "x", hash);
+
+ expect(match).toBe(false);
+ });
+
+ it("should reject password missing characters", async () => {
+ const password = "TestPassword123!";
+ const hash = await hashPassword(password);
+ const match = await checkPassword(password.slice(0, -1), hash);
+
+ expect(match).toBe(false);
+ });
+ });
+
+ describe("Timing Attack Prevention", () => {
+ it("should use constant time comparison in checkPasswordSafe", async () => {
+ const password = "TestPassword123!";
+ const hash = await hashPassword(password);
+
+ // Measure time for correct password
+ const { duration: correctDuration } = await measureTime(() =>
+ checkPasswordSafe(password, hash)
+ );
+
+ // Measure time for incorrect password
+ const { duration: incorrectDuration } = await measureTime(() =>
+ checkPasswordSafe("WrongPassword123!", hash)
+ );
+
+ // Bcrypt comparison should take similar time regardless
+ const timingDifference = Math.abs(correctDuration - incorrectDuration);
+
+ // Allow reasonable variance (bcrypt is inherently slow)
+ expect(timingDifference).toBeLessThan(50);
+ });
+
+ it("should handle null hash without timing leak", async () => {
+ const password = "TestPassword123!";
+
+ // Measure time for null hash
+ const { result: result1, duration: duration1 } = await measureTime(() =>
+ checkPasswordSafe(password, null)
+ );
+
+ // Measure time for undefined hash
+ const { result: result2, duration: duration2 } = await measureTime(() =>
+ checkPasswordSafe(password, undefined)
+ );
+
+ expect(result1).toBe(false);
+ expect(result2).toBe(false);
+
+ // Should take similar time
+ const timingDifference = Math.abs(duration1 - duration2);
+ expect(timingDifference).toBeLessThan(50);
+ });
+
+ it("should run bcrypt even when user doesn't exist", async () => {
+ const password = "TestPassword123!";
+
+ // checkPasswordSafe should always run bcrypt to prevent timing attacks
+ const { duration } = await measureTime(() =>
+ checkPasswordSafe(password, null)
+ );
+
+ // Should take at least a few milliseconds (bcrypt is slow)
+ expect(duration).toBeGreaterThan(1);
+ });
+
+ it("should have consistent timing for user exists vs not exists", async () => {
+ const password = "TestPassword123!";
+ const hash = await hashPassword(password);
+
+ // User exists
+ const { duration: existsDuration } = await measureTime(() =>
+ checkPasswordSafe("WrongPassword", hash)
+ );
+
+ // User doesn't exist (null hash)
+ const { duration: notExistsDuration } = await measureTime(() =>
+ checkPasswordSafe("WrongPassword", null)
+ );
+
+ // Timing should be similar to prevent user enumeration
+ const timingDifference = Math.abs(existsDuration - notExistsDuration);
+ expect(timingDifference).toBeLessThan(50);
+ });
+ });
+
+ describe("Password Validation", () => {
+ it("should accept strong passwords", () => {
+ const strongPassword = "MyStr0ng!P@ssw0rd";
+ const result = validatePassword(strongPassword);
+
+ expect(result.isValid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ expect(result.strength).toBe("good");
+ });
+
+ it("should reject passwords shorter than 12 characters", () => {
+ const shortPassword = "Short1!";
+ const result = validatePassword(shortPassword);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain(
+ "Password must be at least 12 characters long"
+ );
+ });
+
+ it("should reject passwords without uppercase letters", () => {
+ const noUppercase = "lowercase123!@#";
+ const result = validatePassword(noUppercase);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain(
+ "Password must contain at least one uppercase letter"
+ );
+ });
+
+ it("should reject passwords without lowercase letters", () => {
+ const noLowercase = "UPPERCASE123!@#";
+ const result = validatePassword(noLowercase);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain(
+ "Password must contain at least one lowercase letter"
+ );
+ });
+
+ it("should reject passwords without numbers", () => {
+ const noNumbers = "NoNumbersHere!@#";
+ const result = validatePassword(noNumbers);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain(
+ "Password must contain at least one number"
+ );
+ });
+
+ it("should reject passwords without special characters", () => {
+ const noSpecial = "NoSpecialChars123";
+ const result = validatePassword(noSpecial);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain(
+ "Password must contain at least one special character"
+ );
+ });
+
+ it("should reject common weak passwords", () => {
+ const commonPatterns = [
+ "Password123!",
+ "Qwerty123456!",
+ "Letmein12345!",
+ "Welcome123!@"
+ ];
+
+ for (const password of commonPatterns) {
+ const result = validatePassword(password);
+ expect(result.isValid).toBe(false);
+ expect(result.errors.some((e) => e.includes("common patterns"))).toBe(
+ true
+ );
+ }
+ });
+
+ it("should calculate password strength correctly", () => {
+ const fairPassword = "MyP@ssw0rd12"; // 12 chars
+ const goodPassword = "MyStr0ng!P@ssw0rd"; // 17 chars
+ const strongPassword = "MyV3ry!Str0ng@P@ssw0rd123"; // 25 chars
+
+ expect(validatePassword(fairPassword).strength).toBe("fair");
+ expect(validatePassword(goodPassword).strength).toBe("good");
+ expect(validatePassword(strongPassword).strength).toBe("strong");
+ });
+
+ it("should mark weak passwords appropriately", () => {
+ const weakPassword = "weak";
+ const result = validatePassword(weakPassword);
+
+ expect(result.strength).toBe("weak");
+ expect(result.isValid).toBe(false);
+ });
+ });
+
+ describe("Password Matching", () => {
+ it("should confirm matching passwords", () => {
+ const password = "TestPassword123!";
+ const confirmation = "TestPassword123!";
+
+ expect(passwordsMatch(password, confirmation)).toBe(true);
+ });
+
+ it("should reject non-matching passwords", () => {
+ const password = "TestPassword123!";
+ const confirmation = "DifferentPassword123!";
+
+ expect(passwordsMatch(password, confirmation)).toBe(false);
+ });
+
+ it("should reject empty passwords", () => {
+ expect(passwordsMatch("", "")).toBe(false);
+ });
+
+ it("should be case-sensitive", () => {
+ const password = "TestPassword123!";
+ const confirmation = "testpassword123!";
+
+ expect(passwordsMatch(password, confirmation)).toBe(false);
+ });
+
+ it("should detect single character differences", () => {
+ const password = "TestPassword123!";
+ const confirmation = "TestPassword124!";
+
+ expect(passwordsMatch(password, confirmation)).toBe(false);
+ });
+ });
+
+ describe("Password Attack Scenarios", () => {
+ it("should resist brute force attacks with bcrypt slowness", async () => {
+ const password = "TestPassword123!";
+ const hash = await hashPassword(password);
+
+ // Measure time for multiple checks (simulating brute force)
+ const start = performance.now();
+ const attempts = 10;
+
+ for (let i = 0; i < attempts; i++) {
+ await checkPassword(`attempt${i}`, hash);
+ }
+
+ const duration = performance.now() - start;
+ const avgPerAttempt = duration / attempts;
+
+ // Each attempt should take significant time (bcrypt is slow)
+ // This makes brute force impractical
+ expect(avgPerAttempt).toBeGreaterThan(5); // At least 5ms per attempt
+ });
+
+ it("should prevent rainbow table attacks with unique salts", async () => {
+ const password = "CommonPassword123!";
+
+ // Generate multiple hashes for same password
+ const hashes = await Promise.all(
+ Array.from({ length: 10 }, () => hashPassword(password))
+ );
+
+ // All hashes should be unique (different salts)
+ const uniqueHashes = new Set(hashes);
+ expect(uniqueHashes.size).toBe(10);
+ });
+
+ it("should prevent password spraying with validation", () => {
+ // Common passwords that should be rejected
+ const commonPasswords = [
+ "Password123!",
+ "Welcome123!",
+ "Admin123!@#",
+ "Letmein123!"
+ ];
+
+ for (const password of commonPasswords) {
+ const result = validatePassword(password);
+ expect(result.isValid).toBe(false);
+ }
+ });
+
+ it("should resist dictionary attacks", () => {
+ // Dictionary words that should be caught
+ const dictionaryBased = ["Sunshine123!", "Princess456!", "Dragon789!@"];
+
+ for (const password of dictionaryBased) {
+ const result = validatePassword(password);
+ expect(result.isValid).toBe(false);
+ }
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("should handle very long passwords", async () => {
+ const longPassword = "A1!a" + "x".repeat(1000); // Very long but valid
+ const hash = await hashPassword(longPassword);
+ const match = await checkPassword(longPassword, hash);
+
+ expect(match).toBe(true);
+ });
+
+ it("should handle passwords with only whitespace", async () => {
+ const whitespacePassword = " ";
+ const result = validatePassword(whitespacePassword);
+
+ expect(result.isValid).toBe(false);
+ });
+
+ it("should handle null bytes in passwords", async () => {
+ const nullBytePassword = "Test\0Password123!";
+ const hash = await hashPassword(nullBytePassword);
+ const match = await checkPassword(nullBytePassword, hash);
+
+ // Behavior may vary - just ensure no crash
+ expect(typeof match).toBe("boolean");
+ });
+
+ it("should handle passwords with emoji", () => {
+ const emojiPassword = "MyP@ssw0rd🔐🛡️123";
+ const result = validatePassword(emojiPassword);
+
+ expect(result.isValid).toBe(true);
+ });
+
+ it("should handle passwords with newlines", async () => {
+ const newlinePassword = "Test\nPassword123!";
+ const hash = await hashPassword(newlinePassword);
+ const match = await checkPassword(newlinePassword, hash);
+
+ expect(match).toBe(true);
+ });
+
+ it("should handle passwords with tabs", async () => {
+ const tabPassword = "Test\tPassword123!";
+ const hash = await hashPassword(tabPassword);
+ const match = await checkPassword(tabPassword, hash);
+
+ expect(match).toBe(true);
+ });
+ });
+
+ describe("Performance", () => {
+ it("should hash passwords with appropriate slowness", async () => {
+ const password = "TestPassword123!";
+
+ const start = performance.now();
+ await hashPassword(password);
+ const duration = performance.now() - start;
+
+ // Bcrypt should be slow enough to deter brute force
+ // With 10 rounds, should take at least a few milliseconds
+ expect(duration).toBeGreaterThan(5);
+ // But not too slow for normal operation
+ expect(duration).toBeLessThan(500);
+ });
+
+ it("should verify passwords with consistent timing", async () => {
+ const password = "TestPassword123!";
+ const hash = await hashPassword(password);
+
+ const durations: number[] = [];
+ for (let i = 0; i < 5; i++) {
+ const start = performance.now();
+ await checkPassword(password, hash);
+ durations.push(performance.now() - start);
+ }
+
+ // Timing should be relatively consistent
+ const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
+ const maxDeviation = Math.max(...durations.map((d) => Math.abs(d - avg)));
+
+ // Allow reasonable variance
+ expect(maxDeviation).toBeLessThan(avg * 0.5);
+ });
+
+ it("should validate passwords quickly", () => {
+ const password = "TestPassword123!";
+
+ const start = performance.now();
+ for (let i = 0; i < 10000; i++) {
+ validatePassword(password);
+ }
+ const duration = performance.now() - start;
+
+ // Validation is CPU-bound but should be fast
+ expect(duration).toBeLessThan(100);
+ });
+ });
+
+ describe("bcrypt Salt Rounds", () => {
+ it("should use appropriate salt rounds for security", async () => {
+ const password = "TestPassword123!";
+ const hash = await hashPassword(password);
+
+ // Check that hash uses correct salt rounds
+ // Bcrypt format: $2b$rounds$salthash
+ const parts = hash.split("$");
+ const rounds = parseInt(parts[2]);
+
+ // Should use 10 rounds (from password.ts)
+ expect(rounds).toBe(10);
+ });
+
+ it("should generate cryptographically random salts", async () => {
+ const password = "TestPassword123!";
+ const hashes = await Promise.all(
+ Array.from({ length: 100 }, () => hashPassword(password))
+ );
+
+ // Extract salts from hashes
+ const salts = hashes.map((hash) => {
+ const parts = hash.split("$");
+ return parts[3].substring(0, 22); // Salt is 22 characters
+ });
+
+ // All salts should be unique
+ const uniqueSalts = new Set(salts);
+ expect(uniqueSalts.size).toBe(100);
+
+ // Check for patterns in salts (should be random)
+ for (let i = 1; i < salts.length; i++) {
+ // Salts should not be sequential or predictable
+ expect(salts[i]).not.toBe(salts[i - 1]);
+ }
+ });
+ });
+});
diff --git a/src/server/security/rate-limit.test.ts b/src/server/security/rate-limit.test.ts
new file mode 100644
index 0000000..83e791d
--- /dev/null
+++ b/src/server/security/rate-limit.test.ts
@@ -0,0 +1,443 @@
+/**
+ * Rate Limiting Tests
+ * Tests for rate limiting mechanisms on authentication endpoints
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from "bun:test";
+import {
+ checkRateLimit,
+ getClientIP,
+ rateLimitLogin,
+ rateLimitPasswordReset,
+ rateLimitRegistration,
+ rateLimitEmailVerification,
+ clearRateLimitStore,
+ RATE_LIMITS
+} from "~/server/security";
+import { createMockEvent, randomIP } from "./test-utils";
+import { TRPCError } from "@trpc/server";
+
+describe("Rate Limiting", () => {
+ // Clear rate limit store before each test to ensure isolation
+ beforeEach(() => {
+ clearRateLimitStore();
+ });
+
+ describe("checkRateLimit", () => {
+ it("should allow requests within rate limit", () => {
+ const identifier = `test-${Date.now()}`;
+ const maxAttempts = 5;
+ const windowMs = 60000;
+
+ for (let i = 0; i < maxAttempts; i++) {
+ const remaining = checkRateLimit(identifier, maxAttempts, windowMs);
+ expect(remaining).toBe(maxAttempts - i - 1);
+ }
+ });
+
+ it("should block requests exceeding rate limit", () => {
+ const identifier = `test-${Date.now()}`;
+ const maxAttempts = 3;
+ const windowMs = 60000;
+
+ // Use up all attempts
+ for (let i = 0; i < maxAttempts; i++) {
+ checkRateLimit(identifier, maxAttempts, windowMs);
+ }
+
+ // Next attempt should throw
+ expect(() => {
+ checkRateLimit(identifier, maxAttempts, windowMs);
+ }).toThrow(TRPCError);
+ });
+
+ it("should include remaining time in error message", () => {
+ const identifier = `test-${Date.now()}`;
+ const maxAttempts = 2;
+ const windowMs = 60000;
+
+ // Use up all attempts
+ checkRateLimit(identifier, maxAttempts, windowMs);
+ checkRateLimit(identifier, maxAttempts, windowMs);
+
+ try {
+ checkRateLimit(identifier, maxAttempts, windowMs);
+ expect.unreachable("Should have thrown TRPCError");
+ } catch (error) {
+ expect(error).toBeInstanceOf(TRPCError);
+ const trpcError = error as TRPCError;
+ expect(trpcError.code).toBe("TOO_MANY_REQUESTS");
+ expect(trpcError.message).toMatch(/Try again in \d+ seconds/);
+ }
+ });
+
+ it("should reset after time window expires", async () => {
+ const identifier = `test-${Date.now()}`;
+ const maxAttempts = 3;
+ const windowMs = 100; // 100ms window for fast testing
+
+ // Use up all attempts
+ for (let i = 0; i < maxAttempts; i++) {
+ checkRateLimit(identifier, maxAttempts, windowMs);
+ }
+
+ // Should be blocked
+ expect(() => {
+ checkRateLimit(identifier, maxAttempts, windowMs);
+ }).toThrow(TRPCError);
+
+ // Wait for window to expire
+ await new Promise((resolve) => setTimeout(resolve, 150));
+
+ // Should be allowed again
+ const remaining = checkRateLimit(identifier, maxAttempts, windowMs);
+ expect(remaining).toBe(maxAttempts - 1);
+ });
+
+ it("should handle concurrent requests correctly", () => {
+ const identifier = `test-${Date.now()}`;
+ const maxAttempts = 10;
+ const windowMs = 60000;
+
+ // Simulate concurrent requests
+ const results: number[] = [];
+ for (let i = 0; i < maxAttempts; i++) {
+ results.push(checkRateLimit(identifier, maxAttempts, windowMs));
+ }
+
+ // All should succeed with decreasing remaining counts
+ expect(results).toEqual([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]);
+ });
+
+ it("should isolate different identifiers", () => {
+ const maxAttempts = 3;
+ const windowMs = 60000;
+
+ const id1 = `test1-${Date.now()}`;
+ const id2 = `test2-${Date.now()}`;
+
+ // Use up attempts for id1
+ for (let i = 0; i < maxAttempts; i++) {
+ checkRateLimit(id1, maxAttempts, windowMs);
+ }
+
+ // id1 should be blocked
+ expect(() => {
+ checkRateLimit(id1, maxAttempts, windowMs);
+ }).toThrow(TRPCError);
+
+ // id2 should still work
+ const remaining = checkRateLimit(id2, maxAttempts, windowMs);
+ expect(remaining).toBe(maxAttempts - 1);
+ });
+ });
+
+ describe("getClientIP", () => {
+ it("should extract IP from x-forwarded-for header", () => {
+ const event = createMockEvent({
+ headers: { "x-forwarded-for": "192.168.1.1, 10.0.0.1" }
+ });
+
+ const ip = getClientIP(event);
+ expect(ip).toBe("192.168.1.1");
+ });
+
+ it("should extract IP from x-real-ip header", () => {
+ const event = createMockEvent({
+ headers: { "x-real-ip": "192.168.1.2" }
+ });
+
+ const ip = getClientIP(event);
+ expect(ip).toBe("192.168.1.2");
+ });
+
+ it("should prefer x-forwarded-for over x-real-ip", () => {
+ const event = createMockEvent({
+ headers: {
+ "x-forwarded-for": "192.168.1.1",
+ "x-real-ip": "192.168.1.2"
+ }
+ });
+
+ const ip = getClientIP(event);
+ expect(ip).toBe("192.168.1.1");
+ });
+
+ it("should return unknown when no IP headers present", () => {
+ const event = createMockEvent({});
+ const ip = getClientIP(event);
+ expect(ip).toBe("unknown");
+ });
+
+ it("should trim whitespace from IP addresses", () => {
+ const event = createMockEvent({
+ headers: { "x-forwarded-for": " 192.168.1.1 , 10.0.0.1" }
+ });
+
+ const ip = getClientIP(event);
+ expect(ip).toBe("192.168.1.1");
+ });
+
+ it("should handle IPv6 addresses", () => {
+ const event = createMockEvent({
+ headers: {
+ "x-forwarded-for": "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
+ }
+ });
+
+ const ip = getClientIP(event);
+ expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
+ });
+ });
+
+ describe("rateLimitLogin", () => {
+ it("should enforce both IP and email rate limits", () => {
+ const ip = randomIP();
+
+ // Should allow up to LOGIN_IP max attempts (5) with different emails
+ // Use different emails to avoid hitting email rate limit
+ for (let i = 0; i < RATE_LIMITS.LOGIN_IP.maxAttempts; i++) {
+ const email = `test-${Date.now()}-${i}@example.com`;
+ rateLimitLogin(email, ip);
+ }
+
+ // Next attempt should fail due to IP limit
+ expect(() => {
+ const email = `test-${Date.now()}-final@example.com`;
+ rateLimitLogin(email, ip);
+ }).toThrow(TRPCError);
+ });
+
+ it("should limit by email independently of IP", () => {
+ const email = `test-${Date.now()}@example.com`;
+
+ // Use different IPs but same email
+ for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
+ rateLimitLogin(email, randomIP());
+ }
+
+ // Next attempt with different IP should still fail due to email limit
+ expect(() => {
+ rateLimitLogin(email, randomIP());
+ }).toThrow(TRPCError);
+ });
+
+ it("should allow different emails from same IP within IP limit", () => {
+ const ip = randomIP();
+
+ // Use different emails but same IP
+ for (let i = 0; i < RATE_LIMITS.LOGIN_IP.maxAttempts; i++) {
+ const email = `test${i}-${Date.now()}@example.com`;
+ rateLimitLogin(email, ip);
+ }
+
+ // Next attempt should fail due to IP limit
+ expect(() => {
+ rateLimitLogin(`new-${Date.now()}@example.com`, ip);
+ }).toThrow(TRPCError);
+ });
+ });
+
+ describe("rateLimitPasswordReset", () => {
+ it("should enforce password reset rate limit", () => {
+ const ip = randomIP();
+
+ // Should allow up to PASSWORD_RESET_IP max attempts (3)
+ for (let i = 0; i < RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts; i++) {
+ rateLimitPasswordReset(ip);
+ }
+
+ // Next attempt should fail
+ expect(() => {
+ rateLimitPasswordReset(ip);
+ }).toThrow(TRPCError);
+ });
+
+ it("should isolate password reset limits from login limits", () => {
+ const ip = randomIP();
+ const email = `test-${Date.now()}@example.com`;
+
+ // Use up password reset limit
+ for (let i = 0; i < RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts; i++) {
+ rateLimitPasswordReset(ip);
+ }
+
+ // Should still be able to login (different limit)
+ rateLimitLogin(email, ip);
+ });
+ });
+
+ describe("rateLimitRegistration", () => {
+ it("should enforce registration rate limit", () => {
+ const ip = randomIP();
+
+ // Should allow up to REGISTRATION_IP max attempts (3)
+ for (let i = 0; i < RATE_LIMITS.REGISTRATION_IP.maxAttempts; i++) {
+ rateLimitRegistration(ip);
+ }
+
+ // Next attempt should fail
+ expect(() => {
+ rateLimitRegistration(ip);
+ }).toThrow(TRPCError);
+ });
+ });
+
+ describe("rateLimitEmailVerification", () => {
+ it("should enforce email verification rate limit", () => {
+ const ip = randomIP();
+
+ // Should allow up to EMAIL_VERIFICATION_IP max attempts (5)
+ for (let i = 0; i < RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts; i++) {
+ rateLimitEmailVerification(ip);
+ }
+
+ // Next attempt should fail
+ expect(() => {
+ rateLimitEmailVerification(ip);
+ }).toThrow(TRPCError);
+ });
+ });
+
+ describe("Rate Limit Attack Scenarios", () => {
+ it("should prevent brute force login attacks", () => {
+ const email = "victim@example.com";
+ const attackerIP = "1.2.3.4";
+
+ // Simulate brute force attack
+ let blockedAtAttempt = 0;
+ for (let i = 0; i < 10; i++) {
+ try {
+ rateLimitLogin(email, attackerIP);
+ } catch (error) {
+ if (error instanceof TRPCError) {
+ blockedAtAttempt = i;
+ break;
+ }
+ }
+ }
+
+ // Should be blocked before 10 attempts
+ expect(blockedAtAttempt).toBeLessThan(10);
+ expect(blockedAtAttempt).toBeGreaterThan(0);
+ });
+
+ it("should prevent distributed brute force from multiple IPs", () => {
+ const email = "victim@example.com";
+
+ // Simulate distributed attack from different IPs
+ let blockedAtAttempt = 0;
+ for (let i = 0; i < 10; i++) {
+ try {
+ rateLimitLogin(email, randomIP());
+ } catch (error) {
+ if (error instanceof TRPCError) {
+ blockedAtAttempt = i;
+ break;
+ }
+ }
+ }
+
+ // Should be blocked at email limit (3 attempts)
+ expect(blockedAtAttempt).toBeLessThanOrEqual(
+ RATE_LIMITS.LOGIN_EMAIL.maxAttempts
+ );
+ });
+
+ it("should prevent account enumeration via registration spam", () => {
+ const attackerIP = randomIP();
+
+ // Try to register many accounts to enumerate valid emails
+ let blockedAtAttempt = 0;
+ for (let i = 0; i < 10; i++) {
+ try {
+ rateLimitRegistration(attackerIP);
+ } catch (error) {
+ if (error instanceof TRPCError) {
+ blockedAtAttempt = i;
+ break;
+ }
+ }
+ }
+
+ // Should be blocked at registration limit (3 attempts)
+ expect(blockedAtAttempt).toBe(RATE_LIMITS.REGISTRATION_IP.maxAttempts);
+ });
+
+ it("should prevent password reset spam attacks", () => {
+ const attackerIP = randomIP();
+
+ // Try to spam password resets
+ let blockedAtAttempt = 0;
+ for (let i = 0; i < 10; i++) {
+ try {
+ rateLimitPasswordReset(attackerIP);
+ } catch (error) {
+ if (error instanceof TRPCError) {
+ blockedAtAttempt = i;
+ break;
+ }
+ }
+ }
+
+ // Should be blocked at password reset limit (3 attempts)
+ expect(blockedAtAttempt).toBe(RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts);
+ });
+ });
+
+ describe("Rate Limit Configuration", () => {
+ it("should have reasonable limits configured", () => {
+ // Login should be more permissive than registration
+ expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(
+ RATE_LIMITS.REGISTRATION_IP.maxAttempts
+ );
+
+ // All limits should be positive
+ expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(0);
+ expect(RATE_LIMITS.LOGIN_EMAIL.maxAttempts).toBeGreaterThan(0);
+ expect(RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts).toBeGreaterThan(0);
+ expect(RATE_LIMITS.REGISTRATION_IP.maxAttempts).toBeGreaterThan(0);
+ expect(RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts).toBeGreaterThan(0);
+
+ // All windows should be at least 1 minute
+ expect(RATE_LIMITS.LOGIN_IP.windowMs).toBeGreaterThanOrEqual(60000);
+ expect(RATE_LIMITS.LOGIN_EMAIL.windowMs).toBeGreaterThanOrEqual(60000);
+ expect(RATE_LIMITS.PASSWORD_RESET_IP.windowMs).toBeGreaterThanOrEqual(
+ 60000
+ );
+ expect(RATE_LIMITS.REGISTRATION_IP.windowMs).toBeGreaterThanOrEqual(
+ 60000
+ );
+ expect(RATE_LIMITS.EMAIL_VERIFICATION_IP.windowMs).toBeGreaterThanOrEqual(
+ 60000
+ );
+ });
+ });
+
+ describe("Performance", () => {
+ it("should handle high volume of rate limit checks efficiently", () => {
+ const start = performance.now();
+
+ // Check 1000 different identifiers
+ for (let i = 0; i < 1000; i++) {
+ checkRateLimit(`test-${i}`, 5, 60000);
+ }
+
+ const duration = performance.now() - start;
+
+ // Should complete in less than 100ms
+ expect(duration).toBeLessThan(100);
+ });
+
+ it("should not leak memory with many identifiers", () => {
+ // Create many rate limit entries
+ for (let i = 0; i < 10000; i++) {
+ checkRateLimit(`test-${i}`, 5, 60000);
+ }
+
+ // This test mainly ensures no crashes occur
+ // Memory cleanup is tested by the cleanup interval in security.ts
+ expect(true).toBe(true);
+ });
+ });
+});
diff --git a/src/server/security/test-utils.ts b/src/server/security/test-utils.ts
new file mode 100644
index 0000000..bd384c4
--- /dev/null
+++ b/src/server/security/test-utils.ts
@@ -0,0 +1,188 @@
+/**
+ * Security Test Utilities
+ * Shared helpers for security-related tests
+ */
+
+import type { H3Event } from "vinxi/http";
+import { SignJWT } from "jose";
+import { env } from "~/env/server";
+
+/**
+ * Create a mock H3Event for testing
+ * Creates a minimal structure that works with our cookie/header fallback logic
+ */
+export function createMockEvent(options: {
+ headers?: Record;
+ cookies?: Record;
+ method?: string;
+ url?: string;
+}): H3Event {
+ const {
+ headers = {},
+ cookies = {},
+ method = "POST",
+ url = "http://localhost:3000/"
+ } = options;
+
+ const cookieString = Object.entries(cookies)
+ .map(([key, value]) => `${key}=${value}`)
+ .join("; ");
+
+ const allHeaders = {
+ ...headers,
+ ...(cookieString ? { cookie: cookieString } : {})
+ };
+
+ // Try to create Headers object, fall back to plain object if headers contain invalid values
+ let headersObj: Headers | Record;
+ try {
+ headersObj = new Headers(allHeaders);
+ } catch (e) {
+ // If Headers constructor fails (e.g., unicode in headers), use plain object
+ headersObj = allHeaders;
+ }
+
+ // Create mock event with headers accessible via .headers.get() and .node.req.headers
+ const mockEvent = {
+ headers: headersObj,
+ node: {
+ req: {
+ headers: allHeaders
+ },
+ res: {
+ cookies: {}
+ }
+ }
+ } as unknown as H3Event;
+
+ return mockEvent;
+}
+
+/**
+ * Generate a valid JWT token for testing
+ */
+export async function createTestJWT(
+ userId: string,
+ expiresIn: string = "1h"
+): Promise {
+ const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
+ return await new SignJWT({ id: userId })
+ .setProtectedHeader({ alg: "HS256" })
+ .setExpirationTime(expiresIn)
+ .sign(secret);
+}
+
+/**
+ * Generate an expired JWT token for testing
+ */
+export async function createExpiredJWT(userId: string): Promise {
+ const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
+ return await new SignJWT({ id: userId })
+ .setProtectedHeader({ alg: "HS256" })
+ .setExpirationTime("-1h") // Expired 1 hour ago
+ .sign(secret);
+}
+
+/**
+ * Generate a JWT with invalid signature
+ */
+export async function createInvalidSignatureJWT(
+ userId: string
+): Promise {
+ const wrongSecret = new TextEncoder().encode("wrong-secret-key");
+ return await new SignJWT({ id: userId })
+ .setProtectedHeader({ alg: "HS256" })
+ .setExpirationTime("1h")
+ .sign(wrongSecret);
+}
+
+/**
+ * Generate test credentials
+ */
+export function createTestCredentials() {
+ return {
+ email: `test-${Date.now()}@example.com`,
+ password: "TestPass123!@#",
+ passwordConfirmation: "TestPass123!@#"
+ };
+}
+
+/**
+ * Common SQL injection payloads
+ */
+export const SQL_INJECTION_PAYLOADS = [
+ "' OR '1'='1",
+ "'; DROP TABLE User; --",
+ "admin'--",
+ "' UNION SELECT * FROM User--",
+ "1' OR 1=1--",
+ "' OR 'x'='x",
+ "1; DELETE FROM User WHERE 1=1--",
+ "' AND 1=0 UNION ALL SELECT * FROM User--"
+];
+
+/**
+ * Common XSS payloads
+ */
+export const XSS_PAYLOADS = [
+ "",
+ "
",
+ "javascript:alert('XSS')",
+ "