// src/routes/auth.routes.ts // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { Router, Request, Response, NextFunction } from 'express'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { z } from 'zod'; import passport from '../config/passport'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { UniqueConstraintError } from '../services/db/errors.db'; // Import actual class for instanceof checks // Removed: import { logger } from '../services/logger.server'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { validateRequest } from '../middleware/validation.middleware'; import type { UserProfile } from '../types'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { validatePasswordStrength } from '../utils/authUtils'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { requiredString } from '../utils/zodUtils'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { loginLimiter, registerLimiter, forgotPasswordLimiter, resetPasswordLimiter, refreshTokenLimiter, logoutLimiter, } from '../config/rateLimiters'; import { sendSuccess, sendError, ErrorCode } from '../utils/apiResponse'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { authService } from '../services/authService'; const router = Router(); // --- Reusable Schemas --- const passwordSchema = 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 }); }); 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: passwordSchema, // Sanitize optional string inputs. full_name: z.preprocess((val) => (val === '' ? undefined : val), z.string().trim().optional()), // Allow empty string or valid URL. If empty string is received, convert to undefined. avatar_url: z.preprocess( (val) => (val === '' ? undefined : val), z.string().trim().url().optional(), ), }), }); const loginSchema = z.object({ body: z.object({ email: z.string().trim().toLowerCase().email('A valid email is required.'), password: requiredString('Password is required.'), rememberMe: z.boolean().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: passwordSchema, }), }); // --- Authentication Routes --- /** * @openapi * /auth/register: * post: * summary: Register a new user * description: Creates a new user account and returns authentication tokens. * tags: * - Auth * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - email * - password * properties: * email: * type: string * format: email * example: user@example.com * password: * type: string * format: password * minLength: 8 * description: Must be at least 8 characters with good entropy * full_name: * type: string * example: John Doe * avatar_url: * type: string * format: uri * responses: * 201: * description: User registered successfully * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * data: * type: object * properties: * message: * type: string * example: User registered successfully! * userprofile: * type: object * token: * type: string * description: JWT access token * 409: * description: Email already registered * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' */ router.post( '/register', registerLimiter, 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 sendSuccess( res, { message: 'User registered successfully!', userprofile: newUserProfile, token: accessToken, }, 201, ); } catch (error: unknown) { if (error instanceof UniqueConstraintError) { // If the email is a duplicate, return a 409 Conflict status. return sendError(res, ErrorCode.CONFLICT, error.message, 409); } req.log.error({ error }, `User registration route failed for email: ${email}.`); // Pass the error to the centralized handler return next(error); } }, ); /** * @openapi * /auth/login: * post: * summary: Login with email and password * description: Authenticates user credentials and returns JWT tokens. * tags: * - Auth * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - email * - password * properties: * email: * type: string * format: email * example: user@example.com * password: * type: string * format: password * rememberMe: * type: boolean * description: If true, refresh token lasts 30 days * responses: * 200: * description: Login successful * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * data: * type: object * properties: * userprofile: * type: object * token: * type: string * description: JWT access token * 401: * description: Invalid credentials * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' */ router.post( '/login', loginLimiter, validateRequest(loginSchema), (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 sendError(res, ErrorCode.UNAUTHORIZED, info.message || 'Login failed', 401); } 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 sendSuccess(res, { 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); }, ); /** * @openapi * /auth/forgot-password: * post: * summary: Request password reset * description: Sends a password reset email if the account exists. Always returns success to prevent email enumeration. * tags: * - Auth * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - email * properties: * email: * type: string * format: email * example: user@example.com * responses: * 200: * description: Request processed (email sent if account exists) * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * data: * type: object * properties: * message: * type: string * example: If an account with that email exists, a password reset link has been sent. */ 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; sendSuccess(res, responsePayload); } catch (error) { req.log.error({ error }, `An error occurred during /forgot-password for email: ${email}`); next(error); } }, ); /** * @openapi * /auth/reset-password: * post: * summary: Reset password with token * description: Resets the user's password using a valid reset token from the forgot-password email. * tags: * - Auth * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - token * - newPassword * properties: * token: * type: string * description: Password reset token from email * newPassword: * type: string * format: password * minLength: 8 * responses: * 200: * description: Password reset successful * 400: * description: Invalid or expired token * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' */ 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 sendError( res, ErrorCode.BAD_REQUEST, 'Invalid or expired password reset token.', 400, ); } sendSuccess(res, { message: 'Password has been reset successfully.' }); } catch (error) { req.log.error({ error }, `An error occurred during password reset.`); next(error); } }, ); /** * @openapi * /auth/refresh-token: * post: * summary: Refresh access token * description: Uses the refresh token cookie to issue a new access token. * tags: * - Auth * responses: * 200: * description: New access token issued * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * data: * type: object * properties: * token: * type: string * description: New JWT access token * 401: * description: Refresh token not found * 403: * description: Invalid or expired refresh token */ router.post( '/refresh-token', refreshTokenLimiter, async (req: Request, res: Response, next: NextFunction) => { const { refreshToken } = req.cookies; if (!refreshToken) { return sendError(res, ErrorCode.UNAUTHORIZED, 'Refresh token not found.', 401); } try { const result = await authService.refreshAccessToken(refreshToken, req.log); if (!result) { return sendError(res, ErrorCode.FORBIDDEN, 'Invalid or expired refresh token.', 403); } sendSuccess(res, { token: result.accessToken }); } catch (error) { req.log.error({ error }, 'An error occurred during /refresh-token.'); next(error); } }, ); /** * @openapi * /auth/logout: * post: * summary: Logout user * description: Invalidates the refresh token and clears the cookie. * tags: * - Auth * responses: * 200: * description: Logged out successfully * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * data: * type: object * properties: * message: * type: string * example: Logged out successfully. */ router.post('/logout', logoutLimiter, 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', }); sendSuccess(res, { message: 'Logged out successfully.' }); }); // --- OAuth Routes --- /** * @openapi * /auth/google: * get: * summary: Initiate Google OAuth * description: Redirects to Google for authentication. After success, redirects back to the app with a token. * tags: * - Auth * responses: * 302: * description: Redirects to Google OAuth consent screen * * /auth/github: * get: * summary: Initiate GitHub OAuth * description: Redirects to GitHub for authentication. After success, redirects back to the app with a token. * tags: * - Auth * responses: * 302: * description: Redirects to GitHub OAuth consent screen */ /** * Handles the OAuth callback after successful authentication. * Generates tokens and redirects to the frontend with the access token. * @param provider The OAuth provider name ('google' or 'github') for the query param. */ const createOAuthCallbackHandler = (provider: 'google' | 'github') => { return async (req: Request, res: Response) => { const userProfile = req.user as UserProfile; if (!userProfile || !userProfile.user) { req.log.error('OAuth callback received but no user profile found'); return res.redirect(`${process.env.FRONTEND_URL}/?error=auth_failed`); } try { const { accessToken, refreshToken } = await authService.handleSuccessfulLogin( userProfile, req.log, ); res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days }); // Redirect to frontend with the token in a provider-specific query param // The frontend useAppInitialization hook looks for googleAuthToken or githubAuthToken const tokenParam = provider === 'google' ? 'googleAuthToken' : 'githubAuthToken'; res.redirect(`${process.env.FRONTEND_URL}/?${tokenParam}=${accessToken}`); } catch (err) { req.log.error({ error: err }, `Failed to complete ${provider} OAuth login`); res.redirect(`${process.env.FRONTEND_URL}/?error=auth_failed`); } }; }; /* istanbul ignore next -- @preserve: OAuth routes require external provider interaction, not suitable for automated testing */ // Google OAuth routes router.get('/google', passport.authenticate('google', { session: false })); router.get( '/google/callback', passport.authenticate('google', { session: false, failureRedirect: '/?error=google_auth_failed', }), createOAuthCallbackHandler('google'), ); /* istanbul ignore next -- @preserve: OAuth routes require external provider interaction, not suitable for automated testing */ // GitHub OAuth routes router.get('/github', passport.authenticate('github', { session: false })); router.get( '/github/callback', passport.authenticate('github', { session: false, failureRedirect: '/?error=github_auth_failed', }), createOAuthCallbackHandler('github'), ); export default router;