Merge pull request 'refactor: centralize access token handling in cookies and improve related functions' (#356) from feature/cookies into development

Reviewed-on: ADUANASOFT/anexo76#356
This commit is contained in:
2026-05-02 02:38:48 +00:00
31 changed files with 256 additions and 91 deletions

View File

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

View File

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

View File

@@ -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<T = any> {
data?: T;
@@ -164,8 +178,7 @@ async function refreshToken(): Promise<string | null> {
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<string | null> {
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 {

View File

@@ -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<boolean> => {
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<boolean> => {
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) {

View File

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

View File

@@ -1,20 +1,21 @@
<script lang="ts">
import * as Card from "$lib/components/ui/card/index.ts";
import * as Card from "$lib/components/ui/card/index";
import {
FieldGroup,
Field,
FieldLabel,
FieldDescription,
} from "$lib/components/ui/field/index.ts";
import { Input } from "$lib/components/ui/input/index.ts";
import { Button } from "$lib/components/ui/button/index.ts";
import { cn } from "$lib/utils.ts";
} from "$lib/components/ui/field/index";
import { Input } from "$lib/components/ui/input/index";
import { Button } from "$lib/components/ui/button/index";
import { cn } from "$lib/utils";
import faviconUrl from '$lib/assets/favicon.svg';
import type { HTMLAttributes } from "svelte/elements";
import { page } from '$app/state';
import { enhance } from '$app/forms';
import { loginWithProvider } from '$lib/sso.ts';
import { loginWithProvider } from '$lib/sso';
import { onMount, tick } from 'svelte';
import { clearAccessTokenOnDocument } from '$lib/access-token-cookie-browser';
let { class: className, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
@@ -51,7 +52,7 @@
if (typeof document !== 'undefined') {
const isSecure = window.location.protocol === 'https:';
const secureFlag = isSecure ? '; Secure' : '';
document.cookie = `access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secureFlag}`;
clearAccessTokenOnDocument();
document.cookie = `refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secureFlag}`;
document.cookie = `active_company_id=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secureFlag}`;
}

View File

@@ -3,6 +3,7 @@ import { shortcutStore, type ShortcutDef } from '$lib/stores/shortcut-store';
import { page } from '$app/stores';
import { get } from 'svelte/store';
import { browser } from '$app/environment';
import { hasAccessTokenInDocument } from '$lib/access-token-cookie-browser';
/**
* Hook to register shortcuts for a component lifecycle.
@@ -19,7 +20,7 @@ export function useShortcuts(context: string, shortcuts: ShortcutDef[]) {
const isAuthenticatedRoute = currentPath.startsWith('/dashboard');
// Verificar también que haya un token de acceso
const hasAccessToken = document.cookie.includes('access_token=') ||
const hasAccessToken = hasAccessTokenInDocument() ||
localStorage.getItem('access_token');
if (isAuthenticatedRoute && hasAccessToken) {

View File

@@ -0,0 +1,55 @@
import type { Cookies } from '@sveltejs/kit';
import {
ACCESS_TOKEN_CHUNK_COUNT,
accessTokenChunkName,
splitAccessTokenForCookies,
ACCESS_TOKEN_MAX_CHUNKS
} from '$lib/access-token-cookie.shared';
export function getAccessTokenFromCookies(cookies: Cookies): string | null {
const countRaw = cookies.get(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 = cookies.get(accessTokenChunkName(i));
if (p == null) return null;
out += p;
}
return out;
}
return cookies.get('access_token') ?? null;
}
export function clearAccessTokenCookies(cookies: Cookies) {
cookies.delete('access_token', { path: '/' });
cookies.delete(ACCESS_TOKEN_CHUNK_COUNT, { path: '/' });
for (let i = 0; i < ACCESS_TOKEN_MAX_CHUNKS; i++) {
cookies.delete(accessTokenChunkName(i), { path: '/' });
}
}
export function setAccessTokenCookies(
cookies: Cookies,
token: string,
opts: { secure: boolean; maxAge: number }
) {
clearAccessTokenCookies(cookies);
const split = splitAccessTokenForCookies(token);
const base = {
path: '/',
httpOnly: false as const,
sameSite: 'lax' as const,
secure: opts.secure,
maxAge: opts.maxAge
};
if (split.kind === 'single') {
cookies.set('access_token', split.value, base);
return;
}
cookies.set(ACCESS_TOKEN_CHUNK_COUNT, String(split.parts.length), base);
split.parts.forEach((part, i) => {
cookies.set(accessTokenChunkName(i), part, base);
});
}

View File

@@ -4,6 +4,11 @@
*/
import { redirect, type Cookies } from '@sveltejs/kit';
import {
clearAccessTokenCookies,
getAccessTokenFromCookies,
setAccessTokenCookies
} from '$lib/server/access-token-cookie';
/**
* Obtiene y normaliza la URL base de la API para llamadas desde el servidor
@@ -34,7 +39,7 @@ export function getServerApiUrl(): string {
*/
export function getAuthTokens(cookies: Cookies) {
return {
accessToken: cookies.get('access_token'),
accessToken: getAccessTokenFromCookies(cookies),
refreshToken: cookies.get('refresh_token')
};
}
@@ -43,7 +48,7 @@ export function getAuthTokens(cookies: Cookies) {
* Establece los tokens de autenticación en las cookies
*
* Política de seguridad:
* - access_token → NO HttpOnly (el cliente necesita incluirlo en el header Authorization)
* - access_token → NO HttpOnly (Bearer desde JS); si el JWT es muy grande, varias cookies fragmentadas
* - refresh_token → HttpOnly=true (JS nunca lo lee; el servidor lo maneja via /api-sveltekit/auth/silent-refresh)
*/
export function setAuthTokens(
@@ -51,10 +56,7 @@ export function setAuthTokens(
accessToken: string,
refreshToken?: string
) {
cookies.set('access_token', accessToken, {
path: '/',
httpOnly: false, // El cliente JS necesita leerlo para el header Bearer
sameSite: 'lax',
setAccessTokenCookies(cookies, accessToken, {
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7 // 7 días
});
@@ -74,7 +76,7 @@ export function setAuthTokens(
* Limpia todos los tokens de autenticación de las cookies
*/
export function clearAuthTokens(cookies: Cookies) {
cookies.delete('access_token', { path: '/' });
clearAccessTokenCookies(cookies);
cookies.delete('refresh_token', { path: '/' });
cookies.delete('active_company_id', { path: '/' });
}