Reviewed-on: #22 Co-authored-by: hreyes <hreyes@aduanasoft.com.mx> Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
74 lines
3.0 KiB
TypeScript
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
|
|
}
|
|
});
|
|
}
|