Gift list dev diary: API auth

All development for this project is shared on github at https://github.com/codonnell/mygiftlist-blog. I will endeavor to focus on the most relevant snippets of code rather than go over all of them on this blog. For interested parties, the github repo is available to survey all of the minor details.

In this post we’ll add authentication and authorization to our API endpoint. Using the client-side authentication work from this post, we have access to a signed JWT in the client when a user is logged in. We will take the classic approach of sending the JWT as a bearer token in a request header. The implementation work can be split into the the server-side work of validating and unpacking the JWT, the implementation of authorization rules, and the client-side work of adding the JWT to request headers. We’ll cover most of the changes in this post, but you can find the full changeset in this git commit.

For the server-side part of this, we will use auth0’s java-jwt and jwks-rsa libraries, which will allow us to decode and verify a JWT.

JWT Verification

Our first order of business is to fetch a public key from Auth0 in order to validate the JWT’s signature. Auth0 exposes an endpoint that serves this public key. You can find the endpoint by going to “Applications” in the management dashboard, selecting your application, scrolling down on the settings page to click “Show Advanced Settings”, and finding “JSON Web Key Set” in the “Endpoints” tab. We’ll add this value to our config under :jwk-endpoint.

In order to avoid making a request to this auth0 endpoint every time we receive a request, we’ll cache the results. We can do this using a couple of classes from the jwks-rsa library, UrlJwkProvider and GuavaCachedJwkProvider.

(ns rocks.mygiftlist.authentication
  (:require [rocks.mygiftlist.config :as config]
            [integrant.core :as ig])
  (:import [java.net URL]
           [java.time Instant]
           [com.auth0.jwk GuavaCachedJwkProvider UrlJwkProvider]
           [com.auth0.jwt.interfaces RSAKeyProvider]
           [com.auth0.jwt JWT]
           [com.auth0.jwt.algorithms Algorithm]
           [com.auth0.jwt.exceptions JWTVerificationException]))

(defn create-key-provider [url]
  (let [provider (-> url
                   (URL.)
                   (UrlJwkProvider.)
                   (GuavaCachedJwkProvider.))]
    (reify RSAKeyProvider
      (getPublicKeyById [_ key-id]
        (-> provider
          (.get key-id)
          (.getPublicKey)))
      (getPrivateKey [_] nil)
      (getPrivateKeyId [_] nil))))

We create a key provider using the classes from jwks-rsa, and then use the key provider to create a class implementing the RSAKeyProvider interface. This is necessary in order to use our key provider to verify a JWT using java-jwt. In order to verify our access tokens properly, we’ll also add expected audience and issuer values to the configuration. The issuer must match the domain for our auth0 application, and the audience must match the API audience for our auth0 API. Now we’re ready to write a function that takes the key provider, issuer, and audience and uses them to verify a token.

(defn verify-token
  "Given a key-provider created by `create-key-provider`, an issuer,
  an audience, and a jwt, decodes the jwt and returns it if the jwt is
  valid. Returns nil if the jwt is invalid."
  [key-provider {:keys [issuer audience]} token]
  (let [algorithm (Algorithm/RSA256 key-provider)
        verifier (-> algorithm
                   (JWT/require)
                   (.withIssuer (into-array String [issuer]))
                   (.withAudience (into-array String [audience]))
                   (.build))]
    (try
      (let [decoded-jwt (.verify verifier token)]
        {:iss (.getIssuer decoded-jwt)
         :sub (.getSubject decoded-jwt)
         :aud (vec (.getAudience decoded-jwt))
         :iat (.toInstant (.getIssuedAt decoded-jwt))
         :exp (.toInstant (.getExpiresAt decoded-jwt))
         :azp (.asString (.getClaim decoded-jwt "azp"))
         :scope (.asString (.getClaim decoded-jwt "scope"))})
      (catch JWTVerificationException e
        nil))))

We’ve followed the instructions in the java-jwt README to verify a token, enforcing that the issuer and audience match what we expect for our API and that the JWT was signed by auth0. If this verification succeeds, we do a bit of interop to return a map with the JWT claims. Otherwise, we return nil.

Now that we have the ability to verify a JWT, we need to integrate it into our server. The approach we’ll take is to add middleware that attempts to decode a bearer token when there is one and adds the claims to the request map when the token is valid. This is pure authentication; any authorization rules are applied downstream. Since the verification process depends on configuration values and keeps the jwk cache as internal state, we will implement the middleware as an integrant component.

(defn- get-token [req]
  (when-let [header (get-in req [:headers "authorization"])]
    (second (re-find #"^Bearer (.+)" header))))

(defn wrap-jwt
  "Middleware that verifies and adds claim data to a request based on
  a bearer token in the header.

  If a bearer token is found in the authorization header, attempts to
  verify it. If verification succeeds, adds the token's claims to the
  request under the `::claims` key. If verification fails, leaves the
  request unchanged."
  [handler key-provider expected-claims]
  (fn [req]
    (let [token (get-token req)
          claims (when token
                   (verify-token key-provider expected-claims token))]
      (handler (cond-> req
                 claims (assoc ::claims claims))))))

(defmethod ig/init-key ::wrap-jwt
  [_ {::config/keys [config]}]
  (fn [handler]
    (wrap-jwt handler
      (create-key-provider
        (config/jwk-endpoint config))
      {:issuer (config/jwt-issuer config)
       :audience (config/jwt-audience config)})))

The integration of this middleware into our server can be found on github.

Authorization

In a REST API, each resource will generally have its own authorization rules. When writing a server that resolves EQL queries with pathom, I believe most authorization logic belongs in the resolvers. For simplicity’s sake, we will elide any data to which the requester is forbidden access, and we won’t try to communicate authorization failures to the client.

To apply authorization rules, our resolvers need access to the id of the user querying for data. Fortunately, we already add the ring request to our parser env, and our wrap-jwt middleware adds authentication claims to the request map. Since we need the requester’s id frequently, we’ll make it a little more convenient:

(p/env-wrap-plugin
  (fn [env]
    ;; Here is where you can dynamically add
    ;; things to the resolver/mutation
    ;; environment, like the server config,
    ;; database connections, etc.
    (assoc env
      ::db/pool pool
      :requester-auth0-id
      (get-in env [:ring/request ::auth/claims :sub]))))

Let’s take a quick look at how user-by-id changes to reflect that each user should only be able to access their own information.

(defresolver user-by-id
  [{::db/keys [pool] :keys [requester-auth0-id]} {::user/keys [id]}]
  {::pc/input #{::user/id}
   ::pc/output [::user/id ::user/email ::user/auth0-id ::user/created-at]}
  (db/execute-one! pool
    {:select [:id :email :auth0_id :created_at]
     :from [:user]
     :where [:and
             [:= id :id]
             [:= requester-auth0-id :auth0_id]]}))

All we had to do was pull out requester-auth0-id from env and add another where condition.

Client-side API Authentication

The last piece of our API auth puzzle is the client side. We already have a way to get a JWT: (auth/get-access-token), which returns a promise-chan resolving to the access token. The remaining work is to inject that JWT into the authorization header in our API requests.

A natural place to do that is in request middleware. A request middleware function takes a request and returns a modified request value, like one with an added authorization header. That won’t work for us out of the box because auth/get-access-token is asynchronous; our middleware would need to return a promise-chan or a channel, not a request value.

The default fulcro remote is pluggable, so we can write our own remote that gives us the latitude to use asynchronous request middleware. We don’t have to start from scratch, either. The default http remote is nearly suitable. We just need to tweak it a bit.

The heart of a fulcro remote is the transmit! function, which is responsible for sending the remote request and calling the right handler on the result. The request middleware is called as part of transmit!, so we need to wrap the body of transmit! in a go block. The middleware is originally called like so:

(if-let [real-request (try
                        (request-middleware {:headers {}
                                             :body edn
                                             :url url
                                             :method :post})
                        (catch :default e
                          (log/error e "Send aborted due to middleware failure ")
                          nil))]
  ...)

Using let-chan from wsscode-async, which allows the values in its binding form to be channels, we can refactor to the following:

(let-chan [real-request (try
                          (request-middleware {:headers {}
                                               :body edn
                                               :url url
                                               :method :post})
                          (catch :default e
                            (log/error e "Send aborted due to middleware failure ")
                            nil))]
  (if real-request
    ...))

and we have a remote that supports asynchronous request middleware. Now we need the middleware, itself. Our middleware only differs from the default in a couple of ways. First, our function is wrapped in a go block so we have the freedom to get an access token asynchronously. Second, we get the access token and add it as a bearer token in the authorization header.

(defn wrap-fulcro-request
  ([handler addl-transit-handlers transit-transformation]
   (let [writer (t/writer (cond-> {}
                            addl-transit-handlers
                            (assoc :handlers addl-transit-handlers)

                            transit-transformation
                            (assoc :transform transit-transformation)))]
     (fn [{:keys [headers body] :as request}]
       (go
         (let [access-token (<! (auth/get-access-token))
               [body response-type] (f.http/desired-response-type request)
               body    (ct/write writer body)
               headers (assoc headers
                         "Content-Type" "application/transit+json"
                         "Authorization" (str "Bearer " access-token))]
           (handler (merge request
                      {:body body
                       :headers headers
                       :method :post
                       :response-type response-type})))))))
  ([handler addl-transit-handlers]
   (wrap-fulcro-request handler addl-transit-handlers nil))
  ([handler]
   (wrap-fulcro-request handler nil nil))
  ([]
   (wrap-fulcro-request identity nil nil)))

And that’s all! We’re adding a JWT to a header in the client, verifying it in the server, and using it to authorize data access.

Prev: Gift list dev diary: parser tests Next: Gift list dev diary: gift list form and listing