chore: snapshot before development sync

This commit is contained in:
2026-04-24 10:29:16 -05:00
parent d3676aa8ed
commit 5ac757dbc1
22 changed files with 658 additions and 128 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -36,7 +36,7 @@
<Sidebar.Root {collapsible} {...restProps}>
<Sidebar.Header>
<TeamSwitcher />
<TeamSwitcher {userTenants} />
</Sidebar.Header>
<Sidebar.Content>
<NavMain items={data.navMain} />

View File

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

View File

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

View File

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

View File

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

View 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');
};

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

View File

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

View File

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

View File

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

View File

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