Desacopla la sesión de la app del access token de Keycloak (60s). Tras el SSO/ refresh, el backend emite un JWT de sesión local (HS256) con vida por inactividad (SESSION_IDLE_MINUTES, cap SESSION_MAX_HOURS) y guarda los tokens KC en valkey. La app usa esa sesión local como bearer (cookie access_token); el token KC vive en kc_access_token solo para llamadas directas al Hub. Así el refresh KC contra el Hub solo se intenta al expirar la sesión local, no cada ~60s → se elimina el bucle causado por el bug "Token is not active" del relay. Se RESPETA la revocación central de Keycloak: si el Hub rechaza el refresh, la sesión termina (401). Sin re-emisión de fallback → sin bypass de revocación. Todo detrás de SESSION_STORE_ENABLED (default False = comportamiento idéntico). Backend: core/local_session.py, core/session_store.py (valkey), verify_token acepta la sesión local, refresh la emite/actualiza; DTOs con session_token/session_id; test unitario de local_session. Frontend: access_token=sesión local, kc_access_token para el Hub; sso/refresh/ silent-refresh/switch-tenant y pantallas de workspace cableadas. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
95 lines
3.5 KiB
Python
95 lines
3.5 KiB
Python
"""
|
|
Sesión local del CRM (patrón SIWEB).
|
|
|
|
Emite y valida un JWT de sesión propio (HS256, firmado con SECRET_KEY) que
|
|
transporta la identidad YA verificada por Keycloak/Hub. Desacopla la sesión de la
|
|
app del token KC de 60s: la app valida esta sesión local (sin ir al Hub) durante
|
|
su ventana de inactividad, de modo que el refresh del token KC solo se intenta al
|
|
expirar la sesión local — no cada ~60s. Esto elimina el bucle de login.
|
|
|
|
Se RESPETA la revocación central: si el Hub rechaza el refresh, la sesión termina
|
|
(no hay re-emisión de fallback).
|
|
|
|
Marcadores del token:
|
|
- source: "local" + crm_session: True → distingue de tokens KC (RS256) y del
|
|
token dev-local (dev_local: True).
|
|
- sst (session start time, epoch seg) → fija la vida ABSOLUTA máxima (cap).
|
|
- exp → sliding por inactividad (idle); se
|
|
re-emite en cada refresh mientras no se supere el cap.
|
|
|
|
Seguridad: es un desacople CONSCIENTE de la revocación central de KC (OWASP A07).
|
|
Se acota con idle corto (= ssoSessionIdleTimeout) y cap absoluto
|
|
(= ssoSessionMaxLifespan); el logout elimina la sesión de valkey.
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, Optional
|
|
|
|
from jose import JWTError, jwt
|
|
|
|
from core.config import settings
|
|
|
|
# Claims de identidad que se propagan del token KC a la sesión local.
|
|
_IDENTITY_CLAIMS = (
|
|
"sub", "email", "preferred_username", "username", "name",
|
|
"given_name", "family_name", "first_name", "last_name",
|
|
"tenant_id", "tenant_slug", "roles", "permissions",
|
|
"is_hub_admin", "avatar_url",
|
|
)
|
|
|
|
|
|
def _now_epoch() -> int:
|
|
return int(datetime.now(timezone.utc).timestamp())
|
|
|
|
|
|
def mint_session_token(claims: Dict[str, Any], session_start: Optional[int] = None) -> str:
|
|
"""
|
|
Emite un JWT de sesión local a partir de los claims (verificados) del usuario.
|
|
`session_start` (epoch seg) fija el inicio de sesión para el cap absoluto; si
|
|
no se provee, se usa el momento actual (sesión nueva).
|
|
"""
|
|
now = _now_epoch()
|
|
sst = int(session_start) if session_start else now
|
|
|
|
payload: Dict[str, Any] = {
|
|
k: claims[k] for k in _IDENTITY_CLAIMS if claims.get(k) is not None
|
|
}
|
|
payload.update({
|
|
"source": "local",
|
|
"crm_session": True,
|
|
"sst": sst,
|
|
"iat": now,
|
|
"exp": now + settings.SESSION_IDLE_MINUTES * 60,
|
|
})
|
|
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
|
|
|
|
|
def verify_session_token(token: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Valida un JWT de sesión local. Retorna los claims si es válido, no expiró por
|
|
inactividad y no superó el cap absoluto de vida; None en cualquier otro caso.
|
|
Nunca lanza (para poder encadenar con la validación contra el Hub).
|
|
"""
|
|
try:
|
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
|
except JWTError:
|
|
return None
|
|
|
|
# Solo aceptamos tokens de sesión local del CRM (no KC, no dev-local).
|
|
if not payload.get("crm_session") or payload.get("source") != "local":
|
|
return None
|
|
|
|
# Cap absoluto de vida de sesión (independiente del sliding por idle).
|
|
sst = payload.get("sst")
|
|
if isinstance(sst, (int, float)):
|
|
if _now_epoch() - int(sst) > settings.SESSION_MAX_HOURS * 3600:
|
|
return None
|
|
|
|
return payload
|
|
|
|
|
|
def session_start_of(payload: Dict[str, Any]) -> Optional[int]:
|
|
"""Extrae el epoch de inicio de sesión (sst) de un payload de sesión local."""
|
|
sst = payload.get("sst")
|
|
return int(sst) if isinstance(sst, (int, float)) else None
|