1069 lines
36 KiB
TypeScript
1069 lines
36 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, 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 = 'anexo76_auth_user_v1';
|
|
|
|
function pickAvatar(...candidates: Array<unknown>): string | null {
|
|
for (const candidate of candidates) {
|
|
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
|
return candidate.trim();
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function pickText(...candidates: Array<unknown>): 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<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) => {
|
|
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<boolean>(false);
|
|
|
|
/** true mientras `refreshPermissions` revalida permisos en segundo plano (sidebar). */
|
|
export const permissionsRefreshing = writable<boolean>(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<T>(
|
|
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<SidebarPermsCacheEntry, 'cachedAt'>
|
|
): 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<string> = 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<boolean> => {
|
|
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<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();
|
|
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<void> {
|
|
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<boolean> => {
|
|
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('a76: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<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;
|