Connect to Exchange Online automated when MFA is enabled (Using the SecureApp Model)

So in the past months Microsoft has been forcing CSPs and MSPs to use MFA, something I strongly encourage and am glad with. The only issue with this was that Microsoft made this move without accounting for automation and automated jobs that need to run, especially jobs that run unattended and over multiple delegates.

To make sure that we could be able to use this I’ve been advocating and discussing this with Microsoft a lot, including a public part here, but after several months and great help from
Janosch Ulmer at Microsoft I’ve been able to compose a script for everyone to use, to connect to all Microsoft resources using the secure application model, including Exchange Online.

Compiling all of these scripts took me quite some time. If you have questions or issues just let me know!

The Script

Param
(
    [Parameter(Mandatory = $false)]
    [switch]$ConfigurePreconsent,
    [Parameter(Mandatory = $true)]
    [string]$DisplayName,
    [Parameter(Mandatory = $false)]
    [string]$TenantId
)

$ErrorActionPreference = "Stop"

# Check if the Azure AD PowerShell module has already been loaded.
if ( ! ( Get-Module AzureAD ) ) {
    # Check if the Azure AD PowerShell module is installed.
    if ( Get-Module -ListAvailable -Name AzureAD ) {
        # The Azure AD PowerShell module is not load and it is installed. This module
        # must be loaded for other operations performed by this script.
        Write-Host -ForegroundColor Green "Loading the Azure AD PowerShell module..."
        Import-Module AzureAD
    } else {
        Install-Module AzureAD
    }
}

try {
    Write-Host -ForegroundColor Green "When prompted please enter the appropriate credentials... Warning: Window might have pop-under in VSCode"

    if([string]::IsNullOrEmpty($TenantId)) {
        Connect-AzureAD | Out-Null

        $TenantId = $(Get-AzureADTenantDetail).ObjectId
    } else {
        Connect-AzureAD -TenantId $TenantId | Out-Null
    }
} catch [Microsoft.Azure.Common.Authentication.AadAuthenticationCanceledException] {
    # The authentication attempt was canceled by the end-user. Execution of the script should be halted.
    Write-Host -ForegroundColor Yellow "The authentication attempt was canceled. Execution of the script will be halted..."
    Exit
} catch {
    # An unexpected error has occurred. The end-user should be notified so that the appropriate action can be taken.
    Write-Error "An unexpected error has occurred. Please review the following error message and try again." `
        "$($Error[0].Exception)"
}

$adAppAccess = [Microsoft.Open.AzureAD.Model.RequiredResourceAccess]@{
    ResourceAppId = "00000002-0000-0000-c000-000000000000";
    ResourceAccess =
    [Microsoft.Open.AzureAD.Model.ResourceAccess]@{
        Id = "5778995a-e1bf-45b8-affa-663a9f3f4d04";
        Type = "Role"},
    [Microsoft.Open.AzureAD.Model.ResourceAccess]@{
        Id = "a42657d6-7f20-40e3-b6f0-cee03008a62a";
        Type = "Scope"},
    [Microsoft.Open.AzureAD.Model.ResourceAccess]@{
        Id = "311a71cc-e848-46a1-bdf8-97ff7156d8e6";
        Type = "Scope"}
}

$graphAppAccess = [Microsoft.Open.AzureAD.Model.RequiredResourceAccess]@{
    ResourceAppId = "00000003-0000-0000-c000-000000000000";
    ResourceAccess =
        [Microsoft.Open.AzureAD.Model.ResourceAccess]@{
            Id = "bf394140-e372-4bf9-a898-299cfc7564e5";
            Type = "Role"},
        [Microsoft.Open.AzureAD.Model.ResourceAccess]@{
            Id = "7ab1d382-f21e-4acd-a863-ba3e13f7da61";
            Type = "Role"}
}

$partnerCenterAppAccess = [Microsoft.Open.AzureAD.Model.RequiredResourceAccess]@{
    ResourceAppId = "fa3d9a0c-3fb0-42cc-9193-47c7ecd2edbd";
    ResourceAccess =
        [Microsoft.Open.AzureAD.Model.ResourceAccess]@{
            Id = "1cebfa2a-fb4d-419e-b5f9-839b4383e05a";
            Type = "Scope"}
}

$SessionInfo = Get-AzureADCurrentSessionInfo

Write-Host -ForegroundColor Green "Creating the Azure AD application and related resources..."

$app = New-AzureADApplication -AvailableToOtherTenants $true -DisplayName $DisplayName -IdentifierUris "https://$($SessionInfo.TenantDomain)/$((New-Guid).ToString())" -RequiredResourceAccess $adAppAccess, $graphAppAccess, $partnerCenterAppAccess -ReplyUrls @("urn:ietf:wg:oauth:2.0:oob","https://localhost","http://localhost","http://localhost:8400")
$password = New-AzureADApplicationPasswordCredential -ObjectId $app.ObjectId
$spn = New-AzureADServicePrincipal -AppId $app.AppId -DisplayName $DisplayName


    $adminAgentsGroup = Get-AzureADGroup -Filter "DisplayName eq 'AdminAgents'"
    Add-AzureADGroupMember -ObjectId $adminAgentsGroup.ObjectId -RefObjectId $spn.ObjectId

write-host "Installing PartnerCenter Module." -ForegroundColor Green
install-module PartnerCenter -Force
write-host "Sleeping for 30 seconds to allow app creation on O365" -foregroundcolor green
start-sleep 30
write-host "Please approve General consent form." -ForegroundColor Green
$PasswordToSecureString = $password.value | ConvertTo-SecureString -asPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($($app.AppId),$PasswordToSecureString)
$token = New-PartnerAccessToken -ApplicationId "$($app.AppId)" -Scopes 'https://api.partnercenter.microsoft.com/user_impersonation' -ServicePrincipal -Credential $credential -Tenant $($spn.AppOwnerTenantID) -UseAuthorizationCode
write-host "Please approve Exchange consent form." -ForegroundColor Green
$Exchangetoken = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716' -Scopes 'https://outlook.office365.com/.default' -Tenant $($spn.AppOwnerTenantID) -UseDeviceAuthentication
write-host "Last initation required: Please browse to https://login.microsoftonline.com/$($spn.AppOwnerTenantID)/adminConsent?client_id=$($app.AppId)"
write-host "Press any key after auth. An error report about incorrect URIs is expected!"
[void][System.Console]::ReadKey($true)
Write-Host "================ Secrets ================"
Write-Host "`$ApplicationId         = $($app.AppId)"
Write-Host "`$ApplicationSecret     = $($password.Value)"
Write-Host "`$TenantID              = $($spn.AppOwnerTenantID)"
write-host "`$RefreshToken          = $($token.refreshtoken)" -ForegroundColor Blue
write-host "`$Exchange RefreshToken = $($ExchangeToken.Refreshtoken)" -ForegroundColor Green
Write-Host "================ Secrets ================"
Write-Host "    SAVE THESE IN A SECURE LOCATION     " 

The following script creates a new application, and connects to all resources. At the end you will receive several private keys. Store these in a secure location for future usage such as a Azure Keyvault or IT-Glue. With these keys you can connect to all your delegated resources without MFA. There currently are some slight issues on the Azure side with performing consent, and as thus you’ll have to click a couple of times. Sorry about that 🙂

So, now that you have these keys you can use the following scripts to connect to the correct resources:

MSOL-Module
$ApplicationId         = 'xxxx-xxxx-xxxx-xxxx-xxx'
$ApplicationSecret     = 'YOURSECRET' | Convertto-SecureString -AsPlainText -Force
$TenantID              = 'xxxxxx-xxxx-xxx-xxxx--xxx'
$RefreshToken          = 'LongResourcetoken'
$ExchangeRefreshToken  = 'LongExchangeToken'
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)

$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
AzureAD Module
$ApplicationId         = 'xxxx-xxxx-xxxx-xxxx-xxx'
$ApplicationSecret     = 'YOURSECRET' | Convertto-SecureString -AsPlainText -Force
$TenantID              = 'xxxxxx-xxxx-xxx-xxxx--xxx'
$RefreshToken          = 'LongResourcetoken'
$ExchangeRefreshToken  = 'LongExchangeToken'
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)

$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 

Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId 'VALIDEMAILADDRESS' -MsAccessToken $graphToken.AccessToken -TenantId $tenantID
Exchange Online

For the Exchange Online module we’ll need to do a little more effort – You will need the tenantid of the client you are connecting too. I’ll show you how to perform a specific action for each client you have delegated access too.

$ApplicationId         = 'xxxx-xxxx-xxx-xxxx-xxxx'
$ApplicationSecret     = 'TheSecretTheSecrey' | Convertto-SecureString -AsPlainText -Force
$TenantID              = 'YourTenantID'
$RefreshToken          = 'RefreshToken'
$ExchangeRefreshToken  = 'ExchangeRefreshToken'
$upn                   = 'UPN-Used-To-Generate-Tokens'
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)

$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach($customer in $customers){
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.TenantId
    $tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
    $customerId = $customer.DefaultDomainName
    $session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection
    Import-PSSession $session
#From here you can enter your own commands
get-mailbox 
#end of commands
Remove-PSSession $session
}

And that’s it! I hope it helps MSPs, CSPs, etc. 🙂 as always, Happy PowerShelling!

Follow me

Kelvin Tegelaar

I am a Microsoft Certified System Engineer working as the CTO of the Managed Services Provider Lime Networks B.V. in the Netherlands. I mostly enjoy automating business processes by deploying PowerShell solutions, but just have a large passion for Microsoft Technology in general.

If you want to contact me directly you can find me on twitter here, or via email: Kelvin {at} limenetworks.nl
Kelvin Tegelaar
Follow me

12 thoughts on “Connect to Exchange Online automated when MFA is enabled (Using the SecureApp Model)

  1. Ban Hang

    Hey just wanted to give you a quick heads up.
    the first <# seems to be lost in encoding. I'm not sure if this is a formatting issue or something to do with browser compatibility but I thought I'd post to let you know. The script is fanstatic and look great though! thanks

    Reply
  2. Heiner

    I have been working on this since the forced MFA change and could not get it working. Thank you so much for posting this.

    Reply
    1. Kelvin Tegelaar Post author

      The Microsoft Teams module currently does not support delegated access yet. I’ve requested Microsoft to look into it and got the confirmation that it should in a future version, but we’ll have to wait for this… 🙂

      Reply
  3. Geert W

    Hi Kelvin! Sadly, nothing is happening for me after ‘Please approve Exchange consent form’ – there just isn’t anything to approve, no pop-ups. Any idea what this can be?

    Reply
    1. Kelvin Tegelaar Post author

      You should be getting the output

      “To sign in, use a web browser to open the page xxxxx and enter the code xxxxx”

      Are you running the script from ISE or VSCode? I’ve had someone else report the same issue today, So I am suspecting this has something to do with the IDE used. I’ve just tested the script in my VSCode instance and it seems to run fine.

      Reply
    2. Kelvin Tegelaar Post author

      Just checked this for you; When running in ISE the script indeed does not finish running. Please save the script somewhere and run it from a non-ise PowerShell window by entering “.\scriptname.ps1”. Hope it helps!

      Reply
  4. Geert W

    Hi Kelvin,

    Thank you – you are right. I was using the ISE and this was causing the problem.
    However, when using the normal PowerShell windows it won’t output a $RefreshToken or $Exchange RefreshToken for me (just empty).

    I will troubleshoot and let you know :).

    Thank you!

    Reply
    1. Kelvin Tegelaar Post author

      The only tip I can give in this is rerunning the script; the Partnercenter sometimes just does not return any token. I believe this is due to the API getting a time-out but the PartnerCenter module does not report this.

      Good luck, and if you need any more help let me know.

      Reply
  5. Pingback: Documenting with PowerShell: Documenting Office365 mailbox permissions - CyberDrain

  6. Pingback: Monitoring with PowerShell: Alerting on large Office 365 mailboxes - CyberDrain

Leave a Reply

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.