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

810 lines
26 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[];
// 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) => {
persistUserInSession(user);
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: () =>
{
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);
export function markPermissionsHydrated(): void {
permissionsHydrated.set(true);
}
/**
* Verifica si el usuario tiene un permiso específico.
* `user.permissions` debe incluir códigos de la app (p. ej. `user.view`); suelen
* cargarse vía {@link syncCompanyPermissions}, no solo desde /auth/me del Hub.
*/
export function userHasPermission(user: User | null, permission: string): boolean {
if (!user) return false;
return user.roles.includes('admin') || 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;
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;
// 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 previousUser = get(authStore).user;
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,
permissions: parsed?.permissions?.length ? parsed.permissions : currentPerms,
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;
try {
const { api } = await import('./api');
const res = await api.get<{ permissions: string[] }>(
`/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;
authStore.setUser({
...state.user,
permissions: perms
});
} 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 () => {
const token = getToken();
if (token) {
await loadUserInfo(token);
try {
const { companyStore } = await import('./stores/company.svelte');
const cid = companyStore.activeCompany?.id;
if (cid) await syncCompanyPermissions(cid);
} catch {
// ignore
}
return true;
}
return 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: d.roles ?? previousUser?.roles ?? [],
permissions: d.permissions ?? previousUser?.permissions ?? [],
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 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;