Add a Custom Authentication Backend to a Clojure Pedestal Application using Buddy

How do you add a custom authentication backend that can use both Ring session cookies and JWT tokens to a Pedestal application?

Introduction

In two earlier posts (available here and here) I showed how to use the buddy-auth library to develop an SPA that uses session-based (or session cookie) security. The SPA accompanying that post (and available on Github tag v1.0) was also capable of using Google credentials to authenticate the user.

In this post I will provide further details on how the Google web authentication works, and I will show how to add additional authentication mechanisms (jwt and Google IAP) by developing a custom backend for use with buddy-auth.

Client-Side Google Sign-In

Credit where credit is due: The client-side code is based on excellent work done by Tristan Straub which is available on Github.

GAPI is a library provided by Google that enables browser-hosted javascript code to call Google API’s. It is available at https://apis.google.com/js/platform.js.

Amongst other functionality, it also includes the code we’ll need to interact with the endpoints used by Google Sign-In, which is used to generate the OAuth 2.0 credentials for the user.

On the client-side we’ll first need to ensure that the GAPI library is loaded. This is done using the <ensure-gapi! function.

The <ensure-gapi! function first checks if the GAPI library has already been loaded. If not, it will call the function <load-script to do so.

(defn- <ensure-gapi!
  [client-id]
  (go
    (when-not (exists? js/gapi)
      (<!
        (<load-script
          config/google-script-location)))
    (<! (<init-gapi! client-id))))

<load-script is an interesting function. It first creates an HTML <script> element in memory, and sets its source attribute to https://apis.google.com/js/platform.js. It then create a promise (as a channel using <cb) to load the javascript file, adds the <SCRIPT> element to the DOM, and returns the channel/promise.

After loading the GAPI libraries, the <ensure-gapi! function calls <init-gapi! to initialize the auth2 components of the library, and to create the GoogleAuth object. These are the components that we’ll use to get an OAuth 2.0 credentials claim from Google on behalf of a user.

On the SPA’s Users page, we can display a styled Google Sign-In button by calling the gapi.signin2.render function. This function takes a DOM element, within which it will render the sign-in button, and a map of parameters.

(js/gapi.signin2.render
        el
        #js {"scope"     "profile email"
             "width"     144
             "height"    30
             "longtitle" false
             "theme"     "dark"
             "onsuccess" on-success
             "onfailure" on-failure})

The scope parameter in the options map sets the user identity elements we’d like returned by Google, and on-success and on-failure are Clojurescript functions called according to whether the authentication attempt succeeded or failed.

When the user clicks on the Sign-In button, a second browser window will open to a secure Google URL where the user can enter his or her credentials. If the authentication is successful the on-success function is called with an instance of GoogleUser.

Then, by calling GoogleUser.getAuthResponse we can retrieve the AuthResponse map; and from that we can get the id_token, which is the ID token granted by Google.

(fn [google-user]
            (println "running google-login-button on-success function")
            (let
              [token (some->
                       google-user
                       (ocall "getAuthResponse")
                       (oget "id_token"))]
              (when token
                (<post-to-server
                  token))))

Given our requested scopes, the AuthResponse will also contain the user’s Google email address. However, this isn’t to be trusted. The correct (and secure) way to confirm the email address is to validate the ID token using server-side code, before extracting the email address.

In order to do this the success function submits the token to the server for validation using an Ajax POST request.

(defn- <post-to-server
  [token]
  (let
    [form-data
     (str "idtoken=" token "&connection-uuid=" (random-uuid))]
    (POST
      config/google-callback-url
      {:body form-data
       :response-format :raw
       :with-credentials true
       :handler (fn[r]
                  (js/console.log "Server Response from Google Login")
                  (js/console.log r)
                  (js/console.log "will confirm with server")
                  (rf/dispatch
                    [:auth/get-logged-in-user]))
       :error-handler (fn[e]
                        (js/console.log "Server Error")
                        (js/console.log e))})))

In our application the address stored in config/google-callback-url is https://<server-name>/auth/google which is a Pedestal route configured, at the end of its interceptor chain, to call the alloc-auth-google-login handler function.

Within this function a call is made to verify-google-token-response, passing the POST request’s idtoken value. The verify function uses the com.google.api.client.googleapis.auth.oauth2 java library to validate the token and extract the email address.

The server will then create an identity-token as explained here, and add it to the Ring session’s :identity field.

From this point, the client/server behaviors are as they would be for other session-based authentication types.

Google IAP

Google IAP (Identity Aware Proxy) is a context-aware reverse proxy service available within the Google Cloud product line. It sits between the internet and your application’s resources and ensures that access to those resources is granted only to users who have the appropriate Cloud IAM role(s).

When a new user attempts to access a resource protected by IAP, IAP will direct the user to a Google OAuth 2.0 sign-in flow where the user can enter his or her credentials. If those credentials are correct, IAP will then assess whether the user is authorized to access the resource by checking the user’s roles and permissions. If everything checks out, the request is forwarded to the resource with the user’s JWT claim token added to the request’s headers in the x-goog-iap-jwt-assertion field.

It is the responsibility of the application to verify the validity of this token using Google’s public keys.

From the user’s perspective, the sign-in flow is similar to the flow used by the client-side sign-in described above, but with the result of a sucessful authentication being the addition of the x-goog-iap-jwt-assertion header and the token to the request’s headers rather than calling the on-success function in the browser.

IAP uses one of a number of different keys to sign the header. The public component of these keys is published (in JWK format) at https://www.gstatic.com/iap/verify/public_key-jwk. As far as I can tell, there is no way to determine which of the available keys Google will use to sign the JWT claim, although the signed token will include in its :kid field the id of the key used to sign it.

The Challenge

Session backends work just fine and, if handled well, provide perfectly adequate security. In my earlier post I showed how it is possible to integrate Google web login with our Reframe SPA, and although the authentication was performed by Google the end result is that the validated Google identity was inserted into the client’s session.

However, there are circumstances in which session authentication & authorization will not work - for example when a browser session doesn’t exist. This may occur when making calls to HTTP API endoints from clients other than a browser e.g. server calls, calls from desktop clients, or calls mediated by service orchestration engines.

In such cases, the use of authorization bearer tokens, such as JWT, is a better approach. The buddy-auth library comes with a number of included backends, with one of which that can use JWT tokens. However, its implementation assumes that the JWT token will be included in the Authorization header of the request. If our application is using IAP this won’t be the case. Therefore we’ll need to provide our own backend.

Buddy Backends

A Buddy backend is simply an instance of an object that implements two protocols: IAuthentication and IAuthorization. Our custom backend (jws-embedded) which implements these protocols can be found in the server.auth.header namespace and mimics closely the jws backend provided by Buddy.

IAuthentication

The IAuthentication protocol defines two member functions: -parse and -authenticate. The former is responsible only for inspecting the request and parsing from it the material to be passed to -authenticate. The standard implemenation of the Buddy middleware, initiated in the interceptor returned by our alloc-auth-authentication-interceptor function (taking a backend as a parameter) ensures that if the call to -parse in the backend returns nil, then the backend’s -authenticate function won’t be called.

IAuthorization

The IAuthorization protocol requires only a single function, -handle-unauthorized which will be called with parameters of the request and some metadata when an authenticated request is not authorized to access a resource.

Remember the alloc-auth-authentication-interceptor function, which takes a backend as a parameter, creates an interceptor whose :enter method adds to the received context a key :auth/backend and attaches the backend parameter as its value. The :enter function updates the :request map using the buddy.auth.middleware/authentication-request function with the backend parameter. This hooks up Buddy middleware (tying the -parse and -authenticate members together, ensuring that if -parse return nil then -authenticate isn’t called)

The Implementation

The custom backend is created by the jws-embedded-backend function, shown below. As you can see, it implements the two required protocols.

The backend is implemented in such a way as to authenticate using either the session or the header information. This is accomplished in the -parse function which calls parse-embedded-header. If a request session is detected and it contains an :identity field then parse-embedded-header simply returns a 2-vector of `[:session ].

If not, and the request headers contain a field matching one of the names we’ve reserved for our JWT token the parse-embedded-header returns a 2-vector of [:token <embedded-token-value>].

By implementing the backend in this way, and in the abscence of embedded headers, the session-based authentication logic will work as before when we used Buddy’s included session backend, and any authentication logic based on the request’s headers is skipped.

(defn jws-embedded-backend
  [{:keys [authfn unauthorized-handler options token-names on-error]}]
  {:pre [(ifn? authfn)]}
  (reify
    proto/IAuthentication
    (-parse [_ request]
      (parse-embedded-header request token-names))
    (-authenticate [_ request [auth-type session-or-token]]
      (try
        ;; parse-embedded-header will return a 2-vector
        ;; with either :session of :token in the first
        ;; position indicating how the identity is/should be
        ;; determined.
        (case auth-type
          :token
          ;; if we've a token, attempt to identify the user using it
          (authfn request session-or-token)
          :session
          ;; if we've a session with an identity, just accept it
          session-or-token)
        (catch clojure.lang.ExceptionInfo e
          (let [data (ex-data e)]
            (when (fn? on-error)
              (on-error request data))
            nil))))

    proto/IAuthorization
    (-handle-unauthorized [_ request metadata]
      (if unauthorized-handler
        (unauthorized-handler request metadata)
        (#'token/handle-unauthorized-default request)))))

If the -parse function returns a non-nil value then -authenticate is called and the case statement will decide how to handle the actual authentication. As with the session backend if the 2-vector’s first element is :session we use the second element of the vector as the identity. On the other hand, if the first element is :token we call the authfn to validate the token and extract the user’s identity.

The implementation of -handle-unauthorized either calls the default unauthorized handler, or a handler function passed to the jws-embedded-backend function using the :unauthorized-handler key in the options map.

Also, authfn is an option parameter passed to the jws-embedded-backend function and is created when we def’d the backend

(def alloc-auth-google-header-token-auth-backend
  (jws-embedded
    {:authfn      token-authfn
     :token-names ["x-goog-iap-jwt-assertion" "x-debug-token"]
     :on-error
                  (fn [request ex-data]
                    (log/error
                      "Request to"
                      (:uri request)
                      "threw exception: "
                      ex-data))
     :unauthorized-handler
                  (fn unauthorized-handler
                    [request metadata]
                    (let [{user         :user
                           roles        :roles
                           required     :required
                           user-session :user-session}
                          (get-in metadata [:details :request])
                          user-response-session
                          (keyword
                            (if user (name user) "anonymous")
                            (if user-session (name user-session) "anonymous"))
                          error-message
                          (str "NOT AUTHORIZED (HEADER): In unauthenticated handler for "
                               "uri: " (pr-str (:uri request)) ". "
                               "user: " (pr-str user) ", "
                               "roles: " (pr-str roles) ", "
                               "required roles: " (pr-str required) ". "
                               "session-id: " (pr-str user-response-session) ".")]
                      (if user-session
                        (rlog/with-forward-context
                          user-response-session
                          (log/error
                            error-message)
                          {:message-type :error})
                        (log/error
                          error-message)))
                    (cond
                      ;; If request is authenticated, raise 403 instead
                      ;; of 401 (because user is authenticated but permission
                      ;; denied is raised).
                      (auth/authenticated? request)
                      (-> (ring-response/response
                            {:reason
                             (str "Header authenticated, but not authorized for access to "
                                  (some-> metadata :details :request :path) ". "
                                  "Metadata : " (pr-str metadata))})
                          (assoc :status 403))
                      ;; In other cases, respond with a 401.
                      :else
                      (let [current-url (:uri request)]
                        (->
                          (ring-response/response
                            {:reason "Unauthorized"})
                          (assoc :status 401)
                          (ring-response/header "WWW-Authenticate" "tg-auth, type=1")))))}))

The :token-names entry is a vector (in priority order) of the request headers we will inspect to find the user’s JWT identity token. In the code above, if the request header x-goog-iap-jwt-assertion is found then its value will be used, otherwise x-debug-token will be used. If neither is found, nil is returned. The vector’s ordering means that a x-goog-iap-jwt-assertion header injected by Google IAP will take precedence.

In either case the token extracted will be validated by the function passed in the option maps :authfn field (i.e. token-authfn).

(defn token-authfn
  [request token]
  (log/info "Attempting to authorize using token with JWT header: "
            (some-> token (jws/decode-header)))
  (when token
    (let
      [validity-map
       (if (= "local" (:kid (jws/decode-header token)))
         {:aud "local"
          :iss "local"}
         {:aud server-config/google-jwt-audience
          :iss "https://cloud.google.com/iap"})]
      (try
        (when-let
          [user-id
           (google/get-valid-user-id-from-header
             token
             validity-map
             240)]
          (log/info "Successfully retrieved user ID using token " user-id)
          user-id)
        (catch clojure.lang.ExceptionInfo e
          (log/info
            (str "Exception thrown while retrieving user from token. Message: "
                 (.getMessage ^Throwable e) ", Data: " (pr-str (ex-data e))))
          (throw e))))))

Here, we use the get-valid-user-id-from-header function to validate the token and extract from it the user’s id. We pass to it the token, a validity-map, indicating what the :iss and :aud value are expected to be, and a jitter value (as a number of minutes).

The validity-map is a map, based on the :kid value of the signed token, containing the acceptable values for the issuer and the audience fields of the token for a particular :kid. The jitter value is the number of minutes after the token’s actual expiration date (its :exp field) that the token is still considered valid. Because, this is a demonstration project we have set the value to 4 hours (240 minutes).

If the JWT token is valid then get-valid-user-id-from-header returns an identity-token as explained here. The :alloc-auth/token key will contain the user’s JWT token, whether :local or :google.

The use of a :local token is convenient for debugging purposes when you’re running the code locally, and not behind an IAP. A local token can be used by supplying it in the x-debug-token request header, and it will be used if an IAP header doesn’t exist. (You will recall that the x-goog-iap-jwt-assertion header has precedence).

A local token can be created using the same underlying method for the :ext-token field of the identity-token. It is a JWT token created and signed by our Pedestal server.

For convenience, the server supplies a route to retrieve a :local token and

$ curl --insecure -X POST https://<server>:<port>/debug/token/<email-address>

will return a clojure map that contains a token associated with the email address you supplied in the URL. Subsequent requests can use the response’s :token field as the value of the x-debug-token request header.

Obviously, this route, which allows public access, should be disabled in a production server.

(defn get-valid-user-id-from-header
  [google-header-assertion valid-map & [minutes-offset]]
  (letfn
    [(get-user-id-with-validation
       [r-val v-map]
       (log/info "Checking token values for validity:" r-val
                 "against" v-map)
       (when
         (every?
           (fn [kw]
             (= (get r-val kw) (get v-map kw)))
           (keys v-map))
         (log/info "token is valid, email address is" (get r-val :email))
         {:alloc-auth/user-id
          (auth-utils/get-id-from-email-address
            (get r-val :email))
          :alloc-auth/token-type :google
          :alloc-auth/token r-val
          :alloc-auth/ext-token  (debug-sign/sign-using-debug-key
                                   server-config/debug-local-jwt
                                   {:email (get r-val :email)})}))]
    (let
      [decoded-header
       (try
         (jws/decode-header google-header-assertion)
         (catch clojure.lang.ExceptionInfo e
           (throw
             (ex-info "Unable to decode assertion header as jws"
                      (merge
                        (ex-data e)
                        {:ex-message (.getMessage ^Throwable e)
                         :ex-cause   (.getCause ^Throwable e)})
                      e)))
         (catch Exception e
           (throw
             (ex-info "Unable to decode assertion header as jws"
                      {:ex-message (.getMessage ^Throwable e)
                       :ex-cause (.getCause ^Throwable e)}
                      e))))]
      (log/info "Successfully decoded header assertion using :kid" (:kid decoded-header))
      (log/info "Cache agent contains"
                (if-let
                  [jwks (some-> (deref jwk-cache-agent) :jwk)]
                  (str (count jwks) " entries with kid's " (mapv :kid jwks))
                  "no entries."))
      (if-let
        [public-key-jwt
         (some
           (fn [poss]
             (when
               (= (:kid poss)
                  (:kid decoded-header))
               poss))
           (get (deref jwk-cache-agent) :jwk))]
        (let
          [alg (keyword (str/lower-case (:alg public-key-jwt)))]
          (try
            (->
              (jwt/unsign
                google-header-assertion
                (keys/jwk->public-key public-key-jwt)
                {:alg alg
                 :now (time/minus (time/instant) (time/minutes (or minutes-offset 0)))})
              (get-user-id-with-validation valid-map))
            (catch Exception e
              (log/error
                "Google header validation problem, "
                (pr-str (ex-data e))
                "using public key"
                (pr-str public-key-jwt)))))
        (do
          (log/error "No matching :kid entry found in cache.")
          (throw
            (let [e (Exception.
                      (str "No matching :kid ("
                           (pr-str decoded-header)
                           ") entry found in cache."))]
              (ex-info "Bad jws."
                     (merge
                       (ex-data e)
                       {:ex-message (.getMessage ^Throwable e)
                        :ex-cause   (.getCause ^Throwable e)})
                     e))))))))

Testing the Implementation

Now that we have the backend working, we can test it using curl.

$ curl --insecure -X POST https://localhost:8081/api/getsecresource/a

will return the transit response

["^ ","~:reason","Unauthorized"]

indicating that the request couldn’t be authenticated using either a session of a header token. This is as we’d expect.

Now issue a request for a token by supplying an email address associated with the user-id :user

$ curl --insecure -X POST https://localhost:8081/debug/token/user@timpsongray.com

which will return something similar to

{:identity 
  {:alloc-auth/user-id :user, 
   :alloc-auth/token-type :local, 
   :alloc-auth/token {:email "user@timpsongray.com", :iss "local", :aud "local", :iat 1593088750, :exp 1593089650}, :alloc-auth/ext-token "eyJhbGciOiJFUzI1NiIsImtpZCI6ImxvY2FsIn0.eyJlbWFpbCI6InVzZXJAdGltcHNvbmdyYXkuY29tIiwiaXNzIjoibG9jYWwiLCJpYXQiOjE1OTMwODg3NTAsImF1ZCI6ImxvY2FsIiwiZXhwIjoxNTkzMDkyMzUwfQ.oqGnFqQ0N-7OYVBpLsiF_rNkDCtuHvI8i7OfXHaKvtJsTxxtoPHXN1kCd9d5X9Li3PM7q1Xg0z7TuRoZuGXB5A", 
   :alloc-auth/user-session :user-3}, 
   :token "eyJhbGciOiJFUzI1NiIsImtpZCI6ImxvY2FsIn0.eyJlbWFpbCI6InVzZXJAdGltcHNvbmdyYXkuY29tIiwiaXNzIjoibG9jYWwiLCJpYXQiOjE1OTMwODg3NTAsImF1ZCI6ImxvY2FsIiwiZXhwIjoxNTkzMDkyMzUwfQ.tX6bAIqK7tPEpilwTtN-VWoNDgIJZXy7cxWgWmlKyfZi5Wt4R_emuBHPD6J6-WevwblA1V_f1pkWBKxH7QyFQg"}

The token is in the :token field.

Using this token we can now issue a request for a resource to which :user is permitted access, adding the token we recieved as the x-debug-token request header,

$ curl --insecure -X POST \
  -H "x-debug-token: eyJhbGciOiJFUzI1NiIsImtpZCI6ImxvY2FsIn0.eyJlbWFpbCI6InVzZXJAdGltcHNvbmdyYXkuY29tIiwiaXNzIjoibG9jYWwiLCJpYXQiOjE1OTMwODg3NTAsImF1ZCI6ImxvY2FsIiwiZXhwIjoxNTkzMDkyMzUwfQ.tX6bAIqK7tPEpilwTtN-VWoNDgIJZXy7cxWgWmlKyfZi5Wt4R_emuBHPD6J6-WevwblA1V_f1pkWBKxH7QyFQg" \
  https://localhost:8081/api/getsecresource/u

and we should see the following transit response,

["^ ","~:the-results","Let's pretend that this is something interesting."]

letting us know that the request succeeded.

However, if we try to access a resource to which :user does not have permission,

$ curl --insecure -X POST \
-H "X-debug-token: eyJhbGciOiJFUzI1NiIsImtpZCI6ImxvY2FsIn0.eyJlbWFpbCI6InVzZXJAdGltcHNvbmdyYXkuY29tIiwiaXNzIjoibG9jYWwiLCJpYXQiOjE1OTMwODg3NTAsImF1ZCI6ImxvY2FsIiwiZXhwIjoxNTkzMDkyMzUwfQ.tX6bAIqK7tPEpilwTtN-VWoNDgIJZXy7cxWgWmlKyfZi5Wt4R_emuBHPD6J6-WevwblA1V_f1pkWBKxH7QyFQg" \
https://localhost:8081/api/getsecresource/a

we’ll receive an error message letting us know that :admin is the required role, while we only have the :user role,

["^ ","~:reason","Header authenticated, but not authorized for access to /api/getsecresource/a. 
Metadata : {:details {:request {:path \"/api/getsecresource/a\", :path-params {}, 
:user :user, :roles #{:user}, 
:identity #object[clojure.core$identity 0x8a41b2d \"clojure.core$identity@8a41b2d\"], 
:required #{:admin}, :user-session nil}, 
:message \"\\\"Alloc-Unauthorized\\\"\"}}"]

Closure

We now have a authentication back-end that can handle both session- and header-based authentication. Our SPA works as it always did, but now we can also use the api from non-browser based clients, including requests received through, and tagged by the IAP proxy.

Edit this page

Kieran Owens
Kieran Owens
CTO of Timpson Gray

Experienced Technology Leader with a particular interest in the use of functional languages for building accounting systems.

comments powered by Disqus

Related