Search code examples
authenticationhaskellcookiesservant

How does a Servant client handle received cookies?


I want to use a Servant client to first call a login endpoint to obtain a session cookie and then make a request against an endpoint that requires cookie authentication.

The API is (simlified)

import qualified Servant                       as SV
import qualified Servant.Auth.Server           as AS
import qualified Servant.Client                as SC

-- Authentication and X-CSRF cookies
type CookieHeader = ( SV.Headers '[SV.Header "Set-Cookie" AS.SetCookie
                                  , SV.Header "Set-Cookie" AS.SetCookie]
                      SV.NoContent )

type LoginEndpoint = "login" :> SV.ReqBody '[SV.JSON] Login :> SV.Verb 'SV.POST 204 '[SV.JSON] CookieHeader
type ProtectedEndpoint = "protected" :> SV.Get '[SV.JSON]
-- The overall API
type Api = LoginEndpoint :<|> (AS.Auth '[AS.Cookie, AS.JWT] User :> ProtectedEndpoint)

apiProxy :: Proxy Api
apiProxy = Proxy

I define the client as follows:

loginClient :: Api.Login -> SC.ClientM Api.CookieHeader
protectedClient :: AC.Token -> SC.ClientM Text :<|> SC.ClientM SV.NoContent
loginClient :<|> protectedClient = SC.client Api.apiProxy

How does the client handle the authentication cookie? I can think of two ways. When executing a request in the ClientM monad, like

do
  result <- SC.runClientM (loginClient (Login "user" "password")) clientEnv
  [..]

where Login is the login request body and clientEnv of type Servant.Client.ClientEnv, the cookie could part of the result, it could be updated in the cookieJar TVar inside clientEnv, or both. I would assume the TVar to be updated, so that a subsequent request with the same clientEnv would send the received cookies along. However, my attempt to read the TVar and inspect its contents using Network.HTTP.Client.destroyCookieJar revealed an empty array. Is this intended? I couldn't find anything in the documentation.

Thus, to make an authenticated call, I would need to extract the cookie from the header in the result (how?), update the TVar, create a new clientEnv that references this TVar, and make the authenticated call using this new environment. Is this indeed the suggested procedure? I'm asking because I suppose the use case is so standard that there should be a more streamlined solution. Is there? Am I missing something?


Solution

  • After some experimentation, I figured out that the Servant client indeed does maintain cookies in the cookieJar that is part of the clientEnv. To be more precise, clientEnv contains the field cookieJar, which is of type Maybe (TVar CookieJar). It is the TVar the client updates according to the Set-Cookie instructions of subsequent requests. It is up to the developer to create and initialize that TVar before making the first request; otherwise, the Servant client will discard cookies between requests.

    In addition, it is possible to retrieve cookies in the same way as the request body. To this end, the cookies to be retrieved must be defined as part of the API type, like in the example of my original question:

    type LoginEndpoint = "login" :> SV.ReqBody '[SV.JSON] Login :> SV.Verb 'SV.POST 204 '[SV.JSON] CookieHeader
    

    Disassembling the returned was a little tricky at first, because I needed to figure out the final type that results from Servant's type-level machinery. Ultimately, I did the following:

    SV.Headers resp h <- tryRequest clientEnv (loginClient (Api.Login "user" "pwd"))
    let headers = SV.getHeaders h
    

    where tryRequest is a helper to execute runClientM and extract the Right part. The pattern match resp contains the return value (here NoContent), while h is is an HList of the different headers. It can be converted into a regular list of Network.HTTP.Types.Header using Servant's getHeaders function.

    It would then be possible to change or generate new headers and submit them with a new request by adding a new header to the cookieJar TVar (see the cookie-manipulating functions in Network.HTTP.Client).