import os
import json
import base64
import logging
from datetime import datetime, timedelta, timezone
import requests
from cryptography import x509
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.serialization import pkcs7
logger = logging.getLogger(__name__)
# Load .env BEFORE reading os.environ so pydantic paths are available
try:
from dotenv import load_dotenv
_backend_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
load_dotenv(os.path.join(_backend_dir, ".env"))
except Exception:
pass
# Constants for AFIP WSAA — use environment variables, not hardcoded paths
_base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
CERT_PATH = os.environ.get('AFIP_CERT_PATH', os.path.join(_base_dir, 'ws_sr_padron_a5_15dc92ced814cc26.crt'))
KEY_PATH = os.environ.get('AFIP_KEY_PATH', os.path.join(_base_dir, 'infocrow.key'))
CACHE_FILE = os.environ.get('AFIP_CACHE_FILE', os.path.join(_base_dir, 'wsaa_cache.json'))
WSAA_PROD_URL = 'https://wsaa.afip.gov.ar/ws/services/LoginCms'
WSAA_HOMO_URL = 'https://wsaahomo.afip.gov.ar/ws/services/LoginCms'
def _generate_cms(tra_xml: str) -> str:
"""Generates the CMS (PKCS#7) signed data using pure cryptography (no rust_pkcs7 patch)."""
with open(CERT_PATH, 'rb') as f:
cert = x509.load_pem_x509_certificate(f.read())
with open(KEY_PATH, 'rb') as f:
key = serialization.load_pem_private_key(f.read(), password=None)
# Use PKCS7SignatureBuilder with SHA1 digest (required by AFIP)
builder = pkcs7.PKCS7SignatureBuilder()
builder = builder.add_signer(cert, key, hashes.SHA1())
builder = builder.add_data(tra_xml.encode('utf-8'))
# Generate detached signature in DER format
cms_der = builder.sign(
encoding=serialization.Encoding.DER,
options=[pkcs7.PKCS7Options.Binary]
)
return base64.b64encode(cms_der).decode('utf-8')
def _request_new_token(service: str, production: bool = True) -> tuple[str, str, str]:
"""Requests a new token from WSAA and returns (token, sign, expiration_iso)."""
now = datetime.now(timezone.utc)
gen_time = (now - timedelta(minutes=5)).strftime('%Y-%m-%dT%H:%M:%S-00:00')
exp_time = (now + timedelta(hours=12)).strftime('%Y-%m-%dT%H:%M:%S-00:00')
unique_id = str(int(now.timestamp()))
tra_xml = (
''
''
''
f'{unique_id}'
f'{gen_time}'
f'{exp_time}'
''
f'{service}'
''
)
cms_b64 = _generate_cms(tra_xml)
url = WSAA_PROD_URL if production else WSAA_HOMO_URL
soap_body = (
''
''
''
''
f'{cms_b64}'
''
''
''
)
headers = {'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': ''}
logger.info(f"Solicitando nuevo ticket WSAA para {service} en {'PROD' if production else 'HOMO'}")
resp = requests.post(url, data=soap_body.encode('utf-8'), headers=headers, timeout=30)
if resp.status_code != 200:
logger.error(f"Error WSAA: {resp.text}")
raise ValueError(f"AFIP WSAA Error (Status {resp.status_code}): {resp.text[:500]}")
body_text = resp.text.replace('<', '<').replace('>', '>')
token_start = body_text.find('') + 7
token_end = body_text.find('')
sign_start = body_text.find('') + 6
sign_end = body_text.find('')
if token_start <= 6 or token_end <= 0:
raise ValueError(f"No se encontrĂ³ el token en la respuesta de AFIP. Respuesta: {body_text}")
token = body_text[token_start:token_end]
sign = body_text[sign_start:sign_end]
return token, sign, exp_time
def get_afip_credentials(service: str = "ws_sr_padron_a13", production: bool = True) -> tuple[str, str]:
"""
Returns a valid (Token, Sign) pair for the given AFIP service.
Uses a local JSON cache to avoid requesting a new ticket unnecessarily.
"""
cache = {}
if os.path.exists(CACHE_FILE):
try:
with open(CACHE_FILE, 'r') as f:
cache = json.load(f)
except Exception as e:
logger.warning(f"Error reading WSAA cache: {e}")
# Check if we have a valid cached token for this specific service
service_key = f"{service}_{'prod' if production else 'homo'}"
if service_key in cache:
cached_data = cache[service_key]
exp_time_str = cached_data.get("expirationTime")
if exp_time_str:
try:
# Format: 2026-05-26T12:36:00-03:00 (WSAA actually returns -00:00 but we saved our own requested exp_time)
exp_dt = datetime.strptime(exp_time_str, '%Y-%m-%dT%H:%M:%S-00:00')
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
# If it's valid for at least 15 more minutes, use it
if datetime.now(timezone.utc) + timedelta(minutes=15) < exp_dt:
logger.debug(f"Usando ticket cacheado para {service}")
return cached_data["token"], cached_data["sign"]
except Exception as e:
logger.warning(f"Error parsing cached expiration time: {e}")
# Need new token
token, sign, exp_time = _request_new_token(service, production)
# Save to cache
cache[service_key] = {
"token": token,
"sign": sign,
"expirationTime": exp_time
}
try:
with open(CACHE_FILE, 'w') as f:
json.dump(cache, f)
except Exception as e:
logger.warning(f"Error writing WSAA cache: {e}")
return token, sign