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
31 changes: 22 additions & 9 deletions src/middlewares/auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,19 @@ export const apiKeyAuth = async (
try {
const token = extractBearerToken(req);

if (!token || !isApiKeyToken(token)) {
res.status(401).json({ error: 'Unauthorized' });
if (!token) {
res.status(401).json({ error: 'Authentication required' });
return;
}

if (!isApiKeyToken(token)) {
res.status(401).json({ error: 'Invalid or expired token' });
return;
}

const merchant = await authenticateApiKey(token);
if (!merchant) {
res.status(401).json({ error: 'Unauthorized' });
res.status(401).json({ error: 'Invalid or expired token' });
return;
}

Expand All @@ -96,12 +101,12 @@ export const authenticateSessionOnly = async (
const token = extractBearerToken(req);

if (!token) {
res.status(401).json({ error: 'Unauthorized' });
res.status(401).json({ error: 'Authentication required' });
return;
}

if (isApiKeyToken(token)) {
res.status(401).json({ error: 'Unauthorized' });
res.status(401).json({ error: 'Invalid or expired token' });
return;
}

Expand All @@ -111,7 +116,7 @@ export const authenticateSessionOnly = async (
: await authenticateRefreshToken(token);

if (!merchant) {
res.status(401).json({ error: 'Unauthorized' });
res.status(401).json({ error: 'Invalid or expired token' });
return;
}

Expand All @@ -123,7 +128,15 @@ export const authenticateSessionOnly = async (
};

/**
* Authenticates a merchant using refresh tokens, JWT access tokens, or API keys.
* Authenticates a merchant from a bearer token.
*
* Accepts JWT access tokens (signed with `JWT_SECRET`), refresh session tokens,
* or API keys. The resolved Merchant is attached to `req.merchant` on success.
*
* Responds with 401 when the `Authorization: Bearer <token>` header is missing
* or malformed (`Authentication required`), or when the token is invalid,
* expired, or references a merchant that no longer exists
* (`Invalid or expired token`).
*/
export const authenticateMerchant = async (
req: Request,
Expand All @@ -134,13 +147,13 @@ export const authenticateMerchant = async (
const token = extractBearerToken(req);

if (!token) {
res.status(401).json({ error: 'Unauthorized' });
res.status(401).json({ error: 'Authentication required' });
return;
}

const merchant = await resolveMerchantFromToken(token);
if (!merchant) {
res.status(401).json({ error: 'Unauthorized' });
res.status(401).json({ error: 'Invalid or expired token' });
return;
}

Expand Down
4 changes: 2 additions & 2 deletions tests/integration/auth.email-otp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ describe('Email OTP auth routes', () => {
const response = await request(app).post(VERIFY_EMAIL_URL).send({ code: '123456' });

expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'Unauthorized' });
expect(response.body).toEqual({ error: 'Authentication required' });
});

test('returns 400 when code is missing', async () => {
Expand Down Expand Up @@ -163,7 +163,7 @@ describe('Email OTP auth routes', () => {
const response = await request(app).post(RESEND_OTP_URL);

expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'Unauthorized' });
expect(response.body).toEqual({ error: 'Authentication required' });
});

test('returns 200 and re-sends OTP when cooldown has elapsed', async () => {
Expand Down
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
29 changes: 15 additions & 14 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,11 +77,11 @@ 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 () => {
test('returns 401 when the token is invalid', async () => {
prismaMock.refreshToken.findUnique.mockResolvedValue(null);

const response = await request(app)
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