/** * Autenticación servicio-a-servicio por token Bearer para los endpoints que * consume CloudRestoreAS. Separado del JWT de usuarios (cookie) porque el cliente * es una máquina, no un navegador. * * NOTA DE SEGURIDAD: estos endpoints entregan credenciales SQL. En producción * deben servirse solo sobre transporte cifrado (TLS/SSH/VPN) — ver plan, G5. */ import { timingSafeEqual } from 'node:crypto'; import { env } from '$env/dynamic/private'; import { logger } from './logger'; /** * Valida el header Authorization: Bearer contra CLOUDRESTORE_API_TOKEN. * Devuelve null si es válido, o el código HTTP a responder (401/500) si no. * Comparación en tiempo constante para no filtrar el token por timing. */ export function checkServiceToken(request: Request): { ok: true } | { ok: false; status: 401 | 500 } { const expected = env.CLOUDRESTORE_API_TOKEN; if (!expected || !expected.trim()) { // Sin token configurado no se puede autenticar de forma segura: se rechaza. return { ok: false, status: 500 }; } const header = request.headers.get('authorization') ?? ''; const match = header.match(/^Bearer\s+(.+)$/i); if (!match) { return { ok: false, status: 401 }; } const provided = match[1].trim(); const expectedBuf = Buffer.from(expected.trim(), 'utf8'); const providedBuf = Buffer.from(provided, 'utf8'); if (expectedBuf.length !== providedBuf.length) { return { ok: false, status: 401 }; } if (!timingSafeEqual(expectedBuf, providedBuf)) { return { ok: false, status: 401 }; } return { ok: true }; } /** * Registra un rechazo de token de servicio. * * Hasta ahora un 401 no dejaba **ni una línea** en el panel: solo se logueaba el 500 de "token no * configurado". Con el token rotado, todos los agentes quedan mudos —dejan de reportar versión, de * resolver rutas y de registrar resultados— y desde el panel eso se ve igual que un agente * apagado. Peor: el `trace_id` que se le devuelve al agente en el cuerpo del 401 no existía del * lado del servidor, así que era imposible de correlacionar. * * NUNCA se registra el token ni un fragmento suyo. Lo que sirve para diagnosticar es qué ruta se * rechazó, si venía con header o sin él, y de qué instancia. */ export function logServiceTokenRejected(info: { traceId: string; route: string; request: Request; instanceKey?: string | null; }): void { const header = info.request.headers.get('authorization') ?? ''; logger.warn({ trace_id: info.traceId, message: 'Token de servicio rechazado', context: { route: info.route, // Distinguir "no mandó header" de "mandó uno que no cuadra" separa un agente sin // configurar de un token rotado, que son dos problemas con remedios distintos. reason: header ? 'token no coincide' : 'sin header Authorization', instance_key: info.instanceKey?.trim() || null } }); }