From 6925628d92336e0cf4a26965470bcdc4608c8db8 Mon Sep 17 00:00:00 2001 From: James Harrison Date: Sat, 27 Jun 2026 23:11:25 +0100 Subject: [PATCH] chores: implemented jwt authentication middleware --- src/middlewares/auth.middleware.ts | 70 ++++++----- tests/integration/invoice.routes.test.ts | 17 ++- tests/integration/merchant.register.test.ts | 31 +++-- tests/unit/auth.middleware.test.ts | 121 ++++++++++++++++++++ 4 files changed, 187 insertions(+), 52 deletions(-) create mode 100644 tests/unit/auth.middleware.test.ts diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index 655e085..7217db3 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -1,46 +1,62 @@ import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; import prisma from '../config/prisma.js'; +import { environment } from '../config/environment.js'; + +interface AccessTokenPayload extends jwt.JwtPayload { + sub: string; + address?: string; +} /** - * Authenticates a merchant using a session bearer token. + * Authenticates a merchant from a JWT access token. + * + * Expects an `Authorization: Bearer ` header containing a JWT signed + * with `JWT_SECRET`. The token is verified and its `sub` claim is used to load + * the corresponding Merchant, which is attached to `req.merchant` on success. * - * Expects an `Authorization: Bearer ` header that maps to a valid, - * non-expired MerchantSession. On success the resolved merchant is attached to - * `req.merchant`. Otherwise the request is rejected with 401. + * Responds with 401 when the header is missing/malformed, the token is invalid + * or expired, or the referenced merchant no longer exists. */ export const authenticateMerchant = async ( req: Request, res: Response, next: NextFunction, ): Promise => { - try { - const authHeader = req.headers.authorization; + const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - res.status(401).json({ error: 'Unauthorized' }); - return; - } + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + + const token = authHeader.slice('Bearer '.length).trim(); - const token = authHeader.slice('Bearer '.length).trim(); + if (!token) { + res.status(401).json({ error: 'Authentication required' }); + return; + } - if (!token) { - res.status(401).json({ error: 'Unauthorized' }); - return; - } + let payload: AccessTokenPayload; + try { + payload = jwt.verify(token, environment.jwtSecret) as AccessTokenPayload; + } catch { + res.status(401).json({ error: 'Invalid or expired token' }); + return; + } - const session = await prisma.refreshToken.findUnique({ - where: { token }, - include: { merchant: true }, - }); + if (!payload.sub) { + res.status(401).json({ error: 'Invalid or expired token' }); + return; + } - if (!session || session.expiresAt.getTime() < Date.now()) { - res.status(401).json({ error: 'Unauthorized' }); - return; - } + const merchant = await prisma.merchant.findUnique({ where: { id: payload.sub } }); - req.merchant = session.merchant; - next(); - } catch { - res.status(401).json({ error: 'Unauthorized' }); + if (!merchant) { + res.status(401).json({ error: 'Invalid or expired token' }); + return; } + + req.merchant = merchant; + next(); }; diff --git a/tests/integration/invoice.routes.test.ts b/tests/integration/invoice.routes.test.ts index 943675e..f80e280 100644 --- a/tests/integration/invoice.routes.test.ts +++ b/tests/integration/invoice.routes.test.ts @@ -1,7 +1,9 @@ import { mockReset } from 'jest-mock-extended'; +import jwt from 'jsonwebtoken'; import request from 'supertest'; const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { environment } = await import('../../src/config/environment.js'); const { default: app } = await import('../../src/app.js'); const MERCHANT_ID = 'merchant-1'; @@ -47,17 +49,14 @@ const baseInvoice = { }; const authenticate = () => { - prismaMock.refreshToken.findUnique.mockResolvedValue({ - id: 'session-1', - merchantId: MERCHANT_ID, - token: 'valid-token', - expiresAt: new Date(Date.now() + 60 * 60 * 1000), - createdAt: new Date(), - merchant, - } as any); + prismaMock.merchant.findUnique.mockResolvedValue(merchant as any); }; -const auth = { Authorization: 'Bearer valid-token' }; +const accessToken = jwt.sign( + { sub: MERCHANT_ID, address: merchant.address }, + environment.jwtSecret, +); +const auth = { Authorization: `Bearer ${accessToken}` }; describe('Invoice routes', () => { beforeEach(() => { diff --git a/tests/integration/merchant.register.test.ts b/tests/integration/merchant.register.test.ts index b86cda6..2c2934d 100644 --- a/tests/integration/merchant.register.test.ts +++ b/tests/integration/merchant.register.test.ts @@ -1,5 +1,6 @@ import { jest } from '@jest/globals'; import { mockReset } from 'jest-mock-extended'; +import jwt from 'jsonwebtoken'; import request from 'supertest'; const sendOtpEmailMock = jest.fn(async (_email: string) => '123456'); @@ -11,8 +12,12 @@ jest.unstable_mockModule('../../src/services/otp.services.js', () => ({ })); const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { environment } = await import('../../src/config/environment.js'); const { default: app } = await import('../../src/app.js'); +const tokenFor = (merchant: Record) => + jwt.sign({ sub: merchant.id as string }, environment.jwtSecret); + const REGISTER_URL = '/api/v1/merchants/register'; const baseMerchant = { @@ -46,16 +51,11 @@ const validPayload = { }; const authenticateAs = (merchant: Record) => { - prismaMock.refreshToken.findUnique.mockResolvedValue({ - id: 'session-1', - merchantId: merchant.id, - token: 'valid-token', - expiresAt: new Date(Date.now() + 60 * 60 * 1000), - createdAt: new Date(), - merchant, - } as any); + prismaMock.merchant.findUnique.mockResolvedValue(merchant as any); }; +const authHeader = `Bearer ${tokenFor(baseMerchant)}`; + describe('POST /api/v1/merchants/register', () => { beforeEach(() => { mockReset(prismaMock); @@ -66,19 +66,18 @@ describe('POST /api/v1/merchants/register', () => { const response = await request(app).post(REGISTER_URL).send(validPayload); expect(response.status).toBe(401); - expect(response.body).toEqual({ error: 'Unauthorized' }); + expect(response.body).toEqual({ error: 'Authentication required' }); expect(prismaMock.merchant.update).not.toHaveBeenCalled(); }); - test('returns 401 when the session token is invalid', async () => { - prismaMock.merchantSession.findUnique.mockResolvedValue(null); - + test('returns 401 when the token is invalid', async () => { const response = await request(app) .post(REGISTER_URL) .set('Authorization', 'Bearer bad-token') .send(validPayload); expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'Invalid or expired token' }); }); test('returns 200 with the merchant profile on valid payload', async () => { @@ -92,7 +91,7 @@ describe('POST /api/v1/merchants/register', () => { const response = await request(app) .post(REGISTER_URL) - .set('Authorization', 'Bearer valid-token') + .set('Authorization', authHeader) .send(validPayload); expect(response.status).toBe(200); @@ -118,7 +117,7 @@ describe('POST /api/v1/merchants/register', () => { const response = await request(app) .post(REGISTER_URL) - .set('Authorization', 'Bearer valid-token') + .set('Authorization', authHeader) .send(validPayload); expect(response.status).toBe(409); @@ -132,7 +131,7 @@ describe('POST /api/v1/merchants/register', () => { const response = await request(app) .post(REGISTER_URL) - .set('Authorization', 'Bearer valid-token') + .set('Authorization', authHeader) .send(validPayload); expect(response.status).toBe(409); @@ -144,7 +143,7 @@ describe('POST /api/v1/merchants/register', () => { const response = await request(app) .post(REGISTER_URL) - .set('Authorization', 'Bearer valid-token') + .set('Authorization', authHeader) .send({ email: 'not-an-email' }); expect(response.status).toBe(400); diff --git a/tests/unit/auth.middleware.test.ts b/tests/unit/auth.middleware.test.ts new file mode 100644 index 0000000..370d904 --- /dev/null +++ b/tests/unit/auth.middleware.test.ts @@ -0,0 +1,121 @@ +import { jest } from '@jest/globals'; +import jwt from 'jsonwebtoken'; +import type { Request, Response, NextFunction } from 'express'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { environment } = await import('../../src/config/environment.js'); +const { authenticateMerchant } = await import('../../src/middlewares/auth.middleware.js'); + +const MERCHANT_ID = 'merchant-1'; + +const merchant = { + id: MERCHANT_ID, + merchantId: 1, + address: '0x123', + registered: true, +}; + +const buildReq = (authorization?: string): Request => + ({ headers: authorization ? { authorization } : {} }) as unknown as Request; + +const buildRes = () => { + const res = {} as Response; + res.status = jest.fn().mockReturnValue(res) as unknown as Response['status']; + res.json = jest.fn().mockReturnValue(res) as unknown as Response['json']; + return res; +}; + +const validToken = () => + jwt.sign({ sub: MERCHANT_ID, address: merchant.address }, environment.jwtSecret); + +describe('authenticateMerchant', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('attaches the merchant and calls next() for a valid JWT', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(merchant as any); + const req = buildReq(`Bearer ${validToken()}`); + const res = buildRes(); + const next = jest.fn() as unknown as NextFunction; + + await authenticateMerchant(req, res, next); + + expect(prismaMock.merchant.findUnique).toHaveBeenCalledWith({ where: { id: MERCHANT_ID } }); + expect(req.merchant).toEqual(merchant); + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('returns 401 "Authentication required" when the Authorization header is missing', async () => { + const req = buildReq(); + const res = buildRes(); + const next = jest.fn() as unknown as NextFunction; + + await authenticateMerchant(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Authentication required' }); + expect(next).not.toHaveBeenCalled(); + }); + + test('returns 401 "Authentication required" when the scheme is not Bearer', async () => { + const req = buildReq('Basic abc123'); + const res = buildRes(); + const next = jest.fn() as unknown as NextFunction; + + await authenticateMerchant(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Authentication required' }); + }); + + test('returns 401 "Invalid or expired token" for a malformed token', async () => { + const req = buildReq('Bearer not-a-real-jwt'); + const res = buildRes(); + const next = jest.fn() as unknown as NextFunction; + + await authenticateMerchant(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired token' }); + expect(prismaMock.merchant.findUnique).not.toHaveBeenCalled(); + }); + + test('returns 401 "Invalid or expired token" for an expired token', async () => { + const expired = jwt.sign({ sub: MERCHANT_ID }, environment.jwtSecret, { expiresIn: '-1s' }); + const req = buildReq(`Bearer ${expired}`); + const res = buildRes(); + const next = jest.fn() as unknown as NextFunction; + + await authenticateMerchant(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired token' }); + }); + + test('returns 401 "Invalid or expired token" when the token is signed with the wrong secret', async () => { + const forged = jwt.sign({ sub: MERCHANT_ID }, 'a-different-secret'); + const req = buildReq(`Bearer ${forged}`); + const res = buildRes(); + const next = jest.fn() as unknown as NextFunction; + + await authenticateMerchant(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired token' }); + }); + + test('returns 401 when the merchant no longer exists in the database', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + const req = buildReq(`Bearer ${validToken()}`); + const res = buildRes(); + const next = jest.fn() as unknown as NextFunction; + + await authenticateMerchant(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired token' }); + expect(next).not.toHaveBeenCalled(); + }); +});