Files
freno-dev/src/server/api/routers/auth.ts

1654 lines
48 KiB
TypeScript

import { createTRPCRouter, publicProcedure } from "../utils";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { env } from "~/env/server";
import {
ConnectionFactory,
hashPassword,
checkPassword,
checkPasswordSafe
} from "~/server/utils";
import type { User } from "~/db/types";
import {
linkProvider,
findUserByProvider,
findUserByEmail,
updateProviderLastUsed
} from "~/server/provider-helpers";
import {
fetchWithTimeout,
checkResponse,
fetchWithRetry,
NetworkError,
TimeoutError,
APIError
} from "~/server/fetch-utils";
import {
registerUserSchema,
loginUserSchema,
resetPasswordSchema,
requestPasswordResetSchema
} from "../schemas/user";
import {
setCSRFToken,
csrfProtection,
getClientIP,
getUserAgent,
getAuditContext,
rateLimitLogin,
rateLimitPasswordReset,
rateLimitRegistration,
rateLimitEmailVerification,
checkAccountLockout,
recordFailedLogin,
resetFailedAttempts,
resetLoginRateLimits,
createPasswordResetToken,
validatePasswordResetToken,
markPasswordResetTokenUsed
} from "~/server/security";
import { logAuditEvent } from "~/server/audit";
import { getCookie, setCookie } from "vinxi/http";
import type { H3Event } from "vinxi/http";
import type { Context } from "../utils";
import {
AUTH_CONFIG,
NETWORK_CONFIG,
COOLDOWN_TIMERS,
expiryToSeconds,
getAccessTokenExpiry
} from "~/config";
import {
issueAuthToken,
clearAuthToken,
checkAuthStatus,
verifyAuthToken,
getAuthTokenFromEvent
} from "~/server/auth";
import { v4 as uuidV4 } from "uuid";
import { SignJWT, jwtVerify } from "jose";
import {
generateLoginLinkEmail,
generatePasswordResetEmail,
generateEmailVerificationEmail
} from "~/server/email-templates";
/**
* 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;
}
// Zod schemas
async function sendEmail(to: string, subject: string, htmlContent: string) {
const apiKey = env.SENDINBLUE_KEY;
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
const sendinblueData = {
sender: {
name: "freno.me",
email: "no_reply@freno.me"
},
to: [{ email: to }],
htmlContent,
subject
};
return fetchWithRetry(
async () => {
const response = await fetchWithTimeout(apiUrl, {
method: "POST",
headers: {
accept: "application/json",
"api-key": apiKey,
"content-type": "application/json"
},
body: JSON.stringify(sendinblueData),
timeout: NETWORK_CONFIG.EMAIL_API_TIMEOUT_MS
});
await checkResponse(response);
return response;
},
{
maxRetries: NETWORK_CONFIG.MAX_RETRIES,
retryDelay: NETWORK_CONFIG.RETRY_DELAY_MS
}
);
}
export const authRouter = createTRPCRouter({
githubCallback: publicProcedure
.input(z.object({ code: z.string() }))
.mutation(async ({ input, ctx }) => {
const { code } = input;
try {
const tokenResponse = await fetchWithTimeout(
"https://github.com/login/oauth/access_token",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json"
},
body: JSON.stringify({
client_id: env.VITE_GITHUB_CLIENT_ID,
client_secret: env.GITHUB_CLIENT_SECRET,
code
}),
timeout: NETWORK_CONFIG.GITHUB_API_TIMEOUT_MS
}
);
await checkResponse(tokenResponse);
const { access_token } = await tokenResponse.json();
if (!access_token) {
console.error("[GitHub Callback] No access token received");
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Failed to get access token from GitHub"
});
}
const userResponse = await fetchWithTimeout(
"https://api.github.com/user",
{
headers: {
Authorization: `token ${access_token}`
},
timeout: NETWORK_CONFIG.GITHUB_API_TIMEOUT_MS
}
);
await checkResponse(userResponse);
const user = await userResponse.json();
const login = user.login;
const icon = user.avatar_url;
const emailsResponse = await fetchWithTimeout(
"https://api.github.com/user/emails",
{
headers: {
Authorization: `token ${access_token}`
},
timeout: NETWORK_CONFIG.GITHUB_API_TIMEOUT_MS
}
);
await checkResponse(emailsResponse);
const emails = await emailsResponse.json();
const primaryEmail = emails.find(
(e: { primary: boolean; verified: boolean; email: string }) =>
e.primary && e.verified
);
const email = primaryEmail?.email || null;
const emailVerified = primaryEmail?.verified || false;
const conn = ConnectionFactory();
let userId = await findUserByProvider("github", login);
let isNewUser = false;
let isLinkedAccount = false;
if (userId) {
await updateProviderLastUsed(userId, "github");
} else {
// Strategy 2: Check if email matches existing user (account linking)
if (email) {
userId = await findUserByEmail(email);
if (userId) {
try {
await linkProvider(userId, "github", {
providerUserId: login,
email: email,
displayName: login,
image: icon
});
isLinkedAccount = true;
} catch (linkError: any) {
throw new TRPCError({
code: "CONFLICT",
message: linkError.message
});
}
}
}
// Strategy 3: Create new user
if (!userId) {
userId = uuidV4();
const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
const insertParams = [
userId,
email,
emailVerified ? 1 : 0,
login,
"github",
icon
];
try {
await conn.execute({ sql: insertQuery, args: insertParams });
// Also create UserProvider entry for new user
await linkProvider(userId, "github", {
providerUserId: login,
email: email,
displayName: login,
image: icon
});
isNewUser = true;
} catch (insertError: any) {
if (
insertError.code === "SQLITE_CONSTRAINT" &&
insertError.message?.includes("User.email")
) {
throw new TRPCError({
code: "CONFLICT",
message:
"This email is already associated with another account. Please sign in with that account or use a different email address."
});
}
throw insertError;
}
}
}
const event = getH3Event(ctx);
const clientIP = getClientIP(event);
const userAgent = getUserAgent(event);
await issueAuthToken({
event,
userId,
rememberMe: true
});
setCSRFToken(event);
await logAuditEvent({
userId,
eventType: "auth.login.success",
eventData: {
method: "github",
isNewUser,
isLinkedAccount
},
ipAddress: clientIP,
userAgent,
success: true
});
return {
success: true,
redirectTo: "/account"
};
} catch (error) {
console.error("[GitHub Callback] Error during OAuth flow:", 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;
}
if (error instanceof TimeoutError) {
console.error("[GitHub Callback] Timeout:", error.message);
throw new TRPCError({
code: "TIMEOUT",
message: "GitHub authentication timed out. Please try again."
});
} else if (error instanceof NetworkError) {
console.error("[GitHub Callback] Network error:", error.message);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Unable to connect to GitHub. Please try again later."
});
} else if (error instanceof APIError) {
console.error(
"[GitHub Callback] API error:",
error.status,
error.statusText
);
throw new TRPCError({
code: "BAD_REQUEST",
message: "GitHub authentication failed. Please try again."
});
}
console.error("[GitHub Callback] Unknown error:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "GitHub authentication failed"
});
}
}),
googleCallback: publicProcedure
.input(z.object({ code: z.string() }))
.mutation(async ({ input, ctx }) => {
const { code } = input;
try {
const tokenResponse = await fetchWithTimeout(
"https://oauth2.googleapis.com/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: new URLSearchParams({
code: code,
client_id: env.VITE_GOOGLE_CLIENT_ID || "",
client_secret: env.GOOGLE_CLIENT_SECRET,
redirect_uri: `${env.VITE_DOMAIN || "https://freno.me"}/api/auth/callback/google`,
grant_type: "authorization_code"
}),
timeout: NETWORK_CONFIG.GOOGLE_API_TIMEOUT_MS
}
);
await checkResponse(tokenResponse);
const { access_token } = await tokenResponse.json();
if (!access_token) {
console.error("[Google Callback] No access token received");
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Failed to get access token from Google"
});
}
const userResponse = await fetchWithTimeout(
"https://www.googleapis.com/oauth2/v3/userinfo",
{
headers: {
Authorization: `Bearer ${access_token}`
},
timeout: NETWORK_CONFIG.GOOGLE_API_TIMEOUT_MS
}
);
await checkResponse(userResponse);
const userData = await userResponse.json();
const name = userData.name;
const image = userData.picture;
const email = userData.email;
const email_verified = userData.email_verified;
const conn = ConnectionFactory();
let userId = await findUserByProvider("google", email);
let isNewUser = false;
let isLinkedAccount = false;
if (userId) {
await updateProviderLastUsed(userId, "google");
} else {
// Strategy 2: Check if email matches existing user (account linking)
userId = await findUserByEmail(email);
if (userId) {
try {
await linkProvider(userId, "google", {
providerUserId: email,
email: email,
displayName: name,
image: image
});
isLinkedAccount = true;
} catch (linkError: any) {
console.error(
"[Google Callback] Failed to link provider:",
linkError.message
);
throw new TRPCError({
code: "CONFLICT",
message: linkError.message
});
}
}
if (!userId) {
userId = uuidV4();
const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
const insertParams = [
userId,
email,
email_verified ? 1 : 0,
name,
"google",
image
];
try {
await conn.execute({
sql: insertQuery,
args: insertParams
});
// Also create UserProvider entry for new user
await linkProvider(userId, "google", {
providerUserId: email,
email: email,
displayName: name,
image: image
});
isNewUser = true;
} catch (insertError: any) {
if (
insertError.code === "SQLITE_CONSTRAINT" &&
insertError.message?.includes("User.email")
) {
console.error(
"[Google Callback] Email conflict during insert:",
email
);
throw new TRPCError({
code: "CONFLICT",
message:
"This email is already associated with another account. Please sign in with that account instead."
});
}
throw insertError;
}
}
}
// Issue JWT (OAuth defaults to remember me)
const event = getH3Event(ctx);
const clientIP = getClientIP(event);
const userAgent = getUserAgent(event);
await issueAuthToken({
event,
userId,
rememberMe: true
});
setCSRFToken(event);
await logAuditEvent({
userId,
eventType: "auth.login.success",
eventData: {
method: "google",
isNewUser,
isLinkedAccount
},
ipAddress: clientIP,
userAgent,
success: true
});
return {
success: true,
redirectTo: "/account"
};
} catch (error) {
console.error("[Google Callback] Error during OAuth flow:", error);
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;
}
if (error instanceof TimeoutError) {
console.error("[Google Callback] Timeout:", error.message);
throw new TRPCError({
code: "TIMEOUT",
message: "Google authentication timed out. Please try again."
});
} else if (error instanceof NetworkError) {
console.error("[Google Callback] Network error:", error.message);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Unable to connect to Google. Please try again later."
});
} else if (error instanceof APIError) {
console.error(
"[Google Callback] API error:",
error.status,
error.statusText
);
throw new TRPCError({
code: "BAD_REQUEST",
message: "Google authentication failed. Please try again."
});
}
console.error("[Google Callback] Unknown error:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Google authentication failed"
});
}
}),
emailLogin: publicProcedure
.input(
z.object({
email: z.string().email(),
token: z.string(),
rememberMe: z.boolean().optional()
})
)
.mutation(async ({ input, ctx }) => {
const { email, token } = input;
try {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const { payload } = await jwtVerify(token, secret);
if (payload.email !== email) {
console.error("[Email Login] Email mismatch:", {
payloadEmail: payload.email,
inputEmail: email
});
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Email mismatch"
});
}
const rememberMe = (payload.rememberMe as boolean) ?? false;
const conn = ConnectionFactory();
const query = `SELECT * FROM User WHERE email = ?`;
const params = [email];
const res = await conn.execute({ sql: query, args: params });
if (!res.rows[0]) {
console.error("[Email Login] User not found for email:", email);
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found"
});
}
const userId = (res.rows[0] as unknown as User).id;
const event = getH3Event(ctx);
const clientIP = getClientIP(event);
const userAgent = getUserAgent(event);
await issueAuthToken({
event,
userId,
rememberMe
});
setCSRFToken(event);
await logAuditEvent({
userId,
eventType: "auth.login.success",
eventData: { method: "email_link", rememberMe },
ipAddress: clientIP,
userAgent,
success: true
});
return {
success: true,
redirectTo: "/account"
};
} catch (error) {
console.error("[Email Login] Error during login:", 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
: error instanceof Error
? error.message
: "unknown"
},
ipAddress,
userAgent,
success: false
});
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication failed"
});
}
}),
emailCodeLogin: publicProcedure
.input(
z.object({
email: z.string().email(),
code: z.string().length(6),
rememberMe: z.boolean().optional()
})
)
.mutation(async ({ input, ctx }) => {
const { email, code, rememberMe } = input;
try {
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [email]
});
if (!res.rows[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found"
});
}
// Check if there's a valid JWT token with this code
// We need to find the token that was generated for this email
// Since we can't store tokens in DB efficiently, we'll verify against the cookie
// Get the token from cookie (we'll store it when sending email)
const storedToken = getCookie(getH3Event(ctx), "emailLoginToken");
if (!storedToken) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "No login token found. Please request a new code."
});
}
// Verify the JWT and check the code
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
let payload;
try {
const result = await jwtVerify(storedToken, secret);
payload = result.payload;
} catch (jwtError) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Code expired. Please request a new one."
});
}
if (payload.email !== email) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Email mismatch"
});
}
if (payload.code !== code) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid code"
});
}
const userId = (res.rows[0] as unknown as User).id;
// Use rememberMe from JWT if not provided in input, default to false
const shouldRemember =
rememberMe ?? (payload.rememberMe as boolean) ?? false;
const event = getH3Event(ctx);
const clientIP = getClientIP(event);
const userAgent = getUserAgent(event);
await issueAuthToken({
event,
userId,
rememberMe: shouldRemember
});
setCSRFToken(event);
await logAuditEvent({
userId,
eventType: "auth.login.success",
eventData: { method: "email_code", rememberMe: shouldRemember },
ipAddress: clientIP,
userAgent,
success: true
});
return {
success: true,
redirectTo: "/account"
};
} catch (error) {
console.error("[Email Code Login] Error during login:", error);
// Log failed code login
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
eventType: "auth.login.failed",
eventData: {
method: "email_code",
email: input.email,
reason:
error instanceof TRPCError
? error.message
: error instanceof Error
? error.message
: "unknown"
},
ipAddress,
userAgent,
success: false
});
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication failed"
});
}
}),
emailVerification: publicProcedure
.input(
z.object({
email: z.string().email(),
token: z.string()
})
)
.mutation(async ({ input, ctx }) => {
const { email, token } = input;
try {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const { payload } = await jwtVerify(token, secret);
if (payload.email !== email) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Email mismatch"
});
}
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;
}
console.error("Email verification failed:", error);
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid token"
});
}
}),
emailRegistration: publicProcedure
.input(registerUserSchema)
.mutation(async ({ input, ctx }) => {
const { email, password, passwordConfirmation, rememberMe } = input;
// Apply rate limiting
const clientIP = getClientIP(getH3Event(ctx));
await rateLimitRegistration(clientIP, getH3Event(ctx));
// Schema already validates password match, but double check
if (password !== passwordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "passwordMismatch"
});
}
// Check if email already exists (User table or UserProvider table)
const existingUserId = await findUserByEmail(email);
if (existingUserId) {
// User exists - check if they have a password
const conn = ConnectionFactory();
const userCheck = await conn.execute({
sql: "SELECT password_hash, provider FROM User WHERE id = ?",
args: [existingUserId]
});
if (userCheck.rows.length > 0) {
const existingUser = userCheck.rows[0] as any;
// If user has a password, it's a duplicate registration attempt
if (existingUser.password_hash) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "duplicate"
});
}
// If user doesn't have a password (provider-only), redirect to login
throw new TRPCError({
code: "BAD_REQUEST",
message:
"An account with this email already exists. Please sign in and add a password from your account settings."
});
}
}
const passwordHash = await hashPassword(password);
const conn = ConnectionFactory();
const userId = uuidV4();
try {
await conn.execute({
sql: "INSERT INTO User (id, email, password_hash, provider) VALUES (?, ?, ?, ?)",
args: [userId, email, passwordHash, "email"]
});
// Create UserProvider entry for email auth
await linkProvider(userId, "email", {
providerUserId: email,
email: email
});
// Issue auth token with client info
const event = getH3Event(ctx);
const clientIP = getClientIP(event);
const userAgent = getUserAgent(event);
await issueAuthToken({
event,
userId,
rememberMe: rememberMe ?? true
});
// Set CSRF token
setCSRFToken(event);
// 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",
message: "duplicate"
});
}
}),
emailPasswordLogin: publicProcedure
.input(loginUserSchema)
.mutation(async ({ input, ctx }) => {
try {
const { email, password, rememberMe } = input;
// Apply rate limiting
const clientIP = getClientIP(getH3Event(ctx));
await rateLimitLogin(email, clientIP, getH3Event(ctx));
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) {
// Record failed login attempt if user exists
if (user?.id) {
const lockoutStatus = await recordFailedLogin(user.id);
if (lockoutStatus.isLocked) {
const remainingSec = Math.ceil(
(lockoutStatus.remainingMs || 0) / 1000
);
// Log account lockout
try {
const { ipAddress, userAgent } = getAuditContext(
getH3Event(ctx)
);
await logAuditEvent({
userId: user.id,
eventType: "auth.login.failed",
eventData: {
email,
method: "password",
reason: "account_locked",
failedAttempts: lockoutStatus.failedAttempts
},
ipAddress,
userAgent,
success: false
});
} catch (auditError) {
console.error("Audit logging failed:", auditError);
}
throw new TRPCError({
code: "FORBIDDEN",
message: `Account locked due to too many failed login attempts. Try again in ${Math.ceil(remainingSec / 60)} minutes.`
});
}
}
// Log failed login attempt
try {
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId: user?.id,
eventType: "auth.login.failed",
eventData: {
email,
method: "password",
reason: "invalid_credentials",
failedAttempts: user?.id
? (res.rows[0]?.failed_attempts as number)
: undefined
},
ipAddress,
userAgent,
success: false
});
} catch (auditError) {
console.error("Audit logging failed:", auditError);
}
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no-match"
});
}
// Check if account is locked before allowing login
const lockoutCheck = await checkAccountLockout(user.id);
if (lockoutCheck.isLocked) {
const remainingSec = Math.ceil(
(lockoutCheck.remainingMs || 0) / 1000
);
throw new TRPCError({
code: "FORBIDDEN",
message: `Account is locked due to too many failed login attempts. Try again in ${Math.ceil(remainingSec / 60)} minutes.`
});
}
if (
!user.provider ||
!["email", "google", "github", "apple"].includes(user.provider)
) {
await conn.execute({
sql: "UPDATE User SET provider = ? WHERE id = ?",
args: ["email", user.id]
});
}
// Reset failed attempts on successful login
await resetFailedAttempts(user.id);
// Reset rate limits on successful login
await resetLoginRateLimits(email, clientIP);
// Issue JWT for authenticated user
const event = getH3Event(ctx);
const userAgent = getUserAgent(event);
await issueAuthToken({
event,
userId: user.id,
rememberMe: rememberMe ?? false
});
// Set CSRF token for authenticated user
setCSRFToken(event);
// 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: "INTERNAL_SERVER_ERROR",
message: "An error occurred during login",
cause: error
});
}
}),
requestEmailLinkLogin: publicProcedure
.input(
z.object({
email: z.string().email(),
rememberMe: z.boolean().optional()
})
)
.mutation(async ({ input, ctx }) => {
const { email, rememberMe } = input;
try {
const requested = getCookie(getH3Event(ctx), "emailLoginLinkRequested");
if (requested) {
const expires = new Date(requested);
const remaining = expires.getTime() - Date.now();
if (remaining > 0) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "countdown not expired"
});
}
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [email]
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found"
});
}
// Generate cryptographically secure 6-digit code (p8-010)
const randomBytes = new Uint32Array(1);
crypto.getRandomValues(randomBytes);
const loginCode = (100000 + (randomBytes[0] % 900000)).toString();
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({
email,
rememberMe: rememberMe ?? false, // Default to browser cookie
code: loginCode
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime(AUTH_CONFIG.EMAIL_LOGIN_LINK_EXPIRY)
.sign(secret);
const domain = env.VITE_DOMAIN || "https://freno.me";
const loginUrl = `${domain}/api/auth/email-login-callback?email=${email}&token=${token}`;
const htmlContent = generateLoginLinkEmail({
email,
loginUrl,
loginCode
});
await sendEmail(email, "freno.me login link", htmlContent);
const exp = new Date(Date.now() + COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_MS);
setCookie(
getH3Event(ctx),
"emailLoginLinkRequested",
exp.toUTCString(),
{
maxAge: COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_COOKIE_MAX_AGE,
path: "/"
}
);
// Store the token in a cookie so it can be verified with the code later
setCookie(getH3Event(ctx), "emailLoginToken", token, {
maxAge: expiryToSeconds(AUTH_CONFIG.EMAIL_LOGIN_LINK_EXPIRY),
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "strict",
path: "/"
});
return { success: true, message: "email sent" };
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
if (
error instanceof TimeoutError ||
error instanceof NetworkError ||
error instanceof APIError
) {
console.error("Failed to send login email:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to send email. Please try again later."
});
}
console.error("Email login link request failed:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An error occurred. Please try again."
});
}
}),
requestPasswordReset: publicProcedure
.input(requestPasswordResetSchema)
.mutation(async ({ input, ctx }) => {
const { email } = input;
// Apply rate limiting
const clientIP = getClientIP(getH3Event(ctx));
await rateLimitPasswordReset(clientIP, getH3Event(ctx));
try {
const requested = getCookie(getH3Event(ctx), "passwordResetRequested");
if (requested) {
const expires = new Date(requested);
const remaining = expires.getTime() - Date.now();
if (remaining > 0) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "countdown not expired"
});
}
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [email]
});
if (res.rows.length === 0) {
return { success: true, message: "email sent" };
}
const user = res.rows[0] as unknown as User;
// Create password reset token (1 hour expiry, single-use)
const { token } = await createPasswordResetToken(user.id);
const domain = env.VITE_DOMAIN || "https://freno.me";
const resetUrl = `${domain}/login/password-reset?token=${token}`;
const htmlContent = generatePasswordResetEmail({ resetUrl });
await sendEmail(email, "password reset", htmlContent);
const exp = new Date(
Date.now() + COOLDOWN_TIMERS.PASSWORD_RESET_REQUEST_MS
);
setCookie(
getH3Event(ctx),
"passwordResetRequested",
exp.toUTCString(),
{
maxAge: COOLDOWN_TIMERS.PASSWORD_RESET_REQUEST_COOKIE_MAX_AGE,
path: "/"
}
);
// 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;
}
if (
error instanceof TimeoutError ||
error instanceof NetworkError ||
error instanceof APIError
) {
console.error("Failed to send password reset email:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to send email. Please try again later."
});
}
console.error("Password reset request failed:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An error occurred. Please try again."
});
}
}),
resetPassword: publicProcedure
.input(resetPasswordSchema)
.mutation(async ({ input, ctx }) => {
const { token, newPassword, newPasswordConfirmation } = input;
// Schema already validates password match, but double check
if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password Mismatch"
});
}
try {
// Validate and consume the password reset token
const tokenValidation = await validatePasswordResetToken(token);
if (!tokenValidation) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid or expired reset token"
});
}
const { userId, tokenId } = tokenValidation;
const conn = ConnectionFactory();
const passwordHash = await hashPassword(newPassword);
const userRes = await conn.execute({
sql: "SELECT provider FROM User WHERE id = ?",
args: [userId]
});
if (userRes.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found"
});
}
const currentProvider = (userRes.rows[0] as any).provider;
if (
!currentProvider ||
!["google", "github", "apple"].includes(currentProvider)
) {
await conn.execute({
sql: "UPDATE User SET password_hash = ?, provider = ?, failed_attempts = 0, locked_until = NULL WHERE id = ?",
args: [passwordHash, "email", userId]
});
} else {
await conn.execute({
sql: "UPDATE User SET password_hash = ?, failed_attempts = 0, locked_until = NULL WHERE id = ?",
args: [passwordHash, userId]
});
}
// Mark token as used
await markPasswordResetTokenUsed(tokenId);
// Log successful password reset
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId: userId,
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;
}
console.error("Password reset error:", error);
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid or expired reset token"
});
}
}),
resendEmailVerification: publicProcedure
.input(requestPasswordResetSchema)
.mutation(async ({ input, ctx }) => {
const { email } = input;
// Apply rate limiting
const clientIP = getClientIP(getH3Event(ctx));
await rateLimitEmailVerification(clientIP, getH3Event(ctx));
try {
const requested = getCookie(
getH3Event(ctx),
"emailVerificationRequested"
);
if (requested) {
const time = parseInt(requested);
const currentTime = Date.now();
const difference = (currentTime - time) / 1000;
if (difference * 1000 < COOLDOWN_TIMERS.EMAIL_VERIFICATION_MS) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message:
"Please wait before requesting another verification email"
});
}
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [email]
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found"
});
}
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" })
.setExpirationTime(AUTH_CONFIG.EMAIL_VERIFICATION_LINK_EXPIRY)
.sign(secret);
const domain = env.VITE_DOMAIN || "https://freno.me";
const verificationUrl = `${domain}/api/auth/email-verification-callback?email=${email}&token=${token}`;
const htmlContent = generateEmailVerificationEmail({ verificationUrl });
await sendEmail(email, "freno.me email verification", htmlContent);
setCookie(
getH3Event(ctx),
"emailVerificationRequested",
Date.now().toString(),
{
maxAge: COOLDOWN_TIMERS.EMAIL_VERIFICATION_COOKIE_MAX_AGE,
path: "/"
}
);
// 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;
}
if (
error instanceof TimeoutError ||
error instanceof NetworkError ||
error instanceof APIError
) {
console.error("Failed to send verification email:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to send email. Please try again later."
});
}
console.error("Email verification request failed:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An error occurred. Please try again."
});
}
}),
refreshToken: publicProcedure.mutation(async ({ ctx }) => {
try {
const event = getH3Event(ctx);
const authToken = getAuthTokenFromEvent(event);
if (!authToken) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "No valid token found"
});
}
const payload = await verifyAuthToken(authToken);
if (!payload) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid token"
});
}
const expiresIn = payload.exp
? payload.exp - Math.floor(Date.now() / 1000)
: 0;
const shortExpiry = expiryToSeconds(getAccessTokenExpiry());
await issueAuthToken({
event,
userId: payload.sub,
rememberMe: expiresIn > shortExpiry
});
setCSRFToken(event);
return {
success: true,
message: "Token refreshed successfully"
};
} catch (error) {
console.error("Token refresh error:", error);
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Token refresh failed"
});
}
}),
signOut: publicProcedure.mutation(async ({ ctx }) => {
try {
const event = getH3Event(ctx);
const auth = await checkAuthStatus(event);
if (auth.userId) {
const { ipAddress, userAgent } = getAuditContext(event);
await logAuditEvent({
userId: auth.userId,
eventType: "auth.logout",
eventData: {},
ipAddress,
userAgent,
success: true
});
}
clearAuthToken(event);
} catch (e) {
console.error("Error during signout:", e);
}
return { success: true };
})
});