Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions src/controllers/login.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@ import { RequestHandler } from 'express';
import bcrypt from 'bcrypt';
import User from '../models/user';
import { generateAccessToken } from '../utils/token';
import { LoginSchema } from '../validators/auth.validator';
import z from 'zod';

export const loginController: RequestHandler = async (req, res, next) => {
try {
const { email, password } = req.body;
const parsed = LoginSchema.safeParse(req.body);

if (!email || !password) {
res.status(400).json({ message: 'Email and password are required' });
return;
if (!parsed.success) {
return res.status(400).json({
error: 'Validation failed',
messages: z.treeifyError(parsed.error),
});
}

const { email, password } = parsed.data;

const user = await User.findOne({ email });
if (!user) {
res.status(404).json({ message: 'User not found' });
Expand Down Expand Up @@ -45,4 +51,4 @@ export const loginController: RequestHandler = async (req, res, next) => {
} catch (error: any) {
res.status(500).json({ message: error.message });
}
};
};
16 changes: 11 additions & 5 deletions src/controllers/signup.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import bcrypt from 'bcrypt';
import User from '../models/user';
import { generateOTP } from '../utils/otp';
import emailService from '../services/email.service';
import { SignupSchema } from '../validators/auth.validator';
import z from 'zod';

const OTP_EXPIRY_MINUTES = 10;

Expand All @@ -11,13 +13,17 @@ export const signupController: RequestHandler = async (
res: Response,
) => {
try {
const { name, email, password } = req.body;
const parsed = SignupSchema.safeParse(req.body);

if (!name || !email || !password) {
res.status(400).json({ message: 'All fields are required' });
return;
if (!parsed.success) {
return res.status(400).json({
error: 'Validation failed',
messages: z.treeifyError(parsed.error),
});
}

const { name, email, password } = parsed.data;

const existingUser = await User.findOne({ email });
if (existingUser) {
res.status(400).json({ message: 'Email is already in use' });
Expand Down Expand Up @@ -55,4 +61,4 @@ export const signupController: RequestHandler = async (
} catch (error: any) {
res.status(500).json({ message: error.message });
}
};
};
6 changes: 1 addition & 5 deletions src/middlewares/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,5 @@ export interface JwtPayload {
}

export const JwtVerify = (token: string): JwtPayload => {
try {
return jwt.verify(token, process.env.JWT_SECRET as string) as JwtPayload;
} catch (error) {
throw new Error('Invalid or expired token!');
}
return jwt.verify(token, process.env.JWT_SECRET as string) as JwtPayload;
};
13 changes: 9 additions & 4 deletions src/routes/auth.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,16 @@ authRoute.get(

const token = generateToken(req.user as any);

// Redirect to frontend with token
res.redirect(`${process.env.FRONTEND_URL}/oauth?token=${token}`);
const isProduction = process.env.NODE_ENV === 'production';

// Send token in response body
// res.status(200).json({ token });
res.cookie('token', token, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 1000,
});

res.redirect(process.env.FRONTEND_URL!);
},
);

Expand Down
9 changes: 8 additions & 1 deletion src/utils/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@ import User from '../models/user';
var jwt = require('jsonwebtoken');
import { JwtVerify } from '../middlewares/jwt';

const parseCookie = (cookieHeader: string | undefined, name: string): string | undefined => {
if (!cookieHeader) return undefined;
const cookies = cookieHeader.split(';').map((c) => c.trim().split('='));
const cookie = cookies.find(([key]) => key === name);
return cookie?.[1];
};

const extractToken = (req: any): string | null => {
return req.headers.authorization?.split(' ')[1] || null;
return req.headers.authorization?.split(' ')[1] || parseCookie(req.headers.cookie, 'token') || null;
Comment thread
dubemoyibe-star marked this conversation as resolved.
};

const validateAndGetUser = async (token: string) => {
Expand Down
16 changes: 16 additions & 0 deletions src/validators/auth.validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { z } from 'zod';

export const LoginSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(1, 'Password is required'),
});

export type LoginInput = z.infer<typeof LoginSchema>;

export const SignupSchema = z.object({
name: z.string().trim().min(1, 'Name is required'),
email: z.string().email('Invalid email format'),
password: z.string().min(1, 'Password is required'),
Comment thread
dubemoyibe-star marked this conversation as resolved.
});

export type SignupInput = z.infer<typeof SignupSchema>;
88 changes: 88 additions & 0 deletions tests/auth.middleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,98 @@
import { authGuard } from '../src/middlewares/auth';
import User from '../src/models/user';
import { JwtVerify } from '../src/middlewares/jwt';
import jwt from 'jsonwebtoken';

jest.mock('../src/models/user');
jest.mock('../src/middlewares/jwt');

const parseCookie = (header: string | undefined, name: string): string | undefined => {
if (!header) return undefined;
const cookies = header.split(';').map((c) => c.trim().split('='));
const cookie = cookies.find(([key]) => key === name);
return cookie?.[1];
};

describe('authGuard middleware — cookie-based auth (Google OAuth)', () => {
const createResponse = () => ({
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
});

const createRequest = () => ({
headers: {},
});

beforeEach(() => {
jest.clearAllMocks();
(JwtVerify as jest.Mock).mockReturnValue({
id: 'user-id-1',
email: 'user@example.com',
});
});

it('accepts a valid token from the cookie header', async () => {
const verifiedUser = {
_id: 'user-id-1',
email: 'user@example.com',
provider: 'google',
emailVerifiedAt: undefined,
};
(User.findById as jest.Mock).mockResolvedValue(verifiedUser);

const token = 'valid.jwt.token';
const req = {
headers: {
cookie: `token=${token}; other=value`,
},
};
const res = createResponse();
const next = jest.fn();

await authGuard(req as any, res as any, next);

expect(next).toHaveBeenCalledTimes(1);
expect(res.status).not.toHaveBeenCalled();
expect((req as any).user).toBe(verifiedUser);
});

it('rejects a request with an invalid cookie token', async () => {
(JwtVerify as jest.Mock).mockImplementation(() => {
throw new jwt.JsonWebTokenError('Invalid token');
});

const req = {
headers: {
cookie: 'token=invalid.jwt.token',
},
};
const res = createResponse();
const next = jest.fn();

await authGuard(req as any, res as any, next);

expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Unauthorized: Invalid token',
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(next).not.toHaveBeenCalled();
});

it('rejects a request with an empty cookie header', async () => {
const req = createRequest();
const res = createResponse();
const next = jest.fn();

await authGuard(req as any, res as any, next);

expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Unauthorized: No token provided',
});
expect(next).not.toHaveBeenCalled();
});
});

// Regression coverage for issue #122: a valid JWT alone must not grant access to
// protected routes. Unverified local accounts have to be blocked at the guard,
// not only at the login controller.
Expand Down
98 changes: 98 additions & 0 deletions tests/login.controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { loginController } from '../src/controllers/login.controller';
import User from '../src/models/user';

jest.mock('bcrypt', () => ({
compare: jest.fn(),
}));

jest.mock('../src/models/user', () => {
const MockUser = jest.fn();
(MockUser as any).findOne = jest.fn();
return { __esModule: true, default: MockUser };
});

jest.mock('../src/utils/token', () => ({
generateAccessToken: jest.fn().mockReturnValue('mock-token'),
}));

describe('loginController', () => {
const createResponse = () => ({
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
});

beforeEach(() => {
jest.clearAllMocks();
});

it('returns 400 when email is missing', async () => {
const req = { body: { password: 'secret123' } };
const res = createResponse();
await loginController(req as any, res as any, jest.fn());
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'Validation failed' }),
);
});

it('returns 400 when password is missing', async () => {
const req = { body: { email: 'test@example.com' } };
const res = createResponse();
await loginController(req as any, res as any, jest.fn());
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'Validation failed' }),
);
});

it('returns 400 when email is not a valid email format', async () => {
const req = { body: { email: 'not-an-email', password: 'secret123' } };
const res = createResponse();
await loginController(req as any, res as any, jest.fn());
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'Validation failed' }),
);
});

it('returns 400 when email is a NoSQL injection payload ($ne)', async () => {
const req = { body: { email: { $ne: null }, password: 'secret123' } };
const res = createResponse();
await loginController(req as any, res as any, jest.fn());
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'Validation failed' }),
);
expect(User.findOne).not.toHaveBeenCalled();
});

it('returns 400 when email is a NoSQL injection payload ($gt)', async () => {
const req = { body: { email: { $gt: '' }, password: 'secret123' } };
const res = createResponse();
await loginController(req as any, res as any, jest.fn());
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'Validation failed' }),
);
});

it('returns 400 when password is a NoSQL injection payload', async () => {
const req = { body: { email: 'test@example.com', password: { $ne: null } } };
const res = createResponse();
await loginController(req as any, res as any, jest.fn());
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'Validation failed' }),
);
expect(User.findOne).not.toHaveBeenCalled();
});

it('returns 404 when user not found', async () => {
(User.findOne as jest.Mock).mockResolvedValue(null);
const req = { body: { email: 'nonexistent@example.com', password: 'secret123' } };
const res = createResponse();
await loginController(req as any, res as any, jest.fn());
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ message: 'User not found' });
});
});
Loading
Loading