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>
233 lines
8.9 KiB
TypeScript
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');
|
|
};
|