Service to service authentication in Business Central 18.3 – How to test (REST Client & PowerShell)

13 Jul

In the previous blog posts I’ve described the usage scenarios around OAuth client credentials flow for Business Central and how to set it up. The next step is to test the APIs and with OAuth authentication to see if it works properly. In this post, I want to focus on the REST Client extension for VS Code and PowerShell. In the next posts, I will also cover Postman, Insomnia and C#.

Version 18.3 has been officially released and is available for the SaaS platform. So you can just go ahead and test it right away!

The official documentation can be found here, which includes similar information and also a code samples for the REST Client. I’ll just try to clarify some of the steps and give some explanation about the different parameters and why they must have specific values.

Before we dive into the code, it’s important to understand the key parameters and their values.

REST Client

The REST Client allows us to write the raw HTTP requests. The very, very basic HTTP requests to get the OAuth access token and to call the Business central APIs. And if you understand these, then you will be able to translate them to any other language or to make use of the available libraries (as you will see below with the PowerShell example).

All passwords and secrets used in the examples are fake, replace them with your own values!

Getting the access token

The access token can be retrieved with a POST request to https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/token. The body of the request must contain the parameter grant_type=client_credentials plus the parameters as described in the table above.

One comment about the Tenant ID: You may have seen OAuth examples where the word common was used instead of tenant ID. The word common indicates that you are using a multitenant application and the logged-in user will determine the Azure AD tenant. But with the client credentials flow, there is no logged-in user, the application logs in by itself. It is a non-interactive authentication flow, and the application logs in as a service principal. The service principal represents the application account in an Azure AD. So, instead of using the word common, you need to specify which Azure AD tenant the application wants to log in to.

@clientid = 3870c15c-5700-4704-8b1b-e020052cc860
@clientsecret = ~FJRgS5q0YsAEefkW-_pA4ENJ_vIh-5RV9
@scope = https://api.businesscentral.dynamics.com/.default
@tenant = kauffmann.nl


###########################################################################
#    ____      _                                   _        _              
#   / ___| ___| |_    __ _  ___ ___ ___  ___ ___  | |_ ___ | | _____ _ __  
#  | |  _ / _ \ __|  / _` |/ __/ __/ _ \/ __/ __| | __/ _ \| |/ / _ \ '_ \ 
#  | |_| |  __/ |_  | (_| | (_| (_|  __/\__ \__ \ | || (_) |   <  __/ | | |
#   \____|\___|\__|  \__,_|\___\___\___||___/___/  \__\___/|_|\_\___|_| |_|
#
###########################################################################
# @name tokenrequest
POST https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id={{clientid}}
&client_secret={{clientsecret}}
&scope={{scope}}

###
@token = {{tokenrequest.response.body.access_token}}
###

The response should look like this:

HTTP/1.1 200 OK
Cache-Control: no-store, no-cache
Pragma: no-cache
Content-Type: application/json; charset=utf-8
Expires: -1
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
P3P: CP="DSP CUR OTPi IND OTRi ONL FIN"
x-ms-request-id: 2bdd9490-1b1c-4f3f-ace4-ad36c4fd4c04
x-ms-ests-server: 2.1.11829.9 - NCUS ProdSlices
Date: Mon, 12 Jul 2021 20:53:12 GMT
Connection: close
Content-Length: 1235

{
  "token_type": "Bearer",
  "expires_in": 3598,
  "ext_expires_in": 3598,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Im5PbzNaRHJPRFhFSz...."
}

Inspecting the token

The access token is a so-called Json Web Token. It consists of three parts, delimited by a dot. The first two parts are Base64 encoded and can be easily inspected with the website https://jwt.io. Just copy and paste the access token on this website to see the results.

Below is an example of a decoded access token. Note the roles property which has value API.ReadWrite.All. This indicates that the application has been granted this permission in the Azure AD.

{
  "aud": "https://api.businesscentral.dynamics.com",
  "iss": "https://sts.windows.net/5ffa1f42-fc0f-4f71-a789-2e225ce70d09/",
  "iat": 1626123761,
  "nbf": 1626123761,
  "exp": 1626127661,
  "aio": "E2ZgYGgO8g8RMDI59vJwtkH8cvetAA==",
  "appid": "3870c15c-5700-4704-8b1b-e020052cc860",
  "appidacr": "1",
  "idp": "https://sts.windows.net/5ffa1f43-fc0f-4f71-a789-2e215ce70d09/",
  "idtyp": "app",
  "oid": "92640508-f706-49db-bcc6-1cc58543ca48",
  "rh": "0.AQwAQx_6Xw_8cU-niS4hXOcNCVzBcDgAVwRHixvgIAUsyGAMAAA.",
  "roles": [
    "API.ReadWrite.All"
  ],
  "sub": "92640508-f706-49db-bcc6-1cc58543ca48",
  "tid": "5ffa1f42-fc0f-4f71-a789-2e225ce70d09",
  "uti": "V8dCG6Em5EWBDxyjel0-AA",
  "ver": "1.0"
}

Using the access token

With the resulting access token, we can call the Business Central API. The access token must be added to the Authorization header with the value Bearer <token>. Please note that in the code above the token was stored in a variable, using the feature of the REST client to give a request a name and then work with the response as a variable.

@baseurl = https://api.businesscentral.dynamics.com/v2.0/sandbox

#######################################################################
#    ____      _                                           _           
#   / ___| ___| |_    ___ ___  _ __ ___  _ __   __ _ _ __ (_) ___  ___ 
#  | |  _ / _ \ __|  / __/ _ \| '_ ` _ \| '_ \ / _` | '_ \| |/ _ \/ __|
#  | |_| |  __/ |_  | (_| (_) | | | | | | |_) | (_| | | | | |  __/\__ \
#   \____|\___|\__|  \___\___/|_| |_| |_| .__/ \__,_|_| |_|_|\___||___/
#                                       |_|                            
######################################################################
# @name companies
POST {{baseurl}}/api/v2.0/companies
Authorization: Bearer {{token}}

###
@companyid = {{companies.response.body.value[0].id}}


######################################################################
#    ____      _                    _                                
#   / ___| ___| |_    ___ _   _ ___| |_ ___  _ __ ___   ___ _ __ ___ 
#  | |  _ / _ \ __|  / __| | | / __| __/ _ \| '_ ` _ \ / _ \ '__/ __|
#  | |_| |  __/ |_  | (__| |_| \__ \ || (_) | | | | | |  __/ |  \__ \
#   \____|\___|\__|  \___|\__,_|___/\__\___/|_| |_| |_|\___|_|  |___/
#
######################################################################
GET {{baseurl}}/api/v2.0/companies({{companyid}})/customers
Authorization: Bearer {{token}}

If everything is correctly set up, then you will get the usual response.

Error messages

If the application does not have a service principal in the Azure AD (because it was not granted consent) then you will not receive an error message when you request the token. Instead, you will receive an access token with no permissions. Compare the access token below with the previous one, and note that it does not contain the roles property.

{
  "aud": "https://api.businesscentral.dynamics.com",
  "iss": "https://sts.windows.net/5ffa1f42-fc0f-4f71-a789-2e225ce70d09/",
  "iat": 1626123054,
  "nbf": 1626123054,
  "exp": 1626126954,
  "aio": "E2ZgYPDN3bhAgVnuuxGDlPE8HSNvAA==",
  "appid": "3870c15c-5700-4704-8b1b-e020052cc860",
  "appidacr": "1",
  "idp": "https://sts.windows.net/5ffa1f43-fc0f-4f71-a789-2e215ce70d09/",
  "idtyp": "app",
  "rh": "0.AQwAQx_6Xw_8cU-niS4hXOcNCVzBcDgAVwRHixvgIAUsyGAMAAA.",
  "tid": "5ffa1f42-fc0f-4f71-a789-2e225ce70d09",
  "uti": "hcjDPayjqUyOwYoldBjfAA",
  "ver": "1.0"
}

When you use this token to call the Business Central API, then you will get this error message. This means that the application account was not granted consent.

<error xmlns="http://docs.oasis-open.org/odata/ns/metadata">
  <code>Unauthorized</code>
  <message>The credentials provided are incorrect</message>
</error>

If the application has been granted consent, then it is still restricted by the assigned permissions in Business Central. In case the application makes a call that is not allowed, then the response will have status 400 Bad Request and the body contains the details. For example:

{
  "error": {
    "code": "Internal_ServerError",
    "message": "You do not have the following permissions on TableData Contact: Insert.\r\n\r\nTo view details about your permissions, see the Effective Permissions page. To report a problem, refer to the following server session ID: '1266'.  CorrelationId:  56a72e47-44db-4d8e-9608-826ca693bc0c."
  }
}

Automatically retrieve access token

Finally, I would like to mention a feature of the REST Client that allows to automatically retrieve the token, cache it and refresh it when it expires. This is done with a system variable $aadV2Token. It requires some configuration, but it’s extremely easy to set and forget. It works as follows:

The system variable should be referenced to as {{$aadV2Token appOnly}}. The option appOnly is needed for making use of the client credentials flow. The system variable requires some settings that must be provided as environment variables. The REST Client has a feature to set environment variables in the VS Code settings file (user or workspace level). You can group those variables under a name, together they are an environment. By creating multiple groups with similar variables in it, you can easily switch between those environments without manually changing the values or storing them in the source file.

Below is an example of a workspace settings file. Some of the aadV2 variables are defined in the $shared group. This makes the variables available to all other environments. The environment-specific variables are in their respective groups.

    "rest-client.environmentVariables": {
        "$shared": {
            "aadV2ClientId": "3870c15c-5700-4704-8b1b-e020052cc860",
            "aadV2ClientSecret": "YCA-K80B69XDY-4~e3M.zrn3P.BkUdj-4.",
            "aadV2AppUri": "https://api.businesscentral.dynamics.com/"
        },
        "cronus.company-demo": {
            "aadV2TenantId": "cronus.company",
            "baseurl": "https://api.businesscentral.dynamics.com/v2.0/demo"
        },
        "kauffmann.nl-sandbox": {
            "aadV2TenantId": "kauffmann.nl",
            "baseurl": "https://api.businesscentral.dynamics.com/v2.0/sandbox"
        }
    }

The names of these aadV2… variables must exactly be defined like the example. The aadV2AppUri should be the value https://api.businesscentral.dynamics.com/. The REST Client will automatically add .default after it. After configuring these settings, the code in the REST Client does not require a separate request to retrieve the access token. That is now automatically managed for us. Note that I’ve also added the variable baseurl to the environment settings.

To select the environment you can either open the command palette and choose Rest Client: Switch Environment or find the current environment in the right bottom corner and click on it.

#######################################################################
#    ____      _                                           _           
#   / ___| ___| |_    ___ ___  _ __ ___  _ __   __ _ _ __ (_) ___  ___ 
#  | |  _ / _ \ __|  / __/ _ \| '_ ` _ \| '_ \ / _` | '_ \| |/ _ \/ __|
#  | |_| |  __/ |_  | (_| (_) | | | | | | |_) | (_| | | | | |  __/\__ \
#   \____|\___|\__|  \___\___/|_| |_| |_| .__/ \__,_|_| |_|_|\___||___/
#                                       |_|                            
######################################################################
# @name companies
get {{baseurl}}/api/v2.0/companies
Authorization: Bearer {{$aadV2Token appOnly}}

###
@companyid = {{companies.response.body.value[0].id}}


######################################################################
#    ____      _                    _                                
#   / ___| ___| |_    ___ _   _ ___| |_ ___  _ __ ___   ___ _ __ ___ 
#  | |  _ / _ \ __|  / __| | | / __| __/ _ \| '_ ` _ \ / _ \ '__/ __|
#  | |_| |  __/ |_  | (__| |_| \__ \ || (_) | | | | | |  __/ |  \__ \
#   \____|\___|\__|  \___|\__,_|___/\__\___/|_| |_| |_|\___|_|  |___/
#
######################################################################
get {{baseurl}}/api/v2.0/companies({{companyid}})/customers
Authorization: Bearer {{$aadV2Token appOnly}}

Tip: if you start typing {{$aadV2Token}} and do not add appOnly, then you will get popups in VS Code to copy a code and login in a browser. Just click cancel and add the option appOnly to get rid of these popups.

PowerShell

The raw request from the REST Client could be easily translated into PowerShell as follows:

#########################
# PowerShell example
#########################

$clientid     = "3870c15c-5700-4704-8b1b-e020052cc860"
$clientsecret = "~FJRgS5q0YsAEefkW-_pA4ENJ_vIh-5RV9"
$scope        = "https://api.businesscentral.dynamics.com/.default"
$tenant       = "kauffmann.nl"
$environment  = "sandbox"
$baseurl      = "https://api.businesscentral.dynamics.com/v2.0/$environment"

# Get access token
$body = @{grant_type="client_credentials";scope=$scope;client_id=$ClientID;client_secret=$ClientSecret}
$oauth = Invoke-RestMethod -Method Post -Uri $("https://login.microsoftonline.com/$tenant/oauth2/v2.0/token") -Body $body

# Get companies
$companies = Invoke-RestMethod `
             -Method Get `
             -Uri $("$baseurl/api/v2.0/companies") `
             -Headers @{Authorization='Bearer ' + $oauth.access_token}

$companyid = $companies.value[0].id

# Get customers
$customers = Invoke-RestMethod `
             -Method Get `
             -Uri $("$baseurl/api/v2.0/companies($companyid)/customers") `
             -Headers @{Authorization='Bearer ' + $oauth.access_token}

The script above needs to handle the expiration of the token itself, which happens in 60 minutes. Most probably this results in getting a new token for every time the script runs. It’s recommended to install the MSAL.PS module. This PowerShell module has a function Get-MsalToken which handles the call to Azure to acquire the token and it uses a cache to securely safe and to reuse the token until it expires. To install the module run this command:

Install-Module -name MSAL.PS -Force -AcceptLicense

Now we can simplify the script by replacing the Invoke-RestMethod to get the access token with the command Get-MsalToken.

##############################
# PowerShell example with MSAL
##############################

$clientid     = "3870c15c-5700-4704-8b1b-e020052cc860"
$clientsecret = "~FJRgS5q0YsAEefkW-_pA4ENJ_vIh-5RV9"
$scope        = "https://api.businesscentral.dynamics.com/.default"
$tenant       = "kauffmann.nl"
$environment  = "sandbox"
$baseurl      = "https://api.businesscentral.dynamics.com/v2.0/$environment"

# Get access token
$token = Get-MsalToken `
         -ClientId $clientid `
         -TenantId $tenant `
         -Scopes $scope `
         -ClientSecret (ConvertTo-SecureString -String $clientsecret -AsPlainText -Force)

# Get companies
$companies = Invoke-RestMethod `
             -Method Get `
             -Uri $("$baseurl/api/v2.0/companies") `
             -Headers @{Authorization='Bearer ' + $token.AccessToken}

$companyid = $companies.value[0].id

# Get customers
$customers = Invoke-RestMethod `
             -Method Get `
             -Uri $("$baseurl/api/v2.0/companies($companyid)/customers") `
             -Headers @{Authorization='Bearer ' + $oauth.access_token}

If you are using PowerShell Core, then the script can be simplified further because of some new parameters for authentication. Instead of using the Headers parameter on Invoke-RestMethod, we can use the Authentication and Token parameter. Whereas the Token parameter is a secure string, created from the token that was retrieved with the Get-MsalToken command.

##############################
# PowerShell example with MSAL
##############################

$clientid     = "3870c15c-5700-4704-8b1b-e020052cc860"
$clientsecret = "~FJRgS5q0YsAEefkW-_pA4ENJ_vIh-5RV9"
$scope        = "https://api.businesscentral.dynamics.com/.default"
$tenant       = "kauffmann.nl"
$environment  = "sandbox"
$baseurl      = "https://api.businesscentral.dynamics.com/v2.0/$environment"

# Get access token
$token = Get-MsalToken `
         -ClientId $clientid `
         -TenantId $tenant `
         -Scopes $scope `
         -ClientSecret (ConvertTo-SecureString -String $clientsecret -AsPlainText -Force)

$secureToken = ConvertTo-SecureString -String $token.AccessToken -AsPlainText -Force

# Get companies
$companies = Invoke-RestMethod `
             -Method Get `
             -Uri $("$baseurl/api/v2.0/companies") `
             -Authentication OAuth `
             -Token $secureToken

$companyid = $companies.value[0].id

# Get customers
$customers = Invoke-RestMethod `
             -Method Get `
             -Uri $("$baseurl/api/v2.0/companies($companyid)/customers") `
             -Authentication OAuth `
             -Token $secureToken

The next blog post will discuss how to use Postman and Insomnia to get the OAuth token for Business Central. Stay tuned!

3 thoughts on “Service to service authentication in Business Central 18.3 – How to test (REST Client & PowerShell)

  1. Thank you for your articles.

    I am looking forward for using S2S authentication with Postman. I unable to use “Client Credentials” although I have received successfully authorization token and AAD App has admin consent… I use BC18.3 in Cloud.

    I see 401 Unauthorized (The credentials provided are incorrect)… :-/

  2. Hm.

    I finally solved my issue with invalid authorization token via Postman.
    Token never contains “Roles”.
    It was caused because resource not specified in token request.

    I used x-www-form-urlencoded parameter resource = https://api.businesscentral.dynamics.com in token request… And… It works!

Leave a Reply

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