Files
plantillas-proyectos/frontend/src/lib/auth.ts
AlexeerCT a5b378ccff 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.
2026-05-01 21:30:52 -05:00

484 lines
16 KiB
TypeScript

/**
* Servicio de autenticación con Keycloak
*
* Seguridad de tokens:
* - 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
*/
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
// ─────────────────────────────────────────────────────────
export interface User {
id: string;
username: string;
email?: string;
name?: string;
tenantId?: number;
roles: string[];
permissions: string[];
}
export interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
user: User | null;
token: string | null;
}
// ─────────────────────────────────────────────────────────
// Configuración de Keycloak
// ─────────────────────────────────────────────────────────
const keycloakConfig = {
url: import.meta.env.VITE_KEYCLOAK_URL,
realm: import.meta.env.VITE_KEYCLOAK_REALM,
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID
};
let keycloakInstance: Keycloak | null = null;
// ─────────────────────────────────────────────────────────
// Auth store (tokens solo en memoria)
// ─────────────────────────────────────────────────────────
const createAuthStore = () => {
const { subscribe, set, update } = writable<AuthState>({
isAuthenticated: false,
isLoading: true,
user: null,
token: null
});
return {
subscribe,
setAuthenticated: (authenticated: boolean) =>
update((s) => ({ ...s, isAuthenticated: authenticated })),
setLoading: (loading: boolean) =>
update((s) => ({ ...s, isLoading: loading })),
setUser: (user: User | null) => update((s) => ({ ...s, user })),
setToken: (token: string | null) => update((s) => ({ ...s, token })),
/** ⚠️ Los tokens ya NO se guardan en localStorage; solo en memoria. */
setTokens: (accessToken: string, _refreshToken?: string) => {
update((s) => ({ ...s, token: accessToken }));
// El refresh_token llega en cookie HttpOnly desde el servidor;
// el cliente no lo almacena ni lo lee en ningún momento.
},
reset: () =>
set({
isAuthenticated: false,
isLoading: false,
user: null,
token: null
})
};
};
export const authStore = createAuthStore();
export const isAuthenticated = derived(authStore, ($a) => $a.isAuthenticated);
export const currentUser = derived(authStore, ($a) => $a.user);
/**
* Verifica si el usuario tiene un permiso específico
*/
export function userHasPermission(user: User | null, permission: string): boolean {
if (!user) return false;
return user.roles.includes('admin') || user.permissions.includes(permission);
}
// ─────────────────────────────────────────────────────────
// Inicialización
// ─────────────────────────────────────────────────────────
/**
* Inicializa el estado de autenticación en el cliente.
* - Si hay un access_token en la cookie no-HttpOnly, lo usa.
* - En cualquier caso intenta inicializar Keycloak JS (para el flujo SSO).
*/
export const initAuth = async (): Promise<boolean> => {
if (!browser) return false;
try {
authStore.setLoading(true);
// Restaurar token desde cookie no-HttpOnly (password login flow)
const cookieToken = getAccessTokenFromDocument();
if (cookieToken) {
authStore.setToken(cookieToken);
authStore.setAuthenticated(true);
await loadUserInfo(cookieToken).catch(() => { });
authStore.setLoading(false);
return true;
}
// Sin token local, intentar Keycloak JS (flujo SSO)
const authenticated = await initKeycloak();
authStore.setLoading(false);
return authenticated;
} catch (err) {
console.error('[auth] Error en initAuth:', err);
authStore.setLoading(false);
return false;
}
};
/** Inicializa Keycloak JS para el flujo SSO con PKCE */
export const initKeycloak = async (): Promise<boolean> => {
if (!browser) return false;
try {
keycloakInstance = new Keycloak(keycloakConfig);
const authenticated = await keycloakInstance.init({
onLoad: 'check-sso',
silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html',
pkceMethod: 'S256',
checkLoginIframe: false
});
if (authenticated) {
await updateAuthState();
setupKeycloakTokenHooks();
}
return authenticated;
} catch (err) {
console.error('[auth] Error inicializando Keycloak:', err);
return false;
}
};
let previousTenantId: number | undefined = undefined;
const updateAuthState = async () => {
if (!keycloakInstance?.authenticated) {
authStore.reset();
return;
}
try {
const profile = await keycloakInstance.loadUserProfile();
const token = keycloakInstance.token ?? null;
const parsed = keycloakInstance.tokenParsed as any;
const roles: string[] = parsed?.realm_access?.roles ?? [];
const tenantId: number | undefined = parsed?.tenant_id
? parseInt(parsed.tenant_id)
: undefined;
const tenantChanged = previousTenantId !== undefined && previousTenantId !== tenantId;
// Obtener permisos actuales para evitar que el SSO los borre si fallara el fetch posterior
let currentPerms: string[] = [];
try {
const { get } = await import('svelte/store');
const currentState = get(authStore);
currentPerms = currentState.user?.permissions || [];
} catch { }
const user: User = {
id: profile.id ?? '',
username: profile.username ?? '',
email: profile.email,
name: `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim(),
tenantId,
roles,
permissions: parsed?.permissions?.length ? parsed.permissions : currentPerms
};
authStore.setAuthenticated(true);
authStore.setUser(user);
authStore.setToken(token);
// ⚠️ IMPORTANTE: El SSO original de Keycloak no inyecta los permisos granulares
// que viven en la base de datos de PostgreSQL en nuestro `permissions: parsed?.permissions`.
// Necesitamos hacer polling a /auth/me para que `user.permissions` se rellene.
if (token) {
await loadUserInfo(token).catch(() => { });
}
if (tenantChanged && browser) {
try {
const { companyStore } = await import('./stores/company.svelte');
companyStore.clear();
} catch { }
}
previousTenantId = tenantId;
} catch (err) {
console.error('[auth] Error actualizando estado:', err);
authStore.reset();
}
};
// ─────────────────────────────────────────────────────────
// Keycloak JS token hooks (solo para el flujo SSO)
// ─────────────────────────────────────────────────────────
/**
* Configura los callbacks de Keycloak JS para notificar al SessionManager
* sobre cambios de token y eventos de sesión SSO.
*/
const setupKeycloakTokenHooks = () => {
if (!keycloakInstance) return;
keycloakInstance.onTokenExpired = () => {
keycloakInstance
?.updateToken(70)
.then((refreshed) => {
if (refreshed && keycloakInstance?.token) {
authStore.setToken(keycloakInstance.token);
import('./session-manager')
.then(({ getSessionManager }) => {
getSessionManager()?.updateToken(keycloakInstance!.token!);
})
.catch(() => { });
}
})
.catch(() => {
console.error('[auth] No se pudo refrescar el token de Keycloak');
void logout();
});
};
keycloakInstance.onAuthRefreshSuccess = () => {
if (keycloakInstance?.token) authStore.setToken(keycloakInstance.token);
};
keycloakInstance.onAuthRefreshError = () => {
console.error('[auth] Error en refresh de Keycloak — cerrando sesión');
void logout();
};
keycloakInstance.onAuthLogout = () => {
authStore.reset();
};
};
// ─────────────────────────────────────────────────────────
// Login
// ─────────────────────────────────────────────────────────
/** Inicia sesión con Keycloak (OAuth redirect flow) */
export const loginWithKeycloak = async (tenantSlug?: string) => {
if (!keycloakInstance) {
console.error('[auth] Keycloak no está inicializado');
return;
}
const options: any = { redirectUri: window.location.origin + '/callback' };
if (tenantSlug) options.loginHint = tenantSlug;
await keycloakInstance.login(options);
};
/**
* Login con usuario/contraseña (legacy — el login principal es via form action del servidor).
* 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: {
username: string;
password: string;
tenant_slug: string;
}): Promise<{ success: boolean; error?: string; data?: any }> => {
try {
const { api } = await import('./api');
const response = await api.auth.login(credentials);
if (response.error) {
return { success: false, error: response.error };
}
const loginData = response.data;
if (loginData?.access_token) {
// Guardar en memoria y en cookie no-HttpOnly para SSR
authStore.setToken(loginData.access_token);
authStore.setAuthenticated(true);
setAccessTokenOnDocument(loginData.access_token);
// El refresh_token llega en cookie HttpOnly desde el servidor.
// NO lo guardamos en JS.
await loadUserInfo(loginData.access_token);
}
return { success: true, data: loginData };
} catch (err) {
console.error('[auth] Error en login:', err);
return { success: false, error: 'Error de conexión con el servidor' };
}
};
// ─────────────────────────────────────────────────────────
// User info
// ─────────────────────────────────────────────────────────
export const refreshPermissions = async () => {
const token = getToken();
if (token) {
await loadUserInfo(token);
return true;
}
return false;
};
const loadUserInfo = async (token: string) => {
try {
authStore.setToken(token);
const { api } = await import('./api');
const response = await api.auth.me();
if (response.data) {
const d = response.data;
authStore.setUser({
id: d.sub ?? '',
username: d.preferred_username ?? d.username ?? '',
email: d.email,
name: d.name,
tenantId: d.tenant_id,
roles: d.roles ?? [],
permissions: d.permissions ?? []
});
}
} catch (err) {
console.error('[auth] Error cargando info del usuario:', err);
}
};
// ─────────────────────────────────────────────────────────
// Logout
// ─────────────────────────────────────────────────────────
export const logout = async () => {
if (!browser) return;
try {
// Detener el SessionManager
try {
const { destroySessionManager } = await import('./session-manager');
destroySessionManager();
} catch { }
// Limpiar store de compañías
try {
const { companyStore } = await import('./stores/company.svelte');
companyStore.clear();
} catch { }
// Limpiar estado en memoria
authStore.reset();
clearAccessTokenOnDocument();
// La cookie HttpOnly del refresh_token la limpia el servidor
// Logout unificado (SSO y password): POST al logout route del servidor.
// Evita redirección visible al endpoint de Keycloak.
if (keycloakInstance) {
try {
keycloakInstance.clearToken();
} catch {}
}
const form = document.createElement('form');
form.method = 'POST';
form.action = '/logout';
document.body.appendChild(form);
form.submit();
} catch (err) {
console.error('[auth] Error durante logout:', err);
window.location.href = '/login';
}
};
// ─────────────────────────────────────────────────────────
// Token accessors
// ─────────────────────────────────────────────────────────
export const hasRole = (role: string): boolean => {
if (!keycloakInstance?.authenticated) return false;
return keycloakInstance.hasRealmRole(role);
};
/** Obtiene el access token desde memoria (Keycloak JS o authStore) */
export const getToken = (): string | null => {
// Prioridad 1: Keycloak JS en memoria
if (keycloakInstance?.token) return keycloakInstance.token;
// Prioridad 2: authStore en memoria
let token: string | null = null;
const unsub = authStore.subscribe((s) => { token = s.token; });
unsub();
if (token) return token;
// Prioridad 3: cookie no-HttpOnly (fallback para acceso inicial antes del onMount)
if (browser) return getAccessTokenFromDocument();
return null;
};
/**
* Refresca el access token usando el endpoint server-side seguro.
* El servidor lee el refresh_token de la cookie HttpOnly.
* @returns true si el refresh fue exitoso
*/
export const refreshAccessToken = async (): Promise<boolean> => {
if (!browser) return false;
// Con Keycloak JS activo, usar su mecanismo nativo
if (keycloakInstance?.authenticated) {
try {
const refreshed = await keycloakInstance.updateToken(70);
if (refreshed || keycloakInstance.token) {
authStore.setToken(keycloakInstance.token ?? null);
return true;
}
} catch {
await logout();
return false;
}
}
// Flujo de contraseña: usar el endpoint server-side seguro
try {
const resp = await fetch('/api-sveltekit/auth/silent-refresh', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }
});
if (!resp.ok) {
await logout();
return false;
}
const data = await resp.json() as { access_token?: string };
if (data.access_token) {
authStore.setToken(data.access_token);
setAccessTokenOnDocument(data.access_token);
return true;
}
} catch (err) {
console.error('[auth] Error en refreshAccessToken:', err);
}
await logout();
return false;
};
/** @deprecated El refresh_token ya no se expone en JS. */
export const getRefreshToken = (): string | null => {
console.warn('[auth] getRefreshToken() está deprecado — el refresh_token no se expone en JS.');
return null;
};
export const getKeycloakInstance = (): Keycloak | null => keycloakInstance;