FIDO2 adds an extra layer of security to your application. FIDO keys are the only way to guard against online phishing attacks. FIDO is also safe in case of a data breach since only the public keys are exposed. As FIDO keys are physical devices or platform-integrated interfaces, user interaction has to be present to authenticate themself.

Fido2 is the umbrella term and branding of two new w3c standards: WebAuthn and CTAP2. WebAuthn is the JS API that allows browser to talk to the operating system to generate assertions and CTAP2 is the API that allows the operating system to talk to Authenticators (usb security keys etc)

FEITIAN Technologies Co., Ltd. has many FIDO key solutions, and they were kind enough to send me the FIDO2 NFC K9 to review. I will use this key as an MFA authenticator for an ASP.NET Core application.

FEITIAN ePass FIDO2 FIDO U2F USB-A

Before diving into the code, let's clarify some terms.

WebAuthn

The Web Authentication API (WebAuthn) is a specification written by the W3C and FIDO. The API allows servers to register and authenticate users using public key cryptography instead of passwords.

It allows servers to integrate with strong authenticators. Instead of a password, a private-public key pair (a credential) is created for a website. The private key is stored securely on the user's device; a public key and randomly generated credential ID are sent to the server for storage. The server can then use that public key to prove the user's identity.

Authenticator

A cryptographic entity existing in hardware or software that can register a user with a given Relying Party and later assert possession of the registered public key credential, and optionally verify the user, when requested by the Relying Party.

Registration

The ceremony where a user, a Relying Party, and the user's client (containing at least one authenticator) work to create a public key credential and associate it with the user's Relying Party account.

Relying Party

In the context of the WebAuthn API, a relying party identifier is a valid domain string identifying the WebAuthn Relying Party on whose behalf a given registration or authentication ceremony is being performed.

Attestation

Attestation is a statement serving to bear witness, confirm, or authenticate. In the WebAuthn context, attestation is employed to attest to the provenance of an authenticator and the data it emits, including, for example, credential IDs, credential key pairs, signature counters, etc.

Assertion

When a user chooses to log into a service, the server sends a challenge, and the authenticator signs over it with a key pair previously registered to that service. This creates an assertion. Unlike the attestation, the assertion format is always the same regardless of the device used.

The assertion is returned through the WebAuthn API as the AuthenticatorAssertionResponse.

Workflow

In a regular ASP.NET Core application configured with the Identity platform, users are typically registered using the IdentityUser entity in the AspNetUser table. For the sake of simplicity, this post is not focused on the Identity aspect of the application. It only demonstrates the idea of enabling  MFA for a pre-defined user.

TL;DR. Download the source code from here,

https://github.com/fiyazbinhasan/AspNetCoreFido2MFA

Run the following command before running the sample,

dotnet-ef database update

The WebAuthn library used for this demo is fido2-net-lib. In an ASP.NET Core web application (Razor Pages), add these packages,

Install-Package Fido2
Install-Package Fido2.AspNet

The required Fido services are registered in the Program.cs file,

builder.Services.AddMemoryCache();
builder.Services.AddDistributedMemoryCache();

builder.Services.AddSession(options =>
{
    options.IdleTimeout = TimeSpan.FromMinutes(2);
    options.Cookie.HttpOnly = true;
    options.Cookie.SameSite = SameSiteMode.Unspecified;
});

builder.Services.AddFido2(options =>
    {
        options.ServerDomain = builder.Configuration["Fido2:ServerDomain"];
        options.ServerName = "FIDO2 Test";
        options.Origins = builder.Configuration.GetSection("Fido2:Origins").Get<HashSet<string>>();
        options.TimestampDriftTolerance = builder.Configuration.GetValue<int>("Fido2:TimestampDriftTolerance");
        options.MDSCacheDirPath = builder.Configuration["Fido2:MDSCacheDirPath"];
    })
    .AddCachedMetadataService(config =>
    {
        config.AddFidoMetadataRepository(httpClientBuilder =>
        {
            //TODO: any specific config you want for accessing the MDS
        });
    });
Program.cs

AddFido2 service reads configuration from the appsettings.json file where the necessary information is provided to make the web server act as a Relying Party (RP)

"Fido2": {
  "ServerDomain": "localhost",
  "Origins": [ "https://localhost:44388", "https://localhost:7123" ],
  "TimestampDriftTolerance": 300000
}
appsettings.json

Rather than storing the credentials and user registration info in memory, a relational database is configured with two entities, i.e. Fido2User and StoredCredential. Entity framework is used to configure these two entities,

using System.Text;
using AspNetCoreFido2MFA.Models;
using Fido2NetLib;
using Microsoft.EntityFrameworkCore;

namespace AspNetCoreFido2MFA.Data;

public class AppDbContext : DbContext
{
    public DbSet<Fido2User> Fido2Users => Set<Fido2User>();
    public DbSet<StoredCredential> StoredCredentials => Set<StoredCredential>();

    public AppDbContext(DbContextOptions<AppDbContext> optionsBuilder) : base(optionsBuilder) 
    {

    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Fido2User>()
            .HasData(new List<Fido2User>()
            {
                new()
                {
                    Id = Encoding.UTF8.GetBytes("fiyazhasan@fido.local"), 
                    DisplayName = "fiyazhasan@fido.local",
                    Name = "fiyazhasan@fido.local"
                }
            });
    }
}
AppDbContext.cs

Note that Fido2User is already available in the Fido2NetLib as well as StoredCredential. But we are using a modified version of StoredCredential,

public class StoredCredential
{
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
    public string UserName { get; set; } = string.Empty;
    public byte[] UserId { get; set; }
    public byte[] PublicKey { get; set; }
    public byte[] UserHandle { get; set; }
    public uint SignatureCounter { get; set; }
    public string CredType { get; set; }
    public DateTime RegDate { get; set; }
    public Guid AaGuid { get; set; }

    [NotMapped]
    public PublicKeyCredentialDescriptor Descriptor {
        get => string.IsNullOrWhiteSpace(DescriptorJson) ? null : JsonSerializer.Deserialize<PublicKeyCredentialDescriptor>(DescriptorJson);
        set => DescriptorJson = JsonSerializer.Serialize(value);
    }

    public string DescriptorJson { get; set; }
}
StoredCredential.cs

Storing the PublicKeyCredentialDescriptor as a JSON string in the DescriptorJson column helps filter public keys easier.

Note that migration on the database creates a pre-defined user with an Id saved as a byte representation of the user's email address.

Registration of a user with a Fido credential is a two-step process.

MakeCredentialOptions

We perform an attestation process to add FIDO2 credentials to an existing user account. It starts with returning options to the client.

MakeCredential

When the client returns a response, we verify and register the credentials.

The following shows two action methods of the Fido2MfaController,

[Route("api/[controller]")]
public class Fido2MfaController : Controller
{
    private readonly IFido2 _fido2;
    private readonly AppDbContext _context;

    public Fido2MfaController(IFido2 fido2, AppDbContext context)
    {
        _fido2 = fido2;
        _context = context;
    }

    private string FormatException(Exception e)
    {
        return $"{e.Message}{(e.InnerException != null ? " (" + e.InnerException.Message + ")" : "")}";
    }

    [HttpPost]
    [Route("/makeCredentialOptions")]
    public async Task<JsonResult> MakeCredentialOptions([FromForm] string username,
        [FromForm] string displayName,
        [FromForm] string attType,
        [FromForm] string authType,
        [FromForm] string userVerification,
        [FromQuery] bool requireResidentKey)
    {
        try
        {
            // 1. Get user from DB by username (in our example, auto create missing users)
            var user = await _context.Fido2Users.SingleOrDefaultAsync(u => u.Name.Equals(username));

            if (user is null)
                throw new ArgumentException("User not found");

            // 2. Get user existing keys by username
            var existingKeys = await _context.StoredCredentials
                .Where(c => c.UserId.SequenceEqual(user.Id))
                .Select(c => c.Descriptor)
                .ToListAsync();

            // 3. Create options
            var authenticatorSelection = new AuthenticatorSelection
            {
                RequireResidentKey = requireResidentKey,
                UserVerification = userVerification.ToEnum<UserVerificationRequirement>()
            };

            if (!string.IsNullOrEmpty(authType))
                authenticatorSelection.AuthenticatorAttachment = authType.ToEnum<AuthenticatorAttachment>();

            var extensions = new AuthenticationExtensionsClientInputs()
            {
                Extensions = true,
                UserVerificationMethod = true,
            };

            var options = _fido2.RequestNewCredential(user, existingKeys, authenticatorSelection,
                attType.ToEnum<AttestationConveyancePreference>(), extensions);

            // 4. Temporarily store options, session/in-memory cache/redis/db
            HttpContext.Session.SetString("fido2.attestationOptions", options.ToJson());

            // 5. return options to client
            return Json(options);
        }
        catch (Exception e)
        {
            return Json(new CredentialCreateOptions {Status = "error", ErrorMessage = FormatException(e)});
        }
    }

    [HttpPost]
    [Route("/makeCredential")]
    public async Task<JsonResult> MakeCredential([FromBody] AuthenticatorAttestationRawResponse attestationResponse,
        CancellationToken cancellationToken)
    {
        try
        {
            // 1. get the options we sent the client
            var jsonOptions = HttpContext.Session.GetString("fido2.attestationOptions");
            var options = CredentialCreateOptions.FromJson(jsonOptions);

            // 2. Create callback so that lib can verify credential id is unique to this user
            async Task<bool> Callback(IsCredentialIdUniqueToUserParams args, CancellationToken token)
            {
                var credentialIdString = Base64Url.Encode(args.CredentialId);

                var cred = await _context.StoredCredentials
                    .Where(c => c.DescriptorJson.Contains(credentialIdString))
                    .FirstOrDefaultAsync(token);

                if (cred is null)
                    return true;
                
                var users = await _context.Fido2Users
                    .Where(u => u.Id.SequenceEqual(cred.UserId))
                    .ToListAsync(token);

                return users.Count <= 0;
            }

            // 2. Verify and make the credentials
            var success = await _fido2.MakeNewCredentialAsync(attestationResponse, options, Callback,
                cancellationToken: cancellationToken);

            // 3. Store the credentials in db
            if (success.Result != null)
            {
                var credential = new StoredCredential
                {
                    Descriptor = new PublicKeyCredentialDescriptor(success.Result.CredentialId),
                    PublicKey = success.Result.PublicKey,
                    UserHandle = success.Result.User.Id,
                    SignatureCounter = success.Result.Counter,
                    CredType = success.Result.CredType,
                    RegDate = DateTime.Now,
                    AaGuid = success.Result.Aaguid,
                    UserId = options.User.Id,
                    UserName = options.User.Name,
                };

                await _context.StoredCredentials.AddAsync(credential, cancellationToken);
            }

            await _context.SaveChangesAsync(cancellationToken);

            // 4. return "ok" to the client
            return Json(success);
        }
        catch (Exception e)
        {
            return Json(new Fido2.CredentialMakeResult(status: "error", errorMessage: FormatException(e),
                result: null));
        }
    }
}    
Fido2MfaController.cs

The following snippet shows the corresponding javascript code that calls these two action methods to complete the Fido key registration process,

document.getElementById('register').addEventListener('submit', handleRegisterSubmit);

async function handleRegisterSubmit(event) {
    event.preventDefault();

    let username = this.username.value;
    let displayName = this.displayName.value;

    // passwordfield is omitted in demo
    // let password = this.password.value;

    // possible values: none, direct, indirect
    let attestation_type = "none";
    // possible values: <empty>, platform, cross-platform
    let authenticator_attachment = "";

    // possible values: preferred, required, discouraged
    let user_verification = "preferred";

    // possible values: true,false
    let require_resident_key = "false";

    // prepare form post data
    var data = new FormData();
    data.append('username', username);
    data.append('displayName', displayName);
    data.append('attType', attestation_type);
    data.append('authType', authenticator_attachment);
    data.append('userVerification', user_verification);

    // send to server for registering
    let makeCredentialOptions;
    try {
        makeCredentialOptions = await fetchMakeCredentialOptions(data, require_resident_key);

    } catch (e) {
        console.error(e);
        let msg = "Something wen't really wrong";
        showErrorAlert(msg);
    }


    console.log("Credential Options Object", makeCredentialOptions);

    if (makeCredentialOptions.status !== "ok") {
        console.log("Error creating credential options");
        console.log(makeCredentialOptions.errorMessage);
        showErrorAlert(makeCredentialOptions.errorMessage);
        return;
    }

    // Turn the challenge back into the accepted format of padded base64
    makeCredentialOptions.challenge = coerceToArrayBuffer(makeCredentialOptions.challenge);
    // Turn ID into a UInt8Array Buffer for some reason
    makeCredentialOptions.user.id = coerceToArrayBuffer(makeCredentialOptions.user.id);

    makeCredentialOptions.excludeCredentials = makeCredentialOptions.excludeCredentials.map((c) => {
        c.id = coerceToArrayBuffer(c.id);
        return c;
    });

    if (makeCredentialOptions.authenticatorSelection.authenticatorAttachment === null) makeCredentialOptions.authenticatorSelection.authenticatorAttachment = undefined;

    console.log("Credential Options Formatted", makeCredentialOptions);

    Swal.fire({
        title: 'Registering...',
        text: 'Tap your security key to finish registration.',
        imageUrl: "/images/securitykey.min.svg",
        showCancelButton: true,
        showConfirmButton: false,
        focusConfirm: false,
        focusCancel: false
    });


    console.log("Creating PublicKeyCredential...");

    let newCredential;
    try {
        newCredential = await navigator.credentials.create({
            publicKey: makeCredentialOptions
        });
    } catch (e) {
        var msg = "Could not create credentials in browser. Probably because the username is already registered with your authenticator. Please change username or authenticator."
        console.error(msg, e);
        showErrorAlert(msg, e);
    }


    console.log("PublicKeyCredential Created", newCredential);

    try {
        registerNewCredential(newCredential);
    } catch (e) {
        showErrorAlert(err.message ? err.message : err);
    }
}

async function fetchMakeCredentialOptions(formData, requireResidentKey) {
    let response = await fetch(`/makeCredentialOptions?requireResidentKey=${requireResidentKey}`, {
        method: 'POST', // or 'PUT'
        body: formData, // data can be `string` or {object}!
        headers: {
            'Accept': 'application/json'
        }
    });

    let data = await response.json();

    return data;
}


// This should be used to verify the auth data with the server
async function registerNewCredential(newCredential) {
    // Move data into Arrays incase it is super long
    let attestationObject = new Uint8Array(newCredential.response.attestationObject);
    let clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON);
    let rawId = new Uint8Array(newCredential.rawId);

    const data = {
        id: newCredential.id,
        rawId: coerceToBase64Url(rawId),
        type: newCredential.type,
        extensions: newCredential.getClientExtensionResults(),
        response: {
            AttestationObject: coerceToBase64Url(attestationObject),
            clientDataJson: coerceToBase64Url(clientDataJSON)
        }
    };

    let response;
    try {
        response = await registerCredentialWithServer(data);
    } catch (e) {
        showErrorAlert(e);
    }

    console.log("Credential Object", response);

    // show error
    if (response.status !== "ok") {
        console.log("Error creating credential");
        console.log(response.errorMessage);
        showErrorAlert(response.errorMessage);
        return;
    }

    // show success 
    Swal.fire({
        title: 'Registration Successful!',
        text: 'You\'ve registered successfully.',
        type: 'success',
        timer: 2000
    });

    // redirect to dashboard?
    //window.location.href = "/dashboard/" + state.user.displayName;
}

async function registerCredentialWithServer(formData) {
    let response = await fetch('/makeCredential', {
        method: 'POST', // or 'PUT'
        body: JSON.stringify(formData), // data can be `string` or {object}!
        headers: {
            'Accept': 'application/json',
            'Content-Type': 'application/json'
        }
    });

    let data = await response.json();

    return data;
}
mfa.register.js

Public keys are stored in the StoredCredentials table when a user is registered. Using the Fido key to sign in the user is also a two-step process.

AssertionOptionsPost

When a user wants to log a user in, we do an assertion based on the registered credentials. First, we create the assertion options and return them to the client.

MakeAssertion

When the client returns a response, we verify it and accept the login.

The following shows two action methods of the Fido2MfaController responsible for signing in the user,

[Route("api/[controller]")]
public class Fido2MfaController : Controller
{
    private readonly IFido2 _fido2;
    private readonly AppDbContext _context;

    public Fido2MfaController(IFido2 fido2, AppDbContext context)
    {
        _fido2 = fido2;
        _context = context;
    }

    private string FormatException(Exception e)
    {
        return $"{e.Message}{(e.InnerException != null ? " (" + e.InnerException.Message + ")" : "")}";
    }

    [HttpPost]
    [Route("/assertionOptions")]
    public async Task<ActionResult> AssertionOptionsPost([FromForm] string username, [FromForm] string userVerification)
    {
        try
        {
            var existingCredentials = new List<PublicKeyCredentialDescriptor>();

            if (!string.IsNullOrEmpty(username))
            {
                // 1. Get user from DB
                var user = await _context.Fido2Users.SingleOrDefaultAsync(u => u.Name.Equals(username));

                if (user is null) throw new ArgumentException("Username was not registered");

                // 2. Get registered credentials from database
                existingCredentials = await _context.StoredCredentials
                    .Where(c => c.UserName.Equals(username))
                    .Select(c => c.Descriptor)
                    .ToListAsync();
            }

            var extensions = new AuthenticationExtensionsClientInputs()
            {
                UserVerificationMethod = true
            };

            // 3. Create options
            var uv = string.IsNullOrEmpty(userVerification)
                ? UserVerificationRequirement.Discouraged
                : userVerification.ToEnum<UserVerificationRequirement>();

            var options = _fido2.GetAssertionOptions(
                existingCredentials,
                uv,
                extensions
            );

            // 4. Temporarily store options, session/in-memory cache/redis/db
            HttpContext.Session.SetString("fido2.assertionOptions", options.ToJson());

            // 5. Return options to client
            return Json(options);
        }

        catch (Exception e)
        {
            return Json(new AssertionOptions {Status = "error", ErrorMessage = FormatException(e)});
        }
    }

    [HttpPost]
    [Route("/makeAssertion")]
    public async Task<JsonResult> MakeAssertion([FromBody] AuthenticatorAssertionRawResponse clientResponse,
        CancellationToken cancellationToken)
    {
        try
        {
            // 1. Get the assertion options we sent the client
            var jsonOptions = HttpContext.Session.GetString("fido2.assertionOptions");
            var options = AssertionOptions.FromJson(jsonOptions);

            // 2. Get registered credential from database
            var credentialIdString = Base64Url.Encode(clientResponse.Id);

            var credential = await _context.StoredCredentials
                .Where(c => c.DescriptorJson.Contains(credentialIdString)).FirstOrDefaultAsync(cancellationToken);

            if (credential is null) throw new Exception("Unknown credentials");

            // 3. Get credential counter from database
            var storedCounter = credential.SignatureCounter;

            // 4. Create callback to check if userhandle owns the credentialId
            async Task<bool> Callback(IsUserHandleOwnerOfCredentialIdParams args, CancellationToken token)
            {
                var storedCredentials = await _context.StoredCredentials
                    .Where(c => c.UserHandle.SequenceEqual(args.UserHandle)).ToListAsync(cancellationToken);
                return storedCredentials.Exists(c => c.Descriptor.Id.SequenceEqual(args.CredentialId));
            }

            // 5. Make the assertion
            var res = await _fido2.MakeAssertionAsync(clientResponse, options, credential.PublicKey, storedCounter,
                Callback,
                cancellationToken: cancellationToken);

            // 6. Store the updated counter
            var cred = await _context.StoredCredentials
                .Where(c => c.DescriptorJson.Contains(credentialIdString)).FirstOrDefaultAsync(cancellationToken);

            cred.SignatureCounter = res.Counter;

            await _context.SaveChangesAsync(cancellationToken);

            // 7. return OK to client
            return Json(res);
        }
        catch (Exception e)
        {
            return Json(new AssertionVerificationResult {Status = "error", ErrorMessage = FormatException(e)});
        }
    }
}
Fido2MfaController.cs

The following snippet shows the corresponding javascript code that calls these two action methods to complete the login process using the Fido key,

document.getElementById('signin').addEventListener('submit', handleSignInSubmit);

async function handleSignInSubmit(event) {
    event.preventDefault();

    let username = this.username.value;

    // passwordfield is omitted in demo
    // let password = this.password.value;


    // prepare form post data
    var formData = new FormData();
    formData.append('username', username);

    // not done in demo
    // todo: validate username + password with server (has nothing to do with FIDO2/WebAuthn)

    // send to server for registering
    let makeAssertionOptions;
    try {
        var res = await fetch('/assertionOptions', {
            method: 'POST', // or 'PUT'
            body: formData, // data can be `string` or {object}!
            headers: {
                'Accept': 'application/json'
            }
        });

        makeAssertionOptions = await res.json();
    } catch (e) {
        showErrorAlert("Request to server failed", e);
    }

    console.log("Assertion Options Object", makeAssertionOptions);

    // show options error to user
    if (makeAssertionOptions.status !== "ok") {
        console.log("Error creating assertion options");
        console.log(makeAssertionOptions.errorMessage);
        showErrorAlert(makeAssertionOptions.errorMessage);
        return;
    }

    // todo: switch this to coercebase64
    const challenge = makeAssertionOptions.challenge.replace(/-/g, "+").replace(/_/g, "/");
    makeAssertionOptions.challenge = Uint8Array.from(atob(challenge), c => c.charCodeAt(0));

    // fix escaping. Change this to coerce
    makeAssertionOptions.allowCredentials.forEach(function (listItem) {
        var fixedId = listItem.id.replace(/\_/g, "/").replace(/\-/g, "+");
        listItem.id = Uint8Array.from(atob(fixedId), c => c.charCodeAt(0));
    });

    console.log("Assertion options", makeAssertionOptions);

    Swal.fire({
        title: 'Logging In...',
        text: 'Tap your security key to login.',
        imageUrl: "/images/securitykey.min.svg",
        showCancelButton: true,
        showConfirmButton: false,
        focusConfirm: false,
        focusCancel: false
    });

    // ask browser for credentials (browser will ask connected authenticators)
    let credential;
    try {
        credential = await navigator.credentials.get({ publicKey: makeAssertionOptions })
    } catch (err) {
        showErrorAlert(err.message ? err.message : err);
    }

    try {
        await verifyAssertionWithServer(credential);
    } catch (e) {
        showErrorAlert("Could not verify assertion", e);
    }
}

/**
 * Sends the credential to the the FIDO2 server for assertion
 * @param {any} assertedCredential
 */
async function verifyAssertionWithServer(assertedCredential) {

    // Move data into Arrays incase it is super long
    let authData = new Uint8Array(assertedCredential.response.authenticatorData);
    let clientDataJSON = new Uint8Array(assertedCredential.response.clientDataJSON);
    let rawId = new Uint8Array(assertedCredential.rawId);
    let sig = new Uint8Array(assertedCredential.response.signature);
    const data = {
        id: assertedCredential.id,
        rawId: coerceToBase64Url(rawId),
        type: assertedCredential.type,
        extensions: assertedCredential.getClientExtensionResults(),
        response: {
            authenticatorData: coerceToBase64Url(authData),
            clientDataJson: coerceToBase64Url(clientDataJSON),
            signature: coerceToBase64Url(sig)
        }
    };

    let response;
    try {
        let res = await fetch("/makeAssertion", {
            method: 'POST', // or 'PUT'
            body: JSON.stringify(data), // data can be `string` or {object}!
            headers: {
                'Accept': 'application/json',
                'Content-Type': 'application/json'
            }
        });

        response = await res.json();
    } catch (e) {
        showErrorAlert("Request to server failed", e);
        throw e;
    }

    console.log("Assertion Object", response);

    // show error
    if (response.status !== "ok") {
        console.log("Error doing assertion");
        console.log(response.errorMessage);
        showErrorAlert(response.errorMessage);
        return;
    }

    // show success message
    await Swal.fire({
        title: 'Logged In!',
        text: 'You\'re logged in successfully.',
        type: 'success',
        timer: 2000
    });


    // redirect to dashboard to show keys
    window.location.href = "/dashboard/" + value("#login-username");
}
mfa.login.js

There are other essential files available in the repository that are self-explanatory.

Pressing the Create account button after entering the username and display name (password is not mandatory) will prompt you to set a pin for the Fido device. In my case, it's the FEITIAN ePass FIDO2 FIDO U2F USB-A + NFC Security Key | K9

After you set a pin, it will ask you to touch the key for your biometric data. And the registration process is done. Similarly, in the login section, you enter the username (password is not mandatory) and press the Sign in button to prompt you to touch the Fido key.

Once done, it will take you directly to the dashboard, where you can see some general information related to the public key,

https://github.com/fiyazbinhasan/AspNetCoreFido2MFA

To integrate Fido2 MFA with the Identity platform, take a look at this repository from Damien Bowden,

https://github.com/damienbod/AspNetCoreIdentityFido2Mfa

Attestation and Assertion

WebAuthn Guide

WebAuthin WC3 Spec

FEITIAN ePass FIDO2 FIDO U2F USB-A

Add Fido2 MFA to an OpenIddict identity provider using ASP.NET Core Identity