All you need to know about Exchange Online Admin API (or how to run cmdlets without PowerShell)

PowerShell remains the main tool to manage Exchange Online, even though Microsoft 365 customers and partners alike are increasingly looking for a RESTful solution. While at this point Microsoft does not offer such a solution for Exchange Online management, they did address some of PowerShell’s deficiencies over the past few years. They have even gone a step further, providing a set of “native” RESTful cmdlets, and later on “proxying” the “classic” PowerShell cmdlets in a JSON payload over a standard HTTPS request. In turn, all this means that you don’t even have to rely on PowerShell anymore, and can call the corresponding endpoints and methods directly.

For anyone following the evolution of Exchange Online management, none of the above should be news. In fact, we covered these improvements across several articles on this blog, such as the one detailing how to directly call the methods powering the new REST-based cmdlets, or this one detailing the InvokeCommand proxy method. But as questions around the outlined methods keep pouring in, and there is still no official documentation to refer people to, it is time for a single, comprehensive article (or two) that addresses all you need to know. So lets see about that.

Before we begin

First things first, here’s the obligatory reminder that none of these methods are officially supported by Microsoft, so you should use them at your own risk. Changes can happen at any time, and will be undocumented, which means that you might have to spend a lot of additional time maintaining your code. Microsoft might simply decide to pull the plug on using such methods, for example by implementing restrictions on the calling app and whatnot. Don’t say you weren’t warned!

It is important to understand that two distinct sets of cmdlets exist currently, even though both leverage the same endpoint, https://outlook.office365.com/adminapi/beta/. Any of the “classic” Exchange Online cmdlets you are used to working with are now being proxied via the /InvokeCommand method, whereas a set of additional methods powers the new REST-based cmdlets. In addition, few other hidden methods are available, even though none of them maps directly to an existing cmdlet (although they do provide functionality similar to that of existing cmdlets).

Another thing to note is that this is not part of the Graph API, and while certain parallels can be drawn, there are quite few differences you need to be aware of. We can also argue that this is not a “standard” RESTful API either. In other words, while you can expect some familiarity, do not get surprised when things don’t work as expected, or at all. For example, some of the methods might treat parameter values as case-sensitive.

Handling authentication

In order to perform any operation against the Exchange Online REST API, you need to handle authentication. There are few ways you can go about this. First, you can the built-in logic in the Exchange Online PowerShell module, which supports both delegate and application permissions, as described in the official documentation. Alternatively, you can connect by passing an access token obtained for the Exchange Online resource by any of the supported MSAL methods. You can either leverage your own application or the built-in Microsoft Exchange REST API Based PowerShell one, with appID of fb78d390-0c51-40cd-8e17-fdbfab77341b. Since you are reading this article, you are likely not interested in leveraging PowerShell either directly or indirectly, so you likely won’t be using any of the aforementioned methods.

Instead, the assumption is that you want to leverage your own Entra ID application. To enable that, you will need to add the corresponding permissions both in terms of resource scopes (roles), and permissions assigned to the service principal object. As those steps are now diligently documented in the official support article, as well in our previous articles, we will skip most of the details. The TL;DR version is you need the Exchange.Manage or Exchange.ManageAsApp scope, depending on which model you are using, combined with an Exchange Online role. Do make sure to follow the full set of instructions, as a common issue is neglecting one or more of the steps, which will result in various errors later on.

With the above in mind, here is how to obtain an access token in the context of a given user (using the delegate permissions model). For this scenario, it is much easier to obtain an access token by leveraging the built-in MSAL methods. This requires the corresponding binaries to be present on the local device, which shouldn’t be a problem nowadays, as every Microsoft 365 related PowerShell module leverages them. Alternatively, you can install the corresponding NuGet package.

Keep in mind that the binaries and available methods differ from platform to platform, so make sure to replace the code below with something that works on your end. You can leverage any recent Microsoft 365 or Entra-related module, just don’t mix and match different modules (or even versions), as conflicts can happen. Here we use the ExO module, since at the time of writing the Graph SDK for PowerShell (2.32.0) uses a bit outdated version on the MSAL binaries (4.67.2).

#Load the MSAL binaries
Get-Module ExchangeOnlineManagement -ListAvailable | select -First 1 | select -ExpandProperty FileList | % {
    if ($_ -match "Microsoft.Identity.Client.dll|Microsoft.IdentityModel.Abstractions.dll") {
        Add-Type -Path $_
    }
}

The example below shows how you can obtain an access token via MSAL’s AcquireTokenInteractive method. In terms of the actual authentication, we need a public client application id, which can either be the built-in multi-tenant app Microsoft uses (with appId value of fb78d390-0c51-40cd-8e17-fdbfab77341b) or a custom one. For the latter scenario, make sure the Exchange.Manage scope has been consented to. The resource we will be requesting token for is Office 365 Exchange Online, with an id of 00000002-0000-0ff1-ce00-000000000000. We can use the default scope, in this case represented by https://outlook.office365.com/.default.

#Request a token via the built-in "Microsoft Exchange REST API Based Powershell" app
$app = [Microsoft.Identity.Client.PublicClientApplicationBuilder]::Create("fb78d390-0c51-40cd-8e17-fdbfab77341b").WithDefaultRedirectUri().Build()

$Scopes = New-Object System.Collections.Generic.List[string]
$Scope = "https://outlook.office365.com/.default"
$Scopes.Add($Scope)

$token = $app.AcquireTokenInteractive($Scopes).WithLoginHint("user@domain.com").ExecuteAsync().Result


#Request a token for a custom app
$app = [Microsoft.Identity.Client.PublicClientApplicationBuilder]::Create("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx").WithRedirectUri("http://localhost").Build()

$Scopes = New-Object System.Collections.Generic.List[string]
$Scope = "https://outlook.office365.com/.default"
$Scopes.Add($Scope)

$token = $app.AcquireTokenInteractive($Scopes).ExecuteAsync().Result

There are a multitude of additional scenarios we can talk about here, but that’s not the purpose of the current article. Simply make sure you obtain a valid token for the resource Office 365 Exchange Online, the method/details do not matter. Even the old ADAL methods still work, if you want to use them for some reason. Managed Identities are also supported, though do remember that they only support application permissions.

Speaking of which, here is how to obtain an access token in the context of an application (aka application permissions model or client credentials flow). This method is usually preferred for any non-interactive scenarios, but not every cmdlet supports it, as detailed in the official documentation.

#Obtain a token via client secret (application permissions)
$app =  [Microsoft.Identity.Client.ConfidentialClientApplicationBuilder]::Create("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx").WithClientSecret("secret_goes_here").WithTenantId("tenant.onmicrosoft.com").Build()

$Scopes = New-Object System.Collections.Generic.List[string]
$Scope = "https://outlook.office365.com/.default"
$Scopes.Add($Scope)

$token = $app.AcquireTokenForClient($Scopes).ExecuteAsync().Result

In contrast with the delegate permission scenario we covered above, the role needed here is the Exchange.ManageAsApp one, which you can of course find under application permissions. Don’t forget to also assign an appropriate Exchange Online (or Enrta ID) role to the service principal, as a very limited set of cmdlets will be available otherwise. This is in fact one of the main differences with the Graph API, where application permissions usually grant unrestricted, tenant-wide access.

Just for kicks, here’s also how to get the token out of any currently active Exchange Online PowerShell sessions:

$context = [Microsoft.Exchange.Management.ExoPowershellSnapin.ConnectionContextFactory]::GetAllConnectionContexts()
$context.PowerShellTokenInfo.AuthorizationHeader
$context.GetAuthHeader()

Using the InvokeCommand method

Now that we have a valid access token, we can execute cmdlets by means of leveraging the InvokeCommand method. For this, we would a JSON payload detailing the cmdlet and any desired parameters. Apart from that, few additional headers are required for successful execution, which is another difference in behavior compared to the Graph API. To be more specific, a request must include at least the X-AnchorMailbox header, which will help route it to the correct server on the backend. For delegate permission scenarios, set the value to the UPN of the user in question. For application permissions ones, the UPN of a system mailbox is used instead, which is helpful as you can hardcode it. Don’t forget to replace the tenant Id value. You can also use the default tenant domain, tenant.onmicrosoft.com

SystemMailbox{bb558c35-97f1-4cb9-8ff7-d53741dc928c}@xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

Few additional headers can be used to change the type of output (X-ResponseFormat, with values of either json or clixml) or the maximum page size (prefer with value of odata.maxpagesize=int, where int can range up to 1000). For our purposes we don’t need to go into this level of detail, as none of these additional headers are mandatory. So, we can use something like this for our requests:

#Set the headers for request using delegate permissions
$authHeader = @{
     'Content-Type'='application/json'
     'Authorization'="Bearer $($token.AccessToken)"
     'X-ResponseFormat'= "json"
     'X-AnchorMailbox' = "UPN:user@domain.com"
}

#Set the headers for request using application permissions
$authHeader = @{
     'Content-Type'='application/json'
     'Authorization'="Bearer $($token.AccessToken)"
     'X-ResponseFormat'= "json"
     'X-AnchorMailbox' = "UPN:SystemMailbox{bb558c35-97f1-4cb9-8ff7-d53741dc928c}@tenant.onmicrosoft.com"
}

In terms of the payload, things can range from very simple to quite complex, depending on the cmdlets you plan to use. For some scenarios you might even have to resort to capturing the raw cmdlet execution via Fiddler in order to check the proper syntax. As mentioned above, at the end of the day this remains undocumented and unsupported method, but if the cmdlet is running as expected via the ExO module, you will be able to get it working via InvokeCommand as well!

Below are some examples of the JSON payload (well, technically a PowerShell hash-table that we later convert to JSON). The first example will execute the Get-Mailbox cmdlet against specific Identity value, in other words it will check whether a given mailbox exists. The second example is a bit more complicated. It adds an email address to the existing set of EmailAddresses for the mailbox via the Set-Mailbox cmdlet. Not only it requires two parameters to run but for one of them we need to specify the data type.

#Payload for the Get-Mailbox cmdlet
$body = @{
     CmdletInput = @{
          CmdletName="Get-Mailbox"
          Parameters=@{Identity="vasil"}
     }
} | ConvertTo-Json -Depth 5


#Payload for the Set-Mailbox cmdlet with a hash-table input parameter
$body = @{
    CmdletInput = @{
        CmdletName="Set-Mailbox"
        Parameters=@{Identity="MailboxName";EmailAddresses=@{add="user@domain.com";"@odata.type"="#Exchange.GenericHashTable"}}
    }
} | ConvertTo-Json -Depth 5

At long last, we are ready to issue our request. One thing we forgot to mention earlier is that the endpoint we are going to use is tenant-specific, you must provide the tenant Id (or the default domain) as part of it to ensure proper execution. Other than that, this is just a standard POST request with the JSON payload and the configured headers.

$uri = "https://outlook.office365.com/adminapi/beta/tenant.onmicrosoft.com/InvokeCommand"
$res = Invoke-WebRequest -Method POST -Uri $uri -Headers $authHeader -Body $body
($res.Content | ConvertFrom-Json).Value

The output, if any, will be stored within the $res variable in the requested format (json in the example above). Examining it, you might notice that some properties are returned along with an accompanying @data.type and/or @odata.type siblings to help with serialization (i.e. to tell you the type of data stored within the property value). For example:

ExchangeGuid@data.type              : System.Guid
ExchangeGuid@odata.type             : #Guid
ExchangeGuid                        : e61c9754-f72d-4b79-a45e-0d3279ee5b3f

As a tip, you can use the $select operator to have the server return specific properties only:

$uri = 'https://outlook.office365.com/adminapi/beta/tenant.onmicrosoft.com/InvokeCommand?$select=Name,PrimarySmtpAddress,EmailAddresses'
$res = Invoke-WebRequest -Method POST -Uri $uri -Headers $authHeader -Body $body
($res.Content | ConvertFrom-Json).Value[0]

EmailAddresses@odata.type EmailAddresses                                                                             PrimarySmtpAddress                            Name
------------------------- --------------                                                                             ------------------                            ----
#Collection(String)       {smtp:2022test@tenant.onmicrosoft.com, SMTP:G45acdcf8ecf7490d9dff6737af48ea4f@domain.com} G45acdcf8ecf7490d9dff6737af48ea4f@domain.com 2022test

Error handling can be a bit tricky, as you might need to parse the full object. Here is an example of an error you get when the requested object is not found:

                                                                                                      
{
  "error": {
    "code": "NotFound",
    "message": "Error executing cmdlet",
    "details": [
      {
        "code": "Context",
        "target": "",
        "message": "Ex6F9304|Microsoft.Exchange.Configuration.Tasks.ManagementObjectNotFoundException|The operation couldn\u0027t be performed because object \u0027sharedddddd\u0027 couldn\u0027t be found on \u0027VE1PR03A01DC001.EURPR03A001.prod.outlook.com\u0027."
      }
    ],
    "innererror": {
      "message": "Error executing cmdlet",
      "type": "Microsoft.Exchange.Admin.OData.Core.ODataServiceException",
      "stacktrace": "",
      "internalexception": {
        "message": "Exception of type \u0027Microsoft.Exchange.Management.PSDirectInvoke.DirectInvokeCmdletExecutionException\u0027 was thrown.",
        "type": "Microsoft.Exchange.Management.PSDirectInvoke.DirectInvokeCmdletExecutionException",
        "stacktrace": ""
      }
    },
    "adminapi.warnings@odata.type": "#Collection(String)",
    "@adminapi.warnings": []
  }
}

There are a lot more examples we can explore, but the article is long enough as it is. The good news is that you can always fall back on the Exchange Online PowerShell module’s built-in cmdlets in case you run into trouble with the InvokeCommand method. Configure Fiddler to capture PowerShell traffic, run the cmdlet, examine the capture, profit!

In part 2 of this article, we will examine the methods used by the REST cmdlets, as well as the plethora of “hidden” methods you can access via the same endpoints. As the Security and Compliance Center PowerShell is also based on the ExO backend, we will cover the cmdlets supported by it as well. And even more interesting stuff!

1 thought on “All you need to know about Exchange Online Admin API (or how to run cmdlets without PowerShell)

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Discover more from Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading