Automating with PowerShell: Automatically uploading applications to intune tenants

So I’ve been doubting if I should make this blog. I found that others had already done this and maybe my method would just be redundant. After some slight convincing I figured my method does have its merits. One of them being that it uses the secure application model, and thus its easy to apply to all partner tenants for a CSP, the other benefit is that this could run headless as a completely automated solution.

Most MSPs create a baseline of applications for their clients that’s the same across the entire stack, 7zip, Chrome, things that we believe should be installed by default. This script allows you to apply that baseline across all your tenants.

So to get start we’ll have to do a couple of things, just to make sure you have everything:

  • Setup the secure app model, and collect the information you’ll need
  • Grab all the installers you want to use and put them all in a folder, I use C:\intune\Applications, so 7zip would be C:\intune\aplications\7-zip
  • for each application you’ll need a new app.json. To create the app.json, you can use the example below.
  • You’ll need Azcopy, and IntuneWinAppUtil. The script also download it for you, but please host the files yourselves. 🙂

Filling out the JSON actually is not that hard; for most applications you’ll only need to replace the Displayname, InstallCommandLine, UninstallCommandLine, and detection rules. If you need help on all the options I’d suggest the Graph API manual.

If you are using a path, or illegal character you can escape these by adding “\” infront of it.

Example JSON

{

  "displayName": "CyberDrain.com 7Zip",
  "installCommandLine": "ninite.exe /Select \"7-zip\" /silent /disableshortcuts",
  "uninstallCommandLine": "ninite.exe /Select \"7-zip\" /silent /uninstall",
  "description": "Ninite Pro to Install 7zip.",
  "developer": "CyberDrain.com",
  "owner": "Cyberdrain.com",
  "informationUrl": "https://cyberdrain.com",
  "privacyInformationUrl": "https://cyberdrain.com",
  "fileName": "IntunePackage.intunewin",
  "@odata.type": "#microsoft.graph.win32LobApp",
  "applicableArchitectures": "x86, x64",

  "installExperience": {
    "runAsAccount": "user",
    "deviceRestartBehavior": "allow",
    "@odata.type": "microsoft.graph.win32LobAppInstallExperience"
  },
  "detectionRules": [
    {
  "@odata.type": "#microsoft.graph.win32LobAppFileSystemDetection",
  "path": "%programfiles%\\7-zip",
  "fileOrFolderName": "7z.exe",
  "check32BitOn64System": false,
  "detectionType": "exists" }
  ],
      "returncode":  [
                       {
                           "returnCode":  0,
                           "type":  "success",
                           "@odata.type":  "#microsoft.graph.win32LobAppReturnCode"
                       },
                       {
                           "returnCode":  1707,
                           "type":  "Success",
                           "@odata.type":  "#microsoft.graph.win32LobAppReturnCode"
                       },
                       {
                           "returnCode":  1641,
                           "type":  "hardReboot",
                           "@odata.type":  "#microsoft.graph.win32LobAppReturnCode"
                       },
                       {
                           "returnCode":  1618,
                           "type":  "retry",
                           "@odata.type":  "#microsoft.graph.win32LobAppReturnCode"
                       },
                       {
                           "returnCode":  3010,
                           "type":  "softReboot",
                           "@odata.type":  "#microsoft.graph.win32LobAppReturnCode"
                       }
					   ],
  "minimumNumberOfProcessors": "1",
  "minimumFreeDiskSpaceInMB": "8",
  "minimumCpuSpeedInMHz": "4",
  "minimumSupportedOperatingSystem": {
    "@odata.type": "microsoft.graph.windowsMinimumOperatingSystem",
    "v10_1607": true
  },
  "notes": "Loaded via cyberdrain.com application script",
  "minimumMemoryInMB": "1"

  
}

The script: Deploy Intune Applications.

So this script took some figuring out, I’ve been using the examples found here, and Ben Reader’s version right here. There’s some tricks we apply but the one you should be aware of is the padding – We pad the file with a 10mb file to make sure that we can upload using Azcopy. The script is completely headless, so just run it and it will upload all the apps.

The options are straight forward – just fill in all the information and run the script. You can even re-upload apps by changing $ContinueonExistingApp to true. The script currently runs for just the tenant you specify. That way, you can schedulde multiple scripts with different options for different clients. If you’d like a version for all tenants at the same time, let me know!

########################## Secure App Model Settings ############################
$ApplicationId = 'YourAppID'
$ApplicationSecret = 'YourAppSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourCSPTenantID'
$RefreshToken = 'yourverylongrefeshtoken'
$upn = 'UPN-Used-To-Generate-Tokens'
$CustomerTenantID = "YourCustomerTenant.onmicrosoft.com"
########################## Script Settings  ############################
$ApplicationFolder = "C:\intune\Applications"
$Baseuri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps"
$AzCopyUri = "https://cyberdrain.com/wp-content/uploads/2020/04/azcopy.exe"
$IntuneWinAppUri = "https://cyberdrain.com/wp-content/uploads/2020/04/IntuneWinAppUtil.exe"
$ContinueOnExistingApp = $false
###################################################################
write-host "Checking AZCopy prerequisites and downloading these if required" -ForegroundColor Green
try {
    $AzCopyDownloadLocation = Test-Path "$ApplicationFolder\AzCopy.exe"
    if (!$AzCopyDownloadLocation) { 
        Invoke-WebRequest -UseBasicParsing -Uri $AzCopyUri -OutFile "$($ApplicationFolder)\AzCopy.exe" 
    }
}
catch {
    write-host "The download and extraction of AzCopy failed. The script will stop. Error: $($_.Exception.Message)"
    exit 1
}
write-host "Checking IntuneWinAppUtil prerequisites and downloading these if required" -ForegroundColor Green

try {
    $AzCopyDownloadLocation = Test-Path "$ApplicationFolder\IntuneWinAppUtil.exe"
    if (!$AzCopyDownloadLocation) { Invoke-WebRequest -UseBasicParsing -Uri $IntuneWinAppUri -OutFile "$($ApplicationFolder)\IntuneWinAppUtil.exe" }
}
catch {
    write-host "The download and extraction of IntuneWinApp failed. The script will stop. Error: $($_.Exception.Message)"
    exit 1
}

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
write-host "Generating token to log into Intune" -ForegroundColor Green
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $CustomerTenantID
$Header = @{
    Authorization = "Bearer $($graphToken.AccessToken)"
}
$AppFolders = Get-ChildItem $ApplicationFolder -Directory 
foreach ($App in $AppFolders) {
    $intuneBody = get-content "$($app.fullname)\app.json"
    $Settings = $intuneBody | ConvertFrom-Json 
    write-host "Creating if intune package for $($app.name) does not exists." -ForegroundColor Green
    $ApplicationList = (Invoke-RestMethod -Uri $baseuri -Headers $Header -Method get -ContentType "application/json").value | where-object { $_.DisplayName -eq $settings.displayName }
    if ($ApplicationList.count -gt 1 -and $ContinueOnExistingApp -eq $false) { 
        write-host "$($app.name) exists. Skipping this application." -ForegroundColor yellow
        continue
    }
    write-host "Creating intune package for $($App.Name)" -ForegroundColor Green
    $bytes = 10MB
    [System.Security.Cryptography.RNGCryptoServiceProvider] $rng = New-Object System.Security.Cryptography.RNGCryptoServiceProvider
    $rndbytes = New-Object byte[] $bytes
    $rng.GetBytes($rndbytes)
    [System.IO.File]::WriteAllBytes("$($App.fullname)\dummy.dat", $rndbytes)
    $FileToExecute = $Settings.installCommandLine.split(" ")[0]
    start-process "$applicationfolder\IntuneWinAppUtil.exe" -argumentlist "-c $($App.FullName) -s $FileToExecute -o $($App.FullName)" -wait
    write-host "Creating Application on intune platform for $($App.Name)" -ForegroundColor Green
    $InTuneProfileURI = "$($BaseURI)"
    $NewApp = Invoke-RestMethod -Uri $InTuneProfileURI -Headers $Header -body $intuneBody -Method POST -ContentType "application/json"
    write-host "Getting encryption information for intune file for $($App.Name)" -ForegroundColor Green

    $intuneWin = get-childitem $App.fullname -Filter *.intunewin
    #unzip the detection.xml file to get manifest info and encryptioninfo.
    $Directory = [System.IO.Path]::GetDirectoryName("$($intuneWin.fullname)")
    Add-Type -Assembly System.IO.Compression.FileSystem
    $zip = [IO.Compression.ZipFile]::OpenRead("$($intuneWin.fullname)")
    $zip.Entries | Where-Object { $_.Name -like "Detection.xml" } | ForEach-Object {
        [System.IO.Compression.ZipFileExtensions]::ExtractToFile($_, "$Directory\Detection.xml", $true)
    }
    $zip.Dispose()
    [xml ]$intunexml = get-content "$Directory\Detection.xml"
    remove-item  "$Directory\Detection.xml" -Force
    #Unzip the encrypted file to prepare for upload.
    $Directory = [System.IO.Path]::GetDirectoryName("$($intuneWin.fullname)")
    Add-Type -Assembly System.IO.Compression.FileSystem
    $zip = [IO.Compression.ZipFile]::OpenRead("$($intuneWin.fullname)")
    $zip.Entries | Where-Object { $_.Name -like "IntunePackage.intunewin" } | ForEach-Object {
        [System.IO.Compression.ZipFileExtensions]::ExtractToFile($_, "$Directory\IntunePackage.intunewin", $true)
    }
    $zip.Dispose()
    $ExtactedEncFile = (Get-Item "$Directory\IntunePackage.intunewin")
    $intunewinFileSize = (Get-Item "$Directory\IntunePackage.intunewin").Length
  
    $ContentBody = ConvertTo-Json @{
        name          = $intunexml.ApplicationInfo.FileName
        size          = [int64]$intunexml.ApplicationInfo.UnencryptedContentSize
        sizeEncrypted = [int64]$intunewinFileSize
    } 
    write-host "Uploading content information for $($App.Name)." -ForegroundColor Green

    $ContentURI = "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/"
    $ContentReq = Invoke-RestMethod -Uri $ContentURI -Headers $Header -body $ContentBody -Method POST -ContentType "application/json"
    write-host "Trying to get file uri for $($App.Name)." -ForegroundColor Green
    do {
        write-host "Still trying to get file uri for $($App.Name) Please wait." -ForegroundColor Green
        $AzFileUriCheck = "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)"
        $AzFileUri = Invoke-RestMethod -Uri $AzFileUriCheck -Headers $Header -Method get -ContentType "application/json"
        if ($AZfileuri.uploadState -like "*fail*") { break }
        start-sleep 5
    } while ($AzFileUri.AzureStorageUri -eq $null) 
    write-host "Retrieved upload URL. Uploading package $($App.Name) via AzCopy." -ForegroundColor Green

    $UploadResults = & "$($ApplicationFolder)\azCopy.exe" cp "$($ExtactedEncFile.fullname)" "$($Azfileuri.AzureStorageUri)"  --block-size-mb 4 --output-type 'json'    
    remove-item @($intunewin.fullname, $ExtactedEncFile) -Force
    start-sleep 2

    write-host "File uploaded. Commiting $($App.Name) with Encryption Info" -ForegroundColor Green

    $EncBody = @{
        fileEncryptionInfo = @{
            encryptionKey        = $intunexml.ApplicationInfo.EncryptionInfo.EncryptionKey
            macKey               = $intunexml.ApplicationInfo.EncryptionInfo.MacKey
            initializationVector = $intunexml.ApplicationInfo.EncryptionInfo.InitializationVector
            mac                  = $intunexml.ApplicationInfo.EncryptionInfo.Mac
            profileIdentifier    = $intunexml.ApplicationInfo.EncryptionInfo.ProfileIdentifier
            fileDigest           = $intunexml.ApplicationInfo.EncryptionInfo.FileDigest
            fileDigestAlgorithm  = $intunexml.ApplicationInfo.EncryptionInfo.FileDigestAlgorithm
        }
    } | ConvertTo-Json
    $CommitURI = "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)/commit"
    $CommitReq = Invoke-RestMethod -Uri $CommitURI -Headers $Header -body $EncBody -Method POST -ContentType "application/json"

    write-host "Waiting for file commit results for $($App.Name)." -ForegroundColor Green

    do {
        write-host "Still trying to get commit state. Please wait." -ForegroundColor Green

        $CommitStateURL = "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)"
        $CommitStateReq = Invoke-RestMethod -Uri $CommitStateURL -Headers $Header -Method get -ContentType "application/json"
        if ($CommitStateReq.uploadState -like "*fail*") { write-host "Commit Failed for $($App.Name). Moving on to Next application. Manual intervention will be required" -ForegroundColor red; break }
        start-sleep 10
    } while ($CommitStateReq.uploadState -eq "commitFilePending") 
    if ($CommitStateReq.uploadState -like "*fail*") { continue }
    write-host "Commiting application version" -ForegroundColor Green
    $ConfirmBody = @{
        "@odata.type"             = "#microsoft.graph.win32lobapp"
        "committedContentVersion" = "1"
    } | Convertto-Json
    $CommitFinalizeURI = "$($BaseURI)/$($NewApp.id)"
    $CommitFinalizeReq = Invoke-RestMethod -Uri $CommitFinalizeURI -Headers $Header -body $Confirmbody -Method PATCH -ContentType "application/json"
    write-host "Deployment completed for app $($app.name). You can assign this app to users now." -ForegroundColor Green
}

So if you combine this with my earlier autopilot automation blog, you could easily setup the entire autopilot experience, with very little effort. And that’s it! as always, Happy PowerShelling.

18 Comments

  1. markd April 19, 2020 at 9:57 pm

    Hi Kevin, came here from reddit. I tried running the script and it was succesful. I am now making all ninite pro apps available. i see in the example you also did ninite.

    do you know if intinewins can also execute updates like ninite?

    1. Kelvin Tegelaar April 19, 2020 at 10:06 pm

      Hi Mark,

      Its Kelvin, not Kevin. Quite particular about that. 🙂

      I’ve created “update” packages next to the installation packages, for when I need to force an update. 🙂

  2. Daniel September 27, 2020 at 7:26 pm

    Hi,

    i have an issue with your script in these lines:

    $ContentBody = ConvertTo-Json @{
    name = $intunexml.ApplicationInfo.FileName
    size = [int64]$intunexml.ApplicationInfo.UnencryptedContentSize
    sizeEncrypted = [int64]$intunewinFileSize
    }

    for me, the result is always like this:

    {
    “name”: null,
    “size”: 0,
    “sizeEncrypted”: 10607408
    }

    and I can’t get why….

    1. Kelvin Tegelaar September 28, 2020 at 9:18 am

      That’s weird! Can you check the actual file size? It seems like it can’t grab that. Did the script error out anywhere else?

      1. Daniel September 29, 2020 at 3:41 pm

        Ok I had some process.

        Now I am stuck with the last push – the contentcomittedVersion – it’s always failing.
        Everything else is working.

        I create the App, upload the file and then, the last step is failing without any error.

        Error looks like this and I think the message is not helping at all :-/
        Sometimes it is happening that the created app (visible in Apps in Endpoint Manager) is disappearing after I upload the ‘contentBody’ – another mystery.

        Invoke-MSGraphRequest : 400 Bad Request
        {
        “error”: {
        “code”: “BadRequest”,
        “message”: “{\r\n \”_version\”: 3,\r\n \”Message\”: \”An error has occurred – Operation ID (for customer support): 00000000-0000-0000-0000-000000000000 – Activity
        ID: 4cca915d-97b9-49e1-a98a-e586e730a070 – Url: https://fef.msub06.manage.microsoft.com/AppLifecycle_2009/StatelessAppMetadataFEService/deviceAppManagement/mobileApps%28
        %27c5792e8e-1025-440a-b301-26c1c968a540%27%29?api-version=5020-09-02\”,\r\n \”CustomApiErrorPhrase\”: \”\”,\r\n \”RetryAfter\”: null,\r\n \”ErrorSourceService\”:
        \”\”,\r\n \”HttpHeaders\”: \”{}\”\r\n}”,
        “innerError”: {
        “date”: “2020-09-29T11:29:13”,
        “request-id”: “4cca915d-97b9-49e1-a98a-e586e730a070”,
        “client-request-id”: “4cca915d-97b9-49e1-a98a-e586e730a070”
        }
        }
        }
        In Zeile:1 Zeichen:26
        + … nalizeReq = Invoke-MSGraphRequest -Url $CommitFinalizeURI -content $C …
        + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        + CategoryInfo : Verbindungsfehler: (@{Request=; Response=}:PSObject) [Invoke-MSGraphRequest], HttpRequestException
        + FullyQualifiedErrorId : PowerShellGraphSDK_HttpRequestError,Microsoft.Intune.PowerShellGraphSDK.PowerShellCmdlets.InvokeRequest

        1. Frank Schuurman November 16, 2020 at 1:55 pm

          daniel, did you have any progress on this? I do not have this error, but a co-workers does.

          1. Kelvin Tegelaar November 16, 2020 at 2:03 pm

            Seeing the ErrorSource is HttpHeaders it could be that the token expired because the script was running for too long, while a refreshtoken is valid for 90 days, an accesstoken is only valid for 1 hour.

            It could also be that the permissions for the Secure App Model aren’t all in place, consented, or that a valid token could be grabbed from it.

  3. Matt September 29, 2020 at 11:43 am

    Hey Kelvin
    Great work!
    I have an issue with line 107 in your script. Getting this error when I’m trying to run it:

    At C:\IntuneApps\WinApps\7zip\Install7zip.ps1:107 char:24
    + $UploadResults = & "$($ApplicationFolder)\azCopy.exe” cp “$( …
    + ~
    The ampersand (&) character is not allowed. The & operator is reserved for future use; wrap an ampersand in double quotation marks (“&”) to pass it as part of a string.

    Any ideas?

    1. Kelvin Tegelaar September 29, 2020 at 12:59 pm

      My code plugin tends to fudge up code from time to time. It seems that’s happening here too. I’ll see if I can fix it up real quick. 🙂

  4. Frank Schuurman November 15, 2020 at 3:07 pm

    I need to change this. The whole quote needs to be removed. $UploadResults = & “$($ApplicationFolder)\azCopy.exe” and further in line 107.

    1. Kelvin Tegelaar November 15, 2020 at 9:23 pm

      Old comment removed. 🙂 It’s by the way best to pick up these scripts from Github. My wordpress plugin tends to do HTML encoding on some items, which is screwy. I’m working on replacing that soon.

  5. Frank Schuurman November 15, 2020 at 3:10 pm

    Then alter several hours finetuning and testing line 75 should be replaced with [xml]$intunexml = get-content “$Directory\Detection.xml”. I allows a better reading of the XML file so the Content is properly filed. I found several detection.xml file maybe different from the XML kelvin came across. This should fix that. Thanks for all the work Kelvin.

    1. Kelvin Tegelaar November 16, 2020 at 2:04 pm

      Yeah, that’s in the original script but unfortunately stripped out by my blogging engine. 🙂

  6. Joar Paulsen November 25, 2020 at 2:07 pm

    Is it possible to get the version of this with multiple tenants? I’m working on a process to update apps to the newest version among all our tenants, but struggling a bit to just replace the intunewim-file instead of creating a new app to replace the previous version

  7. Sidharth April 4, 2021 at 5:41 am

    Hi Kelvin,

    I ant to add dependencies & supercedence as well into the Intune application when i upload them. Is it possible to do it via script ?

  8. Peter July 5, 2021 at 8:39 am

    Hi Kelvin,

    thank you very much for your script. It was really useful for us.
    However, I noticed when the apps are uploaded via the script that the apps are not installed on the devices.
    It only works if I manually upload the apps.

    Do you have any idea why it doesn’t work?

    1. Kelvin Tegelaar July 5, 2021 at 2:53 pm

      Have you made sure to apply them to a group? by default we do not do that.

  9. Pingback: Automating with PowerShell: uploading your RMM application to all Intune tenants - CyberDrain

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.