This year's entry to Sergey Tihon's F# advent calendar. Thanks again :)
Protect your ASP.NET site using FIDO2 Passkeys
Last time we learned how to protect ASP.NET sites using Bitwarden's Passwordless service. But relying on third parties comes with its own set of problems, and is also just boring. In the spirit of not invented here syndrome, and because all the heavy lifting has been done for us, we're going to integrate Passkey authentication directly into our ASP.NET solution with a single NuGet package: FIDO2. Incidentally, this also comes from the folks over at Passwordless, but it's an open source library that implements the FIDO Alliance Passkey Specification.
Source code on Coderberg and on GitHub
Implementation
I personally found the implementation of Passkeys using the FIDO2 NuGet package the simplest out of the three authentication mechanisms I explored in this series. There are only four endpoints required in your controller. Two for sign up, and two for login. The client side HTML also only consists of a single input field for the (optional) username, and a few lines of javascript. Of course, this is only the most simple implementation of passkeys, and doesn't consider things like adding passkeys to an existing "regular" user account or similar things.
In both cases, the basic flow of data is that the client first configures "options", which the server uses to generate either registration (sign up) or verification (login) challenges. These then get sent to the client at which point the browser will prompt for the actual passkey, using your favorite password manager/hardware key etc. Once the client confirms the creation or verification of the user identity, it is sent back to the server where it can then create a User
object and/or set the session cookie.
Sign Up
As mentioned, the sign up form contains a single input field: the username. We will however submit this form via javascript, and not a normal browser form submit. The first step is that we will create a FormData
object that configures how we want to user passkeys:
const data = { username: username, displayName: displayName, attType: attestationType, authType: authenticatorAttachment, userVerification: userVerification, residentKey: residentKey } // POST that data to the MakeCredentialsOptions controller endpoint
Refer to the FIDO2 documentation about the configuration options. The server then creates a Fido2User
object, and builds an AuthenticatorSelection
with some other information to send back to the client. Additionally, this needs to be temporarily stored in memory or in cache somewhere for the second step of the process. In our case we're going to use a session cookie ctxAccessor.HttpContext.Session.SetString("fido2.attestationOptions", options.ToJson())
. These options
are also what we're going to return to the client: return this.Json(options)
Back in the client, we can now user the server response and prompt the password manager/hardware key to create a new passkey for the domain
const response = // response from the POST to MakeCredentialsOptions // This is the call that will prompt the user const newCredential = await navigator.credentials.create({ publicKey: makeCredentialOptions }); // Register the new credential with the FIDO2User created in the backend // POST to MakeCredential endpoint as described in the registerNewCredential function
The only thing for the MakeCredential
endpoint is to add the credentials to the FIDO2User
object that was created in the previous step and log the user in. We're going to make use of the the AuthService
that we used in the previous post to utilize the ASP.Net way of signing a user in via the HttpContext.SignInAsync
method. The response from here can be a simple redirect to a secure resource or whatever you wish.
Login
The login form is similarly simple. It contains a single username
input field and the form submit button. This is also captured by javascript to be able to build AssertionOptions
this time. This time, we're going to POST the username immediately to the backend AssertionOptions
controller method. The backend then looks up the username
and tries to build the aforementioned AssertionOptions
through the FIDO2 library. This is once again saved in some kind of cache, for us again the session, and then returned to the client.
let options = fido2.GetAssertionOptions(existingCredentials, uv, extensions) ctxAccessor.HttpContext.Session.SetString("fido2.assertionOptions", options.ToJson()) return this.Json(options)
Once we have the response from the server, we can then ask the browser to verify their credentials via password manager/hardware key with const credential = await navigator.credentials.get({ publicKey: makeAssertionOptions })
. This will cause the browser to prompt the user, and after the user "signs in", we need to tell the server that the user passkey has been verified.
We can now POST to the MakeAssertion
controller method with the following fields that we receive from the navigator credentials
const authData = new Uint8Array(credential.response.authenticatorData); const clientDataJSON = new Uint8Array(credential.response.clientDataJSON); const rawId = new Uint8Array(credential.rawId); const sig = new Uint8Array(credential.response.signature); const data = { id: credential.id, rawId: window.coerceToBase64Url(rawId), type: credential.type, extensions: credential.getClientExtensionResults(), response: { authenticatorData: window.coerceToBase64Url(authData), clientDataJSON: window.coerceToBase64Url(clientDataJSON), signature: window.coerceToBase64Url(sig) } }; const res = await fetch('@Url.Action("MakeAssertion", "PasskeyAuthentication")', { method: 'POST', body: JSON.stringify(data), headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } }); const response = await res.json();
Back in the server side of things, we can verify that the passkey matches the FIDO2User object that is requested.
let options = AssertionOptions.FromJson(jsonOptions) let callback = IsUserHandleOwnerOfCredentialIdAsync (fun (ownerOfCredentials: IsUserHandleOwnerOfCredentialIdParams) (cancellationToken: CancellationToken) -> task { let! storedCredentials = fakeDb.GetCredentialsByUserHandleAsync(ownerOfCredentials.UserHandle) return storedCredentials |> List.exists ( _.Descriptor.Id .AsSpan() .SequenceEqual(ownerOfCredentials.CredentialId) ) }) let! result = fido2.MakeAssertionAsync( clientResponse, options, credentials.PublicKey, credentials.DevicePublicKeys, credentials.SignCount, callback )
The MakeAssertionAsync
function takes some of the credential data, but crucially also a callback function where the matching logic happens of verifying the passkey credential to username.
Side note: The callback in this case is a C# Delegate, but we're working with F# here. We can easily convert an F# function to a delegate simply by calling the constructor of the delegate type. In this particular case, the delegate type is IsUserHandleOwnerOfCredentialIdAsync
and we can construct it as follows let callback = IsUserHandleOwnerOfCredentialIdAsync(fun params -> returnValue)
where the parameters and return value types have to match the delegate signature.
Finally, we can once again make use of the AuthService
to sign the user in and store it in a session cookie: do! authService.SignIn(id, username)
where the id in this case is fetched from the fake database. At this point, we can now redirect to user to wherever they came from or return whatever response we'd like to give them access to any areas of the site that require authentication.
Final thoughts
As mentioned previously, I personally felt that this implementation was actually the simplest. However, there's still a few missing pieces. You still need to implement things like email verification to ensure that there's a human on the other side, and account recovery mechanisms as well. There's probably a few other things I'm forgetting at the moment as well.
That being said, considering the ease of implementation, and after playing around with passkeys a bit, I felt that as even as a user, signing up and logging in felt much easier and faster using passkeys than traditional username password combinations. I'm definitely looking forward to more sites adopting passkeys.