Welcome to this, the fourth and final installment of our series on implementing passkeys in Clojure and React Native. Part one covered the background on how passkeys work and the data model, part two covered user registration, and part three covered user authentication. Today, we’re going to build the client using react-native-passkey.

Passkeys on the Client

The react-native-passkey documentation covers a number of things you’ll need to do to enable passkeys in a React Native application in iOS or Android, including setting up responses on your server that prove that you own the domain your passkeys will claim to represent. Rather than rehashing those steps, I’m going to move straight to handling the client-side code. As I noted earlier, the WebAuthn standards passkeys use is standard-ish. Most of what we’ll do here is slightly massaging the shape of the data on the way in or out to match what the server or client library expects.

Base64

In particular, java-webauthn-server and react-native-passkey have different expectations for which flavor of base64 encoding to use. Here are two very simple translation functions:

const base64ToUrl = string => string.replace(/\+/g, "-").replace(/\//g, "_");
const urlToBase64 = string => string
    .replace(/\-/g, "+")
    .replace(/\_/g, "/")
    .padEnd(string.length + (4 - (string.length % 4)), "=");

Registration

As covered in our implementation of the user registration backend, the registration process has two steps, first to set up the challenge, then to fulfill it.

const beginRegistration = async (userId) => {
  const result = await fetch("https://example.com/registration/begin", {
    method: "POST",
    headers: { accept: "application/json", "Content-Type": "application/edn" },
    body: `{:user-id "${userId}"}`
  });
  const json = await result.json();
  const requestId = result.headers.get("request-id");
  return { json, requestId };
};

const finalizeRegistration = async (json, requestId) => {
  delete json.rawId;
  json.id = base64ToUrl(json.id);
  json.response.attestationObject = base64ToUrl(json.response.attestationObject);
  json.response.clientDataJSON = base64ToUrl(json.response.clientDataJSON);
  json.clientExtensionResults = {};
  json.type = "public-key";
  return await fetch(`https://example.com/registration/${requestId}/finalize`, {
    method: "POST",
    headers: { accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(json)
  });
};

In beginRegistration, we’re simply passing the current user’s ID. In an actual production application, you’ll want to have a more secure way of identifying users than just allowing anyone to post an ID to an endpoint, but that will depend on the authentication system you are already using.

In finalizeRegistration, we are translating the object that react-native-passkey returns into the one java-webauthn-server expects, through removing some keys, base64 converting others, and adding still others.

With that done, we’re ready to tie the two together:

import { Passkey } from "react-native-passkey";

const registerPasskey = async (userId, callback) => {
  try {
    const { json, requestId } = await beginRegistration(userId);
    json.publicKey.challenge = urlToBase64(json.publicKey.challenge);
    const result = await Passkey.register(json.publicKey);
    const finalizeResult = await finalizeRegistration(result, requestId);
    if (finalizeResult.status === 200) {
      callback();
    };
  } catch(error) {
    alert(error);
  }
};

At Passkey.register(), the client OS will present the biometric sheet to the user, and if it’s accepted, the passkey will be stored on the local secure enclave, ready to use for authentication.

Authentication

As with registration, authentication is a two-step process:

const beginAuthentication = async () => {
  const result = await fetch("https://example.com/auth/begin", {
    method: "POST",
    headers: { accept: "application/json" }
  });
  const json = await result.json();
  const requestId = result.headers.get("request-id");
  return { json, requestId };
};

const finalizeAuthentication = async (json, requestId) = {
  delete json.rawId;
  json.id = base64ToUrl(json.id);
  json.response.authenticatorData = base64ToUrl(json.response.authenticatorData);
  json.response.signature = base64ToUrl(json.response.signature);
  json.response.userHandle = base64ToUrl(json.response.userHandle);
  json.response.clientDataJSON = base64ToUrl(json.response.clientDataJSON);
  json.clientExtensionResults = {};
  json.type = "public-key";
  const result = await fetch(`https://example.com/auth/${requestId}/finalize`, {
    method: "POST",
    headers: { accept: "application/edn", "Content-Type": "application/json" },
    body: JSON.stringify(json)
  });
  return await result.text();
}

Most of this works just like the translation steps for registration. In the end, we’re returning result.text() as it’s EDN-encoded for our ClojureScript application.

Now to tie it together:

import { Passkey } from "react-native-passkey";

const signInWithPasskey = async (callback) => {
  try {
    const { json, requestId } = await beginAuthentication();
    json.publicKey.challenge = urlToBase64(json.publicKey.challenge);
    const result = await Passkey.authenticate(json.publicKey);
    const finalizeResult = await finalizeAuthentication(result, requestId);
    callback(finalizeResult);
  } catch (error) {
    alert(error);
  }
};

After Passkey.authenticate() prompts the user for facial or thumbprint recognition or similar and everything succeeds, your callback function will be provided the user information in EDN format that we built in Part III. And that is it: an end-to-end passkey service where users can register and authenticate without passwords or phishing attempts.

Wrapping Up

Building passkey support into Sportscaster involved a fair bit of trial-and-error getting the encodings and data shapes to fit together but was otherwise straightforward and rewarding. I thought briefly about turning the implementation into a library, but there are so many places where your own database needs to be injected and so little actual work (again, around 300 LoC) that it seemed better expressed as a blog post. Hopefully this serves as documentation for the grunt work of converting between representations of the standard and saves times for anyone else thinking of building this in Clojure. If you have any questions or are interested in having me consult on a project like this, please feel free to email me or reach out on Twitter or Farcaster or on Clojurians Slack.