One of the applications I’ve been working on exclusively lately is a surprisingly large Job Board application. Not the most exciting concept, I know, but it’s quickly becoming the culmination of some of the best development (and consequently “engineering”) work that I’ve ever done. The codebase is growing huge – 11,000+ lines counting both front and backend code, and while I haven’t gone on any large refactoring sprees, most of the code does not make me feel bad. I use such a vague description for code quality because it’s such a relative target from person to person, but generally when you write code that doesn’t fit even your own standards, I don’t know about other people but I kind of start to feel bad/uneasy. Let’s get into the breakdown of my thought process while I was feeling my way around Servant and writing this authentication combinator in.
As I often do with most tickets, I make a quick to-do list trying to capture all the concerns that I can think of off the top of my head when I write the Gitlab issue. My to-do list looked something like this:
Of course, I didn’t end up needing/doing all of these points, but I think seeing this listing shows just how much still needed to be done starting on this adventure. Again, this i sjust a list of things I could think of, while I still didn’t know exactly what the implementation would look like.
I started the journey by looking at servant’s documentation for “experimental” general purpose authentication combinators. It made me a tad uneasy that the support was “experimental”, but luckily despite being experimental, it served as an OK guide for learning how to extend Servant in the way I wanted to. By using this guide, I was able to get 99% of the way towards the combinator that I really wanted – I came up against the problem of actually storing the cookies somewhere.
Resting on what I already knew from other languages, my first thought was to start writing som ecode to store the session in the database (or in memory, whatever), sign & encrypt my data, and serve it in an HttpOnly
header. Of course, the only bits that were cloudy were everything:
Crypto.Bcrypt
), and it was super fun to work with.During looking at this I discovered Web.Cookie which handles the cookie saving and parsing stuff that I needed to do.
Luckily (or unluckily) I came across Network.Wai.Session – and set about to grappling with trying to understand it
My first serious look is at withSession
, as it returns the thing I want, a Middleware
. Seeing the type signature gives me lots of hope (especially because I’m using Haskell), and it’s the venerable Application -> Application
, giving off the warm architectural fuzzies (in some other languages, it’s hard to know that some piece of code will do NOTHING except middleware like activity, but in Haskell you just know :).
This left me with one question: How do I get the things that withSession
expects? Here’s how I figured that out (the order is a little jumbled):
ByteString
- Just a name to use for the cookie, so I used “
SetCookie
- Comes from Web.cookie, all you have to do is follow the instructions to make one, not much complexity there.
Key (Session m k v)
- Unfortunately I’m still quite “wet behind the ears” and definitely get scared when I see a parametrized type with this many parameters.
Luckily, since the time that I tried to shoehorn a database connection into Vault, I immediately recognized that Key was a Vault.Key and felt quite comfortable with it.
Vault.Key
s are actually really easy to generate, and they can be for anything, and they always come out unique… They remind me of Symbols in ES6/ES2015 Javascript, and I imagine the implementation is somewhat similar (surely with some MVar
somewhere).
SessionStore m k v
- Again, I always get a little scared/uneasy when I see a parametrized type with this many parameters…
I was stumped for a while trying to figure out how to make one of these – I could see the type definitions, but still couldn’t quite wrap my head out of how to implement one. Looking back at the docs, I found that there was an included reference implementation, which is awesome, and let me be productive while I tried to think about the types and what they really meant.
At this point, the code looked like:
sessionKey <- V.newKey -- <---- Haskell couldn't figure out what kind of Key this is, and in turn, what kind of cookie store it's trying to open, and what kind of cookie store it has at all.
let cookieSettings = WC.def { WC.setCookieHttpOnly=True }
cookieStore <- mapStore_
let cookieMiddleware = withSession cookieStore "<appname>-auth" cookieSettings sessionKey
Figuring out the type of the key actually took me a minute, and I need to slow down and really think of what the types involved were describing. I shouldn’t have stumbled here, but I did for a little bit, but am glad I took the time to really think through and understand it.
sessionKey <- V.newKey :: IO (V.Key (Session IO String User))
let cookieSettings = WC.def { WC.setCookieHttpOnly=True }
cookieStore <- mapStore_
let cookieMiddleware = withSession cookieStore "<appname>-auth" cookieSettings sessionKey
Yay, everything compiles! all that was left was to use that middleware on the app that I had already generated and been running, and I had my middleware all ready to use!
According to the documentation (as far as I knew at this point), alll requests should have a Vault
object, and that object would contain the session (if there is one), under the appropriate key
One small issue I realized I had - How do I get & use the Vault
key I made for the session to get the session out again? I need to access the vault key in my combinators, because all I have is the request (which contains the vault, but I need the original key to pull the value that I want out). My first thought was to use the State
monad (clearly I have some piece of state that I need to access later (I talk abou tthis in the previous servant-related post), to store “Application information”, and pass along the vault key. Rather than doing that however, I decided to go with using Servant
’s concept of Context
s. That meant my app starting code, which looked like this:
app :: SqliteBackend -> Middleware -> Application
app backend cookieMiddleware = cookieMiddleware $ serve api appWithDB
where
addDB = evalStateTLNat backend
appWithDB = enter addDB server
Had to turn into this:
makeApp :: SqliteBackend -> Middleware -> V.Key (Session IO ByteString SessionInfo) -> Application
makeApp b addCookies cookieKey = addCookies $ serveWithContext api context appWithGlobals
where
addGlobals = evalStateTLNat $ ApplicationGlobals b
appWithGlobals = enter addGlobals server
context = Auth.genAuthServerContext cookieKey
I end up kind of doing both – You can see that I change the object that I use with enter
into a kind of “application globals” object, rather than just the backend. I also, however, start using Servant’s serveWithContext
. The problem of where to store the vault key is most certainly (for me): use Context
s and serveWithContext
. I first encountered Context
s when trying to figure out the proper way to store a DB connection in Servant, and I’m glad that learning experience was able to be of use to me so soon aferwards.
Well Enough blathering, here’s what the final code looks like:
The code below is mostly relegated to a file called Auth.hs
so I could at least feel like I am properly organizing my code. (It became clear pretty quickly that I should move this code as it was getting hairy to have right next to endpoint-specific code)
type instance AuthServerData (AuthProtect "cookie-auth") = (Session IO ByteString SessionInfo)
Servant code that creates the TypeFamily
that allows Servant to know where to find your custom auth implementation (this code was just copied from servant, and customized)
-- Generate a cookie auth handler, given a session store cookie
genCookieAuthHandler :: V.Key (Session IO ByteString SessionInfo) -> AuthHandler Request (Session IO ByteString SessionInfo)
genCookieAuthHandler k = mkAuthHandler handler
where
handler req = do
let sessionStore = V.lookup k $ vault req
case sessionStore of
Nothing -> throwError Err.invalidAuthHeaders
Just s -> return s
This code is actually a generator for the auth request handler. The Servant docs aren’t exactly clear on what’s happening, but looking at the types, what happens is that , the handler this method generates actually gets stored on the Context
of a started server. This is how AuthProtect
later knows what to do to authenticate. You make a handler, put it in the context, then tell Servant (using the TypeFamily
) how to find it.
Note that that this function has to take the Vault.Key
that pulls out the session from the request’s Vault, as that is made basically on-the-fly during server startup. Of course there are other options, you could hav used a global, but I preferred not to.
-- Given a vault key to use to retrieve the session store (provided by wai-session),
-- generate a context containing a cookie auth handler
genAuthServerContext :: V.Key (Session IO ByteString SessionInfo) -> Context (AuthHandler Request (Session IO ByteString SessionInfo) ': '[])
genAuthServerContext k = authHandler :. EmptyContext
where
authHandler = genCookieAuthHandler k
This function’s job is to produce the context you need to pass to serveWithContext
. As you can see, it builds on the auth handler that is generated in the previous chunk of code, and just builds a simple context with it.
... lines and lines of app setup code ...
-- Create a vault key for use storing the cookie-based sessions
sessionKey <- V.newKey :: IO (V.Key (Session IO ByteString SessionInfo))
-- TODO Add debug mode as a AppConfig setting, need to add secure, expires, max age, settings differently for debug vs prod
-- Create cookie store && middleware to use with the app
let cookieSettings = WC.def { WC.setCookieHttpOnly=True }
cookieStore <- mapStore_
let cookieMiddleware = withSession cookieStore "<appname>-auth" cookieSettings sessionKey
-- Start the app
run port $ makeApp backend cookieMiddleware sessionKey
This code is the app set up code (part of which you’ve seen before)
makeApp :: SqliteBackend -> Middleware -> V.Key (Session IO ByteString SessionInfo) -> Application
makeApp b addCookies cookieKey = applicationWithCookies
where
-- Inject ApplicationGlobals into Server by Monad (StateT) Transforming
addGlobals = evalStateTLNat $ ApplicationGlobals b
serverWithGlobals = enter addGlobals server
context = Auth.genAuthServerContext cookieKey
application = serveWithContext api context serverWithGlobals
applicationWithCookies = addCookies application
To finish it up, here’s the code that creates the app object that will be used with the Servant run
command (this was refactored a little bit, and while still kind of ugly, it should be very clear and easy to read).
Getting this done was a good learning experience, and a good way to figure out how concepts I know pretty well in other languages/frameworks (Node/Express, Python/Flask, Ruby/Sinatra, Go/http, Clojure/ring), get accomplished in Servant
.
While this post was definitely more stream-of-consciousness than polished technical overview, I hope it was worth following and it contains some insight that will help someone else understand just a little bit more about how things are working.