OAuth 2.0 Client Credentials and Authorization Code flows for Business Central
—
—
Does it follow best practices?
Impact
—
No eval scenarios have been run
—
The risk profile of this skill
GetAccessToken() returns valid token in sandboxToken Expiry field), telemetry logs operationsNote: SecretText.Unwrap() blocked in SaaS. Use Text + [NonDebuggable].
Ask user:
Create a setup table with these fields:
table <ID> "<PREFIX> OAuth Setup"
{
DataClassification = CustomerContent;
fields
{
field(1; "Primary Key"; Code[10]) { }
field(10; "Client ID"; Text[100]) { }
field(11; "Tenant ID"; Text[100]) { }
field(12; "Token Endpoint"; Text[250]) { }
field(13; Scope; Text[250]) { }
field(20; "Token Expiry"; DateTime) { Editable = false; }
field(21; Enabled; Boolean) { }
}
keys
{
key(PK; "Primary Key") { Clustered = true; }
}
}Use Isolated Storage for client_secret. Pattern works for SaaS and OnPrem:
codeunit <ID> "<PREFIX> OAuth Secrets Mgt"
{
Access = Internal;
var
SecretKeyLbl: Label 'OAuthClientSecret', Locked = true;
[NonDebuggable]
procedure SetClientSecret(SecretValue: Text)
begin
if SecretValue = '' then
IsolatedStorage.Delete(SecretKeyLbl, DataScope::Company)
else
IsolatedStorage.Set(SecretKeyLbl, SecretValue, DataScope::Company);
end;
[NonDebuggable]
procedure GetClientSecret(): Text
var
SecretValue: Text;
begin
if IsolatedStorage.Get(SecretKeyLbl, DataScope::Company, SecretValue) then
exit(SecretValue);
exit('');
end;
procedure HasClientSecret(): Boolean
begin
exit(IsolatedStorage.Contains(SecretKeyLbl, DataScope::Company));
end;
}Use Codeunit 501 "OAuth2" (system codeunit):
codeunit <ID> "<PREFIX> OAuth Token Mgt"
{
Access = Internal;
var
Setup: Record "<PREFIX> OAuth Setup";
SecretsMgt: Codeunit "<PREFIX> OAuth Secrets Mgt";
[NonDebuggable]
procedure GetAccessToken(): SecretText
var
OAuth2: Codeunit OAuth2;
AccessToken: SecretText;
Scopes: List of [Text];
begin
Setup.Get();
Setup.TestField(Enabled);
Setup.TestField("Client ID");
Setup.TestField("Token Endpoint");
if IsTokenValid() then
exit(GetCachedToken());
Scopes.Add(Setup.Scope);
if not OAuth2.AcquireTokenWithClientCredentials(
Setup."Client ID",
SecretsMgt.GetClientSecret(),
Setup."Token Endpoint",
'',
Scopes,
AccessToken)
then
Error('Failed to acquire OAuth token: %1', GetLastErrorText());
CacheToken(AccessToken);
exit(AccessToken);
end;
local procedure IsTokenValid(): Boolean
begin
exit((Setup."Token Expiry" <> 0DT) and (Setup."Token Expiry" > CurrentDateTime()));
end;
local procedure CacheToken(Token: SecretText)
begin
Setup."Token Expiry" := CurrentDateTime() + (3540 * 1000);
Setup.Modify();
end;
local procedure GetCachedToken(): SecretText
begin
// Implement: Isolated Storage or Session variable
end;
}Create HTTP client that automatically adds Bearer token:
codeunit <ID> "<PREFIX> OAuth HTTP Client"
{
Access = Internal;
var
TokenMgt: Codeunit "<PREFIX> OAuth Token Mgt";
[NonDebuggable]
procedure SendRequest(Method: Text; Url: Text; RequestBody: Text; var ResponseBody: Text; var HttpStatusCode: Integer): Boolean
var
Client: HttpClient;
Request: HttpRequestMessage;
Response: HttpResponseMessage;
Headers: HttpHeaders;
Content: HttpContent;
AccessToken: SecretText;
begin
AccessToken := TokenMgt.GetAccessToken();
Request.Method := Method;
Request.SetRequestUri(Url);
Request.GetHeaders(Headers);
Headers.Add('Authorization', SecretStrSubstNo('Bearer %1', AccessToken));
Headers.Add('Content-Type', 'application/json');
if RequestBody <> '' then begin
Content.WriteFrom(RequestBody);
Request.Content := Content;
end;
if not Client.Send(Request, Response) then begin
HttpStatusCode := 0;
exit(false);
end;
HttpStatusCode := Response.HttpStatusCode();
Response.Content.ReadAs(ResponseBody);
// Auto-retry on 401 (token expired)
if HttpStatusCode = 401 then begin
AccessToken := TokenMgt.GetAccessToken();
Headers.Remove('Authorization');
Headers.Add('Authorization', SecretStrSubstNo('Bearer %1', AccessToken));
if Client.Send(Request, Response) then begin
HttpStatusCode := Response.HttpStatusCode();
Response.Content.ReadAs(ResponseBody);
end;
end;
exit(Response.IsSuccessStatusCode());
end;
}[NonDebuggable] on all procedures handling secretsAccess = Internal on secret management codeunitsDataScope::Company for OAuth credentials(expires_in - 60s) to avoid mid-request expiry| Error | Fix |
|---|---|
| Token request fails | Verify Client ID, Secret, Token Endpoint, Scope |
| 401 on API call | Token expired → auto-retry should refresh |
| 403 Forbidden | Check Azure AD permissions/API scopes |
| AADSTS700016 | App not found in tenant → verify Tenant ID |
Feedback loop: Fix credentials → Re-run Step 3 checkpoint → Confirm token acquired before API calls.
See references/ folder for:
oauth-patterns.md - Complete code patternstoken-management.md - Token caching strategiesazure-setup.md - Azure AD App Registration guidetroubleshooting.md - Common errors and solutions