Files
Horux360/apps/api/src/services/sat/sat-auth.service.ts
Consultoria AS 56e6e27ab3 feat(sat): add SAT authentication and download services (Phase 3)
- Add sat-auth.service.ts for SAML token authentication with SAT
  using FIEL credentials and SOAP protocol
- Add sat-download.service.ts with full download workflow:
  - Request CFDI download (emitted/received)
  - Verify request status with polling support
  - Download ZIP packages when ready
  - Helper functions for status checking
- Install fast-xml-parser and adm-zip dependencies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:49:02 +00:00

160 lines
5.5 KiB
TypeScript

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();
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
// Canonicalizar y firmar
const toDigestXml = `<u:Timestamp xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" u:Id="_0">` +
`<u:Created>${timestamp.created}</u:Created>` +
`<u:Expires>${timestamp.expires}</u:Expires>` +
`</u:Timestamp>`;
const digestValue = createHash('sha1').update(toDigestXml).digest('base64');
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
`<Reference URI="#_0">` +
`<Transforms>` +
`<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
`</Transforms>` +
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
`<DigestValue>${digestValue}</DigestValue>` +
`</Reference>` +
`</SignedInfo>`;
// 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 = `<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<s:Header>
<o:Security xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" s:mustUnderstand="1">
<u:Timestamp u:Id="_0">
<u:Created>${timestamp.created}</u:Created>
<u:Expires>${timestamp.expires}</u:Expires>
</u:Timestamp>
<o:BinarySecurityToken u:Id="uuid-${uuid}-1" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">${cerB64}</o:BinarySecurityToken>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
${signedInfoXml}
<SignatureValue>${signatureValue}</SignatureValue>
<KeyInfo>
<o:SecurityTokenReference>
<o:Reference URI="#uuid-${uuid}-1" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3"/>
</o:SecurityTokenReference>
</KeyInfo>
</Signature>
</o:Security>
</s:Header>
<s:Body>
<Autentica xmlns="http://DescargaMasivaTerceros.gob.mx"/>
</s:Body>
</s:Envelope>`;
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<SatToken> {
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;
}