Files
alrahma_sunday_school_api/docs/security_hardening_incremental.diff
T
2026-06-11 03:22:12 -04:00

343 lines
14 KiB
Diff

diff -ru '--exclude=node_modules' car_project_original/apps/api/src/index.ts car_project_work/apps/api/src/index.ts
--- car_project_original/apps/api/src/index.ts 2026-06-09 19:40:36.000000000 +0000
+++ car_project_work/apps/api/src/index.ts 2026-06-09 23:18:30.521017321 +0000
@@ -1,11 +1,11 @@
import http from 'http'
import { Server as SocketIOServer } from 'socket.io'
import cron from 'node-cron'
-import jwt from 'jsonwebtoken'
import { redis } from './lib/redis'
import { prisma } from './lib/prisma'
import { assertStorageConfiguration } from './lib/storage'
import { createApp, corsOrigins } from './app'
+import { verifyAnyActorToken } from './security/tokens'
import { sendNotification } from './services/notificationService'
import {
runTrialExpirationJob,
@@ -29,7 +29,7 @@
const token = socket.handshake.auth?.token as string | undefined
if (!token) return next() // unauthenticated connections allowed; they just don't join rooms
try {
- const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
+ const payload = verifyAnyActorToken(token)
;(socket as any).authenticatedUserId = payload.sub
next()
} catch {
diff -ru '--exclude=node_modules' car_project_original/apps/api/src/middleware/requireApiKey.test.ts car_project_work/apps/api/src/middleware/requireApiKey.test.ts
--- car_project_original/apps/api/src/middleware/requireApiKey.test.ts 2026-06-09 19:40:36.000000000 +0000
+++ car_project_work/apps/api/src/middleware/requireApiKey.test.ts 2026-06-09 23:18:42.795559504 +0000
@@ -1,10 +1,12 @@
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: {
- company: {
+ companyApiKey: {
findUnique: vi.fn(),
+ update: vi.fn(),
},
},
}))
@@ -42,19 +44,18 @@
message: 'API key required in x-api-key header',
statusCode: 401,
})
- expect(prisma.company.findUnique).not.toHaveBeenCalled()
+ expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled()
expect(next).not.toHaveBeenCalled()
})
- it('rejects unknown API keys', async () => {
- vi.mocked(prisma.company.findUnique).mockResolvedValue(null)
+ 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.company.findUnique).toHaveBeenCalledWith({ where: { apiKey: 'bad-key' } })
+ expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled()
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({
error: 'invalid_api_key',
@@ -64,15 +65,91 @@
expect(next).not.toHaveBeenCalled()
})
- it('attaches company context and calls next for valid API keys', async () => {
- const company = { id: 'company_1', apiKey: 'valid-key', name: 'Atlas Cars' }
- vi.mocked(prisma.company.findUnique).mockResolvedValue(company as any)
- const req = { headers: { 'x-api-key': 'valid-key' } } as unknown as Request
+ 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)
diff -ru '--exclude=node_modules' car_project_original/apps/api/src/middleware/requireApiKey.ts car_project_work/apps/api/src/middleware/requireApiKey.ts
--- car_project_original/apps/api/src/middleware/requireApiKey.ts 2026-06-09 19:44:15.000000000 +0000
+++ car_project_work/apps/api/src/middleware/requireApiKey.ts 2026-06-09 23:18:30.518795557 +0000
@@ -21,14 +21,6 @@
const hashed = hashApiKey(apiKey)
if (!keyRecord || keyRecord.revokedAt || !timingSafeEqualHex(hashed, keyRecord.keyHash)) {
- if (process.env.ALLOW_LEGACY_COMPANY_API_KEYS === 'true') {
- const company = await prisma.company.findUnique({ where: { apiKey } })
- if (company) {
- req.company = company
- req.companyId = company.id
- return next()
- }
- }
return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 })
}
diff -ru '--exclude=node_modules' car_project_original/apps/api/src/middleware/requireCompanyAuth.test.ts car_project_work/apps/api/src/middleware/requireCompanyAuth.test.ts
--- car_project_original/apps/api/src/middleware/requireCompanyAuth.test.ts 2026-06-09 20:03:48.000000000 +0000
+++ car_project_work/apps/api/src/middleware/requireCompanyAuth.test.ts 2026-06-09 23:18:50.351586123 +0000
@@ -63,7 +63,7 @@
await requireCompanyAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
- expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid token type for this endpoint', statusCode: 401 })
+ expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 })
expect(prisma.employee.findUnique).not.toHaveBeenCalled()
})
@@ -108,7 +108,11 @@
await requireCompanyDocumentAuth(req, res, next)
- expect(jwt.verify).toHaveBeenCalledWith('cookie-token', 'test-secret')
+ 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)
})
diff -ru '--exclude=node_modules' car_project_original/apps/api/src/middleware/requireRenterAuth.test.ts car_project_work/apps/api/src/middleware/requireRenterAuth.test.ts
--- car_project_original/apps/api/src/middleware/requireRenterAuth.test.ts 2026-06-09 19:40:36.000000000 +0000
+++ car_project_work/apps/api/src/middleware/requireRenterAuth.test.ts 2026-06-09 23:18:50.352660214 +0000
@@ -49,7 +49,7 @@
await requireRenterAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
- expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid token type', statusCode: 401 })
+ expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired token', statusCode: 401 })
expect(prisma.renter.findUnique).not.toHaveBeenCalled()
})
diff -ru '--exclude=node_modules' car_project_original/apps/api/src/modules/auth/auth.employee.service.ts car_project_work/apps/api/src/modules/auth/auth.employee.service.ts
--- car_project_original/apps/api/src/modules/auth/auth.employee.service.ts 2026-06-09 19:42:41.000000000 +0000
+++ car_project_work/apps/api/src/modules/auth/auth.employee.service.ts 2026-06-09 23:19:29.323222951 +0000
@@ -41,13 +41,22 @@
pwdv: getEmployeePasswordResetVersion(passwordHash),
},
process.env.JWT_SECRET!,
- { expiresIn: `${RESET_TOKEN_TTL_MINUTES}m` },
+ {
+ algorithm: 'HS256',
+ issuer: 'rentaldrivego-api',
+ audience: 'employee_password_reset',
+ expiresIn: `${RESET_TOKEN_TTL_MINUTES}m`,
+ },
)
}
function verifyEmployeePasswordResetToken(token: string): EmployeePasswordResetPayload | null {
try {
- const payload = jwt.verify(token, process.env.JWT_SECRET!) as jwt.JwtPayload
+ const payload = jwt.verify(token, process.env.JWT_SECRET!, {
+ algorithms: ['HS256'],
+ issuer: 'rentaldrivego-api',
+ audience: 'employee_password_reset',
+ }) as jwt.JwtPayload
if (
typeof payload.sub !== 'string' ||
diff -ru '--exclude=node_modules' car_project_original/apps/api/src/security/tokens.ts car_project_work/apps/api/src/security/tokens.ts
--- car_project_original/apps/api/src/security/tokens.ts 2026-06-09 20:20:10.000000000 +0000
+++ car_project_work/apps/api/src/security/tokens.ts 2026-06-09 23:18:30.519590128 +0000
@@ -54,3 +54,14 @@
return payload as ActorTokenPayload
}
+
+export function verifyAnyActorToken(token: string): ActorTokenPayload {
+ const decoded = jwt.decode(token) as jwt.JwtPayload | null
+ const actorType = decoded?.type
+
+ if (actorType !== 'admin' && actorType !== 'employee' && actorType !== 'renter') {
+ throw new Error('Invalid actor token')
+ }
+
+ return verifyActorToken(token, actorType)
+}
diff -ru '--exclude=node_modules' car_project_original/apps/api/src/tests/helpers/fixtures.ts car_project_work/apps/api/src/tests/helpers/fixtures.ts
--- car_project_original/apps/api/src/tests/helpers/fixtures.ts 2026-06-09 19:40:36.000000000 +0000
+++ car_project_work/apps/api/src/tests/helpers/fixtures.ts 2026-06-09 23:19:11.289635569 +0000
@@ -1,8 +1,6 @@
import bcrypt from 'bcryptjs'
-import jwt from 'jsonwebtoken'
import { prisma } from '../../lib/prisma'
-
-const JWT_SECRET = process.env.JWT_SECRET ?? 'test-secret'
+import { signActorToken } from '../../security/tokens'
let counter = 0
function uid() {
@@ -215,28 +213,16 @@
})
}
-export function signEmployeeToken(employeeId: string, companyId: string, role: string = 'OWNER') {
- return jwt.sign(
- { sub: employeeId, companyId, role, type: 'employee' },
- JWT_SECRET,
- { expiresIn: '1h' },
- )
+export function signEmployeeToken(employeeId: string, _companyId: string, _role: string = 'OWNER') {
+ return signActorToken(employeeId, 'employee', { expiresIn: '1h' })
}
export function signAdminToken(adminId: string) {
- return jwt.sign(
- { sub: adminId, type: 'admin' },
- JWT_SECRET,
- { expiresIn: '1h' },
- )
+ return signActorToken(adminId, 'admin', { expiresIn: '1h', last2faAt: Date.now() })
}
export function signRenterToken(renterId: string) {
- return jwt.sign(
- { sub: renterId, type: 'renter' },
- JWT_SECRET,
- { expiresIn: '1h' },
- )
+ return signActorToken(renterId, 'renter', { expiresIn: '1h' })
}
export function authHeader(token: string) {
Only in car_project_work/packages/database/prisma/migrations: 20260609233000_drop_legacy_company_api_key
diff -ru '--exclude=node_modules' car_project_original/packages/database/prisma/schema.prisma car_project_work/packages/database/prisma/schema.prisma
--- car_project_original/packages/database/prisma/schema.prisma 2026-06-09 20:18:52.000000000 +0000
+++ car_project_work/packages/database/prisma/schema.prisma 2026-06-09 23:18:30.516362264 +0000
@@ -502,7 +502,6 @@
address Json?
status CompanyStatus @default(PENDING)
subscriptionPaymentRef String?
- apiKey String @unique @default(cuid())
subscription Subscription?
billingAccounts BillingAccount[]
diff -ru '--exclude=node_modules' car_project_original/packages/database/src/index.d.ts car_project_work/packages/database/src/index.d.ts
--- car_project_original/packages/database/src/index.d.ts 2026-06-09 19:40:36.000000000 +0000
+++ car_project_work/packages/database/src/index.d.ts 2026-06-09 23:18:30.517981475 +0000
@@ -33,7 +33,6 @@
email: string
phone: string | null
status: string
- apiKey: string
}
export interface Employee {
diff -ru '--exclude=node_modules' car_project_original/packages/database/src/index.ts car_project_work/packages/database/src/index.ts
--- car_project_original/packages/database/src/index.ts 2026-06-09 19:40:36.000000000 +0000
+++ car_project_work/packages/database/src/index.ts 2026-06-09 23:18:30.517228009 +0000
@@ -33,7 +33,6 @@
email: string
phone: string | null
status: string
- apiKey: string
}
export interface Employee {