Repository navigation
Authentication Modes
Last verified against v3.0.0
The OData layer authenticates to D365 F&O one of two ways, selected by ODataSettings.Authentication.Mode: OAuth (a Bearer token acquired directly from Azure AD) or ApiKey (a subscription key for an Azure API Management gateway). ODataAuthenticationHandler reads the mode on every outgoing request and attaches the matching header.
The default mode is ApiKey. Set it explicitly — a deployment that omits the mode falls to ApiKey and fails validation at startup if no subscription key is present.
Bind these under ODataSettings in local.settings.json (development) or Azure App configuration (deployed):
{
"ODataSettings": {
"Url": "https://your-environment.operations.dynamics.com/data",
"Authentication": {
"Mode": "OAuth",
"OAuth": {
"ClientId": "<azure-ad-app-id>",
"ClientSecret": "<from-key-vault>",
"TenantId": "<azure-ad-tenant-id>",
"Resource": "https://your-environment.operations.dynamics.com"
}
}
}
}AddIntegratoR(configuration) binds the section and wires the handler. Each value's source:
| Value | Where it comes from |
|---|---|
ClientId |
Application (client) ID of the Azure AD app registration |
ClientSecret |
A secret from the app registration → Certificates & secrets. Store in Key Vault; never commit it |
TenantId |
The directory (tenant) ID that owns the app registration and the D365 environment |
Resource |
The environment URL without /data — the token audience, not the OData root |
OAuthAuthenticator (IntegratoR.Application) acquires the token via MSAL (Microsoft.Identity.Client) using the client credentials flow, requesting the {Resource}/.default scope against https://login.microsoftonline.com/{TenantId}. It caches the token in IMemoryCache and refreshes it five minutes before expiry, so most requests reuse a cached token rather than call Azure AD.
Register the app as a service user inside D365 as well: System administration → Setup → Azure Active Directory applications, adding a row with the ClientId and a proxy identity. Without that row D365 returns its own 401 even after the token is acquired.
Note
The MSAL token cache lives in IMemoryCache, local to one instance. A scaled-out host acquires a token per instance. Front MSAL with a distributed token cache if that matters for your throughput.
Token acquisition never throws. IAuthenticator.GetAccessTokenAsync returns a failed Result<string> carrying an IntegrationError:
Result<string> token = await authenticator.GetAccessTokenAsync(
clientId, clientSecret, tenantId, resource, cancellationToken);
if (token.IsFailed)
{
IntegrationError? error = token.GetError();
// error?.Code == "Auth.Msal.invalid_client" (Auth.Msal.{MSAL error code})
// error?.Message == "Token acquisition failed"
// error?.Type == ErrorType.Failure
// error?.Exception holds the MsalException for server-side logging
}The error Code is Auth.Msal.{code} where {code} is the MSAL error code (for example invalid_client, unauthorized_client). The full MSAL message — which can carry AADSTS codes and tenant IDs — stays on the inner exception, never in error.Message.
ODataAuthenticationHandler calls the same method on every OAuth request. On a failure it returns an immediate 401 Unauthorized with ReasonPhrase "Authentication failed", and no request reaches D365. The handler sits outside the retry and breaker handlers, so the short-circuit is never retried. It also discards the MSAL result and nothing logs it: through IMediator the failure arrives as {Entity}.Unauthorized (for example LedgerJournalHeader.Unauthorized), with no Auth.Msal.* code. To diagnose a bad credential, call GetAccessTokenAsync directly as above — see Troubleshoot Common Issues.
Warning
Never surface IntegrationError.Exception or the raw MSAL message in an HTTP response. The 401 ReasonPhrase is deliberately generic to keep tenant IDs and AADSTS codes out of client-visible output.
Use ApiKey when Azure API Management fronts D365 and owns the authentication to it. The framework attaches only the subscription-key header; the APIM policy decides what reaches D365.
{
"ODataSettings": {
"Url": "https://your-apim.azure-api.net/d365/data",
"Authentication": {
"Mode": "ApiKey",
"ApiManagement": {
"SubscriptionKey": "<from-key-vault>",
"SubscriptionHeaderKey": "Ocp-Apim-Subscription-Key",
"DefaultHeaders": {
"x-routing-hint": "primary"
}
}
}
}
}ODataAuthenticationHandler adds SubscriptionHeaderKey: SubscriptionKey to every request, then any entries in DefaultHeaders (routing hints, correlation IDs). DefaultHeaders is applied in ApiKey mode only.
Warning
DefaultHeaders must not carry an authentication header. ODataSettingsValidator rejects Authorization, Bearer, Ocp-Apim-Subscription-Key, or your configured SubscriptionHeaderKey there and fails startup — the framework owns the auth header.
The authentication mode is transparent to callers. The same IMediator.Send works regardless of mode, because the handler applies the header on the outbound HTTP call. This creates the shipped LedgerJournalHeader (IntegratoR.OData.FO) in company USMF:
Result<LedgerJournalHeader> result = await mediator.Send(
new CreateCommand<LedgerJournalHeader>(new LedgerJournalHeader
{
DataAreaId = "USMF",
JournalName = "GenJrn",
Description = "Month-end accrual",
}),
cancellationToken);
if (result.IsFailed)
{
IntegrationError? error = result.GetError();
// OAuth mode with a bad secret: the 401 short-circuit surfaces here as a failed Result —
// Code "LedgerJournalHeader.Unauthorized", Message "Authentication or authorisation failure".
return;
}
// D365 assigns JournalBatchNumber server-side (IgnoreOnCreate).
string batch = result.Value.JournalBatchNumber!;Both modes are shipped and supported. Prefer OAuth for a service that calls D365 directly — it owns its own identity, its token, and its D365 permissions.
- Choose OAuth when: your Function calls the D365 OData endpoint directly; you want the app's own Azure AD identity and per-app D365 permissions; there is no gateway in the path.
- Choose ApiKey when: Azure API Management fronts D365 and holds the OAuth credentials in its policy; one APIM instance routes across dev/test/prod by header; throttling and quota live at the gateway.
Both ClientSecret and SubscriptionKey are secrets. Keep them out of source control.
| Environment | Store |
|---|---|
| Local development |
local.settings.json (gitignored — never commit) |
| Deployed | Azure Key Vault, resolved via Managed Identity / DefaultAzureCredential
|
The host wires Key Vault in SampleFunction/Program.cs with DefaultAzureCredential and the Azure.Extensions.AspNetCore.Configuration.Secrets provider — see Set Up Azure Functions Host. The framework never logs ODataSettings as a whole; at startup it logs only the normalised base URL, never the credentials.
Caution
Never log ClientSecret, SubscriptionKey, or an acquired bearer token — not in application logs, not in an HTTP response body, not in a ReasonPhrase.
The mode is read from IOptions<ODataSettings> on every request, so set it once at startup per environment via ConfigureOData:
services.AddIntegratoR(configuration, integrator =>
{
integrator.ConfigureOData(settings =>
{
settings.Authentication.Mode = context.HostingEnvironment.IsDevelopment()
? AuthenticationMode.OAuth // direct in dev
: AuthenticationMode.ApiKey; // APIM in deployed environments
});
});Per-request mode switching is not supported; the handler is wired once when the HttpClient is built.
Get Started
Use Cases
- Handle Errors
- Configure Resilience
- Add Validation
- Cache Query Results
- Work with Dimensions
- Run Smoke Tests
- Extend the Pipeline
- Test with TestKit
Reference