424 lines
17 KiB
JavaScript
424 lines
17 KiB
JavaScript
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 = `<?xml version="1.0" encoding="utf-8"?><BlockList>${blockIds.map((b) => `<Latest>${b}</Latest>`).join('')}</BlockList>`;
|
|
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 };
|
|
}
|