Business central blog
Subscribe to blog and get news about new posts.

Sharepoint Service to Service Authorization. Integration with Business Central. No user permissions.

I think integrating Sharepoint into Business Central is a fairly common request. The standard library already supports some level of Sharepoint/OneDrive integration out of the box, but it is quite limited. What if we need more advanced logic? In that case, we can use Sharepoint Interfaces from the System Application! But did you know that this will only work if the user has permissions on Sharepoint? Regardless of whether we are using Microsoft Entra Application, it all comes down to the authorization that Sharepoint Interfaces use by default. Let's dive in and understand this better.

Agenda

As I mentioned, when using Sharepoint Interfaces, I encountered an issue where if the Business Central user (who corresponds to a Microsoft 365 user) tries to interact with Sharepoint, we always get an error. The error itself is a 403 Forbidden without a proper description of what exactly went wrong.
The first reason is that Service to Service, or in other words, Application Only access to Sharepoint is only possible using a certificate. I should mention in advance that if you follow the instructions from the article for Business Central Sharepoint Interfaces, you will encounter failure. A certificate is indeed necessary, but it's much more complicated than that. I suggest we explore the types of authorization available for Sharepoint by default in Business Central. These are Authorization Code and Client Credentials, although I'm not sure why the latter is called that when it is essentially the same as Certificate Authorization. Probably because Certificate is only used to generate the JWT Token, and then it is already a client credentials grant type.
Authorization Code and Client Credentials
Authorization Code and Client Credentials
Client Credentials is actually Certificate Authorization
All of this works through the SharePoint Authorization interface, which is essentially used in the initialization of the Sharepoint Client and is passed as a parameter.
Sharepoint Authorization Interface is passed as a parameter
Ultimately, any request to Sharepoint will call the corresponding implementation of "Sharepoint Authorization" in the SharePoint Request Helper.
Location where Sharepoint Authorization interface implementation is used
But these two types of authorization only work if the user who calls the Sharepoint Interface has the corresponding permissions on Sharepoint! If the user does not have the rights or is in a different tenant, we will always get a 403 Forbidden error.
Important: Why don't we add SharePoint App Principal? The thing is, this method will soon be removed. Starting April 2, 2026, Azure Access Control Service (ACS) usage will be retired for SharePoint in Microsoft 365, and users will no longer be able to create or use Azure ACS principals to access SharePoint. Learn more about the Access Control retirement.
https://learn.microsoft.com/en-us/sharepoint/dev/sp-add-ins/retirement-announcement-for-azure-acs
Let's move on to implementing a wrapper for working with Sharepoint using the Sharepoint Client interface. For this, we will also need to set the corresponding implementation of the SharePoint Authorization interface. As I mentioned, the implementations for Client Credential and Authorization Code already exist, so we can simply use them.
I won't provide all the code, as you can review it yourself at the link at the end of the article. I'll focus on the interesting parts. Since we know that to initialize the SharePoint Client we need to pass the SharePoint Authorization interface, we need to write a method that specifies which implementation of the interface will be used. This method is called GetSharePointAuthorization() in my case. Below is a simplified example of initialization for Authorization Code and Client Credentials.

  local procedure InitializeConnection()
  begin
    SharePointClient.Initialize(SharepointSetup."Sharepoint URL", GetSharePointAuthorization());
  end;

  local procedure GetSharePointAuthorization(): Interface "SharePoint Authorization"
  var
    SharePointAuth: Codeunit "SharePoint Auth.";
  begin
    case SharepointSetup."Authorizaton Type" of
      SharepointSetup."Authorizaton Type"::"Authorization Code":
        exit(SharePointAuth.CreateAuthorizationCode(SharepointSetup.Tenant, SharepointSetup."Client Id",
            SharepointSetup.GetSecret(Enum::"SSC Secret Type"::ClientSecret), SharepointSetup.Scope));

      SharepointSetup."Authorizaton Type"::Certificate:
        exit(SharePointAuth.CreateClientCredentials(SharepointSetup.Tenant, SharepointSetup."Client Id",
            CertificateBase64, SharepointSetup.GetCertificatePassword(), SharepointSetup.Scope));
    end;
  end;
The exit() operator essentially assigns a specific implementation to the interface. Then this interface is passed to the initialization method. Here is a basic example of a method for deleting a file from Sharepoint.

  procedure DeleteFile(FilePath: Text): Boolean
  var
    TempSharepointFile: Record "SharePoint File" temporary;
    Diagnostics: Interface "HTTP Diagnostics";
  begin
    InitializeConnection();

    if SharepointClient.GetFileByServerRelativeUrl(FilePath, TempSharepointFile, false) then
      if SharepointClient.DeleteFileByServerRelativeUrl(FilePath) then
        exit(true);

    Diagnostics := SharepointClient.GetDiagnostics();
    if (not Diagnostics.IsSuccessStatusCode()) then
      Error(DiagErr, Diagnostics.GetHttpStatusCode(), Diagnostics.GetErrorMessage(), Diagnostics.GetResponseReasonPhrase());
  end;
But what should we do if we need to work with Sharepoint through application permissions, and the user might not have the necessary rights? I spent some time finding an answer to this. Reading the Microsoft documentation was of little help, as the described methods here and here simply do not work with Business Central. Therefore, I decided to write my own implementation of the authorization interface. Unfortunately, I encountered the issue that the necessary library, Microsoft.IdentityModel, does not have a public interface in Business Central, even though it is used in the System Application. So, at this stage, I had to use an Azure Function in C# that works with the Microsoft.IdentityModel library.
So, Business Central oAuth2 certificate authorization somehow does not work for Sharepoint. I tried using oAuth2 directly and also to no avail. Therefore, let's look at how certificate credential authorization works. Based on this, we need to send the correct JWT token in the client_assertion parameter.
But where do we get the JWT token? This is exactly where we need to use Microsoft.IdentityModel.JsonWebTokens and Microsoft.IdentityModel.Tokens to generate the JWT token. The plan is as follows:
  1. Create an Azure Function to generate the JWT token based on the certificate.
  2. Obtain the correct Access Code based on the certificate JWT token.
  3. Create our own implementation of the Sharepoint Authorization interface and pass Access Token.
  4. Use the new implementation of the interface for our SharePoint methods.
To work with Azure Function, we need to install the Visual Studio Code extension for Azure Function. Additionally, since we will be writing the function in C#, it will be useful to install the C# extension, C# Dev Kit, and .NET Install Tool. For correctly installing libraries into the project, I also use the NuGet Package Manager extension.
I won't describe the entire process of creating and publishing the Azure Function, as that could essentially be an article on its own. You can find the function itself via the link to the GitHub repository below. I'll note in advance that I'm not an expert in C#, so the code might not be perfect, but it does its job.

using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using Microsoft.Azure.Functions.Worker.Http;
using System.Net;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json;
using System.Security.Cryptography.X509Certificates;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

namespace VLD.AcquireToken
{
    public class AcquireToken
    {
        private readonly ILogger _logger;
        public AcquireToken(ILoggerFactory loggerFactory)
        {
            _logger = loggerFactory.CreateLogger<AcquireToken>();
        }

        [Function("AcquireToken")]
        public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req)
        {
            string requestBody = await new StreamReader(req.Body).ReadToEndAsync();

            JObject jData = JsonConvert.DeserializeObject<JObject>(requestBody);

            if (jData == null)
            {
                return await CreateErrorResponse(req, "Failed to read JSON data.");
            }

            // Extract data from JSON
            string clientId = jData["clientId"]?.ToString();
            string certificatePassword = jData["certificatePassword"]?.ToString();
            string tenantId = jData["tenantId"]?.ToString();
            string base64Cert = jData["base64Cert"]?.ToString();
            string type = jData["type"]?.ToString();

            if (type == "temp")
            {
                var emptyResponse = req.CreateResponse(HttpStatusCode.OK);
                return emptyResponse;
            }

            string aud = $"https://login.microsoftonline.com/{tenantId}/v2.0/";

            byte[] certBytes = Convert.FromBase64String(base64Cert);
            X509Certificate2 certificate = new X509Certificate2(certBytes, certificatePassword);
            var claims = new Dictionary<string, object>();

            claims["aud"] = aud;
            claims["sub"] = clientId;
            claims["iss"] = clientId;
            claims["jti"] = Guid.NewGuid().ToString();

            var signingCredentials = new X509SigningCredentials(certificate);
            var securityTokenDescriptor = new SecurityTokenDescriptor();
            securityTokenDescriptor.Claims = claims;
            securityTokenDescriptor.SigningCredentials = signingCredentials;

            var tokenHandler = new JsonWebTokenHandler();
            var clientAssertion = tokenHandler.CreateToken(securityTokenDescriptor);

            var successResponse = req.CreateResponse(HttpStatusCode.OK);
            successResponse.Headers.Add("Content-Type", "text/plain; charset=utf-8");

            await successResponse.WriteStringAsync(clientAssertion);

            return successResponse;
        }
        private async Task<HttpResponseData> CreateErrorResponse(HttpRequestData req, string message)
        {
            var response = req.CreateResponse(HttpStatusCode.BadRequest);
            response.Headers.Add("Content-Type", "text/plain; charset=utf-8");
            await response.WriteStringAsync(message);
            return response;
        }
    }
}

After publishing the function, we just need to call it, passing the necessary parameters in the body of the request. It will return a valid JWT token that is valid for one hour, which can be verified at https://jwt.io/.
To call this function in AL, I use my universal method SendRequest(), which I have written about previously. I am not yet accustomed to using the new RestClient and do not know how much time it will take to get used to it, so for now, it is easier for me to use my own function.

JObject.Add('clientId', ClientId);
JObject.Add('tenantId', EntraTenantId);
JObject.Add('certificatePassword', CertificatePassword);
JObject.Add('base64Cert', CertificateText);
JObject.WriteTo(TokenRequest);

APIMgt.SendRequest(TokenRequest, Enum::"Http Request Type"::POST,
	 StrSubstNo('%1%2', SharepointSetup."Azure Authrization URL", SharepointSetup."Azure Authrization Key"),
	 '', 0, ResponseTempBlob, DictionaryContentHeaders, DictionaryDefaultHeaders);
Let's implement obtaining the Access Token based on the previously obtained JWT token.

contentToSend.Append(StrSubstNo('client_id=%1', ClientId));
contentToSend.Append(StrSubstNo('&client_assertion=%1', TypeHelper.UriEscapeDataString(AssertionKey)));
contentToSend.Append(StrSubstNo('&scope=%1&', TypeHelper.UriEscapeDataString(Scope)));
contentToSend.Append(StrSubstNo('client_assertion_type=%1&', TypeHelper.UriEscapeDataString('urn:ietf:params:oauth:client-assertion-type:jwt-bearer')));
contentToSend.Append('grant_type=client_credentials');

APIMgt.SendRequest(contentToSend.ToText(), Enum::"Http Request Type"::POST, StrSubstNo(AuthorityTxt, EntraTenantId),
	'application/x-www-form-urlencoded', 0, ResponseTempBlob, DictionaryContentHeaders, DictionaryDefaultHeaders);

ResponseTempBlob.CreateInStream(ResponseInstream);

while not ResponseInstream.EOS() do begin
	ResponseInstream.Read(Buffer);
	Response += Buffer;
end;
JObject.ReadFrom(Response);

JObject.Get('access_token', JToken);
AccessToken := JToken.AsValue().AsText();
In general, the implementation of the Sharepoint Authorization interface looks similar to the interface itself.

codeunit 81772 "SSC SharePoint S2S Certificate" implements "SharePoint Authorization"
{
  procedure Authorize(var HttpRequestMessage: HttpRequestMessage);
  var
    Headers: HttpHeaders;
  begin
    HttpRequestMessage.GetHeaders(Headers);
    Headers.Add('Authorization', SecretStrSubstNo(BearerTxt, AccessToken));
  end;
}
Of course, my final implementation of the interface is much more extensive and aligns with my initial plan, but including it entirely in the article doesn't make sense. I suggest you check out the code in the repository.
It's simple: we add our implementation to the previously created method GetSharePointAuthorization(): Interface "SharePoint Authorization".

  local procedure GetSharePointAuthorization(): Interface "SharePoint Authorization"
  var
    SharePointAuth: Codeunit "SharePoint Auth.";
    SharepointCustomCertificate: Codeunit "SSC SharePoint S2S Certificate";
    CertificateBase64: Text;
  begin
    case SharepointSetup."Authorizaton Type" of
      SharepointSetup."Authorizaton Type"::"Authorization Code":
        exit(SharePointAuth.CreateAuthorizationCode(SharepointSetup.Tenant, SharepointSetup."Client Id",
          SharepointSetup.GetSecret(Enum::"SSC Secret Type"::ClientSecret), SharepointSetup.Scope));

      SharepointSetup."Authorizaton Type"::Certificate:
        exit(SharePointAuth.CreateClientCredentials(SharepointSetup.Tenant, SharepointSetup."Client Id",
          CertificateBase64, SharepointSetup.GetCertificatePassword(), SharepointSetup.Scope));

      SharepointSetup."Authorizaton Type"::"Custom Certificate":
        begin
          SharepointSetup.TestField("Azure Authrization URL");
          SharepointSetup.TestField("Azure Authrization URL");
          SharepointCustomCertificate.SetParameters(SharepointSetup.Tenant, SharepointSetup."Client ID", SharepointSetup.Scope, CertificateBase64,
            SharepointSetup.GetCertificatePassword(), SharepointSetup);
          exit(SharepointCustomCertificate);
        end;
    end;
  end;

Preparation for testing involves several stages. The first is creating an App Registration with the appropriate Sharepoint permissions. For example Application permission Sites.FullControl.All Don't forget to grant admin consent for permissions.

We also need to generate a certificate with password, this is done using the Powershell script from the documentation. The output will be .pfx and .cer files. The .pfx file is used to obtain the JWT Token and is uploaded to Business Central, while the .cer file is uploaded to the App Registration.

For testing, I created the pages "Sharepoint Setup" and "Sharepoint Content". On the "Sharepoint Setup" page, we select the type of authorization and fill in the basic settings. On the "Sharepoint Content" page, we can test uploading, downloading, and deleting files from Sharepoint. I will provide an example of the settings for each field.
Setup Example
In the end, we have gained some understanding of how the Sharepoint Interface works. We also explored the types of authorizations for Sharepoint that are available by default and even wrote our own authorization.
June 17, 2024