Files
PANEL_BASES_ANEXO24/src/lib/server/service-auth.ts
hreyes c4de2f1438 feature/cras-update-in-place (#22)
Reviewed-on: #22
Co-authored-by: hreyes <hreyes@aduanasoft.com.mx>
Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
2026-07-31 13:28:45 +00:00

74 lines
3.0 KiB
TypeScript

/**
* 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 <token> 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
}
});
}