From a5b378ccff40a16452db188367571852dfea9bb2 Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Fri, 1 May 2026 21:30:52 -0500 Subject: [PATCH] refactor: centralize access token handling in cookies and improve related functions - Introduced utility functions for managing access tokens in cookies, including setting, getting, and clearing tokens. - Updated various components and server routes to utilize the new access token functions for better consistency and maintainability. - Removed redundant cookie handling code across the application, streamlining the authentication process. --- frontend/src/hooks.server.ts | 3 +- .../src/lib/access-token-cookie-browser.ts | 71 +++++++++++++++++++ .../src/lib/access-token-cookie.shared.ts | 32 +++++++++ frontend/src/lib/api.ts | 25 +++++-- frontend/src/lib/auth.ts | 49 ++++--------- .../keyboard/KeyboardManager.svelte | 3 +- frontend/src/lib/components/login-form.svelte | 15 ++-- frontend/src/lib/hooks/use-shortcuts.ts | 3 +- .../src/lib/server/access-token-cookie.ts | 55 ++++++++++++++ frontend/src/lib/server/api.ts | 16 +++-- .../auth/silent-refresh/+server.ts | 3 +- .../routes/api-sveltekit/classes/+server.ts | 3 +- .../api-sveltekit/classes/[id]/+server.ts | 3 +- .../company/my-companies/+server.ts | 3 +- .../rate/+server.ts | 3 +- .../routes/api-sveltekit/packages/+server.ts | 3 +- .../src/routes/api-sveltekit/parts/+server.ts | 3 +- .../api-sveltekit/parts/[id]/+server.ts | 3 +- .../api-sveltekit/payment-methods/+server.ts | 3 +- .../api-sveltekit/tariff-fractions/+server.ts | 3 +- .../api-sveltekit/units-of-measure/+server.ts | 3 +- .../units-of-measure/[id]/+server.ts | 3 +- .../src/routes/auth/callback/+page.server.ts | 6 +- frontend/src/routes/auth/sso/+page.server.ts | 8 +-- .../clients_and_providers/+page.svelte | 3 +- .../routes/dashboard/invoices/+page.svelte | 3 +- .../routes/dashboard/pedimentos/+page.svelte | 5 +- .../reference_data/countries/+page.svelte | 3 +- .../currency_types/+page.svelte | 3 +- .../document_types_digitization/+page.svelte | 3 +- frontend/src/routes/logout/+server.ts | 5 +- 31 files changed, 256 insertions(+), 91 deletions(-) create mode 100644 frontend/src/lib/access-token-cookie-browser.ts create mode 100644 frontend/src/lib/access-token-cookie.shared.ts create mode 100644 frontend/src/lib/server/access-token-cookie.ts diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 794de026..b4648d11 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,6 +1,7 @@ import type { Handle } from '@sveltejs/kit'; import { paraglideMiddleware } from '$lib/paraglide/server'; import { sequence } from '@sveltejs/kit/hooks'; +import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie'; const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => { event.request = request; @@ -12,7 +13,7 @@ const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(even const handleAuth: Handle = async ({ event, resolve }) => { // Obtener el token de las cookies - const token = event.cookies.get('access_token'); + const token = getAccessTokenFromCookies(event.cookies); // Agregar el token a los locals para que esté disponible en toda la app event.locals.token = token || null; diff --git a/frontend/src/lib/access-token-cookie-browser.ts b/frontend/src/lib/access-token-cookie-browser.ts new file mode 100644 index 00000000..a7ab23d3 --- /dev/null +++ b/frontend/src/lib/access-token-cookie-browser.ts @@ -0,0 +1,71 @@ +import { browser } from '$app/environment'; +import { + ACCESS_TOKEN_CHUNK_COUNT, + accessTokenChunkName, + splitAccessTokenForCookies, + ACCESS_TOKEN_MAX_CHUNKS +} from '$lib/access-token-cookie.shared'; + +function readCookieRaw(name: string): string | null { + if (!browser) return null; + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null; + return null; +} + +export function getAccessTokenFromDocument(): string | null { + if (!browser) return null; + const countRaw = readCookieRaw(ACCESS_TOKEN_CHUNK_COUNT); + if (countRaw) { + const n = parseInt(countRaw, 10); + if (!Number.isFinite(n) || n < 1 || n > ACCESS_TOKEN_MAX_CHUNKS) return null; + let out = ''; + for (let i = 0; i < n; i++) { + const p = readCookieRaw(accessTokenChunkName(i)); + if (p == null) return null; + out += p; + } + return out; + } + return readCookieRaw('access_token'); +} + +export function hasAccessTokenInDocument(): boolean { + if (!browser) return false; + return !!(readCookieRaw('access_token') || readCookieRaw(ACCESS_TOKEN_CHUNK_COUNT)); +} + +export function clearAccessTokenOnDocument() { + if (!browser) return; + const secure = window.location.protocol === 'https:' ? '; Secure' : ''; + const blank = `; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secure}`; + const clear = (name: string) => { + document.cookie = `${name}=${blank}`; + }; + clear('access_token'); + clear(ACCESS_TOKEN_CHUNK_COUNT); + for (let i = 0; i < ACCESS_TOKEN_MAX_CHUNKS; i++) { + clear(accessTokenChunkName(i)); + } +} + +/** Misma política que auth setCookie: expires + SameSite + Secure en HTTPS. */ +export function setAccessTokenOnDocument(token: string, days: number = 7) { + if (!browser) return; + clearAccessTokenOnDocument(); + const exp = new Date(); + exp.setDate(exp.getDate() + days); + const secure = window.location.protocol === 'https:' ? '; Secure' : ''; + const suffix = `; path=/; expires=${exp.toUTCString()}; SameSite=Lax${secure}`; + + const split = splitAccessTokenForCookies(token); + if (split.kind === 'single') { + document.cookie = `access_token=${split.value}${suffix}`; + return; + } + document.cookie = `${ACCESS_TOKEN_CHUNK_COUNT}=${split.parts.length}${suffix}`; + split.parts.forEach((part, i) => { + document.cookie = `${accessTokenChunkName(i)}=${part}${suffix}`; + }); +} diff --git a/frontend/src/lib/access-token-cookie.shared.ts b/frontend/src/lib/access-token-cookie.shared.ts new file mode 100644 index 00000000..182c073f --- /dev/null +++ b/frontend/src/lib/access-token-cookie.shared.ts @@ -0,0 +1,32 @@ +/** + * Fragmentación del JWT access_token en varias cookies cuando supera el límite ~4KB del navegador. + * La lógica de fetch / Bearer no cambia: solo lectura/escritura de cookies. + */ + +/** Por debajo de esto se usa una sola cookie `access_token` (compatibilidad). */ +export const ACCESS_TOKEN_MAX_SINGLE = 3500; + +export const ACCESS_TOKEN_CHUNK_SIZE = 3500; + +/** 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'; + +export const accessTokenChunkName = (index: number) => `access_token_${index}`; + +export type AccessTokenSplit = + | { kind: 'single'; value: string } + | { kind: 'chunks'; parts: string[] }; + +export function splitAccessTokenForCookies(token: string): AccessTokenSplit { + if (token.length <= ACCESS_TOKEN_MAX_SINGLE) { + return { kind: 'single', value: token }; + } + const parts: string[] = []; + for (let i = 0; i < token.length; i += ACCESS_TOKEN_CHUNK_SIZE) { + parts.push(token.slice(i, i + ACCESS_TOKEN_CHUNK_SIZE)); + } + return { kind: 'chunks', parts }; +} + +/** Máximo de fragmentos soportados (JWT muy grande). */ +export const ACCESS_TOKEN_MAX_CHUNKS = 32; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f3e09140..cd036ea2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4,9 +4,23 @@ import { getToken } from './auth'; import { browser } from '$app/environment'; import { toast } from 'svelte-sonner'; +import { clearAccessTokenOnDocument, setAccessTokenOnDocument } from '$lib/access-token-cookie-browser'; -// Normalize API_BASE_URL to remove trailing slash -const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, ''); +/** Base URL absoluta para fetch; corrige `http:host` sin `//` y añade `http://` si no hay esquema. */ +function normalizeAbsoluteApiBaseUrl(raw: string): string { + let s = (raw ?? '').trim().replace(/\/+$/, ''); + if (!s) return ''; + if (s.startsWith('http:') && !s.startsWith('http://')) { + s = 'http://' + s.slice('http:'.length).replace(/^\/+/, ''); + } + if (s.startsWith('https:') && !s.startsWith('https://')) { + s = 'https://' + s.slice('https:'.length).replace(/^\/+/, ''); + } + if (/^https?:\/\//i.test(s)) return s; + return `http://${s.replace(/^\/+/, '')}`; +} + +const API_BASE_URL = normalizeAbsoluteApiBaseUrl(String(import.meta.env.VITE_API_URL ?? '')); export interface ApiResponse { data?: T; @@ -164,8 +178,7 @@ async function refreshToken(): Promise { if (!response.ok) { console.error('❌ [API] Silent refresh falló, status:', response.status); - // Limpiar la cookie del access_token (no HttpOnly) para forzar re-login - document.cookie = 'access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC'; + clearAccessTokenOnDocument(); setTimeout(() => { window.location.href = '/login'; }, 1500); return null; } @@ -173,9 +186,7 @@ async function refreshToken(): Promise { const data = await response.json() as { access_token?: string }; if (data.access_token) { - // Actualizar cookie no-HttpOnly del access_token - const secure = window.location.protocol === 'https:' ? '; Secure' : ''; - document.cookie = `access_token=${data.access_token}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=Lax${secure}`; + setAccessTokenOnDocument(data.access_token); // Actualizar authStore en memoria try { diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index d30e14f9..490d7c7b 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -2,7 +2,7 @@ * Servicio de autenticación con Keycloak * * Seguridad de tokens: - * - access_token → en memoria (authStore) + cookie no-HttpOnly (para SSR) + * - access_token → en memoria (authStore) + cookies no-HttpOnly (una o varias si el JWT es grande) * - refresh_token → cookie HttpOnly únicamente (JS nunca lo lee directamente) * - El refresh se hace server-side via /api-sveltekit/auth/silent-refresh * - NO se usa localStorage para tokens @@ -11,6 +11,11 @@ import Keycloak from 'keycloak-js'; import { writable, derived } from 'svelte/store'; import { browser } from '$app/environment'; +import { + clearAccessTokenOnDocument, + getAccessTokenFromDocument, + setAccessTokenOnDocument +} from '$lib/access-token-cookie-browser'; // ───────────────────────────────────────────────────────── // Tipos @@ -45,35 +50,6 @@ const keycloakConfig = { let keycloakInstance: Keycloak | null = null; -// ───────────────────────────────────────────────────────── -// Cookie helpers (solo para access_token no-HttpOnly) -// ───────────────────────────────────────────────────────── - -/** Lee el valor de una cookie no-HttpOnly */ -const getCookie = (name: string): string | null => { - if (!browser) return null; - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null; - return null; -}; - -/** Escribe una cookie no-HttpOnly */ -const setCookie = (name: string, value: string, days: number = 7) => { - if (!browser) return; - const exp = new Date(); - exp.setDate(exp.getDate() + days); - const secure = window.location.protocol === 'https:' ? '; Secure' : ''; - document.cookie = `${name}=${value}; path=/; expires=${exp.toUTCString()}; SameSite=Lax${secure}`; -}; - -/** Elimina una cookie */ -const deleteCookie = (name: string) => { - if (!browser) return; - const secure = window.location.protocol === 'https:' ? '; Secure' : ''; - document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secure}`; -}; - // ───────────────────────────────────────────────────────── // Auth store (tokens solo en memoria) // ───────────────────────────────────────────────────────── @@ -139,7 +115,7 @@ export const initAuth = async (): Promise => { authStore.setLoading(true); // Restaurar token desde cookie no-HttpOnly (password login flow) - const cookieToken = getCookie('access_token'); + const cookieToken = getAccessTokenFromDocument(); if (cookieToken) { authStore.setToken(cookieToken); authStore.setAuthenticated(true); @@ -309,7 +285,7 @@ export const loginWithKeycloak = async (tenantSlug?: string) => { /** * Login con usuario/contraseña (legacy — el login principal es via form action del servidor). - * Los tokens se guardan en cookies (vía setCookie) y en memoria (authStore). + * Los tokens se guardan en cookies no-HttpOnly (fragmentadas si hace falta) y en memoria (authStore). * NO se guardan en localStorage. */ export const login = async (credentials: { @@ -330,7 +306,7 @@ export const login = async (credentials: { // Guardar en memoria y en cookie no-HttpOnly para SSR authStore.setToken(loginData.access_token); authStore.setAuthenticated(true); - setCookie('access_token', loginData.access_token); + setAccessTokenOnDocument(loginData.access_token); // El refresh_token llega en cookie HttpOnly desde el servidor. // NO lo guardamos en JS. await loadUserInfo(loginData.access_token); @@ -401,8 +377,7 @@ export const logout = async () => { // Limpiar estado en memoria authStore.reset(); - // Eliminar cookie no-HttpOnly del access_token - deleteCookie('access_token'); + clearAccessTokenOnDocument(); // La cookie HttpOnly del refresh_token la limpia el servidor // Logout unificado (SSO y password): POST al logout route del servidor. @@ -445,7 +420,7 @@ export const getToken = (): string | null => { if (token) return token; // Prioridad 3: cookie no-HttpOnly (fallback para acceso inicial antes del onMount) - if (browser) return getCookie('access_token'); + if (browser) return getAccessTokenFromDocument(); return null; }; @@ -488,7 +463,7 @@ export const refreshAccessToken = async (): Promise => { const data = await resp.json() as { access_token?: string }; if (data.access_token) { authStore.setToken(data.access_token); - setCookie('access_token', data.access_token); + setAccessTokenOnDocument(data.access_token); return true; } } catch (err) { diff --git a/frontend/src/lib/components/keyboard/KeyboardManager.svelte b/frontend/src/lib/components/keyboard/KeyboardManager.svelte index c27c4017..651661df 100644 --- a/frontend/src/lib/components/keyboard/KeyboardManager.svelte +++ b/frontend/src/lib/components/keyboard/KeyboardManager.svelte @@ -3,6 +3,7 @@ import { page } from '$app/stores'; import { browser } from '$app/environment'; import { GLOBAL_NAV } from '$lib/config/shortcuts'; + import { hasAccessTokenInDocument } from '$lib/access-token-cookie-browser'; import { shortcutStore, activeShortcutsList } from '$lib/stores/shortcut-store'; import { focusStore, interactionMode } from '$lib/stores/focus-store'; import ShortcutsHelpModal from './ShortcutsHelpModal.svelte'; @@ -30,7 +31,7 @@ const currentPath = $page?.url?.pathname || ''; const isAuthenticatedRoute = currentPath.startsWith('/dashboard'); const hasAccessToken = - document.cookie.includes('access_token=') || localStorage.getItem('access_token'); + hasAccessTokenInDocument() || localStorage.getItem('access_token'); return isAuthenticatedRoute && !!hasAccessToken; } diff --git a/frontend/src/lib/components/login-form.svelte b/frontend/src/lib/components/login-form.svelte index 28b9bb60..cf51a8be 100644 --- a/frontend/src/lib/components/login-form.svelte +++ b/frontend/src/lib/components/login-form.svelte @@ -1,20 +1,21 @@