Merge pull request 'fix/logout-anexo' (#393) from fix/logout-anexo into development

Reviewed-on: ADUANASOFT/anexo76#393
This commit is contained in:
2026-05-14 17:19:53 +00:00
20 changed files with 163 additions and 53 deletions

View File

@@ -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

View File

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

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

@@ -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: '/' });
}
@@ -76,10 +77,22 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
redirectToWorkspaceLogin(cookies, url);
}
let tokens = await response.json();
let tokens: Record<string, unknown>;
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 +102,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 +115,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 +133,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,
@@ -136,10 +149,22 @@ 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.
if (tokens.tenant_id) {
if (typeof tokens.tenant_id === 'number') {
cookies.set('sso_tenant_id', String(tokens.tenant_id), {
path: '/',
httpOnly: true,
@@ -171,7 +196,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(() => {});

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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 {

View File

@@ -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 {

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