import { XMLParser, XMLBuilder } from 'fast-xml-parser'; import { createHash, randomUUID } from 'crypto'; import type { Credential } from '@nodecfdi/credentials/node'; const SAT_AUTH_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc'; interface SatToken { token: string; expiresAt: Date; } /** * Genera el timestamp para la solicitud SOAP */ function createTimestamp(): { created: string; expires: string } { const now = new Date(); const created = now.toISOString(); const expires = new Date(now.getTime() + 5 * 60 * 1000).toISOString(); // 5 minutos return { created, expires }; } /** * Construye el XML de solicitud de autenticación */ function buildAuthRequest(credential: Credential): string { const timestamp = createTimestamp(); const uuid = randomUUID(); const certificate = credential.certificate(); // El PEM ya contiene el certificado en base64, solo quitamos headers y newlines const cerB64 = certificate.pem().replace(/-----.*-----/g, '').replace(/\s/g, ''); // Canonicalizar y firmar const toDigestXml = `` + `${timestamp.created}` + `${timestamp.expires}` + ``; const digestValue = createHash('sha1').update(toDigestXml).digest('base64'); const signedInfoXml = `` + `` + `` + `` + `` + `` + `` + `` + `${digestValue}` + `` + ``; // Firmar con la llave privada (sign retorna binary string, convertir a base64) const signatureBinary = credential.sign(signedInfoXml, 'sha1'); const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64'); const soapEnvelope = ` ${timestamp.created} ${timestamp.expires} ${cerB64} ${signedInfoXml} ${signatureValue} `; return soapEnvelope; } /** * Extrae el token de la respuesta SOAP */ function parseAuthResponse(responseXml: string): SatToken { const parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, }); const result = parser.parse(responseXml); // Navegar la estructura de respuesta SOAP const envelope = result.Envelope || result['s:Envelope']; if (!envelope) { throw new Error('Respuesta SOAP inválida'); } const body = envelope.Body || envelope['s:Body']; if (!body) { throw new Error('No se encontró el cuerpo de la respuesta'); } const autenticaResponse = body.AutenticaResponse; if (!autenticaResponse) { throw new Error('No se encontró AutenticaResponse'); } const autenticaResult = autenticaResponse.AutenticaResult; if (!autenticaResult) { throw new Error('No se obtuvo token de autenticación'); } // El token es un SAML assertion en base64 const token = autenticaResult; // El token expira en 5 minutos según documentación SAT const expiresAt = new Date(Date.now() + 5 * 60 * 1000); return { token, expiresAt }; } /** * Autentica con el SAT usando la FIEL y obtiene un token */ export async function authenticate(credential: Credential): Promise { const soapRequest = buildAuthRequest(credential); try { const response = await fetch(SAT_AUTH_URL, { method: 'POST', headers: { 'Content-Type': 'text/xml;charset=UTF-8', 'SOAPAction': 'http://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica', }, body: soapRequest, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Error HTTP ${response.status}: ${errorText}`); } const responseXml = await response.text(); return parseAuthResponse(responseXml); } catch (error: any) { console.error('[SAT Auth Error]', error); throw new Error(`Error al autenticar con el SAT: ${error.message}`); } } /** * Verifica si un token está vigente */ export function isTokenValid(token: SatToken): boolean { return new Date() < token.expiresAt; }