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:
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user