Skip to content

Instantly share code, notes, and snippets.

@Hesamedin
Last active October 16, 2025 16:41
Show Gist options
  • Save Hesamedin/cde8eee8abd0c44e007925c75a2a0b99 to your computer and use it in GitHub Desktop.
Save Hesamedin/cde8eee8abd0c44e007925c75a2a0b99 to your computer and use it in GitHub Desktop.
Based on https://stackoverflow.com/a/79444551/513413, the script deploys .ipa/.apk/.aab files to Intune. It woly works on Windows machines (tested on Azure Windows server 2025)
<#
.SYNOPSIS
Uploads an iOS or Android application binary (.ipa, .apk, .aab) to Microsoft Intune.
.DESCRIPTION
This script automates the process of uploading a new version of a Line-of-Business (LOB) application to Microsoft Intune.
It handles authentication, file encryption, uploading to Azure Storage, and committing the new version to the specified Intune application.
The script automatically detects the application type based on the file extension of the AppFilePath parameter.
- For .ipa files, it extracts the version and build number from the Info.plist file.
- For .apk or .aab files, it uses the 'aapt' tool to extract the versionName and versionCode. Ensure 'aapt' is available in the system's PATH when uploading Android apps.
.PARAMETER AppName
The display name of the application in Intune.
.PARAMETER AppFilePath
The local path to the application binary file (.ipa, .apk, or .aab).
.PARAMETER AppPublisher
The publisher of the application.
.PARAMETER AppBundleId
The bundle identifier (for iOS) or package name (for Android) of the application (e.g., com.company.appname).
.PARAMETER IntuneAppId
The existing App ID in Intune for the application that will be updated.
.PARAMETER IntuneTenantId
The Azure Tenant ID where Intune is located.
.PARAMETER IntuneClientId
The Client ID of the Azure AD application registration with permissions to manage Intune.
.PARAMETER IntuneClientSecret
The Client Secret of the Azure AD application registration.
.EXAMPLE
./intune_deploy.ps1
-AppName "My Awesome App" `
-AppFilePath "./path/to/my-app.ipa" `
-AppPublisher "My Company" `
-AppBundleId "com.mycompany.app" `
-IntuneAppId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
-IntuneTenantId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
-IntuneClientId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
-IntuneClientSecret "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
#>
param(
[Parameter(Mandatory=$true)]
[string]$AppName,
[Parameter(Mandatory=$true)]
[string]$AppFilePath,
[Parameter(Mandatory=$true)]
[string]$AppPublisher,
[Parameter(Mandatory=$true)]
[string]$AppBundleId,
[Parameter(Mandatory=$true)]
[string]$IntuneAppId,
[Parameter(Mandatory=$true)]
[string]$IntuneTenantId,
[Parameter(Mandatory=$true)]
[string]$IntuneClientId,
[Parameter(Mandatory=$true)]
[string]$IntuneClientSecret
)
# Define variables for Intune app
$appDisplayName = $AppName
$sourceFile = $AppFilePath
$publisher = $AppPublisher
$bundleId = $AppBundleId
# Define authentication variables
$intuneAppId = $IntuneAppId
$tenantId = $IntuneTenantId
$applicationId = $IntuneClientId
$clientSecret = $IntuneClientSecret
# Get authentication token
$body = @{
grant_type = "client_credentials"
client_id = $applicationId
client_secret = $clientSecret
scope = "https://graph.microsoft.com/.default"
}
$tokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" -Method Post -Body $Body -ContentType "application/x-www-form-urlencoded"
$accessToken = $tokenResponse.access_token
$headers = @{
Authorization = "Bearer $accessToken"
}
function UploadAzureStorageChunk($sasUri, $id, $body){
$uri = "$sasUri&comp=block&blockid=$id";
$request = "PUT $uri";
$iso = [System.Text.Encoding]::GetEncoding("iso-8859-1");
$encodedBody = $iso.GetString($body);
$headers = @{
"x-ms-blob-type" = "BlockBlob"
};
if ($logRequestUris) { Write-Host $request; }
if ($logHeaders) { WriteHeaders $headers; }
try {
Invoke-WebRequest $uri -Method Put -Headers $headers -Body $encodedBody;
} catch {
Write-Host -ForegroundColor Red $request;
Write-Host -ForegroundColor Red $_.Exception.Message;
throw;
}
}
function FinalizeAzureStorageUpload($sasUri, $ids){
$uri = "$sasUri&comp=blocklist";
$request = "PUT $uri";
$xml = '<?xml version="1.0" encoding="utf-8"?><BlockList>';
foreach ($id in $ids) {
$xml += "<Latest>$id</Latest>";
}
$xml += '</BlockList>';
if ($logRequestUris) { Write-Host $request; }
if ($logContent) { Write-Host -ForegroundColor Gray $xml; }
try {
Invoke-RestMethod $uri -Method Put -Body $xml;
} catch {
Write-Host -ForegroundColor Red $request;
Write-Host -ForegroundColor Red $_.Exception.Message;
throw;
}
}
function UploadFileToAzureStorage($sasUri, $filepath){
# Chunk size = 1 MiB
$chunkSizeInBytes = 1024 * 1024;
# Read the whole file and find the total chunks.
#[byte[]]$bytes = Get-Content $filepath -Encoding byte;
# Using ReadAllBytes method as the Get-Content used alot of memory on the machine
[byte[]]$bytes = [System.IO.File]::ReadAllBytes($filepath);
$chunks = [Math]::Ceiling($bytes.Length / $chunkSizeInBytes);
# Upload each chunk.
$ids = @();
$cc = 1
for ($chunk = 0; $chunk -lt $chunks; $chunk++){
$id = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($chunk.ToString("0000")));
$ids += $id;
$start = $chunk * $chunkSizeInBytes;
$end = [Math]::Min($start + $chunkSizeInBytes - 1, $bytes.Length - 1);
$body = $bytes[$start..$end];
Write-Progress -Activity "Uploading File to Azure Storage" -status "Uploading chunk $cc of $chunks" `
-percentComplete ($cc / $chunks*100)
$cc++
UploadAzureStorageChunk $sasUri $id $body;
}
Write-Progress -Completed -Activity "Uploading File to Azure Storage"
Write-Host
# Finalize the upload.
FinalizeAzureStorageUpload $sasUri $ids;
}
function Get-IpaVersionInfo($ipaPath) {
Write-Host "Extracting version info from '$ipaPath'..."
$tempDir = [System.IO.Path]::Combine($env:TEMP, [System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $tempDir | Out-Null
try {
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory($ipaPath, $tempDir)
$payloadPath = Join-Path -Path $tempDir -ChildPath "Payload"
$appPath = (Get-ChildItem -Path $payloadPath -Directory).FullName
$plistPath = Join-Path -Path $appPath -ChildPath "Info.plist"
if (-not (Test-Path -LiteralPath $plistPath)) {
throw "Could not find Info.plist at '$plistPath'"
}
[xml]$plist = Get-Content -LiteralPath $plistPath
$versionNode = $plist.SelectSingleNode("//key[text()='CFBundleShortVersionString']/following-sibling::string[1]")
$buildNode = $plist.SelectSingleNode("//key[text()='CFBundleVersion']/following-sibling::string[1]")
if (-not ($versionNode -and $buildNode)) {
throw "Could not find version or build number keys in Info.plist"
}
$version = $versionNode.'#text'
$buildNumber = $buildNode.'#text'
Write-Host " - Found Version: $version"
Write-Host " - Found Build Number: $buildNumber"
return [PSCustomObject]@{
Version = $version
BuildNumber = $buildNumber
}
}
finally {
if (Test-Path -LiteralPath $tempDir) {
Remove-Item -Recurse -Force -LiteralPath $tempDir
}
}
}
function Get-ApkVersionInfo($apkPath) {
Write-Host "Extracting version info from '$apkPath' using aapt..."
try {
$aaptOutput = aapt dump badging $apkPath
$versionName = $aaptOutput | Select-String -Pattern "versionName='([^']+)'" | ForEach-Object { $_.Matches[0].Groups[1].Value }
$versionCode = $aaptOutput | Select-String -Pattern "versionCode='([^']+)'" | ForEach-Object { $_.Matches[0].Groups[1].Value }
if (-not ($versionName -and $versionCode)) {
throw "Could not parse version name or code from aapt output."
}
Write-Host " - Found Version: $versionName"
Write-Host " - Found Build Number: $versionCode"
return [PSCustomObject]@{
Version = $versionName
BuildNumber = $versionCode
}
}
catch {
Write-Host "Failed to get version info from APK. Ensure 'aapt' is in your PATH." -ForegroundColor Red
throw
}
}
function GenerateKey{
try {
$aes = [System.Security.Cryptography.Aes]::Create();
$aesProvider = New-Object System.Security.Cryptography.AesCryptoServiceProvider;
$aesProvider.GenerateKey();
$aesProvider.Key;
} finally {
if ($null -ne $aesProvider) { $aesProvider.Dispose(); }
if ($null -ne $aes) { $aes.Dispose(); }
}
}
function GenerateIV{
try {
$aes = [System.Security.Cryptography.Aes]::Create();
$aes.IV;
} finally {
if ($null -ne $aes) { $aes.Dispose(); }
}
}
function EncryptFileWithIV($sourceFile, $targetFile, $encryptionKey, $hmacKey, $initializationVector){
$bufferBlockSize = 1024 * 4;
$computedMac = $null;
try {
$aes = [System.Security.Cryptography.Aes]::Create();
$hmacSha256 = New-Object System.Security.Cryptography.HMACSHA256;
$hmacSha256.Key = $hmacKey;
$hmacLength = $hmacSha256.HashSize / 8;
$buffer = New-Object byte[] $bufferBlockSize;
$bytesRead = 0;
$targetStream = [System.IO.File]::Open($targetFile, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read);
$targetStream.Write($buffer, 0, $hmacLength + $initializationVector.Length);
try {
$encryptor = $aes.CreateEncryptor($encryptionKey, $initializationVector);
$sourceStream = [System.IO.File]::Open($sourceFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read);
$cryptoStream = New-Object System.Security.Cryptography.CryptoStream -ArgumentList @($targetStream, $encryptor, [System.Security.Cryptography.CryptoStreamMode]::Write);
$targetStream = $null;
while (($bytesRead = $sourceStream.Read($buffer, 0, $bufferBlockSize)) -gt 0) {
$cryptoStream.Write($buffer, 0, $bytesRead);
$cryptoStream.Flush();
}
$cryptoStream.FlushFinalBlock();
} finally {
if ($null -ne $cryptoStream) { $cryptoStream.Dispose(); }
if ($null -ne $sourceStream) { $sourceStream.Dispose(); }
if ($null -ne $encryptor) { $encryptor.Dispose(); }
}
try {
$finalStream = [System.IO.File]::Open($targetFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::Read)
$finalStream.Seek($hmacLength, [System.IO.SeekOrigin]::Begin) > $null;
$finalStream.Write($initializationVector, 0, $initializationVector.Length);
$finalStream.Seek($hmacLength, [System.IO.SeekOrigin]::Begin) > $null;
$hmac = $hmacSha256.ComputeHash($finalStream);
$computedMac = $hmac;
$finalStream.Seek(0, [System.IO.SeekOrigin]::Begin) > $null;
$finalStream.Write($hmac, 0, $hmac.Length);
} finally {
if ($null -ne $finalStream) { $finalStream.Dispose(); }
}
} finally {
if ($null -ne $targetStream) { $targetStream.Dispose(); }
if ($null -ne $aes) { $aes.Dispose(); }
}
$computedMac;
}
function EncryptFile($sourceFile, $targetFile){
$encryptionKey = GenerateKey;
$hmacKey = GenerateKey;
$initializationVector = GenerateIV;
# Create the encrypted target file and compute the HMAC value.
$mac = EncryptFileWithIV $sourceFile $targetFile $encryptionKey $hmacKey $initializationVector;
# Compute the SHA256 hash of the source file and convert the result to bytes.
$fileDigest = (Get-FileHash $sourceFile -Algorithm SHA256).Hash;
$fileDigestBytes = New-Object byte[] ($fileDigest.Length / 2);
for ($i = 0; $i -lt $fileDigest.Length; $i += 2){
$fileDigestBytes[$i / 2] = [System.Convert]::ToByte($fileDigest.Substring($i, 2), 16);
}
# Return an object that will serialize correctly to the file commit Graph API.
$encryptionInfo = @{};
$encryptionInfo.encryptionKey = [System.Convert]::ToBase64String($encryptionKey);
$encryptionInfo.macKey = [System.Convert]::ToBase64String($hmacKey);
$encryptionInfo.initializationVector = [System.Convert]::ToBase64String($initializationVector);
$encryptionInfo.mac = [System.Convert]::ToBase64String($mac);
$encryptionInfo.profileIdentifier = "ProfileVersion1";
$encryptionInfo.fileDigest = [System.Convert]::ToBase64String($fileDigestBytes);
$encryptionInfo.fileDigestAlgorithm = "SHA256";
$fileEncryptionInfo = @{};
$fileEncryptionInfo.fileEncryptionInfo = $encryptionInfo;
$fileEncryptionInfo;
}
function WaitForFileProcessing($fileUri, $stage){
$attempts= 60;
$waitTimeInSeconds = 1;
$successState = "$($stage)Success";
$pendingState = "$($stage)Pending";
$file = $null;
while ($attempts -gt 0) {
$file = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/deviceAppManagement/$fileUri" -Method GET -Headers $headers -ContentType "application/json"
# MakeGetRequest $fileUri;
if ($file.uploadState -eq $successState) {
break;
} elseif ($file.uploadState -ne $pendingState) {
Write-Host "File processing response (failure):" -ForegroundColor Red
Write-Host ($file | ConvertTo-Json -Depth 10)
throw "File upload state is not success: $($file.uploadState)";
}
Start-Sleep $waitTimeInSeconds;
$attempts--;
}
if ($null -eq $file) {
throw "File request did not complete in the allotted time.";
}
$file;
}
Function Test-SourceFile(){
param(
[parameter(Mandatory=$true)]
[ValidateNotNullOrEmpty()]
$sourceFile
)
try {
if(!(test-path "$sourceFile")){
Write-Host "Source File '$sourceFile' doesn't exist..." -ForegroundColor Red
throw
}
} catch {
Write-Host -ForegroundColor Red $_.Exception.Message;
Write-Host
break;
}
}
#################################################################################
#################################################################################
Write-Host "== Step 0: Prep source ==" -ForegroundColor Yellow
Write-Host "Checking source '$sourceFile'..." -ForegroundColor Yellow
Test-SourceFile "$sourceFile"
# Get version info and set LOB type based on file type
$fileExtension = [System.IO.Path]::GetExtension($sourceFile).ToLower()
$binaryInfo = $null
$lobType = $null
if ($fileExtension -eq ".ipa") {
$binaryInfo = Get-IpaVersionInfo -ipaPath $sourceFile
$lobType = "microsoft.graph.iosLOBApp"
}
elseif ($fileExtension -eq ".apk" -or $fileExtension -eq ".aab") {
$binaryInfo = Get-ApkVersionInfo -apkPath $sourceFile
$lobType = "microsoft.graph.androidLOBApp"
}
else {
throw "Unsupported file type '$fileExtension'. Only '.ipa', '.apk', and '.aab' are supported."
}
$version = $binaryInfo.Version
$buildNumber = $binaryInfo.BuildNumber
# Creating temp file name from Source File path
$tempFile = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($sourceFile), "$([System.IO.Path]::GetFileNameWithoutExtension($sourceFile))_temp.bin")
# Creating filename variable from Source File Path
$fileName = [System.IO.Path]::GetFileName("$sourceFile")
Write-Host "== Step 1: Lookup app ==" -ForegroundColor Yellow
if (-not $intuneAppId) {
throw "The '$intuneAppId' variable is not set. Please provide the ID of the existing Intune application."
}
$appId = $intuneAppId
Write-Host "Using Intune App ID: $appId"
Write-Host
Write-Host "Validating application in Intune..." -ForegroundColor Yellow
try {
$mobileApp = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/deviceAppManagement/mobileApps/$appId" -Method GET -Headers $headers
$mobileApp | ConvertTo-Json
$expectedODataType = "#$lobType"
if ($mobileApp.'@odata.type' -ne $expectedODataType) {
throw "The specified application's type '$($mobileApp.'@odata.type')' does not match the uploaded file's expected type '$expectedODataType'."
}
} catch {
Write-Host "Failed to retrieve or validate the Intune application with ID '$appId'." -ForegroundColor Red
if ($_.Exception.Response -and $_.Exception.Response.Content) {
Write-Host ($_.Exception.Response.Content | Out-String)
} else {
Write-Host $_
}
throw
}
# Get the content version for the new app (this will always be 1 until the new app is committed).
Write-Host "== Step 2: New content version ==" -ForegroundColor Yellow
Write-Host
$contentVersionUri = "mobileApps/$appId/$lobType/contentVersions";
$contentVersion = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/deviceAppManagement/$contentVersionUri" -Method POST -Headers $headers -Body "{}" -ContentType "application/json"
$contentVersion | ConvertTo-Json -Depth 10
Write-Host "== Step 3: Encrypt IPA ==" -ForegroundColor Yellow
Write-Host
Write-Host "Encrypting '$sourceFile'..." -ForegroundColor Yellow
$encryptionInfo = EncryptFile $sourceFile $tempFile;
$Size = (Get-Item "$sourceFile").Length
$EncrySize = (Get-Item "$tempFile").Length
Write-Host "== Step 4: Register file ==" -ForegroundColor Yellow
Write-Host
Write-Host "Creating Intune file entry..." -ForegroundColor Yellow
$contentVersionId = $contentVersion.id;
$fileBody = @{
"@odata.type" = "#microsoft.graph.mobileAppContentFile"
name = $filename
size = $Size
sizeEncrypted = $EncrySize;
}
if ($lobType -eq "microsoft.graph.iosLOBApp") {
Write-Host "Building manifest payload for iOS..." -ForegroundColor Yellow
[string]$manifestXML = '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>items</key><array><dict><key>assets</key><array><dict><key>kind</key><string>software-package</string><key>url</key><string>{UrlPlaceHolder}</string></dict></array><key>metadata</key><dict><key>AppRestrictionPolicyTemplate</key> <string>http://management.microsoft.com/PolicyTemplates/AppRestrictions/iOS/v1</string><key>AppRestrictionTechnology</key><string>Windows Intune Application Restrictions Technology for iOS</string><key>IntuneMAMVersion</key><string></string><key>CFBundleSupportedPlatforms</key><array><string>iPhoneOS</string></array><key>MinimumOSVersion</key><string>9.0</string><key>bundle-identifier</key><string>bundleid</string><key>bundle-version</key><string>bundleversion</string><key>kind</key><string>software</string><key>subtitle</key><string>LaunchMeSubtitle</string><key>title</key><string>bundletitle</string></dict></dict></array></dict></plist>'
$manifestXML = $manifestXML.replace("bundleid","$bundleId")
$manifestXML = $manifestXML.replace("bundleversion","$version")
$manifestXML = $manifestXML.replace("bundletitle","$appDisplayName")
$Bytes = [System.Text.Encoding]::ASCII.GetBytes($manifestXML)
$EncodedText =[Convert]::ToBase64String($Bytes)
$fileBody.manifest = $EncodedText;
}
$filesUri = "mobileApps/$appId/$lobType/contentVersions/$contentVersionId/files";
$file = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/deviceAppManagement/$filesUri" -Method POST -Headers $headers -Body ($fileBody | ConvertTo-Json -Depth 10) -ContentType "application/json"
$file | convertTo-Json -Depth 10
Write-Host "== Step 5: Wait for SAS ==" -ForegroundColor Yellow
Write-Host
Write-Host "Waiting for upload URL..." -ForegroundColor Yellow
$fileId = $file.id;
$fileUri = "mobileApps/$appId/$lobType/contentVersions/$contentVersionId/files/$fileId"
$file = WaitForFileProcessing $fileUri "AzureStorageUriRequest"
$file | convertTo-Json -Depth 10
Write-Host "== Step 6: Upload blob ==" -ForegroundColor Yellow
Write-Host
Write-Host "Uploading encrypted IPA..." -f Yellow
$sasUri = $file.azureStorageUri;
UploadFileToAzureStorage $sasUri $tempFile
Write-Host "== Step 7: Commit file ==" -ForegroundColor Yellow
Write-Host
Write-Host "Finalising file in Intune..." -ForegroundColor Yellow
$commitFileUri = "mobileApps/$appId/$lobType/contentVersions/$contentVersionId/files/$fileId/commit"
Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/deviceAppManagement/$commitFileUri" -Method POST -Headers $headers -Body ($encryptionInfo | ConvertTo-Json -Depth 10) -ContentType "application/json"
Write-Host "== Step 8: Wait commit ==" -ForegroundColor Yellow
Write-Host
Write-Host "Waiting for commit completion..." -ForegroundColor Yellow
$file = WaitForFileProcessing $fileUri "CommitFile"
$file | convertTo-Json -Depth 10
Write-Host "== Step 9: Update app ==" -ForegroundColor Yellow
Write-Host
Write-Host "Saving version/build to Intune..." -ForegroundColor Yellow
$commitAppUri = "mobileApps/$appId";
$commitAppBody = @{
"@odata.type" = "#$lobType"
"displayName" = $appDisplayName
"publisher" = $publisher
"versionNumber" = $version
"buildNumber" = $buildNumber
"committedContentVersion" = $contentVersionId
}
Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/deviceAppManagement/$commitAppUri" -Method PATCH -Headers $headers -Body ($commitAppBody | ConvertTo-Json -Depth 10) -ContentType "application/json"
$commitApp = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/deviceAppManagement/mobileApps/$appId" -Method GET -Headers $headers -ContentType "application/json"
$commitApp | ConvertTo-Json -Depth 10
Write-Host "Removing Temp file '$tempFile'..." -f Gray
Remove-Item -Path "$tempFile" -Force
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment