Welcome back to this series on implementing passkeys in Clojure. For background on how passkeys work and how we’re building this backend, please refer back to part one of the series which covers the data model behind our implementation, and part two, which covers user registration.
User Authentication
Today, we’ll complete the backend of our passkey service by building the endpoints needed to authenticate users who have already registered. This flow is similar to user registration, in that the server will send a challenge to the client and wait to validate the response, but it will be simpler to build as it uses a number of pieces already built for registration.
(ns example.passkey
(:import [com.yubico.webauthn StartAssertionOptions]))
(defn start-authentication
[db _req]
(let [options (-> (StartAssertionOptions/builder)
(.build))
rp (relying-party db)
request (.startAssertion rp options)
json (.toCredentialsGetJson request)
request-id (store-request db request)]
{:status 200
:headers {:content-type "application/json"
:request-id request-id}
:body json}))
As in the registration flow, we are temporarily storing the request in our database, and the server will retrieve it on the response in order to validate the challenge.
Authentication Outcomes
We’ll need three simple functions to back up our eventual authentication completion:
(defn auth-error
[]
{:status 401
:headers {:content-type "application/edn"}
:body (pr-str {:error "Authorization Failed"})})
(defn auth-success-for
[user-data]
{:status 200
:headers {:content-type "application/edn"}
:body (pr-str user-data)})
(defn update-user
[db credential-id signature-count backed-up?]
(let [user (db/find-user-by-credential-id credential-id)]
(db/update-user user {:credential/signature-count signature-count
:credential/backed-up? backed-up?})))
The error function simply returns an HTTP 401 error code. The success function
will return user-data
, which is anything your client application needs to know
to function as a logged-in user, like you would with session information or a
JWT.
The user update function serves to increment the signature count and backup
status; java-webauthn-server
uses this to continue authenticating the passkey.
Completing Authentication
With those built, we are ready to process an authentication response:
(ns example.passkey
(:require [example.db :as db])
(:import [com.yubico.webauthn FinishAssertionOptions]
[com.yubico.webauthn.data PublicKeyCredential]
[com.yubico.webauthn.exception AssertionFailedException]))
(defn complete-authentication
[db req]
(try
(let [json-body (-> req :body bs/to-string)
request-id (-> req :path-params :request)
pkc (PublicKeyCredential/parseAssertionResponseJson json-body)
rp (relying-party db)
request-m (assertion-request-for db request-id)
request (:webauthn/request request-m)
options (-> (FinishAssertionOptions/builder)
(.request request)
(.response pkc)
(.build))
result (.finishAssertion rp options)]
(if (.isSuccess result)
(let [credential-id (-> result .getCredential .getCredentialId .getHex)
signature-count (.getSignatureCount result)
backed-up? (.isBackedUp result)
user (db/find-user-by-username db (.getUsername result))]
(update-user db credential-id signature-count backed-up?)
(remove-request db request-id)
(auth-success-for signer-uuid user))
(auth-error)))
(catch AssertionFailedException e
(println "Error Authenticating Passkey" e)
(auth-error))))
Much like the registration flow, we are invoking the webauthn library to take the incoming signed JSON authentication response. If it succeeds, we lookup the user in our database and return it to the client. If not, we’ll use our error function to return an HTTP error code.
That’s all there is; the entire passkey service is now completed end-to-end.
Up Next
Now that our backend is complete, the fourth and final installment of this series will look at building a client in React Native that can register and authenticate using passkeys on client devices.