diff --git a/docker-compose.yml b/docker-compose.yml index b948675a..ad7e7cda 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -315,5 +315,5 @@ networks: driver: bridge hub-net: - external: false + external: true name: aduanasoft-hub_default diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d34ecb93..d687b492 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -180,7 +180,10 @@ async function refreshToken(): Promise { if (!response.ok) { console.error('❌ [API] Silent refresh falló, status:', response.status); clearAccessTokenOnDocument(); - setTimeout(() => { window.location.href = '/login'; }, 1500); + const hubBase = (import.meta.env.VITE_HUB_URL || 'https://workspace.aduanasoft.com').replace(/\/+$/, ''); + setTimeout(() => { + window.location.href = `${hubBase}/login?return_to=${encodeURIComponent(window.location.origin + '/login?sso_verified=1')}`; + }, 1500); return null; } diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index c17b371d..288f50ad 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -662,7 +662,8 @@ export const logout = async () => { form.submit(); } catch (err) { console.error('[auth] Error durante logout:', err); - window.location.href = '/login'; + const hubBase = (import.meta.env.VITE_HUB_URL || 'https://workspace.aduanasoft.com').replace(/\/+$/, ''); + window.location.href = `${hubBase}/login?return_to=${encodeURIComponent(window.location.origin + '/login?sso_verified=1')}`; } }; diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts index 6d8c9980..fb09ca23 100644 --- a/frontend/src/lib/server/api.ts +++ b/frontend/src/lib/server/api.ts @@ -79,6 +79,7 @@ export function setAuthTokens( export function clearAuthTokens(cookies: Cookies) { clearAccessTokenCookies(cookies); cookies.delete('refresh_token', { path: '/' }); + cookies.delete('id_token', { path: '/' }); cookies.delete('active_company_id', { path: '/' }); } diff --git a/frontend/src/lib/server/workspace-auth.ts b/frontend/src/lib/server/workspace-auth.ts index d1a4f628..e9902830 100644 --- a/frontend/src/lib/server/workspace-auth.ts +++ b/frontend/src/lib/server/workspace-auth.ts @@ -147,6 +147,26 @@ export function buildKeycloakAuthorizationUrl(systemBaseUrl: string, redirectPat 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(); + const redirectUri = `${systemBaseUrl}/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 { storeWorkspaceReturnPath(cookies, url); throw redirect(303, getWorkspaceLoginUrl(url.origin)); @@ -156,17 +176,23 @@ export function redirectToKeycloakAuthorization(systemBaseUrl: string, redirectP throw redirect(303, buildKeycloakAuthorizationUrl(systemBaseUrl, redirectPath)); } -export function buildKeycloakLogoutUrl(systemBaseUrl: string): string { +export function redirectToKeycloakLogin(systemBaseUrl: string, redirectPath: string): never { + throw redirect(303, buildKeycloakLoginUrl(systemBaseUrl, redirectPath)); +} + +export function buildKeycloakLogoutUrl(systemBaseUrl: string, idTokenHint?: string): string { const keycloakBaseUrl = getPublicKeycloakBaseUrl(); - // post_logout_redirect_uri must be a URI registered in the KC client. - // The workspace login URL (workspace.aduanasoft.com/login) is NOT registered there. - // Use a local /auth/post-logout route which IS covered by the app's registered wildcard, - // then that route bounces to workspace login. 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); + } + return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/logout?${params.toString()}`; } \ No newline at end of file diff --git a/frontend/src/routes/auth/callback/+page.server.ts b/frontend/src/routes/auth/callback/+page.server.ts index 3a7c6cc1..899a7ae1 100644 --- a/frontend/src/routes/auth/callback/+page.server.ts +++ b/frontend/src/routes/auth/callback/+page.server.ts @@ -94,6 +94,16 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => { 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'); diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index 3b159fa0..2560714a 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -5,10 +5,12 @@ import { getWorkspaceLoginUrl, readWorkspaceReturnPath, storeReturnPath, - redirectToKeycloakAuthorization + redirectToKeycloakAuthorization, + redirectToKeycloakLogin } from '$lib/server/workspace-auth'; export const load: PageServerLoad = async ({ cookies, url }) => { + console.error('[LOGIN] url.pathname:', url.pathname, '| params:', Object.fromEntries(url.searchParams)); clearAuthTokens(cookies); // Workspace redirige de vuelta aquí con ?sso_verified=1 después de que el usuario @@ -26,12 +28,22 @@ export const load: PageServerLoad = async ({ cookies, url }) => { redirectToKeycloakAuthorization(url.origin, intendedPath); } - // Sin sso_verified → primera visita o sesión expirada. - // Guardar la ruta deseada y mandar al Workspace a autenticar. - const intendedPath = url.searchParams.get('redirect') || '/dashboard'; - if (intendedPath !== '/dashboard') { + // El Hub devolvió ?redirect= sin sso_verified=1. + // Ir directo a Keycloak SIN prompt=none para romper el loop Hub↔login: + // - KC tiene sesión activa → devuelve código al callback → éxito. + // - KC no tiene sesión → muestra el form de login → callback → éxito. + 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); } + // Primera visita sin ningún parámetro → guardar destino y mandar al workspace. + storeReturnPath(cookies, '/dashboard'); throw redirect(303, getWorkspaceLoginUrl(url.origin)); }; diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts index 162e4a65..f5ad03d4 100644 --- a/frontend/src/routes/logout/+server.ts +++ b/frontend/src/routes/logout/+server.ts @@ -1,18 +1,31 @@ import { redirect } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { clearAccessTokenCookies } from '$lib/server/access-token-cookie'; -import { buildKeycloakLogoutUrl, clearWorkspaceReturnPath } from '$lib/server/workspace-auth'; +import { + buildKeycloakLogoutUrl, + clearWorkspaceReturnPath, + getWorkspaceLoginUrl +} from '$lib/server/workspace-auth'; -export const POST: RequestHandler = async ({ cookies, request, url }) => { +export const POST: RequestHandler = async ({ cookies, url }) => { const systemBaseUrl = url.origin; - // Eliminar todas las cookies de autenticación (access_token puede estar fragmentado) + const idToken = cookies.get('id_token'); + + // Eliminar todas las cookies de autenticación clearAccessTokenCookies(cookies); cookies.delete('refresh_token', { path: '/' }); + cookies.delete('id_token', { path: '/' }); cookies.delete('active_company_id', { path: '/' }); cookies.delete('sso_tenant_id', { path: '/' }); cookies.delete('sso_tenant_pub', { path: '/' }); clearWorkspaceReturnPath(cookies); - throw redirect(303, buildKeycloakLogoutUrl(systemBaseUrl)); + // Sin id_token_hint KC rechaza post_logout_redirect_uri no registrado. + // En ese caso redirigir directo al workspace — las cookies ya están limpias. + if (!idToken) { + throw redirect(303, getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true })); + } + + throw redirect(303, buildKeycloakLogoutUrl(systemBaseUrl, idToken)); };