[HOWTO] Implement integration tests for ASP.NET Core Web API with AntiForgery token validation

Unit tests are great and helpful. However, when implementing ASP.NET Core Web APIs, unit tests alone are not sufficient in my opinion. Especially when it comes to testing of ApiControllers, complex business logic or interaction of several components/layers (i.e. controller, service, repository and data layer). Some people may state that you can just stub, mock or fake dependencies and then test each layer (i.e. controller methods) separately. Yes, that’s possible, but I see some huge advantages of integration tests where you call your API like a client over testing every layer separately with unit tests:

  • Test code acts like a real client (i.e. frontend, webhook, …) by doing REST requests over HTTP
  • Serialization and deserialization included
  • Server application startup code is executed
    • Configuration of required services equal to production (except services overridden by ConfigureTestServices)
    • Middleware components that build the request handling pipeline are registered and executed
    • Dependency
  • Built-in support for dependency injection (DI)

Just to name a few – there are for sure many more.

With Microsoft.AspNetCore.Mvc.Testing Microsoft provides a great library/NuGet package that streamlines creation and execution of integration tests in ASP.NET Core. It provides and manages infrastructure components like test web host and in-memory test server. The official docs are a great source for getting started with integration tests in ASP.NET Core.

Of course, there are also some challenges to overcome. Most of them because of my goal to be as near as possible to production when executing integration tests – especially in terms of security features. This means may I fake some security features but try to not get rid of them or circumvent them. With this idea in mind you don’t go the easy, well documented way. Samples and docs don’t cover all aspects of that scenario.
For documentation purposes and to spare others out there the same difficulties, I will address the ones I had to overcome below. But first some information about the setup of the application/solution I am referring to in this blog post:

  • Backend: ASP.NET Core Web API
  • Frontend: React
  • Backend For Frontend (BFF) design pattern is used
  • CI/CD pipeline builds backend and frontend and then copies the built frontend sources into wwwroot directory of the backend
  • In local DEV environment YARP is used to proxy non-API requests to Vite dev server that is used to serve the frontend
  • Identity provider: Microsoft Entra Id
  • Authentication flow: OpenID Connect Code flow with PKCE
  • Authentication scheme: Cookies
  • Additional security features
    • AntiForgery
    • Strict Content Security Policy (CSP)
    • Recommended security headers
    • No external CDN

Now let’s dive into the code assuming you are familiar with the Integration tests in ASP.NET Core documentation.

First a few insights into code snippets of the server (ASP.NET Core Web API).

Program.cs

using Azure.Identity;
using ExampleSolution.Server.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.WebHost
    .ConfigureKestrel(serverOptions => { serverOptions.AddServerHeader = false; })
    .ConfigureAppConfiguration((_, configurationBuilder) =>
    {
        var config = configurationBuilder.Build();
        var azureKeyVaultEndpoint = config["AzureKeyVaultEndpoint"];
        if (!string.IsNullOrEmpty(azureKeyVaultEndpoint))
        {
            // Add Secrets from KeyVault
            Log.Information("Use secrets from {AzureKeyVaultEndpoint}", azureKeyVaultEndpoint);
            configurationBuilder.AddAzureKeyVault(new Uri(azureKeyVaultEndpoint), new DefaultAzureCredential());
        }
        else
        {
            // Add Secrets from UserSecrets for local development
            configurationBuilder.AddUserSecrets("00000000-0000-0000-0000-000000000000");
        }
    });

var app = builder
    .ConfigureServices()
    .ConfigurePipeline();

app.Run();

/// <summary>
/// Expose implicitly defined Program class to the integration test project
/// See https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests?view=aspnetcore-7.0#basic-tests-with-the-default-webapplicationfactory
/// </summary>
public partial class Program { }

HostingExtensions.cs

using NetEscapades.AspNetCore.SecurityHeaders.Infrastructure;

namespace ExampleSolution.Server.Extensions;

internal static class HostingExtensions
{
    private static IWebHostEnvironment? _env;

    public static WebApplication ConfigureServices(this WebApplicationBuilder builder)
    {
        var configuration = builder.Configuration;
        _env = builder.Environment;

        builder.Services
            .AddInfrastructure(_env)
            .AddSecurity(configuration)
            .AddServices();

        if (_env.IsDevelopment())
        {
            builder.Services.AddReverseProxy()
                .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
        }

        return builder.Build();
    }

    public static WebApplication ConfigurePipeline(this WebApplication app)
    {
        // Add if you require debugging in dev
#pragma warning disable S125 // Sections of code should not be commented out
        // IdentityModelEventSource.ShowPII = true;

        app.UseSecurityHeaders(
            SecurityHeadersDefinitions.GetHeaderPolicyCollection(_env!.IsDevelopment(),
                app.Configuration["AzureAd:Instance"]));

        if (_env!.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Error");
            app.UseHsts();
        }

        app.UseHttpsRedirection();
        app.UseStaticFiles();

        app.UseRouting();

        app.UseNoUnauthorizedRedirect("/api");

        app.UseAuthentication();
        app.UseAuthorization();

        app.MapRazorPages();
        app.MapControllers();
        // SPA-specific routing

        app.MapNotFound("/api/{**segment}");

        if (_env!.IsDevelopment())
        {
            var spaDevServer = app.Configuration.GetValue<string>("SpaDevServerUrl");
            if (!string.IsNullOrEmpty(spaDevServer))
            {
                // proxy any non API requests to the vite dev server
                app.MapReverseProxy();
            }
        }

        // handle urls that we think belong to the SPA routing
        app.MapFallbackToPage("/_Host");

        return app;
    }
}

ServiceCollectionExtensions.cs

using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.Identity.Web;
using Microsoft.Identity.Web.UI;
using Microsoft.OpenApi.Models;

namespace Microsoft.Extensions.DependencyInjection;

public static class ServiceCollectionExtension
{
    public static IServiceCollection AddInfrastructure(this IServiceCollection services,
        IWebHostEnvironment environment)
    {
        services.AddOptions();
        services.AddHttpClient();

        services.AddControllersWithViews(options =>
            {
                options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
            });

        services.AddRazorPages().AddMvcOptions(_ => { })
            .AddMicrosoftIdentityUI();

        services.AddDistributedMemoryCache();

        return services;
    }

    public static IServiceCollection AddSecurity(this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddAntiforgery(options =>
        {
            options.HeaderName = "X-XSRF-TOKEN";
            options.Cookie.Name = "__Host-X-XSRF-TOKEN";
            options.Cookie.SameSite = SameSiteMode.Strict;
            options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        });

        services.AddMicrosoftIdentityWebAppAuthentication(configuration)
            .EnableTokenAcquisitionToCallDownstreamApi()
            .AddDistributedTokenCaches();

        services.AddAuthorization(options =>
        {
            options.AddPolicy("AssignmentToAdministratorRoleRequired", policy => policy.RequireRole("Administrator"));
            options.AddPolicy("AssignmentToArbitraryRoleRequired", policy => policy.RequireRole("Arbitrary"));
        });

        services.AddHsts(options =>
        {
            options.Preload = true;
            options.IncludeSubDomains = true;
            options.MaxAge = TimeSpan.FromDays(60);
        });

        return services;
    }
}

Next let’s have a look into the code of the integration test project.

CustomWebApplicationFactory.cs

using System.Data.Common;
using System.Globalization;
using System.Net.Http.Headers;
using System.Security.Claims;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace ExampleSolution.Api.Tests.Integration.Helpers;

/// <summary>
/// see http://www.tiernok.com/posts/2021/mocking-oidc-logins-for-integration-tests/
/// </summary>
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    public HttpClient CreateLoggedInClient<T>(WebApplicationFactoryClientOptions options,
        string role, int internalTenantId, int internalUserId)
        where T : ImpersonatedAuthHandler
    {
        options.BaseAddress = new Uri("https://localhost");
        return CreateLoggedInClient<T>(options, list =>
        {
            list.Add(new Claim(ClaimTypes.Role, role));
            list.Add(new Claim("internalTenantId", internalUserId.ToString(CultureInfo.InvariantCulture)));
            list.Add(new Claim("internalUserId", internalUserId.ToString(CultureInfo.InvariantCulture)));
        });
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            var dbContextDescriptor = services.SingleOrDefault(
                d => d.ServiceType ==
                     typeof(DbContextOptions<ApplicationDbContext>));

            if (dbContextDescriptor is not null)
            {
                services.Remove(dbContextDescriptor);
            }

            var dbConnectionDescriptor = services.SingleOrDefault(
                d => d.ServiceType ==
                     typeof(DbConnection));

            if (dbConnectionDescriptor is not null)
            {
                services.Remove(dbConnectionDescriptor);
            }

            // Create open SqliteConnection so EF won't automatically close it.
            services.AddSingleton<DbConnection>(_ =>
            {
                // Instead of creating
                // new SqliteConnection("DataSource=:memory:") in combination with connection.Open() or
                // new SqliteConnection("DataSource=IntegrationTestDb;mode=memory;cache=shared") - a so called named in-memory database
                // we use Sqlite file-based database as it supports EnsureDeleted() method that allows us
                // to delete and recreated the database between integration tests.
                // Another - in context of integration tests even better option - is to use the real database to avoid
                var connection = new SqliteConnection("DataSource=.\\ExampleSolution.Api.Tests.Integration");

                return connection;
            });

            services.AddDbContext<ApplicationDbContext>((container, optionsBuilder) =>
            {
                var connection = container.GetRequiredService<DbConnection>();
                optionsBuilder.UseSqlite(connection);
            });

            var antiForgery = services.SingleOrDefault(
                d => d.ServiceType == typeof(IAntiforgery));

            if (antiForgery is not null)
            {
                services.Remove(antiForgery);
            }

            services.AddTransient<IAntiforgery, TrueIfTestSchemeAntiForgery>();
        });

        base.ConfigureWebHost(builder);
    }

    private HttpClient CreateLoggedInClient<T>(WebApplicationFactoryClientOptions options, Action<List<Claim>> configure)
        where T : ImpersonatedAuthHandler
    {
        var client = WithWebHostBuilder(builder =>
                builder.ConfigureTestServices(services =>
                {
                    // configure the intercepting provider
                    services.AddTransient<IAuthenticationSchemeProvider, TestAuthenticationSchemeProvider>();

                    services.AddAuthentication(TestAuthenticationSchemeProvider.SchemeName)
                        .AddScheme<ImpersonatedAuthenticationSchemeOptions, T>(
                            TestAuthenticationSchemeProvider.SchemeName, configureOptions =>
                            {
                                configureOptions.Configure = configure;
                            });
                }))
            .CreateClient(options);

        client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue(scheme: TestAuthenticationSchemeProvider.SchemeName);

        return client;
    }
}

This class inherits from WebApplicationFactory<Program> which is used to create an in-memory TestServer for the integration tests. Entry point (generic type argument) is Program.cs so that the test server will be set up similar as in production scenario. By default System Under Test (SUT) environment defaults to Development, so appsettings.Development.json overrides/extends default configuration defined in appsettings.json.

CustomWebApplicationFactory additionally exposes method CreateLoggedInClient<T> which sets the BaseAddress of the HttpClient to https://localhost (default value is http://localhost) to enforce HTTPS in integration tests. Furthermore it calls the private method CreateLoggedInClient<T> by passing an action to enrich the claims of the ClaimsIdentity with additional claims required by the authorization middleware and the business logic to be tested. The values of the claims can be provided as arguments to the method call.

ConfigureWebHost method of WebApplicationFactory is called when a client gets created or when services get accessed. Overriding this method allows you to configure the application before it gets built. Common configuration for all integration tests like overriding services (ApplicationDbContext and IAntiforgery) by calling ConfigureTestServices is therefore implemented in the overridden ConfigureWebhost method.

Last but not least, authentication for integration tests is configured, respectively overridden with a “mocked” authentication in the private CreateLoggedInClient method which in the end returns a HttpClient that sends TestScheme in authentication header with every request. The registration of TestAuthenticationSchemeProvider replaces the built-in AuthenticationSchemeProvider and intercepts requests for all schemes and returns TestScheme. Requests with TestScheme in authentication header are then handled by the authentication handler passed as generic argument – in our case the ImpersonatedAuthHandler which creates the AuthenticationResult containing an AuthenticationTicket with the ClaimsPrincipal that contains the provided claims.

I deliberately decided to run the integration tests in a first version against a Sqlite database. This allows the integration tests to be executed independently and without additional configuration in the CI/CD pipeline. Furthermore as the SUT requires a preconfigured tenant entry and the logged in user in the database which usually gets created after first login, having an in-memory DB that can get cleaned and reinitialized after each test is even more comfortable.

Initially, I started with a SqliteConnection with connection string "DataSource=:memory:". However, this did not work in my setup because new in-memory databases were always created (one during the seeding / initialization of the DB and a new one during test execution). Then I switched to a named in-memory database by changing the connection string to "DataSource=IntegrationTestDb;mode=memory;cache=shared" but in-memory mode does not support EnsureDeleted(). Anyway instead of having to deal with the limitations of in-memory databases, I prefer disabling parallel execution and using a properly initialized Sqlite file database for the integration tests.

TestAuthenticationSchemeProvider.cs

This class returns TestScheme when GetSchemeAsync is called – independently of the name passed as argument.

using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;

namespace ExampleSolution.Api.Tests.Integration.Helpers;

/// <summary>
/// This class is used to replace the built-in AuthenticationSchemeProvider with one
/// that intercepts requests for all schemes and returns the scheme with name "TestScheme"
/// </summary>
public class TestAuthenticationSchemeProvider : AuthenticationSchemeProvider
{
    public const string SchemeName = "TestScheme";

    public TestAuthenticationSchemeProvider(IOptions<AuthenticationOptions> options)
        : base(options)
    {
    }

    protected TestAuthenticationSchemeProvider(IOptions<AuthenticationOptions> options, IDictionary<string, AuthenticationScheme> schemes)
        : base(options, schemes)
    {
    }

    public override Task<AuthenticationScheme?> GetSchemeAsync(string name)
    {
        return base.GetSchemeAsync(SchemeName);
    }
}

ImpersonatedAuthHandler.cs

This authentication handler implementation handles the authentication for scheme TestScheme and enriches the claims identity with the claims provided to the creation of the logged in client.

using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Identity.Web;
using ExampleSolution.Api.Tests.Integration.Options;

namespace ExampleSolution.Api.Tests.Integration.Helpers;

public class ImpersonatedAuthHandler : AuthenticationHandler<ImpersonatedAuthenticationSchemeOptions>
{
    public ImpersonatedAuthHandler(
        IOptionsMonitor<ImpersonatedAuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        ISystemClock clock) : base(options, logger, encoder, clock)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new List<Claim>
        {
            new (ClaimConstants.Name, TestConstants.UserName)
        };
        Options.Configure(claims);
        var identity = new ClaimsIdentity(claims, TestAuthenticationSchemeProvider.SchemeName);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, TestAuthenticationSchemeProvider.SchemeName);

        var result = AuthenticateResult.Success(ticket);

        return Task.FromResult(result);
    }
}

ImpersonatedAuthenticationSchemeOptions.cs

This class derives from AuthenticationSchemeOptions and defines a method to configure the claims identity by enriching it with additional claims.

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;

namespace ExampleSolution.Api.Tests.Integration.Options;

public class ImpersonatedAuthenticationSchemeOptions : AuthenticationSchemeOptions
{
    public Action<List<Claim>> Configure { get; set; } = _ => { };
}

TenantEndpointsTests.cs

using System.Net;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
using ExampleSolution.Api.Tests.Integration.Extensions;
using ExampleSolution.Api.Tests.Integration.Helpers;

// Avoid concurrency issues when accessing the same Sqlite file database from multiple tests
[assembly: CollectionBehavior(DisableTestParallelization = true)]
namespace ExampleSolution.Api.Tests.Integration;

// IClassFixture indicates, that the class contains tests
// and provides shared object instances across the tests in the class
[Trait("Category", "IntegrationTests")]
public class TenantEndpointsTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly CustomWebApplicationFactory _factory;

    /// <summary>
    /// Test setup
    /// </summary>
    /// <param name="factory">Custom web application factory</param>
    public TenantEndpointsTests(CustomWebApplicationFactory factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task GetTenantConfiguration_AsUnauthenticatedUser_ReturnsUnauthorized()
    {
        var client = _factory.CreateClient();

        var response = await client.GetAsync("/api/Tenant/configuration");

        Assert.NotNull(response);
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    [Fact]
    public async Task GetTenantConfiguration_AsUserInRoleAdministrator_ReturnsForbidden()
    {
        var client = _factory.CreateLoggedInClient<ImpersonatedAuthHandler>(
            new WebApplicationFactoryClientOptions { AllowAutoRedirect = false },
            "Administrator",
            0,
            0);

        var response = await client.GetAsync("/api/Tenant/configuration");

        Assert.NotNull(response);
        Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
    }

    [Fact]
    public async Task GetTenantConfiguration_AsUserInRoleArbitrary_ReturnsTenantConfiguration()
    {
        var initialData = _factory.InitializeDatabase();

        var client = _factory.CreateLoggedInClient<ImpersonatedAuthHandler>(
            new WebApplicationFactoryClientOptions { AllowAutoRedirect = false },
            "Arbitrary",
            initialData.InternalTenantId,
            initialData.InternalUserId);

        var response = await client.GetAsync("/api/Tenant/configuration");

        Assert.NotNull(response);
        response.EnsureSuccessStatusCode();

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.NotNull(response.Content);
        Assert.NotNull(response.Content.Headers.ContentType);
        Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString());

        var tenantConfiguration = await response.Content.ReadFromJsonAsync<JsonNode>();
        Assert.NotNull(tenantConfiguration);
        var tenantId = tenantConfiguration["Id"]!.GetValue<int>();
        Assert.Equal(1, tenantId);
        Assert.Equal("Initial Tenant", tenantConfiguration["Name"]!.GetValue<string>());
    }

    [Fact]
    public async Task CreateTenant_AsUserInRoleAdministrator_CreatesTenant()
    {
        var initialData = _factory.InitializeDatabase();

        var client = _factory.CreateLoggedInClient<ImpersonatedAuthHandler>(
            new WebApplicationFactoryClientOptions { AllowAutoRedirect = false },
            "Administrator",
            initialData.InternalTenantId,
            initialData.InternalUserId);

        var tenantCreateRequest = new
        {
            Name = "Arbitrary Tenant",
        };

        var response = await client.PostAsJsonAsync("/api/Tenant", tenantCreateRequest);

        Assert.NotNull(response);
        response.EnsureSuccessStatusCode();
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        Assert.NotNull(response.Content);
        Assert.NotNull(response.Content.Headers.ContentType);
        Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString());

        var tenant = await response.Content.ReadFromJsonAsync<JsonNode>();
        Assert.NotNull(tenant);
        var tenantId = tenant["Id"]!.GetValue<int>();
        Assert.Equal($"https://localhost/api/Tenant/{tenantId}", response.Headers.Location?.ToString());
        Assert.True(tenantId > 0);
        Assert.Equal("Arbitrary Tenant", tenant["Name"]!.GetValue<string>());
    }
}

Inspired by Steven Giesel – I attended a session about testing of him in Zurich – I didn’t use DTOs in integration tests but instead used anonymous types to simulate clients in a realistic manner and to be able to detect breaking API changes easily.

CustomWebApplicationFactoryExtensions.cs

The InitializeDatabase extension method defined in class CustomWebApplicationFactoryExtensions uses the DbContext retrieved from the service collection to delete, recreate and initialize the Sqlite file database. The properties of the InitialData return type are needed to create the logged in clients in the integration tests.

using Microsoft.EntityFrameworkCore.Internal;
using Microsoft.Extensions.DependencyInjection;
using ExampleSolution.Api.Tests.Integration.Helpers;

namespace ExampleSolution.Api.Tests.Integration.Extensions;

public static class CustomWebApplicationFactoryExtensions
{
    public static InitialData InitializeDatabase(this CustomWebApplicationFactory factory)
    {
        using var scope = factory.Services.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

#pragma warning disable EF1001 // Internal EF Core API usage. // just for test purpose
        dbContext.GetDependencies().StateManager.ResetState();
#pragma warning restore EF1001 // Internal EF Core API usage.

        dbContext.Database.EnsureDeleted(); // not supported in in-memory mode

        dbContext.Database.EnsureCreated();

        var initialTenant = new Tenant
        {
            Name = "Initial Tenant",
        };
        dbContext.Tenants.Add(initialTenant);
        dbContext.SaveChanges();

        var initialUser = new User
        {
            DisplayName = "Integration Test-User",
            Email = "integration.test-user@example.com",
        };

        dbContext.Users.Add(initialUser);

        dbContext.SaveChanges();

        return new InitialData(initialTenant.Id, initialUser.Id);
    }

    public class InitialData
    {
        public InitialData(int internalTenantId, int internalUserId)
        {
            InternalUserId = internalUserId;
            InternalTenantId = internalTenantId;
        }

        public int InternalUserId { get; }
        public int InternalTenantId { get; }
    }
}

TrueIfTestSchemeAntiForgery.cs

Last but not least, let’s take a look at the AntiForgery topic, which was giving me a headache. When I ran the integration tests without registering TrueIfTestSchemeAntiForgery, the integration tests that used the logged in client failed with the following exception:

[16:40:41 INF] Antiforgery token validation failed. The required antiforgery cookie "__Host-X-XSRF-TOKEN" is not present. (Microsoft.AspNetCore.Mvc.ViewFeatures.Filters.ValidateAntiforgeryTokenAuthorizationFilter)
Microsoft.AspNetCore.Antiforgery.AntiforgeryValidationException: The required antiforgery cookie "__Host-X-XSRF-TOKEN" is not present.
at Microsoft.AspNetCore.Antiforgery.DefaultAntiforgery.ValidateRequestAsync(HttpContext httpContext)
at Microsoft.AspNetCore.Mvc.ViewFeatures.Filters.ValidateAntiforgeryTokenAuthorizationFilter.OnAuthorizationAsync(AuthorizationFilterContext context)

Special thanks to Damien Bowden, who gave me a hand on that! Together we came up with the following solution which inherits from IAntiforgery and returns true when IsRequestValidAsync is called if and only if authentication scheme is TestScheme. I like this approach as antiforgery validation is also in place as part of integration testing – even if it’s a stub.

using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;

namespace ExampleSolution.Api.Tests.Integration.Helpers;

internal class TrueIfTestSchemeAntiForgery : IAntiforgery
{
    private const string _antiForgeryHeaderName = "X-XSRF-TOKEN";

    public AntiforgeryTokenSet GetAndStoreTokens(HttpContext httpContext)
    {
        return new AntiforgeryTokenSet(null, null, "__RequestVerificationToken", _antiForgeryHeaderName);
    }

    public AntiforgeryTokenSet GetTokens(HttpContext httpContext)
    {
        return new AntiforgeryTokenSet(null, null, "__RequestVerificationToken", _antiForgeryHeaderName);
    }

    public Task<bool> IsRequestValidAsync(HttpContext httpContext)
    {
        var authProperties = httpContext.Features.GetRequiredFeature<IAuthenticateResultFeature>();
        var authScheme = authProperties.AuthenticateResult!.Ticket!.Properties.Items[".AuthScheme"];
        return Task.FromResult(authScheme is TestAuthenticationSchemeProvider.SchemeName);
    }

    public Task ValidateRequestAsync(HttpContext httpContext)
    {
        return Task.CompletedTask;
    }

    public void SetCookieTokenAndHeader(HttpContext httpContext)
    {
    }
}

Two other approaches that I found during my research can be found here:

Leave a comment

Website Powered by WordPress.com.

Up ↑