810 lines
26 KiB
TypeScript
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;
|