/** * Server-authoritative access control for the staff live-chat module. * * The JWT carries only AD_User_ID + AD_Role_ID — NOT IsSystemUser or * FrontendMenu — so a client-side getMenuType() check is not enough. Here we * resolve the role's FrontendMenu and the user's IsSystemUser flag against * iDempiere once, then cache the verdict (TTL) so reconnects/REST calls don't * re-hit the backend every time. * * Fail-closed: any lookup error rejects access. * * Used both from the WebSocket handler (raw cookie header, no h3 event) and the * REST routes (h3 event) — so the iDempiere call goes through $fetch directly * with the bearer token rather than fetchHelper(event). */ import refreshTokenHelper from './refreshTokenHelper' const TTL_MS = 10 * 60 * 1000 const g = globalThis as any const accessCache: Map = g.__chatAccessCache ?? (g.__chatAccessCache = new Map()) const staffCache: Map = g.__chatStaffCache ?? (g.__chatStaffCache = new Map()) const now = () => Date.now() /** Parse a raw Cookie header string into a plain object. (Named to avoid * colliding with h3's auto-imported parseCookies(event).) */ export const parseCookieHeader = (header: string | null | undefined): Record => { const out: Record = {} if (!header) return out for (const part of header.split(';')) { const idx = part.indexOf('=') if (idx === -1) continue const k = part.slice(0, idx).trim() const v = part.slice(idx + 1).trim() if (k) out[k] = decodeURIComponent(v) } return out } /** Decode a JWT payload (no signature verification — identity only). */ export const decodeJwt = (token: string): any => { const payload = token.split('.')[1] if (!payload) throw new Error('malformed token') return JSON.parse(Buffer.from(payload, 'base64').toString()) } const idempiereGet = async (urlPath: string, token: string) => { const config = useRuntimeConfig() return await $fetch(`${config.api.url}/${urlPath}`, { headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' } }) } const readMenuValue = (frontendMenu: any): string | null => { if (!frontendMenu) return null if (typeof frontendMenu === 'string') return frontendMenu return frontendMenu.id ?? frontendMenu.identifier ?? null } const isSystemUserFlag = (user: any): boolean => user?.IsSystemUser === true || user?.isSystemUser === true // Per-user chat opt-in flag (custom ad_user column). Both casings accepted // because iDempiere OData response casing varies by column. const isChatEnabledFlag = (user: any): boolean => user?.IsChatEnabled === true || user?.isChatEnabled === true /** * Throws if the (userId, roleId) is not allowed to use chat: * - role FrontendMenu === 'c' (limited/customer) → rejected * - user IsSystemUser !== true → rejected * Resolves to true (and caches the verdict) when allowed. */ export const assertStaffAccess = async (userId: number, roleId: number, token: string): Promise => { const cached = accessCache.get(userId) if (cached && cached.expires > now()) { if (!cached.ok) throw new Error('chat access denied (cached)') return true } // Role gate const role: any = await idempiereGet('models/ad_role/' + roleId, token) const menu = readMenuValue(role?.FrontendMenu) if (menu === 'c') { accessCache.set(userId, { ok: false, expires: now() + TTL_MS }) throw new Error('chat access denied (limited role)') } // User gate — must be a system user AND have opted into chat (IsChatEnabled). const user: any = await idempiereGet('models/ad_user/' + userId, token) if (!isSystemUserFlag(user)) { accessCache.set(userId, { ok: false, expires: now() + TTL_MS }) throw new Error('chat access denied (not a system user)') } if (!isChatEnabledFlag(user)) { accessCache.set(userId, { ok: false, expires: now() + TTL_MS }) throw new Error('chat access denied (chat disabled for user)') } accessCache.set(userId, { ok: true, expires: now() + TTL_MS }) staffCache.set(userId, { staff: true, expires: now() + TTL_MS }) // we just proved it return true } /** * Best-effort check that a message recipient is a valid chat participant — a * staff (IsSystemUser) user who has opted into chat (IsChatEnabled) — so a DM * can't be addressed to a customer's user_id or a colleague who turned chat * off. Returns: * true → confirmed chat-enabled staff * false → confirmed NOT a valid recipient (reject the send) * null → indeterminate (lookup failed, e.g. token expired) → caller may allow * Cached by user id. */ export const isStaffUser = async (userId: number, token: string): Promise => { const cached = staffCache.get(userId) if (cached && cached.expires > now()) return cached.staff try { const user: any = await idempiereGet('models/ad_user/' + userId, token) const staff = isSystemUserFlag(user) && isChatEnabledFlag(user) staffCache.set(userId, { staff, expires: now() + TTL_MS }) return staff } catch { return null } } /** * REST-route gate: reads the logship_it cookie from the h3 event, decodes it, * and runs assertStaffAccess. Throws an h3 error (401/403) on failure. * Returns { userId, roleId, token } on success. */ export const gateFromEvent = async (event: any): Promise<{ userId: number; roleId: number; token: string }> => { const token = getCookie(event, 'logship_it') if (!token) throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) let userId: number, roleId: number try { const p = decodeJwt(token) userId = Number(p.AD_User_ID) roleId = Number(p.AD_Role_ID) } catch { throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) } if (!userId || !roleId) throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) try { await assertStaffAccess(userId, roleId, token) return { userId, roleId, token } } catch { // A definitive, freshly-cached denial (limited role / not a chat-enabled // system user) is final — don't waste a token refresh on it. const denied = accessCache.get(userId) if (denied && denied.expires > now() && denied.ok === false) { throw createError({ statusCode: 403, statusMessage: 'Forbidden' }) } // Otherwise the failure is likely an expired access token — refresh once and // retry, mirroring the app-wide try/refreshTokenHelper/retry pattern. The // refresh also Set-Cookies a fresh logship_it, which lets the chat WebSocket // succeed on its next reconnect (the WS handler can't refresh on its own). try { const fresh = await refreshTokenHelper(event) await assertStaffAccess(userId, roleId, fresh) return { userId, roleId, token: fresh } } catch { throw createError({ statusCode: 403, statusMessage: 'Forbidden' }) } } }