From 228cd533d239e02ad2e30321656d8b12ef28b6fb Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Thu, 14 May 2026 08:30:15 -0500 Subject: [PATCH 1/6] fix(sso): reduce cookie chunk threshold and add defensive error handling in SSO exchange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reduce ACCESS_TOKEN_MAX_SINGLE y ACCESS_TOKEN_CHUNK_SIZE de 3500 a 2800 para garantizar que cada cookie fragmentada no supere el límite de 4096 bytes del navegador (incluye overhead del nombre, path, attrs). - Agrega try/catch alrededor de response.json() en el load de /auth/sso para evitar SyntaxError no manejado si el body no es JSON. - Type guards explícitos en tokens.access_token, refresh_token y tenant_id para detectar respuestas incompletas del hub antes de llamar cookies.set(). - .catch(() => ({})) en refreshRes.json() para que el refresh proactivo nunca bloquee el flujo SSO aunque el body sea inesperado. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/access-token-cookie.shared.ts | 4 +-- frontend/src/routes/auth/sso/+page.server.ts | 28 +++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/access-token-cookie.shared.ts b/frontend/src/lib/access-token-cookie.shared.ts index 182c073f..84766451 100644 --- a/frontend/src/lib/access-token-cookie.shared.ts +++ b/frontend/src/lib/access-token-cookie.shared.ts @@ -4,9 +4,9 @@ */ /** Por debajo de esto se usa una sola cookie `access_token` (compatibilidad). */ -export const ACCESS_TOKEN_MAX_SINGLE = 3500; +export const ACCESS_TOKEN_MAX_SINGLE = 2800; -export const ACCESS_TOKEN_CHUNK_SIZE = 3500; +export const ACCESS_TOKEN_CHUNK_SIZE = 2800; /** Número de fragmentos; si existe, el token está en access_token_0..access_token_{n-1}. */ export const ACCESS_TOKEN_CHUNK_COUNT = 'access_token_chunks'; diff --git a/frontend/src/routes/auth/sso/+page.server.ts b/frontend/src/routes/auth/sso/+page.server.ts index 1a87a7d6..df2560a8 100644 --- a/frontend/src/routes/auth/sso/+page.server.ts +++ b/frontend/src/routes/auth/sso/+page.server.ts @@ -76,10 +76,22 @@ export const load: PageServerLoad = async ({ url, cookies }) => { redirectToWorkspaceLogin(cookies, url); } - let tokens = await response.json(); + let tokens: Record; + 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?.length, + accessTokenLen: (tokens.access_token as string).length, hasRefreshToken: !!tokens.refresh_token, tenant_id: tokens.tenant_id, tenant_slug: tokens.tenant_slug, @@ -89,7 +101,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => { // 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í: Anexo76 backend → Hub → KC interno → iss=hub-keycloak:8080 → válido. - if (tokens.refresh_token) { + if (typeof tokens.refresh_token === 'string') { try { const internalApiUrl = ( process.env.INTERNAL_API_URL || @@ -102,7 +114,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => { body: JSON.stringify({ refresh_token: tokens.refresh_token }), }); if (refreshRes.ok) { - const refreshed = await refreshRes.json(); + const refreshed = await refreshRes.json().catch(() => ({})); if (refreshed.access_token && refreshed.refresh_token) { tokens = { ...tokens, ...refreshed }; console.log('[SSO] tokens refrescados exitosamente (iss normalizado)'); @@ -120,13 +132,13 @@ export const load: PageServerLoad = async ({ url, cookies }) => { console.log('[SSO] ORIGIN-based secure context:', isProduction); // access_token — NO HttpOnly (Bearer desde JS); fragmentado si el JWT supera ~4KB - setAccessTokenCookies(cookies, tokens.access_token, { + setAccessTokenCookies(cookies, tokens.access_token as string, { secure: isProduction, maxAge: 60 * 60 * 24 * 7, }); // refresh_token — HttpOnly (never exposed to JS) - if (tokens.refresh_token) { + if (typeof tokens.refresh_token === 'string') { cookies.set('refresh_token', tokens.refresh_token, { path: '/', httpOnly: true, @@ -139,7 +151,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => { // 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 (tokens.tenant_id) { + if (typeof tokens.tenant_id === 'number') { cookies.set('sso_tenant_id', String(tokens.tenant_id), { path: '/', httpOnly: true, @@ -171,7 +183,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => { await fetch(`${internalApiUrl}/v1/auth/lazy-link`, { method: 'POST', headers: { - 'Authorization': `Bearer ${tokens.access_token}`, + 'Authorization': `Bearer ${tokens.access_token as string}`, 'Content-Type': 'application/json', }, }).catch(() => {}); From 85f31f8eca7d74a6916ac7fc64c1c3832ef527ab Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Thu, 14 May 2026 09:09:34 -0500 Subject: [PATCH 2/6] fix(auth): replace all /login redirects with redirectToWorkspaceLogin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Todos los page.server.ts que hacían throw redirect(302, '/login') ahora usan redirectToWorkspaceLogin(cookies, url) para enviar al usuario directo al login del workspace en lugar de pasar por la página /login local de Anexo76. Archivos actualizados: - dashboard/pedimentos/+page.server.ts - dashboard/pedimentos/edit/[id]/+page.server.ts (2 redirects) - dashboard/invoices/+page.server.ts - dashboard/invoices/edit/[id]/+page.server.ts - dashboard/invoices/settings/+page.server.ts - dashboard/goods/parts/edit/[[id]]/+page.server.ts - dashboard/general_catalogs/ports/+page.server.ts - dashboard/general_catalogs/seal/+page.server.ts - dashboard/reports/vencimiento/+page.server.ts - dashboard/reports/invoices/+page.server.ts Co-Authored-By: Claude Sonnet 4.6 --- .../dashboard/general_catalogs/ports/+page.server.ts | 3 ++- .../dashboard/general_catalogs/seal/+page.server.ts | 3 ++- .../dashboard/goods/parts/edit/[[id]]/+page.server.ts | 5 +++-- frontend/src/routes/dashboard/invoices/+page.server.ts | 9 +++++---- .../routes/dashboard/invoices/edit/[id]/+page.server.ts | 3 ++- .../routes/dashboard/invoices/settings/+page.server.ts | 3 ++- frontend/src/routes/dashboard/pedimentos/+page.server.ts | 3 ++- .../dashboard/pedimentos/edit/[id]/+page.server.ts | 7 ++++--- .../routes/dashboard/reports/invoices/+page.server.ts | 6 +++--- .../routes/dashboard/reports/vencimiento/+page.server.ts | 6 +++--- 10 files changed, 28 insertions(+), 20 deletions(-) diff --git a/frontend/src/routes/dashboard/general_catalogs/ports/+page.server.ts b/frontend/src/routes/dashboard/general_catalogs/ports/+page.server.ts index c1600afa..d1a33c46 100644 --- a/frontend/src/routes/dashboard/general_catalogs/ports/+page.server.ts +++ b/frontend/src/routes/dashboard/general_catalogs/ports/+page.server.ts @@ -1,12 +1,13 @@ import { getServerApiUrl, getAuthTokens } from '$lib/server/api'; import type { PageServerLoad } from './$types'; import { redirect } from '@sveltejs/kit'; +import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth'; export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { const parentData = await parent(); const { accessToken } = getAuthTokens(cookies); if (!accessToken) { - throw redirect(302, '/login'); + redirectToWorkspaceLogin(cookies, url); } const page = Number(url.searchParams.get('page')) || 1; diff --git a/frontend/src/routes/dashboard/general_catalogs/seal/+page.server.ts b/frontend/src/routes/dashboard/general_catalogs/seal/+page.server.ts index f0341324..81639c28 100644 --- a/frontend/src/routes/dashboard/general_catalogs/seal/+page.server.ts +++ b/frontend/src/routes/dashboard/general_catalogs/seal/+page.server.ts @@ -1,12 +1,13 @@ import { getServerApiUrl, getAuthTokens } from '$lib/server/api'; import type { PageServerLoad } from './$types'; import { redirect } from '@sveltejs/kit'; +import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth'; export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { const parentData = await parent(); const { accessToken } = getAuthTokens(cookies); if (!accessToken) { - throw redirect(302, '/login'); + redirectToWorkspaceLogin(cookies, url); } const page = Number(url.searchParams.get('page')) || 1; diff --git a/frontend/src/routes/dashboard/goods/parts/edit/[[id]]/+page.server.ts b/frontend/src/routes/dashboard/goods/parts/edit/[[id]]/+page.server.ts index 0e4e1801..093b3558 100644 --- a/frontend/src/routes/dashboard/goods/parts/edit/[[id]]/+page.server.ts +++ b/frontend/src/routes/dashboard/goods/parts/edit/[[id]]/+page.server.ts @@ -1,12 +1,13 @@ import type { PageServerLoad } from './$types'; import { error, redirect } from '@sveltejs/kit'; import { getAuthTokens, getActiveCompanyId, authenticatedFetch } from '$lib/server/api'; +import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth'; -export const load: PageServerLoad = async ({ params, cookies, fetch }) => { +export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => { const { accessToken } = getAuthTokens(cookies); if (!accessToken) { - throw redirect(302, '/login'); + redirectToWorkspaceLogin(cookies, url); } const companyId = await getActiveCompanyId(cookies, fetch); diff --git a/frontend/src/routes/dashboard/invoices/+page.server.ts b/frontend/src/routes/dashboard/invoices/+page.server.ts index aabc158e..2ee27026 100644 --- a/frontend/src/routes/dashboard/invoices/+page.server.ts +++ b/frontend/src/routes/dashboard/invoices/+page.server.ts @@ -1,9 +1,10 @@ import type { PageServerLoad } from './$types'; import { redirect } from '@sveltejs/kit'; -import { - getAuthTokens, - authenticatedFetch +import { + getAuthTokens, + authenticatedFetch } from '$lib/server/api'; +import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth'; export const load: PageServerLoad = async ({ fetch, cookies, url, parent }) => { const invoiceType = url.searchParams.get('invoice_type'); @@ -21,7 +22,7 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, parent }) => { const accessToken = tokens.accessToken; if (!accessToken) { - throw redirect(302, '/login'); + redirectToWorkspaceLogin(cookies, url); } try { diff --git a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.server.ts b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.server.ts index 2d1eee3b..cc231385 100644 --- a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.server.ts +++ b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.server.ts @@ -1,12 +1,13 @@ import type { PageServerLoad } from './$types'; import { error, redirect } from '@sveltejs/kit'; import { getAuthTokens, getActiveCompanyId, authenticatedFetch } from '$lib/server/api'; +import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth'; export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => { const { accessToken } = getAuthTokens(cookies); if (!accessToken) { - throw redirect(302, '/login'); + redirectToWorkspaceLogin(cookies, url); } // Obtener el company_id de la cookie diff --git a/frontend/src/routes/dashboard/invoices/settings/+page.server.ts b/frontend/src/routes/dashboard/invoices/settings/+page.server.ts index 852ba58b..a27d86e7 100644 --- a/frontend/src/routes/dashboard/invoices/settings/+page.server.ts +++ b/frontend/src/routes/dashboard/invoices/settings/+page.server.ts @@ -1,12 +1,13 @@ import type { PageServerLoad } from './$types'; import { error, redirect } from '@sveltejs/kit'; import { getAuthTokens, getActiveCompanyId, authenticatedFetch } from '$lib/server/api'; +import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth'; export const load: PageServerLoad = async ({ cookies, fetch, url }) => { const { accessToken } = getAuthTokens(cookies); if (!accessToken) { - throw redirect(302, '/login'); + redirectToWorkspaceLogin(cookies, url); } const companyId = await getActiveCompanyId(cookies, fetch); diff --git a/frontend/src/routes/dashboard/pedimentos/+page.server.ts b/frontend/src/routes/dashboard/pedimentos/+page.server.ts index 16be9ae1..174ce6b5 100644 --- a/frontend/src/routes/dashboard/pedimentos/+page.server.ts +++ b/frontend/src/routes/dashboard/pedimentos/+page.server.ts @@ -1,6 +1,7 @@ import type { PageServerLoad } from './$types'; import { redirect } from '@sveltejs/kit'; import { getAuthTokens, authenticatedFetch } from '$lib/server/api'; +import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth'; export const load: PageServerLoad = async ({ fetch, cookies, url, parent }) => { // Esperar a que el layout padre valide/refresque el token @@ -10,7 +11,7 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, parent }) => { const { accessToken } = getAuthTokens(cookies); if (!accessToken) { - throw redirect(302, '/login'); + redirectToWorkspaceLogin(cookies, url); } try { diff --git a/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.server.ts b/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.server.ts index b00ba4d8..65f89aea 100644 --- a/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.server.ts +++ b/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.server.ts @@ -1,12 +1,13 @@ import type { PageServerLoad } from './$types'; import { error, redirect } from '@sveltejs/kit'; import { getAuthTokens, getActiveCompanyId, authenticatedFetch } from '$lib/server/api'; +import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth'; -export const load: PageServerLoad = async ({ params, cookies, fetch }) => { +export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => { const { accessToken } = getAuthTokens(cookies); if (!accessToken) { - throw redirect(302, '/login'); + redirectToWorkspaceLogin(cookies, url); } // Obtener el company_id de la cookie para ambas ramas (new y edit) @@ -84,7 +85,7 @@ export const load: PageServerLoad = async ({ params, cookies, fetch }) => { throw error(404, 'Pedimento no encontrado'); } if (response.status === 401) { - throw redirect(302, '/login'); + redirectToWorkspaceLogin(cookies, url); } if (response.status === 403) { throw error(403, 'No tiene permiso para editar pedimentos'); diff --git a/frontend/src/routes/dashboard/reports/invoices/+page.server.ts b/frontend/src/routes/dashboard/reports/invoices/+page.server.ts index d86c1b74..b4131ab5 100644 --- a/frontend/src/routes/dashboard/reports/invoices/+page.server.ts +++ b/frontend/src/routes/dashboard/reports/invoices/+page.server.ts @@ -1,12 +1,12 @@ import type { PageServerLoad } from './$types'; -import { redirect } from '@sveltejs/kit'; import { getAuthTokens } from '$lib/server/api'; +import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth'; -export const load: PageServerLoad = async ({ cookies }) => { +export const load: PageServerLoad = async ({ cookies, url }) => { const { accessToken } = getAuthTokens(cookies); if (!accessToken) { - throw redirect(302, '/login'); + redirectToWorkspaceLogin(cookies, url); } return { diff --git a/frontend/src/routes/dashboard/reports/vencimiento/+page.server.ts b/frontend/src/routes/dashboard/reports/vencimiento/+page.server.ts index d279a7d0..cc752309 100644 --- a/frontend/src/routes/dashboard/reports/vencimiento/+page.server.ts +++ b/frontend/src/routes/dashboard/reports/vencimiento/+page.server.ts @@ -1,12 +1,12 @@ import type { PageServerLoad } from './$types'; -import { redirect } from '@sveltejs/kit'; import { getAuthTokens } from '$lib/server/api'; +import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth'; -export const load: PageServerLoad = async ({ cookies }) => { +export const load: PageServerLoad = async ({ cookies, url }) => { const { accessToken } = getAuthTokens(cookies); if (!accessToken) { - throw redirect(302, '/login'); + redirectToWorkspaceLogin(cookies, url); } return { From dacfe8f6e88f5ae02556fd0b380e341293f17552 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Thu, 14 May 2026 09:19:24 -0500 Subject: [PATCH 3/6] docs: update frontend .env.example with all required vars for dev local Agrega INTERNAL_API_URL, INTERNAL_HUB_URL, HUB_URL, KEYCLOAK_URL y ORIGIN al ejemplo para que el dev local tenga el mismo conjunto de variables que usa Docker Compose, evitando que el SSO exchange y el refresh proactivo fallen silenciosamente por vars indefinidas. Co-Authored-By: Claude Sonnet 4.6 --- frontend/.env.example | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/frontend/.env.example b/frontend/.env.example index 000f9014..d4cf3ba4 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,11 +1,22 @@ -# Environment variables para frontend -VITE_API_URL=http://localhost:8000/api/ +# ─── Copia este archivo a .env y ajusta los valores para dev local ───────────── -# Configuración de Keycloak para SSO -VITE_KEYCLOAK_URL=http://localhost:8080 +# API de Anexo76 (frontend y SSR) +VITE_API_URL=http://localhost:8000/api/ +INTERNAL_API_URL=http://localhost:8000/api/ + +# Hub Workspace +VITE_HUB_URL=http://localhost:3001 +HUB_URL=http://localhost:3001 +INTERNAL_HUB_URL=http://localhost:8001 + +# Keycloak +VITE_KEYCLOAK_URL=http://localhost:8085/kcauth VITE_KEYCLOAK_REALM=master VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend +KEYCLOAK_URL=http://localhost:8085/kcauth +KEYCLOAK_REALM=master +KEYCLOAK_CLIENT_ID=anexo76-frontend +# KEYCLOAK_CLIENT_SECRET= # solo si el cliente KC no es público -# Opcional: Habilitar/deshabilitar proveedores SSO -PUBLIC_ENABLE_MICROSOFT_SSO=true -PUBLIC_ENABLE_GOOGLE_SSO=false +# SvelteKit — necesario para cookies secure y URLs SSR correctas +ORIGIN=http://localhost:5173 From 0ed350ca119aec3942c016753f252c6c9c5b5520 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Thu, 14 May 2026 12:07:10 -0500 Subject: [PATCH 4/6] fix(auth): enhance logout and login flow with Keycloak integration and cookie management --- docker-compose.yml | 2 +- frontend/src/lib/api.ts | 5 ++- frontend/src/lib/auth.ts | 3 +- frontend/src/lib/server/api.ts | 1 + frontend/src/lib/server/workspace-auth.ts | 36 ++++++++++++++++--- .../src/routes/auth/callback/+page.server.ts | 10 ++++++ frontend/src/routes/login/+page.server.ts | 22 +++++++++--- frontend/src/routes/logout/+server.ts | 21 ++++++++--- 8 files changed, 83 insertions(+), 17 deletions(-) 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)); }; From 7a7e8a67a317f297ec8a0377e00d690f010dcc6f Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Thu, 14 May 2026 12:15:37 -0500 Subject: [PATCH 5/6] fix(auth): ensure id_token is cleared and set during SSO exchange for Keycloak logout --- frontend/src/routes/auth/sso/+page.server.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/src/routes/auth/sso/+page.server.ts b/frontend/src/routes/auth/sso/+page.server.ts index df2560a8..9a5863a5 100644 --- a/frontend/src/routes/auth/sso/+page.server.ts +++ b/frontend/src/routes/auth/sso/+page.server.ts @@ -29,6 +29,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => { 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: '/' }); } @@ -148,6 +149,18 @@ export const load: PageServerLoad = async ({ url, cookies }) => { }); } + // 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. From 0c876b11bf8eda47cd965b87112684bb95832f29 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Thu, 14 May 2026 12:19:16 -0500 Subject: [PATCH 6/6] fix(docker): change hub-net to be non-external in docker-compose --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index ad7e7cda..b948675a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -315,5 +315,5 @@ networks: driver: bridge hub-net: - external: true + external: false name: aduanasoft-hub_default