The FIDO2 / WebAuthn allows you to create and use strong, attested public key based credentials for the purpose of authenticating users. The API supports the use of BLE, NFC, and USB roaming authenticators (security keys) as well as a platform authenticator, which allows the user to authenticate using their fingerprint or screen lock.
In this codelab, you are going to build a website with a simple re-authentication functionality using a fingerprint sensor. Re-authentication is when a user signs in to a website once, then authenticates again as they try to enter important sections of the website, or come back after a certain interval, etc in order to protect the accountdata.
You will learn how to call the WebAuthn API and options you can provide in order to cater various occasions. You will also learn re-auth specific best practices and tricks.
Hardware (one of following)
Browser
To work on this codelab, we'll be using a service called glitch. This is where you can edit both client and server side code using JavaScript and deploy them instantly. Head to the following URL:
https://glitch.com/edit/#!/webauthn-codelab-start
Let's see the initial state of the website first. Click "Show" at the top and press "Next to The Code" to see the live website side by side.
Notice that you must enter the password every time you try to sign back in.
You can preview what you are going to build from here.
In https://glitch.com/edit/#!/webauthn-codelab-start, find the "Remix Project" button at the top left corner. By pressing the button, you can "fork" the project and continue with your own version with a new URL.
Let's move on!
You first need to register a credential generated by a user verifying platform authenticator (UVPA) - an authenticator that is built into the device and verifies the user identity.
We are adding this feature to the /home
page.
registerCredential()
functionLet's create a function called registerCredential()
which registers a new credential.
export const registerCredential = async (opts) => {
};
/auth/registerRequest
Before asking the user to register a new credential, ask the server to send back a challenge and other parameters. Call _fetch()
with opts
as an argument to send a POST request to the server.
const options = await _fetch('/auth/registerRequest', opts);
Here's example options you will receive (aligns with PublicKeyCredentialCreationOptions
).
{
"rp": {
"name": "WebAuthn Codelab",
"id": "webauthn-codelab.glitch.me"
},
"user": {
"displayName": "Test",
"id": "...",
"name": "test"
},
"challenge": "...",
"pubKeyCredParams": [
{
"type": "public-key",
"alg": -7
}, {
"type": "public-key",
"alg": -257
}
],
"timeout": 1800000,
"attestation": "none",
"excludeCredentials": [
{
"id": "...",
"type": "public-key",
"transports": [
"internal"
]
}
],
"authenticatorSelection": {
"authenticatorAttachment": "platform",
"userVerification": "required"
}
}
To learn about these options, see the official specification of the WebAuthn. Some important ones are explained at the "Register a credential" section in the next page.
Because these options are delivered encoded in order to go through HTTP protocol, you have to convert some parameters back to binary - specifically, user.id
, challenge
and id
s included in excludeCredentials
array:
options.user.id = base64url.decode(options.user.id);
options.challenge = base64url.decode(options.challenge);
if (options.excludeCredentials) {
for (let cred of options.excludeCredentials) {
cred.id = base64url.decode(cred.id);
}
}
And finally call the navigator.credentials.create()
method in order to create a new credential. With this call, the browser will interact with the authenticator and tries to verify the user's identity using the UVPA.
const cred = await navigator.credentials.create({
publicKey: options
});
Once the user verifies their identity, you should be receiving a credential object you can send to the server and register the authenticator.
/auth/registerResponse
Here's an example credential object you should have received.
{
"id": "...",
"rawId": "...",
"type": "public-key",
"response": {
"clientDataJSON": "...",
"attestationObject": "..."
}
}
Like when you received an option object for registering a credential, you should encode the binary parameters of the credential so that it can be delivered to the server as a string.
const credential = {};
credential.id = cred.id;
credential.rawId = base64url.encode(cred.rawId);
credential.type = cred.type;
if (cred.response) {
const clientDataJSON =
base64url.encode(cred.response.clientDataJSON);
const attestationObject =
base64url.encode(cred.response.attestationObject);
credential.response = {
clientDataJSON,
attestationObject
};
}
Store the credential id locally so that we can use it for authentication when the user comes back.
localStorage.setItem(`credId`, credential.id);
Finally, send the object to the server and if it returns HTTP code 200, consider the new credential has been successfully registered.
return await _fetch('/auth/registerResponse' , credential);
Congratulations, you now have the complete registerCredential()
function!
It will be nice to have a list of registered credentials along with buttons to remove them.
Let's add UI to list credentials and a button to register a new credential. Depending on whether the feature is available or not, we'll remove the hidden
class from either the warning message or the button to register a new credential. ul#list
will be the placeholder for adding a list of registered credentials.
<p id="uvpa_unavailable" class="hidden">
This device does not support User Verifying Platform Authenticator. You can't register a credential.
</p>
<h3 class="mdc-typography mdc-typography--headline6">
Your registered credentials:
</h3>
<section>
<div id="list"></div>
<mwc-fab id="register" class="hidden" icon="add"></mwc-fab>
</section>
To check the UVPA availability, you have to do two things. First, check if WebAuthn is available by examining window.PublicKeyCredential
. Second, check if a UVPA is available by calling PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
. If it's available, we'll show the button to register a new credential. If it's not available, we'll show the warning message.
const register = document.querySelector('#register');
if (window.PublicKeyCredential) {
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
.then(UVPAA => {
if (UVPAA) {
register.classList.remove('hidden');
} else {
document
.querySelector('#uvpa_unavailable')
.classList.remove('hidden');
}
});
} else {
document
.querySelector('#uvpa_unavailable')
.classList.remove('hidden');
}
getCredentials()
Let's create getCredentials()
function so you can get registered credentials and display them in a list. Luckily, we already have a handy endpoint on the server /auth/getKeys
which you can fetch registered credentials for the signed-in user.
The returned JSON includes credential information such as id and publicKey. By building HTML you can show them to the user.
const getCredentials = async () => {
const res = await _fetch('/auth/getKeys');
const list = document.querySelector('#list');
const creds = html`${res.credentials.length > 0 ? res.credentials.map(cred => html`
<div class="mdc-card credential">
<span class="mdc-typography mdc-typography--body2">${cred.credId}</span>
<pre class="public-key">${cred.publicKey}</pre>
<div class="mdc-card__actions">
<mwc-button id="${cred.credId}" @click="${removeCredential}" raised>Remove</mwc-button>
</div>
</div>`) : html`
<div>No credentials found.</div>
`}`;
render(creds, list);
};
Let's display available credentials as soon as the user lands on /home
by invoking getCredentials()
.
getCredentials();
removeCredential()
In the list of credentials, you have added a button to remove each credential. By sending a request to /auth/removeKey
along with the credId
query parameter, you can remove them.
export const unregisterCredential = async (credId) => {
localStorage.removeItem('credId');
return _fetch(`/auth/removeKey?credId=${encodeURIComponent(credId)}`);
};
import { _fetch, unregisterCredential } from '/client.js';
const removeCredential = async e => {
try {
await unregisterCredential(e.target.id);
getCredentials();
} catch (e) {
alert(e);
}
};
Finally, call registerCredential()
to register a new credential when (+) button is clicked. Don't forget to renew the credential list by calling getCredentials()
after registration.
Import registerCredential
from client.js
we created earlier:
import { _fetch, registerCredential, unregisterCredential } from '/client.js';
Invoke registerCredential()
with options for navigator.credentials.create()
when the button is clicked.
register.addEventListener('click', e => {
registerCredential({
attestation: 'none',
authenticatorSelection: {
authenticatorAttachment: 'platform',
userVerification: 'required',
requireResidentKey: false
}
})
.then(user => {
getCredentials();
})
.catch(e => alert(e));
});
Following are important options to remember to pass in registerCredential()
(PublicKeyCredentialCreationOptions we referred earlier).
| Preference for attestation conveyance ( | |
| Array of credential descriptors so that the authenticator can avoid creating duplicate ones. | |
|
| Filter available authenticators. If you want an authenticator attached to the device, use " |
| Determine whether authenticator local user verification is " | |
| Use |
OK, you should now be able to register a new credential and display information about those registered credentials. You may try it on your live website.
We now have a credential registered and ready to use it as a way to authenticate the user. Let's add re-auth functionality to the website. Here's the user experience:
As soon as a user lands on /reauth
, the website asks for re-auth using a fingerprint. When the user succeeds to authenticate, forward the user to /home
, otherwise fallback to use the existing form to enter and submit a password.
authenticate()
function:Let's create a function called authenticate()
which verifies user identity using a fingerprint. We'll be adding JavaScript code here.
export const authenticate = async (opts) => {
};
/auth/signinRequest
Before authenticating, let's examine if the user has a stored credential id and set it as a query param if they do. By providing a credential id along with other options, the server can provide relevant allowCredentials
and this will make user verification reliable.
let url = '/auth/signinRequest';
const credId = localStorage.getItem(`credId`);
if (credId) {
url += `?credId=${encodeURIComponent(credId)}`;
}
Before asking the user to authenticate, ask the server to send back a challenge and other parameters. Call _fetch()
with opts
as an argument to send a POST request to the server.
const options = await _fetch(url, opts);
Here's an example options you should be receiving (aligns with PublicKeyCredentialRequestOptions
).
{
"challenge": "...",
"timeout": 1800000,
"rpId": "webauthn-codelab.glitch.me",
"userVerification": "required",
"allowCredentials": [
{
"id": "...",
"type": "public-key",
"transports": [
"internal"
]
}
]
}
Most important option here is allowCredentials
. When you receive options from the server, allowCredentials
should have one of the following statuses:
Status | Explanation |
An empty array | There are no credentials stored to the server. |
Single object in an array | The specified credential id matched one of credentials stored to the server. |
Multiple objects in an array | No credential id was specified. |
Let's skip WebAuthn when allowCredentials
is an empty array.
if (options.allowCredentials.length === 0) {
console.info('No registered credentials found.');
return Promise.resolve(null);
}
Because these options are delivered encoded in order to go through HTTP protocol, you have to convert some parameters back to binary - specifically, challenge
and id
s included in allowCredentials
array:
options.challenge = base64url.decode(options.challenge);
for (let cred of options.allowCredentials) {
cred.id = base64url.decode(cred.id);
}
And finally call the navigator.credentials.get()
method in order to verify the user's identity using a fingerprint sensor or a screenlock.
const cred = await navigator.credentials.get({
publicKey: options
});
Once the user verifies their identity, you should be receiving a credential object you can send to the server and authenticate the user.
/auth/signinResponse
Here's an example credential object you should have received.
{
"id": "...",
"type": "public-key",
"rawId": "...",
"response": {
"clientDataJSON": "...",
"authenticatorData": "...",
"signature": "...",
"userHandle": ""
}
}
Again, encode the binary parameters of the credential so that it can be delivered to the server as a string.
const credential = {};
credential.id = cred.id;
credential.type = cred.type;
credential.rawId = base64url.encode(cred.rawId);
if (cred.response) {
const clientDataJSON =
base64url.encode(cred.response.clientDataJSON);
const authenticatorData =
base64url.encode(cred.response.authenticatorData);
const signature =
base64url.encode(cred.response.signature);
const userHandle =
base64url.encode(cred.response.userHandle);
credential.response = {
clientDataJSON,
authenticatorData,
signature,
userHandle
};
}
Don't forget to store the credential id locally so that we can use it for authentication when the user comes back.
localStorage.setItem(`credId`, credential.id);
Finally, send the object to the server and if it returns HTTP code 200, consider the user has been successfully signed-in.
return await _fetch(`/auth/signinResponse`, credential);
Congratulations, you now have the complete authencation()
function!
When the user comes back, we'd like them to reauth as easily and securely as possible. This is where biometric authentication shines. However, there are cases where:
That is why it's always important that you provide other sign-in options as fallback, and in this codelab we'll use the form based password solution.
Let's add a UI to show an authentication button that invokes the biometric authentication in addition to the password form. We'll use the hidden
class to selectively show and hide one of them depending on the user's state.
<div id="uvpa_available" class="hidden">
<h2>
Verify your identity
</h2>
<div>
<mwc-button id="reauth" raised>Authenticate</mwc-button>
</div>
<div>
<mwc-button id="cancel">Sign-in with password</mwc-button>
</div>
</div>
Also, append class="hidden"
to the form.
<form id="form" method="POST" action="/auth/password" class="hidden">
If one of the following conditions doesn't meet, the user has no choice than signing in with a password:
Selectively show the authentication button or hide it.
if (window.PublicKeyCredential) {
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
.then(UVPAA => {
if (UVPAA && localStorage.getItem(`credId`)) {
document
.querySelector('#uvpa_available')
.classList.remove('hidden');
} else {
form.classList.remove('hidden');
}
});
} else {
form.classList.remove('hidden');
}
The user should also be able to choose to sign-in with a password. By clicking on "Sign-in with password", show the password form and hide the authentication button.
const cancel = document.querySelector('#cancel');
cancel.addEventListener('click', e => {
form.classList.remove('hidden');
document
.querySelector('#uvpa_available')
.classList.add('hidden');
});
Let's finally enable the biometric authentication. Import the authenticate
function from client.js
we created earlier.
import { _fetch, authenticate } from '/client.js';
Invoke authenticate()
when the "Authenticate" button is pressed to start the biometric authentication. Make sure that a failure on biometric authentication will fallback to the password form.
const button = document.querySelector('#reauth');
button.addEventListener('click', e => {
authenticate({}).then(user => {
if (user) {
location.href = '/home';
} else {
throw 'User not found.';
}
}).catch(e => {
console.error(e.message || e);
alert('Authentication failed. Use password to sign-in.');
form.classList.remove('hidden');
document.querySelector('#uvpa_available').classList.add('hidden');
});
});
You have successfully finished the codelab - Your first WebAuthn.
You can learn both by trying out the Your first Android FIDO2 API codelab!
Special thanks to Yuriy Ackermann from FIDO Alliance for your help.