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
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.