Skip to main content

Exchange Online Automation with PowerShell for Microsoft Partners

· 4 min read

Secure Application Model

For this post you'll want a secure application model Azure AD application configured. Use these instructions from Gavin Stone's Gavsto.com blog.

CIPP Inspired

The code in this script was inspired by functionality in the CyberDrain Improved Partner Portal (CIPP). Need to manage M365 tenants - CIPP is a fantastic open-source solution for M365 management for single tenants and Microsoft CSP Partners alike.

Background information

In my quest to stop having to use Windows Sandbox or VMs to access Exchange Online via PowerShell (we have WinRM basic auth disabled) I've been looking at solutions using the Exchange Online REST API to run commands against our customer tenants. The result of my efforts is a pretty complex script that introduces a method to run any PowerShell script in the context of a set of customer tenants.

caution

You need to have configured the Secure Application Model according to the instructions linked above, you will need your SAM tokens etc accessible for this guide.

Securing Credentials in Scripts

Do not store credentials directly in scripts. You should utilise services like Azure KeyVault or other PAM solutions to secure credentials used in scripts, including this one.

The script

Invoke-EORESTDelegated.ps1
using namespace System.Management.Automation
using namespace System.Runtime.InteropServices
<#
.SYNOPSIS
This script handles authenticating to the ExchangeOnline REST API and running a scriptblock for all customers of a Microsoft Partner.
.EXAMPLE
./Invoke-EORESTDelegated.ps1 -PartnerTenantId '37abf3aa-32f5-479e-aa3c-66822ac3d258' -ApplicationId 'af8917e9-c4d7-477f-854b-b9a31a30e335' -ApplicationSecret 'sshhh its a secret' -ScriptBlock { Get-MailBoxPlan | Set-MailboxPlan }
#>
[CmdletBinding()]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Reason for suppressing')]
param (
# The partner (CSP) tenant id.
[Parameter(Mandatory)]
[GUID]$PartnerTenantId,
# The Secure Application Model application (client) id from Microsoft Azure AD.
[Parameter(Mandatory)]
[GUID]$ApplicationId,
# The Secure Application Model application (client) secret from Microsoft Azure AD.
[Parameter(Mandatory)]
[String]$ApplicationSecret,
# The Graph refresh token from your Secure Application Model application.
[Parameter(Mandatory)]
[String]$GraphRefreshToken,
# The Exchange refresh token from your Secure Application Model application.
[Parameter(Mandatory)]
[String]$ExchangeRefreshToken,
# The User Principal Name you will authenticate as. Needs sufficient permissions to access Exchange in customer tenants.
[Parameter(Mandatory)]
[String]$UPN,
# Array of tenants to exclude. Use the `defaultDomainName` for the customer (https://developer.microsoft.com/en-us/graph/graph-explorer?request=contracts&method=GET&version=v1.0&GraphUrl=https://graph.microsoft.com).
[Parameter()]
[String[]]$ExcludedTenants,
# The script block to execute for each customer retrieved for your partner tenant. There is a particular set of requirements for this scriptblock. Each Exchange Online command you want to execute needs to be in the format `Invoke-EORequest -Commandlet 'Get-MailboxPlan' -Params @{ AllMailboxPlanReleases = $True }`. You can use any other PowerShell in the scriptblock but your Exchange Online commands need to follow the format above.
[Parameter(Mandatory)]
[scriptblock]$ScriptBlock
)
# Check whether the tenant is in the included tenants array.
function _CheckIncluded ([String[]]$IncludeTenants = $IncludeTenants, [String]$Tenant) {
if ($IncludeTenants -contains $Tenant) {
Write-Debug ('Tenant: {0} matches entry in IncludeTenants parameter.' -f $Tenant)
Return $true
} else {
Write-Debug ('Tenant: {0} does not match any entry in IncludeTenants parameter.' -f $Tenant)
Return $false
}
}
# Check whether the tenant is in the excluded tenants array.
function _CheckExcluded ([String[]]$ExcludeTenants = $ExcludeTenants, [String]$Tenant) {
if ($ExcludeTenants -contains $Tenant) {
Write-Debug ('Tenant: {0} matches entry in ExcludeTenants parameter.' -f $Tenant)
Return $true
} else {
Write-Debug ('Tenant: {0} does not match any entry in ExcludeTenants parameter.' -f $Tenant)
Return $false
}
}
# Copy of Luke Whitelock's PartnerCenterLW function.
function _NewPartnerAccessToken (
[GUID]$ApplicationId,
[PSCredential]$Credential,
[String]$RefreshToken,
[String]$Scope,
[String]$TenantId
) {
Write-Verbose 'Getting access token for Tenant "{0}"' -f $TenantId
if ($Credential) {
$BinaryString = [Marshal]::SecureStringToBSTR($Credential.Password)
$AppPassword = [Marshal]::PtrToStringAuto($BinaryString)
$AuthenticationBody = @{
client_id = $ApplicationId
scope = $Scope
refresh_token = $RefreshToken
grant_type = 'refresh_token'
client_secret = $AppPassword
}
} else {
$AuthenticationBody = @{
client_id = $ApplicationId
scope = $Scope
refresh_token = $RefreshToken
grant_type = 'refresh_token'
}
}
if ($TenantId) {
$Path = '{0}/oauth2/v2.0/token' -f $TenantId
} else {
$Path = 'organizations/oauth2/v2.0/token'
}
$URI = [System.UriBuilder]'https://login.microsoftonline.com'
$URI.Path = $Path
try {
$TokenResponse = (Invoke-WebRequest -Uri $URI.ToString() -ContentType 'application/x-www-form-urlencoded' -Method Post -Body $AuthenticationBody -ErrorAction Stop).content | ConvertFrom-Json
} catch {
Throw "Authentication Error: $_"
}
Return $TokenResponse.Access_Token
}
# Get partner contracts/customers from Microsoft Graph.
function _GetPartnerCustomers([String]$GraphToken) {
Write-Verbose 'Getting Partner customers from Graph.'
$RequestHeaders = @{ 'Authorization' = 'Bearer {0}' -f $GraphToken }
$CustomersRequestResponse = Invoke-WebRequest -Uri 'https://graph.microsoft.com/v1.0/contracts?$top=999' -Method Get -Headers $RequestHeaders
Write-Debug ('Raw graph contracts response: {0}' -f $CustomersRequestResponse)
$CustomersPSObject = $CustomersRequestResponse | ConvertFrom-Json -Depth 5
$Customers = $CustomersPSObject.value
Return $Customers
}
# Encapsulates an Exchange PowerShell command in a REST POST request to Microsoft's Exchange Online AdminAPI.
function Invoke-EORequest ([String]$Commandlet, [Hashtable]$Parameters) {
Write-Verbose 'Making request to Exchange Online to run cmdlet: "{0}" with parameters "{1}"' -f $Commandlet, ($Parameters | Out-String)
$Headers = @{
Authorization = ('Bearer {0}' -f $ExchangeCustomerToken)
}
if (-not($Parameters)) {
$Parameters = @{}
}
$EOBody = @{
CmdletInput = @{
CmdletName = $Commandlet
Parameters = $Parameters
}
}
$EOBodyJson = $EOBody | ConvertTo-Json -Depth 3
$EOUri = [uri]('https://outlook.office365.com/adminapi/beta/{0}/InvokeCommand' -f $CustomerTenantId)
$EORequestResponse = Invoke-WebRequest -Uri $EOUri -Method Post -Body $EOBodyJson -Headers $Headers -ContentType 'application/json; charset=utf-8'
$EOResultPSObject = $EORequestResponse | ConvertFrom-Json -Depth 10
$EOResult = $EOResultPSObject.value
if ($EOResult) {
$EOResult | Add-Member -MemberType NoteProperty -Name 'EORCustomerId' -Value $CustomerTenantId
} else {
$EOResult = @{}
}
Return $EOResult
}
# Get a Graph token so we can get customers from Microsoft Graph.
$PartnerGraphCredentials = [PSCredential]::New($ApplicationId, (ConvertTo-SecureString $ApplicationSecret -AsPlainText))
$PartnerGraphParams = @{
ApplicationId = $ApplicationId
Credential = $PartnerGraphCredentials
RefreshToken = $GraphRefreshToken
Scope = 'https://graph.microsoft.com/.default'
TenantId = $PartnerTenantId
}
$GraphPartnerToken = _NewPartnerAccessToken @PartnerGraphParams
Write-Debug ('Graph partner access token: {0}' -f $GraphPartnerToken)
# Get customers from Microsoft Graph.
$Customers = _GetPartnerCustomers -GraphToken $GraphPartnerToken
# Iterate over customers, check if excluded and run provided scriptblock.
$CommandResults = foreach ($Customer in $Customers) {
Write-Verbose ('Processing customer {0} [{1}].' -f $Customer.displayName, $Customer.customerId)
if (_CheckExcluded -Tenant $Customer.defaultDomainName) {
Write-Verbose ('Skipping customer {0} as their domain {1} is in the -ExcludedTenants parameter.' -f $Customer.displayName, $Customer.defaultDomainName)
Continue
}
$ExchangeCustomerParams = @{
ApplicationId = 'a0c73c16-a7e3-4564-9a95-2bdf47383716'
RefreshToken = $ExchangeRefreshToken
Scope = 'https://outlook.office365.com/.default'
TenantId = $Customer.customerId
}
$CustomerTenantId = $Customer.customerId
# Get a customer scoped token for ExchangeOnline.
$ExchangeCustomerToken = _NewPartnerAccessToken @ExchangeCustomerParams
&$ScriptBlock
}

Return $CommandResults

Building Parameters

Personally I find passing parameters as a HashTable (splatting]) easier to read and cleaner see what's happening so that's what I'm doing to explain the parameters for the script:

Parameter Splat
$EORESTParameters = @{
PartnerTenantId = (Get-AzKeyVaultSecret -VaultName 'Homotechsual' -Name 'EORESTTenantId' -AsPlainText) # Your partner tenant id.
ApplicationId = (Get-AzKeyVaultSecret -VaultName 'Homotechsual' -Name 'EORESTClientId' -AsPlainText) # Your SAM application / client id.
ApplicationSecret = (Get-AzKeyVaultSecret -VaultName 'Homotechsual' -Name 'EORESTClientSecret' -AsPlainText) # Your SAM application / client secret.
GraphRefreshToken = (Get-AzKeyVaultSecret -VaultName 'Homotechsual' -Name 'EORESTRefreshToken' -AsPlainText) # The Graph refresh t oken provided by the SAM app creation script.
ExchangeRefreshToken = (Get-AzKeyVaultSecret -VaultName 'Homotechsual' -Name 'EORESTExchangeRefreshToken' -AsPlainText) # The Exchange refresh token provided by the SAM app creation script.
UPN = 'mikey@homotechsual.dev' # The UPN of the user used to authorise your SAM tokens.
IncludeTenants = @('test1.onmicrosoft.com') # Run only on the tenants listed - using the tenant's `DefaultDomainName`. This input is NOT validated.
ExcludeTenants = @('test1.onmicrosoft.com') # Run on the all tenant except those listed - using the tenant's `DefaultDomainName`.
}

ScriptBlock

The -ScriptBlock parameter passed to the script runs the commands given against each selected tenant's Exchange Online. You should use the Invoke-EORequest cmdlet in the script block to send commands to ExchangeOnline. For example to get mailboxes:

-ScriptBlock { Invoke-EORequest -Commandlet 'Get-Mailbox' }

More examples are given below.

Result structure

Before returning values we add a property to object containing the customer's tenant id. This property is called EORCustomerId and is the tenant GUID. You can use this to group results by the tenant they relate to.

| Group-Object -Property 'EORCustomerId'

Example uses

These examples assume use of the parameter splat above

Get Mailbox Plans

.\Invoke-EORESTDelegated.ps1 @EORESTParameters -ScriptBlock { Invoke-EORequest -Commandlet 'Get-MailboxPlan' }

Get Shared, Room, Equipment, Group and Team mailboxes

.\Invoke-EORESTDelegated.ps1 @EORESTParameters -ScriptBlock { Invoke-EORequest -Commandlet 'Get-Mailbox' -Parameters @{ Filter = "((RecipientTypeDetails -eq 'SharedMailbox') -or (RecipientTypeDetails -eq 'RoomMailbox') -or (RecipientTypeDetails -eq 'EquipmentMailbox') -or (RecipientTypeDetails -eq 'GroupMailbox') -or (RecipientTypeDetails -eq 'TeamMailbox'))" } }

Get last login time.

.\Invoke-EORESTDelegated.ps1 @EORESTParameters -ScriptBlock {
$Mailboxes = Invoke-EORequest -Commandlet 'Get-Mailbox' -Parameters @{ ResultSize = 'Unlimited' }
ForEach ($Mailbox in $Mailboxes) { Invoke-EORequest -Commandlet 'Get-MailboxStatistics' -Parameters @{ Identity = $Mailbox.UserPrincipalName } | Select-Object -Property DisplayName, LastLoggedOnUserAccount, LastLogonTime }
}

Get mailboxes not logged into in the last 90 days.

.\Invoke-EORESTDelegated.ps1 @EORESTParameters -ScriptBlock {
$Mailboxes = Invoke-EORequest -Commandlet 'Get-Mailbox' -Parameters @{ ResultSize = 'Unlimited' }
ForEach ($Mailbox in $Mailboxes) { Invoke-EORequest -Commandlet 'Get-MailboxStatistics' -Parameters @{ Identity = $Mailbox.UserPrincipalName } | Where-Object { $_.LastLogonTime -lt (Get-Date).AddDays(-90) } | Select-Object -Property DisplayName, LastLoggedOnUserAccount, LastLogonTime }
}

Get inactive mailboxes.

.\Invoke-EORESTDelegated.ps1 @EORESTParameters -ScriptBlock {
Invoke-EORequest -Commandlet 'Get-Mailbox' -Parameters @{ InactiveMailboxOnly = $True }
}

Set hidden from address list for all mailboxes.

.\Invoke-EORESTDelegated.ps1 @EORESTParameters -ScriptBlock {
$Mailboxes = Invoke-EORequest -Commandlet 'Get-Mailbox' -Parameters @{ ResultSize = 'Unlimited' }
ForEach ($Mailbox in $Mailboxes) { Invoke-EORequest -Commandlet 'Set-Mailbox' -Parameters @{ Identity = $Mailbox.UserPrincipalName; HiddenFromAddressListEnabled = $True } }
}