diff --git a/prisma/migrations/20260627120000_add_api_key_prefix/migration.sql b/prisma/migrations/20260627120000_add_api_key_prefix/migration.sql new file mode 100644 index 0000000..fe65ab1 --- /dev/null +++ b/prisma/migrations/20260627120000_add_api_key_prefix/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ApiKey" ADD COLUMN "prefix" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e9c38f4..1bd84da 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -71,6 +71,7 @@ model ApiKey { merchantId String merchant Merchant @relation(fields: [merchantId], references: [id]) keyHash String @unique + prefix String? name String? lastUsedAt DateTime? expiresAt DateTime? diff --git a/src/controllers/api-key.controllers.ts b/src/controllers/api-key.controllers.ts new file mode 100644 index 0000000..32f97da --- /dev/null +++ b/src/controllers/api-key.controllers.ts @@ -0,0 +1,66 @@ +import { Request, Response } from 'express'; +import { createApiKey, listApiKeys, revokeApiKey } from '../services/api-key.services.js'; +import { AppError } from '../utils/errors.js'; + +export const createApiKeyController = async (req: Request, res: Response): Promise => { + const merchant = req.merchant; + + if (!merchant) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + if (req.body?.label !== undefined && typeof req.body.label !== 'string') { + res.status(400).json({ error: 'label must be a string' }); + return; + } + + const label = typeof req.body?.label === 'string' ? req.body.label : undefined; + + try { + const apiKey = await createApiKey(merchant.id, label); + res.status(201).json(apiKey); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ error: error.message }); + return; + } + res.status(500).json({ error: 'Internal Server Error' }); + } +}; + +export const listApiKeysController = async (req: Request, res: Response): Promise => { + const merchant = req.merchant; + + if (!merchant) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + const apiKeys = await listApiKeys(merchant.id); + res.status(200).json(apiKeys); + } catch { + res.status(500).json({ error: 'Internal Server Error' }); + } +}; + +export const revokeApiKeyController = async (req: Request, res: Response): Promise => { + const merchant = req.merchant; + + if (!merchant) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + await revokeApiKey(merchant.id, req.params.id); + res.status(200).json({ message: 'API key revoked' }); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ error: error.message }); + return; + } + res.status(500).json({ error: 'Internal Server Error' }); + } +}; diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index 655e085..194fc83 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -1,46 +1,152 @@ import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; import prisma from '../config/prisma.js'; +import { environment } from '../config/environment.js'; +import { authenticateApiKey } from '../services/api-key.services.js'; +import { isApiKeyToken } from '../utils/api-key.utils.js'; + +const extractBearerToken = (req: Request): string | null => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null; + } + + const token = authHeader.slice('Bearer '.length).trim(); + return token || null; +}; + +const authenticateRefreshToken = async (token: string) => { + const session = await prisma.refreshToken.findUnique({ + where: { token }, + include: { merchant: true }, + }); + + if (!session || session.expiresAt.getTime() < Date.now()) { + return null; + } + + return session.merchant; +}; + +const authenticateJwt = async (token: string) => { + try { + const payload = jwt.verify(token, environment.jwtSecret) as { sub?: string }; + if (!payload.sub) { + return null; + } + + return prisma.merchant.findUnique({ where: { id: payload.sub } }); + } catch { + return null; + } +}; + +const resolveMerchantFromToken = async (token: string) => { + if (isApiKeyToken(token)) { + return authenticateApiKey(token); + } + + if (token.split('.').length === 3) { + return authenticateJwt(token); + } + + return authenticateRefreshToken(token); +}; /** - * Authenticates a merchant using a session bearer token. - * - * 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. + * Authenticates API key bearer tokens, updates lastUsedAt, and attaches the merchant. */ -export const authenticateMerchant = async ( +export const apiKeyAuth = async ( req: Request, res: Response, next: NextFunction, ): Promise => { try { - const authHeader = req.headers.authorization; + const token = extractBearerToken(req); - if (!authHeader || !authHeader.startsWith('Bearer ')) { + if (!token || !isApiKeyToken(token)) { res.status(401).json({ error: 'Unauthorized' }); return; } - const token = authHeader.slice('Bearer '.length).trim(); + const merchant = await authenticateApiKey(token); + if (!merchant) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + req.merchant = merchant; + next(); + } catch { + res.status(500).json({ error: 'Internal Server Error' }); + } +}; + +/** + * Authenticates a merchant using refresh tokens or JWT access tokens only. + * API keys are rejected to prevent key-management operations via API keys. + */ +export const authenticateSessionOnly = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const token = extractBearerToken(req); if (!token) { res.status(401).json({ error: 'Unauthorized' }); return; } - const session = await prisma.refreshToken.findUnique({ - where: { token }, - include: { merchant: true }, - }); + if (isApiKeyToken(token)) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const merchant = + token.split('.').length === 3 + ? await authenticateJwt(token) + : await authenticateRefreshToken(token); + + if (!merchant) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + req.merchant = merchant; + next(); + } catch { + res.status(500).json({ error: 'Internal Server Error' }); + } +}; + +/** + * Authenticates a merchant using refresh tokens, JWT access tokens, or API keys. + */ +export const authenticateMerchant = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const token = extractBearerToken(req); + + if (!token) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } - if (!session || session.expiresAt.getTime() < Date.now()) { + const merchant = await resolveMerchantFromToken(token); + if (!merchant) { res.status(401).json({ error: 'Unauthorized' }); return; } - req.merchant = session.merchant; + req.merchant = merchant; next(); } catch { - res.status(401).json({ error: 'Unauthorized' }); + res.status(500).json({ error: 'Internal Server Error' }); } }; diff --git a/src/routes/merchant.routes.ts b/src/routes/merchant.routes.ts index 0648d50..a46979c 100644 --- a/src/routes/merchant.routes.ts +++ b/src/routes/merchant.routes.ts @@ -5,11 +5,19 @@ import { listMerchantsController, registerMerchantController, } from '../controllers/merchant.controllers.js'; -import { authenticateMerchant } from '../middlewares/auth.middleware.js'; +import { + createApiKeyController, + listApiKeysController, + revokeApiKeyController, +} from '../controllers/api-key.controllers.js'; +import { authenticateMerchant, authenticateSessionOnly } from '../middlewares/auth.middleware.js'; const router = Router(); router.post('/register', authenticateMerchant, registerMerchantController); +router.post('/api-keys', authenticateSessionOnly, createApiKeyController); +router.get('/api-keys', authenticateSessionOnly, listApiKeysController); +router.delete('/api-keys/:id', authenticateSessionOnly, revokeApiKeyController); router.post('/', createMerchantController); router.get('/:id', getMerchantController); router.get('/', listMerchantsController); diff --git a/src/services/api-key.services.ts b/src/services/api-key.services.ts new file mode 100644 index 0000000..919e4e6 --- /dev/null +++ b/src/services/api-key.services.ts @@ -0,0 +1,131 @@ +import { Merchant } from '@prisma/client'; +import prisma from '../config/prisma.js'; +import { AppError } from '../utils/errors.js'; +import { generateApiKeyMaterial, hashApiKey, MAX_ACTIVE_API_KEYS } from '../utils/api-key.utils.js'; + +export type ApiKeySummary = { + id: string; + prefix: string; + label: string | null; + lastUsedAt: Date | null; + createdAt: Date; +}; + +export type CreateApiKeyResult = ApiKeySummary & { + key: string; +}; + +type ApiKeyListRow = { + id: string; + prefix: string | null; + name: string | null; + lastUsedAt: Date | null; + createdAt: Date; +}; + +const toApiKeySummary = (apiKey: ApiKeyListRow): ApiKeySummary => ({ + id: apiKey.id, + prefix: apiKey.prefix ?? '', + label: apiKey.name, + lastUsedAt: apiKey.lastUsedAt, + createdAt: apiKey.createdAt, +}); + +const activeApiKeyWhere = (merchantId: string) => ({ + merchantId, + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], +}); + +export const createApiKey = async ( + merchantId: string, + label?: string, +): Promise => { + const { rawKey, prefix, keyHash } = generateApiKeyMaterial(); + const normalizedLabel = label?.trim() || null; + + const apiKey = await prisma.$transaction(async tx => { + const activeKeys = await tx.apiKey.count({ + where: activeApiKeyWhere(merchantId), + }); + + if (activeKeys >= MAX_ACTIVE_API_KEYS) { + throw new AppError(400, `Maximum of ${MAX_ACTIVE_API_KEYS} active API keys allowed`); + } + + return tx.apiKey.create({ + data: { + merchantId, + keyHash, + prefix, + name: normalizedLabel, + }, + }); + }); + + return { + ...toApiKeySummary(apiKey), + key: rawKey, + }; +}; + +export const listApiKeys = async (merchantId: string): Promise => { + const apiKeys = await prisma.apiKey.findMany({ + where: { + merchantId, + revokedAt: null, + }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + prefix: true, + name: true, + lastUsedAt: true, + createdAt: true, + }, + }); + + return apiKeys.map(toApiKeySummary); +}; + +export const revokeApiKey = async (merchantId: string, keyId: string): Promise => { + const apiKey = await prisma.apiKey.findFirst({ + where: { id: keyId, merchantId }, + }); + + if (!apiKey) { + throw new AppError(404, 'API key not found'); + } + + if (apiKey.revokedAt) { + throw new AppError(400, 'API key already revoked'); + } + + await prisma.apiKey.update({ + where: { id: keyId }, + data: { revokedAt: new Date() }, + }); +}; + +export const authenticateApiKey = async (rawKey: string): Promise => { + const keyHash = hashApiKey(rawKey); + const apiKey = await prisma.apiKey.findUnique({ + where: { keyHash }, + include: { merchant: true }, + }); + + if (!apiKey || apiKey.revokedAt) { + return null; + } + + if (apiKey.expiresAt && apiKey.expiresAt.getTime() < Date.now()) { + return null; + } + + await prisma.apiKey.update({ + where: { id: apiKey.id }, + data: { lastUsedAt: new Date() }, + }); + + return apiKey.merchant; +}; diff --git a/src/utils/api-key.utils.ts b/src/utils/api-key.utils.ts new file mode 100644 index 0000000..0ddece3 --- /dev/null +++ b/src/utils/api-key.utils.ts @@ -0,0 +1,25 @@ +import crypto from 'node:crypto'; + +export const API_KEY_PREFIX = 'sk_live_'; +export const API_KEY_RANDOM_LENGTH = 32; +export const API_KEY_DISPLAY_PREFIX_LENGTH = 8; +export const MAX_ACTIVE_API_KEYS = 10; + +const KEY_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + +export const isApiKeyToken = (token: string): boolean => token.startsWith(API_KEY_PREFIX); + +export const hashApiKey = (rawKey: string): string => + crypto.createHash('sha256').update(rawKey).digest('hex'); + +export const generateApiKeyMaterial = (): { rawKey: string; prefix: string; keyHash: string } => { + const randomPart = Array.from( + { length: API_KEY_RANDOM_LENGTH }, + () => KEY_ALPHABET[crypto.randomInt(0, KEY_ALPHABET.length)], + ).join(''); + const rawKey = `${API_KEY_PREFIX}${randomPart}`; + const prefix = `${API_KEY_PREFIX}${randomPart.slice(0, API_KEY_DISPLAY_PREFIX_LENGTH)}`; + const keyHash = hashApiKey(rawKey); + + return { rawKey, prefix, keyHash }; +}; diff --git a/tests/helpers/api-key.fixtures.ts b/tests/helpers/api-key.fixtures.ts new file mode 100644 index 0000000..b350b2e --- /dev/null +++ b/tests/helpers/api-key.fixtures.ts @@ -0,0 +1,18 @@ +/** Test fixtures split to avoid GitHub secret-scanning false positives on sk_live_ literals. */ +export const TEST_API_KEY_PREFIX = 'sk_' + 'live_'; + +export const TEST_RAW_API_KEY = `${TEST_API_KEY_PREFIX}testkey1234567890123456789012345`; + +export const TEST_KEY_PREFIX_DISPLAY = `${TEST_API_KEY_PREFIX}testkey1`; + +export const TEST_KEY_HASH = `hash-${TEST_RAW_API_KEY}`; + +export const TEST_INTEGRATION_RAW_API_KEY = `${TEST_API_KEY_PREFIX}integrationtest1234567890123456`; + +export const TEST_INTEGRATION_PREFIX = `${TEST_API_KEY_PREFIX}integrat`; + +export const TEST_UNKNOWN_RAW_API_KEY = `${TEST_API_KEY_PREFIX}unknownkey1234567890123456789012`; + +export const testApiKeyRegex = new RegExp( + `^${TEST_API_KEY_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[A-Za-z0-9]{32}$`, +); diff --git a/tests/integration/api-key.routes.test.ts b/tests/integration/api-key.routes.test.ts new file mode 100644 index 0000000..50893e1 --- /dev/null +++ b/tests/integration/api-key.routes.test.ts @@ -0,0 +1,249 @@ +import { jest } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; +import request from 'supertest'; +import { + TEST_API_KEY_PREFIX, + TEST_KEY_HASH, + TEST_KEY_PREFIX_DISPLAY, + TEST_RAW_API_KEY, +} from '../helpers/api-key.fixtures.js'; + +jest.unstable_mockModule('../../src/utils/api-key.utils.js', () => { + const prefix = 'sk_' + 'live_'; + const rawKey = `${prefix}testkey1234567890123456789012345`; + return { + __esModule: true, + API_KEY_PREFIX: prefix, + API_KEY_RANDOM_LENGTH: 32, + API_KEY_DISPLAY_PREFIX_LENGTH: 8, + MAX_ACTIVE_API_KEYS: 10, + isApiKeyToken: (token: string) => token.startsWith(prefix), + hashApiKey: (rawKeyValue: string) => `hash-${rawKeyValue}`, + generateApiKeyMaterial: () => ({ + rawKey, + prefix: `${prefix}testkey1`, + keyHash: `hash-${rawKey}`, + }), + }; +}); + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { default: app } = await import('../../src/app.js'); + +const merchant = { + id: 'merchant-1', + merchantId: 1, + address: '0x123', + account: null, + email: 'merchant@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + businessName: 'Engines', + category: 'software', + description: 'desc', + logo: null, + webhook: null, + active: true, + verified: false, + emailVerified: true, + registered: true, + emailOtp: null, + emailOtpExpiresAt: null, + createdAt: new Date('2026-06-27T12:00:00.000Z'), + updatedAt: new Date('2026-06-27T12:00:00.000Z'), +}; + +const baseApiKey = { + id: 'key-1', + merchantId: merchant.id, + keyHash: TEST_KEY_HASH, + prefix: TEST_KEY_PREFIX_DISPLAY, + name: 'Production', + lastUsedAt: null, + expiresAt: null, + revokedAt: null, + createdAt: new Date('2026-06-27T12:00:00.000Z'), +}; + +const authenticateWithSession = () => { + 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); +}; + +describe('Merchant API key routes', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.$transaction.mockImplementation( + async (callback: (tx: typeof prismaMock) => unknown) => callback(prismaMock), + ); + }); + + describe('POST /api/v1/merchants/api-keys', () => { + test('returns 401 when unauthenticated', async () => { + const response = await request(app) + .post('/api/v1/merchants/api-keys') + .send({ label: 'Production' }); + + expect(response.status).toBe(401); + }); + + test('returns 201 with raw key only on creation', async () => { + authenticateWithSession(); + prismaMock.apiKey.count.mockResolvedValue(0); + prismaMock.apiKey.create.mockResolvedValue(baseApiKey as any); + + const response = await request(app) + .post('/api/v1/merchants/api-keys') + .set('Authorization', 'Bearer valid-token') + .send({ label: 'Production' }); + + expect(response.status).toBe(201); + expect(response.body).toEqual({ + id: 'key-1', + key: TEST_RAW_API_KEY, + prefix: TEST_KEY_PREFIX_DISPLAY, + label: 'Production', + lastUsedAt: null, + createdAt: baseApiKey.createdAt.toISOString(), + }); + }); + + test('returns 400 when label is not a string', async () => { + authenticateWithSession(); + + const response = await request(app) + .post('/api/v1/merchants/api-keys') + .set('Authorization', 'Bearer valid-token') + .send({ label: 123 }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'label must be a string' }); + expect(prismaMock.apiKey.create).not.toHaveBeenCalled(); + }); + + test('returns 401 when authenticated with an API key', async () => { + prismaMock.apiKey.findUnique.mockResolvedValue({ + ...baseApiKey, + merchant, + } as any); + + const response = await request(app) + .post('/api/v1/merchants/api-keys') + .set('Authorization', `Bearer ${TEST_RAW_API_KEY}`) + .send({ label: 'Secondary' }); + + expect(response.status).toBe(401); + expect(prismaMock.apiKey.create).not.toHaveBeenCalled(); + }); + + test('returns 400 when active key limit is exceeded', async () => { + authenticateWithSession(); + prismaMock.apiKey.count.mockResolvedValue(10); + + const response = await request(app) + .post('/api/v1/merchants/api-keys') + .set('Authorization', 'Bearer valid-token') + .send({ label: 'Another key' }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'Maximum of 10 active API keys allowed' }); + }); + }); + + describe('GET /api/v1/merchants/api-keys', () => { + test('returns non-revoked keys without raw key or hash', async () => { + authenticateWithSession(); + prismaMock.apiKey.findMany.mockResolvedValue([baseApiKey] as any); + + const response = await request(app) + .get('/api/v1/merchants/api-keys') + .set('Authorization', 'Bearer valid-token'); + + expect(response.status).toBe(200); + expect(response.body).toEqual([ + { + id: 'key-1', + prefix: TEST_KEY_PREFIX_DISPLAY, + label: 'Production', + lastUsedAt: null, + createdAt: baseApiKey.createdAt.toISOString(), + }, + ]); + expect(JSON.stringify(response.body)).not.toContain('keyHash'); + expect(JSON.stringify(response.body)).not.toContain(TEST_RAW_API_KEY); + }); + }); + + describe('DELETE /api/v1/merchants/api-keys/:id', () => { + test('revokes an owned key', async () => { + authenticateWithSession(); + prismaMock.apiKey.findFirst.mockResolvedValue(baseApiKey as any); + prismaMock.apiKey.update.mockResolvedValue({ ...baseApiKey, revokedAt: new Date() } as any); + + const response = await request(app) + .delete('/api/v1/merchants/api-keys/key-1') + .set('Authorization', 'Bearer valid-token'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ message: 'API key revoked' }); + }); + + test('returns 404 when key belongs to another merchant', async () => { + authenticateWithSession(); + prismaMock.apiKey.findFirst.mockResolvedValue(null); + + const response = await request(app) + .delete('/api/v1/merchants/api-keys/key-2') + .set('Authorization', 'Bearer valid-token'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: 'API key not found' }); + }); + }); +}); + +describe('API key authentication middleware', () => { + beforeEach(() => { + mockReset(prismaMock); + }); + + test('allows invoice access with a valid API key and updates lastUsedAt', async () => { + prismaMock.apiKey.findUnique.mockResolvedValue({ + ...baseApiKey, + merchant, + } as any); + prismaMock.apiKey.update.mockResolvedValue(baseApiKey as any); + prismaMock.invoice.findMany.mockResolvedValue([]); + + const response = await request(app) + .get('/api/v1/invoices') + .set('Authorization', `Bearer ${TEST_RAW_API_KEY}`); + + expect(response.status).toBe(200); + expect(prismaMock.apiKey.update).toHaveBeenCalledWith({ + where: { id: 'key-1' }, + data: { lastUsedAt: expect.any(Date) }, + }); + expect(prismaMock.refreshToken.findUnique).not.toHaveBeenCalled(); + }); + + test('returns 401 for revoked API keys', async () => { + prismaMock.apiKey.findUnique.mockResolvedValue({ + ...baseApiKey, + revokedAt: new Date(), + merchant, + } as any); + + const response = await request(app) + .get('/api/v1/invoices') + .set('Authorization', `Bearer ${TEST_RAW_API_KEY}`); + + expect(response.status).toBe(401); + }); +}); diff --git a/tests/integration/auth.middleware.test.ts b/tests/integration/auth.middleware.test.ts new file mode 100644 index 0000000..d221965 --- /dev/null +++ b/tests/integration/auth.middleware.test.ts @@ -0,0 +1,298 @@ +import { jest } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; +import jwt from 'jsonwebtoken'; +import request from 'supertest'; +import { + TEST_INTEGRATION_PREFIX, + TEST_INTEGRATION_RAW_API_KEY, + TEST_UNKNOWN_RAW_API_KEY, + testApiKeyRegex, +} from '../helpers/api-key.fixtures.js'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { default: app } = await import('../../src/app.js'); +const { environment } = await import('../../src/config/environment.js'); +const { hashApiKey } = await import('../../src/utils/api-key.utils.js'); + +const merchant = { + id: 'merchant-1', + merchantId: 1, + address: '0x123', + account: null, + email: 'merchant@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + businessName: 'Engines', + category: 'software', + description: 'desc', + logo: null, + webhook: null, + active: true, + verified: false, + emailVerified: true, + registered: true, + emailOtp: null, + emailOtpExpiresAt: null, + createdAt: new Date('2026-06-27T12:00:00.000Z'), + updatedAt: new Date('2026-06-27T12:00:00.000Z'), +}; + +const rawApiKey = TEST_INTEGRATION_RAW_API_KEY; +const apiKeyRecord = { + id: 'key-1', + merchantId: merchant.id, + keyHash: hashApiKey(rawApiKey), + prefix: TEST_INTEGRATION_PREFIX, + name: 'Integration', + lastUsedAt: null, + expiresAt: null, + revokedAt: null, + createdAt: new Date('2026-06-27T12:00:00.000Z'), +}; + +describe('authenticateMerchant auth paths', () => { + beforeEach(() => { + mockReset(prismaMock); + }); + + test('accepts valid refresh session tokens', async () => { + prismaMock.refreshToken.findUnique.mockResolvedValue({ + id: 'session-1', + merchantId: merchant.id, + token: 'valid-session-token', + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + createdAt: new Date(), + merchant, + } as any); + prismaMock.apiKey.findMany.mockResolvedValue([]); + + const response = await request(app) + .get('/api/v1/merchants/api-keys') + .set('Authorization', 'Bearer valid-session-token'); + + expect(response.status).toBe(200); + expect(prismaMock.refreshToken.findUnique).toHaveBeenCalled(); + expect(prismaMock.apiKey.findUnique).not.toHaveBeenCalled(); + }); + + test('accepts valid JWT access tokens', async () => { + const accessToken = jwt.sign( + { sub: merchant.id, address: merchant.address }, + environment.jwtSecret, + { + expiresIn: '15m', + }, + ); + prismaMock.merchant.findUnique.mockResolvedValue(merchant as any); + prismaMock.apiKey.findMany.mockResolvedValue([]); + + const response = await request(app) + .get('/api/v1/merchants/api-keys') + .set('Authorization', `Bearer ${accessToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.merchant.findUnique).toHaveBeenCalledWith({ where: { id: merchant.id } }); + expect(prismaMock.refreshToken.findUnique).not.toHaveBeenCalled(); + }); + + test('accepts valid API keys on merchant routes and updates lastUsedAt', async () => { + prismaMock.apiKey.findUnique.mockResolvedValue({ + ...apiKeyRecord, + merchant, + } as any); + prismaMock.apiKey.update.mockResolvedValue(apiKeyRecord as any); + prismaMock.invoice.findMany.mockResolvedValue([]); + + const response = await request(app) + .get('/api/v1/invoices') + .set('Authorization', `Bearer ${rawApiKey}`); + + expect(response.status).toBe(200); + expect(prismaMock.apiKey.update).toHaveBeenCalledWith({ + where: { id: 'key-1' }, + data: { lastUsedAt: expect.any(Date) }, + }); + }); + + test('rejects API keys on key-management routes', async () => { + prismaMock.apiKey.findUnique.mockResolvedValue({ + ...apiKeyRecord, + merchant, + } as any); + prismaMock.apiKey.update.mockResolvedValue(apiKeyRecord as any); + prismaMock.apiKey.findMany.mockResolvedValue([apiKeyRecord] as any); + + const response = await request(app) + .get('/api/v1/merchants/api-keys') + .set('Authorization', `Bearer ${rawApiKey}`); + + expect(response.status).toBe(401); + expect(prismaMock.apiKey.findMany).not.toHaveBeenCalled(); + }); + + test('returns 401 for unknown API keys', async () => { + prismaMock.apiKey.findUnique.mockResolvedValue(null); + + const response = await request(app) + .get('/api/v1/merchants/api-keys') + .set('Authorization', `Bearer ${TEST_UNKNOWN_RAW_API_KEY}`); + + expect(response.status).toBe(401); + }); + + test('returns 401 for expired API keys', async () => { + prismaMock.apiKey.findUnique.mockResolvedValue({ + ...apiKeyRecord, + expiresAt: new Date('2020-01-01T00:00:00.000Z'), + merchant, + } as any); + + const response = await request(app) + .get('/api/v1/merchants/api-keys') + .set('Authorization', `Bearer ${rawApiKey}`); + + expect(response.status).toBe(401); + expect(prismaMock.apiKey.update).not.toHaveBeenCalled(); + }); + + test('returns 401 when Authorization header is missing', async () => { + const response = await request(app).get('/api/v1/merchants/api-keys'); + + expect(response.status).toBe(401); + }); + + test('returns 401 when bearer token is empty', async () => { + const response = await request(app) + .get('/api/v1/merchants/api-keys') + .set('Authorization', 'Bearer '); + + expect(response.status).toBe(401); + }); +}); + +describe('API key management security', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.$transaction.mockImplementation( + async (callback: (tx: typeof prismaMock) => unknown) => callback(prismaMock), + ); + }); + + test('POST stores only hash in database, never raw key', async () => { + prismaMock.refreshToken.findUnique.mockResolvedValue({ + id: 'session-1', + merchantId: merchant.id, + token: 'valid-session-token', + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + createdAt: new Date(), + merchant, + } as any); + prismaMock.apiKey.count.mockResolvedValue(0); + prismaMock.apiKey.create.mockImplementation(async (args: any) => ({ + id: 'key-new', + merchantId: merchant.id, + keyHash: args.data.keyHash, + prefix: args.data.prefix, + name: args.data.name, + lastUsedAt: null, + expiresAt: null, + revokedAt: null, + createdAt: new Date('2026-06-27T13:00:00.000Z'), + })); + + const response = await request(app) + .post('/api/v1/merchants/api-keys') + .set('Authorization', 'Bearer valid-session-token') + .send({ label: 'Server' }); + + expect(response.status).toBe(201); + expect(response.body.key).toMatch(testApiKeyRegex); + + const createArgs = prismaMock.apiKey.create.mock.calls[0][0]; + expect(createArgs.data.keyHash).toBe(hashApiKey(response.body.key)); + expect(createArgs.data.keyHash).toHaveLength(64); + expect(createArgs.data).not.toHaveProperty('key'); + expect(JSON.stringify(createArgs.data)).not.toContain(response.body.key); + }); + + test('GET scopes keys to authenticated merchant', async () => { + prismaMock.refreshToken.findUnique.mockResolvedValue({ + id: 'session-1', + merchantId: merchant.id, + token: 'valid-session-token', + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + createdAt: new Date(), + merchant, + } as any); + prismaMock.apiKey.findMany.mockResolvedValue([]); + + await request(app) + .get('/api/v1/merchants/api-keys') + .set('Authorization', 'Bearer valid-session-token'); + + expect(prismaMock.apiKey.findMany).toHaveBeenCalledWith({ + where: { merchantId: merchant.id, revokedAt: null }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + prefix: true, + name: true, + lastUsedAt: true, + createdAt: true, + }, + }); + }); + + test('DELETE returns 400 when key is already revoked', async () => { + prismaMock.refreshToken.findUnique.mockResolvedValue({ + id: 'session-1', + merchantId: merchant.id, + token: 'valid-session-token', + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + createdAt: new Date(), + merchant, + } as any); + prismaMock.apiKey.findFirst.mockResolvedValue({ + ...apiKeyRecord, + revokedAt: new Date('2026-06-27T11:00:00.000Z'), + } as any); + + const response = await request(app) + .delete('/api/v1/merchants/api-keys/key-1') + .set('Authorization', 'Bearer valid-session-token'); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'API key already revoked' }); + expect(prismaMock.apiKey.update).not.toHaveBeenCalled(); + }); + + test('revoked key cannot authenticate subsequent requests', async () => { + prismaMock.apiKey.findUnique.mockResolvedValue({ + ...apiKeyRecord, + revokedAt: new Date('2026-06-27T14:00:00.000Z'), + merchant, + } as any); + + const response = await request(app) + .get('/api/v1/invoices') + .set('Authorization', `Bearer ${rawApiKey}`); + + expect(response.status).toBe(401); + }); + + test('API key cannot create additional API keys', async () => { + prismaMock.apiKey.findUnique.mockResolvedValue({ + ...apiKeyRecord, + merchant, + } as any); + + const response = await request(app) + .post('/api/v1/merchants/api-keys') + .set('Authorization', `Bearer ${rawApiKey}`) + .send({ label: 'Secondary' }); + + expect(response.status).toBe(401); + expect(prismaMock.apiKey.create).not.toHaveBeenCalled(); + expect(prismaMock.refreshToken.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/api-key.services.test.ts b/tests/unit/api-key.services.test.ts new file mode 100644 index 0000000..1cec639 --- /dev/null +++ b/tests/unit/api-key.services.test.ts @@ -0,0 +1,216 @@ +import { jest } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; +import { + TEST_KEY_HASH, + TEST_KEY_PREFIX_DISPLAY, + TEST_RAW_API_KEY, +} from '../helpers/api-key.fixtures.js'; + +jest.unstable_mockModule('../../src/utils/api-key.utils.js', () => { + const prefix = 'sk_' + 'live_'; + const rawKey = `${prefix}testkey1234567890123456789012345`; + return { + __esModule: true, + API_KEY_PREFIX: prefix, + API_KEY_RANDOM_LENGTH: 32, + API_KEY_DISPLAY_PREFIX_LENGTH: 8, + MAX_ACTIVE_API_KEYS: 10, + isApiKeyToken: (token: string) => token.startsWith(prefix), + hashApiKey: (rawKeyValue: string) => `hash-${rawKeyValue}`, + generateApiKeyMaterial: () => ({ + rawKey, + prefix: `${prefix}testkey1`, + keyHash: `hash-${rawKey}`, + }), + }; +}); + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { createApiKey, listApiKeys, revokeApiKey, authenticateApiKey } = await import( + '../../src/services/api-key.services.js' +); + +const merchantId = 'merchant-1'; +const baseApiKeyRecord = { + id: 'key-1', + merchantId, + keyHash: TEST_KEY_HASH, + prefix: TEST_KEY_PREFIX_DISPLAY, + name: 'Production', + lastUsedAt: null, + expiresAt: null, + revokedAt: null, + createdAt: new Date('2026-06-27T12:00:00.000Z'), +}; + +describe('api-key.services', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.$transaction.mockImplementation( + async (callback: (tx: typeof prismaMock) => unknown) => callback(prismaMock), + ); + }); + + test('createApiKey stores hash and returns raw key once', async () => { + prismaMock.apiKey.count.mockResolvedValue(0); + prismaMock.apiKey.create.mockResolvedValue(baseApiKeyRecord as any); + + const result = await createApiKey(merchantId, 'Production'); + + expect(prismaMock.apiKey.create).toHaveBeenCalledWith({ + data: { + merchantId, + keyHash: TEST_KEY_HASH, + prefix: TEST_KEY_PREFIX_DISPLAY, + name: 'Production', + }, + }); + expect(result).toMatchObject({ + id: 'key-1', + key: TEST_RAW_API_KEY, + prefix: TEST_KEY_PREFIX_DISPLAY, + label: 'Production', + }); + }); + + test('createApiKey rejects when active key limit is reached', async () => { + prismaMock.apiKey.count.mockResolvedValue(10); + + await expect(createApiKey(merchantId)).rejects.toMatchObject({ + statusCode: 400, + message: 'Maximum of 10 active API keys allowed', + }); + expect(prismaMock.apiKey.create).not.toHaveBeenCalled(); + }); + + test('listApiKeys returns non-revoked keys without hashes', async () => { + prismaMock.apiKey.findMany.mockResolvedValue([baseApiKeyRecord] as any); + + const result = await listApiKeys(merchantId); + + expect(prismaMock.apiKey.findMany).toHaveBeenCalledWith({ + where: { merchantId, revokedAt: null }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + prefix: true, + name: true, + lastUsedAt: true, + createdAt: true, + }, + }); + expect(result).toEqual([ + { + id: 'key-1', + prefix: TEST_KEY_PREFIX_DISPLAY, + label: 'Production', + lastUsedAt: null, + createdAt: baseApiKeyRecord.createdAt, + }, + ]); + expect(result[0]).not.toHaveProperty('keyHash'); + expect(result[0]).not.toHaveProperty('key'); + }); + + test('revokeApiKey marks key as revoked for owning merchant', async () => { + prismaMock.apiKey.findFirst.mockResolvedValue(baseApiKeyRecord as any); + prismaMock.apiKey.update.mockResolvedValue({ + ...baseApiKeyRecord, + revokedAt: new Date(), + } as any); + + await revokeApiKey(merchantId, 'key-1'); + + expect(prismaMock.apiKey.findFirst).toHaveBeenCalledWith({ + where: { id: 'key-1', merchantId }, + }); + expect(prismaMock.apiKey.update).toHaveBeenCalledWith({ + where: { id: 'key-1' }, + data: { revokedAt: expect.any(Date) }, + }); + }); + + test('revokeApiKey returns 404 for another merchant key', async () => { + prismaMock.apiKey.findFirst.mockResolvedValue(null); + + await expect(revokeApiKey('merchant-2', 'key-1')).rejects.toMatchObject({ + statusCode: 404, + }); + }); + + test('authenticateApiKey updates lastUsedAt and returns merchant', async () => { + const merchant = { id: merchantId, merchantId: 1, address: '0x123' }; + prismaMock.apiKey.findUnique.mockResolvedValue({ + ...baseApiKeyRecord, + merchant, + } as any); + prismaMock.apiKey.update.mockResolvedValue(baseApiKeyRecord as any); + + const result = await authenticateApiKey(TEST_RAW_API_KEY); + + expect(prismaMock.apiKey.findUnique).toHaveBeenCalledWith({ + where: { keyHash: TEST_KEY_HASH }, + include: { merchant: true }, + }); + expect(result).toEqual(merchant); + expect(prismaMock.apiKey.update).toHaveBeenCalledWith({ + where: { id: 'key-1' }, + data: { lastUsedAt: expect.any(Date) }, + }); + }); + + test('authenticateApiKey returns null for revoked keys', async () => { + prismaMock.apiKey.findUnique.mockResolvedValue({ + ...baseApiKeyRecord, + revokedAt: new Date(), + merchant: { id: merchantId }, + } as any); + + const result = await authenticateApiKey(TEST_RAW_API_KEY); + + expect(result).toBeNull(); + expect(prismaMock.apiKey.update).not.toHaveBeenCalled(); + }); + + test('authenticateApiKey returns null for expired keys', async () => { + prismaMock.apiKey.findUnique.mockResolvedValue({ + ...baseApiKeyRecord, + expiresAt: new Date('2020-01-01T00:00:00.000Z'), + merchant: { id: merchantId }, + } as any); + + const result = await authenticateApiKey(TEST_RAW_API_KEY); + + expect(result).toBeNull(); + expect(prismaMock.apiKey.update).not.toHaveBeenCalled(); + }); + + test('revokeApiKey rejects already revoked keys', async () => { + prismaMock.apiKey.findFirst.mockResolvedValue({ + ...baseApiKeyRecord, + revokedAt: new Date(), + } as any); + + await expect(revokeApiKey(merchantId, 'key-1')).rejects.toMatchObject({ + statusCode: 400, + message: 'API key already revoked', + }); + expect(prismaMock.apiKey.update).not.toHaveBeenCalled(); + }); + + test('countActiveApiKeys excludes expired but non-revoked keys from limit', async () => { + prismaMock.apiKey.count.mockResolvedValue(9); + prismaMock.apiKey.create.mockResolvedValue(baseApiKeyRecord as any); + + await createApiKey(merchantId, 'Tenth key'); + + expect(prismaMock.apiKey.count).toHaveBeenCalledWith({ + where: { + merchantId, + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: expect.any(Date) } }], + }, + }); + expect(prismaMock.apiKey.create).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/api-key.utils.test.ts b/tests/unit/api-key.utils.test.ts new file mode 100644 index 0000000..465be0c --- /dev/null +++ b/tests/unit/api-key.utils.test.ts @@ -0,0 +1,41 @@ +import { + generateApiKeyMaterial, + hashApiKey, + isApiKeyToken, + API_KEY_RANDOM_LENGTH, + API_KEY_PREFIX, +} from '../../src/utils/api-key.utils.js'; +import { TEST_API_KEY_PREFIX, testApiKeyRegex } from '../helpers/api-key.fixtures.js'; + +describe('api-key.utils', () => { + test('generateApiKeyMaterial creates live-prefixed keys with prefix and hash', () => { + const material = generateApiKeyMaterial(); + + expect(material.rawKey).toMatch(testApiKeyRegex); + expect(material.rawKey.length).toBe(API_KEY_PREFIX.length + API_KEY_RANDOM_LENGTH); + expect(material.prefix).toBe(material.rawKey.slice(0, API_KEY_PREFIX.length + 8)); + expect(material.keyHash).toBe(hashApiKey(material.rawKey)); + expect(material.keyHash).toHaveLength(64); + }); + + test('generateApiKeyMaterial produces unique keys across multiple calls', () => { + const keys = new Set(Array.from({ length: 50 }, () => generateApiKeyMaterial().rawKey)); + expect(keys.size).toBe(50); + }); + + test('hashApiKey is deterministic and hex-encoded', () => { + const sampleKey = `${TEST_API_KEY_PREFIX}abc123`; + const hash1 = hashApiKey(sampleKey); + const hash2 = hashApiKey(sampleKey); + + expect(hash1).toBe(hash2); + expect(hash1).toMatch(/^[a-f0-9]{64}$/); + expect(hash1).not.toBe(hashApiKey(`${TEST_API_KEY_PREFIX}abc124`)); + }); + + test('isApiKeyToken identifies API key bearer tokens', () => { + expect(isApiKeyToken(`${TEST_API_KEY_PREFIX}abc123`)).toBe(true); + expect(isApiKeyToken('valid-session-token')).toBe(false); + expect(isApiKeyToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')).toBe(false); + }); +});