Files
plantillas-proyectos/frontend/src/lib/auth.ts

517 lines
13 KiB
TypeScript

/**
* Servicio de autenticación con Keycloak
*/
import Keycloak from 'keycloak-js';
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
// Tipos
export interface User {
id: string;
username: string;
email?: string;
name?: string;
tenantId?: number;
roles: 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
};
// Instancia de Keycloak
let keycloakInstance: Keycloak | null = null;
// Helper para obtener cookies
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;
};
// Helper para establecer cookies con las opciones correctas según el entorno
const setCookie = (name: string, value: string, days: number = 7) => {
if (!browser) return;
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + days);
// En desarrollo (localhost), no usar Secure flag
const isSecure = window.location.protocol === 'https:';
const secureFlag = isSecure ? '; Secure' : '';
const cookieString = `${name}=${value}; path=/; expires=${expirationDate.toUTCString()}; SameSite=Lax${secureFlag}`;
document.cookie = cookieString;
// Verificar que se estableció
const verification = getCookie(name);
};
// Helper para eliminar cookies
const deleteCookie = (name: string) => {
if (!browser) return;
const isSecure = window.location.protocol === 'https:';
const secureFlag = isSecure ? '; Secure' : '';
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secureFlag}`;
};
// Store de autenticación
const createAuthStore = () => {
const { subscribe, set, update } = writable<AuthState>({
isAuthenticated: false,
isLoading: true,
user: null,
token: null
});
return {
subscribe,
setAuthenticated: (authenticated: boolean) =>
update((state) => ({ ...state, isAuthenticated: authenticated })),
setLoading: (loading: boolean) =>
update((state) => ({ ...state, isLoading: loading })),
setUser: (user: User | null) => update((state) => ({ ...state, user })),
setToken: (token: string | null) => update((state) => ({ ...state, token })),
setTokens: (accessToken: string, refreshToken?: string) => {
update((state) => ({ ...state, token: accessToken }));
if (browser && refreshToken) {
localStorage.setItem('refresh_token', refreshToken);
}
},
reset: () =>
set({
isAuthenticated: false,
isLoading: false,
user: null,
token: null
})
};
};
export const authStore = createAuthStore();
// Derived store para verificar si está autenticado
export const isAuthenticated = derived(authStore, ($auth) => $auth.isAuthenticated);
// Derived store para obtener el usuario
export const currentUser = derived(authStore, ($auth) => $auth.user);
/**
* Inicializa la autenticación (Keycloak o token-based)
*/
export const initAuth = async (): Promise<boolean> => {
if (!browser) return false;
try {
authStore.setLoading(true);
// Primero intentar restaurar sesión desde localStorage
const token = localStorage.getItem('access_token');
if (token) {
authStore.setToken(token);
authStore.setAuthenticated(true);
// Sincronizar con cookies si no existe
const cookieToken = getCookie('access_token');
if (!cookieToken) {
setCookie('access_token', token);
}
await loadUserInfo(token);
authStore.setLoading(false);
return true;
}
// Si no hay token local, intentar con Keycloak
await initKeycloak();
authStore.setLoading(false);
return false;
} catch (error) {
console.error('Error inicializando autenticación:', error);
authStore.setLoading(false);
return false;
}
};
/**
* Inicializa Keycloak
*/
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();
setupTokenRefresh();
}
return authenticated;
} catch (error) {
console.error('Error inicializando Keycloak:', error);
return false;
}
};
// Variable para rastrear el tenant anterior
let previousTenantId: number | undefined = undefined;
/**
* Actualiza el estado de autenticación con los datos de Keycloak
*/
const updateAuthState = async () => {
if (!keycloakInstance?.authenticated) {
authStore.reset();
return;
}
try {
const profile = await keycloakInstance.loadUserProfile();
const token = keycloakInstance.token || null;
const tokenParsed = keycloakInstance.tokenParsed as any;
const roles = tokenParsed?.realm_access?.roles || [];
const tenantId = tokenParsed?.tenant_id || tokenParsed?.attributes?.tenant_id;
const newTenantId = tenantId ? parseInt(tenantId) : undefined;
// Detectar si cambió el tenant
const tenantChanged = previousTenantId !== undefined && previousTenantId !== newTenantId;
const user: User = {
id: profile.id || '',
username: profile.username || '',
email: profile.email,
name: `${profile.firstName || ''} ${profile.lastName || ''}`.trim(),
tenantId: newTenantId,
roles
};
authStore.setAuthenticated(true);
authStore.setUser(user);
authStore.setToken(token);
// Si cambió el tenant, limpiar el store de compañías
if (tenantChanged && browser) {
try {
const { companyStore } = await import('./stores/company.svelte');
companyStore.clear();
} catch (error) {
console.error('Error al limpiar store de compañías:', error);
}
}
// Actualizar el tenant anterior
previousTenantId = newTenantId;
} catch (error) {
console.error('Error actualizando estado de autenticación:', error);
authStore.reset();
}
};
/**
* Configura el refresh automático del token
*/
const setupTokenRefresh = () => {
if (!keycloakInstance) return;
// Refrescar token cada 60 segundos si está cerca de expirar
keycloakInstance.onTokenExpired = () => {
keycloakInstance
?.updateToken(70)
.then((refreshed) => {
if (refreshed) {
authStore.setToken(keycloakInstance?.token || null);
}
})
.catch(() => {
console.error('Error refrescando token');
logout();
});
};
};
/**
* Inicia sesión con Keycloak (OAuth flow)
*/
export const loginWithKeycloak = async (tenantSlug?: string) => {
if (!keycloakInstance) {
console.error('Keycloak no está inicializado');
return;
}
const options: any = {
redirectUri: window.location.origin + '/callback'
};
if (tenantSlug) {
options.loginHint = tenantSlug;
}
await keycloakInstance.login(options);
};
/**
* Inicia sesión con credenciales (username/password)
* Nota: Esta función ya no se usa directamente desde el login form,
* el login ahora se hace mediante form actions del servidor.
* Se mantiene para compatibilidad con SSO y otros flujos.
*/
export const login = async (credentials: {
username: string;
password: string;
tenant_slug: string;
}): Promise<{ success: boolean; error?: string; data?: any }> => {
try {
// Usar la API centralizada
const { api } = await import('./api');
const response = await api.auth.login(credentials);
// Si hay error en la respuesta
if (response.error) {
return {
success: false,
error: response.error
};
}
// Guardar tokens y actualizar estado
const loginData = response.data;
if (loginData?.access_token) {
authStore.setToken(loginData.access_token);
authStore.setAuthenticated(true);
// Guardar también en localStorage para persistencia
if (browser) {
localStorage.setItem('access_token', loginData.access_token);
if (loginData.refresh_token) {
localStorage.setItem('refresh_token', loginData.refresh_token);
}
// Guardar en cookies para que el servidor pueda acceder
setCookie('access_token', loginData.access_token);
if (loginData.refresh_token) {
setCookie('refresh_token', loginData.refresh_token);
}
}
// Cargar información del usuario
await loadUserInfo(loginData.access_token);
}
return {
success: true,
data: loginData
};
} catch (error) {
console.error('Error en login:', error);
return {
success: false,
error: 'Error de conexión con el servidor'
};
}
};
/**
* Carga la información del usuario desde el token
*/
const loadUserInfo = async (token: string) => {
try {
// Guardar temporalmente el token para que api.ts lo use
authStore.setToken(token);
// Usar la API centralizada
const { api } = await import('./api');
const response = await api.auth.me();
if (response.data) {
const data = response.data;
const user: User = {
id: data.sub || '',
username: data.preferred_username || data.username || '',
email: data.email,
name: data.name,
tenantId: data.tenant_id,
roles: data.realm_access?.roles || []
};
authStore.setUser(user);
}
} catch (error) {
console.error('Error cargando información del usuario:', error);
}
};
/**
* Cierra sesión
*/
export const logout = async () => {
if (!browser) return;
try {
// Capturar tokens antes de limpiar nada
const refreshToken = localStorage.getItem('refresh_token');
const accessToken = localStorage.getItem('access_token');
// Limpiar store de compañías
try {
const { companyStore } = await import('./stores/company.svelte');
companyStore.clear();
} catch (error) {
console.error('Error al limpiar store de compañías:', error);
}
// Limpiar estado local
authStore.reset();
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
deleteCookie('access_token');
deleteCookie('refresh_token');
// Si hay instancia de Keycloak, hacer logout de Keycloak
if (keycloakInstance?.authenticated) {
// Primero notificamos al servidor para limpieza de cookies (SvelteKit)
try {
await fetch('/logout', {
method: 'POST'
});
} catch (e) {
console.error("Error calling server logout:", e);
}
await keycloakInstance.logout({
redirectUri: window.location.origin + '/login'
});
return;
}
// Llamar al endpoint del servidor para limpiar cookies de SvelteKit
// Usar un formulario para hacer POST y permitir la redirección
const form = document.createElement('form');
form.method = 'POST';
form.action = '/logout';
document.body.appendChild(form);
form.submit();
} catch (error) {
console.error('Error durante logout:', error);
// Asegurar que se redirija al login aunque haya error
window.location.href = '/login';
}
};
/**
* Verifica si el usuario tiene un rol específico
*/
export const hasRole = (role: string): boolean => {
if (!keycloakInstance?.authenticated) return false;
return keycloakInstance.hasRealmRole(role);
};
/**
* Obtiene el token de acceso actual
*/
export const getToken = (): string | null => {
// Intentar obtener de Keycloak primero
if (keycloakInstance?.token) {
return keycloakInstance.token;
}
// Si no, intentar de localStorage
if (browser) {
let token = localStorage.getItem('access_token');
// Si no hay token en localStorage, intentar de las cookies
if (!token) {
token = getCookie('access_token');
// Si lo encontramos en cookies, sincronizarlo a localStorage
if (token) {
localStorage.setItem('access_token', token);
}
}
return token;
}
return null;
};
/**
* Refresca el access token usando el refresh token
*/
export const refreshAccessToken = async (): Promise<boolean> => {
if (!browser) return false;
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) {
return false;
}
try {
const { api } = await import('./api');
const response = await api.auth.refresh(refreshToken);
if (response.error || !response.data) {
console.error('Failed to refresh token:', response.error);
// Si falla el refresh, hacer logout
await logout();
return false;
}
// Actualizar tokens
const newAccessToken = response.data.access_token;
const newRefreshToken = response.data.refresh_token;
authStore.setToken(newAccessToken);
localStorage.setItem('access_token', newAccessToken);
if (newRefreshToken) {
localStorage.setItem('refresh_token', newRefreshToken);
}
// Actualizar también la cookie
setCookie('access_token', newAccessToken);
return true;
} catch (error) {
await logout();
return false;
}
};
/**
* Obtiene el refresh token
*/
export const getRefreshToken = (): string | null => {
if (!browser) return null;
return localStorage.getItem('refresh_token');
};
/**
* Obtiene la instancia de Keycloak
*/
export const getKeycloakInstance = (): Keycloak | null => {
return keycloakInstance;
};