Token authentication with Servant
Servant 0.5 introduced a BasicAuth combinator to support HTTP Basic
authentication. However, Servant is designed to be extensible without needing
to modify the library: the goal of this article is to see how we can implement
a simple token based authentication.
Specifically, we will define a new combinator called Otoke and a HasServer
instance involving it. Our goal is to be able to write an API type that looks
like this:
type API
= "unprotected" :> Get '[PlainText] String
:<|> "protected" :> Otoke :> Get '[PlainText] String
Routes below the Otoke combinator will be protected by the token-based
authentication. Clients will authenticate by using an HTTP Authorization
header whose value will have the form oToke XXX.
Enough planning. Let’s do it!
The combinator
This is the easy part.
data Otoke
No constructors are necessary because Otoke will only appear at the type
level.
The HasServer instance
This is the hard part.
Servant uses what’s called the universe pattern, in which a type-level EDSL is
used to define a generic representation of types. This generic representation
can be specialized to different concrete types by defining different
type-level interpreters. HasServer is the interpreter that computes the
concrete type of a server for our API. What’s more is that HasServer can also
register certain checks for us that occur during routing.
It’s precisely because HasServer is a typeclass with an associated type that
we can extend it without needing to modify the servant-server library itself.
The type family given by the associated type of each instance is open; this
is a huge strength of Servant.
Here’s the definition of HasServer.
class HasServer layout context where
type ServerT layout (m :: * -> *) :: *
route
:: Proxy layout
-> Context context
-> Delayed env (Server layout)
-> Router env
The instances of HasServer work by induction. The base case is when the
Verb primitive is encountered. We don’t normally use Verb directly, but
instead use convenient type aliases such as Get or Post.
The step cases occur when we hit X :> sublayout, where X is a combinator
such as Header, Capture or in our case Otoke, or is a type-level string,
for constant portions of the URL. The instance head in the step cases has the
constraint HasServer sublayout context; this is the induction hypothesis.
Thus, the instance declaration for our combinator looks like this:
instance HasServer sublayout context
=> HasServer (Otoke :> sublayout) context where
The instance body is also defined inductively in these cases. The ServerT
associated type is used to compute the type of a handler function for Otoke :> sublayout. For now, we will just check whether the value of the
Authorization header is among a list of accepted values. However, in a real
server, we might want to fetch a user context from a database and provide that
context to the handler function. So for the sake of example, we will represent
our user context with ().
type ServerT (Otoke :> sublayout) m = () -> ServerT sublayout m
Now we can implement route. Let’s break down its arguments:
Proxy layout: used to guide type inference.Context context: used to pass data around between our routing functions. In a real server, we could use this context to pass a pool of database connections to our routing function in order to look up the token in a database.Delayed env (Server layout): used to register any checks we would like to perform on the request and to decide whether to continue routing inside the sublayout.
Our implementation of route needs to construct a Router. The most obvious
way to do this, by blindly following the types, is to use route provided to
us by the induction hypothesis. To do so, we need to call route with Proxy sublayout. Since the context is unused, we just pass it along unchanged. All
that’s left is the third argument, where we need to add our check for the
Authorization header.
Luckily for us, there is the function addAuthCheck :: Delayed env (a -> b) -> DelayedIO a -> Delayed env b. Using this function will require that a -> b
unify with () -> ServerT sublayout m. If we hadn’t introduced the dummy user
context representation earlier with (), then typechecking would have failed
here.
Here’s the implementation of route:
route Proxy context subserver =
route (Proxy :: Proxy sublayout) context (addAuthCheck subserver go) where
go = withRequest $ \req -> do
case parseHeaderMaybe =<< lookup "Authorization" (requestHeaders req) of
Nothing -> delayedFail err401
Just h -> if h `elem` pws
then pure ()
else delayedFail err401
pws :: [T.Text]
pws = ("oToke " `T.append`) <$>
[ "hello"
, "world"
]
parseHeaderMaybe = eitherMaybe . parseHeader where
eitherMaybe e = case e of
Left _ -> Nothing
Right x -> Just x
The withRequest helper lets us access the Wai request, from which we extract
the list of headers, look up the Authorization header, and parse it. Finally,
we check that the given token is among the list of accepted tokens. If it is,
then we return the dummy user context; else, we fail with Unauthorized. We
also fail with Unauthorized if there is no Authorization header at all.
That concludes the implementation of the HasServer instance!
The server
The server is extremely simple to write, since all the hard work is done by
HasServer during routing.
myServer :: Server API
myServer
= pure "not secret"
:<|> const (pure "secret") -- ignore the ()
main :: IO ()
main = run 8081 $ server (Proxy :: Proxy API)
Results
Let’s confirm that /unprotected is accessible without authorization and that
/protected works with either of the two tokens we hardcoded.
$ curl -v 'http://localhost:8081/unprotected'
* Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8081 (#0)
> GET /unprotected HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.48.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Transfer-Encoding: chunked
< Date: Sat, 18 Jun 2016 23:08:03 GMT
< Server: Warp/3.2.6
< Content-Type: text/plain;charset=utf-8
<
* Connection #0 to host localhost left intact
not secret
$ curl -v 'http://localhost:8081/protected'
* Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8081 (#0)
> GET /protected HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.48.0
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
< Transfer-Encoding: chunked
< Date: Sat, 18 Jun 2016 23:08:15 GMT
< Server: Warp/3.2.6
<
* Connection #0 to host localhost left intact
$ curl -v -H 'Authorization: oToke hello' 'http://localhost:8081/protected'
* Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8081 (#0)
> GET /protected HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.48.0
> Accept: */*
> Authorization: oToke hello
>
< HTTP/1.1 200 OK
< Transfer-Encoding: chunked
< Date: Sat, 18 Jun 2016 23:09:35 GMT
< Server: Warp/3.2.6
< Content-Type: text/plain;charset=utf-8
<
* Connection #0 to host localhost left intact
secret
$ curl -v -H 'Authorization: oToke world' 'http://localhost:8081/protected'
* Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8081 (#0)
> GET /protected HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.48.0
> Accept: */*
> Authorization: oToke world
>
< HTTP/1.1 200 OK
< Transfer-Encoding: chunked
< Date: Sat, 18 Jun 2016 23:09:41 GMT
< Server: Warp/3.2.6
< Content-Type: text/plain;charset=utf-8
<
* Connection #0 to host localhost left intact
secret
Everything seems to be working just as planned!
Conclusion
This was my first experience in adding a combinator to Servant, and I’m sure there are ways that the technique I used here can be improved. In a future post I’ll extend the work here by using the context to pass in a database connection pool and use Esqueleto to look up the token in a database.
Full source code is available on Github.