.NET
Handling Cookie Authentication with ASP.NET Core TestServer
09 Aug 2023
Following up on my article about handling JWT authentication with the ASP.NET Core TestServer, this post will cover the equivalent approach but for cookie based authentication. As a reminder, the idea is to ensure our tests also execute the authorization policies defined by the application so that we can be sure we are verifying the most complete slice through our API as possible.
Achieving this for cookie authentication was much trickier than for JWT and there wasn't a lot of readily available documentation for what I was about to do. I first needed to do this for a side project of mine and I really didn't want to compromise on my preference for hitting the authorization policies. Admittedly at time I was very close to giving up and rethinking my approach however with a bit of perseverence I got there in the end.
The principle is the same as with JWT authentication; we need to reconfigure CookieAuthenticationOptions
so we can set a custom cookie format which allows us to easily create a cookie in tests and have it
correctly handled by the API. As with my previous article, you can either continue reading or
just skip to the sample.
First let's assume we have authorization services configured as follows:
public static void AddAuthorizationServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.Cookie.Name = "api-cookie";
options.Cookie.MaxAge = TimeSpan.FromMinutes(15);
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = 401;
return Task.CompletedTask;
};
});
services.AddAuthorization(options =>
{
options.DefaultPolicy = new AuthorizationPolicyBuilder(CookieAuthenticationDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.Build();
options.AddPolicy("Admin", policy => policy.RequireClaim("role", "admin"));
});
}
This configures cookie authentication, sets up a default authorization policy which requires an
authenticated user, and then adds an additional policy which requires the user to be in the admin
role.
We will use the same API which uses the admin policy from the previous post for this example. For reference this API looks like:
app.MapGet("/people", () => new[]
{
new Person("John", "Doe"),
new Person("Jane", "Doe"),
new Person("John", "Smith"),
new Person("Jane", "Smith")
}).RequireAuthorization("Admin");
If we try to use this API endpoint in tests with the authorization configuration above, we'll get a
a 401 Unauthorized
response. We need to reconfigure CookieAuthenticationOptions
to use a custom
"ticket" format for our cookies. The format I chose to use would create a string of key-value pairs
from the identity claims and then base64 encode it but really the format you use can be anything that
encodes claims into a string. Here is my format:
internal class TestCookieTicketFormat : ISecureDataFormat<AuthenticationTicket>
{
public string Protect(AuthenticationTicket data)
{
var claims = data.Principal.Claims.Select(x => $"{x.Type}={x.Value}").ToArray();
var claimsString = string.Join("&", claims);
var ticketBytes = Encoding.UTF8.GetBytes(claimsString);
return Convert.ToBase64String(ticketBytes);
}
public string Protect(AuthenticationTicket data, string? purpose) => this.Protect(data);
public AuthenticationTicket? Unprotect(string? protectedText)
{
if (string.IsNullOrWhiteSpace(protectedText))
{
return null;
}
var ticketBytes = Convert.FromBase64String(protectedText);
var claimsString = Encoding.UTF8.GetString(ticketBytes);
var claims = claimsString.Split('&').Select(x =>
{
var claimParts = x.Split('=');
return new Claim(claimParts[0], claimParts[1]);
});
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var authenticationTicket = new AuthenticationTicket(new ClaimsPrincipal(claimsIdentity), CookieAuthenticationDefaults.AuthenticationScheme);
return authenticationTicket;
}
public AuthenticationTicket? Unprotect(string? protectedText, string? purpose) => this.Unprotect(protectedText);
}
With this format we can then do the necessary reconfiguration:
The test server setup has been omitted for brevity. See the sample for a full working example.
private static void ConfigureAuthenticationForTest(IServiceCollection services)
{
// Remove the existing configuration from the API, we don't need that...
services.RemoveAll<IPostConfigureOptions<CookieAuthenticationOptions>>();
// Reconfigure CookieAuthenticationOptions to use a custom cookie format
services.PostConfigure<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.CookieManager = new ChunkingCookieManager();
options.TicketDataFormat = new TestCookieTicketFormat();
options.Cookie.Name = "api-cookie";
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = 401;
return Task.CompletedTask;
};
});
}
Here we set up a new TicketDataFormat
by giving it an instance of our custom format and we also need
to set up the CookieManager
for this to work correctly. It is important that we make sure we use the
same cookie name here as we do for the API. The framework will then be able to use this like our regular
cookie authentication and create a ClaimsPrincipal
containing the ClaimsIdentity
for our test "user"
represented by the JWT.
Now we can create a cookie from arbitrary claims in our tests using our custom format:
private static CookieHeaderValue CreateCookie(string role)
{
var claims = new List<Claim>
{
new("name", "Some User"), new("role", role)
};
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
var authenticationTicket = new AuthenticationTicket(claimsPrincipal, CookieAuthenticationDefaults.AuthenticationScheme);
var cookie = new TestCookieTicketFormat().Protect(authenticationTicket);
return new CookieHeaderValue("api-cookie", cookie);
}
and use it in our tests to call the authenticated API endpoint:
[Fact]
public async Task User_is_authenticated_by_test_cookie()
{
var httpClient = await CreateTestClient();
var cookie = CreateCookie("admin");
var request = new HttpRequestMessage(HttpMethod.Get, "/people");
request.Headers.Add("cookie", cookie.ToString());
var response = await httpClient.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
Now we can use cookies with the ASP.NET Core Test Server and make sure our API is properly handling claims within the authorization policies we configured.
With this post and my previous post about JWT authentication in tests we have seen how we can use the Test Server without compromising on what gets executed as part of these tests. I hope this is useful for someone and maybe this will save someone else the headaches I experienced with this some day.