diff --git a/IntuneWin32App.psd1 b/IntuneWin32App.psd1 index 7f4ef7e..ace63a6 100644 --- a/IntuneWin32App.psd1 +++ b/IntuneWin32App.psd1 @@ -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 = @() @@ -154,4 +154,3 @@ PrivateData = @{ # DefaultCommandPrefix = '' } - diff --git a/Private/Invoke-AzureStorageBlobUpload.ps1 b/Private/Invoke-AzureStorageBlobUpload.ps1 index a0e3c56..87a053e 100644 --- a/Private/Invoke-AzureStorageBlobUpload.ps1 +++ b/Private/Invoke-AzureStorageBlobUpload.ps1 @@ -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()] @@ -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() @@ -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) { @@ -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." + } } } @@ -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() -} \ No newline at end of file +} diff --git a/Private/Invoke-AzureStorageBlobUploadChunk.ps1 b/Private/Invoke-AzureStorageBlobUploadChunk.ps1 index d800c78..a019798 100644 --- a/Private/Invoke-AzureStorageBlobUploadChunk.ps1 +++ b/Private/Invoke-AzureStorageBlobUploadChunk.ps1 @@ -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()] @@ -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)" - } -} \ No newline at end of file + throw $_ + } +} diff --git a/Private/Invoke-AzureStorageBlobUploadFinalize.ps1 b/Private/Invoke-AzureStorageBlobUploadFinalize.ps1 index 13d47ba..8328547 100644 --- a/Private/Invoke-AzureStorageBlobUploadFinalize.ps1 +++ b/Private/Invoke-AzureStorageBlobUploadFinalize.ps1 @@ -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()] @@ -27,17 +29,26 @@ function Invoke-AzureStorageBlobUploadFinalize { [ValidateNotNullOrEmpty()] [System.Object]$ChunkID ) + $Uri = "$($StorageUri)&comp=blocklist" - $XML = '' - foreach ($Chunk in $ChunkID) { - $XML += "$($Chunk)" - } - $XML += '' - - 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)" - } -} \ No newline at end of file + + $XML = '' + + foreach ($Chunk in $ChunkID) { + $XML += "$($Chunk)" + } + + $XML += '' + + $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 $_ + } +} diff --git a/Private/Invoke-AzureStorageBlobUploadRenew.ps1 b/Private/Invoke-AzureStorageBlobUploadRenew.ps1 index 84be79d..fe29625 100644 --- a/Private/Invoke-AzureStorageBlobUploadRenew.ps1 +++ b/Private/Invoke-AzureStorageBlobUploadRenew.ps1 @@ -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 -} \ No newline at end of file + + # 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" +} diff --git a/Private/New-AuthenticationHeader.ps1 b/Private/New-AuthenticationHeader.ps1 index d039024..fa081a1 100644 --- a/Private/New-AuthenticationHeader.ps1 +++ b/Private/New-AuthenticationHeader.ps1 @@ -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 diff --git a/Private/New-ClientCredentialsAccessToken.ps1 b/Private/New-ClientCredentialsAccessToken.ps1 new file mode 100644 index 0000000..c76f072 --- /dev/null +++ b/Private/New-ClientCredentialsAccessToken.ps1 @@ -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: $_" + } + } +} diff --git a/Public/Add-IntuneWin32App.ps1 b/Public/Add-IntuneWin32App.ps1 index 0586111..3f17440 100644 --- a/Public/Add-IntuneWin32App.ps1 +++ b/Public/Add-IntuneWin32App.ps1 @@ -11,16 +11,16 @@ function Add-IntuneWin32App { .PARAMETER DisplayName Specify a display name for the Win32 application. - + .PARAMETER Description Specify a description for the Win32 application. - + .PARAMETER Publisher Specify a publisher name for the Win32 application. .PARAMETER AppVersion Specify the app version for the Win32 application. - + .PARAMETER Developer Specify the developer name for the Win32 application. @@ -32,10 +32,10 @@ function Add-IntuneWin32App { .PARAMETER InformationURL Specify the information URL for the Win32 application. - + .PARAMETER PrivacyURL Specify the privacy URL for the Win32 application. - + .PARAMETER CompanyPortalFeaturedApp Specify whether to have the Win32 application featured in Company Portal or not. @@ -44,13 +44,13 @@ function Add-IntuneWin32App { .PARAMETER InstallCommandLine Specify the install command line for the Win32 application. - + .PARAMETER UninstallCommandLine Specify the uninstall command line for the Win32 application. .PARAMETER InstallExperience Specify the install experience for the Win32 application. Supported values are: system or user. - + .PARAMETER RestartBehavior Specify the restart behavior for the Win32 application. Supported values are: allow, basedOnReturnCode, suppress or force. @@ -59,7 +59,7 @@ function Add-IntuneWin32App { .PARAMETER AllowAvailableUninstall Specify whether to allow the Win32 application to be uninstalled from the Company Portal app when assigned as available. - + .PARAMETER DetectionRule Provide an array of a single or multiple OrderedDictionary objects as detection rules that will be used for the Win32 application. @@ -106,7 +106,7 @@ function Add-IntuneWin32App { 1.0.6 - (2021-08-31) Added AppVersion optional parameter 1.0.7 - (2022-09-02) Removed break command that would prevent the Win32 app body JSON output from being display in case an error occured 1.0.8 - (2022-10-02) Added UseAzCopy parameter switch to override the native transfer method. Specify the UseAzCopy parameter switch when uploading large applications. - Added fallback removal code for the cleanup operation at the end of this function, since OneDrive's Files On Demand feature sometimes blocks the + Added fallback removal code for the cleanup operation at the end of this function, since OneDrive's Files On Demand feature sometimes blocks the expanded .intunewin file cleanup process. 1.0.9 - (2023-01-20) Added parameter AzCopyWindowStyle and ScopeTagName. Updated regex pattern for .intunewin file and parameter FilePath. Added support for specifying Scope Tags when creating the Win 32 app, using the ScopeTagName parameter. Added UnattendedInstall and @@ -332,7 +332,7 @@ function Add-IntuneWin32App { } } } - + # Generate Win32 application body data table with different parameters based upon parameter set name Write-Verbose -Message "Start constructing basic layout of Win32 app body" switch ($PSCmdlet.ParameterSetName) { @@ -373,7 +373,7 @@ function Add-IntuneWin32App { if (-not($PSBoundParameters["Developer"])) { $Developer = [string]::Empty } - + # Generate Win32 application body $AppBodySplat = @{ "MSI" = $true @@ -479,7 +479,7 @@ function Add-IntuneWin32App { if (($DetectionRule.'@odata.type' -contains "#microsoft.graph.win32LobAppPowerShellScriptDetection") -and (@($DetectionRules).'@odata.type'.Count -gt 1)) { Write-Warning -Message "Multiple PowerShell Script detection rules were detected, this is not a supported configuration"; break } - + # Add detection rules to Win32 app body object Write-Verbose -Message "Detection rule objects passed validation checks, attempting to add to existing Win32 app body" $Win32AppBody.Add("detectionRules", $DetectionRule) @@ -505,16 +505,35 @@ function Add-IntuneWin32App { $Win32AppBody.Add("requirementRules", $AdditionalRequirementRule) } - # Create the Win32 app - Write-Verbose -Message "Attempting to create Win32 app using constructed body converted to JSON content" - $Win32MobileAppRequest = Invoke-IntuneGraphRequest -APIVersion "Beta" -Resource "mobileApps" -Method "POST" -Body ($Win32AppBody | ConvertTo-Json) - if ($Win32MobileAppRequest.'@odata.type' -notlike "#microsoft.graph.win32LobApp") { - Write-Warning -Message "Failed to create Win32 app using constructed body. Passing converted body as JSON to output." - Write-Warning -Message ($Win32AppBody | ConvertTo-Json); break + # Define retry parameters + $CreateWin32AppRetryCount = 5 + $CreateWin32AppRetryDelay = 10 + + # Create the Win32 app with retry logic + $AppCreationSuccess = $false + for ($i = 0; $i -lt $CreateWin32AppRetryCount; $i++) { + try { + Write-Verbose -Message "Attempting to create Win32 app using constructed body converted to JSON content" + $Win32MobileAppRequest = Invoke-IntuneGraphRequest -APIVersion "Beta" -Resource "mobileApps" -Method "POST" -Body ($Win32AppBody | ConvertTo-Json) -ErrorAction Stop + if ($Win32MobileAppRequest.'@odata.type' -notlike "#microsoft.graph.win32LobApp") { + Write-Warning -Message "Failed to create Win32 app using constructed body. Passing converted body as JSON to output." + Write-Warning -Message ($Win32AppBody | ConvertTo-Json); break + } else { + Write-Verbose -Message "Successfully created Win32 app with ID: $($Win32MobileAppRequest.id)" + $AppCreationSuccess = $true + break + } + } catch { + Write-Warning "An error occurred while creating the Win32 application. Attempt $($i + 1) of $CreateWin32AppRetryCount. Error: $_" + Start-Sleep -Seconds $CreateWin32AppRetryDelay + } + } + + if (-not $AppCreationSuccess) { + Write-Error "Failed to create Win32 app after $CreateWin32AppRetryCount attempts. Aborting process." + return } else { - Write-Verbose -Message "Successfully created Win32 app with ID: $($Win32MobileAppRequest.id)" - # Invoke request to setup the reference pointers of each category added to the Win32 app if ($PSBoundParameters["CategoryName"]) { if ($CategoryList.Count -ge 1) { @@ -528,15 +547,33 @@ function Add-IntuneWin32App { } } + # Define retry parameters + $CreateContentVRetryCount = 5 + $CreateContentVRetryDelay = 10 + # Create Content Version for the Win32 app Write-Verbose -Message "Attempting to create contentVersions resource for the Win32 app" - $Win32MobileAppContentVersionRequest = Invoke-IntuneGraphRequest -APIVersion "Beta" -Resource "mobileApps/$($Win32MobileAppRequest.id)/microsoft.graph.win32LobApp/contentVersions" -Method "POST" -Body "{}" - if ([string]::IsNullOrEmpty($Win32MobileAppContentVersionRequest.id)) { - Write-Warning -Message "Failed to create contentVersions resource for Win32 app" + $ContentVersionSuccess = $false + for ($i = 0; $i -lt $CreateContentVRetryCount; $i++) { + try { + $Win32MobileAppContentVersionRequest = Invoke-IntuneGraphRequest -APIVersion "Beta" -Resource "mobileApps/$($Win32MobileAppRequest.id)/microsoft.graph.win32LobApp/contentVersions" -Method "POST" -Body "{}" -ErrorAction Stop + if ([string]::IsNullOrEmpty($Win32MobileAppContentVersionRequest.id)) { + Write-Warning -Message "Failed to create contentVersions resource for Win32 app" + } else { + Write-Verbose -Message "Successfully created contentVersions resource with ID: $($Win32MobileAppContentVersionRequest.id)" + $ContentVersionSuccess = $true + break + } + } catch { + Write-Warning "An error occurred while creating content version. Attempt $($i + 1) of $CreateContentVRetryCount. Error: $_" + Start-Sleep -Seconds $CreateContentVRetryDelay + } + } + if (-not $ContentVersionSuccess) { + Write-Error "Failed to create content version after $CreateContentVRetryCount attempts. Aborting process." + return } else { - Write-Verbose -Message "Successfully created contentVersions resource with ID: $($Win32MobileAppContentVersionRequest.id)" - # Extract compressed .intunewin file to subfolder $IntuneWinFilePath = Expand-IntuneWin32AppCompressedFile -FilePath $FilePath -FileName $IntuneWinXMLMetaData.ApplicationInfo.FileName -FolderName "Expand" if ($IntuneWinFilePath -ne $null) { @@ -552,10 +589,29 @@ function Add-IntuneWin32App { "isDependency" = $false } + # Define retry parameters + $CreateContentVResourceRetryCount = 5 + $CreateContentVResourceRetryDelay = 10 + # Create the contentVersions files resource - $Win32MobileAppFileContentRequest = Invoke-IntuneGraphRequest -APIVersion "Beta" -Resource "mobileApps/$($Win32MobileAppRequest.id)/microsoft.graph.win32LobApp/contentVersions/$($Win32MobileAppContentVersionRequest.id)/files" -Method "POST" -Body ($Win32AppFileBody | ConvertTo-Json) - if ([string]::IsNullOrEmpty($Win32MobileAppFileContentRequest.id)) { - Write-Warning -Message "Failed to create Azure Storage blob for contentVersions/files resource for Win32 app" + $FileContentSuccess = $false + for ($i = 0; $i -lt $CreateContentVResourceRetryCount; $i++) { + try { + $Win32MobileAppFileContentRequest = Invoke-IntuneGraphRequest -APIVersion "Beta" -Resource "mobileApps/$($Win32MobileAppRequest.id)/microsoft.graph.win32LobApp/contentVersions/$($Win32MobileAppContentVersionRequest.id)/files" -Method "POST" -Body ($Win32AppFileBody | ConvertTo-Json) -ErrorAction Stop + if ([string]::IsNullOrEmpty($Win32MobileAppFileContentRequest.id)) { + Write-Warning -Message "Failed to create Azure Storage blob for contentVersions/files resource for Win32 app" + } else { + $FileContentSuccess = $true + break + } + } catch { + Write-Warning "An error occurred while creating file content. Attempt $($i + 1) of $CreateContentVResourceRetryCount. Error: $_" + Start-Sleep -Seconds $CreateContentVResourceRetryDelay + } + } + if (-not $FileContentSuccess) { + Write-Error "Failed to create file content after $CreateContentVResourceRetryCount attempts. Aborting process." + return } else { # Wait for the Win32 app file content URI to be created @@ -637,7 +693,7 @@ function Add-IntuneWin32App { $Win32MobileAppRequest = Invoke-IntuneGraphRequest -APIVersion "Beta" -Resource "mobileApps/$($Win32MobileAppRequest.id)" -Method "GET" Write-Output -InputObject $Win32MobileAppRequest } - } + } } try { diff --git a/Public/Connect-MSIntuneGraph.ps1 b/Public/Connect-MSIntuneGraph.ps1 index 1cb5b6e..c7de906 100644 --- a/Public/Connect-MSIntuneGraph.ps1 +++ b/Public/Connect-MSIntuneGraph.ps1 @@ -1,10 +1,10 @@ function Connect-MSIntuneGraph { <# .SYNOPSIS - Get or refresh an access token using either authorization code flow or device code flow, that can be used to authenticate and authorize against resources in Graph API. + Get or refresh an access token using various authentication flows for the Graph API. .DESCRIPTION - Get or refresh an access token using either authorization code flow or device code flow, that can be used to authenticate and authorize against resources in Graph API. + Get or refresh an access token using various authentication flows for the Graph API. .PARAMETER TenantID Specify the tenant name or ID, e.g. tenant.onmicrosoft.com or . @@ -41,6 +41,7 @@ function Connect-MSIntuneGraph { 1.0.1 - (2022-03-28) Added ClientSecret parameter input to support client secret auth flow 1.0.2 - (2022-09-03) Added new global variable to hold the tenant id passed as parameter input for access token refresh scenario 1.0.3 - (2023-04-07) Added support for client certificate auth flow (thanks to apcsb) + 1.0.4 - (2024-05-29) Updated to integrate New-ClientCredentialsAccessToken function for client secret flow (thanks to @tjgruber) #> [CmdletBinding(DefaultParameterSetName = "Interactive")] param( @@ -50,7 +51,7 @@ function Connect-MSIntuneGraph { [parameter(Mandatory = $true, ParameterSetName = "ClientCert")] [ValidateNotNullOrEmpty()] [string]$TenantID, - + [parameter(Mandatory = $false, ParameterSetName = "Interactive", HelpMessage = "Application ID (Client ID) for an Azure AD service principal. Uses by default the 'Microsoft Intune PowerShell' service principal Application ID.")] [parameter(Mandatory = $false, ParameterSetName = "DeviceCode")] [parameter(Mandatory = $true, ParameterSetName = "ClientSecret")] @@ -68,7 +69,6 @@ function Connect-MSIntuneGraph { [ValidateNotNullOrEmpty()] [System.Security.Cryptography.X509Certificates.X509Certificate2]$ClientCert, - [parameter(Mandatory = $false, ParameterSetName = "Interactive", HelpMessage = "Specify the Redirect URI (also known as Reply URL) of the custom Azure AD service principal.")] [parameter(Mandatory = $false, ParameterSetName = "DeviceCode")] [ValidateNotNullOrEmpty()] @@ -89,7 +89,7 @@ function Connect-MSIntuneGraph { if (-not([string]::IsNullOrEmpty($ClientID))) { Write-Verbose -Message "Using custom Azure AD service principal specified with Application ID: $($ClientID)" - # Adjust RedirectUri parameter input in case non was passed on command line + # Adjust RedirectUri parameter input in case none was passed on command line if ([string]::IsNullOrEmpty($RedirectUri)) { switch -Wildcard ($PSVersionTable["PSVersion"]) { "5.*" { @@ -116,6 +116,19 @@ function Connect-MSIntuneGraph { Process { Write-Verbose -Message "Using authentication flow: $($PSCmdlet.ParameterSetName)" + # Check if the MSAL.PS module is loaded and install if needed + if (($PSCmdlet.ParameterSetName -ne "ClientSecret") -and (-not (Get-Module -ListAvailable -Name MSAL.PS))) { + Write-Verbose -Message "MSAL.PS module not found. Installing MSAL.PS module..." + try { + Install-Module -Name MSAL.PS -Scope CurrentUser -Force -ErrorAction Stop + Write-Verbose -Message "MSAL.PS module installed successfully." + } + catch { + Write-Error -Message "Failed to install MSAL.PS module. Error: $_" + return + } + } + try { # Construct table with common parameter input for Get-MsalToken cmdlet $AccessTokenArguments = @{ @@ -125,7 +138,7 @@ function Connect-MSIntuneGraph { "ErrorAction" = "Stop" } - # Dynamically add parameter input for Get-MsalToken based on parameter set name + # Dynamically add parameter input based on parameter set name switch ($PSCmdlet.ParameterSetName) { "Interactive" { if ($PSBoundParameters["Refresh"]) { @@ -140,49 +153,59 @@ function Connect-MSIntuneGraph { } "ClientSecret" { Write-Verbose "Using clientSecret" - $AccessTokenArguments.Add("ClientSecret", $(ConvertTo-SecureString $clientSecret -AsPlainText -Force)) + try { + $Global:AccessToken = New-ClientCredentialsAccessToken -TenantID $TenantID -ClientID $ClientID -ClientSecret $ClientSecret + $Global:AccessTokenTenantID = $TenantID + } + catch { + Write-Error "An error occurred while retrieving access token using client credentials: $_" + return + } + $AccessTokenArguments = $null # Skip MSAL token retrieval } "ClientCert" { Write-Verbose "Using clientCert" $AccessTokenArguments.Add("ClientCertificate", $ClientCert) } - } - # Dynamically add parameter input for Get-MsalToken based on command line input - if ($PSBoundParameters["Interactive"]) { - $AccessTokenArguments.Add("Interactive", $true) - } - if ($PSBoundParameters["DeviceCode"]) { - if (-not($PSBoundParameters["Refresh"])) { - $AccessTokenArguments.Add("DeviceCode", $true) + if ($AccessTokenArguments) { + # Dynamically add parameter input based on command line input + if ($PSBoundParameters["Interactive"]) { + $AccessTokenArguments.Add("Interactive", $true) + } + if ($PSBoundParameters["DeviceCode"]) { + if (-not($PSBoundParameters["Refresh"])) { + $AccessTokenArguments.Add("DeviceCode", $true) + } } - } - try { - # Attempt to retrieve or refresh an access token - $Global:AccessToken = Get-MsalToken @AccessTokenArguments - $Global:AccessTokenTenantID = $TenantID - Write-Verbose -Message "Successfully retrieved access token" - try { - # Construct the required authentication header - $Global:AuthenticationHeader = New-AuthenticationHeader -AccessToken $Global:AccessToken - Write-Verbose -Message "Successfully constructed authentication header" - - # Handle return value - return $Global:AuthenticationHeader + # Attempt to retrieve or refresh an access token + $Global:AccessToken = Get-MsalToken @AccessTokenArguments + $Global:AccessTokenTenantID = $TenantID + Write-Verbose -Message "Successfully retrieved access token" } - catch [System.Exception] { - Write-Warning -Message "An error occurred while attempting to construct authentication header. Error message: $($PSItem.Exception.Message)" + catch { + Write-Warning -Message "An error occurred while attempting to retrieve or refresh access token: $_" + return } } - catch [System.Exception] { - Write-Warning -Message "An error occurred while attempting to retrieve or refresh access token. Error message: $($PSItem.Exception.Message)" + + try { + # Construct the required authentication header + $Global:AuthenticationHeader = New-AuthenticationHeader -AccessToken $Global:AccessToken + Write-Verbose -Message "Successfully constructed authentication header" + + # Handle return value + return $Global:AuthenticationHeader + } + catch { + Write-Warning -Message "An error occurred while attempting to construct authentication header: $_" } } - catch [System.Exception] { - Write-Warning -Message "An error occurred while constructing parameter input for access token retrieval. Error message: $($PSItem.Exception.Message)" + catch { + Write-Warning -Message "An error occurred while constructing parameter input for access token retrieval: $_" } } -} \ No newline at end of file +} diff --git a/Public/Get-IntuneWin32App.ps1 b/Public/Get-IntuneWin32App.ps1 index ef14167..b2aaf27 100644 --- a/Public/Get-IntuneWin32App.ps1 +++ b/Public/Get-IntuneWin32App.ps1 @@ -34,7 +34,7 @@ function Get-IntuneWin32App { [parameter(Mandatory = $true, ParameterSetName = "ID", HelpMessage = "Specify the ID for a Win32 application.")] [ValidateNotNullOrEmpty()] - [string]$ID + [string]$ID ) Begin { # Ensure required authentication header variable exists @@ -89,7 +89,7 @@ function Get-IntuneWin32App { $Win32App = Invoke-MSGraphOperation -Get -APIVersion "Beta" -Resource "deviceAppManagement/mobileApps/$($Win32MobileApp.id)" $Win32AppList.Add($Win32App) } - + # Handle return value return $Win32AppList } @@ -99,4 +99,4 @@ function Get-IntuneWin32App { } } } -} \ No newline at end of file +} diff --git a/Public/Test-AccessToken.ps1 b/Public/Test-AccessToken.ps1 index 374009e..d25e015 100644 --- a/Public/Test-AccessToken.ps1 +++ b/Public/Test-AccessToken.ps1 @@ -13,28 +13,52 @@ function Test-AccessToken { Author: Nickolaj Andersen Contact: @NickolajA Created: 2021-04-08 - Updated: 2024-03-07 + Updated: 2024-11-15 Version history: 1.0.0 - (2021-04-08) Script created 1.0.1 - (2023-09-04) Updated to use TotalMinutes instead of Minutes property, which would cause for inaccurate results 1.0.2 - (2024-03-07) Invocation of function when access token is null will now return false + 1.0.3 - (2024-05-29) Updated to handle tokens with ExpiresOn property (thanks to @tjgruber) + 1.0.4 - (2024-11-15) Refactor date handling for token to fix locale-specific parsing issues (thanks to @tjgruber) #> param( [parameter(Mandatory = $false, HelpMessage = "Specify the renewal threshold for access token age in minutes.")] [ValidateNotNullOrEmpty()] - [int]$RenewalThresholdMinutes = 10 + [Int]$RenewalThresholdMinutes = 10 ) Process { - if ($Global:AccessToken -eq $null) { + if ($null -eq $Global:AccessToken) { return $false } else { # Determine the current time in UTC $UTCDateTime = (Get-Date).ToUniversalTime() - - # Determine the token expiration count as minutes - $TokenExpireMinutes = [System.Math]::Round(([datetime]$Global:AccessToken.ExpiresOn.ToUniversalTime().UtcDateTime - $UTCDateTime).TotalMinutes) + + # Calculate the expiration time of the token + if ($Global:AccessToken.PSObject.Properties["ExpiresOn"] -and $Global:AccessToken.ExpiresOn) { + $ExpiresOn = $Global:AccessToken.ExpiresOn.ToUniversalTime() + } + else { + Write-Verbose -Message "The access token does not contain a valid ExpiresOn property. Cannot determine expiration." + return $false + } + + # Convert ExpiresOn to DateTimeOffset in UTC + $ExpiresOnUTC = [DateTimeOffset]::Parse( + $Global:AccessToken.ExpiresOn.ToString(), + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::AssumeUniversal + ).ToUniversalTime() + + # 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 $RenewalThresholdMinutes) { @@ -47,4 +71,4 @@ function Test-AccessToken { } } } -} \ No newline at end of file +}