474 lines
16 KiB
TypeScript
474 lines
16 KiB
TypeScript
/**
|
|
* Servicio de autenticación con Keycloak
|
|
*
|
|
* Seguridad de tokens:
|
|
* - access_token → en memoria (authStore) + cookie no-HttpOnly (para SSR)
|
|
* - 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';
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// 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
|
|
};
|
|
|
|
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)
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
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);
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// 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 = getCookie('access_token');
|
|
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;
|
|
|
|
const user: User = {
|
|
id: profile.id ?? '',
|
|
username: profile.username ?? '',
|
|
email: profile.email,
|
|
name: `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim(),
|
|
tenantId,
|
|
roles
|
|
};
|
|
|
|
authStore.setAuthenticated(true);
|
|
authStore.setUser(user);
|
|
authStore.setToken(token);
|
|
|
|
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 (vía setCookie) 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);
|
|
setCookie('access_token', 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
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
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.realm_access?.roles ?? []
|
|
});
|
|
}
|
|
} 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();
|
|
|
|
// Eliminar cookie no-HttpOnly del access_token
|
|
deleteCookie('access_token');
|
|
// 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 getCookie('access_token');
|
|
|
|
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);
|
|
setCookie('access_token', 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;
|