Files
CRM_AGENTES_CARGA/frontend/src/routes/auth/sso/+page.server.ts
Ernesto Herrera 5d1c65f236 feat(auth): sesión local del CRM (patrón SIWEB) para eliminar el bucle de login
Desacopla la sesión de la app del access token de Keycloak (60s). Tras el SSO/
refresh, el backend emite un JWT de sesión local (HS256) con vida por inactividad
(SESSION_IDLE_MINUTES, cap SESSION_MAX_HOURS) y guarda los tokens KC en valkey.
La app usa esa sesión local como bearer (cookie access_token); el token KC vive en
kc_access_token solo para llamadas directas al Hub. Así el refresh KC contra el Hub
solo se intenta al expirar la sesión local, no cada ~60s → se elimina el bucle
causado por el bug "Token is not active" del relay.

Se RESPETA la revocación central de Keycloak: si el Hub rechaza el refresh, la
sesión termina (401). Sin re-emisión de fallback → sin bypass de revocación.

Todo detrás de SESSION_STORE_ENABLED (default False = comportamiento idéntico).

Backend: core/local_session.py, core/session_store.py (valkey), verify_token acepta
la sesión local, refresh la emite/actualiza; DTOs con session_token/session_id;
test unitario de local_session.
Frontend: access_token=sesión local, kc_access_token para el Hub; sso/refresh/
silent-refresh/switch-tenant y pantallas de workspace cableadas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:50:35 -06:00

233 lines
8.9 KiB
TypeScript

/**
* SSO auto-login page for Mi Aplicación.
* 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';
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth';
import { isValidSystem, setActiveSystemCookie } from '$lib/server/system-gate';
// Disable client-side rendering to prevent SvelteKit from making a second
// __data.json request that would consume the one-time relay token twice.
export const csr = false;
export const load: PageServerLoad = async ({ url, cookies }) => {
const relayToken = url.searchParams.get('relay');
const requestedSystem = url.searchParams.get('active_system');
console.log('[SSO] relay token presente:', !!relayToken, '| active_system:', requestedSystem ?? '(none)');
if (!relayToken) {
redirectToWorkspaceLogin(cookies, url);
}
// Limpiar sesión anterior para que el nuevo usuario reciba sus propias cookies.
// No se omite el exchange aunque haya token existente — la sesión podría ser
// de otro usuario (ej: juan que hace logout e ingresa como lal17).
// La única excepción es si el relay token ya fue consumido (lo maneja el error handler).
{
const { clearAccessTokenCookies } = await import('$lib/server/access-token-cookie');
clearAccessTokenCookies(cookies);
cookies.delete('refresh_token', { path: '/' });
cookies.delete('id_token', { path: '/' });
cookies.delete('active_company_id', { path: '/' });
}
// SSO exchange must call the Hub that GENERATED the relay token.
// This fetch runs server-side (inside the Docker container), so we must use
// INTERNAL_HUB_URL (host.docker.internal) when available — "localhost" inside
// a container never reaches the host where the workspace Hub is running.
const hubUrl = (
process.env.INTERNAL_HUB_URL ||
process.env.HUB_URL ||
process.env.VITE_HUB_URL ||
'http://localhost:8001'
).replace(/\/+$/, '');
const baseUrl = `${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) {
redirectToWorkspaceLogin(cookies, url);
}
if (!response.ok) {
const body = await response.json().catch(() => ({}));
const detail: string = body?.detail || 'sso_exchange_failed';
console.error('[SSO] exchange falló:', response.status, detail);
// If the token is "invalid/used", a concurrent request may have already
// succeeded and set cookies. Redirect to /dashboard — if the session is
// valid it will load; if not, the dashboard layout will redirect to /login.
const tokenAlreadyUsed =
detail.toLowerCase().includes('inválido') ||
detail.toLowerCase().includes('invalido') ||
detail.toLowerCase().includes('invalid') ||
detail.toLowerCase().includes('used') ||
detail.toLowerCase().includes('expired');
if (tokenAlreadyUsed) {
throw redirect(303, '/dashboard');
}
redirectToWorkspaceLogin(cookies, url);
}
let tokens: Record<string, unknown>;
try {
tokens = await response.json();
} catch (err) {
console.error('[SSO] exchange devolvió body no-JSON (status 200):', err);
redirectToWorkspaceLogin(cookies, url);
}
if (!tokens.access_token || typeof tokens.access_token !== 'string') {
console.error('[SSO] exchange exitoso pero access_token faltante o inválido:', tokens);
redirectToWorkspaceLogin(cookies, url);
}
console.log('[SSO] exchange exitoso, tokens recibidos:', {
hasAccessToken: !!tokens.access_token,
accessTokenLen: (tokens.access_token as string).length,
hasRefreshToken: !!tokens.refresh_token,
tenant_id: tokens.tenant_id,
tenant_slug: tokens.tenant_slug,
});
// ── Refresh proactivo ────────────────────────────────────────────────────
// Los tokens del relay fueron emitidos por KC via el browser (iss=IP:8085).
// El Hub backend valida contra KC interno (hub-keycloak:8080) → issuer mismatch → 401.
// Refrescando aquí: Mi Aplicación backend → Hub → KC interno → iss=hub-keycloak:8080 → válido.
if (typeof tokens.refresh_token === 'string') {
try {
const internalApiUrl = (
process.env.INTERNAL_API_URL ||
process.env.VITE_API_URL ||
'http://backend:8000/api/'
).replace(/\/+$/, '');
const refreshRes = await fetch(`${internalApiUrl}/v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: tokens.refresh_token }),
});
if (refreshRes.ok) {
const refreshed = await refreshRes.json().catch(() => ({}));
if (refreshed.access_token && refreshed.refresh_token) {
tokens = { ...tokens, ...refreshed };
console.log('[SSO] tokens refrescados exitosamente (iss normalizado)');
}
} else {
console.warn('[SSO] refresh proactivo falló (status', refreshRes.status, ') — usando tokens originales del relay');
}
} catch (err) {
console.warn('[SSO] refresh proactivo error (non-blocking):', err);
}
}
const { isSecureContext } = await import('$lib/server/workspace-auth');
const isProduction = isSecureContext();
console.log('[SSO] ORIGIN-based secure context:', isProduction);
// Sesión local del CRM (patrón SIWEB): si el refresh proactivo devolvió una
// sesión local firmada, el access_token guarda ESA sesión (bearer de la app,
// sobrevive aunque el refresh KC del Hub falle) y el token KC va a su propia
// cookie kc_access_token (solo para llamadas directas al Hub). Si no vino
// (flag apagado), comportamiento histórico: access_token = token KC.
const sessionToken = typeof tokens.session_token === 'string' ? tokens.session_token : null;
const kcAccessToken = tokens.access_token as string;
// access_token — NO HttpOnly (Bearer desde JS); fragmentado si el JWT supera ~4KB
setAccessTokenCookies(cookies, sessionToken ?? kcAccessToken, {
secure: isProduction,
maxAge: 60 * 60 * 24 * 7,
});
if (sessionToken) {
// kc_access_token — HttpOnly; solo el server lo usa para llamar al Hub.
cookies.set('kc_access_token', kcAccessToken, {
path: '/', httpOnly: true, secure: isProduction, sameSite: 'lax', maxAge: 60 * 60 * 24 * 7,
});
if (typeof tokens.session_id === 'string') {
cookies.set('crm_sid', tokens.session_id, {
path: '/', httpOnly: true, secure: isProduction, sameSite: 'lax', maxAge: 60 * 60 * 24 * 30,
});
}
}
// refresh_token — HttpOnly (never exposed to JS)
if (typeof tokens.refresh_token === 'string') {
cookies.set('refresh_token', tokens.refresh_token, {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
});
}
// id_token — requerido para id_token_hint en el logout de Keycloak.
// Puede venir del refresh proactivo o del exchange original.
if (typeof tokens.id_token === 'string') {
cookies.set('id_token', tokens.id_token, {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
}
// 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 (typeof tokens.tenant_id === 'number') {
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,
});
}
console.log('[SSO] cookies configuradas, preparando redirect a /dashboard con active_system:', requestedSystem ?? '(none)');
// Ejecutar lazy-link server-side: crear UserTenant si hay invite pendiente.
// Se llama con el Bearer token recién obtenido. Best-effort, no bloquea el SSO.
try {
const internalApiUrl = (
process.env.INTERNAL_API_URL ||
process.env.VITE_API_URL ||
'http://backend:8000/api/'
).replace(/\/+$/, '');
await fetch(`${internalApiUrl}/v1/auth/lazy-link`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${tokens.access_token as string}`,
'Content-Type': 'application/json',
},
}).catch(() => {});
} catch { /* non-blocking */ }
if (isValidSystem(requestedSystem)) {
setActiveSystemCookie(cookies, requestedSystem);
}
throw redirect(303, '/dashboard');
};