WebAuthn with React and Node Created Feb 21 2022

Everyone knows the normal one or two factor authentication process we all had to go through. Let's be real, it's boring and takes a long time to complete, especially if you have 2FA set up via SMS or E-Mail. In that case, sometimes it takes even longer because you won't get that sms/mail for the next 30 minutes (looking at you Microsoft).

This guide should help you understand how WebAuthn can help us here, how it works and hopefully how it can be implemented.

WebAuthn to the rescue

So, what's WebAuthn? Well, it's a web standard that makes it very east to simplify this process. Instead of having an app on your phone or an E-Mail account, you usually have some sort of a physical device like a YubiKey that you can use for authentication.

There's generally two ways authenticating with WebAuthn can go. I will explain both ways and then show how they can be implemented.

The basics

In both cases, the following applies:

  • there are three parties involved in the process
    • The Relying Party, or RP for short (server)
    • The browser (client)
    • The authenticator (e.g. YubiKey)

  • it's a two step process
    • Attestation => Setting up the authenticator with the RP
    • Assertion => Authenticating with the set up authenticator

The whole process works using public-key cryptography. The gist of it is that the RP sends the client a challenge, the authenticator signs the challenge and returns it back to the RP. The RP can then check if the challange was signed correctly using the user's public key.

2FA and/or passwordless

One of the scenarios of how it can be used is where you know the user's username or any other identifiable information and then you can authenticate as that user with your YubiKey. This is usually used as a 2nd Factor method, but it can also be used for passwordless, but not usernameless login.

Attestation flow

  1. RP sends the challenge to the client
  2. Authenticator signs the challenge
    • optionally, the authenticator will confirm with the user about signing (this is usually just pressing a button on the key)
  3. Client sends the signed challenge along with it's public key and the credential ID back to the RP
  4. RP stores the information which it can then use later for authentication

Assertion flow

  1. RP sends the challenge to the client
  2. Authenticator signs the challenge
    • optional confirmation
  3. Client sends the signed challenge back to the RP
  4. RP uses the previously stored public key to check if the challenge was signed correctly

Here's a simple diagram of how everything works.

WebAuthn diagram

Usernameless

Ok, this is what everyone is excited about, passwordless and usernameless login. Yes, you can log in without even a username! In this case, the authenticator will store a small piece of information called a userHandle which will be the user you want your authenticator to log you in as. So here, instead of already knowing who we want to log in as, the authenticator tells the RP who it wants to log in as.

This process is usually referred to as Resident Key authentication. And I will refer to it as that from now on.

The flow is pretty similar to the one stated above, but let's repeat and fill in some gaps.

Attestation flow

  1. RP sends the challenge to the client along with the user id of the user
  2. Authenticator signs the challenge and stores the user id in it's memory
    • optional confirmation
  3. Client sends the signed challenge along with it's public key and the credential ID back to the RP
  4. RP stores the information which it can then use later for authentication

Assertion flow

  1. RP sends the challenge to the client, but this time it's not linked to any user
  2. Authenticator signs the challenge
    • optional confirmation
  3. Client sends the signed challenge back to the RP along with the userHandle
  4. RP uses the previously stored public key to check if the challenge was signed correctly

A thing to note about the userHandle:

  • it should be a pseudo-random value
  • it should not contain any personally identifiable information
  • it must be a maximum of 64 bytes long (in JS, this is a 16 letter string)
    • if the string is longer, the authenticator will cut it off, so keep that in mind

Here's another diagram, very similar to the one above, but again with a few changes. Also this diagram describes the assertion process only because the attestation process the same as the one above.

WebAuthn diagram

Also, another popular way of doing usernameless + passwordless login today is via ✨ blockchain ✨ and ✨ Web 3.0 ✨. A colleague of mine, Luc van Kampen wrote a nice blog post about it.

Implementation with React and Node

Alright, time for the fun part(sometimes), the code! Note that I will assume you have or know how to set up a login system.

Installing dependencies

There's basically only one dependency we really need, that being fido2-lib.

Obvious note: if you're using NPM, use npm install isntead of yarn… duh

yarn add fido2-lib base64-arraybuffer

We'll use fido2-lib to handle all cryptography for us and make our lives a lot easier. And base64-arraybuffer which … webauthn really loves ArrayBuffers … and we need a way of transferring them over the network. This will lead to some very painful typescript soon enough.

Important disclaimer: fido2-lib sadly doesn't have webauthn extension support which is necessary for resident keys to work. Luckily, there is a fork of the project that is relatively in sync with the original. If you're getting build errors with it though, first thing you should check is your Node version, because this does not work with Node 17. Trust me, I learned the hard way.

Installation is as follows:

yarn add "https://github.com/efabris/fido2-lib#extension_support"

Also, before I begin, if you wish to check out how this whole flow works, I built a simple full-stack example for it. Git

Initializing the library

Before anything we must set up our Fido2 instance on the server.

const fido2 = new Fido2Lib({
    timeout: 30 * 1000,
    rpId: "localhost",
    rpName: "antony-cloud",
    rpIcon: "https://media.antony.red/logoTransparent.png",
    challengeSize: 128,
    attestation: "direct",
    cryptoParams: [-7, -257],
    authenticatorAttachment: "cross-platform",
    authenticatorUserVerification: "discouraged",
    authenticatorRequireResidentKey: false
});

Ok, let me explain what some of these options mean now.

  • timeout
    • the amount of time the browser will wait for the authenticator to respond
  • rpId
    • identifier of the Relying Party, this is usually set to the hostname of your website
  • attestation
    • can be "direct", "indirect" or "none", you usually want to stick with "direct" Docs
  • cryptoParams
    • an array of allowed cryptographic algorithms, a full list can be found here; we use ES256 and RS256
  • authenticatorRequireResidentKey
    • wether you want to support resident keys or not, set this to true if you're going with the usernameless approach

Attestation

Ok, setting up the keys. Simple stuff, though there's gonna be a lot of TypeScript shinanigans so prepare yourself.

Let's assume the user is logged in, they pressed the button to set up their authenticator and a function gets triggered.

Requesting a challenge

First thing we do is hit up the server and ask it to give us a challenge. Pretty simple stuff.

const attestation = await http.get("/webauthn/attestate/begin")
                            .then(res => res.data as PublicKeyCredentialCreationOptions);

Something like this, we wait for the server, and the returned data gets casted to PublicKeyCredentialCreationOptions. These names are insanely long, but hey, that's what IntelliSense is for.

The server response can be generated by simply invoking a function provided by fido2-lib. But it's not as easy as that, as I mentioned before, WebAuthn looooves ArrayBuffers. Get ready for encoding/decoding madness (encode and decode functions are imported from base64-arraybuffer).

const options = await fido2.attestationOptions();
options.user = { id: user.id, name: user.username, displayName: user.username };

const encoded = {
    ...options,
    challenge: encode(options.challenge)
};

// store the challenge to db
challenges[user.id] = encoded.challenge;

We first generate what's called attestationOptions that contain a challenge. Next step is to set the user we're setting up the key for. Keep in mind that user.id here is the userHandle used in the resident key process. The challenge is also given as an ArrayBuffer so we need to convert it to base64. Make sure to store the generated challenge in some database, we'll use it later for verification.

And now back in the browser we must decode it back to ArrayBuffers, aaaargh.

// cast to unknown because types, safe in this case
attestation.challenge = decode(attestation.challenge as unknown as string);
attestation.user.id = decode(attestation.user.id as unknown as string);

Authenticating

We can then pass our attestation options to CredentialsContainer (navigator.credentials). Since we're setting up the key, we call create, this will store the needed information on the authenticator. It will return a promise of type Credential, which may be null … or might error, so make sure to catch all that.

const credential: Credential | null | false = await navigator.credentials.create({ publicKey: attestation }).catch(() => false);
if(!credential)
    return;

Back to RP

Our credential now has everything needed for the server to know who we are. Simply pass it back

await http.post("/webauthn/attestate/end", encodeAttestationResponse(credential as PublicKeyCredential));

encodeAttestationResponse is simply a function that encodes rawId, response.attestationObject and response.clientDataJSON of the credential as base64. I'll let you have fun with this.

On the server side, we need to validate our response and store the credential ID and the public key in the database. We'll need it soon while doing assertion.

const result = await fido2.attestationResult({
    ...attestation,
    rawId: decode(attestation.rawId)
}, {
    rpId: "localhost",
    challenge: challenges[user.id], // get the previously stored challenge
    origin: "http://localhost:3000",
    factor: "either"
});

We of course need to decode the rawId back to an ArrayBuffer.

result will have clientData and authnrData maps containing some important data. What we need to store is "rawId" from clientData containing the credential ID and "credentialPublicKeyPem" from authnrData containing the public key in PEM format.

Assertion

Ok, this is where it gets fun … not.

Requesting a challenge

This process is pretty similar to the one described above so I won't go as much in depth.

We start of by requesting a challenge. Exactly the same as above

const rawAssertion = await http.get("/webauthn/assert/begin")
                            .then(res => res.data as PublicKeyCredentialCreationOptions);

Again, fido2-lib provides us with another very simple function for generating this. But this time, the response will differ depending if you are using resident keys or not.

Normally

const options = await fido2.assertionOptions();

const encoded = {
    ...options,
    challenge: encode(options.challenge),
    allowCredentials: [{
        type: "public-key",
        id: user.credentialId,
        transports: ["usb", "ble", "nfc"]
    }]
};

challenges[user.id] = encoded.challenge;

As with attestation, we need to encode the challenge, but this time we also need to specify which credentials (keys) we want to allow the user to authenticate with. In this case, we only allow one, the one set up in attestation.

Make sure to store the challenge.

Using resident keys

const options = await fido2.assertionOptions(); // fido2 here has resident keys enabled

const encoded = {
    ...options,
    challenge: encode(options.challenge)
};

return encoded;

While using resident keys, this step is a lot easier, we just need to encode the challenge and thats it. Notice the challenge is not stored this time.

In the broser we need to decode our assertion again. This is mostly the same as in attestation, except we don't have to decode the user id, but on the other hand, all allowed credential IDs need to be decoded back to ArrayBuffers. In the example, I wrapped this in its seperate function that just loops over all allowed credentials, if they exist ofc (they wont if using resident keys), and decodes them.

const assertion = decodeAssertion(rawAssertion);

Authenticating

This step is exactly the same as described above, the only difference is that we no longer use create, but get instead.

Back to RP

We can now send this back to our server. Optionally, if using resident keys, we also must include the challenge our user signed.

await http.post("/webauthn/assert/end", { challenge: rawAssertion.challenge, ...encodeAssertResponse(credential as PublicKeyCredential) })

encodeAssertResponse is another function that encodes a bunch of stuff, specifically rawId, response.authenticatorData, response.clientDataJSON, response.signature and response.userHandle if it exists (it won't if not using resident keys). Encoded userHandle will return the user.id specified while attestating.

On the server, we just need to validate the response. Now this step is again different depending if you're using resident keys or not.

Normally

fido2.assertionResult({
    ...assertion,
    rawId: decode(assertion.rawId),
    response: {
        ...assertion.response,
        authenticatorData: decode(assertion.response.authenticatorData)
    }
}, {
    challenge: challenges[user.id], // get the previously stored challenge
    origin: "http://localhost:3000",
    factor: "either",
    publicKey: user.publicKey, // get the previously stored public key
    prevCounter: 0,
    userHandle: null
});

Notice we use the public key we stored before.

Using resident keys

// fido2 here has resident keys enabled
fido2.assertionResult({
    ...assertion,
    rawId: decode(assertion.rawId),
    response: {
        ...assertion.response,
        authenticatorData: decode(assertion.response.authenticatorData)
    }
}, {
    challenge,
    origin: "http://localhost:3000",
    factor: "either",
    publicKey: user.publicKey,
    prevCounter: 0,
    userHandle: null
});

Challenge is taken from the request we sent to the server and the user for the public key can be found using the userHandle

assertionResult returns a promise, if it resolves, the user authenticated correctly and if it doesn't, authentication failed.

The takeaway

Well, I kinda have mixed feelings about WebAuthn. I really love the result and how quickly and easily I can authenticate. But, on the other hand, it has shown to be a pain to get working how I wanted it to. Adding to that, there's a huge lack of good documentation, tutorials and libraries. That's one of the reasons I wrote this, hopefully I'll make someone's life at least a bit easier.

But to sum up, I personally feel like the tradeoff is worth it. The simplicity of the login process is uncomparable and at the end of the day, you do learn some things while going through all the pain of getting it working.