Somehow, I missed this part in the webauthn protocol series. Originally, I wrote the introduction, with the flows and so on but this crucial part, about the authentication was missing. Perhaps due to the lack of interest, I never finished it. Here it comes, better late than never!
You can also try out everything at passwordless.id or use the simplifying library or just play around with the playground to try out the options.
The authentication has similar steps to the registration, and depicted in the sequence diagram below.
1. Request a challenge from the server
The challenge is basically a nonce to ensure the authentication payload cannot be reused in replay attacks. It is just a bunch of bytes randomly generated from the server.
2. Trigger the webauthn protocol in the browser side
This is done like this:
let assertion = await navigator.credentials.get({
publicKey: {
challenge: Uint8Array.from(
randomStringFromServer, c => c.charCodeAt(0)),
allowCredentials: [{
id: Uint8Array.from(
credentialId, c => c.charCodeAt(0)),
type: 'public-key',
transports: ['internal', 'hybrid','usb', 'ble', 'nfc'],
}],
timeout: 60000,
}
});
It's interesting to note that there are two possible usages / behaviors here:
- Providing
allowCredentials
with the list of credential IDs allowed- typically fetched alongside the challenge from the server for a given username
- Leaving
allowCredentials
as an empty list- a UI will pop up to let you choose a credential you already registered here
- how the UI looks is platform specific, quite recent, still evolving, and a bit buggy from my experience
The transports
defines how the user can authenticate. internal
is to let the device use the local authentication method, hybrid
is authentication on a desktop computer using a smartphone for example and let the platform decide how, usb
for security keys, ble
for bluetooth and nfc
for NFC.
Once the user "authenticates" locally, using biometrics like fingerprint, swiping a pattern or whatever the platform offers, the method returns.
Again, what you obtain is not a JSON like structure, but an object filled with ArrayBuffers and a few methods.
It looks like this:
{
authenticatorAttachment: null,
id: "IJeOSAbRJ4FNTS1aF5D...",
rawId: ArrayBuffer(32),
type: "public-key",
response: {
authenticatorData: ArrayBuffer(37)
clientDataJSON: ArrayBuffer(138)
signature: ArrayBuffer(71)
userHandle: ArrayBuffer(32)
}
Again, you cannot just send it like this to the server, but you must first encode it in some JSON, usually by base64url-encoding these ArrayBuffers.
When "decoding" these array buffers, which are kind of low-level structures, you will obtain various information that you must validate. Most notably the signature, which proves that the provided information is authentic, that the one sending the payload is in possession of the private key.
Roughly speaking, the authenticatorData
which is a sequence of bytes provides following information:
{
"rpIdHash": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2M=",
"flags": {
"userPresent": true,
"userVerified": true,
"backupEligibility": false,
"backupState": false,
"attestedData": false,
"extensionsIncluded": false
},
"counter": 1
}
The clientData
is simpler, it is just a UTF-8 string representing raw JSON, and decoded looks like this:
{
"type": "webauthn.get",
"challenge": "24d224d3-1d0f-4301-8759-398870585e55",
"origin": "http://localhost:8080",
"crossOrigin": false
}
Both of these payloads have to be verified for their validity and the signature too using the public key and signature algorithm obtained during registration.
The "data" that is signed is the authenticatorData
byte array concatenated with the sha256
hash of clientData
.
All this scratches the surface of this complex protocol. Therefore I strongly advise you to use some library and not to roll your own. It will save you some headaches. ;)
Top comments (0)