Registration with nevisAuth
In this use-case, the FIDO2 Registration is implemented with ScriptStates in nevisAuth. For a generic description and prerequisits, please see Registration.
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.
Technical Flow
The initial authentication depends on the custom integration as well as the existing means of authentication the end user possesses. Thus, the initial authentication steps are not explained in detail but only referenced.
The following flow is a simple compact example. Depending on requirements, changes or different approaches might be required. The key fix points are:
- WebAuthn API in the browser.
- FIDO2 HTTP API in nevisFIDO.
- nevisAuth must be aware of the status of the FIDO2 registration.
The user initiates the FIDO2 registration.
The ScriptState returns a GuiDescriptor configuring the nevisLogRend template to include the JS client.
The JS client submits an empty form POST to get the
ServerPublicKeyCredentialCreationOptionsResponse
. In this example implementation theServerPublicKeyCredentialCreationOptionsRequest
is generated by the Groovy ScriptSate.The
ServerPublicKeyCredentialCreationOptionsRequest
is created byScriptState
and calls the nevisFIDO Options endpoint.Endpoint:
https://<nevisFIDO-host>:<nevisFIDO-port>/nevisfido/fido2/attestation/options
Reference: nevisFIDO Reference Guide
nevisFIDO queries the FIDO2 credentials from nevisIdm.
Challenge is generated and the
ServerPublicKeyCredentialCreationOptionsResponse
is built.A direct response is prepared using the
ServerPublicKeyCredentialCreationOptionsResponse
.JS client receives the
ServerPublicKeyCredentialCreationOptionsResponse
.JS client initiates a registration using the received Options response via the WebAuthn API.
Dialog presented to the user by the browser to confirm the credential creation.
The user approves the credential creation.
The WebAuthn API generates the keys and returns an attestation to the JS client.
JS client submits attestation to the backend.
The
ScriptState
in nevisAuth assembles aServerPublicKeyCredentialForRegistration
JSON payload from the form submit and calls the nevisFIDO REST API.Endpoint:
https://<nevisFIDO-host>:<nevisFIDO-port>/nevisfido/fido2/attestation/result
Reference: nevisFIDO Reference Guide
nevisFIDO session lookup. (This session is independent of the nevisAuth session)
Incoming request validated according to the WebAuthn specification.
nevisFIDO stores the FIDO2 credential in nevisIDM, such that it is related to the username.
FIDO2 session is updated to reflect the current status.
ServerResponse is returned stating the status of the FIDO2 authentication. At this point the FIDO2 registration is completed.
ScriptState
transitions took
.
Integration
Overview
The following diagram illustrates the integrated flow, as well as the main points of configuration.
General Considerations
- A legacy authentication should preceed FIDO2 Registration, which ensures the user is authenticated before it can create new credentials. This means the following AuthStates should be created as part of a
stepup
authentication flow in nevisAuth. - The ScriptState assumes that an
IdmGetPropertiesState
AuthState is executed in the flow before, which maps the userId received from the browser to an extId. - Everytime you copy and create a file, make sure it is readable by
nvauser
, and is thus accessible by Nevis components.
Disclaimer
The guide assumes the Nevis components nevisProxy, nevisAuth, nevisLogrend, nevisFIDO and nevisIDM are already installed and setup in some configuration, i.e. that the environment already supports use-cases independent of FIDO2.
Integrate FIDO2 Registration with nevisAuth
Copy & paste the groovy script into your nevisAuth instance folder.
FIDO2 Registration ScriptState
/var/opt/nevisauth/<instance>/conf/fido2_registration.groovyimport ch.nevis.esauth.util.httpclient.api.Http
import groovy.json.JsonBuilder
import groovy.json.JsonGenerator
import groovy.json.JsonSlurper
// we cannot use the name cancel and the -bottom is required in ID Cloud for rendering
if (inargs.containsKey('cancel-bottom')) {
response.setResult('cancel')
return
}
if (inargs.containsKey('failed')) {
response.setResult('failed')
return
}
def getUserFriendlyName(String userAgent) {
if (userAgent == null) {
return null
}
def sb = new StringBuilder()
// Check for browser
def browser = null
if (userAgent.contains('Chrome')) {
browser = 'Chrome'
}
else if (userAgent.contains('Firefox')) {
browser = 'Firefox'
}
else if (userAgent.contains('Safari')) {
browser = 'Safari'
}
else {
browser = 'Unknown browser'
}
// Check for operating system
def os = null
if (userAgent.contains('Windows')) {
os = 'Windows'
}
else if (userAgent.contains('Linux')) {
os = 'Linux'
}
else if (userAgent.contains('iOS')) {
os = 'iOS'
}
// Build the string
if (browser != null) {
sb.append(browser)
}
if (os != null) {
if (sb.length() > 0) {
sb.append(' on ')
}
sb.append(os)
}
return sb.toString()
}
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(requestBuilder, json) {
def body = json.toString()
LOG.info("==> Request: ${body}")
def entity = Http.entity().content(body).build()
def fidoRequest = requestBuilder.entity(entity).build()
return fidoRequest.send()
}
def path = getPath()
if (path == null) {
// POST from JavaScript not received
return // the AuthEngine will trigger default and thus the GUI will be rendered
}
String usernameVal = ${username}
if (usernameVal == null) {
LOG.error("missing username. check your authentication flow.")
}
Objects.requireNonNull(usernameVal) // fatal integration error
String displayNameVal = ${displayName}
if (displayNameVal == null) {
LOG.error("missing displayName. check your authentication flow.")
}
Objects.requireNonNull(displayNameVal) // fatal integration error
def baseUrl = "https://" + parameters.get('fido')
def requestBuilder = Http.post().url(baseUrl + path)
.header('Accept', 'application/json')
.header('Content-Type', 'application/json;charset=utf-8')
def generator = new JsonGenerator.Options().excludeNulls().build()
def json = new JsonBuilder(generator)
if (path == '/nevisfido/fido2/attestation/options') {
def name = ${displayName}
json {
"username" usernameVal
"displayName" displayNameVal
"authenticatorSelection" {
"authenticatorAttachment" parameters.get('authenticatorAttachment')
"requireResidentKey" parameters.get('requireResidentKey')
"residentKey" parameters.get('residentKey')
"userVerification" parameters.get('userVerification')
}
"attestation" parameters.get('attestation')
}
def fidoResponse = post(requestBuilder, json)
def responseCode = fidoResponse.code()
def responseText = fidoResponse.bodyAsString()
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
}
def userAgentVal = request.getHttpHeader('User-Agent')
def userFriendlyNameVal = getUserFriendlyName(userAgentVal)
if (path == '/nevisfido/fido2/attestation/result') {
json {
"id" inargs['id']
"type" inargs['type']
response {
"clientDataJSON" inargs['response.clientDataJSON']
"attestationObject" inargs['response.attestationObject']
}
"userFriendlyName" userFriendlyNameVal
"userAgent" userAgentVal
}
def fidoResponse = post(requestBuilder, json)
def responseCode = fidoResponse.code()
def responseText = fidoResponse.bodyAsString()
LOG.info("<== Response: ${responseCode} : ${responseText}")
if (responseCode == 200 && new JsonSlurper().parseText(responseText).status == 'ok') {
response.setResult('ok')
return
}
}
response.setError(1, "FIDO2 onboarding failed")Create the ScriptState in the nevisAuth configuration:
FIDO2 Registration ScriptState configuration
/var/opt/nevisauth/<instance>/conf/esauth4.xml<AuthState name="Fido2Registration"
class="ch.nevis.esauth.auth.states.scripting.ScriptState" final="false">
<ResultCond name="ok" next="<next-state>"/>
<Response value="AUTH_CONTINUE">
<Gui name="fido2_registration" label="title.login"/>
</Response>
<property name="parameter.fido" value="<fido_host:fido_port>"/>
<property name="script" value="file:///var/opt/nevisauth/<instance>/conf/fido2_registration.groovy"/>
</AuthState>The GUI has an element named
fido2_registration
, this will help nevisLogrend to realize the FIDO2 Client Javascript is to be served to the client side.Substitute data from your environment into this configuration block, such as the
fido-host
andfido-port
which together point to your nevisFIDO instance, and theinstance
which is the name of the nevisAuth instance.Integrate this AuthState into your authentication flow by making another AuthState or Domain refer to it, then set the
next-state
. If no further processing is required after FIDO2 Registration,next-state
should point to anAuthDone
AuthState.noteMake sure to choose a step-up authentication flow to integrate into, and make sure the
extId
of the user is already loaded into the session. This latter can be ensured with theIdmGetPropertiesState
AuthState for example.
Copy the Javascript files into
/var/opt/nevislogrend/<instance>/data/applications/def/resources
or into another application inside nevisLogrend.FIDO2 Client Javascript
/var/opt/nevislogrend/<instance>/data/applications/def/resources/fido2_registration.jsfunction dispatch(name) {
// 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, name, "true");
document.body.appendChild(form);
form.submit();
}
async function attestation(options) {
let credential;
try {
credential = await navigator.credentials.create({
"publicKey": options
});
}
// cancel and timeout can occur besides error
catch (error) {
console.error(`Failed to create WebAuthn credential: ${error}`);
throw error;
}
// 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();
}
function start() {
if (!isWebAuthnSupportedByTheBrowser()) {
dispatch("unsupported");
return;
};
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;
}
return attestation(options);
}).catch((error) => {
console.log('Error during FIDO2 onboarding: ' + error);
dispatch("failed");
});
}FIDO2 utily
/var/opt/nevislogrend/<instance>/data/applications/def/resources/fido2_utils.jsfunction addInput(form, name, value) {
const input = document.createElement("input");
input.name = name;
input.value = value;
form.appendChild(input);
}
/**
* Checks whether WebAuthn is supported by the browser or not.
* @return true if supported, false if it is not supported or not in secure context
*/
function isWebAuthnSupportedByTheBrowser() {
if (window.isSecureContext) {
// This feature is available only in secure contexts in some or all supporting browsers.
if ('credentials' in navigator) {
return true;
}
console.warn('Oh no! This browser does not support WebAuthn.');
return false;
}
console.warn('WebAuthn feature is available only in secure contexts. For testing over HTTP, you can use the origin "localhost".');
return false;
}
/**
* Trigger on cancel pattern of the FIDO2 authentication step.
*
* Provides an alternative when the user decides to
* cancel the fido2 credential operation(create or fetch) or
* the operation fails and the error cannot be handled.
*/
function cancelFido2() {
// 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, "cancel_fido2", "true");
document.body.appendChild(form);
form.submit();
}Base64url encoding library
/var/opt/nevislogrend/<instance>/data/applications/def/resources/base64url.js/*
* 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
}
}
}
})();Configure nevisLogrend
Edit one of the
.vm
configuration files in your nevisLogrend instance. Prefer aheader.vm
or a.vm
file dedicated for script imports.Import scripts into the HTML
/var/opt/nevislogrend/<instance>/data/applications/def/webdata/template/header.vm#if ($gui.name == "fido2_registration")
<script src="<app-data-path>/resources/base64.js"></script>
<script src="<app-data-path>/resources/fido2_utils.js"></script>
<script src="<app-data-path>/resources/fido2_registration.js"></script>
#endConfigure nevisProxy.
The FIDO2 calls of the Javascript now must come through nevisAuth, as the ScriptState is designed to dispatch them to nevisFIDO. To make this happen, subsequent requests must reach the ScriptState.
Enable RecheckAuthentication for your IdentityCreationFilter
<init-param>
<param-name>RecheckAuthentication</param-name>
<param-value>On</param-value>
</init-param>Restart the instances, FIDO2 Registration should be operational!