From 5d1c65f2367400ee5a8be0b6ac30535e911fefb0 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Thu, 16 Jul 2026 16:50:35 -0600 Subject: [PATCH] =?UTF-8?q?feat(auth):=20sesi=C3=B3n=20local=20del=20CRM?= =?UTF-8?q?=20(patr=C3=B3n=20SIWEB)=20para=20eliminar=20el=20bucle=20de=20?= =?UTF-8?q?login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/api/v1/modules/core/auth/dto.py | 12 ++ backend/api/v1/modules/core/auth/service.py | 150 +++++++++++++----- backend/core/config.py | 13 ++ backend/core/local_session.py | 94 +++++++++++ backend/core/security.py | 13 ++ backend/core/session_store.py | 111 +++++++++++++ backend/tests/test_local_session.py | 74 +++++++++ frontend/src/lib/server/api.ts | 71 ++++++++- .../auth/silent-refresh/+server.ts | 35 ++-- .../auth/switch-tenant/+server.ts | 6 +- frontend/src/routes/auth/sso/+page.server.ts | 22 ++- .../src/routes/dashboard/+layout.server.ts | 9 +- .../workspace/organizaciones/+page.server.ts | 6 +- .../workspace/usuarios/+page.server.ts | 6 +- 14 files changed, 556 insertions(+), 66 deletions(-) create mode 100644 backend/core/local_session.py create mode 100644 backend/core/session_store.py create mode 100644 backend/tests/test_local_session.py diff --git a/backend/api/v1/modules/core/auth/dto.py b/backend/api/v1/modules/core/auth/dto.py index a9bad09..287d255 100644 --- a/backend/api/v1/modules/core/auth/dto.py +++ b/backend/api/v1/modules/core/auth/dto.py @@ -36,6 +36,12 @@ class TokenResponseDTO(BaseModel): tenant: Optional["TenantInfoDTO"] = None tenant_id: Optional[int] = None tenant_slug: Optional[str] = None + # Sesión local del CRM (patrón SIWEB) — presente solo con SESSION_STORE_ENABLED. + # Es un JWT propio (HS256) que la app usa como bearer para el backend del CRM y + # que sobrevive aunque el refresh del token KC contra el Hub falle. El access_token + # de arriba sigue siendo el de Keycloak (para llamadas al Hub). + session_token: Optional[str] = None + session_id: Optional[str] = None class Config: json_schema_extra = { @@ -52,6 +58,12 @@ class RefreshTokenRequestDTO(BaseModel): """DTO para solicitud de refresh token""" refresh_token: str = Field(..., description="Refresh token") + # Sesión local actual del CRM (patrón SIWEB). Si se envía, el backend preserva el + # inicio de sesión (cap absoluto) y puede re-emitirla como fallback cuando el + # refresh del token KC contra el Hub falla ("Token is not active" del relay). + session_token: Optional[str] = Field(None, description="Sesión local actual del CRM (opcional)") + # session_id opaco de la sesión en valkey (guarda los tokens KC fuera del browser). + session_id: Optional[str] = Field(None, description="ID de sesión en valkey (opcional)") class UserInfoResponseDTO(BaseModel): diff --git a/backend/api/v1/modules/core/auth/service.py b/backend/api/v1/modules/core/auth/service.py index 524db10..040e220 100644 --- a/backend/api/v1/modules/core/auth/service.py +++ b/backend/api/v1/modules/core/auth/service.py @@ -213,55 +213,133 @@ class AuthService: logger.error(f"Unexpected login error: {str(e)}") raise HTTPException(status_code=500, detail="Authentication error") + def _decode_local_session(self, session_token: Optional[str]) -> Optional[Dict[str, Any]]: + """ + Decodifica una sesión local del CRM (HS256) verificando la firma pero + SIN exigir exp — para poder re-emitirla en el refresh. Retorna los claims + o None si la firma no valida o no es una sesión local del CRM. + """ + if not session_token: + return None + try: + claims = jwt.decode( + session_token, + settings.SECRET_KEY, + algorithms=["HS256"], + options={"verify_exp": False}, + ) + except JWTError: + return None + if not claims.get("crm_session") or claims.get("source") != "local": + return None + return claims + + def _session_claims_from_kc(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Construye los claims de la sesión local a partir de la respuesta KC del Hub.""" + kc_claims = self._decode_kc_user_from_token(data.get("access_token", "")) + claims: Dict[str, Any] = dict(kc_claims) + # tenant_id/tenant_slug explícitos del Hub tienen precedencia sobre el token + if data.get("tenant_id") is not None: + claims["tenant_id"] = data.get("tenant_id") + if data.get("tenant_slug") is not None: + claims["tenant_slug"] = data.get("tenant_slug") + return claims + async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO: """ - Refresca el access token usando el Hub + Refresca la sesión. + + - Intenta el refresh del token KC contra el Hub (comportamiento histórico). + - Con SESSION_STORE_ENABLED, además emite/actualiza la sesión local del CRM + (patrón SIWEB) que la app usa como bearer y que dura por inactividad, de + modo que el refresh KC solo se intenta al expirar esa sesión (no cada ~60s). + - Si el Hub RECHAZA el refresh se devuelve 401 y la sesión termina: se + RESPETA la revocación central de Keycloak (sin re-emisión de fallback). """ + from datetime import datetime, timezone + + session_enabled = bool(getattr(settings, "SESSION_STORE_ENABLED", False)) + prev_claims = self._decode_local_session(refresh_data.session_token) if session_enabled else None + prev_sst = prev_claims.get("sst") if prev_claims else None + prev_session_id = refresh_data.session_id if session_enabled else None + + # Fuente del refresh KC: valkey (sesión) tiene precedencia sobre lo que + # mande el cliente (puede estar desactualizado). Fail-silent. + kc_refresh = refresh_data.refresh_token + if session_enabled and prev_session_id: + from core import session_store + + sess = session_store.get_session(prev_session_id) + if sess and sess.get("refresh_token"): + kc_refresh = sess["refresh_token"] + + # ── Intento de refresh del token KC contra el Hub ──────────────────────── + kc_ok = False + data: Optional[Dict[str, Any]] = None try: async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{settings.HUB_URL}api/v1/auth/refresh", - json=refresh_data.model_dump() + json={"refresh_token": kc_refresh}, ) - - if response.status_code == 200: + kc_ok = response.status_code == 200 + if kc_ok: data = response.json() - from core.workspace_profile_sync import sync_workspace_profile_for_user - from core.workspace_profile_client import WorkspaceProfileClient + else: + logger.warning("Hub rechazó el refresh (status %s)", response.status_code) + except Exception as exc: + logger.warning("Hub inalcanzable en refresh: %s", exc) + kc_ok = False - workspace_profile = None - try: - workspace_profile = await WorkspaceProfileClient().get_me( - data.get("access_token", "") - ) - except Exception as exc: - logger.warning( - "workspace_profile_sync_failed", - extra={ - "event": "workspace_profile_sync_failed", - "phase": "refresh", - "error": str(exc), - }, - ) - workspace_profile = None + # ── Camino feliz: el Hub renovó el token KC ────────────────────────────── + if kc_ok and data is not None: + from core.workspace_profile_sync import sync_workspace_profile_for_user + from core.workspace_profile_client import WorkspaceProfileClient - await sync_workspace_profile_for_user( - self.db, - access_token=data.get("access_token"), - keycloak_user_id=(workspace_profile or {}).get("sub") - or data.get("sub") - or data.get("user_id"), - tenant_id=data.get("tenant_id"), - workspace_profile=workspace_profile, - force=True, + workspace_profile = None + try: + workspace_profile = await WorkspaceProfileClient().get_me(data.get("access_token", "")) + except Exception as exc: + logger.warning( + "workspace_profile_sync_failed", + extra={"event": "workspace_profile_sync_failed", "phase": "refresh", "error": str(exc)}, ) - return TokenResponseDTO(**data) - - raise HTTPException(status_code=401, detail="Invalid or expired refresh token") + workspace_profile = None - except Exception as e: - logger.error(f"Token refresh error: {str(e)}") - raise HTTPException(status_code=500, detail="Token refresh error") + await sync_workspace_profile_for_user( + self.db, + access_token=data.get("access_token"), + keycloak_user_id=(workspace_profile or {}).get("sub") or data.get("sub") or data.get("user_id"), + tenant_id=data.get("tenant_id"), + workspace_profile=workspace_profile, + force=True, + ) + + resp = TokenResponseDTO(**data) + + if session_enabled: + from core import local_session, session_store + + start = int(prev_sst) if prev_sst else int(datetime.now(timezone.utc).timestamp()) + claims = self._session_claims_from_kc(data) + new_access = data.get("access_token", "") + new_refresh = data.get("refresh_token", "") + # Reutiliza la sesión de valkey si ya existía; si no, la crea. + if prev_session_id and session_store.get_session(prev_session_id): + session_store.update_session_tokens(prev_session_id, new_access, new_refresh) + resp.session_id = prev_session_id + else: + resp.session_id = session_store.create_session(new_access, new_refresh, start) + resp.session_token = local_session.mint_session_token(claims, session_start=start) + + return resp + + # El Hub rechazó el refresh: la sesión termina y se RESPETA la revocación + # central de Keycloak (no hay re-emisión local de fallback). El usuario + # re-entra por el App Launcher. La sesión local de larga duración evita el + # bucle: el refresh solo se intenta al expirar la sesión local por + # inactividad (idle), no cada ~60s como con el token KC crudo. + raise HTTPException(status_code=401, detail="Invalid or expired refresh token") async def get_user_info(self, access_token: str) -> UserInfoResponseDTO: """ diff --git a/backend/core/config.py b/backend/core/config.py index ff4be27..7582cb5 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -42,6 +42,19 @@ class Settings(BaseSettings): PERMISSION_CACHE_ENABLED: bool = True PERMISSION_CACHE_TTL_SECONDS: int = 300 + # Sesión local del CRM (patrón SIWEB) — desacopla la sesión de la app del + # token KC de 60s. Tras SSO/login se guardan los tokens KC en valkey y se emite + # una sesión local firmada (HS256) con vida por inactividad (idle) y cap + # absoluto. Así el refresh del token KC contra el Hub solo se intenta al expirar + # la sesión local (no cada ~60s), lo que elimina el bucle de login. + # + # SE RESPETA la revocación central de Keycloak: si el Hub rechaza el refresh, la + # sesión termina (no hay re-emisión local de fallback). Flag-gated para rollback: + # con SESSION_STORE_ENABLED=False el comportamiento no cambia. + SESSION_STORE_ENABLED: bool = False + SESSION_IDLE_MINUTES: int = 30 + SESSION_MAX_HOURS: int = 10 + # Synchronization SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production" CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/" diff --git a/backend/core/local_session.py b/backend/core/local_session.py new file mode 100644 index 0000000..48e8061 --- /dev/null +++ b/backend/core/local_session.py @@ -0,0 +1,94 @@ +""" +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 diff --git a/backend/core/security.py b/backend/core/security.py index cb188c9..9127316 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -47,6 +47,19 @@ async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str, if cache_key in token_cache: return token_cache[cache_key] + # Sesión local del CRM (patrón SIWEB): si el token es una sesión local firmada + # (HS256, crm_session), validarla sin ir al Hub en cada request. Así el refresh + # del token KC solo se intenta al expirar la sesión local (no cada ~60s), lo que + # elimina el bucle de login. verify_session_token retorna None para tokens KC + # (RS256), así que no interfiere con el flujo normal. + if settings.SESSION_STORE_ENABLED: + from core.local_session import verify_session_token + + local_claims = verify_session_token(token) + if local_claims is not None: + token_cache[cache_key] = local_claims + return local_claims + # Shortcut para tokens de desarrollo local if settings.DEV_LOCAL_AUTH: try: diff --git a/backend/core/session_store.py b/backend/core/session_store.py new file mode 100644 index 0000000..e54e4fd --- /dev/null +++ b/backend/core/session_store.py @@ -0,0 +1,111 @@ +""" +Store de sesión en Valkey/Redis (patrón SIWEB). + +Guarda los tokens de Keycloak (access + refresh) FUERA del browser, indexados por +un session_id opaco. La app usa la sesión local firmada (ver core.local_session) +para su propia auth; los tokens KC de aquí solo se usan para llamadas al Hub +(provisioning, my-apps, my-tenants), refrescándolos best-effort. + +Fail-silent: si Valkey no está disponible, las operaciones degradan a None/no-op +y la sesión local firmada sigue sosteniendo la app. +""" + +import json +import logging +import uuid +from typing import Optional + +from core.config import settings + +try: + import redis # type: ignore +except Exception: # pragma: no cover - redis es opcional en algunos entornos + redis = None # type: ignore + +logger = logging.getLogger(__name__) + +_KEY_PREFIX = "crm:session:" +_client = None + + +def _get_client(): + """Cliente Redis/Valkey compartido (perezoso). None si no está disponible.""" + global _client + if redis is None: + return None + if _client is None: + try: + _client = redis.Redis.from_url(settings.VALKEY_URL, decode_responses=True) + except Exception as exc: + logger.warning("session_store_init_failed: %s", exc) + return None + return _client + + +def _ttl_seconds() -> int: + # La sesión en valkey vive como máximo lo que la vida absoluta de la sesión. + return settings.SESSION_MAX_HOURS * 3600 + + +def create_session(access_token: str, refresh_token: str, session_start: int) -> Optional[str]: + """Crea una sesión con los tokens KC y devuelve el session_id (o None si Valkey no está).""" + client = _get_client() + if client is None: + return None + session_id = str(uuid.uuid4()) + data = json.dumps({ + "access_token": access_token, + "refresh_token": refresh_token or "", + "sst": int(session_start), + }) + try: + client.setex(f"{_KEY_PREFIX}{session_id}", _ttl_seconds(), data) + return session_id + except Exception as exc: + logger.warning("session_store_create_failed: %s", exc) + return None + + +def get_session(session_id: str) -> Optional[dict]: + """Devuelve {access_token, refresh_token, sst} de la sesión, o None.""" + client = _get_client() + if client is None or not session_id: + return None + try: + raw = client.get(f"{_KEY_PREFIX}{session_id}") + return json.loads(raw) if raw else None + except Exception as exc: + logger.warning("session_store_get_failed: %s", exc) + return None + + +def update_session_tokens(session_id: str, access_token: str, refresh_token: str) -> None: + """Actualiza los tokens KC de una sesión existente conservando su TTL y su sst.""" + client = _get_client() + if client is None or not session_id: + return + try: + key = f"{_KEY_PREFIX}{session_id}" + ttl = client.ttl(key) + if ttl and ttl > 0: + existing = client.get(key) + sst = json.loads(existing).get("sst") if existing else None + data = json.dumps({ + "access_token": access_token, + "refresh_token": refresh_token or "", + "sst": sst, + }) + client.setex(key, ttl, data) + except Exception as exc: + logger.warning("session_store_update_failed: %s", exc) + + +def delete_session(session_id: str) -> None: + """Elimina la sesión (logout). Fail-silent.""" + client = _get_client() + if client is None or not session_id: + return + try: + client.delete(f"{_KEY_PREFIX}{session_id}") + except Exception as exc: + logger.warning("session_store_delete_failed: %s", exc) diff --git a/backend/tests/test_local_session.py b/backend/tests/test_local_session.py new file mode 100644 index 0000000..2781bc7 --- /dev/null +++ b/backend/tests/test_local_session.py @@ -0,0 +1,74 @@ +""" +Pruebas de la sesión local del CRM (patrón SIWEB) — core.local_session. + +Lógica pura (firma HS256 + claims); no requiere BD ni valkey. +""" + +from datetime import datetime, timezone + +from jose import jwt + +from core.config import settings +from core.local_session import mint_session_token, verify_session_token, session_start_of + + +def _now() -> int: + return int(datetime.now(timezone.utc).timestamp()) + + +def test_round_trip_conserva_identidad(): + claims = { + "sub": "kc-user-123", + "email": "user@example.com", + "tenant_id": 11, + "tenant_slug": "aduanasoft", + "is_hub_admin": True, + } + token = mint_session_token(claims) + out = verify_session_token(token) + + assert out is not None + assert out["sub"] == "kc-user-123" + assert out["tenant_id"] == 11 + assert out["tenant_slug"] == "aduanasoft" + assert out["is_hub_admin"] is True + assert out["source"] == "local" + assert out["crm_session"] is True + assert isinstance(out["sst"], int) + + +def test_cap_absoluto_rechaza_sesion_vieja(): + # session_start más allá del cap absoluto → verify debe rechazar aunque no expiró por idle. + old_start = _now() - (settings.SESSION_MAX_HOURS * 3600 + 120) + token = mint_session_token({"sub": "x"}, session_start=old_start) + assert verify_session_token(token) is None + + +def test_preserva_session_start(): + start = _now() - 60 + token = mint_session_token({"sub": "x"}, session_start=start) + out = verify_session_token(token) + assert out is not None + assert session_start_of(out) == start + + +def test_firma_alterada_se_rechaza(): + token = mint_session_token({"sub": "x"}) + # Alterar el último carácter de la firma invalida el token. + tampered = token[:-1] + ("A" if token[-1] != "A" else "B") + assert verify_session_token(tampered) is None + + +def test_token_no_crm_se_rechaza(): + # Un HS256 válido pero SIN los marcadores de sesión local no debe aceptarse. + other = jwt.encode( + {"sub": "x", "exp": _now() + 600}, + settings.SECRET_KEY, + algorithm="HS256", + ) + assert verify_session_token(other) is None + + +def test_token_basura_se_rechaza(): + assert verify_session_token("no-es-un-jwt") is None + assert verify_session_token("") is None diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts index 2c39118..dd148b5 100644 --- a/frontend/src/lib/server/api.ts +++ b/frontend/src/lib/server/api.ts @@ -82,6 +82,60 @@ export function clearAuthTokens(cookies: Cookies) { cookies.delete('id_token', { path: '/' }); cookies.delete('active_company_id', { path: '/' }); cookies.delete('active_system', { path: '/' }); + // Sesión local del CRM (patrón SIWEB) + cookies.delete('kc_access_token', { path: '/' }); + cookies.delete('crm_sid', { path: '/' }); +} + +/** + * Token de Keycloak para llamadas DIRECTAS al Hub (my-apps, my-tenants, + * provisioning). Con el patrón de sesión local (SIWEB) el `access_token` guarda + * la sesión local del CRM, así que el token KC vive en su propia cookie + * `kc_access_token`. Fallback a `access_token` cuando el patrón está apagado + * (kc_access_token ausente) → comportamiento histórico intacto. + */ +export function getKcAccessToken(cookies: Cookies): string | null { + return cookies.get('kc_access_token') ?? getAccessTokenFromCookies(cookies); +} + +/** + * Aplica a las cookies la respuesta de /v1/auth/refresh considerando la sesión + * local del CRM (patrón SIWEB): + * - Con `session_token`: `access_token` = sesión local (bearer de la app), + * `kc_access_token` = token KC (para el Hub), `crm_sid` = id de sesión valkey. + * - Sin él: comportamiento histórico (`access_token` = token KC). + * Devuelve el token que la app debe usar para reintentar (la sesión local si aplica). + */ +export function applyRefreshedTokens( + cookies: Cookies, + data: { access_token?: string; refresh_token?: string; session_token?: string; session_id?: string } +): string | null { + const secure = isSecureContext(); + if (data.session_token) { + setAccessTokenCookies(cookies, data.session_token, { secure, maxAge: 60 * 60 * 24 * 7 }); + // access_token KC vacío = fallback sin token fresco → conservar el actual. + if (data.access_token) { + cookies.set('kc_access_token', data.access_token, { + path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 60 * 60 * 24 * 7 + }); + } + if (data.session_id) { + cookies.set('crm_sid', data.session_id, { + path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 60 * 60 * 24 * 30 + }); + } + if (data.refresh_token) { + cookies.set('refresh_token', data.refresh_token, { + path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 60 * 60 * 24 * 30 + }); + } + return data.session_token; + } + if (data.access_token) { + setAuthTokens(cookies, data.access_token, data.refresh_token); + return data.access_token; + } + return null; } /** @@ -113,12 +167,21 @@ export async function refreshAccessToken( try { const baseUrl = getServerApiUrl(); + // Sesión local actual del CRM (patrón SIWEB): se envía para preservar el + // inicio de sesión (cap absoluto) y permitir el re-emitido de fallback + // cuando el refresh del token KC contra el Hub falla. + const currentSession = getAccessTokenFromCookies(cookies); + const sessionId = cookies.get('crm_sid'); const response = await fetch(`${baseUrl}v1/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refresh_token: refreshToken }) + body: JSON.stringify({ + refresh_token: refreshToken, + ...(currentSession ? { session_token: currentSession } : {}), + ...(sessionId ? { session_id: sessionId } : {}) + }) }); if (!response.ok) { @@ -127,10 +190,8 @@ export async function refreshAccessToken( const data = await response.json(); - // Actualizar las cookies con los nuevos tokens - setAuthTokens(cookies, data.access_token, data.refresh_token); - - return data.access_token; + // Actualiza las cookies teniendo en cuenta la sesión local (o histórico si no aplica). + return applyRefreshedTokens(cookies, data); } catch (error) { console.error('🔄 [API] Error al refrescar token:', error); return null; diff --git a/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts b/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts index 3d9736d..bc7dad7 100644 --- a/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts +++ b/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts @@ -14,8 +14,8 @@ import { json } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit'; -import { getServerApiUrl, setAuthTokens } from '$lib/server/api'; -import { clearAccessTokenCookies } from '$lib/server/access-token-cookie'; +import { getServerApiUrl, applyRefreshedTokens, clearAuthTokens } from '$lib/server/api'; +import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie'; export const POST = async ({ cookies, fetch }: RequestEvent) => { const refreshToken = cookies.get('refresh_token'); @@ -27,34 +27,43 @@ export const POST = async ({ cookies, fetch }: RequestEvent) => { try { const baseUrl = getServerApiUrl(); + // Sesión local actual del CRM (patrón SIWEB): se reenvía para preservar el + // inicio de sesión y permitir el re-emitido de fallback cuando el refresh KC falla. + const currentSession = getAccessTokenFromCookies(cookies); + const sessionId = cookies.get('crm_sid'); + const response = await fetch(`${baseUrl}v1/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refresh_token: refreshToken }) + body: JSON.stringify({ + refresh_token: refreshToken, + ...(currentSession ? { session_token: currentSession } : {}), + ...(sessionId ? { session_id: sessionId } : {}) + }) }); if (!response.ok) { - // El refresh token expiró o fue invalidado por Keycloak (sesión terminada). - // Limpiar las cookies para que el servidor redirigir al login en la siguiente carga. - cookies.delete('refresh_token', { path: '/' }); - clearAccessTokenCookies(cookies); - cookies.delete('active_company_id', { path: '/' }); + // El refresh falló y no hubo sesión local que re-emitir (cap superado o + // patrón apagado). Limpiar cookies para que el server redirija al login. + clearAuthTokens(cookies); const status = response.status === 401 ? 401 : 400; return json({ error: 'Refresh token expired or invalid' }, { status }); } const data = (await response.json()) as { - access_token: string; + access_token?: string; refresh_token?: string; + session_token?: string; + session_id?: string; expires_in?: number; }; - // Actualizar las cookies HttpOnly con los nuevos tokens - setAuthTokens(cookies, data.access_token, data.refresh_token); + // Actualiza cookies considerando la sesión local; devuelve el bearer de la app. + const appToken = applyRefreshedTokens(cookies, data); - // Devolver solo el access_token al cliente - return json({ access_token: data.access_token }); + // Devolver solo el token de app al cliente (sesión local si aplica, o KC). + return json({ access_token: appToken ?? data.access_token ?? '' }); } catch (error) { console.error('[silent-refresh] Error inesperado:', error); return json({ error: 'Internal server error' }, { status: 500 }); diff --git a/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts b/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts index 143fe04..b899aa9 100644 --- a/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts +++ b/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts @@ -9,7 +9,7 @@ import { json } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; import type { RequestEvent } from '@sveltejs/kit'; -import { getServerApiUrl, getAuthTokens, setAuthTokens } from '$lib/server/api'; +import { getServerApiUrl, getAuthTokens, getKcAccessToken, setAuthTokens } from '$lib/server/api'; export const POST = async ({ request, cookies, fetch }: RequestEvent) => { const body = await request.json(); @@ -28,9 +28,11 @@ export const POST = async ({ request, cookies, fetch }: RequestEvent) => { // Modo SSO relay: validar acceso vía Hub y actualizar cookie de override if (tenant_id) { try { + // Validación contra el Hub → token KC (el access_token puede ser la sesión local). + const kcToken = getKcAccessToken(cookies) ?? accessToken; const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, { - headers: { 'Authorization': `Bearer ${accessToken}` }, + headers: { 'Authorization': `Bearer ${kcToken}` }, }); if (!tenantsRes.ok) { return json({ error: 'Could not validate tenant access' }, { status: 403 }); diff --git a/frontend/src/routes/auth/sso/+page.server.ts b/frontend/src/routes/auth/sso/+page.server.ts index e56cdaf..9b7b62c 100644 --- a/frontend/src/routes/auth/sso/+page.server.ts +++ b/frontend/src/routes/auth/sso/+page.server.ts @@ -134,12 +134,32 @@ export const load: PageServerLoad = async ({ url, cookies }) => { const isProduction = isSecureContext(); console.log('[SSO] ORIGIN-based secure context:', isProduction); + // Sesión local del CRM (patrón SIWEB): si el refresh proactivo devolvió una + // sesión local firmada, el access_token guarda ESA sesión (bearer de la app, + // sobrevive aunque el refresh KC del Hub falle) y el token KC va a su propia + // cookie kc_access_token (solo para llamadas directas al Hub). Si no vino + // (flag apagado), comportamiento histórico: access_token = token KC. + const sessionToken = typeof tokens.session_token === 'string' ? tokens.session_token : null; + const kcAccessToken = tokens.access_token as string; + // access_token — NO HttpOnly (Bearer desde JS); fragmentado si el JWT supera ~4KB - setAccessTokenCookies(cookies, tokens.access_token as string, { + setAccessTokenCookies(cookies, sessionToken ?? kcAccessToken, { secure: isProduction, maxAge: 60 * 60 * 24 * 7, }); + if (sessionToken) { + // kc_access_token — HttpOnly; solo el server lo usa para llamar al Hub. + cookies.set('kc_access_token', kcAccessToken, { + path: '/', httpOnly: true, secure: isProduction, sameSite: 'lax', maxAge: 60 * 60 * 24 * 7, + }); + if (typeof tokens.session_id === 'string') { + cookies.set('crm_sid', tokens.session_id, { + path: '/', httpOnly: true, secure: isProduction, sameSite: 'lax', maxAge: 60 * 60 * 24 * 30, + }); + } + } + // refresh_token — HttpOnly (never exposed to JS) if (typeof tokens.refresh_token === 'string') { cookies.set('refresh_token', tokens.refresh_token, { diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts index a3f98a9..9a38390 100644 --- a/frontend/src/routes/dashboard/+layout.server.ts +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -3,6 +3,7 @@ import type { LayoutServerLoad } from './$types'; import { validateAuth, getAuthTokens, + getKcAccessToken, getUserCompanies, clearAuthTokens } from '$lib/server/api'; @@ -30,12 +31,15 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { let myApps: { apps: unknown[]; routing: unknown } = { apps: [], routing: null }; if (!DEV_LOCAL_AUTH) { + // Llamadas DIRECTAS al Hub → token KC (con el patrón de sesión local, el + // access_token guarda la sesión local del CRM, no el token de Keycloak). + const kcToken = getKcAccessToken(cookies) ?? accessToken; try { const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); const tenantOverride = cookies.get('sso_tenant_id'); const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, { headers: { - 'Authorization': `Bearer ${accessToken}`, + 'Authorization': `Bearer ${kcToken}`, ...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}) } }); @@ -46,8 +50,7 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { // No bloquear el dashboard si falla la carga de tenants } - const freshAccessToken = getAuthTokens(cookies).accessToken ?? accessToken; - myApps = await fetchMyApps(freshAccessToken, fetch, cookies.get('sso_tenant_id')); + myApps = await fetchMyApps(kcToken, fetch, cookies.get('sso_tenant_id')); } return { diff --git a/frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts b/frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts index aab020a..e0533c1 100644 --- a/frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts +++ b/frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts @@ -1,6 +1,6 @@ import { fail } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; -import { getAuthTokens } from '$lib/server/api'; +import { getKcAccessToken } from '$lib/server/api'; import { listWorkspaceTenants, createWorkspaceTenant, @@ -9,7 +9,7 @@ import { import { validateTenantForm } from '$lib/server/workspace-provision.shared'; export const load: PageServerLoad = async ({ cookies, fetch }) => { - const { accessToken } = getAuthTokens(cookies); + const accessToken = getKcAccessToken(cookies); if (!accessToken) { return { tenants: [], forbidden: true, loadError: null }; } @@ -26,7 +26,7 @@ export const load: PageServerLoad = async ({ cookies, fetch }) => { export const actions: Actions = { create: async ({ request, cookies, fetch }) => { - const { accessToken } = getAuthTokens(cookies); + const accessToken = getKcAccessToken(cookies); if (!accessToken) return fail(401, { error: 'Tu sesión expiró. Vuelve a entrar al CRM.' }); const data = await request.formData(); diff --git a/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts b/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts index f3bc5a1..76f63be 100644 --- a/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts +++ b/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts @@ -1,11 +1,11 @@ import { fail } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; -import { getAuthTokens } from '$lib/server/api'; +import { getKcAccessToken } from '$lib/server/api'; import { listWorkspaceTenants, createWorkspaceInvite } from '$lib/server/workspace-provision'; import { WORKSPACE_INVITE_ROLES } from '$lib/server/workspace-provision.shared'; export const load: PageServerLoad = async ({ cookies, fetch }) => { - const { accessToken } = getAuthTokens(cookies); + const accessToken = getKcAccessToken(cookies); if (!accessToken) { return { tenants: [], canListTenants: false, roles: WORKSPACE_INVITE_ROLES }; } @@ -26,7 +26,7 @@ export const load: PageServerLoad = async ({ cookies, fetch }) => { export const actions: Actions = { invite: async ({ request, cookies, fetch }) => { - const { accessToken } = getAuthTokens(cookies); + const accessToken = getKcAccessToken(cookies); if (!accessToken) return fail(401, { error: 'Tu sesión expiró. Vuelve a entrar al CRM.' }); const data = await request.formData();