/** * 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, get } 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; firstName?: string | null; lastName?: string | null; displayName?: string | null; avatarUrl?: string | null; workspaceAvatarUrl?: string | null; legacyAvatarUrl?: string | null; tenantId?: number; roles: string[]; permissions: string[]; allowedSystems: string[]; // sistemas a los que tiene acceso: "fixed_asset" | "inventory" // Cache management profileSyncedAt?: number; // timestamp en ms para cache TTL } export interface AuthState { isAuthenticated: boolean; isLoading: boolean; user: User | null; token: string | null; } // ───────────────────────────────────────────────────────── // Configuración de Keycloak // ───────────────────────────────────────────────────────── /** * Devuelve la URL pública de Keycloak correcta para el browser. * Si VITE_KEYCLOAK_URL está bakeado con localhost/127.0.0.1 pero el browser * no está en localhost (producción), se ignora el valor bakeado y se deriva * del hostname real del browser. Protege contra builds con .env de dev en prod. */ function resolveKeycloakUrl(): string { const configured = (import.meta.env.VITE_KEYCLOAK_URL || '').replace(/\/+$/, ''); if (typeof window === 'undefined') { // SSR: usar el valor configurado tal cual (el server tiene las vars correctas) return configured || 'http://localhost:8085/kcauth'; } const browserHostname = window.location.hostname; const isLocalBrowser = browserHostname === 'localhost' || browserHostname === '127.0.0.1'; if (configured) { try { const parsed = new URL(configured); const configuredHost = parsed.hostname; const isLocalConfigured = configuredHost === 'localhost' || configuredHost === '127.0.0.1'; // Si el build fue con localhost pero el browser NO está en localhost → derivar del hostname real if (isLocalConfigured && !isLocalBrowser) { const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'; return `${protocol}//${browserHostname}/kcauth`; } } catch { // URL malformada — caer al fallback } return configured; } if (isLocalBrowser) { return 'http://localhost:8085/kcauth'; } const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'; return `${protocol}//${browserHostname}/kcauth`; } const keycloakConfig = { url: resolveKeycloakUrl(), realm: import.meta.env.VITE_KEYCLOAK_REALM, clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID }; let keycloakInstance: Keycloak | null = null; const AUTH_USER_SESSION_KEY = 'app_auth_user_v1'; function pickAvatar(...candidates: Array): string | null { for (const candidate of candidates) { if (typeof candidate === 'string' && candidate.trim().length > 0) { return candidate.trim(); } } return null; } function pickText(...candidates: Array): string | null { for (const candidate of candidates) { if (typeof candidate === 'string') { const value = candidate.trim(); if (value.length > 0) { return value; } } } return null; } function readUserFromSession(): User | null { if (!browser) return null; try { const raw = sessionStorage.getItem(AUTH_USER_SESSION_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as User; if (!parsed || typeof parsed !== 'object') return null; if (!parsed.id || !parsed.username) return null; return parsed; } catch { return null; } } function persistUserInSession(user: User | null): void { if (!browser) return; if (!user) { sessionStorage.removeItem(AUTH_USER_SESSION_KEY); return; } sessionStorage.setItem(AUTH_USER_SESSION_KEY, JSON.stringify(user)); } // ───────────────────────────────────────────────────────── // Auth store (tokens solo en memoria) // ───────────────────────────────────────────────────────── const createAuthStore = () => { const { subscribe, set, update } = writable({ 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) => { if (user === null) { persistUserInSession(null); update((s) => ({ ...s, user: null })); return; } update((s) => { const prev = s.user; const permissions = preserveNonEmptyArray(user.permissions, prev?.permissions); const roles = preserveNonEmptyArray(user.roles, prev?.roles); const allowedSystems = preserveNonEmptyArray(user.allowedSystems, prev?.allowedSystems); if ( prev && Array.isArray(user.permissions) && user.permissions.length === 0 && (prev.permissions?.length ?? 0) > 0 ) { console.debug( '[auth] setUser: previene downgrade de permissions', prev.permissions.length, '→ 0' ); } const merged: User = { ...user, permissions, roles, allowedSystems }; persistUserInSession(merged); return { ...s, user: merged }; }); }, /** Asignación directa sin guard. Usar solo cuando el backend confirma el estado (p. ej. syncCompanyPermissions). */ setUserUnsafe: (user: User | null) => { persistUserInSession(user); update((s) => ({ ...s, user })); }, /** Limpia solo el usuario del store (logout parcial). Para logout completo usar reset(). */ clearUser: () => { persistUserInSession(null); update((s) => ({ ...s, user: null })); }, 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: () => { persistUserInSession(null); 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); /** * Indica si los permisos RBAC del usuario para la compañía activa ya se hidrataron * en cliente (vía {@link syncCompanyPermissions} o tras la inicialización del * dashboard si no hay compañía). Las pantallas que muestran 403 según permisos * deben esperar a que esto sea `true` antes de decidir, para evitar el flash * de "Acceso restringido" en el primer render. */ export const permissionsHydrated = writable(false); /** true mientras `refreshPermissions` revalida permisos en segundo plano (sidebar). */ export const permissionsRefreshing = writable(false); export function markPermissionsHydrated(): void { permissionsHydrated.set(true); } /** No sobrescribir permisos RBAC con [] de /v1/auth/me (Hub no es fuente de verdad). * Defensa en profundidad: el guard de authStore.setUser ya cubre esto. */ function mergePermissionsFromHub(incoming: unknown, previous: string[] | undefined): string[] { if (Array.isArray(incoming) && incoming.length > 0) return incoming; if (previous && previous.length > 0) return previous; return Array.isArray(incoming) ? (incoming as string[]) : []; } /** Preserva roles previos (p. ej. admin del Hub) al fusionar con /v1/auth/me. * Defensa en profundidad: el guard de authStore.setUser ya cubre esto. */ function mergeRolesFromHub(incoming: unknown, previous: string[] | undefined): string[] { const prev = previous ?? []; if (Array.isArray(incoming) && incoming.length > 0) { return Array.from(new Set([...prev, ...incoming])); } return prev; } /** * Merge defensivo: preserva el array previo si el incoming es undefined, * null o [] (cuando previo no era vacío). Un incoming con items siempre * se respeta — la revocación parcial sí es válida. * * Para una revocación TOTAL legítima usa `authStore.setUserUnsafe()`. */ export function preserveNonEmptyArray( incoming: T[] | null | undefined, previous: T[] | undefined ): T[] { const prev = previous ?? []; if (incoming == null) return prev; if (Array.isArray(incoming) && incoming.length === 0 && prev.length > 0) { return prev; } return Array.isArray(incoming) ? incoming : prev; } // ───────────────────────────────────────────────────────── // Cache local de permisos del sidebar (localStorage, TTL 5 min) // ───────────────────────────────────────────────────────── const SIDEBAR_PERMS_CACHE_PREFIX = 'a76:perms:v1:'; const SIDEBAR_PERMS_CACHE_TTL_MS = 5 * 60 * 1000; interface SidebarPermsCacheEntry { permissions: string[]; roles: string[]; allowedSystems: string[]; tenantId?: number; cachedAt: number; } function buildSidebarPermsCacheKey(userId: string, companyId: number): string { return `${SIDEBAR_PERMS_CACHE_PREFIX}u${userId}:c${companyId}`; } function saveSidebarPermsCache( userId: string, companyId: number, data: Omit ): void { if (!browser) return; try { const entry: SidebarPermsCacheEntry = { ...data, cachedAt: Date.now() }; localStorage.setItem(buildSidebarPermsCacheKey(userId, companyId), JSON.stringify(entry)); } catch { // localStorage puede estar restringido — no romper la app } } export function loadSidebarPermsCache( userId: string, companyId: number ): SidebarPermsCacheEntry | null { if (!browser) return null; try { const raw = localStorage.getItem(buildSidebarPermsCacheKey(userId, companyId)); if (!raw) return null; const entry = JSON.parse(raw) as SidebarPermsCacheEntry; if (!entry || typeof entry !== 'object') return null; if (!Array.isArray(entry.permissions) || !Array.isArray(entry.roles)) return null; if (Date.now() - (entry.cachedAt ?? 0) > SIDEBAR_PERMS_CACHE_TTL_MS) return null; return entry; } catch { return null; } } function clearSidebarPermsCache(userId?: string): void { if (!browser) return; try { const keys: string[] = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (!key || !key.startsWith(SIDEBAR_PERMS_CACHE_PREFIX)) continue; if (userId === undefined || key.includes(`u${userId}:`)) { keys.push(key); } } for (const key of keys) { localStorage.removeItem(key); } } catch { // silencioso } } /** * Códigos de rol que dan bypass total a checks de permiso: * - `super_admin`: rol local de la compañía (fuente de verdad post-desacoplamiento). * - `admin`: rol del realm Keycloak (preservado por compat con `/v1/auth/me` del Hub). * - `hub_admin`: super-admin atestado por el Hub. */ const ADMIN_ROLE_CODES: ReadonlySet = new Set(['super_admin', 'admin', 'hub_admin']); export function userIsAdmin(user: User | null): boolean { if (!user) return false; return user.roles.some((role) => ADMIN_ROLE_CODES.has(role)); } /** * Verifica si el usuario tiene un permiso específico. * `user.permissions` debe incluir códigos de la app (p. ej. `user.view`); se * cargan vía {@link syncCompanyPermissions} desde `/v1/core/permissions/me`. * * Bypass para super-admins: los códigos en {@link ADMIN_ROLE_CODES} dan acceso * total sin requerir un permiso granular específico. */ export function userHasPermission(user: User | null, permission: string): boolean { if (!user) return false; return userIsAdmin(user) || 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 => { if (!browser) return false; try { authStore.setLoading(true); const sessionUser = readUserFromSession(); if (sessionUser) { authStore.setUser(sessionUser); } // 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 => { 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(); persistUserInSession(null); return; } try { const profile = await keycloakInstance.loadUserProfile(); const token = keycloakInstance.token ?? null; const parsed = keycloakInstance.tokenParsed as any; // Roles y tenant_id se obtienen desde el backend (/v1/auth/me y /permissions/me), // no desde claims del JWT de Keycloak. const previousUser = get(authStore).user; const tenantId: number | undefined = previousUser?.tenantId; const roles: string[] = previousUser?.roles ?? []; 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 firstName = pickText(profile.firstName, parsed?.given_name, previousUser?.firstName); const lastName = pickText(profile.lastName, parsed?.family_name, previousUser?.lastName); const fullNameFromParts = pickText( firstName && lastName ? `${firstName} ${lastName}` : null, firstName, lastName ); const username = pickText( profile.username, parsed?.preferred_username, parsed?.username, previousUser?.username ) ?? ''; const name = pickText( profile.firstName || profile.lastName ? `${profile.firstName ?? ''} ${profile.lastName ?? ''}` : null, fullNameFromParts, parsed?.name, previousUser?.name, username ) ?? username; const user: User = { id: pickText(profile.id, parsed?.sub, previousUser?.id) ?? '', username, email: pickText(profile.email, parsed?.email, previousUser?.email) ?? undefined, name, firstName, lastName, displayName: pickText(name, previousUser?.displayName, username), avatarUrl: pickAvatar(previousUser?.avatarUrl), workspaceAvatarUrl: pickAvatar(previousUser?.workspaceAvatarUrl), legacyAvatarUrl: pickAvatar(previousUser?.legacyAvatarUrl), tenantId, roles, // Los permisos efectivos vienen del backend (/permissions/me); el JWT no decide autorización. permissions: currentPerms, allowedSystems: previousUser?.allowedSystems ?? [], profileSyncedAt: previousUser?.profileSyncedAt }; 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 // ───────────────────────────────────────────────────────── /** * Permisos efectivos RBAC de la app para la compañía (backend: GET .../permissions/me). * Sin esto, `userHasPermission` solo ve lo que venga en /auth/me del Hub. */ export async function syncCompanyPermissions(companyId: number): Promise { if (!browser || !Number.isFinite(companyId)) return; // Pre-popular desde localStorage para que el sidebar renderice sin parpadeo const preUserId = get(authStore).user?.id; if (preUserId) { const cached = loadSidebarPermsCache(preUserId, companyId); if (cached) { const s = get(authStore); if (s.user) { const mergedRoles = Array.from(new Set([...(s.user.roles ?? []), ...cached.roles])); authStore.setUser({ ...s.user, permissions: cached.permissions, roles: mergedRoles, allowedSystems: cached.allowedSystems.length > 0 ? (cached.allowedSystems as import('./stores/system.svelte').SystemType[]) : s.user.allowedSystems, tenantId: cached.tenantId ?? s.user.tenantId }); permissionsHydrated.set(true); } } } try { const { api } = await import('./api'); const res = await api.get<{ permissions: string[]; roles?: string[]; allowed_systems?: string[]; tenant_id?: number | null; }>(`/v1/core/permissions/me?company_id=${companyId}`); if (res.error || res.data === undefined) return; const perms = res.data.permissions; if (!Array.isArray(perms)) return; const state = get(authStore); if (!state.user) return; const { systemStore } = await import('./stores/system.svelte'); const allowedSystems = (res.data.allowed_systems ?? []) as import('./stores/system.svelte').SystemType[]; const rolesFromBackend = Array.isArray(res.data.roles) ? res.data.roles : null; const tenantFromBackend = typeof res.data.tenant_id === 'number' && Number.isFinite(res.data.tenant_id) ? res.data.tenant_id : null; // Merge no destructivo de roles: preservamos los roles atestados por el // Hub (p. ej. `admin` del realm Keycloak en /v1/auth/me) y agregamos los // roles locales devueltos por el backend (`super_admin`, etc.). Si los // roles locales sobrescribieran a los del Hub, el bypass de admin se // rompería entre la primera hidratación y la sincronización por compañía. const previousRoles = state.user.roles ?? []; const mergedRoles = rolesFromBackend === null ? previousRoles : Array.from(new Set([...previousRoles, ...rolesFromBackend])); // El backend es fuente de verdad para permisos de la compañía activa. // Si retornó [] es porque el usuario realmente no tiene permisos aquí; // el guard de setUser preservaría los viejos (incorrectos). Por eso unsafe. authStore.setUserUnsafe({ ...state.user, permissions: perms, roles: mergedRoles, tenantId: tenantFromBackend ?? state.user.tenantId, // Preservar allowedSystems del SSR si el API no los retorna (seed pendiente) allowedSystems: allowedSystems.length > 0 ? allowedSystems : (state.user.allowedSystems ?? []) }); // Solo reinicializar el systemStore si el API retorna sistemas explícitos. // Si está vacío, preservar el estado establecido por SSR para evitar resetear activeSystem a null. if (allowedSystems.length > 0) { const cookieSystem = typeof document !== 'undefined' ? document.cookie.match(/(?:^|;\s*)active_system=([^;]+)/)?.[1] ?? null : null; systemStore.initialize(allowedSystems, cookieSystem); } // Persistir en localStorage para que el sidebar pre-popule sin parpadeo en la próxima sesión const freshUserId = get(authStore).user?.id; if (freshUserId) { saveSidebarPermsCache(freshUserId, companyId, { permissions: perms, roles: mergedRoles, allowedSystems: allowedSystems.length > 0 ? (allowedSystems as string[]) : ((get(authStore).user?.allowedSystems ?? []) as string[]), tenantId: tenantFromBackend ?? get(authStore).user?.tenantId }); } } catch (e) { console.warn('[auth] syncCompanyPermissions:', e); } finally { // Levanta el flag aunque la sync falle: si no se pudo, las pantallas // quedan con lo que vino del SSR y deben dejar de mostrar el loader. permissionsHydrated.set(true); } } export const refreshPermissions = async (): Promise => { const token = getToken(); if (!token) return false; permissionsRefreshing.set(true); try { // RBAC de compañía primero: evita que /auth/me vacíe permisos antes del sync real. try { const { companyStore } = await import('./stores/company.svelte'); const cid = companyStore.activeCompany?.id; if (cid) await syncCompanyPermissions(cid); } catch { // ignore } await loadUserInfo(token); return true; } finally { permissionsRefreshing.set(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 previousUser = get(authStore).user; const d = response.data; const workspaceAvatarUrl = pickAvatar( d.workspaceAvatarUrl, d.workspace_avatar_url, d.avatar_url, d.avatarUrl, d.picture, d.photo, previousUser?.workspaceAvatarUrl ); const legacyAvatarUrl = pickAvatar( d.legacyAvatarUrl, d.legacy_avatar_url, d.avatar, d.photo, d.picture, previousUser?.legacyAvatarUrl ); const avatarUrl = pickAvatar(workspaceAvatarUrl, legacyAvatarUrl, previousUser?.avatarUrl); // Merge no destructivo: nunca pisar datos válidos con campos vacíos const firstName = pickText(d.first_name, d.firstName, previousUser?.firstName); const lastName = pickText(d.last_name, d.lastName, previousUser?.lastName); const username = pickText( d.preferred_username, d.username, previousUser?.username ) ?? ''; const nameFromParts = pickText( firstName && lastName ? `${firstName} ${lastName}` : null, firstName, lastName ); const name = pickText( d.name, nameFromParts, previousUser?.name, username ) ?? username; const displayName = pickText( d.displayName, d.display_name, name, username ) ?? username; const email = pickText(d.email, previousUser?.email) ?? undefined; const userId = pickText(d.sub, d.id, previousUser?.id) ?? ''; console.debug('[avatar][auth.loadUserInfo] /v1/auth/me avatar_url recibido:', workspaceAvatarUrl ?? '(null)'); console.debug('[avatar][auth.loadUserInfo] avatar final para authStore:', avatarUrl ?? '(null)'); console.debug('[profile][auth.loadUserInfo] first_name:', firstName ?? '(null)', 'last_name:', lastName ?? '(null)', 'avatar_url:', workspaceAvatarUrl ?? '(null)'); authStore.setUser({ id: userId, username, email, name, firstName, lastName, displayName, avatarUrl, workspaceAvatarUrl, legacyAvatarUrl, tenantId: d.tenant_id ?? previousUser?.tenantId, roles: mergeRolesFromHub(d.roles, previousUser?.roles), permissions: mergePermissionsFromHub(d.permissions, previousUser?.permissions), allowedSystems: previousUser?.allowedSystems ?? [], profileSyncedAt: Date.now() }); } } catch (err) { console.error('[auth] Error cargando info del usuario:', err); } }; /** * Sincroniza el perfil del usuario desde /v1/auth/me * - Valida cache TTL (5 minutos) antes de hacer fetch * - Extrae first_name, last_name, avatar_url * - Retorna objeto con campos de perfil para UI o update de store * * Uso: * ``` * const profile = await syncUserProfile(accessToken); * if (profile) { * // profile.firstName, profile.lastName, profile.displayName, profile.avatarUrl * } * ``` */ export const syncUserProfile = async (accessToken?: string): Promise<{ firstName: string | null; lastName: string | null; displayName: string | null; avatarUrl: string | null; workspaceAvatarUrl: string | null; legacyAvatarUrl: string | null; rawProfileSyncedAt: number; } | null> => { if (!browser) return null; try { // Validar cache TTL: 5 minutos (300000ms) const PROFILE_CACHE_TTL = 5 * 60 * 1000; const { get } = await import('svelte/store'); const currentState = get(authStore); const now = Date.now(); if ( currentState.user?.profileSyncedAt && (now - currentState.user.profileSyncedAt) < PROFILE_CACHE_TTL ) { console.debug('[profile][sync] Cache válido, no re-fetching /v1/auth/me'); return { firstName: currentState.user.firstName ?? null, lastName: currentState.user.lastName ?? null, displayName: currentState.user.displayName ?? null, avatarUrl: currentState.user.avatarUrl ?? null, workspaceAvatarUrl: currentState.user.workspaceAvatarUrl ?? null, legacyAvatarUrl: currentState.user.legacyAvatarUrl ?? null, rawProfileSyncedAt: currentState.user.profileSyncedAt }; } const token = accessToken || getToken(); if (!token) { console.warn('[profile][sync] No token disponible para sincronizar'); return null; } // Llamar a loadUserInfo que hace fetch a /v1/auth/me await loadUserInfo(token); // Retornar los nuevos valores desde el store const updatedState = get(authStore); if (updatedState.user) { console.debug('[profile][sync] Perfil sincronizado exitosamente'); return { firstName: updatedState.user.firstName ?? null, lastName: updatedState.user.lastName ?? null, displayName: updatedState.user.displayName ?? null, avatarUrl: updatedState.user.avatarUrl ?? null, workspaceAvatarUrl: updatedState.user.workspaceAvatarUrl ?? null, legacyAvatarUrl: updatedState.user.legacyAvatarUrl ?? null, rawProfileSyncedAt: updatedState.user.profileSyncedAt ?? 0 }; } return null; } catch (err) { console.error('[profile][sync] Error sincronizando perfil:', err); return null; } }; // ───────────────────────────────────────────────────────── // 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 cache de permisos del sidebar en localStorage try { const userId = get(authStore).user?.id; clearSidebarPermsCache(userId); } catch { } // Limpiar snapshot visual del sidebar almacenado en sessionStorage try { sessionStorage.removeItem('app:sidebar:nav-main:v1'); } catch { // ignore } // Limpiar estado en memoria authStore.reset(); persistUserInSession(null); 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); const hubBase = (import.meta.env.VITE_HUB_URL || 'https://workspace.aduanasoft.com').replace(/\/+$/, ''); window.location.href = `${hubBase}/login?return_to=${encodeURIComponent(window.location.origin + '/login?sso_verified=1')}`; } }; // ───────────────────────────────────────────────────────── // 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 => { 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;