I typed most of this up the other night on the tablet while falling asleep in bed, but didn’t hit submit it as I figured it was full of typos. And then forgot about it the next day. I appear to have gotten a little carried away (…again), but I hope it helps.
Huge caveat that I’m not a SEng professional and mostly have no idea what I’m doing. So I definitely can’t speak to current best practices.
Here’s the model I use (with a PureScript Halogen front-end and Haskell Servant backend) that keeps the JWT (…with its structured data about the logged-in user) in a
HttpOnly cookie (…and so inaccessible to JS).
On loading of your client app’s main function, send an Affjax request for the profile of the current user to a JWT-protected route on your backend. Basically it’s a
getCurrentUser function on the client.
If the browser already has the HttpOnly cookie set when the request is sent, then the browser will automatically send this cookie along with that request to the backend.
If the backend determines that the cookie contained a [currently] valid JWT, the backend should send back the current user profile as the JSON response to the request. Your client can then store this in your app’s state (however you are managing that) and render the pages as that logged-in user. Your backend could get the profile by:
- storing a userId in the JWT and using that to look that up the user’s profile in the DB (though having to send that query loses one of a JWT’s main advantages)
- skipping the DB query by storing the entire user profile in the JWT and responding with that (if the JWT is validated).
- So the idea is not that the PureScript Halogen client reads the JWT to get the Profile to display
- …but rather that the Halogen client does nothing as the browser sends the JWT-containing-cookie with the request to the Haskell server, the Haskell server validates the JWT, and sends back the Profile contents as a JSON response.
You mentioned a Haskell backend. I’ll take a wild guess and say maybe
servant-auth-server? If so, the contents of the validated HttpOnly JWT is available as an argument to all of the handlers for your routes requiring that authentication. Your handler for the
getCurrentUser would basically just be that argument applied to
If a JWT was sent in a HttpOnly cookie that’s not valid (…or no longer valid), the backend can send back an invalid auth response (with no user profile). You can have your client render the page in whatever way is appropriate for no profile being stored. Maybe including a redirect to the login page if they were trying to load a protected page directly without having valid credentials saved.
If I continue with my wild guess that you’re using
servant-auth-server on the backend (correct me if I’m wrong), there’s a
clearSession function there that you can use to have your invalid auth response from the sever also send new blank Set-Cookies. This will overwrite the existing invalid JWT stored by that client’s browser.
But yeah nowhere along the way here in this example have we been handling/parsing/validating the JWT in the Halogen client app. That’s all handled by the backend that’s receiving the [
Secure] HttpOnly cookies the browser automatically sends with its requests and then sending back the protected data if the JWT was valid (…or sending a response code / error that tells the client they need to log in or are not permitted to access a given resource, etc.).
If you did want the practice of reading a token from cookies and sending it in the request headers from your client, you could do that with an (additional) optional XSRF token. This is again something that
servant-auth-server allows for as one of its defaults. This token will just be a random nonce and not contain any user data.
So if you go down this path then on authenticated requests, your backend would send both:
- Some version of a user profile in a JWT stored in a Set-Cookie (both Secure and HttpOnly, not accessible to JS)
- A random string of bytes in a Set-Cookie that does not have HttpOnly set (…and so is accessible to JS)
The random bytes are sent from server to client as the value for a
XSRF-TOKEN key in the JS-accessible Set-Cookie. And then your client sends it back with the request in a similarly-named-but-not-exactly-the-same
X-XSRF-TOKEN Affjax request header (note the extra
X- prefix that seems to often catch people).
The browser will automatically send this non-HttpOnly cookie with the
XSRF-TOKEN key/value (…just like it will also be doing with the HttpOnly cookie containing the JWT). As Harry mentioned, to get that non-HttpOnly cookie you can FFI in PureScript to
document.cookie. And then use the
biscotti-cookie PureScript library to parse out the cookie’s value (just a random string of bytes that the backend sent).
And then your backend would only consider your client’s requests to it as valid if now both the:
- JWT in the HttpOnly cookie it received was validated as okay; AND
- the value of the request header you sent for the
X-XSRF-TOKEN key also matches the random value of the
XSRF-TOKEN key in the cookie (that, again, the browser sent for you).
servant-auth-server's plumbing checks that these two values match for you.
And the only time your Halogen client touches the cookies was before a request is sent to read the currently-stored value of the XSRF token in your document.
Not going to lie. I struggled a bit at the start (…and for a fair time after) to work out how to adapt the authentication model shown in the
purescript-halogen-realworld project (…where, per the GoThinkster RealWorld spec, the JWT token is returned to the client as part of a regular JSON response along with the profile) to something that worked with a backend that only sent and validated JWTs stored in a Secure / HttpOnly cookie.
It was pretty satisfying to get working in the end.
Happy to answer questions or take criticism of anything about this method
e.g. are XSRF tokens necessary if you are SameSite Strict-ing? or is including them as defence-in-depth okay? I don’t know!