From 5ac757dbc1f63d3c5ff5d1195f6b3b44f258ed22 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Fri, 24 Apr 2026 10:29:16 -0500 Subject: [PATCH] chore: snapshot before development sync --- .../v1/modules/a76/audit_log/middleware.py | 2 +- backend/api/v1/modules/core/auth/dto.py | 8 ++ backend/api/v1/modules/core/auth/routes.py | 37 ++++++ backend/api/v1/modules/core/auth/service.py | 45 ++++++- backend/core/middleware.py | 40 ++++++- backend/core/security.py | 66 ++++++++--- backend/main.py | 19 +-- frontend/src/app.d.ts | 8 +- frontend/src/lib/api.ts | 12 ++ .../components/license-error-screen.svelte | 100 ++++++++++++++++ frontend/src/lib/components/login-form.svelte | 20 +++- .../lib/components/sidebar/app-sidebar.svelte | 2 +- .../components/sidebar/team-switcher.svelte | 110 ++++++++++++++++-- frontend/src/lib/server/api.ts | 10 +- frontend/src/lib/stores/company.svelte.ts | 10 +- .../auth/switch-tenant/+server.ts | 75 +++++++++--- frontend/src/routes/auth/sso/+page.server.ts | 91 +++++++++++++++ frontend/src/routes/auth/sso/+page.svelte | 11 ++ .../src/routes/dashboard/+layout.server.ts | 28 ++--- frontend/src/routes/dashboard/+layout.svelte | 8 +- frontend/src/routes/logout/+server.ts | 43 ++++++- scripts/init_first_time.sh | 41 +------ 22 files changed, 658 insertions(+), 128 deletions(-) create mode 100644 frontend/src/lib/components/license-error-screen.svelte create mode 100644 frontend/src/routes/auth/sso/+page.server.ts create mode 100644 frontend/src/routes/auth/sso/+page.svelte diff --git a/backend/api/v1/modules/a76/audit_log/middleware.py b/backend/api/v1/modules/a76/audit_log/middleware.py index 0d16a79b..b5f4ed68 100644 --- a/backend/api/v1/modules/a76/audit_log/middleware.py +++ b/backend/api/v1/modules/a76/audit_log/middleware.py @@ -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 diff --git a/backend/api/v1/modules/core/auth/dto.py b/backend/api/v1/modules/core/auth/dto.py index 4f5c5bb4..7ca77c50 100644 --- a/backend/api/v1/modules/core/auth/dto.py +++ b/backend/api/v1/modules/core/auth/dto.py @@ -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") diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index 5fa1be67..36121f7e 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -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 diff --git a/backend/api/v1/modules/core/auth/service.py b/backend/api/v1/modules/core/auth/service.py index 5f4d1c8c..8b090267 100644 --- a/backend/api/v1/modules/core/auth/service.py +++ b/backend/api/v1/modules/core/auth/service.py @@ -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") diff --git a/backend/core/middleware.py b/backend/core/middleware.py index d14c1663..3e48fff6 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -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( diff --git a/backend/core/security.py b/backend/core/security.py index 1d1a1701..ceaf1cc5 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -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 diff --git a/backend/main.py b/backend/main.py index c2d060ac..394b70ae 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 9cffaf74..38957027 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -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 {} } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 10a74279..78f4fcb6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -123,6 +123,18 @@ async function fetchApi( 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, diff --git a/frontend/src/lib/components/license-error-screen.svelte b/frontend/src/lib/components/license-error-screen.svelte new file mode 100644 index 00000000..6e3637f7 --- /dev/null +++ b/frontend/src/lib/components/license-error-screen.svelte @@ -0,0 +1,100 @@ + + +
+
+ + {#if isHubOffline} +
+ +
+ {:else} +
+ +
+ {/if} + + +
+

+ {#if isHubOffline} + Servicio de licencias no disponible + {:else} + Acceso suspendido + {/if} +

+

+ {error.message} +

+
+ + +
+ {#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} +
+ + +
+ {#if isHubOffline} + + {/if} + +
+
+
diff --git a/frontend/src/lib/components/login-form.svelte b/frontend/src/lib/components/login-form.svelte index c063eaa2..28b9bb60 100644 --- a/frontend/src/lib/components/login-form.svelte +++ b/frontend/src/lib/components/login-form.svelte @@ -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([]); + 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 { + 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; } }; }} diff --git a/frontend/src/lib/components/sidebar/app-sidebar.svelte b/frontend/src/lib/components/sidebar/app-sidebar.svelte index c04a0d3b..d451158d 100644 --- a/frontend/src/lib/components/sidebar/app-sidebar.svelte +++ b/frontend/src/lib/components/sidebar/app-sidebar.svelte @@ -36,7 +36,7 @@ - + diff --git a/frontend/src/lib/components/sidebar/team-switcher.svelte b/frontend/src/lib/components/sidebar/team-switcher.svelte index e0557c15..ed91bfcc 100644 --- a/frontend/src/lib/components/sidebar/team-switcher.svelte +++ b/frontend/src/lib/components/sidebar/team-switcher.svelte @@ -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(() => { + 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(); + 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); + }); @@ -99,18 +169,42 @@ side={sidebar.isMobile ? 'bottom' : 'right'} sideOffset={4} > - Mis Compañías + Tenant + {#if userTenants.length === 0} + + Sin tenant asignado + + {:else} + {#each userTenants as tenant (tenant.id)} + switchTenant(tenant)} + class="cursor-pointer gap-2 p-2" + disabled={switchingTenant} + > +
+ +
+ {tenant.name} + {#if activeTenantPubId === tenant.id} + + {/if} +
+ {/each} + {/if} + + + Mis compañías {#if companyStore.loading} Cargando... - {:else if companyStore.companies.length === 0} + {:else if myCompanies.length === 0} - No hay compañías disponibles + No tienes compañías disponibles {:else} - {#each companyStore.companies as company, index (company.id)} + {#each myCompanies as company, index (company.id)} companyStore.setActiveCompany(company)} class="cursor-pointer gap-2 p-2" diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts index a68524d0..09a3c2a2 100644 --- a/frontend/src/lib/server/api.ts +++ b/frontend/src/lib/server/api.ts @@ -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) { +export function createAuthHeaders(token: string, additionalHeaders?: Record, 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 || {}) } - : createAuthHeaders(accessToken, options.headers as Record); + : createAuthHeaders(accessToken, options.headers as Record, 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 || {}) } - : createAuthHeaders(newToken, options.headers as Record); + : createAuthHeaders(newToken, options.headers as Record, tenantOverride); response = await fetch(url, { ...options, diff --git a/frontend/src/lib/stores/company.svelte.ts b/frontend/src/lib/stores/company.svelte.ts index ddcbf4b9..43cfbfa1 100644 --- a/frontend/src/lib/stores/company.svelte.ts +++ b/frontend/src/lib/stores/company.svelte.ts @@ -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(); } } diff --git a/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts b/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts index 9996e79c..cd2781f7 100644 --- a/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts +++ b/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts @@ -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 }); } }; diff --git a/frontend/src/routes/auth/sso/+page.server.ts b/frontend/src/routes/auth/sso/+page.server.ts new file mode 100644 index 00000000..f19f0ada --- /dev/null +++ b/frontend/src/routes/auth/sso/+page.server.ts @@ -0,0 +1,91 @@ +/** + * SSO auto-login page for Anexo76. + * The Hub App Launcher redirects here with ?relay= 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'); +}; diff --git a/frontend/src/routes/auth/sso/+page.svelte b/frontend/src/routes/auth/sso/+page.svelte new file mode 100644 index 00000000..e40b13b2 --- /dev/null +++ b/frontend/src/routes/auth/sso/+page.svelte @@ -0,0 +1,11 @@ + + +
+
+
+

Iniciando sesión automáticamente…

+
+
diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts index d59410cb..c793cb29 100644 --- a/frontend/src/routes/dashboard/+layout.server.ts +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -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); } diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 12e28e3f..3d754d13 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -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(null); @@ -117,6 +119,9 @@ }); +{#if data.licenseError} + +{:else} @@ -169,3 +174,4 @@ +{/if} diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts index 679b0816..eed7419a 100644 --- a/frontend/src/routes/logout/+server.ts +++ b/frontend/src/routes/logout/+server.ts @@ -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'); }; diff --git a/scripts/init_first_time.sh b/scripts/init_first_time.sh index b9538075..962c3464 100755 --- a/scripts/init_first_time.sh +++ b/scripts/init_first_time.sh @@ -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 ' \\" - 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}"