Implementing Passkeys to ASP.NET Core Application

By FoxLearn 2/11/2025 3:38:57 AM   126
To implement Passkeys in an ASP.NET Core application, you'll need to follow a series of steps to integrate FIDO2 authentication into your application.

Passkeys work through the FIDO2 protocol, which relies on public-key cryptography and allows for passwordless authentication.

Understanding Public-Private Key Cryptography

Before we dive into implementing passkeys, let’s take a moment to understand the basics of public-private key cryptography. Traditional cryptography often involves encrypting and decrypting data with a single key, like how we use a door key to lock and unlock a door. But on the web, we sometimes don’t want to share our “door key” but still need to prove that the door is ours and we hold the key.

This is where public-key cryptography comes in.

With public-key cryptography, a private key is generated first. A one-way function uses complex algorithms to create a public key from the private one. The public key is not secret, and it can be shared with anyone. What makes it special is that only the corresponding private key can generate that public key. This enables third parties to verify that the public key belongs to the correct user.

This same principle is applied to passkeys. The user’s device stores the private key, while the app retains the public key. This allows users to authenticate without exposing the private key, which is managed entirely by their device and never known to the user.

General Use of Passkeys

Despite all the advantages of passkeys, the web is still in the early stages of adoption. As of May 2024, many services still require a password even if passkeys are enabled. Therefore, it’s a good idea to allow users to create passkeys while still permitting them to log in with traditional passwords.

Additionally, passkeys are designed to eliminate the need for multi-factor authentication, so users can authenticate with a single step making it even easier.

Installing Necessary NuGet Packages

To get started with passkeys, you'll first need to install the following NuGet packages:

The following code libraries are based on Fido2's existing libraries, though they’ve been modified for this specific use case:

using Authentication_Playground_.Data;
using Authentication_Playground_.Models;
using Fido2NetLib;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Fido2Identity
{
    public class Fido2Storage
    {
        private readonly AppDbContext _dbContext;

        public Fido2Storage(AppDbContext dbContext)
        {
            _dbContext = dbContext;
        }

        public List<FidoStoredCredential> GetCredentialsByUsername(string username)
        {
            return _dbContext.FidoStoredCredentials.Where(c => c.Username == username).ToList();
        }

        public async Task RemoveCredentialsByUsername(string username)
        {
            var item = await _dbContext.FidoStoredCredentials
                .Where(c => c.Username == username)
                .FirstOrDefaultAsync();

            if (item != null)
            {
                _dbContext.FidoStoredCredentials.Remove(item);
                await _dbContext.SaveChangesAsync();
            }
        }

        public async Task RemoveCredentialsById(int id)
        {
            var item = await _dbContext.FidoStoredCredentials
                .Where(c => c.ID == id)
                .FirstOrDefaultAsync();

            if (item != null)
            {
                _dbContext.FidoStoredCredentials.Remove(item);
                await _dbContext.SaveChangesAsync();
            }
        }

        public async Task<FidoStoredCredential> GetCredentialById(byte[] id)
        {
            var credentialIdString = Base64Url.Encode(id);
            credentialIdString = credentialIdString.Replace('-', '+').Replace('_', '/');

            var cred = await _dbContext.FidoStoredCredentials
                .Where(c => c.DescriptorJson.Contains(credentialIdString))
                .FirstOrDefaultAsync();

            return cred;
        }

        public Task<List<FidoStoredCredential>> GetCredentialsByUserHandleAsync(byte[] userHandle)
        {
            return Task.FromResult(_dbContext.FidoStoredCredentials
                .Where(c => c.UserHandle.SequenceEqual(userHandle))
                .ToList());
        }

        public async Task UpdateCounter(byte[] credentialId, uint counter)
        {
            var credentialIdString = Base64Url.Encode(credentialId);
            credentialIdString = credentialIdString.Replace('-', '+').Replace('_', '/');

            var cred = await _dbContext.FidoStoredCredentials
                .Where(c => c.DescriptorJson.Contains(credentialIdString))
                .FirstOrDefaultAsync();

            if (cred != null)
            {
                cred.SignatureCounter = counter;
                cred.LastLogin = DateTime.Now;
                await _dbContext.SaveChangesAsync();
            }
        }

        public async Task AddCredentialToUser(Fido2User user, FidoStoredCredential credential)
        {
            credential.UserId = user.Id;
            _dbContext.FidoStoredCredentials.Add(credential);
            await _dbContext.SaveChangesAsync();
        }

        public async Task<List<Fido2User>> GetUsersByCredentialIdAsync(byte[] credentialId)
        {
            var credentialIdString = Base64Url.Encode(credentialId);

            var cred = await _dbContext.FidoStoredCredentials
                .Where(c => c.DescriptorJson.Contains(credentialIdString))
                .FirstOrDefaultAsync();

            if (cred == null)
                return new List<Fido2User>();

            return await _dbContext.Users
                .Where(u => Encoding.UTF8.GetBytes(u.UserHandle)
                    .SequenceEqual(cred.UserId))
                .Select(u => new Fido2User
                {
                    DisplayName = u.Username,
                    Name = u.Username,
                    Id = Encoding.UTF8.GetBytes(u.UserHandle)
                }).ToListAsync();
        }
    }
}

Model

using Fido2NetLib.Objects;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.ComponentModel.DataAnnotations.Schema;

namespace Authentication_Playground_.Models
{
    public class FidoStoredCredential
    {
        public int ID { get; set; }
        public string Username { get; set; }
        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 DateTime LastLogin { get; set; }
        public Guid AaGuid { get; set; }
        public string DeviceInfo { get; set; }

        [NotMapped]
        public PublicKeyCredentialDescriptor Descriptor
        {
            get { return string.IsNullOrWhiteSpace(DescriptorJson) ? null : JsonConvert.DeserializeObject<PublicKeyCredentialDescriptor>(DescriptorJson); }
            set { DescriptorJson = JsonConvert.SerializeObject(value); }
        }
        public string DescriptorJson { get; set; }
    }
}

Implementation

Let's get started with the code implementation

1. Configure Middleware

First, you need to configure the middleware in Program.cs. Add the following block before var app = builder.Build():

#region FIDO2 Configuration

var fido2Configuration = new Fido2Configuration
{
    ServerDomain = "foxlearn.com",  // Replace with your server domain
    ServerName = "foxlearn",      // Replace with your server name
    Origin = "https://foxlearn.com", // Replace with your origin URL
    TimestampDriftTolerance = 300000, // Tolerance in milliseconds for timestamp drift
    MDSCacheDirPath = null            // Optional: Set if needed
};

// Configure the Fido2Configuration service with the appropriate settings
builder.Services.Configure<Fido2Configuration>(options =>
{
    options.ServerDomain = fido2Configuration.ServerDomain;
    options.ServerName = fido2Configuration.ServerName;
    options.Origin = fido2Configuration.Origin;
    options.TimestampDriftTolerance = fido2Configuration.TimestampDriftTolerance;
    options.MDSCacheDirPath = fido2Configuration.MDSCacheDirPath;
});

// Register Fido2Configuration as a singleton service
builder.Services.AddSingleton(fido2Configuration);

#endregion

Note: Replace ServerDomain and Origin with your actual values. If working locally:

  • Server Domain should be localhost.
  • Origin should be https://localhost:{your-port}.

2. Backend Preparation for Registration

Let's set up the backend for user registration.

#region Initializers and Variables

private readonly AppDbContext _dbContext;
private readonly Fido2 _lib;
private readonly IOptions<Fido2Configuration> _optionsFido2Configuration;
private readonly Fido2Storage _fido2Storage;

public Fido2Controller(AppDbContext dbContext, IOptions<Fido2Configuration> optionsFido2Configuration)
{
    _dbContext = dbContext;
    _optionsFido2Configuration = optionsFido2Configuration;
    
    // Initialize FIDO2 library with configuration
    _lib = new Fido2(new Fido2Configuration()
    {
        ServerDomain = _optionsFido2Configuration.Value.ServerDomain,
        ServerName = _optionsFido2Configuration.Value.ServerName,
        Origin = _optionsFido2Configuration.Value.Origin,
        TimestampDriftTolerance = _optionsFido2Configuration.Value.TimestampDriftTolerance
    });

    _fido2Storage = new Fido2Storage(dbContext);
}

#endregion

3. Register Passkey (Registration Request)

This is the action that handles the user registration request:

#region REGISTER PASSKEY

[HttpPost]
public IActionResult RegisterRequest()
{
    // Check if the user is authenticated
    if (HttpContext.Session.GetInt32("UserId").HasValue)
    {
        var user = _dbContext.Users
            .Where(s => s.Id == HttpContext.Session.GetInt32("UserId").Value)
            .FirstOrDefault();

        if (user == null) return BadRequest();

        // Generate user handle if not already set
        if (user.UserHandle == null)
        {
            byte[] userIdBytes = new byte[32];
            new Random().NextBytes(userIdBytes);
            string userIdStr = Convert.ToBase64String(userIdBytes);

            user.UserHandle = userIdStr;
            _dbContext.SaveChanges();
        }

        var userHandle = Convert.FromBase64String(user.UserHandle);

        var fidoUser = new Fido2User
        {
            DisplayName = user.Username,
            Name = user.Username,
            Id = userHandle
        };

        // Get the user's existing keys (if any)
        var items = _fido2Storage.GetCredentialsByUsername(user.Username);
        var existingKeys = new List<PublicKeyCredentialDescriptor>();
        foreach (var publicKeyCredentialDescriptor in items)
        {
            existingKeys.Add(publicKeyCredentialDescriptor.Descriptor);
        }

        // Create options for the new credential
        var authenticatorSelection = new AuthenticatorSelection
        {
            RequireResidentKey = false,
            UserVerification = UserVerificationRequirement.Required
        };

        authenticatorSelection.AuthenticatorAttachment = AuthenticatorAttachment.Platform;

        var exts = new AuthenticationExtensionsClientInputs() { Extensions = true };

        var options = _lib.RequestNewCredential(fidoUser, existingKeys, authenticatorSelection, AttestationConveyancePreference.Direct, exts);
        options.Rp = new PublicKeyCredentialRpEntity("localhost", "YcanInDev", null);

        List<PubKeyCredParam> pubKeyCredParams = new List<PubKeyCredParam>
        {
            new PubKeyCredParam(COSE.Algorithm.ES256, PublicKeyCredentialType.PublicKey),
            new PubKeyCredParam(COSE.Algorithm.RS256, PublicKeyCredentialType.PublicKey)
        };
        options.PubKeyCredParams = pubKeyCredParams;

        // Store the options in session or cache
        HttpContext.Session.SetString("fido2.attestationOptions", options.ToJson());

        // Return the options to the client for the user to register the credential
        return Json(options);
    }
    else
    {
        Response.StatusCode = 401;
        return Json("Unauthorized");
    }
}

#endregion

4. Register Passkey Response (Finalizing Registration)

This is the action that handles the final registration response after the user completes the registration on their device:

[HttpPost]
public async Task<IActionResult> RegisterResponse([FromBody] AuthenticatorAttestationRawResponse attestationResponse)
{
    try
    {
        if (HttpContext.Session.GetInt32("UserId").HasValue)
        {
            var jsonOptions = HttpContext.Session.GetString("fido2.attestationOptions");
            var options = CredentialCreateOptions.FromJson(jsonOptions);

            // Callback to ensure credential ID is unique to the user
            async Task<bool> callback(IsCredentialIdUniqueToUserParams args, CancellationToken token)
            {
                var users = await _fido2Storage.GetUsersByCredentialIdAsync(args.CredentialId);
                return users.Count == 0;
            };

            // Verify and create the credentials
            var success = await _lib.MakeNewCredentialAsync(attestationResponse, options, callback);

            // Collect device info (optional)
            string deviceInfo = "";
            try
            {
                var userAgent = HttpContext.Request.Headers["User-Agent"];
                var uaParser = Parser.GetDefault();
                ClientInfo c = uaParser.Parse(userAgent);

                deviceInfo = c.OS.Family.ToString();
            }
            catch { }

            // Store the credentials in the database
            await _fido2Storage.AddCredentialToUser(options.User, new FidoStoredCredential
            {
                Username = options.User.Name,
                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,
                LastLogin = DateTime.Now,
                AaGuid = success.Result.Aaguid,
                DeviceInfo = deviceInfo
            });

            // Return a success response
            return Ok();
        }
        else
        {
            Response.StatusCode = 401;
            return Json("Unauthorized");
        }
    }
    catch (Exception ex)
    {
        Response.StatusCode = 500;
        return Json("Unexpected error");
    }
}

#endregion

The RegisterRequest API sends configurations to the browser when the user requests them. The browser will prompt the native UI to confirm if the user wants to create a passkey. If successful, the browser sends the credential details back to the server via the RegisterResponse endpoint.

For the front-end, we will implement HTML elements and JavaScript to allow users to register their passkeys. Upon clicking the "Create Passkey" button, the front-end fetches the required configurations from the server and triggers the browser's native UI. If the user successfully completes the process, the credential details are sent to the server, where they are stored.

HTML Part:

<div>
    <p id="message" class="instructions"></p>
    <mwc-button id="create-passkey" class="hidden btn btn-primary ms-3 mb-3" icon="fingerprint" raised>
        Create Passkey
    </mwc-button>
</div>

JavaScript Part:

export const base64url = {
    encode: function (buffer) {
        const base64 = window.btoa(String.fromCharCode(...new Uint8Array(buffer)));
        return base64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
    },
    decode: function (base64url) {
        const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
        const binStr = window.atob(base64);
        const bin = new Uint8Array(binStr.length);
        for (let i = 0; i < binStr.length; i++) {
            bin[i] = binStr.charCodeAt(i);
        }
        return bin.buffer;
    }
};

export async function _fetch(path, payload = '') {
    const headers = {
        'X-Requested-With': 'XMLHttpRequest',
    };
    if (payload && !(payload instanceof FormData)) {
        headers['Content-Type'] = 'application/json';
        payload = JSON.stringify(payload);
    }
    const res = await fetch(path, {
        method: 'POST',
        credentials: 'same-origin',
        headers: headers,
        body: payload,
    });
    if (res.status === 200) {
        return res.json();
    } else {
        const result = await res.json();
        throw new Error(result.error);
    }
};

const createPasskey = document.getElementById('create-passkey');
createPasskey.addEventListener('click', registerCredential);

// Feature detections
if (window.PublicKeyCredential &&
    PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable &&
    PublicKeyCredential.isConditionalMediationAvailable) {
    try {
        const results = await Promise.all([

            PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),
            PublicKeyCredential.isConditionalMediationAvailable()
        ]);
        if (results.every(r => r === true)) {
            createPasskey.classList.remove('hidden');
        } else {
            document.getElementById('message').textContent = 'This device does not support passkeys';
        }
    } catch (e) {
        console.error(e);
    }
} else {
    document.getElementById('message').textContent = 'This device does not support passkeys';
}

export async function registerCredential() {
    try {
        const options = await _fetch('/Fido2/RegisterRequest');

        if (options.excludeCredentials) {
            for (let cred of options.excludeCredentials) {
                cred.id = base64url.decode(cred.id);
            }
        }
        options.authenticatorSelection = {
            authenticatorAttachment: 'platform',
            requireResidentKey: true
        };
        options.challenge = base64url.decode(options.challenge);
        options.user.id = base64url.decode(options.user.id);

        let newCredential;
        try {
            newCredential = await navigator.credentials.create({
                publicKey: options
            });
        } catch (e) {
            alert("Could not create credentials in browser. The username may already be registered with your authenticator. Please change username or authenticator.");
        }

        try {
            registerNewCredential(newCredential);
        } catch (e) {
            alert("Could not create passkey");
        }
    } catch (e) {
        if (e.name === 'InvalidStateError') {
            alert("This device already has a passkey for this service");
        } else if (e.name === 'NotAllowedError') {
            return;
        } else {
            alert("Could not create passkey");
        }
    }
};

async function registerNewCredential(newCredential) {
    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 {
        let res = await registerCredentialWithServer(data);

        if (res.ok == true) {
            alert("Passkey saved successfully");
        } else {
            alert("Could not create passkey");
        }
    } catch (e) {
        alert("Could not create passkey");
    }
}

async function registerCredentialWithServer(formData) {
    let response = await fetch('/Fido2/RegisterResponse', {
        method: 'POST',
        body: JSON.stringify(formData),
        headers: {
            'Accept': 'application/json',
            'Content-Type': 'application/json'
        }
    });
    return response;
}

function coerceToBase64Url(input) {
    if (Array.isArray(input)) {
        input = Uint8Array.from(input);
    }
    if (input instanceof ArrayBuffer) {
        input = new Uint8Array(input);
    }

    if (input instanceof Uint8Array) {
        var str = "";
        var len = input.byteLength;

        for (var i = 0; i < len; i++) {
            str += String.fromCharCode(input[i]);
        }
        input = window.btoa(str);
    }

    if (typeof input !== "string") {
        throw new Error("could not coerce to string");
    }

    input = input.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, "");
    return input;
}

function base64ToArrayBuffer(base64) {
    var binaryString = atob(base64);
    var bytes = new Uint8Array(binaryString.length);
    for (var i = 0; i < binaryString.length; i++) {
        bytes[i] = binaryString.charCodeAt(i);
    }
    return bytes.buffer;
}

Sign-In with Passkey

The sign-in flow follows a similar approach with two main endpoints: one for the sign-in request and another to handle the response from the browser’s OS. The front-end simply adds the autocomplete="webauthn" attribute to the form to let the browser know that WebAuthn sign-in is supported.

<form asp-action="LoginUser" asp-controller="Account" method="post">
    <div class="row mb-3">
        <div class="col-md-12">
            <label class="form-label">Username</label>
            <div class="d-flex align-items-center">
                <input name="username" class="form-control flex-grow-1" maxlength="50" autocomplete="webauthn" required />
            </div>
        </div>
        <div class="col-md-12">
            <label class="form-label">Password</label>
            <div class="d-flex align-items-center">
                <input type="password" name="password" class="form-control flex-grow-1" required />
            </div>
        </div>
    </div>

    <div class="row">
        <button type="submit" class="btn btn-success">Login</button>
    </div>
</form>

The frontend code for the authentication is similar to the registration flow, with an additional authenticate() function to interact with the SigninRequest and VerifyWebAuthn endpoints.