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.
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.
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 (IdentityResource
s and ApiResource
s), Client
s 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.
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",
},
},
};
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
},
};
}
}
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 PartyId
s. 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}");
}
}
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);
});
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);
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.
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.
© Copyright Varealis 2024