Setting up Azure AD Authentication for a React SPA and .NET API with Dataverse On-Behalf-Of Flow

.NET logo

This guide covers how to configure Entra authentication for a React Single Page Application (SPA) and a .NET API backend, enabling access to Microsoft Dataverse using the On-Behalf-Of (OBO) flow.

1. Register the Backend API Application

Name: myapp-api

  • Navigate to Azure Portal → Azure Active Directory → App registrations → New registration.
  • Name: myapp-api
  • Supported account types: Single tenant
  • Redirect URI: leave blank.

After registration:

Screenshot of the Azure portal "Expose an API" configuration page for the myapp-api app registration. The Application ID URI is set to api://4ac715bd-612a-4830-89e0-b3ad0efe5a6e. One scope is defined: user_impersonation, with "Admins and users" able to consent, display name "Access Dataverse data," and the state set to "Enabled."
  1. Expose an API:
    • Set the Application ID URI to: api://<myapp-api-app-id>
    • Add a Scope:
      • Name: user_impersonation
      • Display Name/Description: “Access Dataverse data”
      • State: Enabled
  2. Grant permissions:
    • Go to API permissions → Add a permission.
    • Choose APIs my organization uses, search for Dataverse or Dynamics CRM.
    • Add the user_impersonation or .default scope.
    • Grant admin consent
Azure Active Directory API permissions setup for Dataverse integration. "user_impersonation" delegated permission from Dynamics CRM API is configured and granted for tenant. No admin consent required. Used for On-Behalf-Of (OBO) authentication flow in Azure AD-secured .NET APIs accessing Dataverse.

Note: Dataverse App ID: 00000007-0000-0000-c000-000000000000

2. Register the Frontend SPA Application

Name: myapp-frontend

  • Navigate to Azure Portal → Azure Active Directory → App registrations → New registration.
  • Name: myapp-frontend
  • Supported account types: Single tenant
  • Redirect URIs:
    • Platform: Single-page application (SPA)
    • Add: http://localhost:3000 or any URLs your frontend is running on.

After registration:

  1. Enable Implicit grant (under Authentication):
    • ID tokens
    • Access tokens
  2. Assign API permissions:
    • My APIs → select myaapp-api → add user_impersonation, you can search for the Application ID of myapp-api
Azure AD API permissions setup for React SPA using Microsoft Identity platform. Application configured with Microsoft Graph "User.Read" and custom myapp-api "user_impersonation" delegated permissions for Dataverse access via On-Behalf-Of authentication flow.

3. Pre-authorize Frontend in Backend

Azure AD authorized client applications setup showing pre-authorized React SPA frontend (Client ID: 3915cbe2-24b3-41f6-8337-4b0c7ccf4664) for backend API access using user_impersonation scope in On-Behalf-Of authentication flow.
  • In myapp-api, go to Expose an API → Add a client application.
  • – Add:
    • Client ID: 3915cbe2-24b3-41f6-8337-4b0c7ccf4664 (myapp-frontend App ID)
    • Scope: user_impersonation

4. Register the Backend App as a Dataverse App User

  • Copy the myapp-api Application ID.
  • Go to Power Platform Admin Center → App Users → New App User
  • Paste the Application ID.
  • Assign the appropriate security role for Dataverse access.

5. Backend Code Setup

Program.cs

// Choose credential source based on environment (development vs production)
TokenCredential credential = builder.Environment.IsDevelopment() switch
{
    true => new DefaultAzureCredential(),
    false => new DefaultAzureCredential()
};

// Configure authentication using Microsoft Identity Web
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"))
    .EnableTokenAcquisitionToCallDownstreamApi()
    .AddDownstreamApi("DownstreamDataverseApi", builder.Configuration.GetSection("DownstreamDataverseApi"))
    .AddInMemoryTokenCaches();

// Configure HTTP client for Dataverse API calls
builder.Services.AddHttpClient("DataverseClient", client =>
{
    client.BaseAddress = new Uri(dataverseUrl); // Dataverse environment URL
    client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
    client.DefaultRequestHeaders.Add("OData-MaxVersion", "4.0");
    client.DefaultRequestHeaders.Add("OData-Version", "4.0");
    client.DefaultRequestHeaders.Add("Prefer", "odata.include-annotations=\"*\""); // Request annotations
})
// Add authentication handler for injecting tokens
.AddHttpMessageHandler(_ => new DataverseManagedIdentityAuthHandler())
// Optional: Add resilience features (retry policies, etc.)
.AddStandardResilienceHandler();

// Register custom Dataverse client wrapper
builder.Services.AddScoped<DataverseHttpClient>();

var app = builder.Build();

// Enable authentication and authorization middleware
app.UseAuthentication();
app.UseAuthorization();

appsettings.json

{
    "AzureAd": {
        "Audience": "api://4ac715bd-612a-4830-89e0-b3ad0efe5a6e", // Backend API Application ID URI
        "Authority": "https://login.microsoftonline.com/<your-tenant-id>", // Tenant Authority URL
        "Instance": "https://login.microsoftonline.com/", // Common Microsoft login endpoint
        "TenantId": "<your-tenant-id>", // Azure AD Tenant ID
        "Domain": "your-tenant-domain.com", // Tenant domain name
        "ClientId": "4ac715bd-612a-4830-89e0-b3ad0efe5a6e" // Backend API App Registration Client ID
    },
    "DownstreamDataverseApi": {
        "BaseUrl": "https://{your-environment}.api.crm.dynamics.com/", // Dataverse API base URL
        "Scopes": "https://{your-environment}.api.crm.dynamics.com/.default" // Dataverse API default scope
    }
}

DataverseManagedIdentityAuthHandler.cs

// Custom HTTP message handler for authenticating Dataverse API requests
public sealed class DataverseManagedIdentityAuthHandler(
    ITokenAcquisition tokenAcquisition,
    IHttpContextAccessor httpContextAccessor,
    IConfiguration configuration) : DelegatingHandler
{
    // This method is called for every outgoing HTTP request
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        // Get the current authenticated user from HTTP context
        var user = httpContextAccessor.HttpContext?.User;

        // Reject if no authenticated user is found
        if (user?.Identity is not { IsAuthenticated: true })
        {
            throw new UnauthorizedAccessException("User is not authenticated");
        }

        // Load required scopes from configuration
        var scopesToAccessDataverseApi = configuration["DownstreamDataverseApi:Scopes"]
            ?? throw new Exception("Configuration 'DownstreamDataverseApi:Scopes' is missing.");

        // Acquire access token on behalf of the authenticated user
        var token = await tokenAcquisition.GetAccessTokenForUserAsync(
            [scopesToAccessDataverseApi],
            user: user);

        // Attach the token to the outgoing HTTP request
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

        // Proceed with sending the HTTP request
        return await base.SendAsync(request, cancellationToken);
    }
}

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *