Servant Auth and Elm
servant-auth-and-elm-example is a simple project that shows you how to make an Elm application that can log in, log out and make authenticated requests to a servant back-end. servant-auth adds JSON Web Token authentication to servant. It supports authentication via API tokens or browser cookies. In this post I will highlight how to authentication works between servant, servant-auth and Elm. Hopefully that will be sufficient for you to follow the code in the git repository.
servant and servant-auth
The back-end code follows the example in the servant-auth README.md closely. The code is all in one file Server.hs.
Protected API
The Protected
API requires authentication to use. Any resource that requires authentication should be placed in this API.
data User =
User
{ userId :: Int
, userEmail :: Text
, userPassword :: Text
} deriving (Eq, Read, Show, Generic)
data Login =
Login
{ username :: Text
, password :: Text
} deriving (Eq, Read, Show, Generic)
type Protected =
"die" :> "roll" :> Get '[JSON] Int
:<|> "loggedin" :> Get '[JSON] User
protected :: AuthResult User -> Server Protected
protected (Authenticated _user) =
(liftIO $ randomRIO (1, 6))
:<|> (return $ User 1 "test@test.com" "")
protected _ = throwAll err401
The first route is just a simple random number generator for the front-end to call when the user is logged in. The front-end uses the second route to see if the current token in the browser’s cookies are valid. If they are then the browser gets the user data (excluding the password) and can display the user email. You can expand this to include any user you want to display to the currently logged in user. For simplicity the loggedin
route is just returning a hard coded value. This can easily be expanded to a database system of your choice.
Unprotected API
The Unprotected
API provides any resources that do not require authentication: login request, front-end files, home page, static resources, etc.
type Unprotected =
"login" :> ReqBody '[JSON] Login
:> PostNoContent '[JSON]
(Headers '[ Header "Set-Cookie" SetCookie
, Header "Set-Cookie" SetCookie]
NoContent)
:<|> Raw
The login route returns two Set-Cookie values in the response header if the authentication is successful and returns no value in the body. The last route Raw
will catch all other API calls. Servant route lookup is in order of declaration, from top to bottom. Raw
with no sub-paths needs to be located at the end or it will catch routes that we do not want it to catch.
checkCreds :: CookieSettings
-> JWTSettings
-> Login
-> Handler
(Headers
'[ Header "Set-Cookie" SetCookie
, Header "Set-Cookie" SetCookie]
NoContent)
checkCreds
cookieSettings
jwtSettings
(Login loginUserIdent loginUserPassword) = do
case mUser of
Nothing -> throwError err401
Just usr -> do
mApplyCookies <- liftIO $ acceptLogin cookieSettings jwtSettings usr
case mApplyCookies of
Nothing -> throwError err401
Just applyCookies -> do
return $ applyCookies NoContent
where
mUser =
if loginUserIdent == "test@test.com" && loginUserPassword == "password"
then Just $ User 1 "test@test.com" "test"
else Nothing
checkCreds
handles authentication. Again, for simplicity we use hard coded values but we can easily add a database to handle user data.
staticFiles :: [(FilePath, ByteString)]
staticFiles =
[ ("index.html", $(embedFile "static/index.html"))
]
unprotected :: CookieSettings
-> JWTSettings
-> Server Unprotected
unprotected cs jwts =
loginH :<|> staticH
where
loginH = checkCreds cs jwts
staticH = serveDirectoryWith $ set
where
set =
(defaultWebAppSettings $ error "unused")
{ ssLookupFile = ssLookupFile embedded
, ssIndices = map unsafeToPiece ["index.html"] }
embedded = embeddedSettings staticFiles
staticFiles
declares the lookup name we will use in Raw
and the Template Haskell code $(embedFile ...)
will load the file contents at compile time. index.html
is where we store the compiled Elm code. The last piece of interest is ssIndices
. This tells haskell to treat a root lookup /
as /index.html
.
Take a look at the cabal file and Setup.hs
. I have added elm/src/Main.elm
to data-dirs
and some custom code so that when you run stack build
it will automatically build the elm code and then build the servant project if there have been changes to either the Elm or the Haskell project.
Elm
The front-end for this example can do four things: check if the user is logged in upon loading the page, log in, log out and request a die roll from the server if the user is authenticated.
Native Code
As mentioned here. Each query to an authenticated requires the front-end to set X-XSRF-TOKEN
in the header. In order to do this, we need to write native JavaScript code that is called by Elm. To do so, we must name the function for the user and project name in the repository key in elm-package.json. When you initiate a new elm project it will intially set as user
and project
.
var _mchaver$servant_auth_and_elm_example$Native_CsrfCookie = function() {
var scheduler = _elm_lang$core$Native_Scheduler;
var value = {};
value.csrfCookie = e => {
return scheduler.nativeBinding(callback => {
let r = document.cookie.match(new RegExp('XSRF-TOKEN=([^;]+)'));
if (r) {
callback(scheduler.succeed(r[1]));
} else {
callback(scheduler.fail([]));
}
})
};
value.deleteCookie = e => {
return scheduler.nativeBinding(callback => {
document.cookie = 'XSRF-TOKEN=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
window.location.href = '/';
callback(scheduler.succeed([]));
})
};
return value;
}();
The csrfCookie
function gets XSRF-TOKEN
from the cookies and sends it to Elm. deleteCookie
sets the XSRF-TOKEN
to an expired date to delete it and logout the user. Then it redirects the user to the root path. There is a small layer of code in CsrfCookie.elm to call these two functions from Elm.
Then there is a function sendWithCsrfToken
using elm-http-builder to call csrfCookie
and set X-XSRF-TOKEN
for any http-builder request that you give it. Finally, here is an example of making a simple authenticated request.
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
RollDieRequest ->
let
request =
HttpB.get "/die/roll"
|> HttpB.withExpect (Http.expectJson Json.Decode.int)
in
(model, sendWithCsrfToken RollDieResponse request)