Files
flyer-crawler.projectium.com/src/routes/auth.routes.ts
Torben Sorensen 11aeac5edd
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m10s
whoa - so much - new features (UPC,etc) - Sentry for app logging! so much more !
2026-01-11 19:07:02 -08:00

612 lines
20 KiB
TypeScript

// 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<typeof registerSchema>;
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<typeof forgotPasswordSchema>;
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<typeof resetPasswordSchema>;
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;