Testing Authorization

Unit test authorization handlers, test policy wiring through IAuthorizationService, and assert 401, 403 and 404 over HTTP with a test scheme.

Syntaxvar context = new AuthorizationHandlerContext([OrderOperations.Cancel], user, order); await new OrderAuthorizationHandler().HandleAsync(context); Assert.True(context.HasSucceeded);

Authorization bugs do not crash anything; they quietly return data to the wrong caller. Tests are the only routine way to see them, and they work best at three levels, each answering a different question.

LevelWhat it provesCost
Handler unit testThe rule is right for each kind of user and resourceMicroseconds, no host
Policy test via IAuthorizationServicePolicies and handlers are registered and combine as intendedA ServiceCollection, no HTTP
HTTP test with WebApplicationFactoryEndpoints carry the policies and return 401, 403 or 404An in-memory server per test class

Handler unit tests

Build an AuthorizationHandlerContext from the requirement, a ClaimsPrincipal and the resource, call handler.HandleAsync(context), and assert HasSucceeded or HasFailed. Create principals with new ClaimsIdentity(claims, "Test", "sub", "role"), the same claim types the API and the test handler use: without an authentication type the identity is not authenticated, and without the role claim type IsInRole ignores your role claims. Cover the owner, a stranger, the privileged role and the edge of every threshold.

Policy tests

Register the app's real authorization setup on a fresh ServiceCollection with AddLogging(), resolve IAuthorizationService and call AuthorizeAsync(user, resource, policyName). This catches a handler that was never registered, which a handler unit test cannot.

The HTTP status matrix

Run these tests through WebApplicationFactory with the test authentication handler from Authenticated Integration Tests (net-test-auth), which covers how to write it and register it as the default scheme; callers pick a persona with Authorization: Test <persona>. What this lesson adds is the assertion: one theory row per caller and route, asserting the refusals as carefully as the success. Anonymous gets 401, a signed-in user without the role or scope gets 403, another customer or tenant gets 404 when the design hides existence, and the owner gets 200.

An endpoint inventory test

Resolve EndpointDataSource, walk every RouteEndpoint and require authorization metadata (IAuthorizeData, AuthorizationPolicy or IAuthorizationRequirementData) or IAllowAnonymous with the route on an explicit allow list. It fails the build when someone adds an unprotected endpoint or opens one with AllowAnonymous, which no behavioural test would notice.

Common mistakes

  • Only testing the happy path. The cases that matter are the refusals.
  • Asserting 403 where the design says 404, which means the API leaks existence.
  • Sharing one client with default headers across tests, so users bleed between tests. Create a client per persona.
  • Testing roles with a claim type the handler under test does not read, so role rows pass or fail for the wrong reason.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Pin down who may cancel order 1042 with a theory covering the owner, another customer and a support agent
  • Fail CI when someone maps GET /orders/export without RequireAuthorization or a reviewed AllowAnonymous
  • Assert that a customer requesting another customer's order gets 404, not 403 or 200, through the real pipeline

More examples

xUnit v3 theory for a handler

A theory turns the access rules into a readable table, and adding a row is the natural way to record a new rule or a bug that was fixed. The handler is called directly through <code>HandleAsync</code>, so these tests need no host and run in microseconds. The identity's role claim type must match what the handler expects, otherwise every role row fails for the wrong reason.

Example · csharp
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using Xunit;

namespace Shop.Api.Tests;

public sealed record Order(int Id, string CustomerId, string Status);

public static class OrderOperations
{
    public static readonly OperationAuthorizationRequirement Read = new() { Name = nameof(Read) };
    public static readonly OperationAuthorizationRequirement Cancel = new() { Name = nameof(Cancel) };
}

public sealed class OrderAuthorizationHandler : AuthorizationHandler<OperationAuthorizationRequirement, Order>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, OperationAuthorizationRequirement requirement, Order order)
    {
        var isOwner = context.User.FindFirstValue("sub") == order.CustomerId;
        var allowed = requirement.Name == nameof(OrderOperations.Read)
            ? isOwner || context.User.IsInRole("support")
            : isOwner;
        if (allowed) context.Succeed(requirement);
        return Task.CompletedTask;
    }
}

public sealed class OrderAuthorizationHandlerTests
{
    private static readonly Order Placed = new(1042, "cust-17", "Placed");

    [Theory]
    [InlineData("cust-17", "", "Read", true)]
    [InlineData("cust-17", "", "Cancel", true)]
    [InlineData("cust-42", "", "Read", false)]
    [InlineData("staff-3", "support", "Read", true)]
    [InlineData("staff-3", "support", "Cancel", false)]
    public async Task Decides_by_owner_and_role(string subject, string role, string operation, bool expected)
    {
        var claims = new List<Claim> { new("sub", subject) };
        if (role.Length > 0) claims.Add(new Claim("role", role));
        // Authentication type makes the identity authenticated; the last argument makes IsInRole read "role".
        var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test", "sub", "role"));
        var requirement = operation == "Read" ? OrderOperations.Read : OrderOperations.Cancel;
        var context = new AuthorizationHandlerContext([requirement], user, Placed);

        await new OrderAuthorizationHandler().HandleAsync(context);

        Assert.Equal(expected, context.HasSucceeded);
    }
}

HTTP matrix with WebApplicationFactory and a test scheme

The test is the matrix; the handler and factory are the ones from Authenticated Integration Tests, unchanged, so the whole suite has one way to sign in. Each row names a persona and the status it must get: anonymous 401, a customer on a back-office route 403, another customer's order 404, the owner and the support agent 200. A client per row keeps personas from leaking between cases, and the class fixture builds the host once for every row.

Example · csharp
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 OrderAccessMatrixTests(AuthenticatedApiFactory factory) : IClassFixture<AuthenticatedApiFactory>
{
    [Theory]
    [InlineData(null, "/orders/1042", HttpStatusCode.Unauthorized)]
    [InlineData("alice", "/orders/1042", HttpStatusCode.OK)]
    [InlineData("dave", "/orders/1042", HttpStatusCode.NotFound)]
    [InlineData("alice", "/admin/orders", HttpStatusCode.Forbidden)]
    [InlineData("bob-support", "/admin/orders", HttpStatusCode.OK)]
    public async Task Access_matrix(string? persona, string path, HttpStatusCode expected)
    {
        // A client per row: the persona never leaks into another test case.
        using var client = persona is null ? factory.CreateClient() : factory.CreateClientAs(persona);

        using var response = await client.GetAsync(path, TestContext.Current.CancellationToken);

        Assert.Equal(expected, response.StatusCode);
    }
}

// The factory, handler and personas below are the ones from Authenticated Integration Tests (net-test-auth).
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;
    }
}

// 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]));
        }

        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", "acme")],
        ["dave"] = [new("sub", "cust-42"), new("tenant", "acme")],
        ["bob-support"] = [new("sub", "staff-3"), new("tenant", "acme"), new("role", "support")],
    };
}

// 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;

An endpoint inventory test

This test reads routing metadata instead of sending requests, so it covers every endpoint, including ones nobody wrote a behavioural test for. It fails in two directions: a route with no authorization metadata, and a route opened with <code>AllowAnonymous</code> that is not on the reviewed list. When a fallback policy is set, a route without metadata is protected by it, so only the second check applies; that check is why the test is worth keeping even then, because it turns every new public route into a visible change in code review.

Example · csharp
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Options;
using Xunit;

public sealed class EndpointSecurityTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
    // Every entry here is a reviewed decision to make a route public.
    private static readonly HashSet<string> PublicRoutes =
    [
        "/health/live",
        "/health/ready",
        "/products",
        "/products/{id:int}",
    ];

    [Fact]
    public void Every_endpoint_is_protected_or_explicitly_public()
    {
        var endpoints = factory.Services.GetRequiredService<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>();
        // With a fallback policy, an endpoint without metadata is still protected.
        var hasFallback = factory.Services.GetRequiredService<IOptions<AuthorizationOptions>>().Value.FallbackPolicy is not null;
        var problems = new List<string>();

        foreach (var endpoint in endpoints)
        {
            var route = endpoint.RoutePattern.RawText ?? endpoint.DisplayName ?? "?";
            var isAnonymous = endpoint.Metadata.GetMetadata<IAllowAnonymous>() is not null;
            var isProtected = endpoint.Metadata.GetMetadata<IAuthorizeData>() is not null
                || endpoint.Metadata.GetMetadata<AuthorizationPolicy>() is not null
                || endpoint.Metadata.GetMetadata<IAuthorizationRequirementData>() is not null;

            if (isAnonymous && !PublicRoutes.Contains(route)) problems.Add($"{route} is AllowAnonymous but not on the public list");
            else if (!isAnonymous && !isProtected && !hasFallback) problems.Add($"{route} has no authorization metadata");
        }

        Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems));
    }
}

// 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

  • Be the first to comment on this lesson.