OAuth 2.0 Client Credentials and Authorization Code flows for Business Central
—
Pending
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Pending
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