/** * Middleware de autenticação para a External API. * * Aceita a integration_key via: * - Header: x-nw-key: nw_... * * Resolve a chave para tenantId e injeta em req.extTenantId. * Rate-limiting básico: max 120 req/min por chave (janela deslizante simples em memória). */ import type { Request, Response, NextFunction } from 'express' import type { PrismaClient } from '@prisma/client' // ─── Cache de API key (evita Prisma query em cada requisição) ──────────────── // Chaves são imutáveis durante a vida da sessão — TTL de 5 min é suficiente. const KEY_CACHE_TTL = 5 * 60_000 interface KeyCacheEntry { tenantId: string; expiresAt: Date | null; cachedAt: number } const keyCache = new Map() setInterval(() => { const now = Date.now() for (const [k, v] of keyCache.entries()) { if (now - v.cachedAt > KEY_CACHE_TTL) keyCache.delete(k) } }, KEY_CACHE_TTL) // ─── Rate limiter simples em memória (por chave) ───────────────────────────── const RATE_WINDOW_MS = 60_000 const RATE_MAX = 120 interface RateEntry { count: number; resetAt: number } const rateMap = new Map() function checkRateLimit(key: string): boolean { const now = Date.now() let entry = rateMap.get(key) if (!entry || now > entry.resetAt) { entry = { count: 1, resetAt: now + RATE_WINDOW_MS } rateMap.set(key, entry) return true } entry.count++ return entry.count <= RATE_MAX } // Limpeza periódica para não vazar memória setInterval(() => { const now = Date.now() for (const [k, v] of rateMap.entries()) { if (now > v.resetAt) rateMap.delete(k) } }, RATE_WINDOW_MS * 2) // ─── Extend Express Request ─────────────────────────────────────────────────── declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Express { interface Request { extTenantId?: string } } } // ─── Factory ───────────────────────────────────────────────────────────────── export function buildApiKeyAuth(prisma: PrismaClient) { return async function apiKeyAuth(req: Request, res: Response, next: NextFunction) { const key = (req.headers['x-nw-key'] as string | undefined)?.trim() if (!key) { res.status(401).json({ error: 'Header x-nw-key ausente' }) return } if (!checkRateLimit(key)) { res.status(429).json({ error: 'Rate limit excedido (120 req/min por chave)' }) return } // ── Chave MESTRA + x-nw-email: integração multi-conta por email ──────────── // Permite que um satélite confiável acesse a conta de QUALQUER email (resolve // o tenant pelo User.email). O satélite deve enviar SEMPRE o email do usuário // autenticado dele — nunca um email arbitrário do cliente. if (isMasterKey(key)) { const email = (req.headers['x-nw-email'] as string | undefined)?.trim().toLowerCase() if (!email) { res.status(400).json({ error: 'x-nw-email ausente (chave mestra)' }); return } const tenantId = await resolveMasterEmail(prisma, email) if (!tenantId) { res.status(404).json({ error: `Conta não encontrada no motor para ${email}` }); return } req.extTenantId = tenantId next(); return } // ── Chave de SATÉLITE (PluginPair) ───────────────────────────────────────── // Cada satélite registrado tem sua própria integration_key (nwk_...). Resolve // o tenant dono do par; honra revoke e atualiza lastSeenAt. É o registry — e // não uma única chave mestra global — autenticando o satélite. if (key.startsWith('nwk_')) { const owner = await resolvePairKey(prisma, key) if (!owner) { res.status(401).json({ error: 'Chave de satélite inválida ou revogada' }); return } // Satélite confiável: com x-nw-email resolve por email (preserva multi-conta, // ex.: cada usuário do scoreodonto vê o WhatsApp do seu e-mail); sem email, // cai no tenant dono do par. const email = (req.headers['x-nw-email'] as string | undefined)?.trim().toLowerCase() if (email) { const tenantId = await resolveMasterEmail(prisma, email) if (!tenantId) { res.status(404).json({ error: `Conta não encontrada no motor para ${email}` }); return } req.extTenantId = tenantId next(); return } req.extTenantId = owner next(); return } // Verifica cache antes de ir ao banco const cached = keyCache.get(key) if (cached && Date.now() - cached.cachedAt < KEY_CACHE_TTL) { if (cached.expiresAt && cached.expiresAt < new Date()) { res.status(401).json({ error: 'Chave expirada' }) return } req.extTenantId = cached.tenantId next() return } const apiKey = await prisma.apiKey.findUnique({ where: { key }, select: { tenantId: true, isActive: true, expiresAt: true }, }) if (!apiKey || !apiKey.isActive) { res.status(401).json({ error: 'Chave inválida ou inativa' }) return } if (apiKey.expiresAt && apiKey.expiresAt < new Date()) { res.status(401).json({ error: 'Chave expirada' }) return } keyCache.set(key, { tenantId: apiKey.tenantId, expiresAt: apiKey.expiresAt, cachedAt: Date.now() }) req.extTenantId = apiKey.tenantId next() } } /** * Resolve uma chave de SATÉLITE (PluginPair, nwk_...) para o tenantId dono do par. * Retorna null se não existir, estiver revogada ou a conta inativa. Atualiza * lastSeenAt em background e usa o mesmo cache das demais chaves. */ // Throttle das escritas de lastSeenAt (não cacheia a DECISÃO de auth — a checagem // de revoke bate no banco a cada request, garantindo revoke instantâneo). const pairTouch = new Map() export async function resolvePairKey(prisma: PrismaClient, key: string): Promise { if (!key || !key.startsWith('nwk_')) return null const pair = await prisma.pluginPair.findUnique({ where: { integrationKey: key }, select: { userId: true, revokedAt: true, user: { select: { isActive: true } } }, }) if (!pair || pair.revokedAt || !pair.user.isActive) return null const now = Date.now() if (now - (pairTouch.get(key) ?? 0) > 60_000) { pairTouch.set(key, now) prisma.pluginPair.update({ where: { integrationKey: key }, data: { lastSeenAt: new Date() } }).catch(() => {}) } return pair.userId } /** * Indica se a chave recebida é a chave MESTRA de integração (EXT_MASTER_KEY). * A resolução do tenant, nesse caso, é feita por email (x-nw-email) — ver * resolveMasterEmail — nunca pela tabela apiKey. */ export function isMasterKey(key: string): boolean { const MASTER = process.env.EXT_MASTER_KEY return Boolean(MASTER && key === MASTER) } /** * Resolve o tenant a partir do email (fluxo da chave mestra). Compartilhado * entre o middleware REST e o handshake do WS. Retorna null se o email não * tiver conta ativa no motor. Usa o mesmo cache (prefixo master:). */ export async function resolveMasterEmail( prisma: PrismaClient, email: string | undefined | null, ): Promise { const e = email?.trim().toLowerCase() if (!e) return null const cacheK = `master:${e}` const c = keyCache.get(cacheK) if (c && Date.now() - c.cachedAt < KEY_CACHE_TTL) return c.tenantId const user = await prisma.user.findUnique({ where: { email: e }, select: { id: true, isActive: true } }) if (!user || !user.isActive) return null keyCache.set(cacheK, { tenantId: user.id, expiresAt: null, cachedAt: Date.now() }) return user.id } /** * Versão standalone que valida a chave sem passar pelo Express. * Usada no handshake do WS (upgrade request). */ export async function resolveApiKey( prisma: PrismaClient, key: string, ): Promise { if (!key) return null // Chave de satélite (PluginPair) — resolve pelo registry. if (key.startsWith('nwk_')) return resolvePairKey(prisma, key) const cached = keyCache.get(key) if (cached && Date.now() - cached.cachedAt < KEY_CACHE_TTL) { if (cached.expiresAt && cached.expiresAt < new Date()) return null return cached.tenantId } const apiKey = await prisma.apiKey.findUnique({ where: { key }, select: { tenantId: true, isActive: true, expiresAt: true }, }) if (!apiKey || !apiKey.isActive) return null if (apiKey.expiresAt && apiKey.expiresAt < new Date()) return null keyCache.set(key, { tenantId: apiKey.tenantId, expiresAt: apiKey.expiresAt, cachedAt: Date.now() }) return apiKey.tenantId }