The PEM certificate content is already base64 encoded after removing headers and newlines. We should not re-encode it. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
161 lines
5.6 KiB
TypeScript
161 lines
5.6 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();
|
|
// 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 = `<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;
|
|
}
|