Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhance Intune Win32 App Lifecycle Automation with Improved programmatic Auth and upload Retry Logic #162

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
31dcb6e
feat: remove MSAL.PS requirement for automation
tjgruber May 29, 2024
ed69fbe
feat: dynamicly load MSAL.PS module if required
tjgruber May 29, 2024
83b9ed3
lint: linting fixes
tjgruber May 29, 2024
daad2a8
Merge pull request #1 from tjgruber/feat-remove-MSAL.PS-requirement
tjgruber May 29, 2024
77494c6
fix: add missing content-type header
tjgruber May 29, 2024
59f3f55
Merge pull request #2 from tjgruber/fix-missing-content-type-header
tjgruber May 29, 2024
84bc50a
fix: system.datetime error in Azure blob upload
tjgruber Jun 1, 2024
7a4a51d
Merge pull request #5 from tjgruber/4-error-invalidargument-cannot-co…
tjgruber Jun 1, 2024
7387db7
Fix: Add retry logic for Azure Blob uploads
tjgruber Jun 3, 2024
77120ea
Fix: Add SAS Uri renewal on first retry with fall-
tjgruber Jun 4, 2024
31992d2
fix: removed renewal from retry logic
tjgruber Jun 4, 2024
73130ef
fix: add chunk upload retry warning
tjgruber Jun 4, 2024
d59204e
Merge pull request #7 from tjgruber/6-sporadic-upload-chunk-failure-t…
tjgruber Jun 4, 2024
d4d1e7d
feat: Add retry logic to Win32 app creation
tjgruber Jun 18, 2024
6f2bed7
feat: Add retry logic to content version creation
tjgruber Jun 18, 2024
bb5cbd0
feat: Add retry logic to file content creation
tjgruber Jun 18, 2024
796718e
lint: linting fixes
tjgruber Jun 18, 2024
002878c
Merge pull request #9 from tjgruber/8-sporadic-errors-during-win32-ap…
tjgruber Jun 18, 2024
9e0a395
fix: token expiration calculation to be locale and
tjgruber Nov 16, 2024
9410978
Merge pull request #11 from tjgruber/10-datetime-isnt-locale-safe
tjgruber Nov 16, 2024
bffbb61
fix: token expiration calculation to be locale and
tjgruber Nov 16, 2024
0580d33
update header info and version of modified files
tjgruber Nov 16, 2024
75901a5
Merge pull request #12 from tjgruber/10b-datetime-isnt-locale-safe-fi…
tjgruber Nov 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions IntuneWin32App.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ PowerShellVersion = '5.0'
# ProcessorArchitecture = ''

# Modules that must be imported into the global environment prior to importing this module
RequiredModules = @("MSAL.PS")
# RequiredModules = @("MSAL.PS")

# Assemblies that must be loaded prior to importing this module
# RequiredAssemblies = @()
Expand Down Expand Up @@ -154,4 +154,3 @@ PrivateData = @{
# DefaultCommandPrefix = ''

}

86 changes: 70 additions & 16 deletions Private/Invoke-AzureStorageBlobUpload.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,22 @@ function Invoke-AzureStorageBlobUpload {
Upload and commit .intunewin file into Azure Storage blob container.

This is a modified function that was originally developed by Dave Falkus and is available here:
https://github.com/microsoftgraph/powershell-intune-samples/blob/master/LOB_Application/Win32_Application_Add.ps1
https://github.com/microsoftgraph/powershell-intune-samples/blob/master/LOB_Application/Win32_Application_Add.ps1

.NOTES
Author: Nickolaj Andersen
Contact: @NickolajA
Created: 2020-01-04
Updated: 2023-09-04
Updated: 2024-11-15

Version history:
1.0.0 - (2020-01-04) Function created
1.0.1 - (2020-09-20) Fixed an issue where the System.IO.BinaryReader wouldn't open a file path containing whitespaces
1.0.2 - (2021-03-15) Fixed an issue where SAS Uri renewal wasn't working correctly
1.0.3 - (2022-09-03) Added access token refresh functionality when a token is about to expire, to prevent uploads from failing due to an expire access token
1.0.4 - (2023-09-04) Updated with Test-AccessToken function
#>
1.0.3 - (2022-09-03) Added access token refresh functionality when a token is about to expire, to prevent uploads from failing due to an expired access token
1.0.5 - (2024-06-04) Added retry logic for chunk uploads and finalization steps to enhance reliability (thanks to @tjgruber)
1.0.6 - (2024-11-15) Refactor date handling for token to fix locale-specific parsing issues (thanks to @tjgruber)
#>
param(
[parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
Expand All @@ -36,6 +37,8 @@ function Invoke-AzureStorageBlobUpload {
[string]$Resource
)
$ChunkSizeInBytes = 1024l * 1024l * 6l;
$RetryCount = 5
$RetryDelay = 10

# Start the timer for SAS URI renewal
$SASRenewalTimer = [System.Diagnostics.Stopwatch]::StartNew()
Expand All @@ -46,16 +49,26 @@ function Invoke-AzureStorageBlobUpload {
$BinaryReader = New-Object -TypeName System.IO.BinaryReader([System.IO.File]::Open($FilePath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite))
$Position = $BinaryReader.BaseStream.Seek(0, [System.IO.SeekOrigin]::Begin)

# Upload each chunk and dheck whether a SAS URI renewal is required after each chunk is uploaded and renew if needed
# Upload each chunk and check whether a SAS URI renewal is required after each chunk is uploaded and renew if needed
$ChunkIDs = @()
for ($Chunk = 0; $Chunk -lt $ChunkCount; $Chunk++) {
Write-Verbose -Message "SAS Uri renewal timer has elapsed for: $($SASRenewalTimer.Elapsed.Minutes) minute $($SASRenewalTimer.Elapsed.Seconds) seconds"

# Refresh access token if about to expire
$UTCDateTime = (Get-Date).ToUniversalTime()
# Convert ExpiresOn to DateTimeOffset in UTC
$ExpiresOnUTC = [DateTimeOffset]::Parse(
$Global:AccessToken.ExpiresOn.ToString(),
[System.Globalization.CultureInfo]::InvariantCulture,
[System.Globalization.DateTimeStyles]::AssumeUniversal
).ToUniversalTime()

# Determine the token expiration count as minutes
$TokenExpireMinutes = [System.Math]::Round(([datetime]$Global:AccessToken.ExpiresOn.ToUniversalTime().UtcDateTime - $UTCDateTime).TotalMinutes)
# Get the current UTC time as DateTimeOffset
$UTCDateTime = [DateTimeOffset]::UtcNow

# Calculate the TimeSpan between expiration and current time
$TimeSpan = $ExpiresOnUTC - $UTCDateTime

# Calculate the token expiration time in minutes
$TokenExpireMinutes = [System.Math]::Round($TimeSpan.TotalMinutes)

# Determine if refresh of access token is required when expiration count is less than or equal to minimum age
if ($TokenExpireMinutes -le 10) {
Expand All @@ -75,12 +88,38 @@ function Invoke-AzureStorageBlobUpload {

Write-Progress -Activity "Uploading file to Azure Storage blob" -Status "Uploading chunk $($CurrentChunk) of $($ChunkCount)" -PercentComplete ($CurrentChunk / $ChunkCount * 100)
Write-Verbose -Message "Uploading file to Azure Storage blob, processing chunk '$($CurrentChunk)' of '$($ChunkCount)'"
$UploadResponse = Invoke-AzureStorageBlobUploadChunk -StorageUri $StorageUri -ChunkID $ChunkID -Bytes $Bytes


$UploadSuccess = $false
for ($i = 0; $i -lt $RetryCount; $i++) {
try {
$UploadResponse = Invoke-AzureStorageBlobUploadChunk -StorageUri $StorageUri -ChunkID $ChunkID -Bytes $Bytes
$UploadSuccess = $true
break
} catch {
Write-Warning "Failed to upload chunk. Attempt $($i + 1) of $RetryCount. Error: $_"
Start-Sleep -Seconds $RetryDelay
Write-Warning "Retrying upload of chunk '$($CurrentChunk)' of '$($ChunkCount)'"
}
}

if (-not $UploadSuccess) {
Write-Error "Failed to upload chunk after $RetryCount attempts. Aborting upload."
return
}

if (($CurrentChunk -lt $ChunkCount) -and ($SASRenewalTimer.ElapsedMilliseconds -ge 450000)) {
Write-Verbose -Message "SAS Uri renewal is required, attempting to renew"
$RenewedSASUri = Invoke-AzureStorageBlobUploadRenew -Resource $Resource
$SASRenewalTimer.Restart()
try {
$RenewedSASUri = Invoke-AzureStorageBlobUploadRenew -Resource $Resource
if ($null -ne $RenewedSASUri) {
$StorageUri = $RenewedSASUri
$SASRenewalTimer.Restart()
} else {
Write-Warning "SAS Uri renewal failed, continuing with existing Uri"
}
} catch {
Write-Warning "SAS Uri renewal attempt failed with error: $_. Continuing with existing Uri."
}
}
}

Expand All @@ -91,9 +130,24 @@ function Invoke-AzureStorageBlobUpload {
Write-Progress -Completed -Activity "Uploading File to Azure Storage blob"

# Finalize the upload of the content file to Azure Storage blob
Invoke-AzureStorageBlobUploadFinalize -StorageUri $StorageUri -ChunkID $ChunkIDs
$FinalizeSuccess = $false
for ($i = 0; $i -lt $RetryCount; $i++) {
try {
Invoke-AzureStorageBlobUploadFinalize -StorageUri $StorageUri -ChunkID $ChunkIDs
$FinalizeSuccess = $true
break
} catch {
Write-Warning "Failed to finalize Azure Storage blob upload. Attempt $($i + 1) of $RetryCount. Error: $_"
Start-Sleep -Seconds $RetryDelay
}
}

if (-not $FinalizeSuccess) {
Write-Error "Failed to finalize upload after $RetryCount attempts. Aborting upload."
return
}

# Close and dispose binary reader object
$BinaryReader.Close()
$BinaryReader.Dispose()
}
}
28 changes: 15 additions & 13 deletions Private/Invoke-AzureStorageBlobUploadChunk.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ function Invoke-AzureStorageBlobUploadChunk {
1.0.0 - (2020-01-04) Function created
1.0.1 - (2021-04-02) Added UseBasicParsing to support conditions where IE first run experience have not been completed
1.0.2 - (2024-01-10) Fixed issue described in #128 - thanks to @jaspain for finding the solution
#>
1.0.3 - (2024-06-03) Added exception throwing on failure to support retry logic in the upload process (thanks to @tjgruber)
#>
param(
[parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
Expand All @@ -33,18 +34,19 @@ function Invoke-AzureStorageBlobUploadChunk {
[ValidateNotNullOrEmpty()]
[System.Object]$Bytes
)
$Uri = "$($StorageUri)&comp=block&blockid=$($ChunkID)"
$ISOEncoding = [System.Text.Encoding]::GetEncoding("iso-8859-1")
$EncodedBytes = $ISOEncoding.GetString($Bytes)
$Uri = "$($StorageUri)&comp=block&blockid=$($ChunkID)"
$ISOEncoding = [System.Text.Encoding]::GetEncoding("iso-8859-1")
$EncodedBytes = $ISOEncoding.GetString($Bytes)
$Headers = @{
"content-type" = "text/plain; charset=iso-8859-1"
"x-ms-blob-type" = "BlockBlob"
}
"content-type" = "text/plain; charset=iso-8859-1"
"x-ms-blob-type" = "BlockBlob"
}

try {
$WebResponse = Invoke-WebRequest $Uri -Method "Put" -Headers $Headers -Body $EncodedBytes -UseBasicParsing -ErrorAction Stop
}
catch {
try {
$WebResponse = Invoke-WebRequest $Uri -Method "Put" -Headers $Headers -Body $EncodedBytes -UseBasicParsing -ErrorAction Stop
return $WebResponse
} catch {
Write-Warning -Message "Failed to upload chunk to Azure Storage blob. Error message: $($_.Exception.Message)"
}
}
throw $_
}
}
43 changes: 27 additions & 16 deletions Private/Invoke-AzureStorageBlobUploadFinalize.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,19 @@ function Invoke-AzureStorageBlobUploadFinalize {
Finalize upload of chunks of the .intunewin file into Azure Storage blob container.

This is a modified function that was originally developed by Dave Falkus and is available here:
https://github.com/microsoftgraph/powershell-intune-samples/blob/master/LOB_Application/Win32_Application_Add.ps1
https://github.com/microsoftgraph/powershell-intune-samples/blob/master/LOB_Application/Win32_Application_Add.ps1

.NOTES
Author: Nickolaj Andersen
Contact: @NickolajA
Created: 2020-01-04
Updated: 2020-01-04
Updated: 2024-05-29

Version history:
1.0.0 - (2020-01-04) Function created
#>
1.0.1 - (2024-05-29) Added content-type header to the REST request to ensure correct handling of the request body (thanks to @tjgruber)
1.0.2 - (2024-06-03) Added exception throwing on failure to support retry logic in the finalization process (thanks to @tjgruber)
#>
param(
[parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
Expand All @@ -27,17 +29,26 @@ function Invoke-AzureStorageBlobUploadFinalize {
[ValidateNotNullOrEmpty()]
[System.Object]$ChunkID
)

$Uri = "$($StorageUri)&comp=blocklist"
$XML = '<?xml version="1.0" encoding="utf-8"?><BlockList>'
foreach ($Chunk in $ChunkID) {
$XML += "<Latest>$($Chunk)</Latest>"
}
$XML += '</BlockList>'

try {
$WebResponse = Invoke-RestMethod -Uri $Uri -Method "Put" -Body $XML -ErrorAction Stop
}
catch {
Write-Warning -Message "Failed to finalize Azure Storage blob upload. Error message: $($_.Exception.Message)"
}
}

$XML = '<?xml version="1.0" encoding="utf-8"?><BlockList>'

foreach ($Chunk in $ChunkID) {
$XML += "<Latest>$($Chunk)</Latest>"
}

$XML += '</BlockList>'

$Headers = @{
"content-type" = "text/plain; charset=UTF-8"
}

try {
$WebResponse = Invoke-RestMethod -Uri $Uri -Method "Put" -Body $XML -Headers $Headers -ErrorAction Stop
return $WebResponse
} catch {
Write-Warning -Message "Failed to finalize Azure Storage blob upload. Error message: $($_.Exception.Message)"
throw $_
}
}
25 changes: 21 additions & 4 deletions Private/Invoke-AzureStorageBlobUploadRenew.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,34 @@ function Invoke-AzureStorageBlobUploadRenew {
Author: Nickolaj Andersen
Contact: @NickolajA
Created: 2020-01-04
Updated: 2021-03-15
Updated: 2024-06-03

Version history:
1.0.0 - (2020-01-04) Function created
1.0.1 - (2021-03-15) Fixed an issue where SAS Uri renewal wasn't working correctly
#>
1.0.2 - (2024-06-03) Added loop to check the status of the SAS URI renewal
#>
param(
[parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$Resource
)
$RenewSASURIRequest = Invoke-IntuneGraphRequest -APIVersion "Beta" -Resource "$($Resource)/renewUpload" -Method "POST" -Body "{}"
$FilesProcessingRequest = Wait-IntuneWin32AppFileProcessing -Stage "AzureStorageUriRenewal" -Resource $Resource
}

# Loop to wait for the renewal process to complete and check the status
$attempts = 0
$maxAttempts = 3
$waitTime = 5 # seconds

while ($attempts -lt $maxAttempts) {
$FilesProcessingRequest = Invoke-IntuneGraphRequest -APIVersion "Beta" -Resource "$($Resource)" -Method "GET"
if ($FilesProcessingRequest.uploadState -eq "azureStorageUriRenewalSuccess") {
return $FilesProcessingRequest.azureStorageUri
} elseif ($FilesProcessingRequest.uploadState -eq "azureStorageUriRenewalFailed") {
throw "SAS Uri renewal failed"
}
$attempts++
Start-Sleep -Seconds $waitTime
}
throw "SAS Uri renewal did not complete in the expected time"
}
18 changes: 13 additions & 5 deletions Private/New-AuthenticationHeader.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,26 @@ function New-AuthenticationHeader {
Version history:
1.0.0 - (2021-04-08) Script created
1.0.1 - (2021-09-08) Fixed issue reported by Paul DeArment Jr where the local date time set for ExpiresOn should be UTC to not cause any time related issues
1.0.2 - (2024-05-29) Updated to support access tokens from New-ClientCredentialsAccessToken function (thanks to @tjgruber)
#>
param(
[parameter(Mandatory = $true, HelpMessage = "Pass the AuthenticationResult object returned from Get-AccessToken cmdlet.")]
[Parameter(Mandatory = $true, HelpMessage = "Pass the AuthenticationResult object returned from Get-AccessToken cmdlet.")]
[ValidateNotNullOrEmpty()]
[Microsoft.Identity.Client.AuthenticationResult]$AccessToken
$AccessToken
)
Process {
# Construct default header parameters
$AuthenticationHeader = @{
"Content-Type" = "application/json"
"Authorization" = $AccessToken.CreateAuthorizationHeader()
"ExpiresOn" = $AccessToken.ExpiresOn.UTCDateTime
"Content-Type" = "application/json"
"Authorization" = "Bearer $($AccessToken.access_token)"
}

# Check if ExpiresOn property is available
if ($AccessToken.PSObject.Properties["ExpiresOn"]) {
$AuthenticationHeader["ExpiresOn"] = $AccessToken.ExpiresOn.ToUniversalTime()
}
else {
Write-Warning "The access token does not contain an ExpiresOn property. The ExpiresOn field in the authentication header will not be set."
}

# Handle return value
Expand Down
66 changes: 66 additions & 0 deletions Private/New-ClientCredentialsAccessToken.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
function New-ClientCredentialsAccessToken {
<#
.SYNOPSIS
Requests an access token using the client credentials flow.

.DESCRIPTION
Requests an access token using the client credentials flow.

.PARAMETER TenantID
Tenant ID of the Azure AD tenant.

.PARAMETER ClientID
Application ID (Client ID) for an Azure AD service principal.

.PARAMETER ClientSecret
Application secret (Client Secret) for an Azure AD service principal.

.NOTES
Author: Timothy Gruber
Contact: @tjgruber
Created: 2024-05-29
Updated: 2024-05-29

Version history:
1.0.0 - (2024-05-29) Script created
#>
param(
[parameter(Mandatory = $true, HelpMessage = "Tenant ID of the Azure AD tenant.")]
[ValidateNotNullOrEmpty()]
[String]$TenantID,

[parameter(Mandatory = $true, HelpMessage = "Application ID (Client ID) for an Azure AD service principal.")]
[ValidateNotNullOrEmpty()]
[String]$ClientID,

[parameter(Mandatory = $true, HelpMessage = "Application secret (Client Secret) for an Azure AD service principal.")]
[ValidateNotNullOrEmpty()]
[String]$ClientSecret
)
Process {
$graphRequestUri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token"
$graphTokenRequestBody = @{
"client_id" = $ClientID
"scope" = "https://graph.microsoft.com/.default"
"client_secret" = $ClientSecret
"grant_type" = "client_credentials"
}

try {
$GraphAPIAuthResult = Invoke-RestMethod -Method Post -Uri $graphRequestUri -Body $graphTokenRequestBody -ErrorAction Stop

# Validate the result
if (-not $GraphAPIAuthResult.access_token) {
throw "No access token was returned in the response."
}

# Calculate the ExpiresOn property based on the expires_in value
$GraphAPIAuthResult | Add-Member -MemberType NoteProperty -Name "ExpiresOn" -Value ((Get-Date).AddSeconds($GraphAPIAuthResult.expires_in).ToUniversalTime())

return $GraphAPIAuthResult
}
catch {
throw "Error retrieving the access token: $_"
}
}
}
Loading