For those of us used to cookies in traditional ASP.NET the switch to ASP.NET Core might leave us scratching our heads. In the old system we were able to directly add and remove cookies from both the request and response objects (for better or worse). This might have led to us writing and overwriting the same cookie multiple times during a request as different portions of code affected it. DotNetCore has changed the game and that’s a good thing, trust me. Today we’re going to learn a technique for cookie management in DotNetCore web applications.
All code for this post can be located on my GitHub.
Understanding the past
For argument’s sake I wanted to present what might be “common” code in traditional ASP.NET MVC for loading a cookie. The problem, of course, is that if somewhere in our code has set the cookie value and we are later looking for it again we wanted to make sure we always get the latest copy, not necessarily what came in on the request. The code below looks to see if the response has anything that matches first.
public static System.Web.HttpCookie GetCookie(this System.Web.HttpContextBase context, string keyName)
{
System.Web.HttpCookieCollection cookies = new System.Web.HttpCookieCollection();
System.Web.HttpCookie cookie = null;
// check for response value first...
if (context.Response.Cookies.AllKeys.Any(key => string.Equals(key, keyName, StringComparison.OrdinalIgnoreCase)))
cookie = context.Response.Cookies.Get(keyName);
else if (context.Request.Cookies.AllKeys.Any(key => string.Equals(key, keyName, StringComparison.OrdinalIgnoreCase)))
cookie = context.Request.Cookies.Get(keyName);
return cookie;
}
So given that is how we might have accessed a cookie for consumption, how might we have messed with modifying it? There’s a plethora of ways, I’m sure, but here’s an example of what I might have done:
public static void SetCookie(this System.Web.HttpContextBase context, string keyName, string value, DateTime? expiry = null)
{
if (context.Response.HeadersWritten)
return;
// a null value is equivalent to deletion
if (value == null)
{
context.Request.Cookies.Remove(keyName);
context.Response.Cookies.Add(new System.Web.HttpCookie(keyName, "") { Expires = DateTime.Today.AddYears(-1) });
return;
}
System.Web.HttpCookie newCookie = new System.Web.HttpCookie(keyName, value);
if (expiry.HasValue)
newCookie.Expires = expiry.Value;
context.Response.Cookies.Add(newCookie);
}
In the above code we are trying to ensure that deleting a cookie will also prevent a consumption attempt in the same request if it doesn’t find it. We are also preventing writing a cookie if the headers have already been sent (since that’d throw an exception). One thing this code *does not do* is prevent duplicates and I did that bug on purpose.
Once this writes out to the browser the last one in the response will win so it’ll still “work” as intended but again, we have a bug. In case you’re wondering, you don’t want to willy-nilly context.Response.Cookies.Add
but should check to see if it already exists and, if so, call context.Response.SetCookie
instead.
While it wasn’t difficult to write a cookie manager and ensure all of your cookie code ran through it, it was a common for rookies and seasoned developers alike to assume “it just works”. All that said, if you did learn your workaround and got used to it, DotNetCore would throw you off.
Differences in DotNetCore
Now that we’ve gone over a little of how you might expect to do things in traditional ASP.NET MVC it is important to highlight the differences in DotNetCore.
First of all the HttpContext.Request.Cookies
collection in DotNetCore cannot be modified. You’ll have hopefully noticed in prior examples that when we deleted a cookie in the traditional version we also removed the request copy to ensure we didn’t consume an invalid cookie later. Likewise the HttpContext.Response.Cookies
won’t allow you to remove an item you appended to it. Sure, you can ask to “delete” the cookie but that just modifies the expiry so the browser will delete it. Once it’s in, it’s in.
When I was rewriting a large application in DotNetCore and “copying” code from the old system over, these differences were something I ran into very early on and which led to learning about cookie management in ASP.NET Core.
These differences are a good thing because they force you to give a little more thought to what you’re doing rather than just assuming it all works. Given the example code for traditional ASP.NET MVC you could end up with multiple copies of the cookie in the response unless you’re careful. If that happens and you attempt to read the value later on in the same request you might not actually get back what you hoped for. That’s bad.
Introducing a Cookie Service
Given our differences, then, and the fact that DotNetCore really tries hard to get you to use dependency injection, how might you approach cookie management then? My personal take is that all of your cookie management should funnel through a service and then a middleware would be in charge of writing the final state back out to the response. Let’s go ahead and get started:
public class CachedCookie
{
public string Name { get; set; }
public string Value { get; set; }
public CookieOptions Options { get; set; }
public bool IsDeleted { get; set; }
}
public interface ICookieService
{
void Delete(string cookieName);
T Get<T>(string cookieName, bool isBase64 = false) where T : class;
T GetOrSet<T>(string cookieName, Func<T> setFunc, DateTimeOffset? expiry = null, bool isBase64 = false) where T : class;
void Set<T>(string cookieName, T data, DateTimeOffset? expiry = null, bool base64Encode = false) where T : class;
void WriteToResponse(HttpContext context);
}
public class CookieService : ICookieService
{
private readonly HttpContext _httpContext;
private Dictionary<string, CachedCookie> _pendingCookies = null;
public CookieService(IHttpContextAccessor httpContextAccessor)
{
_httpContext = httpContextAccessor.HttpContext;
_pendingCookies = new Dictionary<string, CachedCookie>();
}
public void Delete(string cookieName)
{
}
public T Get<T>(string cookieName, bool isBase64 = false) where T : class
{
throw new NotImplementedException();
}
public T GetOrSet<T>(string cookieName, Func<T> setFunc, DateTimeOffset? expiry = null, bool isBase64 = false) where T : class
{
throw new NotImplementedException();
}
public void Set<T>(string cookieName, T data, DateTimeOffset? expiry = null, bool base64Encode = false) where T : class
{
}
public void WriteToResponse(HttpContext context)
{
}
}
In the above code block I’ve added a CachedCookie
class,
stubbed out the interface for our CookieService
, and set up the skeleton for our service.
One thing we should understand early on is this service is based on generics for a reason. I want to be able to write almost any value out to my cookies. In this case I opted that the generic should be constrained to a class (which string
will qualify but all the basic value types will fail). For this magic to work I’m going to JSON serialize my value to a string.
In order to understand how all the pieces fit together I figured we’d take this a step at a time.
Our constructor is injecting an IHttpContextAccessor
which enables us to get access to the current HttpContext
for the request. This is similar to old ASP.NET where we’d have used HttpContext.Current
. For this to work, however, we need to register it as an injectable so hop on over to your Startup.cs
and add these lines in your ConfigureServices
method:
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddScoped<ICookieService, CookieService>();
Another thing you’ll notice in our constructor is we’re setting up an empty dictionary for instances of our CachedCookie
. This is where we’re going to track state of our cookies for the duration of a request before the middleware dumps them out to the response.
Middleware
Next thing we need to take care of is creating our middleware and getting it into our pipeline. Let’s add CookieServiceMiddleware.cs
and fill it in:
internal class CookieServiceMiddleware
{
private readonly RequestDelegate _next;
public CookieServiceMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context, ICookieService cookieService)
{
// write cookies to response right before it starts writing out from MVC/api responses...
context.Response.OnStarting(() =>
{
// cookie service should not write out cookies on 500, possibly others as well
if (!context.Response.StatusCode.IsInRange(500, 599))
{
cookieService.WriteToResponse(context);
}
return Task.CompletedTask;
});
await _next(context);
}
}
Injecting a scoped service into a middleware cannot be done at the constructor level. You’ll notice that I’m injecting it in the Invoke method and it probably seems a bit like magic. Somewhere deep in the bowels of DotNetCore it is smart enough to know how to inject there.
Another thing to note is that I detect when the response is starting and then check to see if the status code is not within a certain range. If it is outside that range then we go ahead and write our cookies out to the response via the service. The IsInRange
extension method is one I’ve added so, without further ado, here is a basic IntExtensions.cs
I added to the project:
public static class IntExtensions
{
public static bool IsInRange(this int checkVal, int value1, int value2)
{
// First check to see if the passed in values are in order. If so, then check to see if checkVal is between them
if (value1 <= value2)
return checkVal >= value1 && checkVal <= value2;
// Otherwise invert them and check the checkVal to see if it is between them
return checkVal >= value2 && checkVal <= value1;
}
}
Registering the middleware
Alrighty. One last thing we need to add to our middleware code and hook up. Convention with middleware is to create a static class and extension method that will handle registering the middleware. Let’s add CookieServiceMiddlewareExtensions
:
public static class CookieServiceMiddlewareExtensions
{
public static IApplicationBuilder UseCookieService(this IApplicationBuilder builder)
{
return builder.UseMiddleware<CookieServiceMiddleware>();
}
}
And let’s go into Startup.cs into our Configure method and add app.UseCookieService();
somewhere up the chain. The trick here is you want it to show up before your app.UseMvc
call as well as before anything else that might affect your response but not too high up to prematurely write out cookies to the response. In this case I’m choosing to add it after the app.UseCookiePolicy
call. Your own mileage may vary if you have a lot of other middleware.
Let’s circle back for just a quick moment. If my middleware were a bit more complicated and had more than one service needing registering I might have also created an extension method to call from my ConfigureServices method as well. If I were creating a middleware for distribution then I’d most assuredly do it even if there were only a single service. I don’t want to force somebody to have to know everything to configure my middleware for DI, they should be able to simply ask to add it and move on.
That extension method might have a signature like this public static IServiceCollection ConfigureCookieService(this IServiceCollection services, IConfiguration configuration)
. (IConfiguration optional here… I’ve needed it for some things but clearly in this case we wouldn’t need it).
Fleshing it out
Wonderful, we now have our service and middleware registered but it doesn’t do anything yet. Let’s go ahead and start fleshing it out one method at a time. Since the first thing we’d actually try to do is load a cookie for consumption maybe we should start there. Go into your CookieService.cs
and add the following code into the public T Get<T>
method:
Get<T>
public T Get<T>(string cookieName, bool isBase64 = false)
where T : class
{
return ExceptionHandler.SwallowOnException(() =>
{
// check local cache first...
if (_pendingCookies.TryGetValue(cookieName, out CachedCookie cookie))
{
// don't retrieve a "deleted" cookie
if (cookie.IsDeleted)
return default(T);
return isBase64 ? Newtonsoft.Json.JsonConvert.DeserializeObject<T>(cookie.Value.FromBase64String())
: Newtonsoft.Json.JsonConvert.DeserializeObject<T>(cookie.Value);
}
if (_httpContext.Request.Cookies.TryGetValue(cookieName, out string cookieValue))
return isBase64 ? Newtonsoft.Json.JsonConvert.DeserializeObject<T>(cookieValue.FromBase64String())
: Newtonsoft.Json.JsonConvert.DeserializeObject<T>(cookieValue);
return default(T);
});
}
Let’s look at the meat of this before we talk about the ExceptionHandler
. Our Get<T> method first asks our pendingCookies
dictionary if it has something matching the key. If it does, it then asks if we’ve marked it IsDeleted
. If we have one and it isn’t deleted then we go ahead and deserialize it to the requested object type and optionally we’ll need to additionally decode it from base64 first.
If we don’t have a local copy of it in our cache then we go ahead and see if the HttpContext.Request.Cookies
has it and, as with our local cache, optionally decode from base64 before finally deserializing it.
Now why in the world would I have it base64 encoded? It’s not so much that I want to “protect” my cookie from prying eyes, per se, but if I have a pretty complex object I’m writing out to a cookie I want to break it down a bit. A JSON string representation of an object can get pretty unwieldy.
Speaking of base64 encoding… those are a couple more extension methods I added in a StringExtensions.cs
file. Here you go:
public static class StringExtensions
{
public static string FromBase64String(this string value, bool throwException = true)
{
try
{
byte[] decodedBytes = System.Convert.FromBase64String(value);
string decoded = System.Text.Encoding.UTF8.GetString(decodedBytes);
return decoded;
}
catch (Exception ex)
{
if (throwException)
throw new Exception(ex.Message, ex);
else
return value;
}
}
public static string ToBase64String(this string value)
{
byte[] bytes = System.Text.ASCIIEncoding.UTF8.GetBytes(value);
string encoded = System.Convert.ToBase64String(bytes);
return encoded;
}
}
Ok, now what is this ExceptionHandler.SwallowOnException
sorcery? I could have gone with the try {} catch {}
block but this is a use-case where I’m 100% ok with a failure just dropping out of existence because the cookie just simply doesn’t have to exist. Now… if you dig into the code for that handler you’ll see that it still does a try/catch block, I’m just abstracting it away a little. Let me prove that to you.
Exception Handler
public static class ExceptionHandler
{
public static T SwallowOnException<T>(Func<T> func)
{
try
{
return func();
}
catch
{
return default(T);
}
}
}
Set<T>
Hey we’re pretty cool and can load a cookie but that’s not terribly useful if we can’t create one, right? Let’s make that part work.
public void Set<T>(string cookieName, T data, DateTimeOffset? expiry = null, bool base64Encode = false)
where T : class
{
// info about cookieoptions
CookieOptions options = new CookieOptions()
{
Secure = _httpContext.Request.IsHttps
};
if (expiry.HasValue)
options.Expires = expiry.Value;
if (!_pendingCookies.TryGetValue(cookieName, out CachedCookie cookie))
cookie = Add(cookieName);
// always set options and value;
cookie.Options = options;
cookie.Value = base64Encode
? Newtonsoft.Json.JsonConvert.SerializeObject(data).ToBase64String()
: Newtonsoft.Json.JsonConvert.SerializeObject(data);
}
When creating a cookie we need to set up a few bits of information. I’m going pretty bare here but I highly recommend you read up on CookieOptions. Not setting an Expires will default to being a “session” cookie. These don’t work properly if you use Google Chrome with the “always open” mode (or whatever the heck they call it).
In our code here we’ll see if we already have a pendingCookie instance and, if not, we add one. I’ll get to that method in a minute. After we have an instance of the cookie we’ll attach the options and write the value optionally base64 encoded. Let’s look at the Add
method now.
protected CachedCookie Add(string cookieName)
{
var cookie = new CachedCookie
{
Name = cookieName
};
_pendingCookies.Add(cookieName, cookie);
return cookie;
}
Pretty basic stuff. We just flat out trust we can add it and do so. This method isn’t exposed so I’m going to trust I don’t have to check the dictionary first. If you don’t feel good about that, feel free to modify it.
Deleting a cookie
At some point we’re going to want to delete a cookie, right? We want to make sure that subsequent queries for that same cookie know it is deleted as we’ve seen in the Get<T> call. For this to work properly we need our local cache to track that.
void ICookieService.Delete(string cookieName)
{
Delete(cookieName);
}
protected CachedCookie Delete(string cookieName)
{
if (_pendingCookies.TryGetValue(cookieName, out CachedCookie cookie))
cookie.IsDeleted = true;
else
{
cookie = new CachedCookie
{
Name = cookieName,
IsDeleted = true
};
_pendingCookies.Add(cookieName, cookie);
}
return cookie;
}
In the above code we have the interface Delete method and the class Delete method both with the same signature. I could have named them different but I really didn’t want to. In order to prevent the compiler from whining about that, however, we have to make the interface method an explicit interface call. We simply pass that call into our class instance method.
Once inside our class instance delete method we see if we already have a pending instance and, if so, mark it deleted. If not, we add it to the cache and mark it deleted.
GetOrSet<T>
Sometimes you want a cookie to exist no matter what but if it is already there you want to get it’s value. A use-case for this is if you want to load a cookie if present or set defaults otherwise. On one site I worked on we have a “trip planner” that fits this use-case. I want to know their details if they have them otherwise I’m going to set some defaults so the rest of the session experience is based on the same information. This is pretty simple to set up:
public T GetOrSet<T>(string cookieName, Func<T> setFunc, DateTimeOffset? expiry = null, bool isBase64 = false)
where T : class
{
T cookie = Get<T>(cookieName, isBase64);
if (cookie != null)
return cookie;
T data = setFunc();
Set(cookieName, data, expiry, isBase64);
return data;
}
If the cookie exists, we get it. If it doesn’t, we set it. Easy peasy.
Writing it out
All the above code really doesn’t matter if we never write it back out to the response, right? Remember in our middleware during the context.Response.OnStarting
block we told the service to WriteToResponse
? Let’s make that actually do something now:
public void WriteToResponse(HttpContext context)
{
foreach (var cookie in _pendingCookies.Values)
{
if (cookie.IsDeleted)
context.Response.Cookies.Delete(cookie.Name);
else
context.Response.Cookies.Append(cookie.Name, cookie.Value, cookie.Options);
}
}
We iterate each of our pending cookies and will either Delete
or Append
them based on our cached value. Now we only ever have a single copy of each cookie being written out instead of our classic ASP.NET debacle we introduced at the beginning of this post.
Putting it together
The code on GitHub has a pretty lame little contrived demo in the HomeController. What follows are some unit tests instead. Before I post some code I wanted to go over a little how my BaseTest.cs
works. I could have (and frankly should have but since I copied this out of production code which had other concerns, I didn’t) use the DotNetCore service collection. BaseTest, instead, is dependent on UnityContainer. It was just a very simple way for me to set up the dependency engine. Scoff at it all you want.
What follows is just going to be a fat dump of the CookieServiceTests
class. The Initialize
method sets up things that each test will use and then each individual test sets up their own scenarios. It should become readily apparent how to use the service and hopefully give you some ideas how to use it in your own projects.
[TestClass]
public class CookieServiceTests : BaseTest
{
IHttpContextAccessor _httpContextAccessor;
HttpContext _httpContext;
CookieService _target;
[TestInitialize]
public void Initialize()
{
_httpContextAccessor = Substitute.For<IHttpContextAccessor>();
_httpContext = new DefaultHttpContext();
_httpContextAccessor.HttpContext.Returns(_httpContext);
Container.RegisterInstance(_httpContextAccessor);
_target = Container.Resolve<CookieService>();
}
[TestMethod]
public void CookieService_SetCookie_Success()
{
CookieFake cookie = new CookieFake { TestProperty = 25, TestPropertyString = "blah" };
_target.Set("fakecookie", cookie);
CookieFake cachedCookie = _target.Get<CookieFake>("fakecookie");
Assert.IsNotNull(cachedCookie);
Assert.AreEqual(cookie.TestProperty, cachedCookie.TestProperty);
Assert.AreEqual(cookie.TestPropertyString, cachedCookie.TestPropertyString);
}
[TestMethod]
public void CookieService_SetCookie_StringOnly_Success()
{
string value = "I'm a cookie value";
_target.Set("fakecookie", value);
string result = _target.Get<string>("fakecookie");
Assert.IsFalse(string.IsNullOrWhiteSpace(result));
Assert.AreEqual(value, result);
}
[TestMethod]
public void CookieService_SetCookie_Base64_Success()
{
CookieFake cookie = new CookieFake { TestProperty = 25, TestPropertyString = "blah" };
_target.Set("fakecookie", cookie, base64Encode: true);
CookieFake cachedCookie = _target.Get<CookieFake>("fakecookie", true);
Assert.IsNotNull(cachedCookie);
Assert.AreEqual(cookie.TestProperty, cachedCookie.TestProperty);
Assert.AreEqual(cookie.TestPropertyString, cachedCookie.TestPropertyString);
}
[TestMethod]
public void CookieService_GetOrSetCookie_SetsCookie_Success()
{
Func<CookieFake> createCookie = () =>
{
return new CookieFake { TestProperty = 25, TestPropertyString = "blah" };
};
var cookie = _target.GetOrSet<CookieFake>("fakecookie", createCookie);
Assert.IsNotNull(cookie);
Assert.AreEqual(cookie.TestProperty, 25);
}
[TestMethod]
public void CookieService_GetOrSetCookie_GetsCookie_Success()
{
CookieFake cookie = new CookieFake { TestProperty = 25, TestPropertyString = "blah" };
_target.Set("fakecookie", cookie);
Func<CookieFake> createCookie = () =>
{
return new CookieFake { TestProperty = 55, TestPropertyString = "blah2" };
};
var retrievedCookie = _target.GetOrSet<CookieFake>("fakecookie", createCookie);
Assert.IsNotNull(retrievedCookie);
Assert.AreEqual(retrievedCookie.TestProperty, cookie.TestProperty);
Assert.AreEqual(retrievedCookie.TestPropertyString, cookie.TestPropertyString);
}
[TestMethod]
public void CookieService_GetCookie_Fail()
{
CookieFake cachedCookie = _target.Get<CookieFake>("fakecookie");
Assert.IsNull(cachedCookie);
}
[TestMethod]
public void CookieService_GetCookie_Base64_Fail()
{
CookieFake cookie = new CookieFake { TestProperty = 25, TestPropertyString = "blah" };
_target.Set("fakecookie", cookie);
CookieFake cachedCookie = _target.Get<CookieFake>("fakecookie", true);
Assert.IsNull(cachedCookie);
}
}
public class CookieFake
{
public int TestProperty { get; set; }
public string TestPropertyString { get; set; }
}
Conclusion
Cookie management in DotNetCore web applications is not a complicated thing but it is easy to make inefficient. We’ve looked at a way to ensure our response is as clean as possible by introducing a CookieService and middleware.
All code from today’s post can be located on my GitHub. I encourage you to look at the full project, look at my lame contrived example in the web application, and then use it yourself.
Credits
Photo by John Dancy on Unsplash