// mdm.js - Optimized: 24h cache + direct fresh lookup by Id.Value const axios = require('axios'); const MDM_BASE_URL = process.env.WS1_BASE_URL || 'https://as1991.awmdm.com'; const TOKEN_URL = 'https://na.uemauth.workspaceone.com/connect/token'; // Logging configuration (duplicated small logger for independence) const isVerbose = process.env.DEBUG === 'true' || process.env.NODE_ENV !== 'production'; const useJsonLogs = process.env.LOG_FORMAT === 'json' || process.env.NODE_ENV === 'production'; const logger = { info: (msg, meta = {}) => log('info', msg, meta), warn: (msg, meta = {}) => log('warn', msg, meta), error: (msg, meta = {}) => log('error', msg, meta), debug: (msg, meta = {}) => { if (isVerbose) log('debug', msg, meta); } }; function normalizeMeta(meta) { if (meta == null) return {}; if (meta instanceof Error) { const out = { error: meta.message, errorName: meta.name, stack: meta.stack }; if (meta.code) out.code = meta.code; if (meta.response) { out.responseStatus = meta.response.status; out.responseData = meta.response.data; } return out; } if (typeof meta !== 'object') return { value: meta }; return meta; } function log(level, msg, meta = {}) { const timestamp = new Date().toISOString(); const normMeta = normalizeMeta(meta); if (useJsonLogs) { const entry = { timestamp, level, msg, ...normMeta }; Object.keys(entry).forEach(k => entry[k] === undefined && delete entry[k]); console.log(JSON.stringify(entry)); } else { const emoji = level === 'error' ? '❌' : level === 'warn' ? 'âš ī¸' : level === 'debug' ? '🐛' : 'â„šī¸'; const metaStr = Object.keys(normMeta).length ? ' ' + JSON.stringify(normMeta) : ''; const out = level === 'error' ? console.error : console.log; out(`${emoji} ${msg}${metaStr}`); } } let currentMDMToken = null; let tokenExpiresAt = 0; let deviceIdCache = new Map(); // serial → { id, ...basicInfo } let lastFullCacheTime = 0; // In-flight promise trackers to coalesce concurrent callers (prevents // burst webhook traffic from triggering N parallel token fetches / // cache refreshes when one would suffice). let inFlightTokenPromise = null; let inFlightCacheRefreshPromise = null; const CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours function invalidateMDMToken() { currentMDMToken = null; tokenExpiresAt = 0; } async function getMDMToken(forceRefresh = false) { const now = Date.now(); if (!forceRefresh && currentMDMToken && now < tokenExpiresAt) return currentMDMToken; // If another caller is already fetching, wait on that same promise. if (inFlightTokenPromise) return inFlightTokenPromise; inFlightTokenPromise = (async () => { logger.info('Fetching new Workspace ONE MDM token...'); const response = await axios.post(TOKEN_URL, new URLSearchParams({ grant_type: 'client_credentials', client_id: process.env.WS1_CLIENT_ID, client_secret: process.env.WS1_CLIENT_SECRET, }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 20000 }); currentMDMToken = response.data.access_token; tokenExpiresAt = Date.now() + (response.data.expires_in * 1000) - 60000; logger.info('MDM token acquired'); return currentMDMToken; })().finally(() => { inFlightTokenPromise = null; }); return inFlightTokenPromise; } // Full cache refresh every 24 hours (used only to map serial → Id) async function refreshDeviceIdCache() { const now = Date.now(); if (deviceIdCache.size > 0 && now - lastFullCacheTime < CACHE_DURATION_MS) { return; } // Coalesce concurrent callers — a burst of webhooks should trigger one refresh, not N. if (inFlightCacheRefreshPromise) return inFlightCacheRefreshPromise; inFlightCacheRefreshPromise = (async () => { logger.info('Refreshing device ID cache (24h cycle)...'); let token; try { token = await getMDMToken(); } catch (e) { logger.error('Failed to obtain MDM token for cache refresh', { error: e.message }); return; // keep existing cache if any } const tempCache = new Map(); let page = 0; const pageSize = 500; let hasMore = true; let success = false; try { while (hasMore) { const response = await axios.get(`${MDM_BASE_URL}/api/mdm/devices/search`, { params: { page, page_size: pageSize }, headers: { Authorization: `Bearer ${token}`, 'aw-tenant-code': process.env.WS1_TENANT_CODE, 'Accept': 'application/json' }, timeout: 30000 }); const pageDevices = response.data.Devices || []; pageDevices.forEach(d => { if (d.SerialNumber) { tempCache.set(d.SerialNumber.toUpperCase().trim(), { id: d.Id?.Value || d.id, serial: d.SerialNumber, basicInfo: d }); } }); logger.debug('Cache refresh page', { page, devices: pageDevices.length }); if (pageDevices.length < pageSize) hasMore = false; else page++; } success = true; } catch (err) { logger.error('Device ID cache refresh failed (keeping previous cache)', { error: err.message }); // do not throw — callers (webhooks) should continue with stale-but-better-than-nothing data } if (success && tempCache.size > 0) { deviceIdCache = tempCache; // atomic swap lastFullCacheTime = Date.now(); logger.info('Device ID cache refreshed', { total: deviceIdCache.size }); } })().finally(() => { inFlightCacheRefreshPromise = null; }); return inFlightCacheRefreshPromise; } // Get fresh device details by ID (always current status) async function getFreshMDMDeviceById(deviceId) { if (!deviceId) return null; let token = await getMDMToken(); try { const response = await axios.get(`${MDM_BASE_URL}/api/mdm/devices/${deviceId}`, { headers: { Authorization: `Bearer ${token}`, 'aw-tenant-code': process.env.WS1_TENANT_CODE, 'Accept': 'application/json' }, timeout: 15000 }); return response.data; } catch (err) { const status = err.response?.status; if (status === 401 || status === 403) { logger.warn('MDM auth failed (401/403) — invalidating token and retrying once'); invalidateMDMToken(); token = await getMDMToken(true); try { const retryResp = await axios.get(`${MDM_BASE_URL}/api/mdm/devices/${deviceId}`, { headers: { Authorization: `Bearer ${token}`, 'aw-tenant-code': process.env.WS1_TENANT_CODE, 'Accept': 'application/json' }, timeout: 15000 }); return retryResp.data; } catch (retryErr) { logger.warn('Retry also failed for deviceId', { deviceId, error: retryErr.message }); return null; } } logger.warn('Failed to fetch fresh details for deviceId', { deviceId, error: err.message }); return null; } } // Main lookup function async function getMDMDeviceBySerial(serialNumber) { if (!serialNumber) return null; const normalized = serialNumber.toUpperCase().trim(); if (isVerbose) logger.debug('Looking up MDM device for serial', { serial: normalized }); // Ensure we have the ID cache await refreshDeviceIdCache(); const cachedEntry = deviceIdCache.get(normalized); if (!cachedEntry || !cachedEntry.id) { if (isVerbose) logger.debug('No ID found for serial', { serial: normalized }); return null; } // Get fresh/current data using the ID if (isVerbose) logger.debug('Fetching fresh status for deviceId', { deviceId: cachedEntry.id }); const freshDevice = await getFreshMDMDeviceById(cachedEntry.id); if (freshDevice) { if (isVerbose) logger.debug('Fresh MDM data retrieved', { serial: normalized }); return freshDevice; } // Fallback to cached basic info if (isVerbose) logger.debug('Using cached basic info', { serial: normalized }); return cachedEntry.basicInfo; } // Send a SoftReset (reboot) command to a device in Workspace ONE UEM. // `deviceId` is the WS1 numeric Id (e.g. mdmDevice.Id.Value), NOT the serial. // Returns { success: boolean, status?: number, error?: string }. // Note: For iOS, SoftReset is only honored when the device is Supervised (DEP-enrolled). // Non-supervised iOS devices will surface a WS1 error in the returned message. async function sendMDMRebootCommand(deviceId) { if (!deviceId) { return { success: false, error: 'No WS1 deviceId supplied' }; } const url = `${MDM_BASE_URL}/api/mdm/devices/commands`; const params = { command: 'SoftReset', searchBy: 'DeviceId', id: deviceId }; async function doRequest(token) { return axios.post(url, null, { params, headers: { Authorization: `Bearer ${token}`, 'aw-tenant-code': process.env.WS1_TENANT_CODE, 'Accept': 'application/json' }, timeout: 20000 }); } let token; try { token = await getMDMToken(); } catch (e) { return { success: false, error: `Could not obtain MDM token: ${e.message}` }; } try { const resp = await doRequest(token); return { success: true, status: resp.status }; } catch (err) { const status = err.response?.status; // One-shot retry on stale token if (status === 401 || status === 403) { logger.warn('MDM auth failed on reboot command — invalidating token and retrying once', { deviceId }); invalidateMDMToken(); try { const freshToken = await getMDMToken(true); const retryResp = await doRequest(freshToken); return { success: true, status: retryResp.status }; } catch (retryErr) { return { success: false, status: retryErr.response?.status, error: retryErr.response?.data?.message || retryErr.response?.data?.errorCode || retryErr.message }; } } return { success: false, status, error: err.response?.data?.message || err.response?.data?.errorCode || err.message }; } } module.exports = { getMDMDeviceBySerial, sendMDMRebootCommand };