4 Commits

Author SHA1 Message Date
Ernesto Herrera
63ad2e2ecd feat(core,crm): gestión de compañías en el CRM + soporte hub_admin sin tenant
Las compañías (a76.company) se gestionan en el CRM, ligadas a un tenant del
Workspace. Cambios:

- my-companies: por MEMBRESÍA (user_tenants ∪ user_company_roles), así el hub_admin
  sin tenant en el token ve las compañías que creó/se le asignaron; para usuarios
  con tenant, autocrea una por defecto en el primer acceso.
- POST /auth/companies: da de alta una compañía bajo un tenant + asigna al usuario.
  GET /auth/assignable-tenants: tenants elegibles (hub_admin: todos; usuario: el suyo).
- security.validate_access_to_resource: si el token no trae tenant (hub_admin),
  resuelve el tenant desde la compañía activa (a76.company.tenant_id) → puede operar
  por compañía seleccionada.
- set-active: fija sso_tenant_id/pub con el tenant de la compañía (override).
- Pantalla "Compañías" (nav) para crear/listar/seleccionar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:44:36 -06:00
Ernesto Herrera
0b6848bbdd fix(core): crear compañía real en a76.company por tenant (arregla FK y desbloqueo)
my-companies usaba company_id = tenant_id, pero user_tenants.company_id tiene FK a
a76.company (entidad real heredada de Anexo76). Con el tenant real (aduanasoft=11)
no existía fila de compañía → ForeignKeyViolation → sin compañía → todo bloqueado.

Ahora my-companies lista las compañías del tenant desde a76.company y, si no hay
ninguna, crea una por defecto (nombre = el del tenant) en el primer acceso, usando
su id real; luego asegura el vínculo usuario↔compañía. Base para el módulo de
gestión de compañías.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:31:59 -06:00
Ernesto Herrera
29170f7c8c fix(auth): validar licencia sin reenviar la sesión local al Hub (rompe el bucle 401)
El LicenseValidationMiddleware reenviaba el Bearer al Hub /verify-license, pero con
el patrón SIWEB el Bearer es un JWT HS256 local que el Hub no entiende → 401 →
silent-refresh infinito → toast "Sesión expirada" en cada página.

Ahora, para una sesión local válida, la licencia se valida con el token KC guardado
en valkey (refrescándolo si está vencido) y se cachea por tenant (TTL 10 min). Si el
Hub no es concluyente (su refresh falla), se permite el paso (la sesión se emitió tras
un login válido) evitando el bucle; los resultados concluyentes sí se cachean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:43:32 -06:00
Ernesto Herrera
b76d42be83 refactor(auth): login 100% vía Workspace/Hub — sin comunicación directa a Keycloak
Patrón SIWEB: el CRM ya NO habla directo a Keycloak. Se elimina el flujo OIDC
paralelo (que causaba el bucle e "issuer mismatch"):

- workspace-auth.ts: se quitan builders de URL de KC (authorization/login/logout)
  y getPublicKeycloakBaseUrl/Realm/ClientId. getWorkspaceLoginUrl ya no lleva
  return_to a /login?sso_verified (evita el rebote sin sesión = bucle).
- /login: sso_verified=1 → /dashboard; sin sesión → App Launcher del Workspace
  (relay). Se eliminan redirectToKeycloakAuthorization/Login.
- /auth/callback: obsoleto — ya no intercambia code con KC; redirige a /dashboard.
- /logout y /auth/post-logout: limpian sesión local y vuelven al Workspace (el
  logout completo del Hub/KC se hace desde el Workspace).
- /join: usa redirectToWorkspaceLogin en vez de KC.
- lib/auth.ts: initAuth ya no inicializa keycloak-js en el browser; getToken y
  refreshAccessToken operan por cookie + silent-refresh (backend → Hub).

Login = solo App Launcher del Workspace (relay → /auth/sso → Hub /sso-exchange).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:01:10 -06:00
14 changed files with 562 additions and 355 deletions

View File

@@ -24,6 +24,12 @@ from .dto import (
)
from .service import AuthService
import logging
from typing import Optional
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["Authentication"])
security = HTTPBearer()
@@ -431,32 +437,146 @@ async def get_my_companies(
_ensure_user_tenant_for_company,
)
from api.v1.modules.core.tenants.models import Tenant
from sqlalchemy import text
user_id = current_user.get("sub") or current_user.get("id")
tenant_id = resolve_effective_tenant_id_from_user(current_user)
if not tenant_id:
return []
tenant = db.query(Tenant).filter(Tenant.id == int(tenant_id)).first()
name = (
(tenant.name if tenant else None)
or current_user.get("tenant_slug")
or "Mi empresa"
# 1) Usuario CON tenant en el token (flujo normal): autocrea una compañía por
# defecto en el primer acceso y asegura la membresía.
if tenant_id:
tenant_id = int(tenant_id)
exists = db.execute(
text("SELECT id FROM a76.company WHERE tenant_id = :tid ORDER BY id LIMIT 1"),
{"tid": tenant_id},
).fetchone()
if not exists:
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
default_name = (
(tenant.name if tenant else None)
or current_user.get("tenant_slug")
or "Mi empresa"
)
created = db.execute(
text("INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) RETURNING id"),
{"tid": tenant_id, "name": default_name},
).fetchone()
db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
db.commit()
logger.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0])
if user_id:
try:
_ensure_user_tenant_for_company(db, str(user_id), tenant_id, int(created[0]))
except Exception as exc:
logger.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc)
# 2) Compañías por MEMBRESÍA (user_tenants user_company_roles) → funciona
# también para hub_admin sin tenant en el token: verá las compañías que creó
# o a las que fue asignado. La membresía la determina el CRM, no el Hub.
if not user_id:
return []
rows = db.execute(
text(
"""
SELECT c.id, c.name, c.rfc, c.logo, c.tenant_id
FROM a76.company c
WHERE c.id IN (
SELECT company_id FROM core.user_tenants
WHERE keycloak_user_id = :uid AND is_active AND company_id IS NOT NULL
UNION
SELECT company_id FROM core.user_company_roles
WHERE user_id = :uid AND is_active
)
ORDER BY c.id
"""
),
{"uid": str(user_id)},
).fetchall()
return [
{
"id": int(r[0]),
"name": r[1] or "Empresa",
"tenant_id": int(r[4]),
"rfc": r[2],
"logo": r[3],
"is_active": True,
}
for r in rows
]
class _CreateCompanyDTO(BaseModel):
name: str
tenant_id: int
rfc: Optional[str] = None
@router.get("/assignable-tenants")
async def assignable_tenants(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Tenants disponibles para asignar una compañía nueva. El tenant lo crea el
Workspace; aquí solo se elige. hub_admin ve todos; un usuario con tenant ve el suyo.
"""
from api.v1.modules.core.tenants.models import Tenant
from core.security import resolve_effective_tenant_id_from_user, is_hub_admin
q = db.query(Tenant).filter(Tenant.is_active == True) # noqa: E712
tid = resolve_effective_tenant_id_from_user(current_user)
if tid and not is_hub_admin(current_user):
q = q.filter(Tenant.id == int(tid))
return [{"id": t.id, "name": t.name, "slug": t.slug} for t in q.order_by(Tenant.id).all()]
@router.post("/companies", status_code=201)
async def create_company(
data: _CreateCompanyDTO,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Da de alta una compañía (a76.company) bajo un tenant del Workspace y asigna al
usuario como miembro. hub_admin puede crear en cualquier tenant; un usuario con
tenant solo en el suyo. El rol super_admin se otorga al seleccionarla (/permissions/me).
"""
from sqlalchemy import text as _text
from api.v1.modules.core.tenants.models import Tenant
from core.security import (
resolve_effective_tenant_id_from_user,
is_hub_admin,
_ensure_user_tenant_for_company,
)
# Garantizar el vínculo usuario↔tenant↔company (company_id = tenant_id).
name = (data.name or "").strip()
if len(name) < 2:
raise HTTPException(status_code=422, detail="El nombre de la compañía es obligatorio.")
tid = int(data.tenant_id)
tenant = db.query(Tenant).filter(Tenant.id == tid, Tenant.is_active == True).first() # noqa: E712
if not tenant:
raise HTTPException(status_code=404, detail="Tenant no encontrado.")
if not is_hub_admin(current_user):
own = resolve_effective_tenant_id_from_user(current_user)
if own is None or int(own) != tid:
raise HTTPException(status_code=403, detail="No puedes crear compañías en ese tenant.")
created = db.execute(
_text("INSERT INTO a76.company (tenant_id, name, rfc) VALUES (:t, :n, :r) RETURNING id"),
{"t": tid, "n": name, "r": (data.rfc or None)},
).fetchone()
db.execute(_text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
db.commit()
cid = int(created[0])
user_id = current_user.get("sub") or current_user.get("id")
if user_id:
try:
_ensure_user_tenant_for_company(db, str(user_id), int(tenant_id), int(tenant_id))
_ensure_user_tenant_for_company(db, str(user_id), tid, cid)
except Exception as exc:
logger = __import__("logging").getLogger(__name__)
logger.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc)
logger.warning("create_company: no se pudo asegurar membresía (no bloquea): %s", exc)
return [{
"id": int(tenant_id),
"name": name,
"tenant_id": int(tenant_id),
"rfc": None,
"logo": None,
"is_active": True,
}]
return {"id": cid, "name": name, "tenant_id": tid, "rfc": data.rfc, "logo": None, "is_active": True}

View File

@@ -3,6 +3,7 @@ import time
import httpx
from datetime import datetime, timezone
from typing import Callable, Optional
from cachetools import TTLCache
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
@@ -12,6 +13,11 @@ from .security import get_tenant_from_token, verify_token, get_active_system
logger = logging.getLogger(__name__)
# Caché de validación de licencia por tenant (patrón SIWEB): evita consultar al
# Hub en cada request. Valor: "valid" o "invalid:<mensaje>". TTL corto para que
# los cambios de licencia se propaguen en minutos.
_license_cache: TTLCache = TTLCache(maxsize=1000, ttl=600)
def _normalize_text(value: str | None) -> str:
if not value:
@@ -145,6 +151,18 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
token = auth_header.split(" ")[1]
# Sesión local del CRM (patrón SIWEB): el Bearer es un JWT HS256 propio que
# el Hub NO entiende. No se le reenvía: la licencia se valida con el token KC
# guardado en valkey y se cachea por tenant.
if getattr(settings, "SESSION_STORE_ENABLED", False):
try:
from core.local_session import verify_session_token
local_claims = verify_session_token(token)
except Exception:
local_claims = None
if local_claims is not None:
return await self._handle_local_session_license(request, call_next, local_claims)
tenant_override = request.headers.get("X-Tenant-Override")
if not tenant_override:
# Fallback para flujos SSO cuando el override no viaja en header.
@@ -307,6 +325,136 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
}
)
async def _handle_local_session_license(self, request: Request, call_next: Callable, local_claims: dict):
"""
Valida licencia para una sesión local del CRM (patrón SIWEB).
El Hub no valida el JWT HS256 local, así que se usa el token KC guardado en
valkey (refrescándolo si está vencido) para consultar verify-license, con
caché por tenant. Si el Hub no es concluyente (p. ej. su refresh falla), se
permite el paso: la sesión local se emitió tras un login válido (el App
Launcher solo ofrece apps licenciadas), evitando bloquear por un problema
transitorio del Hub. Los resultados concluyentes (válido/ inválido) sí se cachean.
"""
from core import session_store
tenant_key = str(local_claims.get("tenant_id") or "")
cached = _license_cache.get(tenant_key) if tenant_key else None
if cached == "valid":
return await call_next(request)
if isinstance(cached, str) and cached.startswith("invalid:"):
return JSONResponse(
status_code=402,
content={"error": "LICENSE_ERROR", "message": cached[len("invalid:"):], "status_code": 402},
)
tenant_override = (
tenant_key
or request.cookies.get("sso_tenant_id")
or request.cookies.get("sso_tenant_pub")
or ""
)
sid = request.cookies.get("crm_sid")
sess = session_store.get_session(sid) if sid else None
kc_token = (sess or {}).get("access_token") or ""
kc_refresh = (sess or {}).get("refresh_token") or ""
async def _verify(tok: str):
if not tok:
return None
headers = {"Authorization": f"Bearer {tok}"}
if tenant_override:
headers["X-Tenant-Override"] = str(tenant_override)
try:
async with httpx.AsyncClient(timeout=5.0) as client:
return await client.get(
f"{settings.HUB_URL}api/v1/auth/verify-license", headers=headers
)
except Exception as exc:
logger.warning("[license] verify-license (sesión local) error de red: %s", exc)
return None
resp = await _verify(kc_token)
# ¿El KC token guardado está vencido? Refrescar una vez y reintentar.
needs_refresh = resp is None or resp.status_code == 401
if not needs_refresh and resp.status_code == 200:
try:
_d = resp.json()
except Exception:
_d = {}
if not _d.get("valid", False) and _is_token_issue_message(
_d.get("message"), _d.get("detail"), _d.get("reason")
):
needs_refresh = True
if needs_refresh and kc_refresh:
try:
async with httpx.AsyncClient(timeout=8.0) as client:
rr = await client.post(
f"{settings.HUB_URL}api/v1/auth/refresh",
json={"refresh_token": kc_refresh},
)
if rr.status_code == 200:
nt = rr.json()
kc_token = nt.get("access_token") or kc_token
if sid:
session_store.update_session_tokens(
sid, kc_token, nt.get("refresh_token") or kc_refresh
)
resp = await _verify(kc_token)
else:
logger.warning("[license] refresh KC para verify-license devolvió %s", rr.status_code)
except Exception as exc:
logger.warning("[license] refresh KC para verify-license falló: %s", exc)
if resp is not None and resp.status_code == 200:
try:
data = resp.json()
except Exception:
data = {}
if data.get("valid", False):
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):
msg = f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción."
if tenant_key:
_license_cache[tenant_key] = f"invalid:{msg}"
return JSONResponse(
status_code=402,
content={"error": "LICENSE_EXPIRED", "message": msg, "status_code": 402},
)
except (ValueError, TypeError):
pass
if tenant_key:
_license_cache[tenant_key] = "valid"
request.state.license_info = data
return await call_next(request)
message = data.get("message", "Sin licencia asignada para este tenant")
if not _is_token_issue_message(data.get("message"), data.get("detail"), data.get("reason")):
if tenant_key:
_license_cache[tenant_key] = f"invalid:{message}"
return JSONResponse(
status_code=402,
content={"error": "LICENSE_ERROR", "message": message, "status_code": 402},
)
# No concluyente (Hub no dio 200, o el problema de token persiste porque su
# refresh falla): la sesión local es válida → permitir sin cachear. Evita el
# bucle de 401 por el bug de refresh del Hub.
logger.warning(
"[license] verify-license no concluyente para sesión local (tenant=%s) — se permite",
tenant_key,
)
return await call_next(request)
class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""

View File

@@ -666,6 +666,20 @@ def validate_access_to_resource(
tenant_id = resolve_effective_tenant_id_from_user(current_user)
# Si el usuario no trae tenant en el token (p. ej. hub_admin del workspace),
# resolverlo desde la compañía activa (a76.company.tenant_id). Permite operar
# por compañía seleccionada cuando el token no está ligado a un tenant.
if tenant_id is None and company_id:
try:
from sqlalchemy import text as _text
row = db.execute(
_text("SELECT tenant_id FROM a76.company WHERE id = :c"), {"c": company_id}
).first()
if row and row[0] is not None:
tenant_id = int(row[0])
except Exception as exc:
logger.warning("no se pudo resolver tenant desde company_id=%s: %s", company_id, exc)
# Bypass de checks de permisos: hub_admin (atestado por el Hub en /auth/me)
# o rol local "super_admin" en la compañía (fuente de verdad: BD de a76).
# Se reemplazó el antiguo "admin" in realm_access.roles para que la

View File

@@ -407,10 +407,12 @@ export const initAuth = async (): Promise<boolean> => {
return true;
}
// Sin token local, intentar Keycloak JS (flujo SSO)
const authenticated = await initKeycloak();
// Sin token local: no hay sesión en el cliente. El login entra SIEMPRE por
// el App Launcher del Workspace (relay → /auth/sso → Hub /sso-exchange);
// el CRM NO inicializa Keycloak en el browser. Si no hay token, el layout
// del servidor reenvía al Workspace.
authStore.setLoading(false);
return authenticated;
return false;
} catch (err) {
console.error('[auth] Error en initAuth:', err);
authStore.setLoading(false);

View File

@@ -7,6 +7,7 @@ import {
Ship,
Receipt,
Building2,
Building,
} from '@lucide/svelte';
export type SystemContext = 'fixed_asset' | 'inventory';
@@ -70,6 +71,11 @@ export function getNavMain(): NavMainItem[] {
{ title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' },
],
},
{
title: 'Compañías',
url: '/dashboard/companias',
icon: Building,
},
{
title: 'Workspace',
url: '/dashboard/workspace/organizaciones',

View File

@@ -5,9 +5,14 @@ const DEFAULT_WORKSPACE_BASE_URL = 'https://workspace.aduanasoft.com';
const RETURN_PATH_COOKIE = 'workspace_return_path';
/**
* Returns true only when the public-facing URL uses HTTPS.
* Use this for cookie `secure` flag instead of NODE_ENV so that
* cookies work on HTTP LAN dev environments (e.g. 192.168.x.x).
* Autenticación 100% vía el Hub/Workspace (patrón SIWEB). El CRM NUNCA habla
* directo a Keycloak: el login entra por el App Launcher del workspace (relay
* → /auth/sso → Hub /sso-exchange) y el resto de auth va por la API del Hub.
*/
/**
* True solo cuando la URL pública usa HTTPS. Se usa para el flag `secure` de las
* cookies (en vez de NODE_ENV) para que funcionen en dev HTTP LAN (192.168.x.x).
*/
export function isSecureContext(): boolean {
const origin = (env.ORIGIN || process.env.ORIGIN || '').trim();
@@ -20,9 +25,8 @@ function stripTrailingSlashes(value: string): string {
}
/**
* Detecta si una URL apunta a un host que solo es accesible localmente:
* localhost, 127.0.0.1, IPs de red LAN/privada y hostnames internos de Docker.
* Estas URLs no son válidas como redirect_uri ni como KC public URL en producción.
* Detecta URLs solo accesibles localmente (localhost, IPs LAN/privadas, hosts
* internos de Docker). No son válidas como URL pública del Workspace.
*/
function isDevOnlyUrl(rawUrl: string): boolean {
try {
@@ -60,27 +64,14 @@ export function getWorkspaceBaseUrl(): string {
}
/**
* Normaliza la URL base del sistema (Mi Aplicación) para construir redirect_uri seguros.
*
* Problema habitual en producción: SvelteKit deriva `url.origin` de la variable de entorno
* `ORIGIN`. Si el contenedor se despliega con `ORIGIN=http://localhost:5173` (valor del .env
* de dev), todos los redirect_uri generados por el servidor apuntan a localhost.
*
* Esta función:
* 1. Usa `requestOrigin` si ya es una URL pública (no dev-only).
* 2. Si es localhost, busca `SITE_URL` (env var de producción recomendada) como fallback.
* 3. Como último recurso devuelve requestOrigin tal cual (entorno dev genuino).
*
* Var de entorno recomendada en producción:
* SITE_URL=https://mi-app.dominio.com (además de arreglar ORIGIN)
* Normaliza la URL base del sistema (Mi Aplicación) para construir redirect_uri.
* Corrige el caso donde `ORIGIN` env var apunta a localhost en producción.
*/
export function resolveSystemBaseUrl(requestOrigin: string): string {
if (!isDevOnlyUrl(requestOrigin)) {
return stripTrailingSlashes(requestOrigin);
}
// requestOrigin es dev-only → ORIGIN env var apunta a localhost en producción.
// Buscar URL pública en env vars adicionales.
const candidates = [
(env.SITE_URL || '').trim(),
(env.APP_URL || '').trim(),
@@ -93,31 +84,17 @@ export function resolveSystemBaseUrl(requestOrigin: string): string {
}
}
// Entorno dev genuino: devolver requestOrigin tal cual
return stripTrailingSlashes(requestOrigin);
}
export type WorkspaceLoginUrlOptions = {
/**
* URL del login del Hub sin `return_to`. Usar en `post_logout_redirect_uri` para que,
* tras logout en KC, el Hub aplique myApps() (launcher si el usuario tiene varias apps).
* Con `return_to` a Mi Aplicación, el re-login siempre rebotaba a esa app aunque hubiera más.
*/
forPostLogout?: boolean;
};
export function getWorkspaceLoginUrl(
systemBaseUrl: string,
options?: WorkspaceLoginUrlOptions
): string {
const workspaceBaseUrl = getWorkspaceBaseUrl();
if (options?.forPostLogout) {
return `${workspaceBaseUrl}/login`;
}
// return_to includes sso_verified=1 so the workspace preserves it when redirecting
// back, regardless of what additional params the workspace appends.
const loginUrl = `${systemBaseUrl}/login?sso_verified=1`;
return `${workspaceBaseUrl}/login?return_to=${encodeURIComponent(loginUrl)}`;
/**
* URL de login del Workspace. NO lleva `return_to` a Mi Aplicación: el Hub
* muestra el App Launcher y el usuario re-entra al CRM por relay
* (→ /auth/sso?relay=). Así se evita el rebote a /login sin sesión (bucle) y no
* se usa ningún flujo OIDC directo contra Keycloak.
*/
export function getWorkspaceLoginUrl(): string {
return `${getWorkspaceBaseUrl()}/login`;
}
export function storeReturnPath(cookies: Cookies, path: string): void {
@@ -131,26 +108,6 @@ export function storeReturnPath(cookies: Cookies, path: string): void {
});
}
export function getPublicKeycloakBaseUrl(): string {
const configuredKeycloakUrl = (env.VITE_KEYCLOAK_URL || '').trim();
// Si VITE_KEYCLOAK_URL apunta a un host dev-only (localhost, IP LAN, Docker service),
// ignorarlo y derivar la URL del hostname público del Workspace.
// Esto protege contra builds donde el .env de dev llega a producción por error.
if (configuredKeycloakUrl && !isDevOnlyUrl(configuredKeycloakUrl)) {
return stripTrailingSlashes(configuredKeycloakUrl);
}
return `${getWorkspaceBaseUrl()}/kcauth`;
}
export function getKeycloakRealm(): string {
return (env.KEYCLOAK_REALM || env.VITE_KEYCLOAK_REALM || 'master').trim();
}
export function getKeycloakClientId(): string {
return (env.KEYCLOAK_CLIENT_ID || env.VITE_KEYCLOAK_CLIENT_ID || 'app-frontend').trim();
}
export function getCleanReturnPath(url: URL): string {
const cleanParams = new URLSearchParams(url.searchParams);
cleanParams.delete('sso_verified');
@@ -186,68 +143,9 @@ export function clearWorkspaceReturnPath(cookies: Cookies): void {
cookies.delete(RETURN_PATH_COOKIE, { path: '/' });
}
export function buildKeycloakAuthorizationUrl(systemBaseUrl: string, redirectPath: string): string {
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
// resolveSystemBaseUrl corrige el caso donde url.origin es localhost por ORIGIN env var mal configurado
const publicBase = resolveSystemBaseUrl(systemBaseUrl);
const redirectUri = `${publicBase}/auth/callback`;
const state = JSON.stringify({ redirect_url: redirectPath });
const params = new URLSearchParams({
client_id: getKeycloakClientId(),
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid',
prompt: 'none',
state
});
return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/auth?${params.toString()}`;
}
/**
* Construye URL de login directo en KC sin prompt=none.
* Usa la sesión KC existente si la hay; si no, muestra el form de login.
* Usar cuando se recibe ?redirect= del Hub (rompe el loop Hub↔login).
*/
export function buildKeycloakLoginUrl(systemBaseUrl: string, redirectPath: string): string {
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
// resolveSystemBaseUrl corrige el caso donde url.origin es localhost por ORIGIN env var mal configurado
const publicBase = resolveSystemBaseUrl(systemBaseUrl);
const redirectUri = `${publicBase}/auth/callback`;
const state = JSON.stringify({ redirect_url: redirectPath });
const params = new URLSearchParams({
client_id: getKeycloakClientId(),
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid',
state
});
return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/auth?${params.toString()}`;
}
export function redirectToWorkspaceLogin(cookies: Cookies, url: URL): never {
// Modo local: nunca salir al workspace, mostrar el login local.
if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') {
throw redirect(303, '/login');
}
storeWorkspaceReturnPath(cookies, url);
throw redirect(303, getWorkspaceLoginUrl(url.origin));
}
export function redirectToKeycloakAuthorization(systemBaseUrl: string, redirectPath: string): never {
throw redirect(303, buildKeycloakAuthorizationUrl(systemBaseUrl, redirectPath));
}
export function redirectToKeycloakLogin(systemBaseUrl: string, redirectPath: string): never {
throw redirect(303, buildKeycloakLoginUrl(systemBaseUrl, redirectPath));
}
/**
* URL del Hub FastAPI para llamadas server-to-server (ej. sso-exchange).
* No aplica isDevOnlyUrl: las URLs internas de Docker son válidas aquí.
* Lee HUB_BACKEND_URL (override explícito) → INTERNAL_HUB_URL (ya en docker-compose)
* → fallback a URL pública del workspace (vía proxy SvelteKit del Hub).
*/
export function getHubBackendUrl(): string {
const direct =
@@ -257,19 +155,15 @@ export function getHubBackendUrl(): string {
return getWorkspaceBaseUrl();
}
export function buildKeycloakLogoutUrl(systemBaseUrl: string, idTokenHint?: string): string {
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
const postLogoutRedirectUri = `${systemBaseUrl}/auth/post-logout`;
const params = new URLSearchParams({
client_id: getKeycloakClientId(),
post_logout_redirect_uri: postLogoutRedirectUri
});
// Con id_token_hint KC acepta cualquier post_logout_redirect_uri sin necesidad
// de que esté registrado explícitamente en el cliente.
if (idTokenHint) {
params.set('id_token_hint', idTokenHint);
/**
* Redirige al login del Workspace (App Launcher). Único punto de entrada de
* login: el CRM no inicia ningún flujo contra Keycloak.
*/
export function redirectToWorkspaceLogin(cookies: Cookies, url: URL): never {
// Modo local: nunca salir al workspace, mostrar el login local.
if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') {
throw redirect(303, '/login');
}
return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/logout?${params.toString()}`;
}
storeWorkspaceReturnPath(cookies, url);
throw redirect(303, getWorkspaceLoginUrl());
}

View File

@@ -158,7 +158,9 @@ class CompanyStore {
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ companyId: company.id }),
// tenant_id de la compañía → override para que el backend escale por
// ese tenant (necesario cuando el usuario es hub_admin sin tenant en el token).
body: JSON.stringify({ companyId: company.id, tenantId: company.tenant_id }),
credentials: 'include'
});
} catch (error) {

View File

@@ -6,22 +6,28 @@ import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ cookies, request }) => {
try {
const { companyId } = await request.json();
const body = await request.json();
const companyId = body?.companyId;
const tenantId = body?.tenantId;
if (!companyId || typeof companyId !== 'number') {
return json({ error: 'Invalid company ID' }, { status: 400 });
}
// Establecer la cookie desde el servidor
cookies.set('active_company_id', companyId.toString(), {
path: '/',
maxAge: 60 * 60 * 24 * 30, // 30 días
sameSite: 'lax',
httpOnly: false, // Permitir acceso desde JavaScript
secure: process.env.NODE_ENV === 'production'
});
const isProd = process.env.NODE_ENV === 'production';
const base = { path: '/', maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' as const, secure: isProd };
return json({ success: true, companyId });
// Compañía activa (legible desde JS)
cookies.set('active_company_id', companyId.toString(), { ...base, httpOnly: false });
// Fijar el tenant de la compañía como override → el backend escala por ese
// tenant aunque el token no lo traiga (caso hub_admin operando por compañía).
if (typeof tenantId === 'number' && Number.isFinite(tenantId)) {
cookies.set('sso_tenant_id', tenantId.toString(), { ...base, httpOnly: true });
cookies.set('sso_tenant_pub', tenantId.toString(), { ...base, httpOnly: false });
}
return json({ success: true, companyId, tenantId: tenantId ?? null });
} catch (error) {
console.error('Error setting active company:', error);
return json({ error: 'Internal server error' }, { status: 500 });

View File

@@ -1,132 +1,15 @@
import { redirect, isRedirect } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
import {
clearWorkspaceReturnPath,
getWorkspaceLoginUrl,
readWorkspaceReturnPath,
storeReturnPath,
} from '$lib/server/workspace-auth';
export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
// Obtener el código y state de los query params
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const errorParam = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
if (errorParam) {
console.error('❌ [Callback Server] KC auth error:', errorParam, errorDescription);
// login_required means no KC session exists yet → send to Workspace login.
// Preserve the intended destination through the detour so /login can pick it up.
if (state) {
try {
const stateObj = JSON.parse(state);
const returnPath = stateObj.redirect_url;
if (returnPath && returnPath.startsWith('/') && returnPath !== '/login') {
storeReturnPath(cookies, returnPath);
}
} catch { /* ignore malformed state */ }
}
throw redirect(303, getWorkspaceLoginUrl(url.origin));
}
if (!code) {
console.error('❌ [Callback Server] No se recibió código de autorización');
throw redirect(303, getWorkspaceLoginUrl(url.origin));
}
try {
// Intercambiar código por tokens usando el backend de Keycloak
// En el servidor (SSR), usar KEYCLOAK_URL que apunta a http://keycloak:8080
// En producción o fuera de Docker, usar VITE_KEYCLOAK_URL como fallback
const KEYCLOAK_URL = process.env.KEYCLOAK_URL || process.env.VITE_KEYCLOAK_URL || 'http://localhost:8080';
const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM || process.env.VITE_KEYCLOAK_REALM || 'master';
const KEYCLOAK_CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || process.env.VITE_KEYCLOAK_CLIENT_ID || 'app-backend';
const KEYCLOAK_CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || '';
// La redirect_uri debe coincidir exactamente con la registrada en Keycloak.
// resolveSystemBaseUrl corrige el caso donde url.origin es localhost porque
// ORIGIN env var apunta a localhost en producción (usa SITE_URL como fallback).
const { resolveSystemBaseUrl } = await import('$lib/server/workspace-auth');
const redirectUri = `${resolveSystemBaseUrl(url.origin)}/auth/callback`;
const tokenEndpoint = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`;
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: redirectUri,
client_id: KEYCLOAK_CLIENT_ID,
...(KEYCLOAK_CLIENT_SECRET && { client_secret: KEYCLOAK_CLIENT_SECRET })
});
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString()
});
if (!tokenResponse.ok) {
const errorData = await tokenResponse.text();
console.error('❌ [Callback Server] Error al intercambiar código:', errorData);
throw new Error('Error al obtener tokens');
}
const tokens = await tokenResponse.json();
// Establecer las cookies en el servidor
// access_token → NO HttpOnly (el cliente JS lo usa para el header Authorization)
// refresh_token → HttpOnly (el JS nunca lo lee; el servidor lo gestiona)
const { isSecureContext } = await import('$lib/server/workspace-auth');
const isProduction = isSecureContext();
setAccessTokenCookies(cookies, tokens.access_token, {
secure: isProduction,
maxAge: 60 * 60 * 24 * 7 // 7 días
});
if (tokens.refresh_token) {
cookies.set('refresh_token', tokens.refresh_token, {
path: '/',
httpOnly: true, // *** HttpOnly: nunca expuesto a JS ***
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30 // 30 días
});
}
if (tokens.id_token) {
cookies.set('id_token', tokens.id_token, {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7
});
}
// Obtener la URL de redirección del state o ir al dashboard
let redirectTo = readWorkspaceReturnPath(cookies, '/dashboard');
if (state) {
try {
const stateObj = JSON.parse(state);
redirectTo = stateObj.redirect_url || '/dashboard';
} catch (e) {
console.warn('⚠️ [Callback Server] No se pudo obtener redirect_url del state');
}
}
clearWorkspaceReturnPath(cookies);
// Redirigir a la página de destino
throw redirect(303, redirectTo);
} catch (err: any) {
if (isRedirect(err)) throw err;
console.error('❌ [Callback Server] Error procesando autenticación:', err);
throw redirect(303, getWorkspaceLoginUrl(url.origin));
}
/**
* Callback OIDC — OBSOLETO.
*
* El CRM ya no inicia flujo de autorización contra Keycloak: el login entra por
* el App Launcher del Workspace (relay → /auth/sso → Hub /sso-exchange). Esta
* ruta se conserva solo para no romper enlaces viejos; cualquier acceso se
* redirige al dashboard (el layout valida la sesión y, si no hay, reenvía al
* Workspace). No se intercambia ningún `code` con Keycloak.
*/
export const load: PageServerLoad = async () => {
throw redirect(303, '/dashboard');
};

View File

@@ -3,11 +3,10 @@ import type { RequestHandler } from './$types';
import { getWorkspaceLoginUrl } from '$lib/server/workspace-auth';
/**
* KC redirects here after completing the logout flow.
* This URL is covered by the app's registered wildcard in KC (e.g. mi-app.dominio.com/*).
* We then send the user to workspace login so it can apply myApps() launcher logic.
* Ruta de retorno post-logout. El CRM ya no dispara logout contra Keycloak
* (el cierre completo se hace desde el Workspace); se conserva por compatibilidad
* y redirige al App Launcher del Workspace.
*/
export const GET: RequestHandler = async ({ request, url }) => {
const systemBaseUrl = url.origin;
throw redirect(303, getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true }));
export const GET: RequestHandler = async () => {
throw redirect(303, getWorkspaceLoginUrl());
};

View File

@@ -0,0 +1,157 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Building, Plus } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { toast } from 'svelte-sonner';
import { api } from '$lib/api';
import { companyStore } from '$lib/stores/company.svelte';
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
type Tenant = { id: number; name: string; slug: string };
let tenants = $state<Tenant[]>([]);
let loading = $state(true);
let submitting = $state(false);
let name = $state('');
let rfc = $state('');
let tenantId = $state<number | null>(null);
const companies = $derived(companyStore.companies);
onMount(async () => {
const res = await api.get<Tenant[]>('/v1/auth/assignable-tenants');
if (res.data) {
tenants = res.data;
if (tenants.length === 1) tenantId = tenants[0].id;
}
await companyStore.loadCompanies();
loading = false;
});
async function createCompany() {
if (name.trim().length < 2) {
toast.error('El nombre de la compañía es obligatorio');
return;
}
if (!tenantId) {
toast.error('Selecciona el tenant al que pertenece');
return;
}
submitting = true;
try {
const res = await api.post<{ id: number }>('/v1/auth/companies', {
name: name.trim(),
tenant_id: tenantId,
rfc: rfc.trim() || null
});
if (res.error) {
toast.error(res.error);
return;
}
toast.success('Compañía creada');
name = '';
rfc = '';
await companyStore.loadCompanies();
// Seleccionarla como activa para poder trabajar de inmediato.
const created = res.data?.id
? companyStore.companies.find((c) => c.id === res.data!.id)
: null;
if (created) await companyStore.setActiveCompany(created);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo crear la compañía');
} finally {
submitting = false;
}
}
</script>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Building class="h-6 w-6" /> Compañías
</h1>
<p class="mt-1 text-sm text-muted-foreground">
Da de alta las empresas del CRM. Cada compañía pertenece a un tenant (organización) del
Workspace. Al crear una, quedas asignado como administrador y se selecciona como activa.
</p>
</div>
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2"><Plus class="h-4 w-4" /> Nueva compañía</Card.Title>
<Card.Description>El tenant lo crea el Workspace; aquí eliges bajo cuál registrar la empresa.</Card.Description>
</Card.Header>
<Card.Content>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Nombre / Razón social *</span>
<input class={inputCls} bind:value={name} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">RFC</span>
<input class="font-mono {inputCls}" maxlength="13" bind:value={rfc} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Tenant (Workspace) *</span>
<select class={inputCls} bind:value={tenantId}>
<option value={null} disabled>Selecciona…</option>
{#each tenants as t (t.id)}
<option value={t.id}>{t.name} ({t.slug})</option>
{/each}
</select>
</label>
</div>
<div class="mt-6 flex justify-end border-t pt-4">
<Button onclick={createCompany} disabled={submitting}>
{submitting ? 'Creando…' : 'Crear compañía'}
</Button>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Compañías ({companies.length})</Card.Title>
<Card.Description>Empresas a las que tienes acceso.</Card.Description>
</Card.Header>
<Card.Content>
{#if loading}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if companies.length === 0}
<p class="text-sm text-muted-foreground">Aún no tienes compañías. Crea una arriba.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left text-muted-foreground">
<tr class="border-b">
<th class="py-2 pr-4 font-medium">Nombre</th>
<th class="py-2 pr-4 font-medium">RFC</th>
<th class="py-2 pr-4 font-medium">Tenant</th>
<th class="py-2 pr-4 font-medium">Activa</th>
</tr>
</thead>
<tbody>
{#each companies as c (c.id)}
<tr class="border-b last:border-0">
<td class="py-2 pr-4">{c.name}</td>
<td class="py-2 pr-4 font-mono text-xs">{c.rfc ?? '—'}</td>
<td class="py-2 pr-4">{c.tenant_id}</td>
<td class="py-2 pr-4">
{#if companyStore.activeCompany?.id === c.id}
<span class="rounded-full bg-emerald-500/15 px-2 py-0.5 text-xs text-emerald-600">activa</span>
{:else}
<button class="text-xs text-primary hover:underline" onclick={() => companyStore.setActiveCompany(c)}>usar</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>

View File

@@ -1,7 +1,7 @@
import { redirect, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { getServerApiUrl, getAuthTokens } from '$lib/server/api';
import { redirectToKeycloakLogin } from '$lib/server/workspace-auth';
import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth';
export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
const code = url.searchParams.get('code')?.toUpperCase().trim() ?? '';
@@ -13,7 +13,7 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
if (!accessToken) {
// Sesión KC expiró entre redirecciones — volver a auth
redirectToKeycloakLogin(url.origin, `/join?code=${code}&step=consume`);
redirectToWorkspaceLogin(cookies, url);
}
const apiUrl = getServerApiUrl();
@@ -76,7 +76,7 @@ export const actions: Actions = {
if (!accessToken) {
// Redirigir a Keycloak; al volver, el callback irá a /join?code=XXX&step=consume
redirectToKeycloakLogin(url.origin, `/join?code=${code}&step=consume`);
redirectToWorkspaceLogin(cookies, url);
}
// Si ya hay sesión, consumir directamente vía redirect a step=consume

View File

@@ -4,12 +4,9 @@ import type { Actions, PageServerLoad } from './$types';
import { clearAuthTokens, getAuthTokens } from '$lib/server/api';
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
import {
getWorkspaceLoginUrl,
readWorkspaceReturnPath,
clearWorkspaceReturnPath,
storeReturnPath,
redirectToKeycloakAuthorization,
redirectToKeycloakLogin,
redirectToWorkspaceLogin,
getHubBackendUrl,
isSecureContext,
} from '$lib/server/workspace-auth';
@@ -65,29 +62,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
clearAuthTokens(cookies);
// Vuelta desde el Workspace tras autenticarse: el CRM NO inicia ningún flujo
// OIDC contra Keycloak. La sesión se obtiene por relay del App Launcher
// (→ /auth/sso). Si el usuario llega aquí ya autenticado en el Hub, se le
// manda al dashboard; si no hay sesión local, el layout lo reenvía al
// Workspace (App Launcher) para re-entrar por relay.
if (url.searchParams.get('sso_verified') === '1') {
const existingReturnPath = readWorkspaceReturnPath(cookies, '');
const intendedPath =
existingReturnPath && existingReturnPath !== '/login'
? existingReturnPath
: (url.searchParams.get('redirect') || '/dashboard');
storeReturnPath(cookies, intendedPath);
redirectToKeycloakAuthorization(url.origin, intendedPath);
throw redirect(303, '/dashboard');
}
const redirectParam = url.searchParams.get('redirect');
if (redirectParam) {
const existingReturnPath = readWorkspaceReturnPath(cookies, '');
const intendedPath =
existingReturnPath && existingReturnPath !== '/login'
? existingReturnPath
: redirectParam;
storeReturnPath(cookies, intendedPath);
redirectToKeycloakLogin(url.origin, intendedPath);
}
storeReturnPath(cookies, '/dashboard');
throw redirect(303, getWorkspaceLoginUrl(url.origin));
// Sin sesión → App Launcher del Workspace (relay). Nunca Keycloak directo.
redirectToWorkspaceLogin(cookies, url);
};
export const actions: Actions = {

View File

@@ -1,37 +1,28 @@
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';
import { clearAccessTokenCookies } from '$lib/server/access-token-cookie';
import {
buildKeycloakLogoutUrl,
clearWorkspaceReturnPath,
getWorkspaceLoginUrl
} from '$lib/server/workspace-auth';
import { clearAuthTokens } from '$lib/server/api';
import { clearWorkspaceReturnPath, getWorkspaceLoginUrl } from '$lib/server/workspace-auth';
export const POST: RequestHandler = async ({ cookies, url }) => {
const systemBaseUrl = url.origin;
const idToken = cookies.get('id_token');
// Eliminar todas las cookies de autenticación
clearAccessTokenCookies(cookies);
cookies.delete('refresh_token', { path: '/' });
/**
* Logout del CRM. Limpia la sesión LOCAL (cookies) y devuelve al Workspace.
* NO habla directo a Keycloak: el cierre de sesión completo (Hub/KC) se hace
* desde el Workspace. Patrón SIWEB.
*/
export const POST: RequestHandler = async ({ cookies }) => {
// Eliminar todas las cookies de autenticación (incluye sesión local + token KC)
clearAuthTokens(cookies);
cookies.delete('id_token', { path: '/' });
cookies.delete('active_company_id', { path: '/' });
cookies.delete('active_system', { path: '/' });
cookies.delete('sso_tenant_id', { path: '/' });
cookies.delete('sso_tenant_pub', { path: '/' });
clearWorkspaceReturnPath(cookies);
// En modo local no hay Keycloak ni Hub — ir directo al login local.
// Modo local: no hay Workspace — ir al login local.
if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') {
throw redirect(303, '/login');
}
// Sin id_token_hint KC rechaza post_logout_redirect_uri no registrado.
if (!idToken) {
throw redirect(303, getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true }));
}
throw redirect(303, buildKeycloakLogoutUrl(systemBaseUrl, idToken));
// Volver al Workspace (App Launcher). Para cerrar la sesión del Hub por
// completo, el usuario cierra sesión desde el Workspace.
throw redirect(303, getWorkspaceLoginUrl());
};