Pages

Friday, November 27, 2020

Setting up Graph API to export Microsoft Teams PSTN Usage Records with TeamsCloudCommunicationApi and Get-TeamsPSTNCallRecords PowerShell scripts

I’ve recently been involved in many Teams Direct Routing deployments and was asked by one of the clients to assist with automating the generation of PSTN usage reports so they can correctly bill each department long distance fees. Prior to this year, the only way to obtain these reports was to manually export them from the Analytics & reports > Usage reports section of the Teams admin center:

image

In 2020, Microsoft released the ability to programmatically retrieve Microsoft Teams PSTN Usage records using the Graph API and the following are a module and PowerShell script that Jeff Brown and Lee Ford created for the community:

Jeff Brown’s TeamsCloudCommunicationApi module
https://jeffbrown.tech/use-graph-api-to-export-microsoft-teams-pstn-usage-records/

Lee Ford’s Get-TeamsPSTNCallRecords script
https://github.com/leeford/Get-TeamsPSTNCallRecords

This was my first time using Graph API and was initially confused as to how to use the module or script so I thought I’d write a blog post outlining the exact steps required for each of the two solutions above. Note that the information is provided by both Jeff Brown, who provides a comprehensive guide for setting it up but it is in a separate post and requires a bit of scrolling and Lee Ford, who provides the information but not the comprehensive detail Jeff includes, so the purpose here is just to demonstrated what I went through to set them up. I will also include the links to Jeff’s posts for reference.

Prerequisites – Set up an Azure AD application for Graph API

The first thing you’ll need to do is create an App registration that will be used by the module or PowerShell script. This step is to create the necessary permissions for the module or script to run without requiring the person running it to log in.

Log into Azure admin portal then navigate to Azure Active Directory > App registrations and then click on New registration:

image

Give the application a name (this is a logical name that won’t be referenced in the module or script). For this example, I’ll be using: Graph-API-Teams-Logs with the rest of the configuration settings left as the defaults:

image

The configuration parameter we’ll need later is the Application (client) ID and Directory (tenant) ID so copy those into notepad:

Application (client) ID: 57c4b54b-2c88-485d-8a42-5ae62c628294
Directory (tenant) ID: 84f4470b-3f1e-4889-9f95-##############

image

Note that you can obtain the tenant ID in the Overview section of Azure Active Directory as well:

image

Next, navigate to Certificates & secrets > New client secret:

image

Provide a description for the secret (Graph-API-Teams-Logs), configure how long this secret will remain valid and then click add:

image

A client secret will be created. Copy the Value of the secret immediately as you can browse away and back to this page within a short period of time before the value becomes masked with ****:

image

With the client secret created, navigate to API permissions > Add a permission:

image

Select Microsoft Graph:

image

Select Application permissions:

image

Search for CallRecords.Read.All and add the permission:

image

Click on Grand admin consent for <organization name> to complete the permissions configuration:

image

You should now see the Status with a green check mark:

image

The following are the three configuration parameters that you should have after completing the steps above:

Application (client) ID: 57c4b54b-2c88-485d-8a42-5ae62c628294
Directory (tenant) ID: 84f4470b-3f1e-4889-9f95-##############
Value: Ydwo##################-aZS39Zqn

Using Lee Ford’s Get-TeamsPSTNCallRecords script

I’ll start with demonstrating how to use Lee Ford’s Get-TeamsPSTNCallRecords script because it is simpler but it did not provide me with a report I needed because the extension of the user and the number dialed was masked with a *. It also did not let me specify a date range.

Begin by downloading the PowerShell script from here: https://github.com/leeford/Get-TeamsPSTNCallRecords

Open the Get-TeamsPSTNCallRecords.ps1 script and navigate down to the following lines and fill in the appropriate values:

# Client (application) ID, tenant (directory) ID and secret
$clientId = "ddbc3c22-3a28-47c9-bfb3-3b857d97f1e0"
$tenantId = "84f4470b-3f1e-4889-9f95-###########"
$clientSecret = 'pGLJ####################hm5j_J'

image

Save the Get-TeamsPSTNCallRecords.ps1 and you should now be able to use the following cmdlet to export the logs:

.\Get-TeamsPSTNCallRecords.ps1 -SavePath C:\Temp -Days 50 -SaveFormat CSV

image

As mentioned earlier, the report appears to mask the caller and the callee’s number:

image

Using Jeff Brown’s TeamsCloudCommunicationApi module

Jeff Brown’s TeamsCloudCommunicationApi module provides more flexibility as you can specify the date range as well as have the caller and callee’s number displayed.

As specified in Jeff’s post (https://jeffbrown.tech/use-graph-api-to-export-microsoft-teams-pstn-usage-records/), the module can be downloaded directly from the PowerShell gallery with the command:

Install-Module -Name TeamsCloudCommunicationApi

Alternatively, the module can also be downloaded from GitHub: https://github.com/JeffBrownTech/TeamsCloudCommunicationApi

After downloading the files in this GitHub repository, the module can be imported by executing the following cmdlet:

Import-Module TeamsCloudCommunicationApi.psd1

The available commands can be displayed with the following cmdlet:

Get-Command -Module TeamsCloudCommunicationApi

Get-GraphApiAccessToken
Get-TeamsDirectRoutingCalls
Get-TeamsPstnCalls

image

With either the module installed or the module downloaded and imported, the next step is to generate an access token that will be used with the PowerShell cmdlet to retrieve the logs. The steps provided at GitHub: https://github.com/JeffBrownTech/TeamsCloudCommunicationApi did not work for me but the step in Jeff’s other post here did: https://jeffbrown.tech/creating-microsoft-teams-and-channels-with-graph-api-and-powershell/

image

The following are the lines to modify and execute to generate the access token (replace the text highlighted in red with the appropriate values):

$env:graphApiDemoAppId = "12345678-abcd-efgh-jklm-123456789abc" # Replace with your Azure AD app id

$env:graphApiDemoAppSecret = "1234567890asdfjk;l54321" # Replace with your Azure AD app secret

$env:tenantId = "12345678-abcd-efgh-ijkl-987654321wxyz" # Replace with your Azure AD tenant ID

$oauthUri = https://login.microsoftonline.com/$env:tenantId/oauth2/v2.0/token

# Create token request body

$tokenBody = @{

client_id = $env:graphApiDemoAppId

client_secret = $env:graphApiDemoAppSecret

scope = "https://graph.microsoft.com/.default"

grant_type = "client_credentials"

}

# Retrieve access token

$tokenRequest = Invoke-RestMethod -Uri $oauthUri -Method POST -ContentType "application/x-www-form-urlencoded" -Body $tokenBody –UseBasicParsing

# Save access token

$accessToken = ($tokenRequest).access_token

With the access token created, we can now use either the:

Get-TeamsDirectRoutingCalls

Get-TeamsPstnCalls

… to export the logs:

image

Hope this helps anyone who may not be familiar with setting up App registrations and using Graph API.

Huge thanks to Jeff Brown and Lee Ford for providing this to the community!

Publishing Office 365 web-based apps with Citrix Workspace fails to launch with the error: "AADSTS51004: The user account @contoso.com does not exist in the 87f1d4b7-d6e7-4ebb-842d-cce6024b0bb2 directory."

Problem

You’re attempting to publish Office web-based 365 applications within Citrix with the following instructions:

Configuring individual Office suite applications in Citrix Workspace

https://docs.citrix.com/en-us/advanced-concepts/design-guides/citrix-gateway-o365-saas.html#configuring-individual-office-suite-applications-in-citrix-workspace

… but notice that the first Microsoft Word you publish fails to launch and presents the following error:

image

Sign in

Sorry, but we’re having trouble signing you in.

AADSTS51004: The user account tluk@contoso.com does not exist in the 87f1d4b7-d6e7-4ebb-842d-cce6024b0bb2 directory. To sign into this application, the account must be added to the directory.

Troubleshooting details

If you contact your administrator, send this info to them.

Copy info to clipboard

Request Id: 468d4068-a6aa-4559-b8bf-fcc7f7be8200

Correlation Id: 145b397e-dd9f-462c-87c0-50b1b685190e

Timestamp: 2020-11-25T21:21:14Z

Message: AADSTS51004: The user account tluk@contoso.com does not exist in the 87f1d4b7-d6e7-4ebb-842d-cce6024b0bb2 directory. To sign into this application, the account must be added to the directory.

Advanced diagnostics: Enable

If you plan on getting support for an issue, turn this on and try to reproduce the error. This will collect additional information that will help troubleshoot the issue.

image

Solution

One of the common causes for this error during the launch is if the Launch the app using the specified URL (SP initiated) is not enabled (the documentation does not explicitly specify this should be):

image

Word should launch once the configuration is enabled:

image

Monday, November 23, 2020

Configuring the Azure AD Federation Requirement for Citrix Workspace to publish Office 365 SaaS applications

I’ve recently had a few clients reach out to me about the configuration for publishing Office 365 SaaS applications via Citrix Workspace because most quickly realize that it isn’t as easy as using the SaaS templates in the Citrix Cloud portal as soon as the service is provisioned. Doing so would present the application icon:

image

… but clicking on it will display the following error:

Sign in

Sorry, but we’re having trouble signing you in.

AADSTS50107: The requested federation realm object 'https://citrix.com/dcintd0a28a9' does not exist.

Troubleshooting details

If you contact your administrator, send this info to them.

Copy info to clipboard

Request Id: 63e1ab2a-8c53-48d9-9bbd-a775b8e61a02

Correlation Id: 80a8cfd9-acfd-4faf-995c-d91afe14a5b5

Timestamp: 2020-11-09T14:18:15Z

Message: AADSTS50107: The requested federation realm object 'https://citrix.com/dcintd0a28a9' does not exist.

Advanced diagnostics: Enable

If you plan on getting support for an issue, turn this on and try to reproduce the error. This will collect additional information that will help troubleshoot the issue.

image

One of the important prerequisites that many administrators may not be away of is that Citrix Workspace requires a domain in Azure AD to be federated with it in order to successfully publishing Office 365 as a SaaS application. The choices for federation is to federate the primary domain or another domain that is verified within Azure AD. Most of the clients I work with would not be able to federate their primary domain because that would cause users who attempt to log into Office 365 via https://office.com or https://login.microsoftonline.com/ to be redirected to the Citrix Workspace portal for authentication:

https://accounts.cloud.com/core/company/prompt

image

With this in mind, what I typically recommend is to review and select a domain that the organization owns and can be verifiable but is not used for any Office 365 authentication to federate with Citrix.

The following Prerequisites section of the document provides a more detailed explanation about this:

https://docs.citrix.com/en-us/advanced-concepts/design-guides/citrix-gateway-o365-saas.html#prerequisites

The rest of the document provides instructions on how to configure the federation:

Citrix Gateway SaaS and O365 Cloud Citrix Validated Reference Design
https://docs.citrix.com/en-us/advanced-concepts/design-guides/citrix-gateway-o365-saas.html

What I’ve been told by a lot of administrators who may not be completely familiar with Citrix Cloud is that a few of the steps can be confusing so I would like to write this short blog post to demonstrate the configuration.

Prerequisites

Before beginning the federation configuration, ensure that a domain has been selected and verified in the Office 365 portal (Azure AD) as we will need this for the configuration:

image

Step #1 – Install Azure AD Modules and Connect to Azure Active Directory

Begin by launching PowerShell as an administrator and install the AzureAD modules then connect to Azure Active Directory with the following cmdletse:

Install-Module AzureAD -Force

Import-Module AzureAD -Force

Install-Module MSOnline -Force

Import-module MSOnline -Force

Connect-MsolService

Step #2 – Define the required variables

The following are the variables that will need to be defined and where to obtain the information:

$dom = "contoso.com" < replace contoso.com with the domain that has been selected and verified in Azure AD to federate with Citrix Workspace

$IssuerUri = “https://citrix.com/dcintd0a28a9” < replace the dcintd0a28a9with the Customer ID, which can be located here in the Citrix Cloud portal:

image

$fedBrandName = "CitrixWorkspace" < Leave this without any changes

$logoffuri = "https://app.netscalergateway.net/cgi/logout" < Leave this without any changes

$uri = "https://app.netscalergateway.net/ngs/dcintd0a28a9/saml/login?APPID=27e051ca-42c2-478d-a460-9d512a4e8dab" < Replace the full https:// URI with the following:

Assuming that the Office 365 SaaS has been published, click into the Library Offerings:

image

Edit the Office 365 application:

image

Expand the Single sign on heading and copy the Login URL string:

image

$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("c:\cert\dcintd0a28a9_cert.crt") > The certificate can be confusing for more administrators and the location to download it is in the same place as the Login URL above where you will find the Certificate available to download in multiple formats. Proceed to select CRT and download the file:

image

$certData = [system.convert]::tobase64string($cert.rawdata) < Leave this without any changes

The following are all the variables with a sample configuration and the items that need to be changed highlighted in red:

$dom = "contoso.com"

$IssuerUri = "https://citrix.com/dcintd0a28a9"

$fedBrandName = "CitrixWorkspace"

$logoffuri = "https://app.netscalergateway.net/cgi/logout"

$uri = "https://app.netscalergateway.net/ngs/dcintd0a28a9/saml/login?APPID=27e051ca-42c2-478d-a460-9d512a4e8dab"

$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("c:\cert\dcintd0a28a9_cert.crt")

$certData = [system.convert]::tobase64string($cert.rawdata)

Step #3 – Configure the Federation between Azure AD and Citrix

Configure the federation with the following PowerShell cmdlet without any changes:

Set-MsolDomainAuthentication -DomainName $dom -federationBrandName $fedBrandName -Authentication Federated -PassiveLogOnUri $uri -LogOffUri $logoffuri -SigningCertificate $certData -IssuerUri $IssuerUri -PreferredAuthenticationProtocol SAMLP

Step #4 – Confirm the Federation between Azure AD and Citrix

Confirm that the federation has been configured by executing the following PowerShell cmdlet:

Get-MsolDomainFederationSettings -DomainName contoso.bm

------------------------------------------------------------------------------------------------------------------------

The following is a sample output in the PowerShell window after a successful configuration:

image

With the federation in place, clicking on the Office 365 application will redirect the user to Office 365 to authenticate.

Saturday, November 21, 2020

Configuring Office 365 license app options with PowerShell

I’ve recently had to perform a bit of licensing management for a client because they had a set of users who have Office 365 E1 licenses assigned but the Apps configured were inconsistent across the board. Using the Microsoft 365 admin center to perform this is possible but isn’t the most efficient method for large batches.

https://admin.microsoft.com/

image

What I ended up using was PowerShell for the assignments and thought it would be useful to share the PowerShell cmdlets for some common licensing tasks that may be useful for others.

All of the cmdlets below require you to connect to the O365 tenant via Connect-MsolService cmdlet.

Obtaining licenses available in the tenant

The cmdlet to obtain the licenses available in the tenant is Get-MsolAccountSku and as the output below shows, the AccountSkuId name will not directly reflect what you see in the portal. Case in point, STANDARDPACK is actually the name for Office 365 E1:

image

Most of the names for these SKUs can be found at the following TechNet article:

Product names and service plan identifiers for licensing
https://docs.microsoft.com/en-us/azure/active-directory/enterprise-users/licensing-service-plan-reference

Obtaining the enabled or disabled apps for the license assigned to a user

Each Office 365 license actually bundles a group of applications that can be enabled or disabled for a user. Below is an example of what Apps are available when Office 365 E1 is assigned:

imageimage

The following cmdlet allows us to obtain the Apps and their status for the specified user:

(Get-MsolUser -UserPrincipalName shaejames@contoso.com).Licenses.ServiceStatus

image

Note that this cmdlet will list all the Apps and their status. Success and Disabled is self-explanatory, while PendingProvisioning and PendingActivation is reflected in the follow 3 app entries with the same description:

This app is assigned at the organization level. It can’t be assigned per user.

image

Assigning a usage location and E1 license to a user

Set-MsolUser -UserPrincipalName "ATrott@contoso.com" -UsageLocation BM

Set-MsolUserLicense -UserPrincipalName "ATrott@contoso.com" -AddLicenses "reseller-account:STANDARDPACK"

**Note that the usage location cmdlet is Set-MsolUser

Retrieving a list of all users with E1 license

Get-MsolUser -All | Where-Object {($_.licenses).AccountSkuId -match "STANDARDPACK"}

Enabling or disabling apps for a user assigned with an E1 license

The following are examples of how to enable and disable various applications:

Only disable SWAY and enable all apps

$E1_DefaultApps = New-MsolLicenseOptions -AccountSkuId reseller-account:STANDARDPACK -DisabledPlans SWAY

Set-MsolUserLicense -UserPrincipalName "shaejames@contoso.com" -LicenseOptions $E1_DefaultApps

Disable SWAY and Exchange Online and enable all apps

$DisabledApps=@()

$DisabledApps+="SWAY"

$DisabledApps+="EXCHANGE_S_STANDARD"

$E1_DefaultApps = New-MsolLicenseOptions -AccountSkuId reseller-account:STANDARDPACK -DisabledPlans $DisabledApps

Set-MsolUserLicense -UserPrincipalName "shaejames@contoso.com" -LicenseOptions $E1_DefaultApps

Enable all apps

$DisabledApps=@()

$E1_DefaultApps = New-MsolLicenseOptions -AccountSkuId reseller-account:STANDARDPACK -DisabledPlans $DisabledApps

Set-MsolUserLicense -UserPrincipalName "shaejames@contoso.com" -LicenseOptions $E1_DefaultApps

Disable Skype for Business Online (Plan 2) and Exchange Online and enable all apps

$DisabledApps=@()

$DisabledApps+="MCOSTANDARD"

$DisabledApps+="EXCHANGE_S_STANDARD"

$E1_DefaultApps = New-MsolLicenseOptions -AccountSkuId reseller-account:STANDARDPACK -DisabledPlans $DisabledApps

Set-MsolUserLicense -UserPrincipalName "shaejames@contoso.com" -LicenseOptions $E1_DefaultApps

Set all users with E1 license to disable Skype for Business Online (Plan 2) and Exchange Online (Plan 1) options

$DisabledApps=@()

$DisabledApps+="MCOSTANDARD"

$DisabledApps+="EXCHANGE_S_STANDARD"

Get-MsolUser -All | Where-Object {($_.licenses).AccountSkuId -match "STANDARDPACK"} | Set-MsolUserLicense -LicenseOptions $E1_DefaultApps

Get all users with Microsoft Teams Commercial Cloud and E1 license

Get-MsolUser -All | Where-Object {($_.licenses).AccountSkuId -match "TEAMS_COMMERCIAL_TRIAL" -and ($_.licenses).AccountSkuId -match "STANDARDPACK"}

Get all users with Microsoft Teams Commercial Cloud and E1 license AND remove Microsoft Teams Commercial Cloud license

Get-MsolUser -All | Where-Object {($_.licenses).AccountSkuId -match "TEAMS_COMMERCIAL_TRIAL" -and ($_.licenses).AccountSkuId -match "STANDARDPACK"} | Set-MsolUserLicense -RemoveLicenses reseller-account:TEAMS_COMMERCIAL_TRIAL

Get all users with Microsoft Teams Exploratory and E1 license

Get-MsolUser -All | Where-Object {($_.licenses).AccountSkuId -match "TEAMS_Exploratory" -and ($_.licenses).AccountSkuId -match "STANDARDPACK"}

Get all users with Microsoft Teams Exploratory and E1 license AND remove Microsoft Teams Exploratory license

Get-MsolUser -All | Where-Object {($_.licenses).AccountSkuId -match "TEAMS_Exploratory" -and ($_.licenses).AccountSkuId -match "STANDARDPACK"} | Set-MsolUserLicense -RemoveLicenses reseller-account:TEAMS_Exploratory

------------------------------------------------------------------------------------------------------------------------

I hope these cmdlets will help anyone who may need to undertake a similar task.

Attempting to install Exchange Server 2019 to Cumulative Update 7 fails with: “Cannot start service MSExchangeIS on computer”

I do not have many clients with an on-premise Exchange Server but there was one who needed their Exchange Server 2019 CU3 patched to the latest CU7.

Error:

The following error was generated when "$error.Clear();

start-SetupService -ServiceName MSExchangeIS

" was run: "Microsoft.Exchange.Configuration.Tasks.ServiceDisabledException: Service 'MSExchangeIS' is disabled on this server. ---> System.InvalidOperationException: Cannot start service MSExchangeIS on computer '.'. ---> System.ComponentModel.Win32Exception: The service cannot be started, either because it is disabled or because it has no enabled devices associated with it

--- End of inner exception stack trace ---

at System.ServiceProcess.ServiceController.Start(String[] args)

at Microsoft.Exchange.Management.Tasks.ManageSetupService.StartServiceWorker(ServiceController serviceController, String[] serviceParameters)

--- End of inner exception stack trace ---

at Microsoft.Exchange.Configuration.Tasks.Task.ThrowError(Exception exception, ErrorCategory errorCategory, Object target, String helpUrl)

at Microsoft.Exchange.Management.Tasks.ManageSetupService.StartService(ServiceController serviceController, Boolean ignoreServiceStartTimeout, Boolean failIfServiceNotInstalled, Unlimited`1 maximumWaitTime, String[] serviceParameters)

at Microsoft.Exchange.Management.Tasks.ManageSetupService.StartService(String serviceName, Boolean ignoreServiceStartTimeout, Boolean failIfServiceNotInstalled, Unlimited`1 maximumWaitTime, String[] serviceParameters)

at Microsoft.Exchange.Management.Tasks.StartSetupService.InternalProcessRecord()

at Microsoft.Exchange.Configuration.Tasks.Task.<ProcessRecord>b__91_1()

at Microsoft.Exchange.Configuration.Tasks.Task.InvokeRetryableFunc(String funcName, Action func, Boolean terminatePipelineIfFailed)".

image

Attempting to restart the CU7 patch detects the unfinished patching and restarts but then fails again:

imageimage

There aren’t any Microsoft KBs or blog posts with the error but the message implies the Microsoft Exchange Information Store (MSExchangeIS) could not be started and reviewing the services console does show that it is disabled so I manually enabled it, started it, restarted the installer but it would fail again with the MSExchangeIS service disabled.

A bit of searching on the internet lead me to the following blog post:

https://oddytee.wordpress.com/2017/11/09/cannot-start-service-msexchangeservicehost-on-computer-during-exchange-cu-update/

… which suggested that we chronically change any Microsoft Exchange services set to disabled to automatic with the following PowerShell command:

While (1 -le 2) { Sleep 1 ; Get-Service | Where { $_.DisplayName -Like ‘Microsoft Exchange*’ } | Set-Service –StartupType ‘Automatic’ }

I gave this a shot and can confirm that it allowed CU7 to complete patching the Exchange server.

Installing Microsoft .NET Framework 4.8 stalls at: "File security verification: All files were verified successfully. Installation progress:"

Problem

You’re attempting to upgrade an Exchange Server 2019 to Cumulative Update 7, which requires .NET Framework 4.8:

https://support.microsoft.com/en-ca/help/4571787/cumulative-update-7-for-exchange-server-2019

image

The prerequisites identifies that .NET Framework 4.8 is not present and provides a link to the following installer:

https://support.microsoft.com/en-us/help/4503548/microsoft-net-framework-4-8-offline-installer-for-windows

image

The following is the offline installer:

image

Executing it on the Exchange server loads the installer but stays stuck at the following prompt:

File security verification:

All files were verified successfully.

Installation progress:

image

Solution

Before begin troubleshooting, review the current version of .NET Framework installed with the following PowerShell cmdlet:

PS C:\Windows\system32> Get-ItemProperty -Path "HKLM:SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full" | Format-List

CBS : 1

Install : 1

InstallPath : C:\Windows\Microsoft.NET\Framework64\v4.0.30319\

Release : 461814

Servicing : 0

TargetVersion : 4.0.0

Version : 4.7.03190

PSPath : Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\NET Framework

Setup\NDP\v4\Full

PSParentPath : Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4

PSChildName : Full

PSDrive : HKLM

PSProvider : Microsoft.PowerShell.Core\Registry

PS C:\Windows\system32>

image

This issue is one that I’ve come across multiple times in the past as demonstrated in one of my previous posts:

Installing .NET Framework 4.7.1 on Windows Server 2016 as a prerequisite for patching Exchange 2016 CU8 to CU12 remains stuck at: “File security verification: All files were verified successfully.”
http://terenceluk.blogspot.com/2019/04/installing-net-framework-471-on-windows_4.html

There are times when waiting 20 minutes or more will proceed with the install:

image

However, what I’ve found that the MSU from the Microsoft Update Catalog installs much faster:

https://www.catalog.update.microsoft.com/Search.aspx?q=4486153

image

image

image

Once installed, execute the following cmdlet again to verify that .NET Framework 4.8 is installed:

Get-ItemProperty -Path "HKLM:SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full" | Format-List

image

Hope this helps anyone who may run into this issue as updating Exchange Server to a CU is usually performed after hours with limited time for troubleshooting.