@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
JavaScript
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 };