Codeunit 560 CrossIntercompany Connector, source in 29

Source29

src/Layers/W1/BaseApp/Finance/Intercompany/DataExchange/CrossIntercompanyConnector.Codeunit.al593 lines, Copyright (c) Microsoft Corporation. MIT

// ------------------------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
// ------------------------------------------------------------------------------------------------
namespace Microsoft.Intercompany.DataExchange;

using Microsoft.Finance.GeneralLedger.Setup;
using Microsoft.Intercompany.Partner;
using System.Environment;
using System.Security.Authentication;
using System.Utilities;

/// <summary>
/// Manages secure API connections and data exchange between intercompany partners across different environments.
/// Handles authentication, HTTP communication, and data validation for cross-environment intercompany operations.
/// </summary>
codeunit 560 "CrossIntercompany Connector"
{
    trigger OnRun()
    begin
        StandardExpirationInSeconds := 3599;
        StoreTokenInICPartner := true;
    end;

    var
        StandardExpirationInSeconds: Integer;
        StoreTokenInICPartner: Boolean;
        EmptyGuid: Guid;
        CrossIntercompanyTok: Label 'CrossEnvironmentIntercompanyToken', Locked = true;
        CrossEnvironmentAPIsPathTok: Label 'microsoft/intercompany/%1/companies', Locked = true, Comment = '%1 = version of the API';
        ICOutgoingNotificationTok: Label 'intercompanyOutgoingNotification', Locked = true;
        OutboundActionTok: Label '/Microsoft.NAV.', Locked = true;
        ExpandOperationTok: Label '?$expand=', Locked = true;
        V1VersionTok: Label 'v1.0', Locked = true;
        GeneralAPIsPathTok: Label 'v2.0/companies', Locked = true;
        BCResourceURLScopeTok: Label 'https://api.businesscentral.dynamics.com/.default', Locked = true;
        DynamicsProdHostSuffixTok: Label '.dynamics.com', Locked = true;
        DynamicsPPEHostSuffixTok: Label '.dynamics-tie.com', Locked = true;
        EntraAuthorityPrefixTok: Label 'https://login.microsoftonline.com/', Locked = true;
        EntraPPEAuthorityPrefixTok: Label 'https://login.windows-ppe.net/', Locked = true;
        OAuthTokenEndpointSuffixTok: Label '/oauth2/v2.0/token', Locked = true;
        TenantDomainPatternTok: Label '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$', Locked = true;
        ExpandedTok: Label 'bufferIntercompanyInboxTransactions,bufferIntercompanyInboxJournalLines,bufferIntercompanyInboxPurchaseHeaders,bufferIntercompanyInboxPurchaseLines,bufferIntercompanyInboxSalesHeaders,bufferIntercompanyInboxSalesLines,bufferIntercompanyInOutJournalLineDimensions,bufferIntercompanyDocumentDimensions,bufferIntercompanyCommentLines', Locked = true;

        NonSaaSEnvironmentErr: Label 'This functionality is only available in online environments.';
        HttpErrorMsg: Label 'Error Code: %1, Error Message: %2', Comment = '%1 = Error Code, %2 = Error Message';
        HTTPSuccessMsg: Label 'The HTTP request was successful and the body contains the resource fetched.'; // 200
        HTTPSuccessAndCreatedMsg: Label 'The HTTP request was successful and a new resource was created.'; //201
        HTTPBadRequestMsg: Label 'The HTTP request was incorrectly formed or invalid.'; // 400
        HTTPUnauthorizedMsg: Label 'The HTTP request is not authorized. Authentication credentials are not valid.'; // 401
        HTTPForbiddenMsg: Label 'The HTTP request is forbidden. Missing permissions to access the requested resource.'; // 403
        HTTPNotFoundMsg: Label 'The HTTP request is not found. The requested resource does not exist.'; // 404
        HTTPNotSupportInsertMsg: Label 'The HTTP request is not supported. The requested resource does not support insert.'; // 405
        HTTPInternalServerErrorMsg: Label 'The HTTP request is not successful. An internal server error occurred.'; // 500
        HTTPServiceUnavailableMsg: Label 'The HTTP request is not successful. The service is unavailable.'; // 503
        HTTPGeneralErrMsg: Label 'Something went wrong, try again later.';
        SuccessConnectingToPartnerMsg: Label 'Successfully connected, the partner %1 is available to be used with intercompany.', Comment = '%1 = IC Partner Code';
        PartnerMissingICSetupErr: Label 'Partner %1 has not completed the information required to use intercompany.', Comment = '%1 = IC Partner Code';
        MissalignmentBetweenNamesErr: Label 'The partner''s company name %1 does not match the name you are introducing for partner %2.', Comment = '%1 = Partner''s Company Name, %2 = IC Partner Name';
        InvalidDestinationUrlErr: Label 'The intercompany connection URL must use the trusted Business Central API host.';
        InvalidDestinationUrlSecurityAuditTxt: Label 'An invalid destination URL was rejected for a cross-environment intercompany connection. Host: %1.', Locked = true, Comment = '%1 = the rejected host';
        InvalidDestinationUrlTelemetryTxt: Label 'A cross-environment intercompany destination URL failed trusted-host validation.', Locked = true;
        InvalidTokenEndpointErr: Label 'The token endpoint must identify a Microsoft Entra tenant on the trusted authority.';
        InvalidTokenEndpointSecurityAuditTxt: Label 'An invalid OAuth token endpoint was rejected for a cross-environment intercompany connection. Host: %1.', Locked = true, Comment = '%1 = the rejected host';
        InvalidTokenEndpointTelemetryTxt: Label 'A cross-environment intercompany OAuth token endpoint failed trusted-authority validation.', Locked = true;
        CrossIntercompanyServiceNameTxt: Label 'Cross-environment Intercompany', Locked = true;
        UnparsableHostTok: Label '(unparsable host)', Locked = true;

    internal procedure TestICPartnerSetup(var TempICPartner: Record "IC Partner" temporary): Boolean
    var
        JsonResponse: JsonArray;
    begin
        StoreTokenInICPartner := false;
        JsonResponse := RequestICPartnerRecordsFromEntityName(TempICPartner, 'intercompanySetup');

        if FindValueFromJsonAttribute(JsonResponse, 'icPartnerCode') = TempICPartner.Code then begin
            Message(SuccessConnectingToPartnerMsg, TempICPartner.Code);
            exit(true);
        end
        else begin
            Message(PartnerMissingICSetupErr, TempICPartner.Code);
            exit(false);
        end;
    end;

    internal procedure FinishICPartnerSetup(var TempICPartner: Record "IC Partner" temporary): Boolean
    var
        GeneralLedgerSetup: Record "General Ledger Setup";
        CompanyJsonObject: JsonObject;
        JsonResponse: JsonArray;
        DisplayNameToken: JsonToken;
        NameToken: JsonToken;
        DisplayName: Text;
        CompanyName: Text;
        CurrencyCode: Code[10];
    begin
        StoreTokenInICPartner := false;
        JsonResponse := RequestICPartnerCompanyInformation(TempICPartner);
        CompanyJsonObject := RequestICPartnerCompany(TempICPartner);
        CompanyJsonObject.Get('displayName', DisplayNameToken);
        DisplayName := DisplayNameToken.AsValue().AsText();
        CompanyJsonObject.Get('name', NameToken);
        CompanyName := NameToken.AsValue().AsText();
        StoreTokenInICPartner := true;
        if (DisplayName = TempICPartner.Name) or (CompanyName = TempICPartner.Name) then begin
#pragma warning disable AA0139
            CurrencyCode := FindValueFromJsonAttribute(JsonResponse, 'currencyCode');
#pragma warning restore AA0139
            GeneralLedgerSetup.Get();
            if CurrencyCode <> GeneralLedgerSetup."LCY Code" then begin
                TempICPartner."Currency Code" := CurrencyCode;
                TempICPartner.Modify();
            end;
            exit(true);
        end
        else begin
            Message(MissalignmentBetweenNamesErr, DisplayName, TempICPartner.Name);
            exit(false);
        end;
    end;

    internal procedure RequestICPartnerRecordsFromEntityName(ICPartner: Record "IC Partner"; EntityName: Text): JsonArray
    var
        QueryURL: Text;
        HttpResponseBodyText: Text;
    begin
        QueryURL := BuildQueryURL(ICPartner, V1VersionTok, EntityName);
        exit(GetRequest(QueryURL, HttpResponseBodyText, ICPartner));
    end;

    internal procedure RequestICPartnerGeneralLedgerSetup(ICPartner: Record "IC Partner"): JsonArray
    var
        QueryURL: Text;
        HttpResponseBodyText: Text;
    begin
        QueryURL := BuildQueryURL(ICPartner, GeneralAPIsPathTok, V1VersionTok, 'generalLedgerSetup');
        exit(GetRequest(QueryURL, HttpResponseBodyText, ICPartner));
    end;

    internal procedure RequestICPartnerCompanyInformation(ICPartner: Record "IC Partner"): JsonArray
    var
        QueryURL: Text;
        HttpResponseBodyText: Text;
    begin
        QueryURL := BuildQueryURL(ICPartner, GeneralAPIsPathTok, V1VersionTok, 'companyInformation');
        exit(GetRequest(QueryURL, HttpResponseBodyText, ICPartner));
    end;

    [NonDebuggable]
    internal procedure RequestICPartnerCompany(ICPartner: Record "IC Partner"): JsonObject
    var
        QueryURL: Text;
        HttpResponseBodyText: Text;
    begin
        QueryURL := ICPartner.GetSecret(ICPartner."Connection Url Key").Unwrap() + GeneralAPIsPathTok + '(' + RemoveCurlyBracketsAndUpperCases(ICPartner.GetSecret(ICPartner."Company Id Key").Unwrap()) + ')';
        Send('GET', QueryURL, '', HttpResponseBodyText, ICPartner);
        exit(ParseObjectData(HttpResponseBodyText));
    end;

    internal procedure RequestICPartnerBankAccount(ICPartner: Record "IC Partner"): JsonArray
    var
        QueryURL: Text;
        HttpResponseBodyText: Text;
    begin
        QueryURL := BuildQueryURL(ICPartner, GeneralAPIsPathTok, V1VersionTok, 'bankAccounts');
        exit(GetRequest(QueryURL, HttpResponseBodyText, ICPartner));
    end;

    [TryFunction]
    internal procedure RequestICPartnerICOutgoingNotification(ICPartner: Record "IC Partner"; OperationID: Guid; var ResponseJsonObject: JsonObject)
    var
        QueryURL: Text;
        HttpResponseBodyText: Text;
    begin
        QueryURL := BuildExpandedQueryURL(ICPartner, OperationID);
        Send('GET', QueryURL, '', HttpResponseBodyText, ICPartner);
        ResponseJsonObject := ParseObjectData(HttpResponseBodyText);
    end;

    local procedure GetRequest(QueryURL: Text; var HttpResponseBodyText: Text; var ICPartner: Record "IC Partner"): JsonArray
    begin
        Send('GET', QueryURL, '', HttpResponseBodyText, ICPartner);
        exit(ParseArrayData(HttpResponseBodyText));
    end;

    /// <summary>
    /// Submits record data to an intercompany partner via API using entity name routing.
    /// Validates response against expected JSON field values for operation confirmation.
    /// </summary>
    /// <param name="ICPartner">Target partner to receive the record data</param>
    /// <param name="Content">JSON content containing the records to submit</param>
    /// <param name="EntityName">API entity name for routing the submission request</param>
    /// <param name="JsonFieldKey">JSON field key to validate in the response</param>
    /// <param name="JsonFieldExpectedValue">Expected value for response validation</param>
    /// <returns>True if submission successful and response contains expected values</returns>
    procedure SubmitRecordsToICPartnerFromEntityName(ICPartner: Record "IC Partner"; Content: Text; EntityName: Text; JsonFieldKey: Text; JsonFieldExpectedValue: Text): Boolean
    var
        ResultResponse: JsonObject;
        AttributeJsonToken: JsonToken;
    begin
        if SubmitRecordsToICPartnerFromEntityName(ICPartner, Content, EntityName, ResultResponse) then begin
            ResultResponse.Get(JsonFieldKey, AttributeJsonToken);
            exit(true);
        end;
        exit(false);
    end;

    /// <summary>
    /// Submits record data to an intercompany partner via API and returns the full response object.
    /// Provides complete response data for detailed processing and error handling.
    /// </summary>
    /// <param name="ICPartner">Target partner to receive the record data</param>
    /// <param name="Content">JSON content containing the records to submit</param>
    /// <param name="EntityName">API entity name for routing the submission request</param>
    /// <param name="ResultResponse">JSON response object containing API result data</param>
    /// <returns>True if submission successful, false if operation failed</returns>
    [TryFunction]
    procedure SubmitRecordsToICPartnerFromEntityName(ICPartner: Record "IC Partner"; Content: Text; EntityName: Text; var ResultResponse: JsonObject)
    var
        QueryURL: Text;
        HttpResponseBodyText: Text;
    begin
        QueryURL := BuildQueryURL(ICPartner, V1VersionTok, EntityName);
        PostRequest(QueryURL, Content, HttpResponseBodyText, ICPartner);
        ResultResponse := ParseObjectData(HttpResponseBodyText);
    end;

    [TryFunction]
    internal procedure NotifyICPartnerFromBoundAction(ICPartner: Record "IC Partner"; EntityID: Guid; BoundActionName: Text)
    var
        QueryURL: Text;
        HttpResponseBodyText: Text;
    begin
        QueryURL := BuildBoundActionQueryURL(ICPartner, EntityID, BoundActionName);
        PostRequest(QueryURL, '', HttpResponseBodyText, ICPartner);
    end;

    /// <summary>
    /// Submits job queue entry data to intercompany partner via API and validates response.
    /// Processes asynchronous operations and verifies completion status from partner system.
    /// </summary>
    /// <param name="ICPartner">Target intercompany partner for job submission</param>
    /// <param name="Content">JSON content payload for the job queue entry</param>
    /// <param name="JsonFieldKey">Key field name to validate in the response</param>
    /// <param name="JsonFieldExpectedValue">Expected value for validation field</param>
    /// <returns>True if job was successfully submitted and validated, false otherwise</returns>
    procedure SubmitJobQueueEntryToICPartner(ICPartner: Record "IC Partner"; Content: Text; JsonFieldKey: Text; JsonFieldExpectedValue: Text): Boolean
    var
        QueryURL: Text;
        HttpResponseBodyText: Text;
        ResultResponse: JsonObject;
        AttributeJsonToken: JsonToken;
    begin
        QueryURL := BuildQueryURL(ICPartner, CrossEnvironmentAPIsPathTok, V1VersionTok, 'jobQueueEntries');
        PostRequest(QueryURL, Content, HttpResponseBodyText, ICPartner);
        ResultResponse := ParseObjectData(HttpResponseBodyText);
        ResultResponse.Get(JsonFieldKey, AttributeJsonToken);
        exit(AttributeJsonToken.AsValue().AsText() = JsonFieldExpectedValue);
    end;

    [TryFunction]
    local procedure PostRequest(QueryURL: Text; Content: Text; var HttpResponseBodyText: Text; var ICPartner: Record "IC Partner")
    begin
        Send('POST', QueryURL, Content, HttpResponseBodyText, ICPartner);
    end;

    [TryFunction]
    internal procedure Send(Method: Text; Uri: Text; Content: Text; var HttpResponseBodyText: Text; var ICPartner: Record "IC Partner")
    var
        ICAPILog: Record "IC API Log";
        ICAPILogContext: Codeunit "IC API Log Context";
        HttpClient: HttpClient;
        HttpRequestMessage: HttpRequestMessage;
        HttpResponseMessage: HttpResponseMessage;
    begin
        ValidateDestinationUrl(Uri);
        HttpRequestMessage.Method(Method);
        HttpRequestMessage.SetRequestUri(Uri);
        PrepareHeaders(HttpRequestMessage, ICPartner);
        PrepareContent(HttpRequestMessage, Content);

        HttpClient.Send(HttpRequestMessage, HttpResponseMessage);
        HttpResponseMessage.Content().ReadAs(HttpResponseBodyText);

        ICAPILogContext.RecordLogEntry(ICPartner.Code, ICAPILog.Direction::Outgoing, Method, Uri, Content, HttpResponseBodyText, HttpResponseMessage.HttpStatusCode());

        HandleHttpResponse(HttpResponseMessage);
    end;

    local procedure PrepareHeaders(HttpRequestMessage: HttpRequestMessage; var ICPartner: Record "IC Partner")
    var
        HttpRequestHeaders: HttpHeaders;
    begin
        HttpRequestMessage.GetHeaders(HttpRequestHeaders);

        HttpRequestHeaders.Add('Accept', 'application/json');
        HttpRequestHeaders.Add('Accept-Language', 'en-US');
        HttpRequestHeaders.Add('Authorization', SecretStrSubstNo('Bearer %1', GetBearerAccessToken(ICPartner)));
    end;

    local procedure PrepareContent(HttpRequestMessage: HttpRequestMessage; Content: Text)
    var
        HttpContent: HttpContent;
        HttpContentHeaders: HttpHeaders;
    begin
        if Content = '' then
            exit;

        HttpContent.GetHeaders(HttpContentHeaders);
        HttpContent.WriteFrom(Content);
        HttpContentHeaders.Remove('Content-Type');
        HttpContentHeaders.Add('Content-Type', 'application/json');
        HttpRequestMessage.Content(HttpContent);
    end;

    local procedure HandleHttpResponse(HttpResponseMessage: HttpResponseMessage)
    var
        FriendlyErrorMsg, ErrorMsg : Text;
    begin
        case HttpResponseMessage.HttpStatusCode() of
            200:
                begin
                    Session.LogMessage('0000KZE', HTTPSuccessMsg, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CrossIntercompanyTok);
                    exit;
                end;
            201:
                begin
                    Session.LogMessage('0000KZF', HTTPSuccessAndCreatedMsg, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CrossIntercompanyTok);
                    exit;
                end;
            400:
                FriendlyErrorMsg := HTTPBadRequestMsg;
            401:
                FriendlyErrorMsg := HTTPUnauthorizedMsg;
            403:
                FriendlyErrorMsg := HTTPForbiddenMsg;
            404:
                FriendlyErrorMsg := HTTPNotFoundMsg;
            405:
                FriendlyErrorMsg := HTTPNotSupportInsertMsg;
            500:
                FriendlyErrorMsg := HTTPInternalServerErrorMsg;
            503:
                FriendlyErrorMsg := HTTPServiceUnavailableMsg;
            else
                FriendlyErrorMsg := HTTPGeneralErrMsg;
        end;

        HttpResponseMessage.Content().ReadAs(ErrorMsg);
        FriendlyErrorMsg := StrSubstNo(HttpErrorMsg, HttpResponseMessage.HttpStatusCode(), FriendlyErrorMsg);
        FriendlyErrorMsg := FriendlyErrorMsg + ', ' + ErrorMsg;
        Session.LogMessage('0000KZG', StrSubstNo(HttpErrorMsg, HttpResponseMessage.HttpStatusCode(), ErrorMsg), Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CrossIntercompanyTok);
        Error(FriendlyErrorMsg);
    end;

    internal procedure GetBearerAccessToken(var ICPartner: Record "IC Partner"): SecretText
    var
        Token: SecretText;
    begin
        if (ICPartner."Token Expiration Time" <= CurrentDateTime) then begin
            Token := AcquireBearerAccessToken(ICPartner);
            if StoreTokenInICPartner then begin
                ICPartner.SetSecret(ICPartner."Token Key", Token);
                ICPartner."Token Expiration Time" := CurrentDateTime + StandardExpirationInSeconds;
                ICPartner.Modify();
            end
        end
        else
            Token := ICPartner.GetSecret(ICPartner."Token Key");
        exit(Token);
    end;

    [NonDebuggable]
    internal procedure AcquireBearerAccessToken(var ICPartner: Record "IC Partner"): SecretText
    var
        EnvironmentInformation: Codeunit "Environment Information";
        OAuth2: Codeunit OAuth2;
        Scopes: List of [Text];
        ClientId, TokenEndpoint, RedirectURL : Text;
        ClientSecret: SecretText;
        BearerAccessToken: SecretText;
    begin
        if not EnvironmentInformation.IsSaaSInfrastructure() then
            Error(NonSaaSEnvironmentErr);

        ClientId := ICPartner.GetSecret(ICPartner."Client Id Key").Unwrap();
        ClientSecret := ICPartner.GetSecret(ICPartner."Client Secret Key");
        TokenEndpoint := GetValidatedTokenEndpoint(ICPartner.GetSecret(ICPartner."Token Endpoint Key").Unwrap());
        OAuth2.GetDefaultRedirectUrl(RedirectURL);
        Scopes.Add(BCResourceURLScopeTok);

        OAuth2.AcquireTokenWithClientCredentials(ClientId, ClientSecret, TokenEndpoint, RedirectURL, Scopes, BearerAccessToken);

        exit(BearerAccessToken);
    end;

    local procedure ValidateDestinationUrl(DestinationUrl: Text): Boolean
    begin
        if not IsDestinationUrlTrusted(DestinationUrl) then
            RejectInvalidDestinationUrl(GetHostFromUrl(DestinationUrl));

        exit(true);
    end;

    internal procedure IsDestinationUrlTrusted(DestinationUrl: Text): Boolean
    var
        UrlHelper: Codeunit "Url Helper";
    begin
        if UrlHelper.IsPPE() then
            exit(IsDestinationUrlAllowed(DestinationUrl, DynamicsPPEHostSuffixTok));
        if UrlHelper.IsPROD() then
            exit(IsDestinationUrlAllowed(DestinationUrl, DynamicsProdHostSuffixTok));
        exit(false);
    end;

    internal procedure IsDestinationUrlAllowed(DestinationUrl: Text; ExpectedHostSuffix: Text): Boolean
    var
        Uri: Codeunit Uri;
    begin
        if not Uri.IsWellFormedUriString(DestinationUrl, Enum::UriKind::Absolute) then
            exit(false);

        Uri.Init(DestinationUrl);
        if LowerCase(Uri.GetScheme()) <> 'https' then
            exit(false);
        if Uri.GetPort() <> 443 then
            exit(false);

        // Allow any Dynamics host (including Embed ISV subdomains) under the environment's trusted domain.
        exit(LowerCase(Uri.GetHost()).EndsWith(ExpectedHostSuffix));
    end;

    local procedure RejectInvalidDestinationUrl(Host: Text)
    var
        TelemetryDimensions: Dictionary of [Text, Text];
    begin
        TelemetryDimensions.Add('Category', CrossIntercompanyTok);
        TelemetryDimensions.Add('Host', Host);
        Session.LogMessage('0000VC7', InvalidDestinationUrlTelemetryTxt, Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::All, TelemetryDimensions);
        Session.LogSecurityAudit(
            CrossIntercompanyServiceNameTxt, SecurityOperationResult::Failure,
            StrSubstNo(InvalidDestinationUrlSecurityAuditTxt, Host), AuditCategory::ApplicationManagement);
        Error(InvalidDestinationUrlErr);
    end;

    local procedure GetValidatedTokenEndpoint(ConfiguredTokenEndpoint: Text): Text
    var
        UrlHelper: Codeunit "Url Helper";
        AuthorityPrefix: Text;
    begin
        if UrlHelper.IsPPE() then
            AuthorityPrefix := EntraPPEAuthorityPrefixTok
        else
            if UrlHelper.IsPROD() then
                AuthorityPrefix := EntraAuthorityPrefixTok
            else
                RejectInvalidTokenEndpoint(GetHostFromUrl(ConfiguredTokenEndpoint));

        exit(GetValidatedTokenEndpointForAuthority(ConfiguredTokenEndpoint, AuthorityPrefix));
    end;

    internal procedure GetValidatedTokenEndpointForAuthority(ConfiguredTokenEndpoint: Text; AuthorityPrefix: Text): Text
    var
        TenantIdText: Text;
    begin
        if not LowerCase(ConfiguredTokenEndpoint).StartsWith(AuthorityPrefix) or
           not LowerCase(ConfiguredTokenEndpoint).EndsWith(OAuthTokenEndpointSuffixTok)
        then
            RejectInvalidTokenEndpoint(GetHostFromUrl(ConfiguredTokenEndpoint));

        TenantIdText := CopyStr(ConfiguredTokenEndpoint, StrLen(AuthorityPrefix) + 1, StrLen(ConfiguredTokenEndpoint) - StrLen(AuthorityPrefix) - StrLen(OAuthTokenEndpointSuffixTok));
        if not IsValidTenantIdentifier(TenantIdText) then
            RejectInvalidTokenEndpoint(GetHostFromUrl(ConfiguredTokenEndpoint));

        exit(ConfiguredTokenEndpoint);
    end;

    local procedure IsValidTenantIdentifier(TenantIdentifier: Text): Boolean
    var
        Regex: Codeunit Regex;
        TenantId: Guid;
    begin
        if TenantIdentifier = '' then
            exit(false);

        // A tenant is identified either by its GUID or by a verified Entra domain name (for example, contoso.onmicrosoft.com).
        if Evaluate(TenantId, TenantIdentifier) then
            exit(true);

        exit(Regex.IsMatch(LowerCase(TenantIdentifier), TenantDomainPatternTok));
    end;

    local procedure RejectInvalidTokenEndpoint(Host: Text)
    var
        TelemetryDimensions: Dictionary of [Text, Text];
    begin
        TelemetryDimensions.Add('Category', CrossIntercompanyTok);
        TelemetryDimensions.Add('Host', Host);
        Session.LogMessage('0000VC8', InvalidTokenEndpointTelemetryTxt, Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::All, TelemetryDimensions);
        Session.LogSecurityAudit(
            CrossIntercompanyServiceNameTxt, SecurityOperationResult::Failure,
            StrSubstNo(InvalidTokenEndpointSecurityAuditTxt, Host), AuditCategory::ApplicationManagement);
        Error(InvalidTokenEndpointErr);
    end;

    local procedure GetHostFromUrl(Url: Text): Text
    var
        Uri: Codeunit Uri;
        Host: Text;
    begin
        if not Uri.IsValidUri(Url) then
            exit(UnparsableHostTok);
        Uri.Init(Url);
        Host := LowerCase(Uri.GetHost());
        if Host = '' then
            exit(UnparsableHostTok);
        exit(Host);
    end;

    #region Auxiliar methods
    local procedure ParseArrayData(Response: Text): JsonArray
    var
        JsonObject: JsonObject;
        JsonToken: JsonToken;
    begin
        JsonObject.ReadFrom(Response);
        JsonObject.Get('value', JsonToken);
        exit(JsonToken.AsArray());
    end;

    local procedure ParseObjectData(Response: Text): JsonObject
    var
        JsonObject: JsonObject;
    begin
        JsonObject.ReadFrom(Response);
        exit(JsonObject);
    end;

    local procedure FindValueFromJsonAttribute(JsonInput: JsonArray; JsonKey: Text): Text
    var
        IndividualToken: JsonToken;
        AttributeJsonToken: JsonToken;
    begin
        foreach IndividualToken in JsonInput do begin
            IndividualToken.AsObject().Get(JsonKey, AttributeJsonToken);
            exit(AttributeJsonToken.AsValue().AsText());
        end;
    end;

    local procedure BuildQueryURL(ICPartner: Record "IC Partner"; APIVersion: Text; EntityName: Text): Text
    begin
        exit(BuildQueryURL(ICPartner, CrossEnvironmentAPIsPathTok, APIVersion, EntityName, EmptyGuid, ''));
    end;

    local procedure BuildQueryURL(ICPartner: Record "IC Partner"; APIPath: Text; APIVersion: Text; EntityName: Text): Text
    begin
        exit(BuildQueryURL(ICPartner, APIPath, APIVersion, EntityName, EmptyGuid, ''));
    end;

    local procedure BuildExpandedQueryURL(ICPartner: Record "IC Partner"; EntityID: Guid): Text
    var
        OperationTxt: Text;
    begin
        OperationTxt := ExpandOperationTok + ExpandedTok;
        exit(BuildQueryURL(ICPartner, CrossEnvironmentAPIsPathTok, V1VersionTok, ICOutgoingNotificationTok, EntityID, OperationTxt));
    end;

    local procedure BuildBoundActionQueryURL(ICPartner: Record "IC Partner"; EntityID: Guid; BoundActionName: Text): Text
    var
        OperationTxt: Text;
    begin
        OperationTxt := OutboundActionTok + BoundActionName;
        exit(BuildQueryURL(ICPartner, CrossEnvironmentAPIsPathTok, V1VersionTok, ICOutgoingNotificationTok, EntityID, OperationTxt));
    end;

    [NonDebuggable]
    local procedure BuildQueryURL(ICPartner: Record "IC Partner"; APIPath: Text; APIVersion: Text; EntityName: Text; EntityID: Guid; OperationTxt: Text): Text
    var
        Result: Text;
    begin
        Result := ICPartner.GetSecret(ICPartner."Connection Url Key").Unwrap() + StrSubstNo(APIPath, APIVersion) + '(' + RemoveCurlyBracketsAndUpperCases(ICPartner.GetSecret(ICPartner."Company Id Key").Unwrap()) + ')/' + EntityName;
        if EntityID <> EmptyGuid then
            Result += '(' + RemoveCurlyBracketsAndUpperCases(EntityID) + ')' + OperationTxt;
        exit(Result);
    end;

    internal procedure RemoveCurlyBracketsAndUpperCases(Input: Guid): Text
    begin
        exit(LowerCase(DelChr(Input, '=', '{}')));
    end;
    #endregion
}