fix architecture and write new tests

This commit is contained in:
root
2026-06-10 00:40:19 -04:00
parent 560da1cadf
commit 80a597bc10
377 changed files with 84020 additions and 1337 deletions
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { getCookie, sendForbidden, sendPaymentRequired, sendUnauthorized } from './authHelpers'
function responseStub() {
const res = {
status: vi.fn(),
json: vi.fn(),
}
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('authHelpers', () => {
it('extracts and decodes a cookie value by name', () => {
const req = {
headers: {
cookie: 'theme=dark; employee_session=abc%20123%3D; other=value',
},
} as Request
expect(getCookie(req, 'employee_session')).toBe('abc 123=')
})
it('returns null when the cookie header or requested cookie is missing', () => {
expect(getCookie({ headers: {} } as Request, 'employee_session')).toBeNull()
expect(getCookie({ headers: { cookie: 'theme=dark' } } as Request, 'employee_session')).toBeNull()
})
it('sends a uniform unauthorized response', () => {
const res = responseStub()
sendUnauthorized(res, 'invalid_token', 'Invalid token')
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid token', statusCode: 401 })
})
it('sends forbidden responses with optional metadata', () => {
const res = responseStub()
sendForbidden(res, 'forbidden', 'Nope', { requiredRole: 'OWNER' })
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'forbidden',
message: 'Nope',
statusCode: 403,
requiredRole: 'OWNER',
})
})
it('sends payment-required responses with optional metadata', () => {
const res = responseStub()
sendPaymentRequired(res, 'subscription_suspended', 'Pay up', { billingUrl: '/billing' })
expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith({
error: 'subscription_suspended',
message: 'Pay up',
statusCode: 402,
billingUrl: '/billing',
})
})
})
+13
View File
@@ -23,6 +23,19 @@ export function getCookie(req: Request, name: string): string | null {
return null
}
export function getBearerToken(req: Request): string | null {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) return null
const token = authHeader.slice(7).trim()
return token.length > 0 ? token : null
}
export function getAuthToken(req: Request, cookieName?: string): string | null {
// Prefer HttpOnly cookies over bearer tokens so stale script-readable tokens
// cannot shadow a valid server-managed session during migration.
return (cookieName ? getCookie(req, cookieName) : null) ?? getBearerToken(req)
}
export function sendForbidden(res: Response, error: string, message: string, extra?: Record<string, unknown>) {
return res.status(403).json({ error, message, statusCode: 403, ...extra })
}
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const createdLimiters: any[] = []
vi.mock('express-rate-limit', () => ({
default: vi.fn((config: any) => {
createdLimiters.push(config)
return config
}),
ipKeyGenerator: vi.fn((ip: string) => `ip:${ip}`),
}))
vi.mock('../security/tokens', () => ({
verifyAnyActorToken: vi.fn((token: string) => {
if (token === 'employee-token') return { type: 'employee', sub: 'employee_1' }
if (token === 'renter-token') return { type: 'renter', sub: 'renter_1' }
throw new Error('Invalid actor token')
}),
}))
import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
describe('rateLimiter middleware configuration', () => {
beforeEach(() => {
createdLimiters.length = 0
vi.resetModules()
})
it('configures auth limiter to count failed attempts only', async () => {
const { authLimiter } = await import('./rateLimiter')
expect(authLimiter.max).toBe(20)
expect(authLimiter.windowMs).toBe(15 * 60 * 1000)
expect(authLimiter.skipSuccessfulRequests).toBe(true)
expect(authLimiter.message).toMatchObject({ error: 'too_many_requests', statusCode: 429 })
expect(rateLimit).toHaveBeenCalled()
})
it('keys general API limits by verified actor identity before falling back to request context', async () => {
const { apiLimiter } = await import('./rateLimiter')
expect(apiLimiter.keyGenerator({
ip: '203.0.113.10',
headers: { cookie: 'theme=dark; employee_session=employee-token' },
companyId: 'company_1',
} as any)).toBe('ip:203.0.113.10:employee:employee_1')
expect(apiLimiter.keyGenerator({
ip: '203.0.113.10',
headers: { authorization: 'Bearer renter-token' },
renterId: 'legacy_renter_context',
} as any)).toBe('ip:203.0.113.10:renter:renter_1')
expect(apiLimiter.keyGenerator({
ip: '203.0.113.10',
headers: { authorization: 'Bearer invalid-token' },
companyId: 'company_1',
} as any)).toBe('ip:203.0.113.10:company_1')
expect(apiLimiter.keyGenerator({ ip: '203.0.113.10', headers: {}, renterId: 'renter_1' } as any)).toBe('ip:203.0.113.10:renter_1')
expect(ipKeyGenerator).toHaveBeenCalledWith('203.0.113.10')
})
it('uses tighter public and admin limits with explicit 429 payloads', async () => {
const { publicLimiter, adminLimiter } = await import('./rateLimiter')
expect(publicLimiter.max).toBe(60)
expect(publicLimiter.message.message).toBe('Rate limit exceeded')
expect(adminLimiter.max).toBe(100)
expect(adminLimiter.message.message).toBe('Too many admin requests')
})
})
+75 -2
View File
@@ -1,5 +1,54 @@
import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
import type { Request } from 'express'
import { verifyAnyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
const SESSION_COOKIE_NAMES = [
getSessionCookieName('admin'),
getSessionCookieName('employee'),
getSessionCookieName('renter'),
]
function readCookie(cookieHeader: string | undefined, name: string): string | null {
if (!cookieHeader) return null
for (const chunk of cookieHeader.split(';')) {
const [rawName, ...rawValue] = chunk.trim().split('=')
if (rawName === name) return decodeURIComponent(rawValue.join('='))
}
return null
}
function getBearerToken(req: Request): string | null {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) return null
const token = authHeader.slice(7).trim()
return token || null
}
function getRequestToken(req: Request): string | null {
const cookieHeader = req.headers.cookie
for (const name of SESSION_COOKIE_NAMES) {
const token = readCookie(cookieHeader, name)
if (token) return token
}
return getBearerToken(req)
}
function getAuthenticatedActorKey(req: Request): string | null {
const token = getRequestToken(req)
if (!token) return null
try {
const payload = verifyAnyActorToken(token)
return `${payload.type}:${payload.sub}`
} catch {
return null
}
}
// req.ip is already the real client IP when app.set('trust proxy', 1) is configured
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
@@ -25,9 +74,10 @@ export const apiLimiter = rateLimit({
legacyHeaders: false,
keyGenerator: (req) => {
const ip = getClientIpKey(req)
const actorKey = getAuthenticatedActorKey(req)
const companyId = (req as any).companyId ?? ''
const renterId = (req as any).renterId ?? ''
return `${ip}:${companyId || renterId}`
return `${ip}:${actorKey || companyId || renterId || 'anonymous'}`
},
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
@@ -48,6 +98,29 @@ export const adminLimiter = rateLimit({
max: 100,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: (req) => getClientIpKey(req),
keyGenerator: (req) => {
const ip = getClientIpKey(req)
return `${ip}:${getAuthenticatedActorKey(req) || 'anonymous'}`
},
message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 },
})
// Applied after authentication so limits can include actor identity rather than
// pretending every employee behind the same NAT is the same organism.
export const actorLimiter = rateLimit({
windowMs: 60 * 1000,
max: 240,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: (req) => {
const ip = getClientIpKey(req)
const actorKey = getAuthenticatedActorKey(req)
const companyId = (req as any).companyId ?? ''
const employeeId = (req as any).employee?.id ?? ''
const renterId = (req as any).renterId ?? ''
const adminId = (req as any).admin?.id ?? ''
return `${ip}:${companyId}:${actorKey || employeeId || renterId || adminId || 'anonymous'}`
},
message: { error: 'too_many_requests', message: 'Authenticated rate limit exceeded', statusCode: 429 },
})
+10
View File
@@ -0,0 +1,10 @@
import crypto from 'crypto'
import type { Request, Response, NextFunction } from 'express'
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction) {
const incoming = req.headers['x-request-id']
const requestId = Array.isArray(incoming) ? incoming[0] : incoming
req.requestId = requestId && requestId.length <= 128 ? requestId : `req_${crypto.randomUUID()}`
res.setHeader('X-Request-Id', req.requestId)
next()
}
@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
vi.mock('jsonwebtoken', () => ({
default: { verify: vi.fn() },
}))
vi.mock('../lib/prisma', () => ({
prisma: {
adminUser: { findUnique: vi.fn() },
},
}))
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { requireAdminAuth, requireAdminRole } from './requireAdminAuth'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireAdminAuth middleware', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.JWT_SECRET = 'test-secret'
})
it('rejects requests without an admin bearer token', async () => {
const req = { headers: {} } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('rejects invalid token signatures', async () => {
vi.mocked(jwt.verify).mockImplementation(() => { throw new Error('bad token') })
const req = { headers: { authorization: 'Bearer bad' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
expect(prisma.adminUser.findUnique).not.toHaveBeenCalled()
})
it('rejects non-admin token types', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
expect(prisma.adminUser.findUnique).not.toHaveBeenCalled()
})
it('rejects inactive admin accounts', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue({ id: 'admin_1', isActive: false } as any)
const req = { headers: { authorization: 'Bearer admin-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin account not found or deactivated', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('attaches the admin record for valid admin tokens', async () => {
const admin = { id: 'admin_1', isActive: true, role: 'SUPPORT', totpEnabled: true }
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue(admin as any)
const req = { headers: { authorization: 'Bearer admin-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(prisma.adminUser.findUnique).toHaveBeenCalledWith({ where: { id: 'admin_1' } })
expect(req.admin).toEqual(admin)
expect(next).toHaveBeenCalledTimes(1)
})
it('blocks non-enrolled admins from privileged routes', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue({ id: 'admin_1', isActive: true, role: 'ADMIN', totpEnabled: false } as any)
const req = { headers: { authorization: 'Bearer admin-token' }, path: '/companies' } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({ error: 'admin_2fa_required', message: 'Admin 2FA enrollment is required before using privileged admin routes', statusCode: 403 })
expect(next).not.toHaveBeenCalled()
})
})
describe('requireAdminRole middleware', () => {
it('requires requireAdminAuth to run first', () => {
const req = {} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireAdminRole('SUPPORT' as any)(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('blocks admins below the required rank', () => {
const req = { admin: { role: 'VIEWER' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireAdminRole('FINANCE' as any)(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'forbidden',
message: 'This action requires the FINANCE role or higher',
statusCode: 403,
})
expect(next).not.toHaveBeenCalled()
})
it('allows admins at or above the required rank', () => {
const req = { admin: { role: 'ADMIN' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireAdminRole('SUPPORT' as any)(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
})
})
+40 -11
View File
@@ -1,8 +1,9 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { AdminRole } from '@rentaldrivego/database'
import { sendUnauthorized, sendForbidden } from './authHelpers'
import { getAuthToken, sendUnauthorized, sendForbidden } from './authHelpers'
import { verifyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
const ROLE_RANK: Record<AdminRole, number> = {
SUPER_ADMIN: 5,
@@ -12,35 +13,48 @@ const ROLE_RANK: Record<AdminRole, number> = {
VIEWER: 1,
}
const ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS = new Set([
'/auth/me',
'/auth/logout',
'/auth/2fa/setup',
'/auth/2fa/verify',
])
const FRESH_2FA_WINDOW_MS = Number(process.env.ADMIN_FRESH_2FA_WINDOW_MS ?? 10 * 60 * 1000)
function is2faEnrollmentExempt(req: Request) {
return ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS.has(req.path)
}
/**
* Requires a valid admin Bearer token.
* Requires a valid admin session token.
*
* Guarantees on success:
* req.admin — the full AdminUser record
*/
export async function requireAdminAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
const token = getAuthToken(req, getSessionCookieName('admin'))
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
let payload: { sub: string; type: string }
let payload: { sub: string; type: string; last2faAt?: number }
try {
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
payload = verifyActorToken(token, 'admin')
} catch {
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired admin token')
}
if (payload.type !== 'admin') {
return sendUnauthorized(res, 'invalid_token', 'Invalid token type')
}
const admin = await prisma.adminUser.findUnique({ where: { id: payload.sub } })
if (!admin || !admin.isActive) {
return sendUnauthorized(res, 'unauthenticated', 'Admin account not found or deactivated')
}
if (!admin.totpEnabled && !is2faEnrollmentExempt(req)) {
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required before using privileged admin routes')
}
req.admin = admin
req.adminAuthLast2faAt = typeof payload.last2faAt === 'number' ? payload.last2faAt : undefined
next()
}
@@ -63,3 +77,18 @@ export function requireAdminRole(minimumRole: AdminRole) {
next()
}
}
export function requireFreshAdmin2FA(req: Request, res: Response, next: NextFunction) {
const admin = req.admin
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
if (!admin.totpEnabled) {
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required for this action')
}
const last2faAt = req.adminAuthLast2faAt
if (!last2faAt || Date.now() - last2faAt > FRESH_2FA_WINDOW_MS) {
return sendForbidden(res, 'fresh_2fa_required', 'Fresh admin 2FA verification is required for this action')
}
next()
}
@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
import { generateCompanyApiKey } from '../security/apiKeys'
vi.mock('../lib/prisma', () => ({
prisma: {
companyApiKey: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}))
import { prisma } from '../lib/prisma'
import { requireApiKey } from './requireApiKey'
function createResponseStub() {
const res = {
status: vi.fn(),
json: vi.fn(),
}
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireApiKey middleware', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('rejects requests with no x-api-key header', async () => {
const req = { headers: {} } as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({
error: 'missing_api_key',
message: 'API key required in x-api-key header',
statusCode: 401,
})
expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled()
expect(next).not.toHaveBeenCalled()
})
it('rejects malformed API keys before database lookup', async () => {
const req = { headers: { 'x-api-key': 'bad-key' } } as unknown as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled()
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({
error: 'invalid_api_key',
message: 'Invalid API key',
statusCode: 401,
})
expect(next).not.toHaveBeenCalled()
})
it('rejects unknown API key prefixes', async () => {
const generated = generateCompanyApiKey()
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue(null)
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect((prisma as any).companyApiKey.findUnique).toHaveBeenCalledWith({
where: { prefix: generated.prefix },
include: { company: true },
})
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({
error: 'invalid_api_key',
message: 'Invalid API key',
statusCode: 401,
})
expect(next).not.toHaveBeenCalled()
})
it('rejects revoked API keys', async () => {
const generated = generateCompanyApiKey()
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
id: 'key_1',
companyId: 'company_1',
prefix: generated.prefix,
keyHash: generated.keyHash,
revokedAt: new Date('2026-06-01T00:00:00.000Z'),
company: { id: 'company_1', name: 'Atlas Cars' },
})
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(next).not.toHaveBeenCalled()
})
it('rejects API keys whose secret does not match the stored hash', async () => {
const generated = generateCompanyApiKey()
const other = generateCompanyApiKey()
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
id: 'key_1',
companyId: 'company_1',
prefix: generated.prefix,
keyHash: other.keyHash,
revokedAt: null,
company: { id: 'company_1', name: 'Atlas Cars' },
})
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(next).not.toHaveBeenCalled()
})
it('attaches company context, updates lastUsedAt, and calls next for valid API keys', async () => {
const generated = generateCompanyApiKey()
const company = { id: 'company_1', name: 'Atlas Cars' }
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
id: 'key_1',
companyId: company.id,
prefix: generated.prefix,
keyHash: generated.keyHash,
revokedAt: null,
company,
})
vi.mocked((prisma as any).companyApiKey.update).mockResolvedValue({})
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect((prisma as any).companyApiKey.update).toHaveBeenCalledWith({
where: { id: 'key_1' },
data: { lastUsedAt: expect.any(Date) },
})
expect(req.company).toEqual(company)
expect(req.companyId).toBe('company_1')
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
expect(res.json).not.toHaveBeenCalled()
})
})
+20 -4
View File
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from 'express'
import { prisma } from '../lib/prisma'
import { getApiKeyPrefix, hashApiKey, timingSafeEqualHex } from '../security/apiKeys'
export async function requireApiKey(req: Request, res: Response, next: NextFunction) {
const apiKey = req.headers['x-api-key'] as string | undefined
@@ -8,12 +9,27 @@ export async function requireApiKey(req: Request, res: Response, next: NextFunct
return res.status(401).json({ error: 'missing_api_key', message: 'API key required in x-api-key header', statusCode: 401 })
}
const company = await prisma.company.findUnique({ where: { apiKey } })
if (!company) {
const prefix = getApiKeyPrefix(apiKey)
if (!prefix) {
return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 })
}
req.company = company
req.companyId = company.id
const keyRecord = await (prisma as any).companyApiKey.findUnique({
where: { prefix },
include: { company: true },
})
const hashed = hashApiKey(apiKey)
if (!keyRecord || keyRecord.revokedAt || !timingSafeEqualHex(hashed, keyRecord.keyHash)) {
return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 })
}
await (prisma as any).companyApiKey.update({
where: { id: keyRecord.id },
data: { lastUsedAt: new Date() },
})
req.company = keyRecord.company
req.companyId = keyRecord.companyId
next()
}
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
vi.mock('jsonwebtoken', () => ({
default: { verify: vi.fn() },
}))
vi.mock('../lib/prisma', () => ({
prisma: {
employee: { findUnique: vi.fn() },
},
}))
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { requireCompanyAuth, requireCompanyDocumentAuth } from './requireCompanyAuth'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireCompanyAuth middleware', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.JWT_SECRET = 'test-secret'
})
it('rejects missing bearer tokens', async () => {
const req = { headers: {} } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
expect(prisma.employee.findUnique).not.toHaveBeenCalled()
expect(next).not.toHaveBeenCalled()
})
it('rejects invalid tokens', async () => {
vi.mocked(jwt.verify).mockImplementation(() => { throw new Error('bad token') })
const req = { headers: { authorization: 'Bearer bad' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('rejects non-employee token types', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as any)
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 })
expect(prisma.employee.findUnique).not.toHaveBeenCalled()
})
it('rejects inactive or missing employees', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
vi.mocked(prisma.employee.findUnique).mockResolvedValue({ id: 'emp_1', isActive: false } as any)
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Employee account not found or inactive', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('attaches employee and company context for active employees', async () => {
const employee = { id: 'emp_1', companyId: 'company_1', isActive: true, company: { id: 'company_1', name: 'Atlas' } }
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee as any)
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyAuth(req, res, next)
expect(prisma.employee.findUnique).toHaveBeenCalledWith({ where: { id: 'emp_1' }, include: { company: true } })
expect(req.employee).toEqual(employee)
expect(req.company).toEqual(employee.company)
expect(req.companyId).toBe('company_1')
expect(next).toHaveBeenCalledTimes(1)
})
it('accepts employee_session cookies for document routes', async () => {
const employee = { id: 'emp_2', companyId: 'company_2', isActive: true, company: { id: 'company_2' } }
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_2', type: 'employee' } as any)
vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee as any)
const req = { headers: { cookie: 'employee_session=cookie-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyDocumentAuth(req, res, next)
expect(jwt.verify).toHaveBeenCalledWith('cookie-token', 'test-secret', {
algorithms: ['HS256'],
issuer: 'rentaldrivego-api',
audience: 'employee',
})
expect(req.companyId).toBe('company_2')
expect(next).toHaveBeenCalledTimes(1)
})
})
+9 -14
View File
@@ -1,7 +1,9 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { getCookie, sendUnauthorized } from './authHelpers'
import { getAuthToken, sendUnauthorized } from './authHelpers'
import { verifyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
import { actorLimiter } from './rateLimiter'
/**
* Validates a company-scoped Bearer token and loads the employee + company onto `req`.
@@ -11,25 +13,18 @@ import { getCookie, sendUnauthorized } from './authHelpers'
* req.company — the employee's company
* req.companyId — string shorthand for req.company.id
*/
async function authenticateCompanyRequest(req: Request, res: Response, next: NextFunction, allowCookie = false) {
const authHeader = req.headers.authorization
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
const cookieToken = allowCookie ? getCookie(req, 'employee_token') : null
const token = bearerToken ?? cookieToken
async function authenticateCompanyRequest(req: Request, res: Response, next: NextFunction) {
const token = getAuthToken(req, getSessionCookieName('employee'))
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
let payload: { sub: string; type: string }
try {
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
payload = verifyActorToken(token, 'employee')
} catch {
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired session token')
}
if (payload.type !== 'employee') {
return sendUnauthorized(res, 'invalid_token', 'Invalid token type for this endpoint')
}
const employee = await prisma.employee.findUnique({
where: { id: payload.sub },
include: { company: true },
@@ -42,7 +37,7 @@ async function authenticateCompanyRequest(req: Request, res: Response, next: Nex
req.employee = employee
req.company = employee.company
req.companyId = employee.companyId
next()
return actorLimiter(req, res, next)
}
export async function requireCompanyAuth(req: Request, res: Response, next: NextFunction) {
@@ -50,5 +45,5 @@ export async function requireCompanyAuth(req: Request, res: Response, next: Next
}
export async function requireCompanyDocumentAuth(req: Request, res: Response, next: NextFunction) {
return authenticateCompanyRequest(req, res, next, true)
return authenticateCompanyRequest(req, res, next)
}
@@ -0,0 +1,22 @@
import type { Request, Response, NextFunction } from 'express'
import { sendForbidden, sendUnauthorized } from './authHelpers'
import type { EmployeeRole } from '@rentaldrivego/database'
import { CompanyPolicy, type CompanyPolicyAction } from '../security/policies/companyPolicy'
export function requireCompanyPolicy(action: CompanyPolicyAction) {
return (req: Request, res: Response, next: NextFunction) => {
const employee = req.employee
if (!employee) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
const allowedRoles: readonly EmployeeRole[] = CompanyPolicy[action]
if (!allowedRoles.includes(employee.role)) {
return sendForbidden(res, 'forbidden', 'You do not have permission to perform this action', {
action,
allowedRoles,
yourRole: employee.role,
})
}
next()
}
}
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
vi.mock('jsonwebtoken', () => ({
default: { verify: vi.fn() },
}))
vi.mock('../lib/prisma', () => ({
prisma: {
renter: { findUnique: vi.fn() },
},
}))
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { optionalRenterAuth, requireRenterAuth } from './requireRenterAuth'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireRenterAuth middleware', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.JWT_SECRET = 'test-secret'
})
it('rejects missing bearer tokens', async () => {
const req = { headers: {} } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireRenterAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Renter authentication required', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('rejects invalid tokens and wrong token types', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireRenterAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired token', statusCode: 401 })
expect(prisma.renter.findUnique).not.toHaveBeenCalled()
})
it('rejects inactive renters', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as any)
vi.mocked(prisma.renter.findUnique).mockResolvedValue({ id: 'renter_1', isActive: false } as any)
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireRenterAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Renter account not found or inactive', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('attaches renterId for active renters', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as any)
vi.mocked(prisma.renter.findUnique).mockResolvedValue({ id: 'renter_1', isActive: true } as any)
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireRenterAuth(req, res, next)
expect(req.renterId).toBe('renter_1')
expect(next).toHaveBeenCalledTimes(1)
})
it('optional auth never blocks missing or invalid tokens', async () => {
const noTokenReq = { headers: {} } as Request
const invalidReq = { headers: { authorization: 'Bearer bad' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
vi.mocked(jwt.verify).mockImplementation(() => { throw new Error('bad token') })
await optionalRenterAuth(noTokenReq, res, next)
await optionalRenterAuth(invalidReq, res, next)
expect(next).toHaveBeenCalledTimes(2)
})
it('optional auth attaches renterId only for renter tokens', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_2', type: 'renter' } as any)
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await optionalRenterAuth(req, res, next)
expect(req.renterId).toBe('renter_2')
expect(next).toHaveBeenCalledTimes(1)
})
})
+10 -14
View File
@@ -1,7 +1,9 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { sendUnauthorized } from './authHelpers'
import { getAuthToken, sendUnauthorized } from './authHelpers'
import { verifyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
import { actorLimiter } from './rateLimiter'
/**
* Requires a valid renter Bearer token.
@@ -10,29 +12,24 @@ import { sendUnauthorized } from './authHelpers'
* req.renterId — the authenticated renter's id
*/
export async function requireRenterAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
const token = getAuthToken(req, getSessionCookieName('renter'))
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Renter authentication required')
let payload: { sub: string; type: string }
try {
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
payload = verifyActorToken(token, 'renter')
} catch {
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired token')
}
if (payload.type !== 'renter') {
return sendUnauthorized(res, 'invalid_token', 'Invalid token type')
}
const renter = await prisma.renter.findUnique({ where: { id: payload.sub } })
if (!renter || !renter.isActive) {
return sendUnauthorized(res, 'unauthenticated', 'Renter account not found or inactive')
}
req.renterId = renter.id
next()
return actorLimiter(req, res, next)
}
/**
@@ -43,13 +40,12 @@ export async function requireRenterAuth(req: Request, res: Response, next: NextF
* req.renterId — the authenticated renter's id (if token is valid)
*/
export async function optionalRenterAuth(req: Request, _res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
const token = getAuthToken(req, getSessionCookieName('renter'))
if (!token) return next()
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type === 'renter') req.renterId = payload.sub
const payload = verifyActorToken(token, 'renter')
req.renterId = payload.sub
} catch {
// Optional — silently ignore invalid tokens
}
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
import { requireRole } from './requireRole'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireRole middleware', () => {
it('rejects requests when company auth did not attach an employee', () => {
const req = {} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireRole('AGENT' as any)(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('rejects employees below the required role and includes role context', () => {
const req = { employee: { role: 'AGENT' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireRole('MANAGER' as any)(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'forbidden',
message: 'This action requires the MANAGER role or higher',
statusCode: 403,
requiredRole: 'MANAGER',
yourRole: 'AGENT',
})
expect(next).not.toHaveBeenCalled()
})
it('allows employees at or above the required role', () => {
const req = { employee: { role: 'OWNER' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireRole('MANAGER' as any)(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
import { requireSubscription } from './requireSubscription'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireSubscription middleware', () => {
beforeEach(() => {
process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test'
})
it('requires tenant/company context first', () => {
const req = {} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'No company context', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('blocks suspended companies with a billing URL', () => {
const req = { company: { status: 'SUSPENDED' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith({
error: 'subscription_suspended',
message: 'Your account has been suspended. Please contact support or renew your subscription.',
statusCode: 402,
billingUrl: 'https://dashboard.example.test/billing',
})
expect(next).not.toHaveBeenCalled()
})
it('blocks pending companies with setup guidance', () => {
const req = { company: { status: 'PENDING' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
error: 'subscription_pending',
message: 'Your account is pending activation. Please complete your subscription setup.',
}))
expect(next).not.toHaveBeenCalled()
})
it('allows active companies through', () => {
const req = { company: { status: 'ACTIVE' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireSubscription(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
vi.mock('../lib/prisma', () => ({
prisma: {
company: { findUnique: vi.fn() },
},
}))
import { prisma } from '../lib/prisma'
import { requireTenant } from './requireTenant'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireTenant middleware', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fails fast when company auth did not set companyId', async () => {
const req = {} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireTenant(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({
error: 'unauthenticated',
message: 'Tenant context missing — requireCompanyAuth must run first',
statusCode: 401,
})
expect(prisma.company.findUnique).not.toHaveBeenCalled()
})
it('rejects company ids that no longer exist', async () => {
vi.mocked(prisma.company.findUnique).mockResolvedValue(null)
const req = { companyId: 'company_missing' } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireTenant(req, res, next)
expect(prisma.company.findUnique).toHaveBeenCalledWith({ where: { id: 'company_missing' } })
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'company_not_found', message: 'Company not found', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('attaches the fresh company record and continues', async () => {
const company = { id: 'company_1', status: 'ACTIVE' }
vi.mocked(prisma.company.findUnique).mockResolvedValue(company as any)
const req = { companyId: 'company_1' } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireTenant(req, res, next)
expect(req.company).toEqual(company)
expect(next).toHaveBeenCalledTimes(1)
})
})