Tuesday, 13 May 2025

Secure API Integration in D365FO Using OAuth2 and Azure Key Vault (X++)

 

Secure API Integration in D365FO Using OAuth2 and Azure Key Vault (X++)

Integrating with external APIs securely is a common requirement in Microsoft Dynamics 365 Finance and Operations (D365FO) projects. In this post, we will walk through a complete, secure, and reusable approach for integrating a third-party API using OAuth2 and Azure Key Vault to retrieve secrets like the client secret.

Use Case

You want to:

  • Authenticate using OAuth2 client credentials flow.

  • Securely retrieve the client secret from Azure Key Vault.

  • Send a JSON-based POST request to an external API.

  • Build this in a reusable way using standard .NET classes in X++.

Solution Overview

We'll create a reusable class NarviAPIClient with two core methods:

  1. getOAuthTokenFromKeyVault() - Retrieves an access token securely.

  2. sendPostRequest() - Sends a POST request with Authorization header and JSON payload.

Step-by-Step Implementation

Step 1: Create NarviAPIClient Class in AOT

class NarviAPIClient
{
    public static str getOAuthTokenFromKeyVault(str clientId, str tenantId, str resource, str secretName)
    {
        str tokenEndpoint, clientSecret, formData, responseText, accessToken;
        System.Net.HttpWebRequest tokenRequest;
        System.Net.HttpWebResponse tokenResponse;
        System.IO.Stream responseStream;
        System.IO.StreamReader streamReader;
        System.Text.UTF8Encoding encoding;
        System.Byte[] bytes;
        Map tokenMap;
        KeyVaultParameters keyVaultParameters = KeyVaultParameters::construct();

        clientSecret = KeyVaultCertificateHelper::getSecretValueFromKeyVault(secretName, keyVaultParameters);

        tokenEndpoint = strFmt("https://login.microsoftonline.com/%1/oauth2/token", tenantId);
        formData = strFmt("grant_type=client_credentials&client_id=%1&client_secret=%2&resource=%3",
                          clientId, clientSecret, resource);

        try
        {
            tokenRequest = System.Net.WebRequest::Create(tokenEndpoint) as System.Net.HttpWebRequest;
            tokenRequest.set_Method("POST");
            tokenRequest.set_ContentType("application/x-www-form-urlencoded");

            encoding = new System.Text.UTF8Encoding();
            bytes = encoding.GetBytes(formData);
            tokenRequest.get_RequestStream().Write(bytes, 0, bytes.Length);

            tokenResponse = tokenRequest.GetResponse();
            responseStream = tokenResponse.GetResponseStream();
            streamReader = new System.IO.StreamReader(responseStream);
            responseText = streamReader.ReadToEnd();

            tokenMap = RetailCommonWebAPI::parseJSON(responseText);
            accessToken = tokenMap.lookup("access_token");

            return accessToken;
        }
        catch (Exception::CLRError)
        {
            error(strFmt("OAuth token error: %1", AifUtil::getClrErrorMessage()));
            return "";
        }
    }

    public static str sendPostRequest(str url, str jsonBody, str accessToken)
    {
        System.Net.HttpWebRequest httpRequest;
        System.Net.HttpWebResponse httpResponse;
        System.IO.Stream requestStream, responseStream;
        System.IO.StreamReader reader;
        System.Text.UTF8Encoding encoding;
        System.Byte[] bytes;
        str responseText;

        try
        {
            httpRequest = System.Net.WebRequest::Create(url) as System.Net.HttpWebRequest;
            httpRequest.set_Method("POST");
            httpRequest.set_ContentType("application/json");
            httpRequest.Headers.Add("Authorization", strFmt("Bearer %1", accessToken));

            encoding = new System.Text.UTF8Encoding();
            bytes = encoding.GetBytes(jsonBody);
            requestStream = httpRequest.GetRequestStream();
            requestStream.Write(bytes, 0, bytes.Length);

            httpResponse = httpRequest.GetResponse();
            responseStream = httpResponse.GetResponseStream();
            reader = new System.IO.StreamReader(responseStream);
            responseText = reader.ReadToEnd();

            return responseText;
        }
        catch (Exception::CLRError)
        {
            return strFmt("POST error: %1", AifUtil::getClrErrorMessage());
        }
    }
}

Step 2: Use the Class from a Job or Service

static void CallNarviAPIClient(Args _args)
{
    str tenantId     = "<your-tenant-id>";
    str clientId     = "<your-client-id>";
    str resource     = "<your-api-resource-uri>";
    str secretName   = "your-client-secret-keyvault-name";
    str apiUrl       = "https://your-api-endpoint.com/api/post";
    str jsonPayload, accessToken, response;

    System.IO.StringWriter writer = new System.IO.StringWriter();
    Newtonsoft.Json.JsonTextWriter jsonWriter = new Newtonsoft.Json.JsonTextWriter(writer);

    jsonWriter.WriteStartObject();
    jsonWriter.WritePropertyName("ProjId");
    jsonWriter.WriteValue("000123");
    jsonWriter.WritePropertyName("TransDate");
    jsonWriter.WriteValue("2025-05-13");
    jsonWriter.WriteEndObject();

    jsonPayload = writer.ToString();

    accessToken = NarviAPIClient::getOAuthTokenFromKeyVault(clientId, tenantId, resource, secretName);

    if (!accessToken)
    {
        error("Token not retrieved.");
        return;
    }

    response = NarviAPIClient::sendPostRequest(apiUrl, jsonPayload, accessToken);
    info(strFmt("API Response: %1", response));
}

Benefits of This Approach

  • Secure: Client secrets aren't hardcoded.

  • Reusable: Centralized logic for token generation and POST request.

  • Modular: Easily extendable for GET requests, token caching, retries, etc.

  • Production Ready: Compatible with batch and service classes.

Prerequisites

  • Azure Key Vault configured in LCS.

  • The app must have permissions to read secrets.

  • Secrets must be stored in Key Vault.

How to Disable “Advanced Filter or Sort” and Enforce Custom Filters on Any D365FO Form

 In Dynamics 365 Finance and Operations, users can apply filters through the “Advanced filter or sort” feature found under the Options tab...