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 {