Learnixo
Back to blog
AI Systemsintermediate

Testing Authentication and Authorisation in ASP.NET Core

Write integration tests for secured ASP.NET Core APIs: fake JWT authentication, custom test auth handlers, testing role-based and policy-based authorisation, and WebApplicationFactory patterns for clinical APIs.

Asma Hafeez KhanMay 16, 20265 min read
Integration TestingAuthenticationAuthorisationASP.NET Core.NETxUnit
Share:𝕏

The Authentication Testing Problem

Integration tests for a secured API need to:
  → Make authenticated requests as specific users (nurse, pharmacist, admin)
  → Test that unauthenticated requests return 401
  → Test that unauthorised users (wrong role) return 403
  → Not require a real identity provider (Entra ID, Keycloak) during testing

Options:
  1. Disable authentication in tests — misses entire auth layer, dangerous
  2. Issue real JWTs in tests — couples tests to identity provider, slow
  3. Custom test authentication handler — fast, fully controlled, recommended

WebApplicationFactory Setup

C#
// Test-specific factory that replaces the real auth scheme with a test one

public sealed class ClinicalApiFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Replace real JWT auth with test auth handler
            services.AddAuthentication(TestAuthHandler.SchemeName)
                .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
                    TestAuthHandler.SchemeName, _ => { });

            // Override database with test connection
            services.RemoveAll<DbContextOptions<PrescriptionsDbContext>>();
            services.AddDbContext<PrescriptionsDbContext>(opts =>
                opts.UseSqlServer(TestDatabase.ConnectionString));
        });

        builder.UseEnvironment("IntegrationTest");
    }
}

Test Authentication Handler

C#
// Reads claims from a request header — tests set the header to simulate any user
public sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public const string SchemeName    = "TestAuth";
    public const string UserIdHeader  = "X-Test-UserId";
    public const string RolesHeader   = "X-Test-Roles";

    public TestAuthHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder)
        : base(options, logger, encoder) { }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue(UserIdHeader, out var userId))
            return Task.FromResult(AuthenticateResult.NoResult());

        var roles = Request.Headers.TryGetValue(RolesHeader, out var rolesHeader)
            ? rolesHeader.ToString().Split(',')
            : Array.Empty<string>();

        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, userId.ToString()),
            new(ClaimTypes.Name,           userId.ToString()),
        };
        claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r.Trim())));

        var identity  = new ClaimsIdentity(claims, SchemeName);
        var principal = new ClaimsPrincipal(identity);
        var ticket    = new AuthenticationTicket(principal, SchemeName);

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

Authenticated Client Extensions

C#
// Extension methods for creating pre-configured test clients

public static class HttpClientExtensions
{
    public static HttpClient AsNurse(this HttpClient client, Guid? userId = null)
    {
        client.DefaultRequestHeaders.Set(
            TestAuthHandler.UserIdHeader, (userId ?? Guid.NewGuid()).ToString());
        client.DefaultRequestHeaders.Set(
            TestAuthHandler.RolesHeader, "Nurse");
        return client;
    }

    public static HttpClient AsPharmacist(this HttpClient client, Guid? userId = null)
    {
        client.DefaultRequestHeaders.Set(
            TestAuthHandler.UserIdHeader, (userId ?? Guid.NewGuid()).ToString());
        client.DefaultRequestHeaders.Set(
            TestAuthHandler.RolesHeader, "Pharmacist,Nurse");
        return client;
    }

    public static HttpClient AsAdmin(this HttpClient client) =>
        client.WithHeaders(
            TestAuthHandler.UserIdHeader, Guid.NewGuid().ToString(),
            TestAuthHandler.RolesHeader,  "Admin,Pharmacist,Nurse");

    private static HttpClient WithHeaders(
        this HttpClient client, string nameHeader, string nameValue,
        string rolesHeader, string rolesValue)
    {
        client.DefaultRequestHeaders.Set(nameHeader, nameValue);
        client.DefaultRequestHeaders.Set(rolesHeader, rolesValue);
        return client;
    }
}

Writing Auth Tests

C#
[Collection("ClinicalApi")]
public sealed class PrescriptionAuthTests : IClassFixture<ClinicalApiFactory>
{
    private readonly ClinicalApiFactory _factory;

    public PrescriptionAuthTests(ClinicalApiFactory factory) => _factory = factory;

    [Fact]
    public async Task ApprovePrescription_Unauthenticated_Returns401()
    {
        var client = _factory.CreateClient();
        // No auth headers — unauthenticated

        var response = await client.PostAsJsonAsync(
            $"/api/prescriptions/{Guid.NewGuid()}/approve",
            new { InrValue = 2.5 });

        response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
    }

    [Fact]
    public async Task ApprovePrescription_AsNurse_Returns403()
    {
        // Nurses can create prescriptions but not approve them
        var client = _factory.CreateClient().AsNurse();

        var response = await client.PostAsJsonAsync(
            $"/api/prescriptions/{Guid.NewGuid()}/approve",
            new { InrValue = 2.5 });

        response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
    }

    [Fact]
    public async Task ApprovePrescription_AsPharmacist_Succeeds()
    {
        var prescriptionId = await CreateDraftPrescription();
        var client         = _factory.CreateClient().AsPharmacist();

        var response = await client.PostAsJsonAsync(
            $"/api/prescriptions/{prescriptionId}/approve",
            new { InrValue = 2.5, CheckedAt = DateTime.UtcNow });

        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }

    [Fact]
    public async Task GetAllPrescriptions_AsNurse_ReturnsOnlyOwnWardPrescriptions()
    {
        // Test ward-scoped data access — nurses see only their ward
        var nurseId    = Guid.NewGuid();
        var nurseWardId = await GetNurseWardIdAsync(nurseId);
        var client     = _factory.CreateClient().AsNurse(nurseId);

        var response = await client.GetAsync("/api/prescriptions");
        var result   = await response.Content
            .ReadFromJsonAsync<List<PrescriptionDto>>();

        // All returned prescriptions should belong to the nurse's ward
        result!.All(p => p.WardId == nurseWardId).Should().BeTrue();
    }
}

Testing Policy-Based Authorisation

C#
// Policy: "CanApproveWarfarin" requires Pharmacist role AND clinical certification

// In Program.cs:
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("CanApproveWarfarin", policy =>
        policy.RequireRole("Pharmacist")
              .RequireClaim("clinical_cert", "warfarin_certified"));
});

// Test client with certification claim:
public static HttpClient AsCertifiedPharmacist(this HttpClient client)
{
    client.DefaultRequestHeaders.Set(TestAuthHandler.UserIdHeader, Guid.NewGuid().ToString());
    client.DefaultRequestHeaders.Set(TestAuthHandler.RolesHeader, "Pharmacist");
    client.DefaultRequestHeaders.Set("X-Test-Claims", "clinical_cert=warfarin_certified");
    return client;
}

// Test that uncertified pharmacist cannot approve Warfarin:
[Fact]
public async Task ApproveWarfarin_UncertifiedPharmacist_Returns403()
{
    var client = _factory.CreateClient().AsPharmacist(); // no warfarin certification
    var response = await client.PostAsJsonAsync(
        $"/api/prescriptions/{warfarinPrescriptionId}/approve",
        new { InrValue = 2.5 });

    response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

Production issue I've seen: A clinical API had integration tests that simply disabled authentication in the test environment (services.AddAuthentication().AddFakeAuth()). All tests passed. When deployed, the authorisation policies (nurses cannot approve prescriptions) worked correctly — but a subtle bug in ward-scoping meant a nurse in Ward A could approve prescriptions from Ward B. This bug was never tested because the test setup didn't include user-to-ward mappings. Adding tests with specific nurse user IDs and ward assignments (using the TestAuthHandler pattern) caught the ward-scoping bug in the integration suite within 20 minutes of the tests being written.


Key Takeaway

Replace real JWT authentication with a TestAuthHandler that reads identity from request headers — tests set the headers to simulate any user or role. Extend HttpClient with convenience methods like AsNurse(), AsPharmacist(), AsAdmin(). Test all three auth states: unauthenticated (401), authenticated but unauthorised (403), and authorised (200). Test policy-based auth and data-scoping rules (ward-level access, tenant isolation) — these are the auth bugs most likely to reach production.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.