FIDO2 AuthStates and JavaScript Client
Nevis offers Groovy ScriptState examples for the FIDO2 authentication / registration flows. We recommend to use nevisAdmin4 as all described here comes out of the box there.
The presented example solution simplifies nevisAuth configuration by hiding most of the logic in the Groovy ScriptState including the HTTP API calls to nevisFIDO.
The Nevis Identity Suite provides an nevisAdmin4 pattern named nevisFIDO FIDO2 Self-Admin App which contains a FIDO2 / WebAuthn capable client JavaScript. Use it or build upon it, but note that it is considered to be experimental, we might change it or remove it in the future.
High level integration steps
- Copy the ScriptStates into the nevisAuth instance/conf directory.
- Make sure they are readable by nvauser.
- Configure nevisAuth in the esauth4.xml.
- Copy Js files into the nevisLogRend template directory.
- Make sure they are readable by nvauser.
- Include the JavaScript files in the velocity templates.
General Considerations
The ScriptStates assumes a IdmGetPropertiesState
AuthState is executed in the flow before, which maps the userId received from the browser to an extId.
FIDO2 Groovy ScriptStates
These AuthStates require a nevisFIDO server as a connection that is configured to support FIDO2.
FIDO2 credential check
The IdmCredStatusCheckState AuthState currently does not support FIDO2 credentials. In case you would like to check in the flow if a FIDO2 credential exists you can use the REST api of nevisIdm.
The usage of the IdmRestClient requires the nevisidmcl package to be on the classpath.
Configuration
<AuthState name="CheckFido2Credential" class="ch.nevis.esauth.auth.states.scripting.ScriptState"
classPath="/opt/nevisidmcl/nevisauth/lib">
<ResultCond name="default" next="Fido2Authentication"/>
<ResultCond name="credentialNotFound" next="Fido2Registration"/>
<property name="parameter.idm" value="<nevisidm-host>:<nevisidm-port>"/>
<!-- The nevisIdm clientId has to be the same as what is configured in nevisFIDO for credential-repository.client-id -->
<property name="parameter.idmClientId" value="100"/>
<property name="script" value="file:///var/opt/nevisauth/default/scripts/fido2_cred_check.groovy"/>
</AuthState>
ScriptState
import ch.nevis.idm.client.IdmRestClient
import ch.nevis.idm.client.IdmRestClientFactory
import groovy.json.*
def hasFIDO2(client, userBaseUrl) {
LOG.debug("credential query: " + userBaseUrl + '/fido2')
try {
def idmResponse = client.get(userBaseUrl + '/fido2')
LOG.debug("credential response: " + idmResponse)
def json = new JsonSlurper().parseText(idmResponse)
return json && json.items
}
catch(Exception e) {
LOG.error('failed to get FIDO 2 credentials: ' + e.getMessage())
}
}
def client = IdmRestClientFactory.get(parameters)
def userExtId = session['ch.nevis.idm.User.extId']
String baseUrl = "https://${parameters.get('idm')}"
String credentialBaseUrl = "${baseUrl}/nevisidm/api/core/v1/${parameters.get('idmClientId')}/users/${userExtId}"
if (hasFIDO2(client, credentialBaseUrl)) {
response.setResult('default')
}
else {
response.setResult('credentialNotFound')
}
Registration ScriptState
Handles aspects of the FIDO2 registration ceremony:
- Render self executing JS which will execute the following steps.
- Starts the ceremony by handling the incoming JS request and call the nevisFIDO Options endpoint.
- Finishes the ceremony by handling the incoming JS request and call the nevisFIDO Attestation endpoint.
The current implementation does not send any AuthenticatorSelectionCriteria in the Options call, it relies on the default values from nevisFIDO:
- requireResidentKey = false
- residentKeyRequirement = DISCOURAGED
- userVerificationRequirement = PREFERRED
- authenticatorAttachment = null
When this AuthState is executed, a GUI with the name fido2_onboard
will be sent to nevisLogrend for rendering. This could then be the condition which triggers to include our JavaScript in the HTML.
Configuration
<AuthState name="Fido2Registration"
class="ch.nevis.esauth.auth.states.scripting.ScriptState" final="false">
<ResultCond name="ok" next="<your-next-state>"/>
<Response value="AUTH_CONTINUE">
<Gui name="fido2_onboard" label="title.login"/>
</Response>
<property name="parameter.fido" value="<fido_host:fido_port>"/>
<property name="script" value="file:///var/opt/nevisauth/<instance>/conf/fido2_onboard.groovy"/>
</AuthState>
ScriptState
import groovy.json.JsonBuilder
import groovy.json.JsonSlurper
def showGui() {
response.setGuiName('fido2_onboard') // name is the trigger for including the fido2_onboard.js
response.setGuiLabel('title.login')
response.addInfoGuiField('info', 'info.login', null)
if (notes.containsKey('lasterrorinfo') || notes.containsKey('lasterror')) {
response.addErrorGuiField('lasterror', notes['lasterrorinfo'], notes['lasterror'])
}
}
def getPath() {
if (inargs.containsKey('path')) { // form POST
return inargs['path']
}
if (inargs.containsKey('o.path.v')) { // AJAX POST
return inargs['o.path.v']
}
return null
}
def post(connection, json) {
connection.setRequestMethod("POST")
connection.setRequestProperty("Content-Type", "application/json")
connection.setDoOutput(true) // required to write body
String body = json.toString()
LOG.info("==> Request: ${body}")
connection.getOutputStream().write(body.getBytes())
}
// the following session variables are supposed to be set by a IdmGetPropertiesState
def userExtId = session['ch.nevis.idm.User.extId']
def userEmail = session['ch.nevis.idm.User.email']
// without the user extId this script won't work and we can fail with a System Error
Objects.requireNonNull(userExtId)
def path = getPath()
if (path == null) {
showGui() // POST from JavaScript not received
return
}
def connection = new URL("https://${parameters.get('fido')}${path}").openConnection()
def json = new JsonBuilder()
if (path == '/nevisfido/fido2/attestation/options') {
json {
"username" userExtId
"displayName" userEmail
}
post(connection, json)
def responseCode = connection.responseCode
def responseText = connection.inputStream.text
LOG.info("<== Response: ${responseCode} : ${responseText}")
response.setContent(responseText) // return response from nevisFIDO "as-is"
response.setContentType('application/json')
response.setHttpStatusCode(200)
response.setIsDirectResponse(true)
return
}
if (path == '/nevisfido/fido2/attestation/result') {
def userHandleValue = userExtId.getBytes().encodeBase64Url().toString()
LOG.info("encoded userHandle: ${userHandleValue}")
json {
"id" inargs['id']
"type" inargs['type']
response {
"clientDataJSON" inargs['response.clientDataJSON']
"attestationObject" inargs['response.attestationObject']
}
}
post(connection, json)
def responseCode = connection.responseCode
def responseText = connection.inputStream.text
LOG.info("<== Response: ${responseCode} : ${responseText}")
if (responseCode == 200 && new JsonSlurper().parseText(responseText).status == 'ok') {
response.setResult('ok')
return
}
}
response.setError(1, "FIDO2 onboarding failed")
showGui()
Authentication ScriptState
Handles aspects of the FIDO2 authentication ceremony:
- Render self executing JS which will execute the following steps.
- Starts the ceremony by handling the incoming JS request and call the nevisFIDO Options endpoint.
- Finishes the ceremony by handling the incoming JS request and call the nevisFIDO Assertion endpoint.
The current implementation does not send any UserVerification in the Options call, it relies on the default value PREFERRED from nevisFIDO.
When this AuthState is executed, a GUI with the name fido2_auth
will be sent to nevisLogrend for rendering. This could then be the condition which triggers to include our JavaScript in the HTML.
Configuration
<AuthState name="Fido2Authentication"
class="ch.nevis.esauth.auth.states.scripting.ScriptState" final="false">
<ResultCond name="ok" next="<your-next-state>"/>
<Response value="AUTH_CONTINUE">
<Gui name="fido2_auth" label="title.login"/>
</Response>
<property name="parameter.fido" value="<fido_host:fido_port>"/>
<property name="script" value="file:///var/opt/nevisauth/<instance>/conf/fido2_auth.groovy"/>
</AuthState>
ScriptState
import groovy.json.JsonBuilder
import groovy.json.JsonSlurper
def showGui() {
response.setGuiName('fido2_auth') // name is the trigger for including the fido2_auth.js
response.setGuiLabel('title.login')
response.addInfoGuiField('info', 'info.login', null)
if (notes.containsKey('lasterrorinfo') || notes.containsKey('lasterror')) {
response.addErrorGuiField('lasterror', notes['lasterrorinfo'], notes['lasterror'])
}
}
def getPath() {
if (inargs.containsKey('path')) { // form POST
return inargs['path']
}
if (inargs.containsKey('o.path.v')) { // AJAX POST
return inargs['o.path.v']
}
return null
}
def post(connection, json) {
connection.setRequestMethod("POST")
connection.setRequestProperty("Content-Type", "application/json")
connection.setDoOutput(true) // required to write body
String body = json.toString()
LOG.info("==> Request: ${body}")
connection.getOutputStream().write(body.getBytes())
}
// for security reasons we always take the extId of the user from the session, never from the request
// this session variable is supposed to be set by a IdmGetPropertiesState
def userExtId = session['ch.nevis.idm.User.extId']
// without the user extId this script won't work and we can fail with a System Error
Objects.requireNonNull(userExtId)
def path = getPath()
if (path == null) {
showGui() // POST from JavaScript not received
return
}
def connection = new URL("https://${parameters.get('fido')}${path}").openConnection()
def json = new JsonBuilder()
if (path == '/nevisfido/fido2/attestation/options') {
json {
"username" userExtId
}
post(connection, json)
def responseCode = connection.responseCode
def responseText = connection.inputStream.text
LOG.info("<== Response: ${responseCode} : ${responseText}")
response.setContent(responseText) // return response from nevisFIDO "as-is"
response.setContentType('application/json')
response.setHttpStatusCode(200)
response.setIsDirectResponse(true)
return
}
if (path == '/nevisfido/fido2/assertion/result') {
def userHandleValue = userExtId.getBytes().encodeBase64Url().toString()
LOG.info("encoded userHandle: ${userHandleValue}")
json {
"id" inargs['id']
"type" inargs['type']
response {
"clientDataJSON" inargs['response.clientDataJSON']
"authenticatorData" inargs['response.authenticatorData']
"signature" inargs['response.signature']
"userHandle" userHandleValue
}
}
post(connection, json)
def responseCode = connection.responseCode
def responseText = connection.inputStream.text
LOG.info("<== Response: ${responseCode} : ${responseText}")
if (responseCode == 200 && new JsonSlurper().parseText(responseText).status == 'ok') {
response.setResult('ok')
return
}
}
response.setError(1, "FIDO2 authentication failed")
showGui()
NevisLogRend resources
The following JavaScript files can be used to initiate and execute the FIDO2 ceremonies on the browser side.
Registration JavaScript
Self executing JavaScript handling the Registration Ceremony.
- Calls the nevisFIDO Options endpoint via nevisProxy and nevisAuth.
- The user identifier is transmitted to nevisFIDO by the ScriptState in nevisAuth. There is no need to send any data here in the JavaScript.
- Base64URL decodes the necessary fields of the Options.
- Calls the WebAuthn api to create the credential via the browser using the received options
- Calls the nevisFIDO attestation endpoint via nevisProxy and nevisAuth to finish the registration.
- For technical reasons the JavaScript do not submit a JSON payload, but a hidden form containing the data.
Configuration:
- Include the JavaScripts above in the nevisLogRend velocity templates:
#if ($gui.name == "fido2_onboard")
<script src="${login.appDataPath}/resources/base64.js"></script>
<script src="${login.appDataPath}/resources/fido2_onboard.js"></script>
#end
JavaScript
(function() {
function onboard() {
const request = {};
request.path = "/nevisfido/fido2/attestation/options";
// calling nevisFIDO through nevisAuth on current URL using AJAX
fetch("", {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(request)
}).then(res => {
res.json().then(options => {
options.user.id = base64url.decode(options.user.id);
options.challenge = base64url.decode(options.challenge);
if (options.excludeCredentials != null) {
options.excludeCredentials = options.excludeCredentials.map((c) => {
c.id = base64url.decode(c.id);
return c;
});
}
if (options.authenticatorSelection.authenticatorAttachment === null) {
options.authenticatorSelection.authenticatorAttachment = undefined;
}
attestation(options);
});
}).catch((err) => console.error("error: ", err));
}
function addInput(form, name, value) {
const input = document.createElement("input");
input.name = name;
input.value = value;
form.appendChild(input);
}
async function attestation(options) {
const credential = await navigator.credentials.create({ "publicKey": options });
// as this is the last call we have to do a top-level request instead of AJAX
const form = document.createElement("form");
form.method = "POST";
form.style.display = "none";
addInput(form, "path", "/nevisfido/fido2/attestation/result")
addInput(form, "id", credential.id);
addInput(form, "type", credential.type);
addInput(form, "response.clientDataJSON", base64url.encode(credential.response.clientDataJSON));
addInput(form, "response.attestationObject", base64url.encode(credential.response.attestationObject));
document.body.appendChild(form);
form.submit();
}
onboard();
})();
Authentication JavaScript
Self executing JavaScript handling the Authentication Ceremony.
- Calls the nevisFIDO Options endpoint via nevisProxy and nevisAuth.
- The user identifier is transmitted to nevisFIDO by the ScriptState in nevisAuth. There is no need to send any data here in the JavaScript.
- Base64URL decodes the necessary fields of the Options.
- Calls the WebAuthn api to query the assertion from the browser using the received options
- Calls the nevisFIDO assertion endpoint via nevisProxy and nevisAuth to finalize the authentication.
- For technical reasons the JavaScript do not submit a JSON payload, but a hidden form containing the data.
Configuration
- Include the JavaScripts above in the nevisLogRend velocity templates:
#if ($gui.name == "fido2_auth")
<script src="${login.appDataPath}/resources/base64.js"></script>
<script src="${login.appDataPath}/resources/fido2_auth.js"></script>
#end
JavaScript
(function() {
function authenticate() {
const request = {};
request.path = "/nevisfido/fido2/attestation/options";
// calling nevisFIDO through nevisAuth on current URL using AJAX
fetch("", {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(request)
}).then(res => {
res.json().then(options => {
options.challenge = base64url.decode(options.challenge);
options.allowCredentials = options.allowCredentials.map((c) => {
c.id = base64url.decode(c.id);
return c;
});
assertion(options);
});
}).catch((err) => console.error("error: ", err));
}
function addInput(form, name, value) {
const input = document.createElement("input");
input.name = name;
input.value = value;
form.appendChild(input);
}
async function assertion(options) {
const assertion = await navigator.credentials.get({ "publicKey": options });
// as this is the last call we have to do a top-level request instead of AJAX
const form = document.createElement("form");
form.method = "POST";
form.style.display = "none";
addInput(form, "path", "/nevisfido/fido2/assertion/result")
addInput(form, "id", assertion.id);
addInput(form, "type", assertion.type);
addInput(form, "response.clientDataJSON", base64url.encode(assertion.response.clientDataJSON));
addInput(form, "response.authenticatorData", base64url.encode(assertion.response.authenticatorData));
addInput(form, "response.signature", base64url.encode(assertion.response.signature));
document.body.appendChild(form);
form.submit();
}
authenticate();
})();
Base64URL / ArrayBuffer handling
Some of the JSON payload attributes require encoding or decoding of Base64URL values into ArrayBuffers. A minimal OpenSource JavaScript library exists which is able to perform these encoding and decoding operations. For the sake of completeness, the complete library has been included here.
The browser-local WebAuthn API itself uses ArrayBuffers whilst the HTTP APIs use Base64URL encoded representations of the ArrayBuffer objects.
/*
* Base64URL-ArrayBuffer
* https://github.com/herrjemand/Base64URL-ArrayBuffer
*
* Copyright (c) 2017 Yuriy Ackermann <[email protected]>
* Copyright (c) 2012 Niklas von Hertzen
* Licensed under the MIT license.
*
*/
(function() {
"use strict";
var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
// Use a lookup table to find the index.
var lookup = new Uint8Array(256);
for (var i = 0; i < chars.length; i++) {
lookup[chars.charCodeAt(i)] = i;
}
var encode = function(arraybuffer) {
var bytes = new Uint8Array(arraybuffer),
i, len = bytes.length, base64 = "";
for (i = 0; i < len; i+=3) {
base64 += chars[bytes[i] >> 2];
base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)];
base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)];
base64 += chars[bytes[i + 2] & 63];
}
if ((len % 3) === 2) {
base64 = base64.substring(0, base64.length - 1);
} else if (len % 3 === 1) {
base64 = base64.substring(0, base64.length - 2);
}
return base64;
};
var decode = function(base64) {
var bufferLength = base64.length * 0.75,
len = base64.length, i, p = 0,
encoded1, encoded2, encoded3, encoded4;
var arraybuffer = new ArrayBuffer(bufferLength),
bytes = new Uint8Array(arraybuffer);
for (i = 0; i < len; i+=4) {
encoded1 = lookup[base64.charCodeAt(i)];
encoded2 = lookup[base64.charCodeAt(i+1)];
encoded3 = lookup[base64.charCodeAt(i+2)];
encoded4 = lookup[base64.charCodeAt(i+3)];
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
}
return arraybuffer;
};
/**
* Exporting and stuff
*/
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
module.exports = {
'encode': encode,
'decode': decode
}
} else {
if (typeof define === 'function' && define.amd) {
define([], function() {
return {
'encode': encode,
'decode': decode
}
});
} else {
window.base64url = {
'encode': encode,
'decode': decode
}
}
}
})();
Additional Hints
Browser WebAuthn Support
The JavaScript Client requires a WebAuthn capable browser to interact with the WebAuthn JavaScript API provided by the browser. Thus, we recommend you to check whether the users' browser supports WebAuthn and incorporate this into your product. You can check whether a browser supports WebAuthn with the PublicKeyCredential object, like this:
if (window.PublicKeyCredential) {
console.log("WebAuthn Supported.");
} else {
console.log("WebAuthn not supported.");
}
Detecting Platform Authenticator support
FIDO2 and WebAuthn support two types of authenticators, platform authenticators and roaming authenticators.
- Platform authenticators are authentication mechanisms built into devices. This could include things like Windows Hello, Apple's Touch ID or Face ID.
- Roaming authenticators are separate authentication hardware keys like Yubikeys or Google's Titan Keys.
Because roaming authenticators are expensive and require a special device, we expect platform authenticators to be the main way we see WebAuthn adoption in consumer identity.
You can detect if a platform authenticator is available with the PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() method. Run the following in a browser console:
if (window.PublicKeyCredential) {
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
.then((available) => {
if (available) {
console.log("Supported.");
} else {
console.log(
"WebAuthn supported, Platform Authenticator *not* supported."
);
}
})
.catch((err) => console.log("Something went wrong."));
} else {
console.log("WebAuthn not supported.");
}
Resources
For additional information, visit the official resources such as: