UNPKG

@hemia/access-manager

Version:

Vue 3 SDK for handling SSO sessions and permission-based access via @hemia/auth-sso and @hemia/access-sdk.

375 lines (359 loc) 17 kB
import { inject, computed, ref } from 'vue'; import { defineStore } from 'pinia'; import { z } from 'zod'; const SSOClientKey = Symbol('SSOClient'); // sso/injectSSO.ts function injectSSO() { const client = inject(SSOClientKey); if (!client) { throw new Error('[SSO] SSOClient is not provided. Did you forget to use createAccessSession()?'); } return client; } const STORAGE_KEY = 'access.userContext'; const SYSTEM_KEY = 'access.system'; const TTL_KEY = 'access.ttl'; const TTL_MS = 1000 * 60 * 60 * 1; // 1 horas const useAccessStore = defineStore('access', { state: () => ({ userContext: null, system: '' }), actions: { setUserContext(context, system) { this.userContext = structuredClone(context); if (system) this.system = system; sessionStorage.setItem(STORAGE_KEY, JSON.stringify(this.userContext)); sessionStorage.setItem(SYSTEM_KEY, this.system); sessionStorage.setItem(TTL_KEY, Date.now().toString()); }, clearContext() { this.userContext = null; this.system = ''; sessionStorage.removeItem(STORAGE_KEY); sessionStorage.removeItem(SYSTEM_KEY); sessionStorage.removeItem(TTL_KEY); }, restoreContext(ttlContext) { try { const ttl = parseInt(sessionStorage.getItem(TTL_KEY) || '0'); if (Date.now() - ttl > (ttlContext || TTL_MS)) { this.clearContext(); return; } const raw = sessionStorage.getItem(STORAGE_KEY); const sys = sessionStorage.getItem(SYSTEM_KEY); if (raw) { this.userContext = JSON.parse(raw); this.system = sys || ''; } console.log('[AccessStore] Restore userContext'); } catch (e) { console.warn('[AccessStore] Error restoring session:', e); this.clearContext(); } } }, }); function getAllRoles(user, system) { return [ ...user.globalRoles, ...(user.systemScopedRoles?.[system ?? ''] || []) ]; } function hasPermission(user, type, action, system) { const roles = getAllRoles(user, system); for (const role of roles) { for (const perm of role.permissions) { if (perm.type === type) { if (perm.deniedActions?.includes(action)) return false; if (perm.actions?.includes(action)) return true; } } } return false; } function canAnyOf(user, checks, system) { return checks.some(c => hasPermission(user, c.type, c.action, system)); } function canAllOf(user, checks, system) { return checks.every(c => hasPermission(user, c.type, c.action, system)); } const UserContextSchema = z.object({ userId: z.string().optional(), authId: z.string().optional(), globalRoles: z.array(z.object({ module: z.string(), name: z.string(), permissions: z.array(z.object({ type: z.string(), actions: z.array(z.string()), deniedActions: z.array(z.string()).optional() })) })), systemScopedRoles: z.record(z.string(), z.array(z.object({ module: z.string(), name: z.string(), permissions: z.array(z.object({ type: z.string(), actions: z.array(z.string()), deniedActions: z.array(z.string()).optional() })) }))), attributes: z.record(z.any()).optional(), profile: z.record(z.any()).optional(), meta: z.record(z.any()).optional() }); async function loadAccessContext(config) { const response = await fetch(config.meEndpoint, { method: 'GET', credentials: 'include' }); if (!response.ok) { throw new Error(`Failed to fetch user context: ${response.status}`); } const json = await response.json(); const parsed = UserContextSchema.safeParse(json); if (!parsed.success) { console.error('Invalid user context received:', parsed.error.flatten()); throw new Error('Invalid user context structure received from API'); } const store = useAccessStore(); store.setUserContext(parsed.data, config.system); } function useAccess() { const store = useAccessStore(); const user = computed(() => store.userContext); const system = computed(() => store.system); function ensureUserContext() { if (!user.value) throw new Error('No user context loaded.'); } return { user: user, system: system, hasPermission: (type, action) => { ensureUserContext(); return hasPermission(store.userContext, type, action, store.system); }, canAnyOf: (checks) => { ensureUserContext(); return canAnyOf(store.userContext, checks, store.system); }, canAllOf: (checks) => { ensureUserContext(); return canAllOf(store.userContext, checks, store.system); }, clear: () => store.clearContext(), restoreContext: (ttlContext) => store.restoreContext(ttlContext), loadIfMissing: async (config) => { try { store.restoreContext(config.ttlContext); if (!store.userContext) { await loadAccessContext(config); } } catch (error) { console.warn('[Access] Error loading user context:', error); store.clearContext(); throw error; } } }; } function hasPermissionModular(user, type, action, system, module) { const matches = (scope) => (!module || scope.module === module) && scope.permissions.some(p => (p.type === type || p.type === '*') && (p.actions.includes(action) || p.actions.includes('*'))); if (user.globalRoles?.some(matches)) return true; if (system && user.systemScopedRoles?.[system]?.some(matches)) return true; return false; } function applyAccess(el, value, user, system) { if (!value || !user) { el.style.display = 'none'; return; } const check = (c) => user && hasPermissionModular(user, c.type, c.action, system, c.module); let allowed = false; if ('anyOf' in value && Array.isArray(value.anyOf)) { allowed = value.anyOf.some(check); } else if ('allOf' in value && Array.isArray(value.allOf)) { allowed = value.allOf.every(check); } else if ('type' in value && 'action' in value) { allowed = check(value) || false; } el.style.display = allowed ? '' : 'none'; } const accessDirective = { mounted(el, binding) { const store = useAccessStore(); applyAccess(el, binding.value, store.userContext, store.system); }, updated(el, binding) { const store = useAccessStore(); applyAccess(el, binding.value, store.userContext, store.system); } }; function useAccessControl() { const store = useAccessStore(); const user = store.userContext; const system = store.system; const can = (type, action, module) => { return !!user && hasPermissionModular(user, type, action, system, module); }; const canAnyOf = (checks) => { return !!user && checks.some(c => hasPermissionModular(user, c.type, c.action, system, c.module)); }; const canAllOf = (checks) => { return !!user && checks.every(c => hasPermissionModular(user, c.type, c.action, system, c.module)); }; return { can, canAnyOf, canAllOf }; } const isAuth = ref(false); const reasonSession = ref(''); let validatedOnce = false; function useSSO() { const sso = injectSSO(); const validateSession = async () => { if (validatedOnce) return { isAuthenticated: isAuth.value, reason: reasonSession.value }; validatedOnce = true; const { isAuthenticated, reason } = await sso.validateSession(); isAuth.value = isAuthenticated; reasonSession.value = reason; return { isAuthenticated, reason }; }; const onLogout = async () => { const { clear } = useAccess(); await sso.logout(); clear(); }; return { validateSession, handleCallback: () => sso.handleCallback(), startLogin: (auto, skip, clientId) => { localStorage.removeItem('x-sso-session'); localStorage.removeItem('sso-state'); localStorage.removeItem(`${clientId}-pkce`); sso.startLogin(auto, skip); }, refreshToken: () => sso.refreshToken(), logout: onLogout, isAuthenticated: computed(() => isAuth.value), reason: computed(() => reasonSession.value) }; } function shouldIgnore(toPath, ignore) { if (!ignore?.length) return false; const path = toPath.replace(/\/+$/, ''); const noSlash = path.replace(/^\//, ''); return ignore.some(p => { const pNorm = p.replace(/\/+$/, '').replace(/^\//, ''); return (noSlash === pNorm || noSlash.startsWith(`${pNorm}/`) || noSlash.endsWith(pNorm)); }); } function createAccessGuard(config) { return async (to, from, next) => { if (shouldIgnore(to.path, config.ignorePath)) { return next(); } if (to.query?.code) { return next(); } const { validateSession, refreshToken, startLogin } = useSSO(); const { loadIfMissing } = useAccess(); const { isAuthenticated, reason } = await validateSession(); console.log(`Session validation result: isAuthenticated=${isAuthenticated}, reason=${reason}`); if (isAuthenticated) { await loadIfMissing(config.access); return next(); } if (!to.meta.requiresAuth) return next(); if (reason === 'token_invalid_no_refresh_but_session_valid') { startLogin(true); return next(false); } if (reason === 'token_expired_but_session_valid') { const refreshed = await refreshToken(); if (refreshed) { await loadIfMissing(config.access); return next(); } } if (reason === 'skip_during_oauth_callback') { return next(); } startLogin(); return next(false); }; } class e{constructor(e){this.reconnecting=false,this.SESSION_TTL_MS=6e4,this.TTL_KEY="sso.lastSessionValidation",this.SSO_SESSION_KEY="x-sso-session",this.config=e;}async validateSession(){try{if("undefined"!=typeof window){if(new URL(window.location.href).searchParams.has("code"))return {isAuthenticated:!1,reason:"skip_during_oauth_callback"}}const e={},t=parseInt(localStorage.getItem(this.TTL_KEY)||"0"),o=Date.now();if(t&&o-t<this.SESSION_TTL_MS)return {isAuthenticated:!0,reason:"session_recently_validated"};const s=localStorage.getItem("x-sso-session");s&&(e["x-sso-session"]=s);const n=await fetch(this.config.sessionEndpoint,{method:"GET",credentials:"include",headers:e}),i=await n.json();return i?.isAuthenticated?localStorage.setItem(this.TTL_KEY,o.toString()):(localStorage.removeItem(this.TTL_KEY),localStorage.removeItem("x-sso-session")),{isAuthenticated:!0===i?.isAuthenticated,reason:i?.reason}}catch(e){return console.warn("Sesión no válida. Redirigiendo a SSO..."),{isAuthenticated:false,reason:"unexpected_error"}}}async startLogin(e,t){const o=(()=>{const e=new Uint8Array(32);return window.crypto.getRandomValues(e),btoa(String.fromCharCode(...e)).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")})(),s=await(async e=>{const t=(new TextEncoder).encode(e),o=await window.crypto.subtle.digest("SHA-256",t);return btoa(String.fromCharCode(...new Uint8Array(o))).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")})(o),n=crypto.randomUUID();localStorage.setItem(`${this.config.clientId}-pkce`,o),localStorage.setItem("sso-state",n);const i=new URLSearchParams({response_type:"code",client_id:this.config.clientId,redirect_uri:this.config.redirectUri,scope:this.config.scopes.join(" "),state:n,code_challenge:s,code_challenge_method:"S256",api_key:this.config.apiKey||"",auto:e?"true":"false",skip:t?"true":"false"});window.location.href=`${this.config.ssoAuthUrl}?${i.toString()}`;}async handleCallback(){const e=new URLSearchParams(window.location.search),t=e.get("code"),o=e.get("state");if(!t)return {success:false,message:"No se recibió el código de autorización."};const s=localStorage.getItem(`${this.config.clientId}-pkce`);if(!s)return {success:false,message:"No se pudo verificar la sesión."};if(localStorage.getItem("sso-state")!==o)return {success:false,message:"La sesión no es válida. Intenta iniciar sesión nuevamente."};try{const e=await fetch(this.config.tokenEndpoint,{method:"POST",headers:{"Content-Type":"application/json"},credentials:"include",body:JSON.stringify({code:t,code_verifier:s,grant_type:"authorization_code"})}),o=await e.json().catch((()=>null));if(!e.ok||!1===o?.success){localStorage.removeItem("x-sso-session"),localStorage.removeItem(`${this.config.clientId}-pkce`),localStorage.removeItem("sso-state");return {success:!1,message:o?.message||"No se pudo completar el inicio de sesión.",brokenSession:!0===o?.data?.brokenSession}}return localStorage.removeItem(`${this.config.clientId}-pkce`),localStorage.removeItem("sso-state"),{success:!0}}catch(e){return console.error("Error en el intercambio de tokens:",e),{success:false,message:"Ocurrió un error inesperado durante el inicio de sesión."}}}async refreshToken(){try{const e={"Content-Type":"application/json"},t=localStorage.getItem("x-sso-session");t&&(e["x-sso-session"]=t);return !!(await fetch(this.config.refreshEndpoint,{method:"POST",credentials:"include",headers:e})).ok||(console.warn("No se pudo renovar el token"),!1)}catch(e){return console.error("Error al renovar token:",e),false}}async logout(){try{const e={"Content-Type":"application/json"},t=localStorage.getItem("x-sso-session");t&&(e["x-sso-session"]=t),await fetch(this.config.logoutEndpoint,{method:"POST",credentials:"include",headers:e}),localStorage.removeItem(this.TTL_KEY),localStorage.removeItem(this.SSO_SESSION_KEY);}catch(e){console.warn("Error al cerrar sesión, redirigiendo de todos modos");}finally{localStorage.removeItem("x-sso-session"),window.location.href=this.config.logoutRedirectUrl||"/";}}} let ssoInstance = null; function setupSSOClient(config) { if (!ssoInstance) { ssoInstance = new e(config); } } function getSSOClient() { if (!ssoInstance) throw new Error('[SSO] SSOClient not initialized. Call setupSSOClient() first.'); return ssoInstance; } function createAccessSession(options) { return { async install(app) { setupSSOClient(options.sso); app.provide(SSOClientKey, getSSOClient()); console.log('[AccessSession] SSOClient has been provided.'); } }; } async function SSOCallback({ config, route, router, fallbackRedirect = '/', redirectParam = 'redirectTo', onFail, onSuccess }) { const { handleCallback, validateSession, startLogin } = useSSO(); const { loadIfMissing } = useAccess(); const result = await handleCallback(); // 🔐 Guarda sesión si viene en el query param if (route.query?.session) { localStorage.setItem('x-sso-session', route.query.session.toString()); } // ❌ Si falla el callback (código inválido, verificador inválido, etc.) if (!result.success) { if (onFail) onFail(result.message ?? 'No se pudo iniciar sesión.'); // 🔁 Solo intenta redirigir al login si la sesión no está rota if (!result.brokenSession) { startLogin(); } return; } console.log('SSO Callback successful'); // ✅ Verifica que la sesión es válida const { isAuthenticated, reason } = await validateSession(); // const isAuthenticated = true; console.log(`Post-callback session validation: isAuthenticated=${isAuthenticated}, reason=${reason}`); if (isAuthenticated || reason === 'skip_during_oauth_callback') { await loadIfMissing(config); if (onSuccess) onSuccess(); // 🌐 Redirige al destino original o fallback const redirectTo = route.query?.[redirectParam] || fallbackRedirect; router.replace(redirectTo); } else { console.log('Session validation failed after callback'); if (onFail) onFail('No se pudo verificar la sesión.'); startLogin(); } } export { SSOCallback, e as SSOClient, createAccessGuard, createAccessSession, useAccess, useAccessControl, useAccessStore, useSSO, accessDirective as vAccess };