Files
dashback/src/api/middleware/auth.js
2026-10-31 21:02:53 +01:00

144 lines
4.6 KiB
JavaScript

import jwt from 'jsonwebtoken';
import env from '../../config/env.js';
import * as db from '../../db/googleAuth.js';
import { respondWithAuthenticatedUser } from '../auth/meJson.js';
const userCache = new Map();
const USER_CACHE_TTL = 10 * 60 * 1000;
const TOKEN_COOKIE = {
httpOnly: true,
secure: true,
sameSite: 'strict',
};
export function clearTokenCookie(res) {
res.clearCookie('token', TOKEN_COOKIE);
}
export function getCachedUser(authId) {
const cached = userCache.get(authId);
if (cached && cached.expiresAt > Date.now()) {
return cached.user;
}
userCache.delete(authId);
return null;
}
export function setCachedUser(authId, user) {
userCache.set(authId, { user, expiresAt: Date.now() + USER_CACHE_TTL });
if (userCache.size > 1000) {
const now = Date.now();
for (const [k, v] of userCache) {
if (v.expiresAt <= now) userCache.delete(k);
}
}
}
export function clearCachedUser(authId) {
userCache.delete(authId);
}
export function clearCachedCompanyUsers(companyId) {
for (const [k, v] of userCache) {
if (v.user?.selectedCompanyId === companyId) {
userCache.delete(k);
}
}
}
async function respondWithAuthPayload(res, req, status, body) {
if (req.path === '/api/auth/me') {
return respondWithAuthenticatedUser(res, status, body);
}
return res.status(status).json(body);
}
export const authMiddleware = async (req, res, next) => {
if (req.path === '/' || req.path === '/api/auth/login' || req.path === '/api/auth/logout' || req.path === '/api/health' || req.path === '/health' || req.path === '/api/openapi.json') {
return next();
}
if (req.path.startsWith('/v1') || req.path.startsWith('/api/v1')) {
return next();
}
if (req.path === '/api/agent' || req.path.startsWith('/api/agent/')) {
return next();
}
if (req.path.startsWith('/api/public') || req.path.startsWith('/public')) {
return next();
}
if (req.path.startsWith('/@') || req.path.startsWith('/node_modules') || req.path.startsWith('/src') || req.path === '/favicon.ico') {
return next();
}
if (req.path === '/mcp' || req.path.startsWith('/.well-known') || req.path === '/oauth/register' || req.path === '/oauth/token' || req.path === '/oauth/introspect') {
return next();
}
const isOAuthAuthorize = req.path === '/oauth/authorize';
const bearerToken = req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice(7).trim()
: null;
const token = req.cookies.token || bearerToken;
const isDocs = req.path.startsWith('/docs') || req.path.startsWith('/api/docs');
if (!token) {
if (isDocs || isOAuthAuthorize) return next();
return await respondWithAuthPayload(res, req, 401, { error: 'Unauthorized' });
}
try {
const decoded = jwt.verify(token, env.jwtSecret);
const authId = Number(decoded.id);
if (!Number.isInteger(authId) || authId <= 0) {
if (req.cookies.token) clearTokenCookie(res);
if (isDocs) return next();
return await respondWithAuthPayload(res, req, 401, { error: 'Invalid token' });
}
let user = getCachedUser(authId);
if (!user) {
const row = await db.getById(authId);
if (row) {
// Do not cache pictureBinary — large and only needed for /auth/me.
const { pictureBinary: _pictureBinary, ...rest } = row;
user = rest;
setCachedUser(authId, user);
}
}
if (!user) {
clearCachedUser(authId);
if (req.cookies.token) clearTokenCookie(res);
if (isDocs) return next();
return await respondWithAuthPayload(res, req, 403, { error: 'User is no longer authorized' });
}
req.user = {
id: user.id,
email: user.email,
selectedCompanyId: user.selectedCompanyId,
};
const now = Math.floor(Date.now() / 1000);
if (req.cookies.token && decoded.exp - now < 2 * 60 * 60) {
const newToken = jwt.sign({ id: user.id }, env.jwtSecret, { expiresIn: '1d' });
res.cookie('token', newToken, {
...TOKEN_COOKIE,
maxAge: 24 * 60 * 60 * 1000,
});
}
next();
} catch {
if (req.cookies.token) clearTokenCookie(res);
if (isDocs || isOAuthAuthorize) return next();
return await respondWithAuthPayload(res, req, 401, { error: 'Invalid token' });
}
};