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
コメント