import { isAllowedAuthorityHost, isAllowedGraphHost } from '../data/graphHosts.js'; function sanitizeBase(url = '') { return String(url || '').replace(/\/+$/, ''); } // Defense in depth: even though connection URLs are validated on write, re-check // before every outbound request so stored/legacy rows can't trigger SSRF. function assertAllowedEndpoints(connection) { if (!isAllowedAuthorityHost(connection.authority_host)) { throw new Error('Graph connection authority host is not an allowed Microsoft endpoint'); } if (!isAllowedGraphHost(connection.graph_base_url)) { throw new Error('Graph connection base URL is not an allowed Microsoft endpoint'); } } export async function getGraphToken(connection) { assertAllowedEndpoints(connection); const authority = sanitizeBase(connection.authority_host); const graphBase = sanitizeBase(connection.graph_base_url); const response = await fetch(`${authority}/${encodeURIComponent(connection.tenant_id)}/oauth2/v2.0/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: connection.client_id, client_secret: connection.clientSecret, scope: `${graphBase}/.default`, grant_type: 'client_credentials' }) }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error_description || payload.error || `Token request failed: ${response.status}`); return payload.access_token; } export async function graphRequest(connection, path, options = {}) { assertAllowedEndpoints(connection); const token = options.token || await getGraphToken(connection); const base = sanitizeBase(connection.graph_base_url); const response = await fetch(`${base}${path}`, { method: options.method || 'GET', headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', ...(options.body ? { 'Content-Type': 'application/json' } : {}) }, body: options.body ? JSON.stringify(options.body) : undefined }); const text = await response.text(); const payload = text ? JSON.parse(text) : {}; if (!response.ok) { const message = payload?.error?.message || payload?.error_description || `Graph request failed: ${response.status}`; const error = new Error(message); error.status = response.status; error.payload = payload; throw error; } return payload; } export async function testGraphConnection(connection) { const token = await getGraphToken(connection); const payload = await graphRequest(connection, '/v1.0/deviceAppManagement?$select=id&$top=1', { token }); return { ok: true, message: `Connected. deviceAppManagement readable; sample keys: ${Object.keys(payload).join(', ')}` }; } // Create or update the win32LobApp metadata object. Content (.intunewin bytes) // upload is a separate Phase 2 concern; this writes the app definition only. // mobileApps create/update lives on the beta endpoint for full win32 fidelity. export async function upsertWin32App(connection, payload, existingAppId = '') { const token = await getGraphToken(connection); if (existingAppId) { await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(existingAppId)}`, { token, method: 'PATCH', body: payload }); return { id: existingAppId, updated: true }; } const created = await graphRequest(connection, '/beta/deviceAppManagement/mobileApps', { token, method: 'POST', body: payload }); return { id: created.id, created: true, raw: created }; } const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function waitForFileState(connection, token, appPath, cvId, fileId, target, { attempts = 30, delayMs = 2000 } = {}) { for (let i = 0; i < attempts; i++) { const file = await graphRequest(connection, `${appPath}/contentVersions/${cvId}/files/${fileId}`, { token }); const state = file.uploadState || ''; if (state === target) return file; if (state.endsWith('Failed')) throw new Error(`Intune content upload failed in state "${state}"`); await sleep(delayMs); } throw new Error(`Timed out waiting for Intune content state "${target}"`); } // Upload encrypted .intunewin content to Azure Storage in blocks, then commit. // The SAS URI is returned by Graph (trusted); the block PUTs go straight to // Azure Blob storage, outside the Graph allow-list. async function azureBlockUpload(sasUri, content) { const blockSize = 6 * 1024 * 1024; const blockIds = []; let index = 0; for (let start = 0; start < content.length; start += blockSize) { const chunk = content.subarray(start, start + blockSize); const blockId = Buffer.from(String(index).padStart(8, '0')).toString('base64'); blockIds.push(blockId); const res = await fetch(`${sasUri}&comp=block&blockid=${encodeURIComponent(blockId)}`, { method: 'PUT', headers: { 'x-ms-blob-type': 'BlockBlob' }, body: chunk }); if (!res.ok) throw new Error(`Azure block upload failed (${res.status}) on block ${index}`); index += 1; } const listXml = `${blockIds.map((b) => `${b}`).join('')}`; const commit = await fetch(`${sasUri}&comp=blocklist`, { method: 'PUT', headers: { 'Content-Type': 'text/plain; charset=UTF-8' }, body: listXml }); if (!commit.ok) throw new Error(`Azure block-list commit failed (${commit.status})`); } // Full Win32 content commit flow (Phase 2). Requires a live tenant + Azure // Storage; validated end to end only against a real tenant. `parsed` is the // output of parseIntunewin(). export async function uploadWin32Content(connection, appId, parsed) { const token = await getGraphToken(connection); const appPath = `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}/microsoft.graph.win32LobApp`; const version = await graphRequest(connection, `${appPath}/contentVersions`, { token, method: 'POST', body: {} }); const cvId = version.id; const fileMeta = await graphRequest(connection, `${appPath}/contentVersions/${cvId}/files`, { token, method: 'POST', body: { '@odata.type': '#microsoft.graph.mobileAppContentFile', name: parsed.setupFile || 'IntunePackage.intunewin', size: parsed.unencryptedContentSize, sizeEncrypted: parsed.encryptedContentSize, isDependency: false, manifest: null } }); const fileId = fileMeta.id; const ready = await waitForFileState(connection, token, appPath, cvId, fileId, 'azureStorageUriRequestSuccess'); await azureBlockUpload(ready.azureStorageUri, parsed.encryptedContent); await graphRequest(connection, `${appPath}/contentVersions/${cvId}/files/${fileId}/commit`, { token, method: 'POST', body: { fileEncryptionInfo: parsed.fileEncryptionInfo } }); await waitForFileState(connection, token, appPath, cvId, fileId, 'commitFileSuccess'); await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}`, { token, method: 'PATCH', body: { '@odata.type': '#microsoft.graph.win32LobApp', committedContentVersion: cvId } }); return { contentVersionId: cvId, fileId, size: parsed.unencryptedContentSize, sizeEncrypted: parsed.encryptedContentSize }; } // Replace the app's assignments in one call. The /assign action is a full // replacement, so callers send the complete desired assignment set. export async function assignWin32App(connection, appId, mobileAppAssignments) { const token = await getGraphToken(connection); await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}/assign`, { token, method: 'POST', body: { mobileAppAssignments } }); return { ok: true, count: mobileAppAssignments.length }; } // Replace the app's supersedence/dependency relationships. updateRelationships // is also a full replacement of the app's outgoing relationship set. export async function setWin32AppRelationships(connection, appId, relationships) { const token = await getGraphToken(connection); await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}/updateRelationships`, { token, method: 'POST', body: { relationships } }); return { ok: true, count: relationships.length }; } // Live win32LobApp metadata + assignments + relationships, used by drift checks. export async function getWin32AppState(connection, appId) { const token = await getGraphToken(connection); const base = `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}`; const [app, assignments, relationships] = await Promise.all([ graphRequest(connection, base, { token }), graphRequest(connection, `${base}/assignments`, { token }).then((r) => r.value || []).catch(() => []), graphRequest(connection, `${base}/relationships`, { token }).then((r) => r.value || []).catch(() => []) ]); return { app, assignments, relationships }; } export async function getGraphMobileApp(connection, appId) { const token = await getGraphToken(connection); const version = connection.default_api_version || 'v1.0'; return graphRequest( connection, `/${version}/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}?$select=id,displayName,publisher,displayVersion`, { token } ); } export async function listGraphMobileApps(connection, query = '') { const token = await getGraphToken(connection); const version = connection.default_api_version || 'v1.0'; const filter = query ? `&$filter=contains(displayName,'${String(query).replaceAll("'", "''")}')` : ''; const payload = await graphRequest( connection, `/${version}/deviceAppManagement/mobileApps?$select=id,displayName,publisher,createdDateTime,lastModifiedDateTime,isAssigned${filter}&$top=50`, { token } ); return (payload.value || []).map((app) => ({ id: app.id, displayName: app.displayName || app.name || app.id, publisher: app.publisher || '', createdDateTime: app.createdDateTime || '', lastModifiedDateTime: app.lastModifiedDateTime || '', isAssigned: Boolean(app.isAssigned) })); } export async function syncIntuneDeploymentStatus(connection, graphAppId, mode = 'reports') { const token = await getGraphToken(connection); const rows = []; const sources = []; let overview = {}; if (mode === 'reports' || mode === 'both') { const reportResult = await syncReports(connection, token, graphAppId); rows.push(...reportResult.rows); overview = reportResult.overview; sources.push(...reportResult.sources); } if ((mode === 'legacy-status' || mode === 'both') && !rows.length) { const legacyRows = await syncLegacyDeviceStatuses(connection, token, graphAppId); rows.push(...legacyRows); sources.push('legacy-deviceStatuses'); } return { rows, overview, sources, summary: summarizeRows(rows, overview) }; } async function syncReports(connection, token, graphAppId) { const rows = []; const sources = []; let overview = {}; try { const appOverview = await graphRequest(connection, '/beta/deviceManagement/reports/getAppStatusOverviewReport', { token, method: 'POST', body: { filter: `(ApplicationId eq '${graphAppId}')` } }); overview = reportRows(appOverview)[0] || {}; sources.push('getAppStatusOverviewReport'); } catch (error) { overview = { warning: `Overview report unavailable: ${error.message}` }; } try { const deviceReport = await graphRequest(connection, '/beta/deviceManagement/reports/getDeviceInstallStatusReport', { token, method: 'POST', body: { select: [ 'DeviceId', 'DeviceName', 'UserPrincipalName', 'Platform', 'Status', 'ErrorCode', 'AppInstallState', 'InstallState', 'InstallStateDetail', 'LastModifiedDateTime', 'ApplicationId' ], filter: `(ApplicationId eq '${graphAppId}')`, skip: 0, top: 50000, orderBy: [] } }); rows.push(...reportRows(deviceReport).map((row) => normalizeReportStatus(row, 'device'))); sources.push('getDeviceInstallStatusReport'); } catch (error) { rows.push(failureRow('device', 'report-unavailable', error.message, { source: 'getDeviceInstallStatusReport' })); } try { const userReport = await graphRequest(connection, '/beta/deviceManagement/reports/getUserInstallStatusReport', { token, method: 'POST', body: { select: ['UserId', 'UserName', 'UserPrincipalName', 'Status', 'InstalledDeviceCount', 'FailedDeviceCount', 'ApplicationId'], filter: `(ApplicationId eq '${graphAppId}')`, skip: 0, top: 50000, orderBy: [] } }); rows.push(...reportRows(userReport).map((row) => normalizeReportStatus(row, 'user'))); sources.push('getUserInstallStatusReport'); } catch (error) { rows.push(failureRow('user', 'report-unavailable', error.message, { source: 'getUserInstallStatusReport' })); } return { rows, overview, sources }; } async function syncLegacyDeviceStatuses(connection, token, graphAppId) { const payload = await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${graphAppId}/deviceStatuses`, { token }); return (payload.value || []).map((row) => ({ scope: 'device', principalId: row.id || '', principalName: row.deviceName || row.userName || '', deviceId: row.deviceId || '', deviceName: row.deviceName || '', userId: row.userId || '', userPrincipalName: row.userPrincipalName || row.userName || '', installState: normalizeState(row.installState || row.mobileAppInstallStatusValue || row.status), installStateDetail: row.installStateDetail || '', errorCode: row.errorCode, errorDescription: row.errorDescription || failureCause(row.errorCode, row.installStateDetail), lastSyncDateTime: row.lastSyncDateTime || '', raw: row })); } function reportRows(report) { const schema = report.Schema || report.schema || []; const values = report.Values || report.values || report.value || []; if (!Array.isArray(values)) return []; if (!schema.length) return values; return values.map((entry) => { if (!Array.isArray(entry)) return entry; return Object.fromEntries(schema.map((column, index) => [column.Column || column.column || column.Name || column.name, entry[index]])); }); } function normalizeReportStatus(row, scope) { const installState = normalizeState(row.Status || row.InstallState || row.AppInstallState || row.State || row.InstallStatus); const errorCode = row.ErrorCode ?? row.Error ?? row.HexErrorCode ?? ''; return { scope, principalId: scope === 'device' ? row.DeviceId || row.ManagedDeviceId || '' : row.UserId || '', principalName: scope === 'device' ? row.DeviceName || '' : row.UserName || row.UserPrincipalName || '', deviceId: row.DeviceId || row.ManagedDeviceId || '', deviceName: row.DeviceName || '', userId: row.UserId || '', userPrincipalName: row.UserPrincipalName || row.UserName || '', installState, installStateDetail: row.InstallStateDetail || row.StatusDetails || row.StateDetails || '', errorCode, errorDescription: row.ErrorDescription || row.ErrorDetails || failureCause(errorCode, row.InstallStateDetail), lastSyncDateTime: row.LastModifiedDateTime || row.LastSyncDateTime || row.LastReportedDateTime || '', raw: row }; } function normalizeState(value = '') { const text = String(value || '').toLowerCase(); if (text.includes('fail') || text === 'error') return 'failed'; if (text.includes('install') || text === 'success' || text === 'installed') return 'installed'; if (text.includes('pending') || text.includes('progress')) return 'pending'; if (text.includes('notapplicable') || text.includes('not applicable')) return 'notApplicable'; if (text.includes('notinstalled') || text.includes('not installed')) return 'notInstalled'; return text || 'unknown'; } function failureRow(scope, code, message, raw) { return { scope, installState: 'failed', installStateDetail: code, errorCode: code, errorDescription: message, raw }; } function failureCause(errorCode, detail = '') { const code = String(errorCode || '').toLowerCase(); const known = { '0x87d1041c': 'Intune did not detect the app after installation. Check detection rules, install command, and app install timing.', '0x87d13b9f': 'App installation failed. Review Intune Management Extension logs and PSADT logs.', '1602': 'User deferred or cancelled installation. Confirm return code is mapped as retry and DeferRunInterval is configured when needed.', '3010': 'Soft reboot required. Confirm Intune return code behavior.', '1703': 'Legacy soft reboot mapping. Confirm this is intentional for the package.' }; return known[code] || detail || ''; } function summarizeRows(rows, overview = {}) { const counts = { total: rows.length, installed: 0, failed: 0, pending: 0, notInstalled: 0, notApplicable: 0, unknown: 0 }; for (const row of rows) { const state = row.installState || 'unknown'; if (counts[state] == null) counts.unknown += 1; else counts[state] += 1; } return { counts, overview }; }