fix(auth): enhance logout and login flow with Keycloak integration and cookie management

This commit is contained in:
2026-05-14 12:07:10 -05:00
parent dacfe8f6e8
commit 0ed350ca11
8 changed files with 83 additions and 17 deletions

View File

@@ -315,5 +315,5 @@ networks:
driver: bridge
hub-net:
external: false
external: true
name: aduanasoft-hub_default

View File

@@ -180,7 +180,10 @@ async function refreshToken(): Promise<string | null> {
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;
}

View File

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

View File

@@ -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: '/' });
}

View File

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

View File

@@ -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');

View File

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

View File

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