Skip to main content

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.

note

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.

note

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.
  1. The user initiates the FIDO2 registration.

  2. The ScriptState returns a GuiDescriptor configuring the nevisLogRend template to include the JS client.

  3. The JS client submits an empty form POST to get the ServerPublicKeyCredentialCreationOptionsResponse. In this example implementation the ServerPublicKeyCredentialCreationOptionsRequest is generated by the Groovy ScriptSate.

  4. The ServerPublicKeyCredentialCreationOptionsRequest is created by ScriptState and calls the nevisFIDO Options endpoint.

    Endpoint: https://<nevisFIDO-host>:<nevisFIDO-port>/nevisfido/fido2/attestation/options

    Reference: nevisFIDO Reference Guide

  5. nevisFIDO queries the FIDO2 credentials from nevisIdm.

  6. Challenge is generated and the ServerPublicKeyCredentialCreationOptionsResponse is built.

  7. A direct response is prepared using the ServerPublicKeyCredentialCreationOptionsResponse.

  8. JS client receives the ServerPublicKeyCredentialCreationOptionsResponse.

  9. JS client initiates a registration using the received Options response via the WebAuthn API.

  10. Dialog presented to the user by the browser to confirm the credential creation.

  11. The user approves the credential creation.

  12. The WebAuthn API generates the keys and returns an attestation to the JS client.

  13. JS client submits attestation to the backend.

  14. The ScriptState in nevisAuth assembles a ServerPublicKeyCredentialForRegistration 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

  15. nevisFIDO session lookup. (This session is independent of the nevisAuth session)

  16. Incoming request validated according to the WebAuthn specification.

  17. nevisFIDO stores the FIDO2 credential in nevisIDM, such that it is related to the username.

  18. FIDO2 session is updated to reflect the current status.

  19. ServerResponse is returned stating the status of the FIDO2 authentication. At this point the FIDO2 registration is completed.

  20. ScriptState transitions to ok.

Integration

Overview

The following diagram illustrates the integrated flow, as well as the main points of configuration.

FIDO2 Registration with nevisAuth

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

  1. Copy & paste the groovy script into your nevisAuth instance folder.

    FIDO2 Registration ScriptState
    /var/opt/nevisauth/<instance>/conf/fido2_registration.groovy
    import 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")
  2. 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.

    1. Substitute data from your environment into this configuration block, such as the fido-host and fido-port which together point to your nevisFIDO instance, and the instance which is the name of the nevisAuth instance.

    2. 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 an AuthDone AuthState.

      note

      Make 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 the IdmGetPropertiesState AuthState for example.

  3. 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.js
    function 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.js
    function 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
    }
    }
    }
    })();
  4. Configure nevisLogrend

    Edit one of the .vm configuration files in your nevisLogrend instance. Prefer a header.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>
    #end
  5. Configure 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>
  6. Configure FIDO2 at nevisFIDO.

  7. Configure nevisIDM.

  8. Restart the instances, FIDO2 Registration should be operational!