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>
This commit is contained in:
Ernesto Herrera
2026-07-17 09:01:10 -06:00
parent 223395b430
commit b76d42be83
7 changed files with 79 additions and 325 deletions

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

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