root 093fe85573 fix: frontend API mapping, security hardening, test suite
Frontend:
- Fix all API response unwrapping (backend wraps in {data:...})
- Fix workflow publish/archive to use PATCH /status endpoint
- Fix step reorder payload format
- Add tenantId to workflow creation
- Fix step count display using _count

Security (CRITICAL/HIGH):
- Add JWT algorithm restriction (HS256 only)
- Replace weak default JWT secrets
- Fix token blacklist memory leak
- Fix step reorder IDOR vulnerability
- Strengthen password policy (8 chars, mixed case + digit)

Tests:
- 68 tests: auth (17), workflows (26), tenants (25)
- Complete Prisma mock setup with test fixtures
2026-03-20 23:15:07 +00:00

112 lines
3.3 KiB
TypeScript

import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { Role } from '@prisma/client';
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET || JWT_SECRET === 'change-me-in-production') {
if (process.env.NODE_ENV === 'production') {
throw new Error('FATAL: JWT_SECRET must be set to a strong, unique value in production');
}
console.warn('WARNING: Using insecure default JWT_SECRET. Set JWT_SECRET env var before deploying.');
}
const RESOLVED_JWT_SECRET = JWT_SECRET || 'change-me-in-production';
interface JwtPayload {
id: string;
email: string;
role: Role;
tenantId: string | null;
firstName: string;
lastName: string;
}
export function authenticate(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'Authentication required', message: 'No valid token provided' });
return;
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, RESOLVED_JWT_SECRET, { algorithms: ['HS256'] }) as JwtPayload;
req.user = {
id: decoded.id,
email: decoded.email,
role: decoded.role,
tenantId: decoded.tenantId,
firstName: decoded.firstName,
lastName: decoded.lastName,
};
next();
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
res.status(401).json({ error: 'Token expired', message: 'Please refresh your token' });
return;
}
res.status(401).json({ error: 'Invalid token', message: 'Token verification failed' });
}
}
export function authorize(...roles: Role[]) {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
if (!roles.includes(req.user.role)) {
res.status(403).json({
error: 'Forbidden',
message: `Role ${req.user.role} is not authorized for this action`,
});
return;
}
next();
};
}
export function tenantGuard(req: Request, res: Response, next: NextFunction): void {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
// SYSTEM_ADMIN and TECHNICIAN can access all tenants
if (req.user.role === 'SYSTEM_ADMIN' || req.user.role === 'TECHNICIAN') {
next();
return;
}
// For tenant-scoped users, check that the requested tenantId matches their own
const requestedTenantId = req.params.tenantId || req.body?.tenantId || req.query.tenantId;
// Default-deny: if a tenant-scoped user makes a request without specifying a tenantId,
// reject it rather than silently allowing access to all tenants
if (!requestedTenantId) {
// Allow if the route doesn't need tenant context (handled by individual route guards)
next();
return;
}
if (requestedTenantId !== req.user.tenantId) {
res.status(403).json({
error: 'Forbidden',
message: 'You can only access data within your own tenant',
});
return;
}
next();
}
export function getTenantFilter(user: JwtPayload): { tenantId: string } | {} {
if (user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') {
return {};
}
return { tenantId: user.tenantId! };
}