In-Memory Acceptance Tests with Authentication

In my last blog post, I showed how it was possible to run integration and acceptance tests at a level where it is still possible to isolate the code under test from third-party dependencies. Here I'm going to detail (using real-life examples) how Varealis expanded these tests to also include Authentication and Authorisation when testing our ASP.Net Core applications.

Introduction

There are countless ways to add authentication and/or authorisation to an ASP.Net Core web application. At Varealis we have a dedicated microservice responsible for user (and client) authentication across the RadSpider platform. This service uses 'Identity Server 4' an excellent open-source OpenID Connect and OAuth 2.0 framework for ASP.Net. Once authenticated on the platform, each microservice is then responsible for authorisation by mapping the user to the relevant claims.

Configuring an Identity Server In-Memory

Similarly to our application under test we want to host an instance of Identity Server inside a local Test Server. This Identity Server closely mimics our microservice's implementation but uses In-Memory providers instead of our usual database dependencies.


var identityServerBuilder = new WebHostBuilder()
    .ConfigureServices(services =>
    {
        services.AddIdentityServer()
            .AddDeveloperSigningCredential(persistKey: false)
            .AddInMemoryPersistedGrants()
            .AddInMemoryIdentityResources(IdentityResources)
            .AddInMemoryApiResources(ApiResources)
            .AddInMemoryClients(this.Clients)
            .AddTestUsers(Users.Where(x => x != null).Select(user =>
            {
                return new TestUser
                {
                    SubjectId = Guid.NewGuid().ToString(),
                    Username = user.Username,
                    Password = user.Password,
                    IsActive = true,
                    Claims = new List<Claim>
                    {
                        new Claim(JwtClaimTypes.Role, user.Role),
                        user.PartyId != default
                            ? new Claim(RadSpiderClaimTypes.PartyId, user.PartyId.ToString())
                            : new Claim("NoPartyId", Guid.Empty.ToString()),
                    },
                };
            }).ToList());
    })
    .Configure(app =>
    {
        app.UseIdentityServer();
    });

var identityServer = new TestServer(identityServerBuilder);

We then configure the relevant Resources (IdentityResources and ApiResources), Clients and Users for the acceptance tests. For all the same reasons that test environments closely replicate our live environments any differences in the configuration must be limited to the fields that require changes to run the services locally.

Configuring the Resources

Setting up the In-Memory Identity and API Resources is as simple as copying the relevant resources from your real Identity Server.


private static List<IdentityResource> IdentityResources =>
    new List<IdentityResource>
        {
            new IdentityResource
            {
                Enabled = true,
                Name = "openid",
                DisplayName = "Your user identifier",
                Description = null,
                Required = true,
                Emphasize = false,
                ShowInDiscoveryDocument = true,
            },
            // Removed for brevity
        };

private static List<ApiResource> ApiResources =>
    new List<ApiResource>
    {
        new ApiResource
        {
            Enabled = true,
            Name = "Service-Name",
            DisplayName = "Service Name",
            Scopes = new List<Scope>
            {
                new Scope
                {
                    Name = "Scope",
                    DisplayName = "Scope Name",
                    Required = true,
                    ShowInDiscoveryDocument = true,
                },
            },
            UserClaims = new List<string>
            {
                JwtClaimTypes.Role,
                "party_id",
            },
        },
    }; 

Configuring the Client

Some changes to the client's configuration need to be made for it to work in an In-Memory TestServer. Make sure to override the RedirectUris to include the 'localhost' URL and override the ClientSecret so that it matches the one used by the application under test.


private List<Client> Clients
{
    get
    {
        return new List<Client>
        {
            new Client
            {
                Enabled = true,
                ClientId = "AcceptanceTests",
                ClientSecrets = new List<Secret>
                {
                    new Secret(this.config.GetValue<string>(
                        "SecurityConfig:ApiSecret").ToSha256())
                    {
                        Type = "SharedSecret",
                    },
                },
                ProtocolType = "oidc",
                AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
                RedirectUris = new List<string>
                    { "http://localhost/signin-oidc" },
                AllowedScopes = new List<string>
                {
                    "openid",
                    "profile",
                    "Scope",
                },
                RequireClientSecret = true,
                RequireConsent = false,
                // Removed for brevity
            },
        };
    }
}

Configuring Users

Our Identity Server stamps authenticated users with a 'Role' claim and a custom PartyId claim. Each microservice is then responsible for mapping the Role to specific claims/actions (I.e. Administrators can do actions A, B and C but Reporting Users can only do C), while the PartyId is used to determine whether or not the user 'owns' a given resource.

With this in mind, we need to configure a user for each support 'Role', one with an unrecognised role type. I then configured a couple of users across the different roles with invalid PartyIds. These users are then stored so they can be accessed by the initial startup configuration and the acceptance tests at run time.


public static class PlatformUsers
{
    private static readonly Dictionary<UserType, PlatformUser> ConfiguredUsers
        = new Dictionary<UserType, PlatformUser>
    {
        { UserType.Undefined,  null },
        { 
            UserType.Unrecognised,  
            new PlatformUser(
                UserType.Unrecognised,
                username: "unrecognised",
                password: "unrec0gn1sed",
                role: "UNRECOGNISED",
                partyId: Guid.NewGuid())
        },
        {
            UserType.NoPartyId,
            new PlatformUser(
                UserType.NoPartyId,
                username: "nopartyId",
                password: "n0party1d",
                role: "ADMINISTRATOR",
                partyId: Guid.Empty)
        },
        {
            UserType.Administrator,
            new PlatformUser(
                UserType.Administrator,
                username: "administrator",
                password: "ad1m1n",
                role: "ADMINISTRATOR",
                partyId: Guid.NewGuid()) 
        },
        // Removed for brevity
    };

    public static IEnumerable<PlatformUser> Users => ConfiguredUsers.Values;

    public static PlatformUser GetUser(UserType userType)
    {
        if (ConfiguredUsers.TryGetValue(userType, out var user))
        {
            return user;
        }

        throw new Exception(
            $"No user has been configured for the user type: {userType}");
    }
}   

Configuring the Application Under Test

Now that all of the boring stuff is out of the way we need to modify the application under test to point at our In-Memory Identity Server. To do this we need to inject the TestServer that is hosting our Identity Server into our mock Startup class. I did this by extending the WebApplicationFactory and injecting the TestServer alongside the other mocked dependencies.


public class MockApiWebApplicationFactory
{
    /// <summary>
    /// Initializes a new instance of the
    /// <see cref="MockApiWebApplicationFactory"/> class.
    /// </summary>
    /// <param name="environment">The hosting environment.</param>
    /// <param name="identityServer">The Identity Server.</param>
    public MockApiWebApplicationFactory(
        IWebHostEnvironment environment,
        TestServer identityServer)
    {
        this.environment = environment;
        this.identityServer = identityServer;
    }

    /// <inheritdoc/>
    protected override IWebHostBuilder CreateWebHostBuilder()
    {
        return WebHost.CreateDefaultBuilder()
            // The Mock Startup that extends the common Base Startup.
            .UseStartup<MockApiStartup>()
            .ConfigureServices(services =>
            {
                services.AddSingleton(this.identityServer);
                services.AddSingleton(this.environment);
            });
    }
}

Then add an abstract method void ConfigureAuthenticationOptions( IdentityServerAuthenticationOptions options, IServiceCollection services) to the StartupBase. The standard implementation in our target application's Startup class can be left unimplemented, but add the following code to our MockApiStartup where the IdentityServer is the TestServer we injected earlier.


/// <inheritdoc/>
protected override IdentityServerAuthenticationOptions 
    ConfigureAuthenticationOptions(
        IdentityServerAuthenticationOptions options,
        IServiceCollection services)
{
    options.JwtBackChannelHandler = this.identityServer.CreateHandler();

    services.AddHttpClient(
        OAuth2IntrospectionDefaults.BackChannelHttpClientName,
        _ => this.identityServer.CreateClient());

    return options;
}

Now all we need to do is make sure that we override the values in configuration so the for the Identity Server's URL is set to 'https://localhost'. We then configure authentication in the StartupBase class like normal (adding the call to `ConfigureAuthenticationOptions`).


services.AddAuthentication(options =>
{
    options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddIdentityServerAuthentication(options =>
{
    options.Authority = securityConfig.GetValue<string>("IdentityServerUrl");
    options.ApiSecret = securityConfig.GetValue<string>("APISecret");

    options.EnableCaching = true;
    options.CacheDuration = TimeSpan.FromMinutes(10);
    options.SupportedTokens = SupportedTokens.Both;

    options.LegacyAudienceValidation = false;

    this.ConfigureAuthenticationOptions(options, services);
});

Configuring the Secure Client

Now when we create a HttpClient from the TestServer hosting the application under test it will be configured to use our In-Memory Identity Server to authenticate requests. We could now use this HttpClient to interact with the application under test but I'm going to use a SecureServiceClient class that uses a client that we automatically generated using swagger and autorest.


TestServer identityServer = IdentityServerApplicationFactory.BuildIdentityServer();

HttpClient applicationClient = new MockApiWebApplicationFactory(
    subHostingEnvironment,
    identityServer,
    repository,
    subBusControl).CreateClient();

SecureServiceClient secureClient = new SecureServiceClient(
    configuration,
    identityServer,
    applicationClient);

    Notes

  • A lot of this implementation has been cut out for brevity.
  • IServiceClient is the interface that is generated by autorest.
  • `ISecureServiceClient` extends the IServiceClient interface, adding the AuthenticateAs method.
  • ServiceClient is the client that is generated by autorest.
  • ClientCredentials is a no-op extension to Microsoft.Rest.ServiceClientCredentials where it calls the base class.

public class SecureServiceClient :
    ISecureServiceClient<IServiceClient>,
    IServiceClient
{
    private readonly IConfiguration config;
    private readonly HttpClient httpClient;
    private readonly TestServer identityServer;

    /// <summary>
    /// Initializes a new instance of the
    /// <see cref="SecureServiceClient"/> class.
    /// </summary>
    /// <param name="configuration">
    /// The configuration.
    /// </param>
    /// <param name="identityServer">
    /// The <see cref="TestServer"/> used to host Identity Server.
    /// </param>
    /// <param name="httpClient">
    /// The <see cref="HttpClient"/> used to comunicate with the Service.
    /// </param>
    public SecureServiceClient(
        IConfiguration configuration,
        TestServer identityServer,
        HttpClient httpClient)
    {
        this.config = configuration;
        this.httpClient = httpClient;
        this.IdentityServer = identityServer;

        this.Client = new ServiceClient(
            new ClientCredentials(),
            this.httpClient,
            true);
    }

    public IServiceClient Client { get; }

    public async Task<IServiceClient> AuthenticateAs(PlatformUser user)
    {
        // If the user is null we don't try to authenticate with the service.
        if (user == null)
        {
            return this;
        }

        var passwordRequest = new PasswordTokenRequest
        {
            Address = "http://localhost/connect/token",
            ClientId = "AcceptanceTests",
            ClientSecret = this.config.GetValue<string>(
                "SecurityConfig:ApiSecret"),
            UserName = user.Username,
            Password = user.Password,
            GrantType = "password",
            Scope = "openid profile",
        };

        var passwordToken = await this.IdentityServer.CreateClient()
            .RequestPasswordTokenAsync(passwordRequest);

        if (string.IsNullOrWhiteSpace(passwordToken.AccessToken)
            || passwordToken.IsError)
        {
            throw passwordToken.Exception ?? 
                new UnauthorizedAccessException("Unable to generate a password token.");
        }

        this.httpClient.SetBearerToken(passwordToken.AccessToken);

        return this;
    }

    async Task<HttpOperationResponse<object>>
        IServiceClient.PageModelsWithHttpMessagesAsync(
            string cursor,
            int? limit,
            Dictionary<string, List<string>> customHeaders,
            CancellationToken cancellationToken)
    {
        var response = await this.Client.PageModelsWithHttpMessagesAsync(
            cursor,
            limit,
            customHeaders,
            cancellationToken);
        return response;
    }

    // Etc.
}

This class can then be used to interact with our API Service as an authenticated user, or an unauthenticated user.

Happy Testing

Hopefully this, in conjunction with my previous blog post, gives you everything you need to produce thorough yet speedy automation tests. Testing at this level (I.e. before deployment) significantly tightens the feedback loop for developers and significantly reduces the number of variables that can lead to flakey, unreliable tests!

I appreciate that this blog post is very code-heavy, I tried to keep it as concise as possible! If you have any questions about this feel free to tweet me and/or get in touch using the contact form below.

Jamie Peacock
Published on 19/12/2019
Founder of Varealis

Great code builds great companies.

© Copyright Varealis 2024