517 lines
13 KiB
TypeScript
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;
|
|
};
|