Eliminate passwords with FIDO2 in ASP.NET Core

Tarun Gudipati
8 min readFeb 18, 2024

--

Did you have that one moment of frustration where you’ve just reset your password but still can’t remember it.
What if I tell you we can get rid of passwords ?

Passwords have been the default way to prove our identity to any external system, long before computers were invented.
We’ve all heard that story of Alibaba and the forty thieves right!

Photo by Jason Dent on Unsplash

Why “no” to passwords ?

Like everything else in software, “passwords” also present their own set of challenges like:

  1. One should remember them, Most obvious yet frustrating one 😫!
  2. We have to store them in a secure way.
  3. Keep changing them periodically or in case of a compromise.
  4. Susceptible to password sprays, Common let’s be honest, how many of us use the same password for many sites ?
  5. Can’t be shared easily, because password might contain sensitive information regarding our personal lives.

So how do we move towards a better solution?

Think Passkeys!

Passkeys are a replacement for passwords that provide faster, easier, and more secure sign-ins to websites and apps across a user’s devices. Unlike passwords, passkeys are always strong and phishing-resistant.
Technically it’s based on FIDO2 scheme.

In simple terms passkey can be thought of a cryptographically strong public-private key pair.

Let’s understand how passkeys work with a simple example:

Passkey registration flow
  1. Let’s say a user is going to register in our system.
    Since we don’t need a password, all we need is a unique identity for the user such as username/email.
  2. Once we have it, we start the registration process by asking the server for what’s called a “Credential Options”.
    For now think of this as a unique challenge issued by the server which can be relayed back to the server post successful credential generation
  3. Now the user is prompted to verify their identity to the device by securely by a local authentication method like biometric (face or fingerprints) / local PIN/ usb key.
  4. Once the user proves their identity to the device, it produces a public and private key pair and the public key is sent back to the server for storing it and generating whats called a “Credential”, while the private key is retained on the users device.
  5. In the subsequent turns whenever a user would like to sign-in to the system, all they need to do is prove that they have the relevant private key pertaining to the public key shared with the server.
  6. Typically the sign-in process also involves 2 hops, in the first hop the user would ask the server to give what’s called “Assertion Options”.
    Think of it as something very similar to “Credential Options” but with added metadata as to which public keys the server has stored on its side mapped to this user.
  7. Again the user would be prompted to prove their identity to the device by means of secure local authentication methods.
  8. Once successfully proved the device generates an “Assertion Response”by utilizing the previously stored private key and sends it back to the server to the server, where it can check if the “Assertion” was done in line to the “Assertion Options” or not.

As we can see, this dramatically improves both security and user experience, because the user didn’t have to remember anything and also we didn’t have to store any sensitive user information.

So it’s a win-win for developers and also users.

A Practical Example!

Let’s implement passkeys in an ASP.NET Core and Vue JS application and get our hands dirty!

Like with all of my other write-ups the code samples described in the below section can be found on my GitHub repos

https://github.com/Tarun047/PassKeyDemo

One of the awesome OSS .NET libraries for supporting FIDO2 based authentication is fido2-net-lib.

TechStack

  1. ASP.NET Core + Entity Framework Core: For backend heavy lifting and cause I love it 😍
  2. Vue.JS (Typescript + Vuetify + Vite): For frontend
  3. PostgreSQL: For Relational Database Storage
  4. Redis: For Caching
  5. Podman: For containerizing the solution
  6. Docker compose: For container orchestration

From the above explained flow, it looks like we’ll need at least one controller for dealing with FIDO2 Authentication.

Let’s think what endpoints do we need.

Let’s start with an endpoint for creating credential options, this endpoint takes a username and issues a credential options containing a challenge.

This endpoint is implemented in the repository as
POST /api/fido2/credential-options
It’s implementation is as follows

[HttpPost("credential-options")]
[AllowAnonymous]
public async Task<ActionResult<CredentialOptionsResponse>> CreateCredentialOptions([FromBody]string userName)
{
if (string.IsNullOrEmpty(userName))
{
return BadRequest("Username is required to create an authentication options");
}

var existingUser = await userRepository.GetUserAsync(userName);
if (existingUser != null)
{
return BadRequest($"Username {userName} is already taken!");
}

var user = await userRepository.CreateUserAsync(userName);
var excludedCredentials = user.Credentials;

var credentialOptions = fido2.RequestNewCredential(user.ToFido2User(),
excludedCredentials.Select(credential => new PublicKeyCredentialDescriptor(credential.Id)).ToList());
await cache.SetAsync(user.Id.ToString(), Encoding.UTF8.GetBytes(credentialOptions.ToJson()));
return Ok(new CredentialOptionsResponse
{
Options = credentialOptions,
UserId = user.Id
});
}

We do some basic sanity checks and proceed to create a user, one important thing to note is that we don’t want a user to be able to register the same credential multiple times on the same device.
We achieve this by specifying what are the excluded credentials as part of credential options when we call fido2.RequestNewCredential(...)

Also we need to store the original credential options in some cache storage, so that we can reference them later when we are verifying and storing the credential in our system.
In this case we are using Redis for distributed caching.

Once the frontend gets the credential options, it begins the passkey registration process by calling the navigator.credentials.create(...) method.
This is done in the createCredential(...) method of the PassKeyService typescript class.

class PassKeyService
....
async createCredential(userId: string, options: CredentialCreationOptions) {
const attestationResponse = await create(options);
const credentialResponse = await fetch('/api/fido2/credential', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': navigator.userAgent,
},
body: JSON.stringify({
attestationResponse: attestationResponse.toJSON(),
userId: userId
})
});
if (credentialResponse.status == 400) {
throw new Error(await credentialResponse.text());
}
return await credentialResponse.json();
}
...

One caveat I came across is that FIDO2’s Web Implementation defines some of the credential option fields as ArrayBuffers and when we return byte arrays from ASP.NET Core to frontend they are returned in base64url format.
However GitHub’s @github/webauthn-json library solves this issue, by providing helper functions until browsers catchup and provide a native way to do this.

Now that we have generated whats called a PublicKeyCredential we need to transmit this back to the server.

So let’s implement an endpoint for validating the credentials we just generated, mapping them to a specific user and storing them in the database.

This is done in the POST /api/fido2/credential endpoint

[HttpPost("credential")]
[AllowAnonymous]
public async Task<ActionResult<CreateCredentialResponse>> CreateCredential(
[FromBody] CreateCredentialRequest createCredentialRequest)
{
var userKey = createCredentialRequest.UserId.ToString();
var credentialOptionsBytes = await cache.GetAsync(userKey);
if (credentialOptionsBytes == null)
{
return BadRequest();
}

await cache.RemoveAsync(userKey);
var credentialOptions = CredentialCreateOptions.FromJson(Encoding.UTF8.GetString(credentialOptionsBytes));
var credential = await fido2.MakeNewCredentialAsync(createCredentialRequest.AttestationResponse,
credentialOptions,
(args, _) => userRepository.IsCredentialIdUniqueToUserAsync(args.User.Id, args.CredentialId));
if (credential.Result != null)
{
await userRepository.AddCredentialToUserAsync(
createCredentialRequest.UserId,
credential.Result.CredentialId,
credential.Result.PublicKey,
credential.Result.Counter,
(HttpContext.Items[Constants.Device.PlatformInfoKey] as string)!);
var token = await tokenService.GenerateTokenAsync(createCredentialRequest.UserId);
return Created("", new CreateCredentialResponse
{
CredentialMakeResult = credential,
Token = token
});
}

return BadRequest();
}

We start by retrieving the original credentialOptions from the cache and proceed to call fido2.MakeNewCredentialAsync(...) method for validation of our original credential options challenge and retrieving the public key sent.

If the call succeeds, it means that the user is actually who they say they are Now we save the credential to our database, in this case it’s a PostgreSQL DB exposed through EntityFramework core

Also we need to have some sort of mechanism to “sign-in” the the user if we decide to sign-in the user after creating a passkey, typically either through Cookies or JWT tokens, in this case I’ve chosen to go with JWT tokens to favor stateless architecture.

Now to log-in a returning user, we’ll need to produce AssertionOptions this is done in the POST /api/fido2/assertion-options endpoint.

[HttpPost("assertion-options")]
[AllowAnonymous]
public async Task<ActionResult<AssertionOptionsResponse>> CreateAssertionOptions([FromBody]string userName, [FromQuery]UserVerificationRequirement userVerificationRequirement = UserVerificationRequirement.Required)
{
if (string.IsNullOrEmpty(userName))
{
return BadRequest("Username is required to create an authentication options");
}

var user = await userRepository.GetUserAsync(userName);
if (user == null)
{
return BadRequest("No such user found");
}

var existingCredentials = user.Credentials.Select(credential => new PublicKeyCredentialDescriptor(credential.Id));
var options = fido2.GetAssertionOptions(existingCredentials, userVerificationRequirement);
await cache.SetAsync(user.Id.ToString(), Encoding.UTF8.GetBytes(options.ToJson()));
return Ok(new AssertionOptionsResponse
{
AssertionOptions = options,
UserId = user.Id
});
}

We can see that the implementation looks very similar to credential option generation.
Only difference being instead of using existing credentials that are registered with the server for exclusion, we use them for inclusion.
i.e. The server conveys to the frontend that these are the credentials that I have stored with me, so you’ll have to solve the challenge with any one of these matching credentials.

The frontend needs to query the credentials available on the device and ask the user to prove their identity.
All this complexity is nicely abstracted out by the navigator.credentials.get(...) method call, which is called in the verifyAssertion(...) method of the PassKeyService class.

class PassKeyService
...
async verifyAssertion(userId: string, options: CredentialRequestOptions)
{
const isConditionalMediationAvailable = await PublicKeyCredential.isConditionalMediationAvailable();
if (!isConditionalMediationAvailable) {
throw new Error('Mediation is not supported :(');
}
const assertionResponse = await get(options);
const verificationResponse = await fetch('/api/fido2/assertion', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': navigator.userAgent,
},
body: JSON.stringify({
assertionRawResponse: assertionResponse.toJSON(),
userId: userId
})
});
return await verificationResponse.json();
}
...

Finally we’ll need one backend endpoint to verify the assertion challenge and issue tokens to the returning user for making subsequent API calls.
This is done in the POST /api/fido2/assertion endpoint.

[HttpPost("assertion")]
[AllowAnonymous]
public async Task<ActionResult<VerifyAssertionResponse>> VerifyAssertion([FromBody] VerifyAssertionRequest verificationRequest)
{
var userKey = verificationRequest.UserId.ToString();
var assertionOptionBytes = await cache.GetAsync(userKey);
await cache.RemoveAsync(userKey);
if (assertionOptionBytes == null)
{
return BadRequest();
}

var assertionOptions = AssertionOptions.FromJson(Encoding.UTF8.GetString(assertionOptionBytes));
var credential = await userRepository.GetCredentialAsync(verificationRequest.AssertionRawResponse.Id);
var assertionResult = await fido2.MakeAssertionAsync(verificationRequest.AssertionRawResponse, assertionOptions, credential.PublicKey, credential.SignCounter,
(args, _) => Task.FromResult(new Guid(args.UserHandle) == credential.UserId));
credential.SignCounter = assertionResult.Counter;
credential.LastUsedPlatformInfo = HttpContext.Items[Constants.Device.PlatformInfoKey] as string;
await userRepository.UpdateCredentialAsync(credential);
var token = await tokenService.GenerateTokenAsync(verificationRequest.UserId);
return Ok(new VerifyAssertionResponse
{
AssertionVerificationResult = assertionResult,
Token = token
});
}

As we can see, in terms of implementation, its very similar to what we see in CreateCredential(...) endpoint with the slight variation that we operate on AssertionOptions instead of CredentialOptions

Bring the Action!

Alright, lets have some code in action and see how this works in practice.
You can run the same setup on your local machine assuming you have podman and compose extensions installed.

All you have to do is issue the

podman compose up

Wait for the containers to start and head to http://localhost:5001

References

Anyway thought I’d share something I found very interesting, that’s it for this story, thanks for making it till here 😄

Connect with me on LinkedIn and X
for more such interesting reads.

--

--