Authenticated Integration Tests
Run integration tests as specific users with a test authentication handler, per-test claims and, where it matters, real signed tokens.
services.AddAuthentication(o => o.DefaultScheme =
o.DefaultAuthenticateScheme = o.DefaultChallengeScheme = "Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", null);
// request: Authorization: Test aliceMost API endpoints require a signed-in caller, so most integration tests need one. Fetching real tokens from an identity provider in tests is slow, needs network and secrets, and fails when the provider is down. The usual answer is a test authentication scheme: a handler that builds a ClaimsPrincipal from the request without any cryptography, registered only in the test host.
The test handler
Derive from AuthenticationHandler<AuthenticationSchemeOptions> and override HandleAuthenticateAsync. Return NoResult() when the request carries no test credentials, so the anonymous path still produces a challenge and a 401; return Fail for an unknown user; return Success with a ticket otherwise. Build the identity with an authentication type (new ClaimsIdentity(claims, Scheme.Name, "sub", "role")): without one, IsAuthenticated is false and every request is a 401; without the right role claim type, RequireRole never matches. The base class already turns a challenge into 401 and a forbid into 403, so those status codes are tested for real.
Choosing the user per test
| Approach | Cost | Use for |
|---|---|---|
User in a request header (Authorization: Test alice) | One host for every user | The default: most suites |
Default user configured per factory with WithWebHostBuilder | One host per user | A handful of very different identities |
| Real JWTs signed with a key generated for the run | Token minting per test, real JwtBearer validation | A few tests of audience, issuer, expiry and claim mapping |
Named personas (a customer, a support agent, a user from another tenant) keep tests readable and make the authorization matrix obvious. Allow extra claims per request for the edge cases, such as a customer who also holds the orders.refund scope.
Wiring it into the factory
In ConfigureTestServices, call AddAuthentication with the test scheme as default and AddScheme<AuthenticationSchemeOptions, TestAuthHandler>. Setting the defaults after the app's registration replaces Bearer as the default scheme. AddAuthentication(name) sets only DefaultScheme: if the app set DefaultAuthenticateScheme or DefaultChallengeScheme explicitly, those still point at Bearer, so set all three to the test scheme. Policies that name a scheme explicitly (AddAuthenticationSchemes("Bearer")) ignore the default; for those, point the existing Bearer scheme at the test handler instead of adding a new scheme.
What the shortcut cannot see
The test scheme skips token validation, so the tests stay green when the audience is misconfigured, the clock skew is wrong, or the identity provider sends roles in a claim the app does not map. Keep a few tests that mint real tokens with an RSA key generated per run and send them through the unchanged JwtBearer handler; the Issuing Tokens for Development and Tests lesson has the full minting setup. And keep the test handler in the test project: a test scheme that is reachable in production is an authentication bypass.
Example
When to use it
- Run 120 endpoint tests as a customer, a support agent and a user from another tenant against one test host, choosing the user per request.
- Prove that a customer from tenant globex gets 404, not 403, for an acme order, so the API never confirms that the order exists.
- Catch a deploy that would accept tokens issued for billing-api by minting a wrong-audience JWT in a test and expecting 401.
More examples
The factory and tests that use personas
Setting all three defaults matters: <code>DefaultScheme</code> alone does not override a <code>DefaultChallengeScheme</code> the app set explicitly, and the challenge would still go to JwtBearer. One factory and one host serve every persona because the user travels in the header, so the theory costs one host start. The scoped-persona test shows why per-request extra claims beat a persona per combination: the policy difference is one claim, and the test says so.
using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Options;
using Xunit;
public sealed class AuthenticatedApiFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.ConfigureTestServices(services =>
services.AddAuthentication(options =>
{
options.DefaultScheme = TestAuthHandler.SchemeName;
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, null));
public HttpClient CreateClientAs(string persona)
{
var client = CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName, persona);
return client;
}
}
public sealed class OrderAccessTests(AuthenticatedApiFactory factory) : IClassFixture<AuthenticatedApiFactory>
{
[Theory]
[InlineData("alice", HttpStatusCode.OK)]
[InlineData("bob-support", HttpStatusCode.OK)]
[InlineData("carol-globex", HttpStatusCode.NotFound)]
public async Task Orders_are_visible_only_inside_the_tenant(string persona, HttpStatusCode expected)
{
using var client = factory.CreateClientAs(persona);
using var response = await client.GetAsync("/tenants/acme/orders/1042", TestContext.Current.CancellationToken);
Assert.Equal(expected, response.StatusCode);
}
[Fact]
public async Task Refund_needs_the_scope_not_just_a_login()
{
using var plain = factory.CreateClientAs("alice");
using var scoped = factory.CreateClientAs("alice scope=orders.refund");
using var denied = await plain.PostAsync("/orders/1042/refund", null, TestContext.Current.CancellationToken);
using var allowed = await scoped.PostAsync("/orders/1042/refund", null, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
Assert.Equal(HttpStatusCode.Accepted, allowed.StatusCode);
}
[Fact]
public async Task Anonymous_callers_get_401()
{
using var client = factory.CreateClient();
using var response = await client.GetAsync("/tenants/acme/orders/1042", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
// Stand-in so this file compiles on its own. Delete it in a real test project: in .NET 10 the API's
// generated Program is already public, and a Program declared here would shadow it (CS0436).
public partial class Program;
// Accepts "Authorization: Test <persona> [claim=value ...]" and nothing else.
public sealed class TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string SchemeName = "Test";
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var header = Request.Headers.Authorization.ToString();
if (!header.StartsWith(SchemeName + " ", StringComparison.Ordinal))
return Task.FromResult(AuthenticateResult.NoResult()); // anonymous: the 401 path stays testable
var parts = header[(SchemeName.Length + 1)..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0 || !TestUsers.Personas.TryGetValue(parts[0], out var baseClaims))
return Task.FromResult(AuthenticateResult.Fail($"Unknown test persona '{header}'"));
var claims = new List<Claim>(baseClaims);
foreach (var extra in parts.Skip(1))
{
var pair = extra.Split('=', 2);
claims.Add(new Claim(pair[0], pair[1])); // per-test additions such as scope=orders.refund
}
// Authentication type set and role claim type named, exactly like the real JWT setup.
var identity = new ClaimsIdentity(claims, Scheme.Name, nameType: "sub", roleType: "role");
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)));
}
}
public static class TestUsers
{
public static readonly Dictionary<string, Claim[]> Personas = new()
{
["alice"] = [new("sub", "cust-17"), new("tenant_id", "acme")],
["bob-support"] = [new("sub", "staff-3"), new("tenant_id", "acme"), new("role", "support")],
["carol-globex"] = [new("sub", "cust-88"), new("tenant_id", "globex")],
};
}A few tests with real signed tokens
A short version of the setup in the Issuing Tokens for Development and Tests lesson, which also covers expiry and clock skew. The app's JwtBearer handler stays as it is; only the key source changes, through a static <code>OpenIdConnectConfiguration</code>, so issuer, audience and algorithm checks still run. The key is RSA and signs with RS256, because an API that pins <code>ValidAlgorithms</code> to RS256 rejects an HS256 test token and every test would get 401. The rows catch a wrong audience and a policy that expects one scope per claim when real tokens carry <code>"orders.read orders.refund"</code> in one claim.
using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using Xunit;
public sealed class RealTokenFactory : WebApplicationFactory<Program>
{
public const string Issuer = "https://login.shop.test/realms/shop";
// Generated per run and RS256 like production, so the API's ValidAlgorithms pin still applies.
public RsaSecurityKey Key { get; } = new(RSA.Create(2048)) { KeyId = "test-key" };
// Only the key source changes: a static configuration instead of a metadata download.
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.ConfigureTestServices(services =>
services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.Configuration = new OpenIdConnectConfiguration { Issuer = Issuer };
options.Configuration.SigningKeys.Add(Key);
}));
public string Mint(string scope, string audience = "orders-api") =>
new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{
Issuer = Issuer,
Audience = audience,
Expires = DateTime.UtcNow.AddMinutes(10),
Subject = new ClaimsIdentity([new Claim("sub", "cust-17"), new Claim("tenant_id", "acme"), new Claim("scope", scope)]),
SigningCredentials = new SigningCredentials(Key, SecurityAlgorithms.RsaSha256),
});
}
public sealed class TokenValidationTests(RealTokenFactory factory) : IClassFixture<RealTokenFactory>
{
[Theory]
[InlineData("orders.read orders.refund", "orders-api", HttpStatusCode.Accepted)] // scope inside a multi-scope claim
[InlineData("orders.read orders.refund", "billing-api", HttpStatusCode.Unauthorized)]
[InlineData("orders.read", "orders-api", HttpStatusCode.Forbidden)]
public async Task Refund_needs_a_valid_token_and_the_scope(string scope, string audience, HttpStatusCode expected)
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", factory.Mint(scope, audience));
using var response = await client.PostAsync("/orders/1042/refund", null, TestContext.Current.CancellationToken);
Assert.Equal(expected, response.StatusCode);
}
}
// Stand-in so this file compiles on its own. Delete it in a real test project: in .NET 10 the API's
// generated Program is already public, and a Program declared here would shadow it (CS0436).
public partial class Program;Policies that name the Bearer scheme explicitly
Adding a second scheme called <code>Bearer</code> throws because the name is taken, and making a test scheme the default does not reach a policy that lists <code>Bearer</code> by name. Changing <code>HandlerType</code> on the registered scheme keeps every policy, endpoint and <code>[Authorize(AuthenticationSchemes = "Bearer")]</code> attribute untouched while the test handler answers for that name. The request then sends <code>Authorization: Test sub=partner-9; partner_id=globex</code>.
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Options;
// The API has: options.AddPolicy("Partners", p => p.AddAuthenticationSchemes("Bearer").RequireClaim("partner_id"));
// A new default scheme does not help: the policy authenticates with "Bearer" only and every test gets 401.
public sealed class PartnerApiFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.ConfigureTestServices(services =>
services.Configure<AuthenticationOptions>(options =>
{
// Keep the scheme name every policy refers to, swap the code behind it.
var bearer = options.Schemes.Single(s => s.Name == "Bearer");
bearer.HandlerType = typeof(HeaderClaimsHandler);
}));
}
public sealed class HeaderClaimsHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var header = Request.Headers.Authorization.ToString();
if (!header.StartsWith("Test ", StringComparison.Ordinal)) return Task.FromResult(AuthenticateResult.NoResult());
var claims = header[5..].Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select(pair => pair.Split('=', 2))
.Select(kv => new Claim(kv[0].Trim(), kv[1].Trim()));
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name, "sub", "role"));
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)));
}
}
// Stand-in so this file compiles on its own. Delete it in a real test project: in .NET 10 the API's
// generated Program is already public, and a Program declared here would shadow it (CS0436).
public partial class Program;
Discussion