Skip to content

Add passkeys to ASP.NET Core Identity #62112

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open

Conversation

MackinnonBuck
Copy link
Member

@MackinnonBuck MackinnonBuck commented May 27, 2025

Add passkeys to ASP.NET Core Identity

This PR adds passkey support to ASP.NET Core Identity.

Description

Following is a summary of the changes in this PR:

  • Updated the Blazor Web App template to support passkey management and login
  • Added passkey (WebAuthn) support to ASP.NET Core Identity:
    • New passkey store abstractions with updated store implementations in Microsoft.AspNetCore.Identity.EntityFrameworkCore
    • New passkey abstractions for attestation and assertion
    • Extensibility points in the default passkey handler for e.g., attestation statement validation
    • Support for all cryptographic algorithms tested by the FIDO conformance testing tool, except EdDSA.
    • New APIs in SignInManager and UserManager for passkey management and sign in
  • Added a sample project that can be run against the FIDO conformance testing tool

Note that the goal of this PR is to add support for passkey authentication in ASP.NET Core Identity. While it implements core WebAuthn functionality, it does not provide a complete and general-purpose WebAuthn/FIDO2 library. The public API surface is limited in order to enable long-term stability of the feature. Targeted extensibility points were added to enable functionality not implemented by default, most notably attestation statement validation. This allows the use of third-party libraries to fill the missing gaps, when desired. Community feedback may result in additional extensibility APIs being added in the future.

E2E tests are a work in progress. I'll add unit tests after we agree on the design to avoid churn.

Fixes #53467

@github-actions github-actions bot added the area-identity Includes: Identity and providers label May 27, 2025
@JamesNK
Copy link
Member

JamesNK commented May 27, 2025

FYI I have a PR adding metrics to identity here: #62078. Whoever merges second will need to react and add counters and tags for passkey signins.

@MackinnonBuck MackinnonBuck requested a review from Copilot May 27, 2025 20:31
Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds support for passkeys to ASP.NET Core Identity by extending the existing identity stores and sign‐in flows. Key changes include:

  • Introducing new generic abstractions and methods for managing passkeys in both UserStore and UserOnlyStore.
  • Updating IdentityUserContext model building to support passkeys under Identity Schema Version 3.
  • Extending SignInManager with new APIs for passkey sign in and for configuring/retrieving passkey creation and request options.

Reviewed Changes

Copilot reviewed 111 out of 111 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/Identity/EntityFrameworkCore/src/UserStore.cs Added a new generic UserStore overload and methods to create, find, update, and remove user passkeys.
src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs Extended the store for users without roles to support passkey operations along with checks for DB support.
src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs Modified OnModelCreating to include a new entity for passkeys when using Identity Schema Version 3.
src/Identity/Core/src/SignInManager.cs Introduced new methods for passkey sign in and configuration of passkey creation/request options.
PublicAPI.Unshipped.txt and build files Updated the public API surface and project configuration to expose passkey-related features.

Copy link
Member Author

@MackinnonBuck MackinnonBuck left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments to help with reviewing.

Comment on lines +15 to +20
public interface IPasskeyRequestContextProvider
{
/// <summary>
/// Gets the current <see cref="PasskeyRequestContext"/>.
/// </summary>
PasskeyRequestContext Context { get; }
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The purpose of this abstraction is to allow passkey attestation and assertion logic to access information about the current request, like the host and origin.

The Microsoft.Extensions.Identity.Core package doesn't reference Microsoft.AspNetCore.Http.Abstractions, so we can't directly grab information off the current HttpContext unless we move parts of the passkey implementation to Microsoft.AspNetCore.Identity.

Comment on lines 43 to 46
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IPasskeyRequestContextProvider, HttpPasskeyRequestContextProvider>();
builder.Services.AddScoped(typeof(ISecurityStampValidator), typeof(SecurityStampValidator<>).MakeGenericType(builder.UserType));
builder.Services.AddScoped(typeof(ITwoFactorSecurityStampValidator), typeof(TwoFactorSecurityStampValidator<>).MakeGenericType(builder.UserType));
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This service allows passkey handlers to infer the server's origin from the ongoing request. We can't register this by default in Microsoft.Extensions.Identity.Core because that package can't assume an HttpContext will be available. Putting the service registration here means that it will get registered as long as the app is already calling AddSignInManager().

However, we might be able to eliminate the need for this abstraction completely by adjusting the layering and moving most of the passkey logic to Microsoft.AspNetCore.Identity. The M.E.Identity.Core package is mostly web-agnostic, but WebAuthn is very much not, so it might make sense to move things around.

Comment on lines +15 to +23
public interface IPasskeyOriginValidator
{
/// <summary>
/// Determines whether the specified origin is valid for passkey operations.
/// </summary>
/// <param name="originInfo">Information about the passkey's origin.</param>
/// <returns><c>true</c> if the origin is valid; otherwise, <c>false</c>.</returns>
bool IsValidOrigin(PasskeyOriginInfo originInfo);
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This interface exists because the meaning of a "valid origin" can vary widely between applications. See https://www.w3.org/TR/webauthn-3/#sctn-validating-origin.

Comment on lines +17 to +25
/// <summary>
/// Adds a new passkey credential in the store for the specified <paramref name="user"/>,
/// or updates an existing passkey.
/// </summary>
/// <param name="user">The user to create the passkey credential for.</param>
/// <param name="passkey">The passkey to add.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>The <see cref="Task"/> that represents the asynchronous operation.</returns>
Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering splitting this out into AddPasskeyAsync and UpdatePasskeyAsync.

Comment on lines +32 to +40
/// <summary>
/// Gets the JSON representation of the options.
/// </summary>
/// <remarks>
/// The structure of the JSON string matches the description in the WebAuthn specification.
/// See <see href="https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialcreationoptionsjson"/>.
/// </remarks>
public string AsJson()
=> _optionsJson;
Copy link
Member Author

@MackinnonBuck MackinnonBuck May 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR currently just exposes the credential creation/request options as a JSON string rather than returning a strongly-typed object. That way we're not locked into making the entirety of the Passkeys/ folder public. In the future, if we wanted to e.g., write a hand-crafted JSON writer that constructed a string without first building a .NET representation, we could do that.

Comment on lines +55 to +68
/// <summary>
/// Gets or sets whether the passkey has a verified user.
/// </summary>
public virtual bool IsUserVerified { get; set; }

/// <summary>
/// Gets or sets whether the passkey is eligible for backup.
/// </summary>
public virtual bool IsBackupEligible { get; set; }

/// <summary>
/// Gets or sets whether the passkey is currently backed up.
/// </summary>
public virtual bool IsBackedUp { get; set; }
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These flags are all present on the authenticator data flags. We could just store the flags byte directly so that we don't introduce another schema change in the future, should we find ourselves wanting to store more flags.

Comment on lines +1 to +25
async function createCredential(optionsJSON) {
// See: https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential

// 1. Let options be a new PublicKeyCredentialCreationOptions structure configured to
// the Relying Party’s needs for the ceremony.
// See: https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-parsecreationoptionsfromjson
const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJSON);

// 2. Call navigator.credentials.create() and pass options as the publicKey option.
// Let credential be the result of the successfully resolved promise.
// If the promise is rejected, abort the ceremony with a user-visible error,
// or otherwise guide the user experience as might be determinable from the
// context available in the rejected promise.
const credential = await navigator.credentials.create({ publicKey: options });

// 3. Let response be credential.response. If response is not an instance of
// AuthenticatorAttestationResponse, abort the ceremony with a user-visible error.
if (!(credential?.response instanceof AuthenticatorAttestationResponse)) {
throw new Error('The authenticator failed to provide a valid credential.');
}

// Continue the ceremony on the server.
// See: https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-tojson
return JSON.stringify(credential);
}
Copy link
Member Author

@MackinnonBuck MackinnonBuck May 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could move this code to a package, but it's so small that I think it's safe to include it in the template.
However, I'm considering changing the options parameter here to represent the top-level object passed to the navigator.credentials.create() function instead of representing the publicKey property. That's because the WebAuthn level 3 spec references another top-level property, mediation in the ceremony, and we might want a way to configure it (and other not-yet-introduced properties) in the future.

Also, this script assumes it's loaded as part of a full page navigation, not an enhanced navigation. This strategy currently works because the script gets rendered as a result of a non-enhanced form post.

Comment on lines +31 to +41
[
new(COSEAlgorithmIdentifier.ES256),
new(COSEAlgorithmIdentifier.PS256),
new(COSEAlgorithmIdentifier.ES384),
new(COSEAlgorithmIdentifier.PS384),
new(COSEAlgorithmIdentifier.PS512),
new(COSEAlgorithmIdentifier.RS256),
new(COSEAlgorithmIdentifier.ES512),
new(COSEAlgorithmIdentifier.RS384),
new(COSEAlgorithmIdentifier.RS512),
];
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This list does not include RS1, even though the FIDO conformance testing tool checks for it. Maybe we should make this list configurable.

@MackinnonBuck MackinnonBuck marked this pull request as ready for review May 28, 2025 22:37
@MackinnonBuck MackinnonBuck requested review from halter73, a team and wtgodbe as code owners May 28, 2025 22:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-identity Includes: Identity and providers
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Passkeys Authentication support in ASP.NET Core
2 participants