Moving From Server Side Sessions To Client Side Session Tokens with Servant

Documenting my move from Network.Wai.Session to Web.ClientSession

vados

12 minute read

tl;dr - I moved from server-side stored sessions provided by Network.Wai.Session to client-side signed+encrypted session tokens provided by Wai.ClientSession for my Servant-powered webapp, it’s pretty easy, skim through to see the setup code, /login and /logout code that was required.

UPDATE After posting to r/haskell, user u/cocreature pointed out the existence of the servant-auth package – it looks like an awesome solution so also make sure to give that a try before rolling your own. I took a quick look at it, and it certainly would have saved me a ton of time – right off the bat it would save me time implementing multiple auth methods (which I haven’t run into yet, no API key/JWT based access is in the project yet) for the same group of endpoints.

One of my recent projects is a Servant-powered application, and for a long time I used Network.Wai.Session to do cookie-based authentication, using the default/basic in-memory session store. While Network.Wai.Session is an excellent library, and has served me well, I wanted to start persisting sessions across server restarts and was faced with at least the two following options:

  1. Implement a SessionStore that used something like redis or the database itself to store sessions
  2. Use client-side signed & encrypted sessions

The obvious drawbacks of the first approach are that I would need to expand my infrastructure (either by way of another service or slight modifications to the database). The obvious drawbacks of the second approach are that it I have to do considerably more work to implement the change (I already had server-side sessions working), and (at first glance) some loss of control over session expiry (unless I was willing to add some management features which would be just like having server-side sessions to begin with).

Generally client-side sessions with no expiry mechanism (whether natural or forced) are bad for security, since if someone gets a hold of a session they can now pretend to be that authenticated user forever. Since it’s possible to bolt on the required functionality for session expiration to the client-signed session approach (basically by building more into the backend like you would have in #1, and doing some checks), I consider them almost identical approaches. I chose to go with signed+encrypted client-side sessions for a few (good?) reasons:

  1. I wanted to get a feel for what it was like to do client-side sessions in Haskell + Servant land
  2. I wanted to minimize the state I stored/managed on the server if I could avoid it, at least initially
  3. I wanted the easiest path to reusable sessions across app instances (as long as the instsances have the same encryption-related secret we’re good, they could even have separate backend databases but be able to share user account tokens)

So with that choice somewhat-hastily made, it was time to go about switching to client-side sessions.

Pre-existing libraries

I found two libraries to help make signed+encrypted client-side sessions happen:

  1. Web.ClientSession - This seemed to be a more general-use/pluggable library, that provides the means to encrypt the information, and leaves the rest to the user
  2. Servant.Server.Experimental.Auth.Cookie - This seems to fit more/be made to go with Servant and does a lot more for you

Normally, #2 would be the best option as picking libraries that are built for the framework you use is usually the right way to go. I, however, am using a custom monad for my servant handlers which complicates things, and I did not much like having to wrap all my endpoints in (Cookied ..) – so I’m going to piece my own solution together using #1, filling in all the things that #2 would have given me for free.

The general idea

The concept of what I’m trying to do is pretty simple, so I’ll try and lay it out here:

At first /login

  1. When the user hits /login, validate their credentials (fail & reject the request if there’s an error)
  2. Create an object that contains a subset of the credentials that are important about them – let’s say just user id, email, and role/permissions.
  3. Serialize that object
  4. Sign & Encrypt it
  5. Create a cookie (named let’s say auth-cookie) with the right settings (the domain path it’s valid on, whether it’s secure, httpOnly, all the important settings) and have it’s value be the serialized+encrypted session info object
  6. Add the cookie to the response (the browser will take care of including it on every subsequent reesponse)

On subsequent requests

  1. Read the incoming auth-cookie cookie
  2. Decrypt and read the stored value
  3. If decryption fails, throw some sort of authentication failure error & message, possibly setting the Remove-Cookie header
  4. If decryption succeeded, authenticate as normal

When the user visits /logout

  1. Set Remove-Cookie header
  2. (Optionally) deactivate the session in anyway possible (this requires extra session remembering/forgetting machinery on the backend)

Since I know what I want to do (it’s generally the same in every webapp and every language), but now how to do it in an axiomatic way (in both the Haskell and Servant senses), time will be figuring out the right way to do things.

Let’s get into what I actually ended up doing to get everything in place.

Step 0: Take out bits of the old auth code

I started by taking out the set up and initialization code that powered the old Network.Wai.Session-powered code. Things like creating the default SessionStore and creating a Vault Key for the cookie I needed to store in the Request now no longer needed to be done. Along with those things, I now no longer needed to install the middleware that came with Network.Wai.Session that did all the necessary cookie setting.

Step 1: Create the user info object and tell Servant about it

The Servant.API.Experimental.Auth package (don’t let the word “experimental” scare you, the API has been pretty stable and is definitely usable, the docs refer to it as generalized authentication now) dictates a bit of what needs to exist.

data CookieUserAuthInfo = CookieUserAuthInfo { uaiId           :: String
                                             , uaiEmailAddress :: String
                                             , uaiRole         :: Role
                                             , uaiPermissions  :: [Permission]
                                             , homeLocationId  :: Maybe ForeignKey
                                             }
 -- For Servant generalized auth
type instance AuthServerData (AuthProtect "cookie-auth") = CookieUserAuthInfo

As you can see, at the very least I need some type with some information about a user that I want to save, and I need to sprinkle some type magic to enable AuthProtect "cookie-auth" to produce my type when needed.

Step 2: Write/Modify the auth handler to read cookies from the request

Now that we’ve codified what we want to save in the signed+encrypted cookie, it’s time to modify the Servant.Server.Experimental.Auth.AuthHandler to read cookies from the request, rather than pulling out the SessionStore and doing whatever else it used to do.

I started by writing out a bunch of pseudo code:

-- Generate a cookie auth handler, given a session store cookie
genCookieAuthHandler :: AuthHandler Request UserAc
genCookieAuthHandler req = getNamedCookieFromRequest
                           >>= ifNothingThrowError Err.notLoggedIn
                           >>= decryptCookie
                           >>= ifNothingThrowError throwError Err.invalidAuthHeaders

Now, I just needed to make those functions I’m bind-chaining together so that this can be a real function! I spent an unusual amount of time being confused with why the code I wrote to support this flow didn’t work, mostly because of the fact that Maybe is monadic, and trying to use it inside of a >>=-heavy chain of functions changes the context (so the result might end up in the Maybe monad, and not the Handler monad like it’s supposed to). While I was lost I found it was helpful to check and re-check the type of AuthHandler (using ghci’s :t and :info commands), and really try to burn the definitions into my head and understand what I was trying to do at the type level.

Here’s what the code ended up looking like:

genCookieAuthHandler :: Key -> AuthHandler Request CookieUserAuthInfo
genCookieAuthHandler serverKey = mkAuthHandler handler
    where
      handler req = getAuthCookieValue req
                    >>= ifNothingThrowError Err.notLoggedIn
                    >>= decryptCookie serverKey
                    >>= ifNothingThrowError Err.invalidAuthHeaders

getAuthCookieValue :: Request -> Handler (Maybe ByteString)
getAuthCookieValue = return . maybe Nothing (lookup authCookieName) . (parseCookies<$>) . lookup hCookie .  requestHeaders

decryptCookie :: Key -> ByteString -> Handler (Maybe CookieUserAuthInfo)
decryptCookie key str = return . fromMaybe Nothing $ decode . BSL8.fromStrict <$> decrypt key str

I had to then go through and remove the Wai.Session based code and replace it with this – the old code looked pretty ugly when I looked back at it (I’ve gotten very used to the >>= chaining style, so find long do sections tedious now).

Here’s what the setup code looks like now in my startApp function:

-- Get/create the existing client side cookie key
  cookieKey <- WCS.getKey (cookieSecretFilePath cookieCfg)

  let appGlobals = ApplicationGlobals c databaseBackend mailer userContentStore searchBackend appLogger cookieKey
  let app = makeApp appGlobals cookieKey

This code is inside a rather large do block, but you can see, the only setup I need to do to be able to use this form of cookies is just read in the cookie secret and use functions already provided by Web.ClientSession (aliased to WCS here). the rest of the stuff that happens is more app specific (outside of maybe using the cookieKey later). makeApp is responsible for doing a few things like setting up the routes and serving the app with the right Context that makes Servant generalized auth work.

Step 3: Modifying the /login endpoint

Next up was to start actually setting the cookies and doing the authentication with the changed AuthHandler. This prompted more of a re-write than I thought would be necessary.

Before, the webapp’s /login handler code would:

  • Check for Network.Wai.Session session (AuthHandler has run at this point and provided the session or rejected the request)
  • Use the provided saveSession function function to save an updated session in the in-memory store
  • Set-Cookie header and other things are taken care of by the WAI session middleware that goes with Network.Wai.Session

Under the new setup, the webapp’s /login handler code has to:

  • Modify the Response that WAI (which runs under Servant) would send directly
  • The appropriate CookieUserInfo has to be created with the right user information
  • The object must be serialized into a ByteString and encrypted
  • Set-Cookieheader needs to be added, with that encrypted value. (note that encryptIO does Base64 encoding for you so no need to worry about sending bad characters)

At first, I struggled a lot trying to figure out how to axiomatically change response headers with Servant, but thanks to the great work by the Servant team, there’s a nice easy to use API for manipulating ResponseHeaders API. I also spent a bit of time adding some more utility functions to do translation between cookie-related types and stuff I get from the backend.

Quick sidetrack: The Swagger2 package doesn’t really like generting docs stuff ByteStrings Once I had something I thought would work, I got down to dealing with an issue with haskell’s swagger2 package – turns out you need a newtype wrapper if you plan to expose bytestrings. I side-stepped this issue by using Data.Text instead – I think there are some performance ramifications but I don’t think they’re too bad (at least I really hope they aren’t, I have to admit I didn’t really check).

Here’s what the old code looked like:

login :: Auth.WAISession -> UserEmailAndPassword -> WithApplicationGlobals Handler (EnvelopedResponse SessionInfo)
login (_,saveSession) (UserEmailAndPassword e p) = do
   backend <- getBackendOrFail
   user <- liftIO $ findUserWithEmailAndPassword backend e p
   case user of
       Nothing -> throwError $ Err.enveloped Err.failedToLoginUser
       u -> do
           session <- liftIO $ createUserSession backend defaultUserSessionExpiryInMinutes u
           case session of
               Nothing -> throwError $ Err.enveloped Err.failedToLoginUser
               Just s -> do
                   liftIO $ saveSession Auth.sessionInfoKey (LoggedIn s) -- Save the session info key
                   return $ EnvelopedResponse "success" "Successfully Logged in" s

And the new code:

login :: UserEmailAndPassword -> WithApplicationGlobals Handler (Headers '[Header "Set-Cookie" T.Text] (EnvelopedResponse SessionInfo))
login (UserEmailAndPassword e p) = getBackendOrFail
                                   >>= \backend -> liftIO (loginUser backend e p)
                                   >>= createUserSession backend defaultUserSessionExpiryInSeconds
                                   >>= ifNothingThrowError (Err.enveloped Err.failedToLoginUser)
                                   >>= \session -> makeSessionCookie session
                                   >>= \cookie -> pure $ addHeader cookie $ EnvelopedResponse "success" "Successfully Logged in" session

I personally like the second version of the code, but to each their own!

Step 4: Modifying the /logout endpoint

The old code that powered the /logout endpoint also needed to change. Under the old setup I had a type whose value of LoggedOut represented logged out sessions, but in the new design I simply needed to remove the cookie from the request, or set it to empty (or both).

The old code:

logout :: Auth.WAISession -> WithApplicationGlobals Handler (EnvelopedResponse SessionInfo)
logout s@(_,saveSession)  = do
    session <- getSessionInfoOrFail s
    liftIO $ saveSession Auth.sessionInfoKey LoggedOut
    return $ EnvelopedResponse "success" "Successfully logged out" session

Here’s what the new code looks like:

logout :: SessionInfo -> WithApplicationGlobals Handler (Headers '[Header "Set-Cookie" T.Text] (EnvelopedResponse SessionInfo))
logout sessionInfo = clearSessionCookie
                     >>= pure . flip addHeader response
    where
      response = EnvelopedResponse "success" "Successfully logged out" sessionInfo

Much better… kind of. The newer implementation is more streamlined, but a tiny bit harder to understand, mostly thanks to the use of flip.

Step 5: Test it all out

Of course, the last step is to test it all out. I won’t bore you with it, as you might guess that involves:

  1. Login, go to the app page
  2. Stop and start the backend server
  3. Refresh the app page (user should still be logged in, and I shouldn’t be bounced out)

This worked, which was awesome!

One thing I didn’t count on was having all the E2E (End to End) tests start failing. I had no idea why they were failing, as they passed in the browser (and even when I stopped the test and clicked through the created browser by hand).

I use webdriver.io (it’s awesome) + chromedriver for my tests, and the getCookie API command seemed to be puzzlingly returning null from inside the test, despite the cookie getting set on the browser. My mind raced to the possible causes: httpOnly? Maybe the driver was broken? Maybe the cookie wasn’t even working properly and the whole app was just broken? I burned a lot of time trying to debug, only in the end to switch to not trying to pull the cookie from the live browser. In the end what I did was to grab the request after logging in and manually pull the Set-Cookie header off of it for use in future requests. This isn’t ideal, because it kind of breaks the “we’re behaving just like a user” illusion, but I was far too annoyed with the problem to try and get to the bottom of it searching through webdriver code.

This also reminds me, I have a bit of a security issue to deal with – CSRF. Basically, the problem is that I don’t add any kind of token to differentiate requests that actually hit the server (like someone actually loading the /login page, it’s possible for people to execute CSRF-based attacks. This is particularly useful/important on pages with important forms.

Wrapping up

I’m happy to report that it was all pretty easy to set up – I was able to stand on the shoulders & heads of giants and get this feature out the door relatively quickly. I think I’ve also ended up with less setup code and less moving pieces which is great as well. I hope this code helps you set up your own solution if/when necessary!

Did you find this read beneficial? Send me questions/comments/clarifciations.
Want my expertise on your team/project? Send me interesting opportunities!