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:
SessionStore
that used something like redis or the database itself to store sessionsThe 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:
So with that choice somewhat-hastily made, it was time to go about switching to client-side sessions.
I found two libraries to help make signed+encrypted client-side sessions happen:
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 concept of what I’m trying to do is pretty simple, so I’ll try and lay it out here:
At first /login
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 objectOn subsequent requests
auth-cookie
cookieRemove-Cookie
headerWhen the user visits /logout
Remove-Cookie
headerSince 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.
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.
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.
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.
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:
Network.Wai.Session
session (AuthHandler
has run at this point and provided the session or rejected the request)saveSession
function function to save an updated session in the in-memory storeSet-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:
Response
that WAI
(which runs under Servant) would send directlyCookieUserInfo
has to be created with the right user informationByteString
and encryptedSet-Cookie
header 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 ResponseHeader
s 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!
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
.
Of course, the last step is to test it all out. I won’t bore you with it, as you might guess that involves:
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.
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!