2 Commits

Author SHA1 Message Date
Ernesto Herrera
579c6e1f19 chore(deploy): activar sesión local (patrón SIWEB) en testing
Pasa SESSION_STORE_ENABLED/IDLE/MAX al backend vía environment del override
(el contenedor no lee .env). SESSION_STORE_ENABLED=false revierte el patrón.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 17:03:01 -06:00
Ernesto Herrera
5d1c65f236 feat(auth): sesión local del CRM (patrón SIWEB) para eliminar el bucle de login
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>
2026-07-16 16:50:35 -06:00
15 changed files with 582 additions and 67 deletions

View File

@@ -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):

View File

@@ -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:
"""

View File

@@ -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/"

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

View File

@@ -5,11 +5,26 @@
# docker compose -f docker-compose.yml -f deploy/docker-compose.testing.yml up -d --build
services:
backend:
# DNS por-contenedor: el resolver del host no es alcanzable desde los contenedores;
# el backend debe resolver workspace.aduanasoft.com (Hub) en runtime.
dns:
- "8.8.8.8"
- "1.1.1.1"
ports: !override
- "127.0.0.1:8000:8000"
# Sesión local del CRM (patrón SIWEB). El backend toma su config de esta lista
# (no lee el .env dentro del contenedor), así que los flags van aquí. Valores
# desde el .env por sustitución. SESSION_STORE_ENABLED=false revierte al comportamiento previo.
environment:
- SESSION_STORE_ENABLED=${SESSION_STORE_ENABLED:-true}
- SESSION_IDLE_MINUTES=${SESSION_IDLE_MINUTES:-30}
- SESSION_MAX_HOURS=${SESSION_MAX_HOURS:-10}
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
frontend:
dns:
- "8.8.8.8"
- "1.1.1.1"
build:
context: ./frontend
dockerfile: Dockerfile.prod
@@ -21,8 +36,11 @@ services:
VITE_KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-aduanasoft}
environment:
- NODE_ENV=production
# El callback OIDC (/auth/callback) intercambia el código por tokens con el
# secret del client confidencial 'aduanasoft'. El compose base no lo pasa al frontend.
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-}
volumes: !override []
command: !override ["pnpm", "start"]
command: !override ["node", "build/index.js"]
ports: !override
- "127.0.0.1:5173:5173"
@@ -31,3 +49,10 @@ services:
minio:
ports: !override []
# En el server no existe el Hub local: app-hub deja de ser red externa y se crea local.
# El CRM alcanza el Hub por su URL pública (workspace.aduanasoft.com), no por esta red.
networks:
app-hub:
external: false
driver: bridge

View File

@@ -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;

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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, {

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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();