chore: snapshot before development sync
This commit is contained in:
@@ -12,7 +12,7 @@ class UserContextMiddleware(BaseHTTPMiddleware):
|
||||
try:
|
||||
# verify_token might raise exception if invalid, we catch it to not block request
|
||||
# but we won't have user context
|
||||
user_info = verify_token(token)
|
||||
user_info = await verify_token(token)
|
||||
set_user_context(user_info)
|
||||
except Exception:
|
||||
# Log error or ignore
|
||||
|
||||
@@ -34,6 +34,8 @@ class TokenResponseDTO(BaseModel):
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
tenant: Optional["TenantInfoDTO"] = None
|
||||
tenant_id: Optional[int] = None
|
||||
tenant_slug: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
@@ -196,3 +198,9 @@ class LoginChoiceResponseDTO(BaseModel):
|
||||
|
||||
status: str = "choose_tenant"
|
||||
tenants: list[TenantInfoDTO]
|
||||
|
||||
|
||||
class SSOExchangeRequestDTO(BaseModel):
|
||||
"""DTO para canjear el relay token por KC tokens."""
|
||||
|
||||
relay_token: str = Field(..., description="Relay token recibido en la URL")
|
||||
|
||||
@@ -17,6 +17,7 @@ from .dto import (
|
||||
RegisterRequestDTO,
|
||||
RegisterResponseDTO,
|
||||
SetCookieRequestDTO,
|
||||
SSOExchangeRequestDTO,
|
||||
SwitchTenantRequestDTO,
|
||||
TokenResponseDTO,
|
||||
UserInfoResponseDTO,
|
||||
@@ -231,3 +232,39 @@ async def set_cookie(
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Error validando tokens: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/sso-exchange", response_model=TokenResponseDTO)
|
||||
async def sso_exchange(
|
||||
body: SSOExchangeRequestDTO,
|
||||
response: Response,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Canjea un relay token de un solo uso (generado por el Hub) por KC tokens.
|
||||
Llamado server-side desde la página /auth/sso del frontend de Anexo76.
|
||||
Establece cookies HttpOnly con los tokens y devuelve el resultado.
|
||||
"""
|
||||
service = AuthService(db)
|
||||
tokens = await service.sso_exchange(body.relay_token)
|
||||
|
||||
_is_prod = False # TODO: leer de settings.ENVIRONMENT == "production"
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=tokens.access_token,
|
||||
httponly=True,
|
||||
secure=_is_prod,
|
||||
samesite="lax",
|
||||
max_age=3600,
|
||||
path="/",
|
||||
)
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=tokens.refresh_token,
|
||||
httponly=True,
|
||||
secure=_is_prod,
|
||||
samesite="lax",
|
||||
max_age=86400,
|
||||
path="/",
|
||||
)
|
||||
return tokens
|
||||
|
||||
@@ -64,13 +64,17 @@ class AuthService:
|
||||
|
||||
return TokenResponseDTO(**data)
|
||||
|
||||
# Si el Hub falló con error de credenciales
|
||||
# Pasar el mensaje de error real del Hub al cliente
|
||||
try:
|
||||
hub_detail = response.json().get("detail", None)
|
||||
except Exception:
|
||||
hub_detail = None
|
||||
|
||||
if response.status_code == 401:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
# Otros errores del Hub
|
||||
raise HTTPException(status_code=401, detail=hub_detail or "Credenciales inválidas")
|
||||
|
||||
logger.error(f"Hub login failed with status {response.status_code}: {response.text}")
|
||||
raise HTTPException(status_code=response.status_code, detail="Authentication server error")
|
||||
raise HTTPException(status_code=response.status_code, detail=hub_detail or "Error en el servidor de autenticación")
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Hub unreachable during login: {str(e)}")
|
||||
@@ -175,3 +179,34 @@ class AuthService:
|
||||
except Exception as e:
|
||||
logger.error(f"Switch tenant error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Switch tenant error")
|
||||
|
||||
async def sso_exchange(self, relay_token: str) -> TokenResponseDTO:
|
||||
"""
|
||||
Canjea un relay token de un solo uso por KC tokens.
|
||||
Llama al Hub backend (server-to-server), sin Bearer requerido en el Hub.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/sso-exchange",
|
||||
json={"relay_token": relay_token},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return TokenResponseDTO(
|
||||
access_token=data["access_token"],
|
||||
refresh_token=data["refresh_token"],
|
||||
token_type=data.get("token_type", "bearer"),
|
||||
expires_in=data.get("expires_in", 3600),
|
||||
tenant_id=data.get("tenant_id"),
|
||||
tenant_slug=data.get("tenant_slug"),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=response.json().get("detail", "SSO exchange failed"),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"SSO exchange error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="SSO exchange error")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import time
|
||||
import httpx
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -75,7 +76,7 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
|
||||
exempt_paths = [
|
||||
"/api/docs", "/api/redoc", "/openapi.json",
|
||||
"/api/v1/auth", "/api/v1/status", "/api/health",
|
||||
"/api/", "/api/v1/core/help-center",
|
||||
"/api/v1/core/help-center",
|
||||
]
|
||||
|
||||
is_exempt = any(
|
||||
@@ -97,23 +98,54 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
|
||||
# Validación contra el Hub Central
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(
|
||||
f"{settings.HUB_URL}/api/v1/auth/verify-license",
|
||||
f"{settings.HUB_URL}api/v1/auth/verify-license",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
logger.info(f"🔑 verify-license → status={response.status_code} body={response.text[:300]}")
|
||||
|
||||
if response.status_code == 404:
|
||||
# Endpoint no existe en este Hub — dejar pasar
|
||||
return await call_next(request)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# Escenario 1: sin licencia asignada o licencia inactiva
|
||||
if not data.get("valid", False):
|
||||
message = data.get("message", "Sin licencia asignada para este tenant")
|
||||
logger.warning(f"🚫 License invalid for tenant: {data.get('tenant_slug')} — {message}")
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
content={
|
||||
"error": "LICENSE_ERROR",
|
||||
"message": f"Licencia inválida: {data.get('message', 'Sin suscripción activa')}",
|
||||
"message": message,
|
||||
"status_code": 402,
|
||||
}
|
||||
)
|
||||
|
||||
# Escenario 2: licencia vencida (verificación local de expires_at)
|
||||
expires_at_str = data.get("expires_at")
|
||||
if expires_at_str:
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
if expires_at < datetime.now(timezone.utc):
|
||||
logger.warning(f"🚫 License expired for tenant: {data.get('tenant_slug')} — expired at {expires_at_str}")
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
content={
|
||||
"error": "LICENSE_EXPIRED",
|
||||
"message": f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción.",
|
||||
"status_code": 402,
|
||||
}
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass # Si no se puede parsear, dejamos pasar — el Hub es la fuente de verdad
|
||||
|
||||
request.state.license_info = data
|
||||
return await call_next(request) # <--- Único camino al éxito
|
||||
return await call_next(request) # <--- Único camino al éxito
|
||||
|
||||
elif response.status_code == 403:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -5,7 +5,7 @@ Utilidades de seguridad y autenticación con Keycloak
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
from fastapi import Depends, HTTPException, Security
|
||||
from fastapi import Depends, HTTPException, Request, Security
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
import httpx
|
||||
@@ -24,28 +24,37 @@ token_cache = TTLCache(maxsize=1000, ttl=60)
|
||||
# IDs de tenants ya sincronizados en este proceso (evita consultas repetidas)
|
||||
_synced_tenant_ids: Set[int] = set()
|
||||
|
||||
# Alias Hub tenant_id -> tenant_id local cuando existe drift histórico de IDs
|
||||
# (mismo slug, diferente id).
|
||||
_tenant_id_aliases: Dict[int, int] = {}
|
||||
|
||||
# Security scheme
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def verify_token(token: str) -> Dict[str, Any]:
|
||||
async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Verifica un token JWT llamando al Hub central.
|
||||
"""
|
||||
# Check cache first
|
||||
if token in token_cache:
|
||||
return token_cache[token]
|
||||
# Cache key incluye el override para que distintos tenants no se mezclen
|
||||
cache_key = (token, tenant_id_override)
|
||||
if cache_key in token_cache:
|
||||
return token_cache[cache_key]
|
||||
|
||||
try:
|
||||
headers: Dict[str, str] = {"Authorization": f"Bearer {token}"}
|
||||
if tenant_id_override:
|
||||
headers["X-Tenant-Override"] = tenant_id_override
|
||||
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(
|
||||
f"{settings.HUB_URL}api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
user_info = response.json()
|
||||
token_cache[token] = user_info
|
||||
token_cache[cache_key] = user_info
|
||||
return user_info
|
||||
|
||||
logger.error(f"Hub token verification failed with status {response.status_code}")
|
||||
@@ -72,19 +81,23 @@ def _ensure_company_exists(db: Session, tenant_id: int, tenant_name: str) -> Non
|
||||
db.add(company)
|
||||
db.commit()
|
||||
logger.info(f"Empresa creada automáticamente para tenant id={tenant_id}: '{tenant_name}'")
|
||||
elif exists.name != tenant_name:
|
||||
exists.name = tenant_name
|
||||
db.commit()
|
||||
logger.info(f"Empresa actualizada para tenant id={tenant_id}: '{tenant_name}'")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.warning(f"No se pudo crear empresa automática para tenant {tenant_id}: {e}")
|
||||
|
||||
|
||||
def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> None:
|
||||
def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int:
|
||||
"""
|
||||
Garantiza que el tenant del Hub exista en core.tenants local.
|
||||
Se ejecuta una sola vez por tenant_id por ciclo de vida del proceso.
|
||||
El Hub es la fuente de verdad — este método solo sincroniza en una dirección.
|
||||
"""
|
||||
if tenant_id in _synced_tenant_ids:
|
||||
return
|
||||
return _tenant_id_aliases.get(tenant_id, tenant_id)
|
||||
|
||||
try:
|
||||
# Importación local para evitar imports circulares
|
||||
@@ -94,16 +107,17 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> None
|
||||
|
||||
existing = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
if existing:
|
||||
# Update name/slug if they differ (Hub is source of truth)
|
||||
if existing.slug != tenant_slug or existing.name != name:
|
||||
# Update name/slug/keycloak_realm if they differ (Hub is source of truth)
|
||||
if existing.slug != tenant_slug or existing.name != name or existing.keycloak_realm != tenant_slug:
|
||||
existing.slug = tenant_slug
|
||||
existing.name = name
|
||||
existing.keycloak_realm = tenant_slug
|
||||
db.commit()
|
||||
logger.info(f"Tenant id={tenant_id} actualizado: slug='{tenant_slug}'")
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
# Garantizar empresa aunque el tenant ya existiera
|
||||
_ensure_company_exists(db, tenant_id, name)
|
||||
return
|
||||
return tenant_id
|
||||
|
||||
# Crear el tenant local con los datos disponibles del token.
|
||||
# El Hub siempre crea el realm de Keycloak con el mismo nombre que el slug.
|
||||
@@ -121,6 +135,7 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> None
|
||||
logger.info(f"Tenant '{tenant_slug}' (id={tenant_id}) sincronizado desde Hub a core.tenants")
|
||||
# Crear la empresa correspondiente al tenant recién sincronizado
|
||||
_ensure_company_exists(db, tenant_id, name)
|
||||
return tenant_id
|
||||
|
||||
except IntegrityError:
|
||||
# Puede ser concurrencia o colisión de slug (id diferente, mismo slug)
|
||||
@@ -136,16 +151,25 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> None
|
||||
f"Elimine el registro obsoleto con: "
|
||||
f"DELETE FROM core.tenants WHERE id={stale.id};"
|
||||
)
|
||||
# Auto-heal en runtime: mapear temporalmente al tenant local existente por slug
|
||||
# para evitar dejar al usuario sin compañías y evitar este conflicto en cada request.
|
||||
_tenant_id_aliases[tenant_id] = int(stale.id)
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
_ensure_company_exists(db, int(stale.id), stale.name or tenant_slug)
|
||||
return int(stale.id)
|
||||
else:
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
return tenant_id
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.warning(f"No se pudo sincronizar tenant {tenant_id} ({tenant_slug}): {e}")
|
||||
return _tenant_id_aliases.get(tenant_id, tenant_id)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Security(security),
|
||||
db: Session = Depends(get_core_db),
|
||||
request: Request = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Dependency para obtener el usuario actual desde el token JWT.
|
||||
@@ -156,13 +180,27 @@ async def get_current_user(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
"""
|
||||
token = credentials.credentials
|
||||
user_info = await verify_token(token)
|
||||
|
||||
# Leer tenant override del header X-Tenant-Override (pasado por el SvelteKit server
|
||||
# desde la cookie sso_tenant_id, flujo SSO relay multi-tenant)
|
||||
tenant_override = request.headers.get('X-Tenant-Override') if request else None
|
||||
|
||||
logger.info(f"[get_current_user] X-Tenant-Override={tenant_override!r}")
|
||||
|
||||
user_info = await verify_token(token, tenant_id_override=tenant_override)
|
||||
# Copia local para poder normalizar tenant_id sin mutar el objeto cacheado
|
||||
user_info = dict(user_info)
|
||||
|
||||
# Sincronizar tenant desde Hub a BD local (solo la primera vez por tenant)
|
||||
tenant_id = user_info.get("tenant_id")
|
||||
tenant_slug = user_info.get("tenant_slug")
|
||||
if tenant_id and tenant_slug:
|
||||
_ensure_tenant_synced(db, int(tenant_id), str(tenant_slug))
|
||||
effective_tenant_id = _ensure_tenant_synced(db, int(tenant_id), str(tenant_slug))
|
||||
if effective_tenant_id != int(tenant_id):
|
||||
logger.warning(
|
||||
f"[get_current_user] tenant_id ajustado por alias: hub={tenant_id} local={effective_tenant_id} slug={tenant_slug}"
|
||||
)
|
||||
user_info["tenant_id"] = effective_tenant_id
|
||||
|
||||
return user_info
|
||||
|
||||
|
||||
@@ -58,15 +58,6 @@ async def on_startup():
|
||||
logger.info("Base de datos inicializada correctamente.")
|
||||
|
||||
|
||||
# Configurar CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Agregar middlewares personalizados
|
||||
if settings.DEBUG:
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
@@ -75,6 +66,16 @@ app.add_middleware(LicenseValidationMiddleware)
|
||||
app.add_middleware(TenantMiddleware)
|
||||
app.add_middleware(UserContextMiddleware)
|
||||
|
||||
# CORS debe ser el último en añadirse para que sea el más externo
|
||||
# y cubra todas las respuestas, incluyendo las de los middlewares internos
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Centraliza startup para evitar on_event() (deprecated en FastAPI)
|
||||
|
||||
8
frontend/src/app.d.ts
vendored
8
frontend/src/app.d.ts
vendored
@@ -7,7 +7,13 @@ declare global {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
// interface PageData {}
|
||||
interface PageData {
|
||||
licenseError?: {
|
||||
type: string;
|
||||
message: string;
|
||||
status: number;
|
||||
};
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,18 @@ async function fetchApi<T = any>(
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Incluir tenant override para flujo SSO multi-tenant.
|
||||
// sso_tenant_pub es una cookie no-HttpOnly que el servidor setea junto con sso_tenant_id.
|
||||
if (browser) {
|
||||
const tenantPub = document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('sso_tenant_pub='))
|
||||
?.split('=')[1];
|
||||
if (tenantPub) {
|
||||
headers['X-Tenant-Override'] = tenantPub;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
|
||||
100
frontend/src/lib/components/license-error-screen.svelte
Normal file
100
frontend/src/lib/components/license-error-screen.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { logout } from '$lib/auth';
|
||||
|
||||
interface LicenseError {
|
||||
type: string;
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
let { error }: { error: LicenseError } = $props();
|
||||
|
||||
const isHubOffline = error.type === 'HUB_OFFLINE' || error.type === 'HUB_ERROR';
|
||||
|
||||
function handleLogout() {
|
||||
void logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center p-8">
|
||||
<div class="flex max-w-md flex-col items-center gap-6 text-center">
|
||||
<!-- Icon -->
|
||||
{#if isHubOffline}
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900/30">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-10 w-10 text-yellow-600 dark:text-yellow-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-destructive/10">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-10 w-10 text-destructive"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Heading -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-foreground">
|
||||
{#if isHubOffline}
|
||||
Servicio de licencias no disponible
|
||||
{:else}
|
||||
Acceso suspendido
|
||||
{/if}
|
||||
</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{error.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Help text -->
|
||||
<div class="rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
|
||||
{#if isHubOffline}
|
||||
El servidor de licencias no está disponible en este momento. Por favor, inténtalo de nuevo
|
||||
en unos minutos o contacta a soporte si el problema persiste.
|
||||
{:else if error.type === 'LICENSE_ERROR'}
|
||||
Tu organización no cuenta con una licencia activa para acceder al sistema. Contacta a tu
|
||||
administrador o al equipo de soporte para regularizar tu suscripción.
|
||||
{:else}
|
||||
No tienes permisos para acceder al sistema. Contacta a tu administrador.
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
{#if isHubOffline}
|
||||
<Button variant="outline" onclick={() => window.location.reload()}>
|
||||
Reintentar
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="destructive" onclick={handleLogout}>
|
||||
Cerrar sesión
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -11,7 +11,7 @@
|
||||
import { cn } from "$lib/utils.ts";
|
||||
import faviconUrl from '$lib/assets/favicon.svg';
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { page } from '$app/stores';
|
||||
import { page } from '$app/state';
|
||||
import { enhance } from '$app/forms';
|
||||
import { loginWithProvider } from '$lib/sso.ts';
|
||||
import { onMount, tick } from 'svelte';
|
||||
@@ -32,8 +32,9 @@
|
||||
// Descubrimiento de tenants
|
||||
type TenantInfo = { id: number; name: string; slug: string };
|
||||
let tenants = $state<TenantInfo[]>([]);
|
||||
let discoveryError = $state('');
|
||||
|
||||
const error = $derived(page.form?.error || '');
|
||||
const error = $derived(discoveryError || page.form?.error || '');
|
||||
|
||||
// Limpiar todo el localStorage y cookies al montar el componente de login
|
||||
onMount(() => {
|
||||
@@ -66,6 +67,7 @@
|
||||
}
|
||||
|
||||
async function fetchTenants(): Promise<TenantInfo[]> {
|
||||
discoveryError = '';
|
||||
try {
|
||||
const apiBase = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
|
||||
const res = await fetch(`${apiBase}/v1/auth/login`, {
|
||||
@@ -73,15 +75,17 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Múltiples tenants: { status: "choose_tenant", tenants: [...] }
|
||||
if (data.tenants) return data.tenants;
|
||||
// Un solo tenant: Hub devuelve token directo con data.tenant
|
||||
if (data.access_token && data.tenant) return [data.tenant];
|
||||
} else {
|
||||
discoveryError = data.detail || 'Error de autenticación';
|
||||
}
|
||||
} catch {
|
||||
// ignore — el server action mostrará el error de autenticación
|
||||
discoveryError = 'Error de conexión con el servidor';
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -137,8 +141,11 @@
|
||||
} else if (tenants.length > 1) {
|
||||
// Varias orgs: mostrar selector
|
||||
step = 2;
|
||||
} else if (discoveryError) {
|
||||
// Error claro del Hub (sin licencia, credenciales inválidas, etc.)
|
||||
// No hacer submit — el error ya se muestra en discoveryError
|
||||
} else {
|
||||
// 0 orgs: enviar igual, el backend rechazará
|
||||
// 0 orgs sin error: enviar igual, el backend rechazará
|
||||
readyToSubmit = true;
|
||||
await tick();
|
||||
formEl?.requestSubmit();
|
||||
@@ -147,11 +154,12 @@
|
||||
}
|
||||
loading = true;
|
||||
return async ({ update, result }) => {
|
||||
await update();
|
||||
await update({ reset: false });
|
||||
loading = false;
|
||||
readyToSubmit = false;
|
||||
if (result.type === 'failure') {
|
||||
clearClientCookies();
|
||||
step = 1;
|
||||
}
|
||||
};
|
||||
}}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
<Sidebar.Root {collapsible} {...restProps}>
|
||||
<Sidebar.Header>
|
||||
<TeamSwitcher />
|
||||
<TeamSwitcher {userTenants} />
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Content>
|
||||
<NavMain items={data.navMain} />
|
||||
|
||||
@@ -7,9 +7,20 @@
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { getBackendAssetUrl } from '$lib/utils';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
|
||||
interface Tenant {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
let { userTenants = [] }: { userTenants: Tenant[] } = $props();
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
let switchingTenant = $state(false);
|
||||
|
||||
// Derivar la URL del logo usando el endpoint específico
|
||||
let activeCompanyLogoUrl = $derived(
|
||||
companyStore.activeCompany?.logo
|
||||
@@ -24,11 +35,70 @@
|
||||
companyStore.activeCompany?.name?.slice(0, 2).toUpperCase() || 'CO'
|
||||
);
|
||||
|
||||
// Fallback en degradé cuando no hay logo cargado
|
||||
const fallbackBg =
|
||||
'radial-gradient(circle at 30% 30%, rgba(0,0,0,0.08), rgba(0,0,0,0.12)), linear-gradient(135deg, rgba(99,102,241,0.12), rgba(14,165,233,0.18))';
|
||||
function readCookie(name: string): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const logoBg = $derived(activeCompanyLogoUrl ? `url(${activeCompanyLogoUrl})` : fallbackBg);
|
||||
let activeTenantPubId = $derived.by<number | null>(() => {
|
||||
const fromCookie = readCookie('sso_tenant_pub');
|
||||
if (fromCookie && !Number.isNaN(Number(fromCookie))) return Number(fromCookie);
|
||||
return null;
|
||||
});
|
||||
|
||||
async function switchTenant(tenant: Tenant) {
|
||||
if (switchingTenant) return;
|
||||
switchingTenant = true;
|
||||
try {
|
||||
const res = await fetch('/api-sveltekit/auth/switch-tenant', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenant_id: tenant.id }),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
companyStore.clear();
|
||||
await invalidateAll();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
console.error('[team-switcher] switch-tenant error:', err);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[team-switcher] fetch error:', e);
|
||||
} finally {
|
||||
switchingTenant = false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIdentity(value: string | undefined | null): string {
|
||||
return (value ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
let tenantIdentitySet = $derived.by(() => {
|
||||
const set = new Set<string>();
|
||||
for (const tenant of userTenants) {
|
||||
set.add(normalizeIdentity(tenant.name));
|
||||
set.add(normalizeIdentity(tenant.slug));
|
||||
}
|
||||
set.delete('');
|
||||
return set;
|
||||
});
|
||||
|
||||
// Excluir del listado de companias cualquier registro que realmente represente al tenant.
|
||||
let myCompanies = $derived(
|
||||
companyStore.companies.filter((company) => !tenantIdentitySet.has(normalizeIdentity(company.name)))
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const active = companyStore.activeCompany;
|
||||
if (!active) return;
|
||||
if (!tenantIdentitySet.has(normalizeIdentity(active.name))) return;
|
||||
if (myCompanies.length === 0) return;
|
||||
void companyStore.setActiveCompany(myCompanies[0], true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu>
|
||||
@@ -99,18 +169,42 @@
|
||||
side={sidebar.isMobile ? 'bottom' : 'right'}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Mis Compañías</DropdownMenu.Label>
|
||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Tenant</DropdownMenu.Label>
|
||||
{#if userTenants.length === 0}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">Sin tenant asignado</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
{#each userTenants as tenant (tenant.id)}
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => switchTenant(tenant)}
|
||||
class="cursor-pointer gap-2 p-2"
|
||||
disabled={switchingTenant}
|
||||
>
|
||||
<div class="flex size-6 items-center justify-center rounded-md border bg-muted">
|
||||
<BuildingIcon class="size-3.5" />
|
||||
</div>
|
||||
<span class="truncate font-medium">{tenant.name}</span>
|
||||
{#if activeTenantPubId === tenant.id}
|
||||
<CheckIcon class="ml-auto size-4 text-primary" />
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
{/if}
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Mis compañías</DropdownMenu.Label>
|
||||
|
||||
{#if companyStore.loading}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">Cargando...</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else if companyStore.companies.length === 0}
|
||||
{:else if myCompanies.length === 0}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">No hay compañías disponibles</span>
|
||||
<span class="text-muted-foreground">No tienes compañías disponibles</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
{#each companyStore.companies as company, index (company.id)}
|
||||
{#each myCompanies as company, index (company.id)}
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => companyStore.setActiveCompany(company)}
|
||||
class="cursor-pointer gap-2 p-2"
|
||||
|
||||
@@ -82,10 +82,11 @@ export function clearAuthTokens(cookies: Cookies) {
|
||||
/**
|
||||
* Crea headers de autorización con el token Bearer
|
||||
*/
|
||||
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>) {
|
||||
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>, tenantOverride?: string) {
|
||||
return {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}),
|
||||
...additionalHeaders
|
||||
};
|
||||
}
|
||||
@@ -162,6 +163,9 @@ export async function authenticatedFetch(
|
||||
// Construir URL completa
|
||||
const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
|
||||
|
||||
// Leer tenant override de cookie SSO (flujo multi-tenant relay)
|
||||
const tenantOverride = cookies.get('sso_tenant_id');
|
||||
|
||||
// Crear AbortController para timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
@@ -174,7 +178,7 @@ export async function authenticatedFetch(
|
||||
const isFormData = options.body instanceof FormData;
|
||||
const headers = isFormData
|
||||
? { 'Authorization': `Bearer ${accessToken}`, ...(options.headers as Record<string, string> || {}) }
|
||||
: createAuthHeaders(accessToken, options.headers as Record<string, string>);
|
||||
: createAuthHeaders(accessToken, options.headers as Record<string, string>, tenantOverride);
|
||||
|
||||
let response = await fetch(url, {
|
||||
...options,
|
||||
@@ -205,7 +209,7 @@ export async function authenticatedFetch(
|
||||
// Si el body es FormData, no incluir Content-Type
|
||||
const newHeaders = isFormData
|
||||
? { 'Authorization': `Bearer ${newToken}`, ...(options.headers as Record<string, string> || {}) }
|
||||
: createAuthHeaders(newToken, options.headers as Record<string, string>);
|
||||
: createAuthHeaders(newToken, options.headers as Record<string, string>, tenantOverride);
|
||||
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
|
||||
@@ -103,8 +103,14 @@ class CompanyStore {
|
||||
}
|
||||
} else {
|
||||
console.error('Error loading companies:', response.error);
|
||||
// Si falla la carga (ej: 401), limpiar el store
|
||||
if (response.status === 401) {
|
||||
if (response.status === 402) {
|
||||
const { toast } = await import('svelte-sonner');
|
||||
toast.error('Licencia inactiva', {
|
||||
duration: 8000,
|
||||
description: response.error || 'Tu licencia no está activa para este tenant. Contacta al administrador.'
|
||||
});
|
||||
this.clear();
|
||||
} else if (response.status === 401) {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,82 @@
|
||||
/**
|
||||
* Endpoint server-side para cambiar de tenant sin exponer el refresh_token al cliente.
|
||||
*
|
||||
* Flujo:
|
||||
* 1. Cliente llama POST /api-sveltekit/auth/switch-tenant con { tenant_slug }
|
||||
* 2. Este servidor lee access_token y refresh_token de las cookies (HttpOnly).
|
||||
* 3. Llama al backend /v1/auth/switch-tenant con ambos tokens.
|
||||
* 4. Si es exitoso, actualiza las cookies con los nuevos tokens.
|
||||
* 5. Retorna ok al cliente para que recargue la página.
|
||||
* Dos modos:
|
||||
* - { tenant_id } → flujo SSO relay: solo actualiza cookie sso_tenant_id (override de tenant)
|
||||
* - { tenant_slug } → flujo login clásico: re-emite tokens KC para el nuevo tenant
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
|
||||
const { tenant_slug } = await request.json();
|
||||
const body = await request.json();
|
||||
const { tenant_id, tenant_slug } = body as { tenant_id?: number; tenant_slug?: string };
|
||||
|
||||
if (!tenant_slug) {
|
||||
return json({ error: 'tenant_slug is required' }, { status: 400 });
|
||||
if (!tenant_id && !tenant_slug) {
|
||||
return json({ error: 'tenant_id or tenant_slug is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = getAuthTokens(cookies);
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
if (!accessToken) {
|
||||
return json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Modo SSO relay: validar acceso vía Hub y actualizar cookie de override
|
||||
if (tenant_id) {
|
||||
try {
|
||||
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}` },
|
||||
});
|
||||
if (!tenantsRes.ok) {
|
||||
return json({ error: 'Could not validate tenant access' }, { status: 403 });
|
||||
}
|
||||
const tenants: { id: number }[] = await tenantsRes.json();
|
||||
const hasAccess = tenants.some((t) => t.id === tenant_id);
|
||||
if (!hasAccess) {
|
||||
return json({ error: 'Access denied to tenant' }, { status: 403 });
|
||||
}
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
cookies.set('sso_tenant_id', String(tenant_id), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
cookies.set('sso_tenant_pub', String(tenant_id), {
|
||||
path: '/',
|
||||
httpOnly: false,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
return json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[switch-tenant] SSO mode error:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Modo login clásico: re-emitir tokens KC para el nuevo tenant
|
||||
if (!refreshToken) {
|
||||
return json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = getServerApiUrl();
|
||||
|
||||
const response = await fetch(`${baseUrl}v1/auth/switch-tenant`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ tenant_slug, refresh_token: refreshToken })
|
||||
body: JSON.stringify({ tenant_slug, refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -44,15 +85,13 @@ export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Actualizar cookies con los nuevos tokens del nuevo tenant
|
||||
setAuthTokens(cookies, data.access_token, data.refresh_token);
|
||||
// Limpiar la compañía activa para que el dashboard recargue con el nuevo tenant
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
|
||||
cookies.delete('sso_tenant_id', { path: '/' });
|
||||
cookies.delete('sso_tenant_pub', { path: '/' });
|
||||
return json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[switch-tenant] Error:', error);
|
||||
console.error('[switch-tenant] Classic mode error:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
91
frontend/src/routes/auth/sso/+page.server.ts
Normal file
91
frontend/src/routes/auth/sso/+page.server.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* SSO auto-login page for Anexo76.
|
||||
* The Hub App Launcher redirects here with ?relay=<token> after generating a relay token.
|
||||
* This server-side load function exchanges the relay token for KC tokens via the
|
||||
* Hub backend, sets HttpOnly cookies, and redirects to /dashboard.
|
||||
*/
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
const relayToken = url.searchParams.get('relay');
|
||||
|
||||
if (!relayToken) {
|
||||
throw redirect(303, '/login?error=sso_missing_token');
|
||||
}
|
||||
|
||||
// SSO exchange must call Hub backend, not Anexo76 backend.
|
||||
// Use INTERNAL_HUB_URL for server-to-server communication.
|
||||
let hubUrl = process.env.INTERNAL_HUB_URL;
|
||||
if (!hubUrl) {
|
||||
hubUrl = process.env.VITE_HUB_URL;
|
||||
// Fallback: replace localhost with hub-backend for Docker
|
||||
hubUrl = hubUrl?.replace('localhost', 'host.docker.internal').replace('127.0.0.1', 'host.docker.internal');
|
||||
}
|
||||
const baseUrl = hubUrl?.endsWith('/') ? hubUrl : `${hubUrl}/`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}api/v1/auth/sso-exchange`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ relay_token: relayToken }),
|
||||
});
|
||||
} catch (err) {
|
||||
throw redirect(303, '/login?error=sso_hub_unreachable');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
const detail = body?.detail || 'sso_exchange_failed';
|
||||
throw redirect(303, `/login?error=${encodeURIComponent(detail)}`);
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
// access_token — NO HttpOnly (client JS reads it for Bearer headers)
|
||||
cookies.set('access_token', tokens.access_token, {
|
||||
path: '/',
|
||||
httpOnly: false,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
|
||||
// refresh_token — HttpOnly (never exposed to JS)
|
||||
if (tokens.refresh_token) {
|
||||
cookies.set('refresh_token', tokens.refresh_token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
});
|
||||
}
|
||||
|
||||
// sso_tenant_id — HttpOnly cookie con el tenant seleccionado.
|
||||
// El backend lo pasa como X-Tenant-Override en Hub /auth/me para que
|
||||
// devuelva el tenant correcto aunque el KC token tenga otro tenant baked in.
|
||||
if (tokens.tenant_id) {
|
||||
cookies.set('sso_tenant_id', String(tokens.tenant_id), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
// sso_tenant_pub — companion no-HttpOnly para que el cliente JS pueda
|
||||
// leer el tenant override e incluirlo como X-Tenant-Override en fetch directo al backend.
|
||||
// No es un secreto (solo un ID numérico; Hub valida UserTenant en cada request).
|
||||
cookies.set('sso_tenant_pub', String(tokens.tenant_id), {
|
||||
path: '/',
|
||||
httpOnly: false,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
}
|
||||
throw redirect(303, '/dashboard');
|
||||
};
|
||||
11
frontend/src/routes/auth/sso/+page.svelte
Normal file
11
frontend/src/routes/auth/sso/+page.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
// This page is never rendered — the server-side load always redirects.
|
||||
// It exists only to satisfy SvelteKit's file-based routing requirement.
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent"></div>
|
||||
<p class="text-sm text-slate-500">Iniciando sesión automáticamente…</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,11 +1,11 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import {
|
||||
validateAuth,
|
||||
getUserCompanies,
|
||||
getAuthTokens,
|
||||
clearAuthTokens,
|
||||
authenticatedFetch
|
||||
getUserCompanies,
|
||||
clearAuthTokens
|
||||
} from '$lib/server/api';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
@@ -38,18 +38,19 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Cargar los tenants del usuario para el selector de organización
|
||||
let userTenants: { id: number; name: string; slug: string; is_active: boolean }[] = [];
|
||||
// Cargar los tenants del usuario desde Hub (fuente de verdad multi-tenant)
|
||||
let userTenants: { id: number; name: string; slug: string }[] = [];
|
||||
try {
|
||||
const tenantsRes = await authenticatedFetch(
|
||||
`v1/core/user-tenants/user/${userData.sub}`,
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
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}`,
|
||||
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}),
|
||||
},
|
||||
});
|
||||
if (tenantsRes.ok) {
|
||||
const tenantsData = await tenantsRes.json();
|
||||
userTenants = tenantsData.tenants ?? [];
|
||||
userTenants = await tenantsRes.json();
|
||||
}
|
||||
} catch {
|
||||
// No bloquear el dashboard si falla la carga de tenants
|
||||
@@ -69,7 +70,6 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
}
|
||||
|
||||
// Para cualquier otro error (conexión, etc), limpiar token y redirigir
|
||||
console.error('🔐 [Dashboard] Error validando token:', error);
|
||||
clearAuthTokens(cookies);
|
||||
throw redirect(303, redirectOnFail);
|
||||
}
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
import type { SessionExpiredDetail } from '$lib/session-manager';
|
||||
import { authStore } from '$lib/auth';
|
||||
import { logout, getKeycloakInstance } from '$lib/auth';
|
||||
import LicenseErrorScreen from '$lib/components/license-error-screen.svelte';
|
||||
|
||||
let { data, children }: { data: LayoutData; children: any } = $props();
|
||||
type LicenseError = { type: string; message: string; status: number };
|
||||
let { data, children }: { data: LayoutData & { licenseError?: LicenseError }; children: any } = $props();
|
||||
|
||||
let csvImportBanner = $state(false);
|
||||
let csvImportBannerLabel = $state<string | null>(null);
|
||||
@@ -117,6 +119,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if data.licenseError}
|
||||
<LicenseErrorScreen error={data.licenseError} />
|
||||
{:else}
|
||||
<Sidebar.Provider>
|
||||
<AppSidebar />
|
||||
<Sidebar.Inset class="overflow-x-hidden">
|
||||
@@ -169,3 +174,4 @@
|
||||
|
||||
<!-- Diálogo de advertencia de sesión por inactividad -->
|
||||
<SessionTimeoutWarning />
|
||||
{/if}
|
||||
|
||||
@@ -1,14 +1,49 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies }) => {
|
||||
export const POST: RequestHandler = async ({ cookies, request }) => {
|
||||
const refreshToken = cookies.get('refresh_token');
|
||||
|
||||
// Eliminar todas las cookies de autenticación
|
||||
cookies.delete('access_token', { path: '/' });
|
||||
cookies.delete('refresh_token', { path: '/' });
|
||||
|
||||
// Eliminar la cookie de la compañía activa
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
cookies.delete('sso_tenant_id', { path: '/' });
|
||||
cookies.delete('sso_tenant_pub', { path: '/' });
|
||||
|
||||
// Llamar al Hub para revocar el refresh token y obtener la URL de logout KC.
|
||||
// Si lo logramos, redirigimos al browser a través del endpoint KC logout para
|
||||
// que Keycloak elimine su cookie de sesión SSO (evita auto-login silencioso).
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
|
||||
// Detectar el origen del request para usar como post_logout_redirect_uri
|
||||
const origin = request.headers.get('origin') || request.headers.get('referer')?.replace(/\/$/, '') || '';
|
||||
const postLogoutUri = origin ? `${origin}/login` : '/login';
|
||||
|
||||
const res = await fetch(`${hubUrl}/api/v1/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
refresh_token: refreshToken,
|
||||
post_logout_redirect_uri: postLogoutUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data.kc_logout_url) {
|
||||
// Redirigir el browser al endpoint KC logout para limpiar la sesión SSO
|
||||
throw redirect(303, data.kc_logout_url);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Si es un redirect de SvelteKit, relanzar
|
||||
if (err?.status && err?.location) throw err;
|
||||
// Si falla, caer al login local
|
||||
}
|
||||
}
|
||||
|
||||
// Redirigir al login
|
||||
throw redirect(303, '/login');
|
||||
};
|
||||
|
||||
@@ -138,43 +138,10 @@ hub_mode_init() {
|
||||
done
|
||||
echo -e "${GREEN}✓ Hub disponible${NC}"
|
||||
|
||||
# ── 2. Registrar usuario via Hub ───────────────────────────────────────────
|
||||
echo -e "\n${YELLOW}[2/4] Registrando usuario '${USER_USERNAME}' en tenant '${TENANT_SLUG}'...${NC}"
|
||||
echo -e "${YELLOW} (Si el tenant no existe en el Hub, el registro fallará aquí)${NC}"
|
||||
|
||||
local register_response http_code
|
||||
register_response=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
"${HUB_URL}/api/v1/auth/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"username\": \"${USER_USERNAME}\",
|
||||
\"email\": \"${USER_EMAIL}\",
|
||||
\"password\": \"${USER_PASSWORD}\",
|
||||
\"first_name\": \"${USER_FIRSTNAME}\",
|
||||
\"last_name\": \"${USER_LASTNAME}\",
|
||||
\"tenant_slug\":\"${TENANT_SLUG}\"
|
||||
}")
|
||||
|
||||
http_code=$(echo "${register_response}" | tail -n1)
|
||||
local register_body
|
||||
register_body=$(echo "${register_response}" | head -n -1)
|
||||
|
||||
if [[ "${http_code}" == "201" ]]; then
|
||||
echo -e "${GREEN}✓ Usuario creado exitosamente${NC}"
|
||||
elif [[ "${http_code}" == "409" ]] || (echo "${register_body}" | grep -qi "already\|existe\|conflict" 2>/dev/null); then
|
||||
echo -e "${YELLOW}⚠ Usuario ya existe, continuando con login...${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Error registrando usuario (HTTP ${http_code}):${NC}"
|
||||
echo "${register_body}" | python3 -m json.tool 2>/dev/null || echo "${register_body}"
|
||||
echo ""
|
||||
echo -e "${YELLOW} Asegúrate de que el tenant '${TENANT_SLUG}' esté provisionado en el Hub.${NC}"
|
||||
echo -e " Un administrador del Hub debe ejecutar:${NC}"
|
||||
echo -e " curl -X POST ${HUB_URL}/api/v1/hub/provisioning/ \\"
|
||||
echo -e " -H 'Authorization: Bearer <hub-admin-token>' \\"
|
||||
echo -e " -H 'Content-Type: application/json' \\"
|
||||
echo -e " -d '{\"name\":\"${COMPANY_NAME}\",\"slug\":\"${TENANT_SLUG}\",\"contact_email\":\"${USER_EMAIL}\",\"plan\":\"ENTERPRISE\",\"license_months\":12}'"
|
||||
exit 1
|
||||
fi
|
||||
# ── 2. Verificar que el usuario exista (creado desde el Hub) ─────────────
|
||||
echo -e "\n${YELLOW}[2/4] El usuario debe estar creado previamente desde el Hub.${NC}"
|
||||
echo -e "${YELLOW} El registro requiere un token de invitación gestionado por el Hub.${NC}"
|
||||
echo -e "${CYAN} Usuario esperado: ${USER_USERNAME} / tenant: ${TENANT_SLUG}${NC}"
|
||||
|
||||
# ── 3. Login para obtener token y crear empresa ────────────────────────────
|
||||
echo -e "\n${YELLOW}[3/4] Login y creación de empresa en Anexo76...${NC}"
|
||||
|
||||
Reference in New Issue
Block a user