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.
User Registration
Today, we’ll be building the API endpoints we need for users to register a new passkey on our service. The flow looks like this:
- A user makes a request with their account information to start the registration process.
- The server generates a challenge to send back to the client, and stores that request locally.
- The client responds with a signed challenge and the server authenticates it against the stored request.
- If the response is successful, the server removes the temporarily stored request and permanently stores the public key for future authentication.
Registration Support
First, let’s look at the functions we’ll need to support that first incoming registration request:
(ns example.passkeys
(:require [example.db :as db])
(:import [com.yubico.webauthn StartRegistrationOptions
FinishRegistrationOptions]
[com.yubico.webauthn.data ByteArray UserIdentity
AuthenticatorSelectionCriteria ResidentKeyRequirement]
[java.security SecureRandom]))
(defn random-bytes
[size]
(let [ba (byte-array size)]
(-> (SecureRandom/getInstanceStrong)
(.nextBytes ba))
(ByteArray. ba)))
(defn create-user-passkey
[db id username]
(-> (UserIdentity/builder)
(.name id)
(.displayName username)
(.id (random-bytes 64))
(.build)))
(defn public-key-request
[db user]
(let [auth-selection (-> (AuthenticatorSelectionCriteria/builder)
(.residentKey ResidentKeyRequirement/REQUIRED)
(.build))
options (-> (StartRegistrationOptions/builder)
(.user user)
(.authenticatorSelection auth-selection)
(.build))
rp (relying-party db)]
(.startRegistration rp options)))
(defn store-request
([db request]
(let [request-id (db/create-request {:webauthn/request (.toJson request)})]
id))
([db request user]
(let [id (db/create-request {:webauthn/request (.toJson request)
:user/username (.getDisplayName user)
:user/fid (Long/parseLong (.getName user))
:user/handle (-> user .getId .getHex)})]
id)))
Some things to note about these functions:
create-user-passkey
is creating the Java object that represents a passkey user record. The field names (name
,displayName
,id
) may not necessarily line up with your database; in particular,id
is a byte array thatjava-webauthn-server
uses to identify the record.- In
public-key-request
, we are usingResidentKeyRequirement/REQUIRED
to build a passkey instead of a generic webauthn record. - When storing requests (and eventually, user records) you will likely need to
convert byte arrays using their
getHex
method.
Registration Initiation
With that scaffolding, we are now ready to accept an incoming registration initiation request. For all of the web requests and responses in the rest of this series, we will be using the Ring request/response model.
(defn start-registration
[db req]
(let [{:keys [username id]} (:params req)
user (create-user-passkey db id username)
request (public-key-request db user)
creds (.toCredentialsCreateJson request)
request-id (store-request db request user)]
{:status 200
:headers {:content-type "application/json"
:request-id (str request-id)}
:body creds}))
At this point, this is relatively straightforward. We take in the user’s username and ID from the request (in a real production system, you would validate this with whatever preexisting authentication solution you have rather than just accepting the client’s word for it), create a passkey user record, create a public key request from that record and store it, and then respond to the user with the challenge JSON and the request ID.
On the client, the user will take in that challenge, sign it, and respond back to the server using the request ID we provided for them.
Registration Completion
When the user responds with the completed challenge, we’re ready to check that response and if it passes, store it in our database and let the client know that their passkey is valid.
(defn store-result
[db result username id handle pkc]
(let [key-id (-> result .getKeyId .getId)
signature-count (.getSignatureCount result)
discoverable? (when (.isPresent (.isDiscoverable result))
(.isDiscoverable result))
backup-eligible? (.isBackupEligible result)
backed-up? (.isBackedUp result)
public-key-cose (.getPublicKeyCose result)
attestation-obj (-> pkc .getResponse .getAttestationObject)
client-data-json (-> pkc .getResponse .getClientDataJSON)]
(db/store-passkey-user {:user/username username
:user/id id
:user/handle handle
:credential/id (.getHex key-id)
:credential/public-key-cose (.getHex public-key-cose)
:credential/signature-count signature-count
:credential/discoverable? discoverable?
:credential/backup-eligible? backup-eligible?
:credential/backed-up? backed-up?
:credential/attestation-obj (.getHex attestation-obj)
:credential/client-data-json (.getHex client-data-json)}]])))
(defn complete-registration
[db req]
(try
(let [json-body (:body req)
request-id (get-in req [:path-params :request])
pkc (PublicKeyCredential/parseRegistrationResponseJson json-body)
rp (relying-party db)
request-m (db/registration-request-for db request-id)
request (:webauthn/request request-m)
username (:user/username request-m)
id (:user/id request-m)
handle (:user/handle request-m)
options (-> (FinishRegistrationOptions/builder)
(.request request)
(.response pkc)
(.build))
result (.finishRegistration rp options)]
(store-result db result username fid handle pkc signer)
(db/remove-request db request-id)
{:status 204})
(catch RegistrationFailedException e
(println "Error Registering Passkey" e)
{:status 400
:headers {:content-type "application/edn"}
:body (pr-str {:error "Registration Failed"})})))
As in the support functions we built above, we are serializing the various byte
array fields into hex strings for DB storage. The client will be passing a
standard(-ish, more on that in a later installment) JSON body from the result of
signing the challenge back to the server. If that request is valid, we tell
java-webauthn-server
to complete the registration on its passkey object and
store that result, along with any other user information we need, in our
database for future authentication. We also remove the temporary request record
from the database to prevent replays.
All the client needs then is an indication (status 204 from the server) that registration was successful, and it’ll be able to use a passkey to authenticate in the future.
Up Next
Now that users can register a passkey with our system, part three will look at how they can use that passkey to sign in.