Skip to content
Open
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
70 changes: 43 additions & 27 deletions src/middlewares/auth.middleware.ts
Original file line number Diff line number Diff line change
@@ -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 <token>` 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 <token>` 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<void> => {
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;
Comment thread
codebestia marked this conversation as resolved.
} 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();
};
17 changes: 8 additions & 9 deletions tests/integration/invoice.routes.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(() => {
Expand Down
27 changes: 14 additions & 13 deletions tests/integration/merchant.register.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { jest } from '@jest/globals';
import { mockReset } from 'jest-mock-extended';
import jwt from 'jsonwebtoken';
import request from 'supertest';

const sendOtpMock = jest.fn(async () => undefined);
Expand All @@ -20,8 +21,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<string, unknown>) =>
jwt.sign({ sub: merchant.id as string }, environment.jwtSecret);

const REGISTER_URL = '/api/v1/merchants/register';

const baseMerchant = {
Expand Down Expand Up @@ -57,16 +62,11 @@ const validPayload = {
};

const authenticateAs = (merchant: Record<string, unknown>) => {
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);
Expand All @@ -77,7 +77,7 @@ 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();
});

Expand All @@ -90,6 +90,7 @@ describe('POST /api/v1/merchants/register', () => {
.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 () => {
Expand All @@ -103,7 +104,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);
Expand All @@ -129,7 +130,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);
Expand All @@ -143,7 +144,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);
Expand All @@ -155,7 +156,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);
Expand Down
121 changes: 121 additions & 0 deletions tests/unit/auth.middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading