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.