Search code examples
c#authorizationasp.net-core-webapiintegration-testing.net-6.0

Integration testing authorized endpoints with already specified authentication schemes


I am trying to set up integration tests for my ASP.NET Web API with authorized endpoints.

I have followed the documentation from Microsoft to add mock authentication to the integration tests to allow the test client to access the authorized endpoints. https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests?view=aspnetcore-6.0

E.g.

builder.ConfigureTestServices(services =>
{
    services.AddAuthorization(options =>
    {
        options.DefaultPolicy = new AuthorizationPolicyBuilder()
            .AddAuthenticationSchemes("Test")
            .RequireAuthenticatedUser()
            .Build();
    });
}

This works fine if you are using default authentication schemes that you can change on the startup of your integration tests to use the test scheme. But, my authorized endpoints are using specified AuthenticationSchemes so the test scheme will never be authorized for the endpoint. E.g.

[Authorize(AuthenticationSchemes = "Scheme1,Scheme2")]
public class AppVersionController : ControllerBase
{
    ...
}

I can get around this issue by specifying an environment variable when testing, checking this and dynamically adding the test scheme to the authorized endpoint. Yet, this adds a lot of test-specific logic to the app, which isn't nice to have in the main project.

This would work:

// Test scheme added dynamically from an environment variable to get the below result
[Authorize(AuthenticationSchemes = "Scheme1,Scheme2,Test")]
public class AppVersionController : ControllerBase
{
    ...
}

I get this done by creating a custom attribute that looks basically like this:

public class AuthorizeAll : AuthorizeAttribute
{
    public AuthorizeAll()
    {
        var authenticationSchemes = "Scheme1,Scheme2";
        if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Testing")
        {
            authenticationSchemes += ",Test";
        }
        AuthenticationSchemes = authenticationSchemes;
    }
}

I just don't like how we will have to continue to maintain this test authentication scheme in the application layer as well as the security concerns with this approach.

Questions

What is the best way to authorize endpoints for .NET integration tests when specific authentication schemes are set?

Is it good practice to check environment variables in the app when unit testing to run specific logic that the tests need to work?

The main authentication scheme used at the moment is using JWTs, so is there a better way to mock JWTs for the tests?


Solution

  • There are many ways you can mock authentication (triggered from authorization policy). Depends on your app setup, you might choose what's the "best" for you.

    1. Use default policy

    When you use [Authorize(AuthenticationSchemes = "Scheme1,Scheme2")] on an action/controller, the default authorization policy is combined before evaluation. In your test setup, you can combine an empty policy with Test scheme with the default policy from the actual code to make sure your Test scheme always runs.

    builder.ConfigureTestServices(services =>
    {
        services.AddAuthorization(opt =>
        {
            opt.DefaultPolicy = new AuthorizationPolicyBuilder()
                .AddAuthenticationSchemes("Test")
                .Combine(opt.DefaultPolicy)
                .Build();
        });
    });
    

    The above code will authenticate the request using three policies: Scheme1, Scheme2 and Test. This won't work for you if you also have [Authorize(Policy = "Policy1")].

    2. Use a custom policy evaluator

    ASP.NET Core evaluates authorization policy in a service of type IPolicyEvaluator. You can override this service in your test to always authenticate the request using a specific scheme. You can also do it per request.

    class TestPolicyEvaluator : IPolicyEvaluator
    {
        private readonly PolicyEvaluator _innerEvaluator;
    
        public TestPolicyEvaluator(PolicyEvaluator innerEvaluator)
        {
            _innerEvaluator = innerEvaluator;
        }
    
        public Task<AuthenticateResult> AuthenticateAsync(AuthorizationPolicy policy, 
            HttpContext context)
        {
            var combinedPolicy = new AuthorizationPolicyBuilder()
                .AddAuthenticationSchemes("Test")
                .Combine(policy)
                .Build();
    
            return _innerEvaluator.AuthenticateAsync(combinedPolicy, context);
        }
    
        public Task<PolicyAuthorizationResult> AuthorizeAsync(AuthorizationPolicy policy,
            AuthenticateResult authenticationResult, HttpContext context, object? resource)
        {
            return _innerEvaluator.AuthorizeAsync(policy, authenticationResult,
                context, resource);
        }
    }
    

    Then add this to your test setup:

    builder.ConfigureTestServices(services =>
    {
        services.AddTransient<IPolicyEvaluator>(serviceProvider => new TestPolicyEvaluator(
            ActivatorUtilities.CreateInstance<PolicyEvaluator>(serviceProvider)));
    });
    

    This works similarly as the default policy solution, and should work for all policy evaluation scenarios.

    3. Use custom security token validation

    You can also customize how your JWT token is validated. You can tweak your token validation parameters to allow a test-specific JWT token. Alternatively, you can also use a customized token validator.

    class TestJwtSecurityTokenHandler : ISecurityTokenValidator
    {
        private readonly JwtSecurityTokenHandler _innerHandler = new();
    
        public bool CanValidateToken => _innerHandler.CanValidateToken;
    
        public int MaximumTokenSizeInBytes
        {
            get => _innerHandler.MaximumTokenSizeInBytes;
            set => _innerHandler.MaximumTokenSizeInBytes = value;
        }
    
        public bool CanReadToken(string securityToken) => true;
    
        public ClaimsPrincipal ValidateToken(string securityToken, 
            TokenValidationParameters validationParameters,
            out SecurityToken validatedToken)
        {
            validatedToken = new JwtSecurityToken();
            return new ClaimsPrincipal(new ClaimsIdentity("Test"));
        }
    }
    

    Assuming you have a JWT scheme called Scheme1, you can configure it in your test setup:

    builder.ConfigureTestServices(services =>
    {
        services.Configure<JwtBearerOptions>("Scheme1", opt =>
        {
            // Tweak parameters
            opt.TokenValidationParameters.ValidateLifetime = false;
                            
            // Or, fully override token validator
            opt.SecurityTokenValidators.Clear();
            opt.SecurityTokenValidators.Add(new TestJwtSecurityTokenHandler());
        });
    });