Testing with Azure Entra ID Tokens
Level: Advanced 📙 | Time: 20-30 min | Prerequisites: Testing, ASP.NET Core Authorization
Most Trellis integration tests should use CreateClientWithActor(...). It is fast and perfect when you only want to test authorization behavior inside your app.
But sometimes that is not enough.
If you need to verify the real authentication path—token issuance, JWT validation, claim mapping, and EntraActorProvider—you need real Entra tokens. That is what MsalTestTokenProvider is for.
When should you use this?
Use real Entra tokens when you want confidence in:
- Azure Entra ID issuing the token you expect
- ASP.NET Core JWT validation
- your claim-to-permission mapping
EntraActorProviderbehavior in production-like conditions
Use CreateClientWithActor(...) when you only need to test:
- application authorization rules
- handler behavior
- endpoint behavior
flowchart LR
A[Fast integration tests] --> B[X-Test-Actor header]
C[Full auth pipeline tests] --> D[MsalTestTokenProvider]
D --> E[Bearer token]
E --> F[JWT validation]
F --> G[EntraActorProvider]
Install the package
dotnet add package Trellis.Testing
How it works
MsalTestTokenProvider uses the MSAL ROPC flow against a dedicated test tenant:
- one Entra test tenant
- one public client app registration
- test users with known passwords
- MFA disabled for those test users
Warning
ROPC is acceptable for automated test tenants. It is not a production authentication pattern.
Tenant setup checklist
1. Create a dedicated test tenant
Never reuse a production tenant for automated tests.
2. Register a public client app
In the app registration:
- supported account type: single tenant
- allow public client flows: Yes
3. Assign app roles to test users
Create users that match the permission profiles you want to test, then assign roles through the Enterprise Application.
4. Disable MFA for those test users
ROPC does not support interactive MFA.
Store configuration safely
Local development should use dotnet user-secrets. CI should use environment variables.
Local secrets
dotnet user-secrets set "EntraTest:TenantId" "<tenant-id>"
dotnet user-secrets set "EntraTest:ClientId" "<client-id>"
dotnet user-secrets set "EntraTest:Scopes:0" "api://<client-id>/.default"
dotnet user-secrets set "EntraTest:TestUsers:salesRep:Username" "salesrep@contoso-test.onmicrosoft.com"
dotnet user-secrets set "EntraTest:TestUsers:salesRep:Password" "<password>"
dotnet user-secrets set "EntraTest:TestUsers:admin:Username" "admin@contoso-test.onmicrosoft.com"
dotnet user-secrets set "EntraTest:TestUsers:admin:Password" "<password>"
CI environment variables
Configuration binding uses the EntraTest__ prefix and double underscores for nesting.
env:
EntraTest__TenantId: ${{ secrets.ENTRA_TEST_TENANT_ID }}
EntraTest__ClientId: ${{ secrets.ENTRA_TEST_CLIENT_ID }}
EntraTest__Scopes__0: api://${{ secrets.ENTRA_TEST_CLIENT_ID }}/.default
EntraTest__TestUsers__salesRep__Username: ${{ secrets.ENTRA_TEST_SALESREP_USERNAME }}
EntraTest__TestUsers__salesRep__Password: ${{ secrets.ENTRA_TEST_SALESREP_PASSWORD }}
Bind configuration into MsalTestOptions
MsalTestTokenProvider takes MsalTestOptions, so keep the test setup boring and explicit.
using Microsoft.Extensions.Configuration;
using Trellis.Testing;
var configuration = new ConfigurationBuilder()
.AddUserSecrets<Program>(optional: true)
.AddEnvironmentVariables()
.Build();
var options = new MsalTestOptions();
configuration.GetSection("EntraTest").Bind(options);
var tokenProvider = new MsalTestTokenProvider(options);
Tip
Keep one MsalTestTokenProvider instance per fixture or test class. MSAL caches tokens per provider instance.
First working test
The simplest happy path is: create the token provider once, create a client for a named test user, then call the real API.
using System.Net;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Trellis.Testing;
using Xunit;
[JsonSerializable(typeof(CreateOrderRequest))]
internal partial class ApiJsonContext : JsonSerializerContext
{
}
public sealed class Program
{
}
public sealed record CreateOrderRequest(string CustomerId, int Quantity);
public sealed class EntraTestFixture
{
public EntraTestFixture(WebApplicationFactory<Program> factory)
{
Factory = factory;
var configuration = new ConfigurationBuilder()
.AddUserSecrets<EntraTestFixture>(optional: true)
.AddEnvironmentVariables()
.Build();
var options = new MsalTestOptions();
configuration.GetSection("EntraTest").Bind(options);
if (string.IsNullOrWhiteSpace(options.TenantId) ||
string.IsNullOrWhiteSpace(options.ClientId))
{
throw new InvalidOperationException("Configure EntraTest before running Entra token tests.");
}
TokenProvider = new MsalTestTokenProvider(options);
}
public WebApplicationFactory<Program> Factory { get; }
public MsalTestTokenProvider TokenProvider { get; }
}
public sealed class OrdersEntraTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly EntraTestFixture _fixture;
public OrdersEntraTests(WebApplicationFactory<Program> factory)
{
_fixture = new EntraTestFixture(factory);
}
[Fact]
public async Task Sales_rep_can_create_orders()
{
var client = await _fixture.Factory.CreateClientWithEntraTokenAsync(
_fixture.TokenProvider,
"salesRep");
var response = await client.PostAsJsonAsync(
"/api/orders",
new CreateOrderRequest("customer-1", 2),
ApiJsonContext.Default.CreateOrderRequest);
response.StatusCode.Should().Be(HttpStatusCode.Created);
}
}
Verifying roles and permissions
If you store the expected permissions in configuration, you can use them for sanity checks in your test setup.
dotnet user-secrets set "EntraTest:TestUsers:salesRep:ExpectedPermissions:0" "orders:create"
dotnet user-secrets set "EntraTest:TestUsers:salesRep:ExpectedPermissions:1" "orders:read"
That is useful when you want the test fixture to verify the tenant still matches the documented role model.
Common failure modes
| Error | Usually means | Fix |
|---|---|---|
AADSTS50126 |
username or password is wrong | reset the test user password |
AADSTS50076 |
MFA is required | exclude test users from MFA |
AADSTS7000218 |
app is not configured as public client | enable public client flows |
AADSTS650057 |
wrong scope | use api://{clientId}/.default |
| token authenticates but app sees no permissions | app roles were not assigned | assign users/groups in Enterprise Applications |
Practical guidance
Keep these tests small in number
They validate the real auth pipeline, so they are slower and more environment-dependent than header-based integration tests.
Use them as confidence tests, not as your entire suite
A few targeted scenarios usually go much further than duplicating every authorization test with real tokens.
Isolate the tenant
Treat the test tenant as disposable infrastructure, not as a shared long-lived environment with manual changes.