// src/routes/auth.routes.ts import { Router, Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import rateLimit from 'express-rate-limit'; import passport from './passport.routes'; import { UniqueConstraintError } from '../services/db/errors.db'; // Import actual class for instanceof checks import { logger } from '../services/logger.server'; import { validateRequest } from '../middleware/validation.middleware'; import type { UserProfile } from '../types'; import { validatePasswordStrength } from '../utils/authUtils'; import { requiredString } from '../utils/zodUtils'; import { authService } from '../services/authService'; const router = Router(); // Conditionally disable rate limiting for the test environment const isTestEnv = process.env.NODE_ENV === 'test'; // --- Rate Limiting Configuration --- const forgotPasswordLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, message: 'Too many password reset requests from this IP, please try again after 15 minutes.', standardHeaders: true, legacyHeaders: false, skip: () => isTestEnv, // Skip this middleware if in test environment }); const resetPasswordLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, message: 'Too many password reset attempts from this IP, please try again after 15 minutes.', standardHeaders: true, legacyHeaders: false, skip: () => isTestEnv, // Skip this middleware if in test environment }); const registerSchema = z.object({ body: z.object({ // Sanitize email by trimming and converting to lowercase. email: z.string().trim().toLowerCase().email('A valid email is required.'), password: z .string() .trim() // Prevent leading/trailing whitespace in passwords. .min(8, 'Password must be at least 8 characters long.') .superRefine((password, ctx) => { const strength = validatePasswordStrength(password); if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback }); }), // Sanitize optional string inputs. full_name: z.string().trim().optional(), avatar_url: z.string().trim().url().optional(), }), }); const forgotPasswordSchema = z.object({ body: z.object({ // Sanitize email by trimming and converting to lowercase. email: z.string().trim().toLowerCase().email('A valid email is required.'), }), }); const resetPasswordSchema = z.object({ body: z.object({ token: requiredString('Token is required.'), newPassword: z .string() .trim() // Prevent leading/trailing whitespace in passwords. .min(8, 'Password must be at least 8 characters long.') .superRefine((password, ctx) => { const strength = validatePasswordStrength(password); if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback }); }), }), }); // --- Authentication Routes --- // Registration Route router.post( '/register', validateRequest(registerSchema), async (req: Request, res: Response, next: NextFunction) => { type RegisterRequest = z.infer; const { body: { email, password, full_name, avatar_url }, } = req as unknown as RegisterRequest; try { const { newUserProfile, accessToken, refreshToken } = await authService.registerAndLoginUser( email, password, full_name, avatar_url, req.log, ); res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days }); return res .status(201) .json({ message: 'User registered successfully!', userprofile: newUserProfile, token: accessToken }); } catch (error: unknown) { if (error instanceof UniqueConstraintError) { // If the email is a duplicate, return a 409 Conflict status. return res.status(409).json({ message: error.message }); } logger.error({ error }, `User registration route failed for email: ${email}.`); // Pass the error to the centralized handler return next(error); } }, ); // Login Route router.post('/login', (req: Request, res: Response, next: NextFunction) => { passport.authenticate( 'local', { session: false }, async (err: Error, user: Express.User | false, info: { message: string }) => { // --- LOGIN ROUTE DEBUG LOGGING --- req.log.debug(`[API /login] Received login request for email: ${req.body.email}`); if (err) req.log.error({ err }, '[API /login] Passport reported an error.'); if (!user) req.log.warn({ info }, '[API /login] Passport reported NO USER found.'); if (user) req.log.debug({ user }, '[API /login] Passport user object:'); // Log the user object passport returns if (user) req.log.info({ user }, '[API /login] Passport reported USER FOUND.'); if (err) { req.log.error( { error: err }, `Login authentication error in /login route for email: ${req.body.email}`, ); return next(err); } if (!user) { return res.status(401).json({ message: info.message || 'Login failed' }); } try { const { rememberMe } = req.body; const userProfile = user as UserProfile; const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(userProfile, req.log); req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`); const cookieOptions = { httpOnly: true, secure: process.env.NODE_ENV === 'production', maxAge: rememberMe ? 30 * 24 * 60 * 60 * 1000 : undefined, // 30 days }; res.cookie('refreshToken', refreshToken, cookieOptions); // Return the full user profile object on login to avoid a second fetch on the client. return res.json({ userprofile: userProfile, token: accessToken }); } catch (tokenErr) { const email = (user as UserProfile)?.user?.email || req.body.email; req.log.error({ error: tokenErr }, `Failed to process login for user: ${email}`); return next(tokenErr); } }, )(req, res, next); }); // Route to request a password reset router.post( '/forgot-password', forgotPasswordLimiter, validateRequest(forgotPasswordSchema), async (req: Request, res: Response, next: NextFunction) => { type ForgotPasswordRequest = z.infer; const { body: { email }, } = req as unknown as ForgotPasswordRequest; try { // The service handles finding the user, creating the token, and sending the email. const token = await authService.resetPassword(email, req.log); // For testability, return the token in the response only in the test environment. const responsePayload: { message: string; token?: string } = { message: 'If an account with that email exists, a password reset link has been sent.', }; if (process.env.NODE_ENV === 'test' && token) responsePayload.token = token; res.status(200).json(responsePayload); } catch (error) { req.log.error({ error }, `An error occurred during /forgot-password for email: ${email}`); next(error); } }, ); // Route to reset the password using a token router.post( '/reset-password', resetPasswordLimiter, validateRequest(resetPasswordSchema), async (req: Request, res: Response, next: NextFunction) => { type ResetPasswordRequest = z.infer; const { body: { token, newPassword }, } = req as unknown as ResetPasswordRequest; try { const resetSuccessful = await authService.updatePassword(token, newPassword, req.log); if (!resetSuccessful) { return res.status(400).json({ message: 'Invalid or expired password reset token.' }); } res.status(200).json({ message: 'Password has been reset successfully.' }); } catch (error) { req.log.error({ error }, `An error occurred during password reset.`); next(error); } }, ); // New Route to refresh the access token router.post('/refresh-token', async (req: Request, res: Response, next: NextFunction) => { const { refreshToken } = req.cookies; if (!refreshToken) { return res.status(401).json({ message: 'Refresh token not found.' }); } try { const result = await authService.refreshAccessToken(refreshToken, req.log); if (!result) { return res.status(403).json({ message: 'Invalid or expired refresh token.' }); } res.json({ token: result.accessToken }); } catch (error) { req.log.error({ error }, 'An error occurred during /refresh-token.'); next(error); } }); /** * POST /api/auth/logout - Logs the user out by invalidating their refresh token. * It clears the refresh token from the database and instructs the client to * expire the `refreshToken` cookie. */ router.post('/logout', async (req: Request, res: Response) => { const { refreshToken } = req.cookies; if (refreshToken) { // Invalidate the token in the database so it cannot be used again. // We don't need to wait for this to finish to respond to the user. authService.logout(refreshToken, req.log).catch((err: Error) => { req.log.error({ error: err }, 'Logout token invalidation failed in background.'); }); } // Instruct the browser to clear the cookie by setting its expiration to the past. res.cookie('refreshToken', '', { httpOnly: true, maxAge: 0, // Use maxAge for modern compatibility; Express sets 'Expires' as a fallback. secure: process.env.NODE_ENV === 'production', }); res.status(200).json({ message: 'Logged out successfully.' }); }); // --- OAuth Routes --- // const handleOAuthCallback = (req: Request, res: Response) => { // const user = req.user as { user_id: string; email: string }; // const payload = { user_id: user.user_id, email: user.email }; // const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' }); // const refreshToken = crypto.randomBytes(64).toString('hex'); // db.saveRefreshToken(user.user_id, refreshToken).then(() => { // res.cookie('refreshToken', refreshToken, { // httpOnly: true, // secure: process.env.NODE_ENV === 'production', // maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days // }); // // Redirect to a frontend page that can handle the token // res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${accessToken}`); // }).catch(err => { // logger.error('Failed to save refresh token during OAuth callback:', { error: err }); // res.redirect(`${process.env.FRONTEND_URL}/login?error=auth_failed`); // }); // }; // router.get('/google', passport.authenticate('google', { session: false })); // router.get('/google/callback', passport.authenticate('google', { session: false, failureRedirect: '/login' }), handleOAuthCallback); // router.get('/github', passport.authenticate('github', { session: false })); // router.get('/github/callback', passport.authenticate('github', { session: false, failureRedirect: '/login' }), handleOAuthCallback); export default router;