diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index 4dd1a10..44a9d2b 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -53,12 +53,17 @@ async def get_user_statistics( """ tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user)) + from core.hub_token import get_hub_access_token + auth_header = request.headers.get("Authorization") or "" token = ( auth_header[7:].strip() if auth_header.lower().startswith("bearer ") else auth_header.strip() ) + kc_token = await get_hub_access_token(request) + if kc_token: + token = kc_token hub_tid = resolve_hub_tenant_id_for_api( tenant_id, request.headers.get("X-Tenant-Override") ) @@ -84,12 +89,19 @@ async def list_users( """ tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user)) + # El Bearer de la app puede ser la sesión local (SIWEB), que el Hub no acepta. + # Para listar usuarios del tenant se usa el token KC de la sesión (valkey), refrescado. + from core.hub_token import get_hub_access_token + auth_header = request.headers.get("Authorization") or "" token = ( auth_header[7:].strip() if auth_header.lower().startswith("bearer ") else auth_header.strip() ) + kc_token = await get_hub_access_token(request) + if kc_token: + token = kc_token hub_tid = resolve_hub_tenant_id_for_api( tenant_id, request.headers.get("X-Tenant-Override") ) diff --git a/backend/core/hub_token.py b/backend/core/hub_token.py new file mode 100644 index 0000000..2c2c6a3 --- /dev/null +++ b/backend/core/hub_token.py @@ -0,0 +1,67 @@ +""" +Obtención de un access token de Keycloak VÁLIDO para llamar a la API del Hub. + +Con el patrón de sesión local (SIWEB) el Bearer de la app es un JWT propio (HS256) +que el Hub NO entiende. Para las llamadas server→Hub se usa el token KC guardado en +la sesión (valkey, vía cookie crm_sid), refrescándolo si está por expirar. +""" + +import logging +import time +from typing import Optional + +import httpx +from jose import jwt + +from core.config import settings +from core import session_store + +logger = logging.getLogger(__name__) + + +def _kc_exp_ok(token: str, leeway_seconds: int = 30) -> bool: + """True si el token KC no está expirado (con margen).""" + try: + claims = jwt.get_unverified_claims(token) + exp = claims.get("exp") + return isinstance(exp, (int, float)) and (int(exp) - int(time.time())) > leeway_seconds + except Exception: + return False + + +async def get_hub_access_token(request) -> Optional[str]: + """ + Devuelve un access token KC válido tomado de la sesión (valkey vía crm_sid), + refrescándolo contra el Hub si está por expirar. None si no hay sesión. + Best-effort: si el refresh falla, devuelve el token guardado (puede estar vencido). + """ + sid = request.cookies.get("crm_sid") if request is not None else None + if not sid: + return None + sess = session_store.get_session(sid) + if not sess: + return None + + access = sess.get("access_token") + refresh = sess.get("refresh_token") + + if access and _kc_exp_ok(access): + return access + + if refresh: + try: + async with httpx.AsyncClient(timeout=8.0) as client: + r = await client.post( + f"{settings.HUB_URL}api/v1/auth/refresh", + json={"refresh_token": refresh}, + ) + if r.status_code == 200: + data = r.json() + new_access = data.get("access_token") or access + session_store.update_session_tokens(sid, new_access, data.get("refresh_token") or refresh) + return new_access + logger.info("get_hub_access_token: Hub refresh devolvió %s", r.status_code) + except Exception as exc: + logger.warning("get_hub_access_token: refresh falló: %s", exc) + + return access