Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m10s
612 lines
20 KiB
TypeScript
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;
|