Dynamics 365 Business Central: How to use OAuth 2.0 in AL with SecretText (Using codeunit 501 OAuth2)

Dynamics 365 Business Central

Hi, Readers.
Today I would like to briefly talk about how to use OAuth 2.0 in AL with SecretText.

AL is the programming language that is used for manipulating data such as retrieving, inserting, and modifying records in a Dynamics 365 Business Central database. More details: Programming in AL

As you might know, Web Service Access Keys (Basic Auth) for Business Central Online has been removed with Business Central 2022 release wave 1. We are gradually transitioning from Basic Auth to OAuth 2.0. We briefly talked about how to use OAuth 2.0 to connect Business Central APIs and Web Service in Postman and Power Automate. More details: Using OAuth to connect Business Central APIs and Web Service in Postman and Using OAuth 2.0 to connect Business Central APIs and Web Services in Power Automate – OAuth in HTTP action.

There are two reasons why I want to discuss this. One is that starting from Business Central 2023 wave 2 (BC23), Microsoft released a new SecretText Data type, which is also supported in OAuth 2.0 authentication. More details: Business Central 2023 wave 2 (BC23): Use SecretText type to protect credentials and sensitive textual values from being revealed (New SecretText type)

The second is that last month I was asked if it was possible to get a list of all current environments from one BC environment, I looked around and there’s no easy way to do it except through the API. So in this post, I want to briefly share how to do it in AL.

Setting up OAuth 2.0

This part of the setup is the same as in Using OAuth to connect Business Central APIs and Web Service in Postman. If you have already completed these settings, you can skip this chapter.

1. Access to Azure Portal.

2. Search for App registrations and then choose the related link.

3. Choose New registration to create a new app registration.

Enter the Name, select account type, then choose Register.

Created successfully.

4. Choose Authentication.

Choose Add a platform.

Choose Web.

Enter the redirect URl of the application, and then choose Configure.
For example: https://localhost:8080/login

5. Choose API permissions.

Choose Add a permission.

Find Dynamics 365 Business Central and click it.

You can choose the required permissions according to your situation.
For example:
Choose Delegated permissions.

Select the permissions, and then choose Add permissions.

Choose Add a permission again.

Click Dynamics 365 Business Central Central.

Choose Application permissions this time.

Select the permissions you need, then choose Add permissions.

Choose Grant admin consent for Contoso.

Choose Yes.

More details:

6. Choose Certificates & secrets.

Choose New Client secret.

Enter Description and select Expires, then choose Add.

The settings in Azure Portal is over.

The following is the information you can get.

Application (client) ID: b4fe1687-f1ab-4bfa-b494-0e2236ed50bd
Certificates & secrets value: huL8Q~edsQZ4pwyxka3f7.WUkoKNcPuqlOXv0bww
Directory (tenant) ID: 7e47da45-7f7d-448a-bd3d-1f4aa2ec8f62

Then we can do some simple tests in Postman. Let’s look at two simple examples

1. If we want to process data within the environment, we need to add the Application ID to the Microsoft Entra Applications page.

GET:

https://api.businesscentral.dynamics.com/v2.0/7e47da45-7f7d-448a-bd3d-1f4aa2ec8f62/Sandbox251/api/v2.0/companies

2. If we want to process data within BC admin center, we need to add the Application ID to the Business Central admin center -> Microsoft Entra Apps page.

GET

https://api.businesscentral.dynamics.com/admin/v2.3/applications/businesscentral/environments/

Using OAuth 2.0 in AL

Basic Information: Please replace with your

Application (client) ID: b4fe1687-f1ab-4bfa-b494-0e2236ed50bd
Certificates & secrets value: huL8Q~edsQZ4pwyxka3f7.WUkoKNcPuqlOXv0bww
Directory (tenant) ID: 7e47da45-7f7d-448a-bd3d-1f4aa2ec8f62

This time I will introduce a simple method using codeunit 501 OAuth2. This supports saving AccessToken as SecretText Data type, which will provide higher security.

codeunit 501 OAuth2: Contains methods supporting authentication via OAuth 2.0 protocol.

procedure AcquireTokenWithClientCredentials(ClientId: Text; ClientSecret: SecretText; OAuthAuthorityUrl: Text; RedirectURL: Text; Scopes: List of [Text]; var AccessToken: SecretText)

For example, very simple.

    procedure GetOAuthToken() AuthToken: SecretText
    var
        ClientID: Text;
        ClientSecret: Text;
        TenantID: Text;
        AccessTokenURL: Text;
        OAuth2: Codeunit OAuth2;
        Scopes: List of [Text];
    begin
        ClientID := 'b4fe1687-f1ab-4bfa-b494-0e2236ed50bd';
        ClientSecret := 'huL8Q~edsQZ4pwyxka3f7.WUkoKNcPuqlOXv0bww';
        TenantID := '7e47da45-7f7d-448a-bd3d-1f4aa2ec8f62';
        AccessTokenURL := 'https://login.microsoftonline.com/' + TenantID + '/oauth2/v2.0/token';
        Scopes.Add('https://api.businesscentral.dynamics.com/.default');
        if not OAuth2.AcquireTokenWithClientCredentials(ClientID, ClientSecret, AccessTokenURL, '', Scopes, AuthToken) then
            Error('Failed to get access token from response\%1', GetLastErrorText());
    end;

PS:
1. The SecretText Data type cannot be displayed on the UI via the Message(Text [, Any,…]) Method.

Argument 1: cannot convert from ‘SecretText’ to ‘Text’ AL AL0133

2.

Method ‘AcquireTokenWithClientCredentials’ is marked for removal. Reason: Use AcquireTokenWithClientCredentials with SecretText data type for AccessToken.. Tag: 24.0. AL AL0432

Here is the template for calling the endpoint using OAuthToken. I updated it slightly to use the new SecretText Data type.

    procedure GetEnvironments()
    var
        HttpClient: HttpClient;
        HttpRequestMessage: HttpRequestMessage;
        HttpResponseMessage: HttpResponseMessage;
        Headers: HttpHeaders;
        AuthToken: SecretText;
        CallEndpoint: Text;
        ResponseText: Text;
    begin
        // Get OAuth token
        AuthToken := GetOAuthToken();

        if AuthToken.IsEmpty() then
            Error('Failed to obtain access token.');

        CallEndpoint := 'https://api.businesscentral.dynamics.com/admin/v2.3/applications/businesscentral/environments/';
        // Initialize the HTTP request
        HttpRequestMessage.SetRequestUri(CallEndpoint);
        HttpRequestMessage.Method := 'GET';
        HttpRequestMessage.GetHeaders(Headers);
        Headers.Add('Authorization', SecretStrSubstNo('Bearer %1', AuthToken));

        // Send the HTTP request
        if HttpClient.Send(HttpRequestMessage, HttpResponseMessage) then begin
            // Log the status code for debugging
            //Message('HTTP Status Code: %1', HttpResponseMessage.HttpStatusCode());

            if HttpResponseMessage.IsSuccessStatusCode() then begin
                HttpResponseMessage.Content.ReadAs(ResponseText);
                Message(ResponseText);
            end else begin
                // Here's where the error is reported
                HttpResponseMessage.Content.ReadAs(ResponseText);
                Error('Failed to get: %1 %2', HttpResponseMessage.HttpStatusCode(), ResponseText);
            end;
        end else
            Error('Failed to send HTTP request');
    end;

Test on the page (UI): All environments (This time I simply displayed all the data as text, you can pass it into a JsonObject to process it)

Another test, all companies

Source code: Github (Please note that the source code is for reference only, you can improve it according to your own needs)

pageextension 50100 CustomerListExt extends "Customer List"
{
    actions
    {
        addafter("Sent Emails")
        {
            action(GetEnvironments)
            {
                Caption = 'Get Environments';
                ApplicationArea = All;
                Promoted = true;
                PromotedCategory = Process;
                PromotedIsBig = true;
                Image = GetActionMessages;

                trigger OnAction()
                var
                    BCEnvironmentHandler: Codeunit BCEnvironmentHandler;
                begin
                    BCEnvironmentHandler.Run();
                end;
            }
        }
    }
}

codeunit 50120 BCEnvironmentHandler
{
    trigger OnRun()
    begin
        GetEnvironments();
    end;

    procedure GetEnvironments()
    var
        HttpClient: HttpClient;
        HttpRequestMessage: HttpRequestMessage;
        HttpResponseMessage: HttpResponseMessage;
        Headers: HttpHeaders;
        AuthToken: SecretText;
        CallEndpoint: Text;
        ResponseText: Text;
    begin
        // Get OAuth token
        AuthToken := GetOAuthToken();

        if AuthToken.IsEmpty() then
            Error('Failed to obtain access token.');

        CallEndpoint := 'https://api.businesscentral.dynamics.com/v2.0/7e47da45-7f7d-448a-bd3d-1f4aa2ec8f62/Sandbox251/api/v2.0/companies';
        // Initialize the HTTP request
        HttpRequestMessage.SetRequestUri(CallEndpoint);
        HttpRequestMessage.Method := 'GET';
        HttpRequestMessage.GetHeaders(Headers);
        Headers.Add('Authorization', SecretStrSubstNo('Bearer %1', AuthToken));

        // Send the HTTP request
        if HttpClient.Send(HttpRequestMessage, HttpResponseMessage) then begin
            // Log the status code for debugging
            //Message('HTTP Status Code: %1', HttpResponseMessage.HttpStatusCode());

            if HttpResponseMessage.IsSuccessStatusCode() then begin
                HttpResponseMessage.Content.ReadAs(ResponseText);
                Message(ResponseText);
            end else begin
                // Here's where the error is reported
                HttpResponseMessage.Content.ReadAs(ResponseText);
                Error('Failed to get: %1 %2', HttpResponseMessage.HttpStatusCode(), ResponseText);
            end;
        end else
            Error('Failed to send HTTP request');
    end;

    procedure GetOAuthToken() AuthToken: SecretText
    var
        ClientID: Text;
        ClientSecret: Text;
        TenantID: Text;
        AccessTokenURL: Text;
        OAuth2: Codeunit OAuth2;
        Scopes: List of [Text];
    begin
        ClientID := 'b4fe1687-f1ab-4bfa-b494-0e2236ed50bd';
        ClientSecret := 'huL8Q~edsQZ4pwyxka3f7.WUkoKNcPuqlOXv0bww';
        TenantID := '7e47da45-7f7d-448a-bd3d-1f4aa2ec8f62';
        AccessTokenURL := 'https://login.microsoftonline.com/' + TenantID + '/oauth2/v2.0/token';
        Scopes.Add('https://api.businesscentral.dynamics.com/.default');
        if not OAuth2.AcquireTokenWithClientCredentials(ClientID, ClientSecret, AccessTokenURL, '', Scopes, AuthToken) then
            Error('Failed to get access token from response\%1', GetLastErrorText());
    end;
}

We can also upgrade it a little bit and make a more popular page.

Test video:

Source code: Github (Please note that the source code is for reference only, you can improve it according to your own needs)

page 50100 "Test OAuth 2.0 in AL"
{
    Caption = 'Test OAuth 2.0 in AL';
    PageType = Card;
    ApplicationArea = All;
    UsageCategory = Administration;

    layout
    {
        area(Content)
        {
            group(Info)
            {
                field(ClientID; ClientID)
                {
                    ApplicationArea = All;
                    Caption = 'Client ID';
                    ToolTip = 'The client ID of the Azure AD application';
                }
                field(ClientSecret; ClientSecret)
                {
                    ApplicationArea = All;
                    Caption = 'Client Secret';
                    ToolTip = 'The client secret of the Azure AD application';
                }
                field(TenantID; TenantID)
                {
                    ApplicationArea = All;
                    Caption = 'Tenant ID';
                    ToolTip = 'The tenant ID of the Azure AD application';
                }
                field(CallEndpoint; CallEndpoint)
                {
                    ApplicationArea = All;
                    Caption = 'Call Endpoint';
                    ToolTip = 'The endpoint to call';

                    trigger OnValidate()
                    begin
                        Result := '';
                    end;
                }
            }
            group(Results)
            {
                field(Result; Result)
                {
                    ApplicationArea = All;
                    MultiLine = true;
                    Editable = false;
                    Caption = 'Result';
                    ShowCaption = false;
                    ToolTip = 'The result of the call';
                }
            }
        }
    }

    actions
    {
        area(Processing)
        {
            action(GetData)
            {
                Caption = 'Get Data';
                ApplicationArea = All;
                Promoted = true;
                PromotedCategory = Process;
                PromotedIsBig = true;
                Image = GetActionMessages;

                trigger OnAction()
                var
                    OAuth2TestInAL: Codeunit OAuth2TestInAL;
                begin
                    OAuth2TestInAL.GetData(CallEndpoint, ClientID, ClientSecret, TenantID, Result);
                    CurrPage.Update();
                end;
            }
        }
    }

    var
        CallEndpoint: Text;
        ClientID: Text;
        ClientSecret: Text;
        TenantID: Text;
        Result: Text;
}

codeunit 50120 OAuth2TestInAL
{
    procedure GetData(CallEndpoint: Text; ClientID: Text; ClientSecret: Text; TenantID: Text; var Result: Text)
    var
        HttpClient: HttpClient;
        HttpRequestMessage: HttpRequestMessage;
        HttpResponseMessage: HttpResponseMessage;
        Headers: HttpHeaders;
        AuthToken: SecretText;
        ResponseText: Text;
    begin
        // Get OAuth token
        AuthToken := GetOAuthToken(ClientID, ClientSecret, TenantID);

        if AuthToken.IsEmpty() then
            Error('Failed to obtain access token.');

        // Initialize the HTTP request
        HttpRequestMessage.SetRequestUri(CallEndpoint);
        HttpRequestMessage.Method := 'GET';
        HttpRequestMessage.GetHeaders(Headers);
        Headers.Add('Authorization', SecretStrSubstNo('Bearer %1', AuthToken));

        // Send the HTTP request
        if HttpClient.Send(HttpRequestMessage, HttpResponseMessage) then begin
            // Log the status code for debugging
            //Message('HTTP Status Code: %1', HttpResponseMessage.HttpStatusCode());

            if HttpResponseMessage.IsSuccessStatusCode() then begin
                HttpResponseMessage.Content.ReadAs(ResponseText);
                Result := ResponseText;
            end else begin
                // Here's where the error is reported
                HttpResponseMessage.Content.ReadAs(ResponseText);
                Error('Failed to get: %1 %2', HttpResponseMessage.HttpStatusCode(), ResponseText);
            end;
        end else
            Error('Failed to send HTTP request');
    end;

    procedure GetOAuthToken(ClientID: Text; ClientSecret: Text; TenantID: Text) AuthToken: SecretText
    var
        AccessTokenURL: Text;
        OAuth2: Codeunit OAuth2;
        Scopes: List of [Text];
    begin
        AccessTokenURL := 'https://login.microsoftonline.com/' + TenantID + '/oauth2/v2.0/token';
        Scopes.Add('https://api.businesscentral.dynamics.com/.default');
        if not OAuth2.AcquireTokenWithClientCredentials(ClientID, ClientSecret, AccessTokenURL, '', Scopes, AuthToken) then
            Error('Failed to get access token from response\%1', GetLastErrorText());
    end;
}

Finally, there is actually a general method that does not use codeunit 501 OAuth2, although I personally do not recommend it.
For exmaple,

Source code: Github (Please note that the source code is for reference only, you can improve it according to your own needs)

pageextension 50100 CustomerListExt extends "Customer List"
{
    actions
    {
        addafter("Sent Emails")
        {
            action(GetOAuthToken)
            {
                Caption = 'Get OAuth Token';
                ApplicationArea = All;
                Promoted = true;
                PromotedCategory = Process;
                PromotedIsBig = true;
                Image = GetActionMessages;

                trigger OnAction()
                var
                    BCEnvironmentHandler: Codeunit OAuth2TestInAL;
                begin
                    BCEnvironmentHandler.GetOAuthToken();
                end;
            }
        }
    }
}

codeunit 50120 OAuth2TestInAL
{
    procedure GetOAuthToken() AuthToken: Text
        var
            HttpClient: HttpClient;
            HttpRequestMessage: HttpRequestMessage;
            HttpResponseMessage: HttpResponseMessage;
            HttpContent: HttpContent;
            Headers: HttpHeaders;
            ResponseText: Text;
            JsonToken: JsonToken;
            JsonResponse: JsonObject;
            JsonValue: JsonValue;
            ClientID: Text;
            ClientSecret: Text;
            TenantID: Text;
            TokenEndpoint: Text;
            Content: Text;
        begin
            // Get client ID, client secret, and tenant ID from setup
            ClientID := 'b4fe1687-f1ab-4bfa-b494-0e2236ed50bd';
            ClientSecret := 'huL8Q~edsQZ4pwyxka3f7.WUkoKNcPuqlOXv0bww';
            TenantID := '7e47da45-7f7d-448a-bd3d-1f4aa2ec8f62';

            // Define the token endpoint
            TokenEndpoint := 'https://login.microsoftonline.com/' + TenantID + '/oauth2/v2.0/token';

            // Define the request content
            Content := 'grant_type=client_credentials&client_id=' + ClientID + '&client_secret=' + ClientSecret + '&scope=https://graph.microsoft.com/.default';

            // Initialize the HTTP request
            HttpRequestMessage.SetRequestUri(TokenEndpoint);
            HttpRequestMessage.Method := 'POST';

            // Initialize the HTTP content
            HttpContent.WriteFrom(Content);
            HttpContent.GetHeaders(Headers);
            Headers.Remove('Content-Type');
            Headers.Add('Content-Type', 'application/x-www-form-urlencoded');
            HttpRequestMessage.Content := HttpContent;

            // Send the HTTP request
            if HttpClient.Send(HttpRequestMessage, HttpResponseMessage) then begin
                if HttpResponseMessage.IsSuccessStatusCode() then begin
                    HttpResponseMessage.Content.ReadAs(ResponseText);
                    JsonResponse.ReadFrom(ResponseText);
                    if JsonResponse.Get('access_token', JsonToken) then begin
                        JsonValue := JsonToken.AsValue();
                        AuthToken := JsonValue.AsText();
                        Message(AuthToken);
                        exit(AuthToken);
                    end else
                        Error('Failed to get access token from response');
                end else
                    Error('Failed to get access token: %1', HttpResponseMessage.HttpStatusCode());
            end else
                Error('Failed to send HTTP request for access token');
        end;
}

END

Hope this will help.

Thanks for reading.

ZHU

コメント

Copied title and URL