diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts index e8bd701..8960e20 100644 --- a/src/server/api/routers/auth.ts +++ b/src/server/api/routers/auth.ts @@ -30,6 +30,7 @@ import { setCSRFToken, csrfProtection, getClientIP, + getUserAgent, getAuditContext, rateLimitLogin, rateLimitPasswordReset, @@ -37,6 +38,22 @@ import { 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 @@ -115,7 +132,7 @@ function setAuthCookies( const cookieOptions: any = { path: "/", httpOnly: true, - secure: true, // Always enforce secure cookies + secure: env.NODE_ENV === "production", sameSite: "lax", ...options }; @@ -292,9 +309,9 @@ export const authRouter = createTRPCRouter({ } // Create session with client info - const clientIP = getClientIP(ctx.event.nativeEvent); + const clientIP = getClientIP(getH3Event(ctx)); const userAgent = - ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown"; + getUserAgent(getH3Event(ctx)); const sessionId = await createSession( userId, "14d", @@ -304,16 +321,16 @@ export const authRouter = createTRPCRouter({ const token = await createJWT(userId, sessionId); - setCookie(ctx.event.nativeEvent, "userIDToken", token, { + setCookie(getH3Event(ctx), "userIDToken", token, { maxAge: 60 * 60 * 24 * 14, // 14 days path: "/", httpOnly: true, - secure: true, // Always enforce secure cookies + secure: env.NODE_ENV === "production", sameSite: "lax" }); // Set CSRF token for authenticated session - setCSRFToken(ctx.event.nativeEvent); + setCSRFToken(getH3Event(ctx)); // Log successful OAuth login await logAuditEvent({ @@ -331,7 +348,7 @@ export const authRouter = createTRPCRouter({ }; } catch (error) { // Log failed OAuth login - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ eventType: "auth.login.failed", eventData: { @@ -475,9 +492,9 @@ export const authRouter = createTRPCRouter({ } // Create session with client info - const clientIP = getClientIP(ctx.event.nativeEvent); + const clientIP = getClientIP(getH3Event(ctx)); const userAgent = - ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown"; + getUserAgent(getH3Event(ctx)); const sessionId = await createSession( userId, "14d", @@ -487,16 +504,16 @@ export const authRouter = createTRPCRouter({ const token = await createJWT(userId, sessionId); - setCookie(ctx.event.nativeEvent, "userIDToken", token, { + setCookie(getH3Event(ctx), "userIDToken", token, { maxAge: 60 * 60 * 24 * 14, // 14 days path: "/", httpOnly: true, - secure: true, // Always enforce secure cookies + secure: env.NODE_ENV === "production", sameSite: "lax" }); // Set CSRF token for authenticated session - setCSRFToken(ctx.event.nativeEvent); + setCSRFToken(getH3Event(ctx)); // Log successful OAuth login await logAuditEvent({ @@ -514,7 +531,7 @@ export const authRouter = createTRPCRouter({ }; } catch (error) { // Log failed OAuth login - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ eventType: "auth.login.failed", eventData: { @@ -595,9 +612,9 @@ export const authRouter = createTRPCRouter({ const userId = (res.rows[0] as unknown as User).id; // Create session with client info - const clientIP = getClientIP(ctx.event.nativeEvent); + const clientIP = getClientIP(getH3Event(ctx)); const userAgent = - ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown"; + getUserAgent(getH3Event(ctx)); const expiresIn = rememberMe ? "14d" : "12h"; const sessionId = await createSession( userId, @@ -611,7 +628,7 @@ export const authRouter = createTRPCRouter({ const cookieOptions: any = { path: "/", httpOnly: true, - secure: true, // Always enforce secure cookies + secure: env.NODE_ENV === "production", sameSite: "lax" }; @@ -620,14 +637,14 @@ export const authRouter = createTRPCRouter({ } setCookie( - ctx.event.nativeEvent, + getH3Event(ctx), "userIDToken", userToken, cookieOptions ); // Set CSRF token for authenticated session - setCSRFToken(ctx.event.nativeEvent); + setCSRFToken(getH3Event(ctx)); // Log successful email link login await logAuditEvent({ @@ -645,7 +662,7 @@ export const authRouter = createTRPCRouter({ }; } catch (error) { // Log failed email link login - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ eventType: "auth.login.failed", eventData: { @@ -704,10 +721,10 @@ export const authRouter = createTRPCRouter({ await conn.execute({ sql: query, args: params }); // Log successful email verification - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ userId, - eventType: "auth.email_verified", + eventType: "auth.email.verify.complete", eventData: { email }, ipAddress, userAgent, @@ -720,9 +737,9 @@ export const authRouter = createTRPCRouter({ }; } catch (error) { // Log failed email verification - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ - eventType: "auth.email_verified", + eventType: "auth.email.verify.complete", eventData: { email, reason: error instanceof TRPCError ? error.message : "unknown" @@ -749,8 +766,8 @@ export const authRouter = createTRPCRouter({ const { email, password, passwordConfirmation } = input; // Apply rate limiting - const clientIP = getClientIP(ctx.event.nativeEvent); - rateLimitRegistration(clientIP, ctx.event.nativeEvent); + const clientIP = getClientIP(getH3Event(ctx)); + rateLimitRegistration(clientIP, getH3Event(ctx)); // Schema already validates password match, but double check if (password !== passwordConfirmation) { @@ -771,9 +788,9 @@ export const authRouter = createTRPCRouter({ }); // Create session with client info - const clientIP = getClientIP(ctx.event.nativeEvent); + const clientIP = getClientIP(getH3Event(ctx)); const userAgent = - ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown"; + getUserAgent(getH3Event(ctx)); const sessionId = await createSession( userId, "14d", @@ -783,21 +800,21 @@ export const authRouter = createTRPCRouter({ const token = await createJWT(userId, sessionId); - setCookie(ctx.event.nativeEvent, "userIDToken", token, { + setCookie(getH3Event(ctx), "userIDToken", token, { maxAge: 60 * 60 * 24 * 14, // 14 days path: "/", httpOnly: true, - secure: true, // Always enforce secure cookies + secure: env.NODE_ENV === "production", sameSite: "lax" }); // Set CSRF token for authenticated session - setCSRFToken(ctx.event.nativeEvent); + setCSRFToken(getH3Event(ctx)); // Log successful registration await logAuditEvent({ userId, - eventType: "auth.registration.success", + eventType: "auth.register.success", eventData: { email, method: "email" }, ipAddress: clientIP, userAgent, @@ -807,9 +824,9 @@ export const authRouter = createTRPCRouter({ return { success: true, message: "success" }; } catch (e) { // Log failed registration - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ - eventType: "auth.registration.failed", + eventType: "auth.register.failed", eventData: { email, method: "email", @@ -831,107 +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; - // Apply rate limiting - const clientIP = getClientIP(ctx.event.nativeEvent); - rateLimitLogin(email, clientIP, ctx.event.nativeEvent); + // Apply rate limiting + const clientIP = getClientIP(getH3Event(ctx)); + rateLimitLogin(email, clientIP, getH3Event(ctx)); - const conn = ConnectionFactory(); - const res = await conn.execute({ - sql: "SELECT * FROM User WHERE email = ?", - args: [email] - }); + 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); + // 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) { - // Log failed login attempt (wrap in try-catch to ensure it never blocks auth flow) + // 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 { - const { ipAddress, userAgent } = getAuditContext( - ctx.event.nativeEvent - ); await logAuditEvent({ - eventType: "auth.login.failed", - eventData: { - email, - method: "password", - reason: "invalid_credentials" - }, - ipAddress, + userId: user.id, + eventType: "auth.login.success", + eventData: { method: "password", rememberMe: rememberMe || false }, + ipAddress: clientIP, userAgent, - success: false + 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 }); } - - 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 = - ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown"; - const sessionId = await createSession( - user.id, - expiresIn, - clientIP, - userAgent - ); - - const token = await createJWT(user.id, sessionId, expiresIn); - - const cookieOptions: any = { - path: "/", - httpOnly: true, - secure: true, // Always enforce secure cookies - sameSite: "lax" - }; - - if (rememberMe) { - cookieOptions.maxAge = 60 * 60 * 24 * 14; // 14 days - } - - setCookie(ctx.event.nativeEvent, "userIDToken", token, cookieOptions); - - // Set CSRF token for authenticated session - setCSRFToken(ctx.event.nativeEvent); - - // 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" }; }), requestEmailLinkLogin: publicProcedure @@ -946,7 +987,7 @@ export const authRouter = createTRPCRouter({ try { const requested = getCookie( - ctx.event.nativeEvent, + getH3Event(ctx), "emailLoginLinkRequested" ); if (requested) { @@ -1025,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(), { @@ -1066,12 +1107,12 @@ export const authRouter = createTRPCRouter({ const { email } = input; // Apply rate limiting - const clientIP = getClientIP(ctx.event.nativeEvent); - rateLimitPasswordReset(clientIP, ctx.event.nativeEvent); + const clientIP = getClientIP(getH3Event(ctx)); + rateLimitPasswordReset(clientIP, getH3Event(ctx)); try { const requested = getCookie( - ctx.event.nativeEvent, + getH3Event(ctx), "passwordResetRequested" ); if (requested) { @@ -1145,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(), { @@ -1155,10 +1196,10 @@ export const authRouter = createTRPCRouter({ ); // Log password reset request - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ userId: user.id, - eventType: "auth.password_reset.requested", + eventType: "auth.password.reset.request", eventData: { email }, ipAddress, userAgent, @@ -1172,10 +1213,10 @@ export const authRouter = createTRPCRouter({ !(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS") ) { const { ipAddress, userAgent } = getAuditContext( - ctx.event.nativeEvent + getH3Event(ctx) ); await logAuditEvent({ - eventType: "auth.password_reset.requested", + eventType: "auth.password.reset.request", eventData: { email: input.email, reason: error instanceof TRPCError ? error.message : "unknown" @@ -1266,20 +1307,20 @@ 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(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ userId: payload.id, - eventType: "auth.password_reset.completed", + eventType: "auth.password.reset.complete", eventData: {}, ipAddress, userAgent, @@ -1289,9 +1330,9 @@ export const authRouter = createTRPCRouter({ return { success: true, message: "success" }; } catch (error) { // Log failed password reset - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ - eventType: "auth.password_reset.completed", + eventType: "auth.password.reset.complete", eventData: { reason: error instanceof TRPCError ? error.message : "unknown" }, @@ -1317,12 +1358,12 @@ export const authRouter = createTRPCRouter({ const { email } = input; // Apply rate limiting - const clientIP = getClientIP(ctx.event.nativeEvent); - rateLimitEmailVerification(clientIP, ctx.event.nativeEvent); + const clientIP = getClientIP(getH3Event(ctx)); + rateLimitEmailVerification(clientIP, getH3Event(ctx)); try { const requested = getCookie( - ctx.event.nativeEvent, + getH3Event(ctx), "emailVerificationRequested" ); if (requested) { @@ -1399,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(), { @@ -1409,10 +1450,10 @@ export const authRouter = createTRPCRouter({ ); // Log email verification request - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ userId: user.id, - eventType: "auth.email_verification.requested", + eventType: "auth.email.verify.request", eventData: { email }, ipAddress, userAgent, @@ -1426,10 +1467,10 @@ export const authRouter = createTRPCRouter({ !(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS") ) { const { ipAddress, userAgent } = getAuditContext( - ctx.event.nativeEvent + getH3Event(ctx) ); await logAuditEvent({ - eventType: "auth.email_verification.requested", + eventType: "auth.email.verify.request", eventData: { email: input.email, reason: error instanceof TRPCError ? error.message : "unknown" @@ -1468,7 +1509,7 @@ export const authRouter = createTRPCRouter({ // Try to get user ID for audit log before clearing cookies let userId: string | null = null; try { - const token = getCookie(ctx.event.nativeEvent, "userIDToken"); + const token = getCookie(getH3Event(ctx), "userIDToken"); if (token) { const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); const { payload } = await jwtVerify(token, secret); @@ -1478,17 +1519,17 @@ export const authRouter = createTRPCRouter({ // Ignore token verification errors during signout } - setCookie(ctx.event.nativeEvent, "userIDToken", "", { + 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(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ userId, eventType: "auth.logout", diff --git a/src/server/security.ts b/src/server/security.ts index 778698f..c25f7d6 100644 --- a/src/server/security.ts +++ b/src/server/security.ts @@ -3,6 +3,7 @@ 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) @@ -108,7 +109,7 @@ export function setCSRFToken(event: H3Event): string { maxAge: 60 * 60 * 24 * 14, // 14 days - same as session path: "/", httpOnly: false, // Must be readable by client JS - secure: true, // Always enforce secure + secure: env.NODE_ENV === "production", sameSite: "lax" }); return token;