aspnetcore icon indicating copy to clipboard operation
aspnetcore copied to clipboard

[API Proposal] Passkeys in ASP.NET Core Identity

Open MackinnonBuck opened this issue 6 months ago • 6 comments

Passkeys in ASP.NET Core Identity

Proposes new APIs for passkey support in ASP.NET Core Identity.

Background and Motivation

Passkeys are a modern, phishing-resistant authentication method based on the WebAuthn and FIDO2 standards. They provide a significant security improvement over traditional passwords by relying on public key cryptography and device-based authentication. In addition to enhancing security, passkeys offer a more seamless and user-friendly sign-in experience.

There is growing industry momentum behind passkeys as a replacement for passwords. Major platforms and browsers have adopted support, and user expectations are shifting accordingly. Customers building web applications with ASP.NET Core have expressed strong interest in out-of-the-box support for passkey-based authentication (https://github.com/dotnet/aspnetcore/issues/53467).

To address this, we intend to add passkey support to the ASP.NET Core Web project templates and first-class support for passkeys in ASP.NET Core Identity.

The APIs proposed in this issue are deliberately scoped to serve ASP.NET Core Identity scenarios. They are not intended to provide general-purpose WebAuthn or FIDO2 functionality. Developers who need broader or lower-level support are encouraged to continue using existing community libraries that provide full access to the WebAuthn specification. Our goal is to provide a focused and well-integrated solution that covers the most common use cases for passkey-based registration and login in ASP.NET Core applications using Identity.

Proposed API

Implemented in https://github.com/dotnet/aspnetcore/pull/62112

[!NOTE] Some XML docs have been omitted for brevity

Microsoft.AspNetCore.Identity

Expand to view
namespace Microsoft.AspNetCore.Identity;

// Existing type. Only new members shown.
public class SignInManager<TUser>
    where TUser : class
{
    /// <summary>
    /// Creates a new instance of <see cref="SignInManager{TUser}"/>.
    /// </summary>
    /// <param name="userManager">An instance of <see cref="UserManager"/> used to retrieve users from and persist users.</param>
    /// <param name="contextAccessor">The accessor used to access the <see cref="HttpContext"/>.</param>
    /// <param name="claimsFactory">The factory to use to create claims principals for a user.</param>
    /// <param name="optionsAccessor">The accessor used to access the <see cref="IdentityOptions"/>.</param>
    /// <param name="logger">The logger used to log messages, warnings and errors.</param>
    /// <param name="schemes">The scheme provider that is used enumerate the authentication schemes.</param>
    /// <param name="confirmation">The <see cref="IUserConfirmation{TUser}"/> used check whether a user account is confirmed.</param>
    /// <param name="passkeyHandler">The <see cref="IPasskeyHandler{TUser}"/> used when performing passkey attestation and assertion.</param>
    public SignInManager(
        UserManager<TUser> userManager,
        IHttpContextAccessor contextAccessor,
        IUserClaimsPrincipalFactory<TUser> claimsFactory,
        IOptions<IdentityOptions> optionsAccessor,
        ILogger<SignInManager<TUser>> logger,
        IAuthenticationSchemeProvider schemes,
        IUserConfirmation<TUser> confirmation,
        IPasskeyHandler<TUser> passkeyHandler);

    /// <summary>
    /// Performs passkey attestation for the given <paramref name="credentialJson"/> and <paramref name="options"/>.
    /// </summary>
    /// <param name="credentialJson">The credentials obtained by JSON-serializing the result of the <c>navigator.credentials.create()</c> JavaScript function.</param>
    /// <param name="options">The original passkey creation options provided to the browser.</param>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyAttestationResult"/>.
    /// </returns>
    public virtual async Task<PasskeyAttestationResult> PerformPasskeyAttestationAsync(string credentialJson, PasskeyCreationOptions options);

    /// <summary>
    /// Performs passkey assertion for the given <paramref name="credentialJson"/> and <paramref name="options"/>.
    /// </summary>
    /// <param name="credentialJson">The credentials obtained by JSON-serializing the result of the <c>navigator.credentials.get()</c> JavaScript function.</param>
    /// <param name="options">The original passkey creation options provided to the browser.</param>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyAssertionResult{TUser}"/>.
    /// </returns>
    public virtual async Task<PasskeyAssertionResult<TUser>> PerformPasskeyAssertionAsync(string credentialJson, PasskeyRequestOptions options);

    /// <summary>
    /// Attempts to sign in the user with a passkey.
    /// </summary>
    /// <param name="credentialJson">The credentials obtained by JSON-serializing the result of the <c>navigator.credentials.get()</c> JavaScript function.</param>
    /// <param name="options">The original passkey request options provided to the browser.</param>
    /// <returns>
    /// The task object representing the asynchronous operation containing the <see cref="SignInResult"/>
    /// for the sign-in attempt.
    /// </returns>
    public virtual async Task<SignInResult> PasskeySignInAsync(string credentialJson, PasskeyRequestOptions options);

    /// <summary>
    /// Generates a <see cref="PasskeyCreationOptions"/> and stores it in the current <see cref="HttpContext"/> for later retrieval.
    /// </summary>
    /// <param name="creationArgs">Args for configuring the <see cref="PasskeyCreationOptions"/>.</param>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyCreationOptions"/>.
    /// </returns>
    public virtual async Task<PasskeyCreationOptions> ConfigurePasskeyCreationOptionsAsync(PasskeyCreationArgs creationArgs);

    /// <summary>
    /// Generates a <see cref="PasskeyRequestOptions"/> and stores it in the current <see cref="HttpContext"/> for later retrieval.
    /// </summary>
    /// <param name="requestArgs">Args for configuring the <see cref="PasskeyRequestOptions"/>.</param>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyRequestOptions"/>.
    /// </returns>
    public virtual async Task<PasskeyRequestOptions> ConfigurePasskeyRequestOptionsAsync(PasskeyRequestArgs<TUser> requestArgs);

    /// <summary>
    /// Generates a <see cref="PasskeyCreationOptions"/> to create a new passkey for a user.
    /// </summary>
    /// <param name="creationArgs">Args for configuring the <see cref="PasskeyCreationOptions"/>.</param>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyCreationOptions"/>.
    /// </returns>
    public virtual async Task<PasskeyCreationOptions> GeneratePasskeyCreationOptionsAsync(PasskeyCreationArgs creationArgs);

    /// <summary>
    /// Generates a <see cref="PasskeyRequestOptions"/> to request an existing passkey for a user.
    /// </summary>
    /// <param name="requestArgs">Args for configuring the <see cref="PasskeyRequestOptions"/>.</param>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyRequestOptions"/>.
    /// </returns>
    public virtual async Task<PasskeyRequestOptions> GeneratePasskeyRequestOptionsAsync(PasskeyRequestArgs<TUser>? requestArgs);

    /// <summary>
    /// Retrieves the <see cref="PasskeyCreationOptions"/> stored in the current <see cref="HttpContext"/>.
    /// </summary>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyCreationOptions"/>.
    /// </returns>
    public virtual async Task<PasskeyCreationOptions?> RetrievePasskeyCreationOptionsAsync();

    /// <summary>
    /// Retrieves the <see cref="PasskeyRequestOptions"/> stored in the current <see cref="HttpContext"/>.
    /// </summary>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyRequestOptions"/>.
    /// </returns>
    public virtual async Task<PasskeyRequestOptions?> RetrievePasskeyRequestOptionsAsync();
}

/// <summary>
/// Represents arguments for generating <see cref="PasskeyCreationOptions"/>.
/// </summary>
public sealed class PasskeyCreationArgs
{
    /// <summary>
    /// Constructs a new <see cref="PasskeyCreationArgs">.
    /// </summary>
    /// <param name="userEntity">The user entity to be associated with the passkey.</param>
    public PasskeyCreationArgs(PasskeyUserEntity userEntity);

    public PasskeyUserEntity UserEntity { get; }
    public AuthenticatorSelectionCriteria? AuthenticatorSelection { get; set; }
    public string Attestation { get; set; } = "none";
    public JsonElement? Extensions { get; set; }
}

/// <summary>
/// Represents options for creating a passkey.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialcreationoptions"/>.
/// </remarks>
public sealed class PasskeyCreationOptions
{
    /// <summary>
    /// Constructs a new <see cref="PasskeyCreationOptions">.
    /// </summary>
    /// <param name="userEntity">The user entity associated with the passkey.</param>
    /// <param name="optionsJson">The JSON representation of the options.</param>
    public PasskeyCreationOptions(PasskeyUserEntity userEntity, string optionsJson);

    public PasskeyUserEntity UserEntity { get; } = userEntity;

    /// <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();

    // Same XML docs and implementation as AsJson()
    public override string ToString();
}

/// <summary>
/// Represents arguments for generating <see cref="PasskeyRequestOptions"/>.
/// </summary>
public sealed class PasskeyRequestArgs<TUser>
    where TUser : class
{
    /// <summary>
    /// Gets or sets the user verification requirement.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-userverification"/>.
    /// Possible values are "required", "preferred", and "discouraged".
    /// The default value is "preferred".
    /// </remarks>
    public string UserVerification { get; set; } = "preferred";

    /// <summary>
    /// Gets or sets the user to be authenticated.
    /// </summary>
    /// <remarks>
    /// While this value is optional, it should be specified if the authenticating
    /// user can be identified. This can happen if, for example, the user provides
    /// a username before signing in with a passkey.
    /// </remarks>
    public TUser? User { get; set; }

    public JsonElement? Extensions { get; set; }
}

/// <summary>
/// Represents options for a passkey request.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptions"/>.
/// </remarks>
public sealed class PasskeyRequestOptions
{
    /// <summary>
    /// Constructs a new <see cref="PasskeyRequestOptions"/>.
    /// </summary>
    /// <param name="userId">The ID of the user for whom this request is made.</param>
    /// <param name="optionsJson">The JSON representation of the options.</param>
    public PasskeyRequestOptions(string? userId, string optionsJson);

    public string? UserId { get; } = userId;

    /// <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-publickeycredentialrequestoptionsjson"/>.
    /// </remarks>
    public string AsJson();

    // Same XML docs and implementation as AsJson()
    public override string ToString();
}

/// <summary>
/// Represents information about the user associated with a passkey.
/// </summary>
public sealed class PasskeyUserEntity
{
    /// <summary>
    /// Constructs a new <see cref="PasskeyUserEntity">.
    /// </summary>
    /// <param name="id">The user ID.</param>
    /// <param name="name">The name of the user.</param>
    /// <param name="displayName">The display name of the user. When omitted, defaults to <paramref name="name"/>.</param>
    public PasskeyUserEntity(string id, string name, string? displayName);

    public string Id { get; }
    public string Name { get; }
    public string DisplayName { get; }
}

/// <summary>
/// Used to specify requirements regarding authenticator attributes.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#dictdef-authenticatorselectioncriteria"/>.
/// </remarks>
public sealed class AuthenticatorSelectionCriteria
{
    /// <summary>
    /// Gets or sets the authenticator attachment.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-authenticatorattachment"/>.
    /// </remarks>
    public string? AuthenticatorAttachment { get; set; }

    /// <summary>
    /// Gets or sets the extent to which the server desires to create a client-side discoverable credential.
    /// Supported values are "discouraged", "preferred", or "required".
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-residentkey"/>
    /// </remarks>
    public string? ResidentKey { get; set; }

    /// <summary>
    /// Gets whether a resident key is required.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-requireresidentkey"/>.
    /// </remarks>
    public bool RequireResidentKey { get; }

    /// <summary>
    /// Gets or sets the user verification requirement.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-userverification"/>.
    /// </remarks>
    public string UserVerification { get; set; } = "preferred";
}

/// <summary>
/// Represents a handler for passkey assertion and attestation.
/// </summary>
public interface IPasskeyHandler<TUser>
    where TUser : class
{
    /// <summary>
    /// Performs passkey attestation using the provided credential JSON and original options JSON.
    /// </summary>
    /// <param name="context">The context containing necessary information for passkey attestation.</param>
    /// <returns>A task object representing the asynchronous operation containing the <see cref="PasskeyAttestationResult"/>.</returns>
    Task<PasskeyAttestationResult> PerformAttestationAsync(PasskeyAttestationContext<TUser> context);

    /// <summary>
    /// Performs passkey assertion using the provided credential JSON, original options JSON, and optional user.
    /// </summary>
    /// <param name="context">The context containing necessary information for passkey assertion.</param>
    /// <returns>A task object representing the asynchronous operation containing the <see cref="PasskeyAssertionResult{TUser}"/>.</returns>
    Task<PasskeyAssertionResult<TUser>> PerformAssertionAsync(PasskeyAssertionContext<TUser> context);
}

/// <summary>
/// Represents the context for passkey attestation.
/// </summary>
/// <typeparam name="TUser">The type of user associated with the passkey.</typeparam>
public sealed class PasskeyAttestationContext<TUser>
    where TUser : class
{
    /// <summary>
    /// Gets or sets the credentials obtained by JSON-serializing the result of the
    /// <c>navigator.credentials.create()</c> JavaScript function.
    /// </summary>
    public required string CredentialJson { get; init; }

    /// <summary>
    /// Gets or sets the JSON representation of the original passkey creation options provided to the browser.
    /// </summary>
    public required string OriginalOptionsJson { get; init; }

    /// <summary>
    /// Gets or sets the <see cref="UserManager{TUser}"/> to retrieve user information from.
    /// </summary>
    public required UserManager<TUser> UserManager { get; init; }

    /// <summary>
    /// Gets or sets the <see cref="HttpContext"/> for the current request. 
    /// </summary>
    public required HttpContext HttpContext { get; init; }
}

/// <summary>
/// Represents the context for passkey assertion.
/// </summary>
/// <typeparam name="TUser">The type of user associated with the passkey.</typeparam>
public sealed class PasskeyAssertionContext<TUser>
    where TUser : class
{
    /// <summary>
    /// Gets or sets the user associated with the passkey, if known.
    /// </summary>
    public TUser? User { get; init; }

    /// <summary>
    /// Gets or sets the credentials obtained by JSON-serializing the result of the
    /// <c>navigator.credentials.get()</c> JavaScript function.
    /// </summary>
    public required string CredentialJson { get; init; }

    /// <summary>
    /// Gets or sets the JSON representation of the original passkey creation options provided to the browser.
    /// </summary>
    public required string OriginalOptionsJson { get; init; }

    /// <summary>
    /// Gets or sets the <see cref="UserManager{TUser}"/> to retrieve user information from.
    /// </summary>
    public required UserManager<TUser> UserManager { get; init; }

    /// <summary>
    /// Gets or sets the <see cref="HttpContext"/> for the current request. 
    /// </summary>
    public required HttpContext HttpContext { get; init; }
}

/// <summary>
/// The default passkey handler.
/// </summary>
public sealed partial class DefaultPasskeyHandler<TUser> : IPasskeyHandler<TUser>
    where TUser : class
{
    public DefaultPasskeyHandler(IOptions<IdentityOptions> options);
    public Task<PasskeyAttestationResult> PerformAttestationAsync(PasskeyAttestationContext<TUser> context);
    public Task<PasskeyAssertionResult<TUser>> PerformAssertionAsync(PasskeyAssertionContext<TUser> context);
    protected virtual Task<PasskeyAttestationResult> PerformAttestationCoreAsync(PasskeyAttestationContext<TUser> context);
    protected virtual Task<PasskeyAssertionResult<TUser>> PerformAssertionCoreAsync(PasskeyAssertionContext<TUser> context);
    protected virtual Task<bool> IsValidOriginAsync(PasskeyOriginInfo originInfo, HttpContext httpContext);
    protected virtual Task<bool> VerifyAttestationStatementAsync(ReadOnlyMemory<byte> attestationObject, ReadOnlyMemory<byte> clientDataHash, HttpContext httpContext);
}

/// <summary>
/// Contains information used for determining whether a passkey's origin is valid.
/// </summary>
public readonly struct PasskeyOriginInfo
{
    /// <summary>
    /// Constructs a new <see cref="PasskeyOriginInfo"/>.
    /// </summary>
    /// <param name="origin">The fully-qualified origin of the requester.</param>
    /// <param name="crossOrigin">Whether the request came from a cross-origin <c>&lt;iframe&gt;</c></param>
    public PasskeyOriginInfo(string origin, bool crossOrigin);

    /// <summary>
    /// Gets the fully-qualified origin of the requester.
    /// </summary>
    public string Origin { get; }

    /// <summary>
    /// Gets whether the request came from a cross-origin <c>&lt;iframe&gt;</c>.
    /// </summary>
    public bool CrossOrigin { get; }
}

/// <summary>
/// Represents an error that occurred during passkey attestation or assertion.
/// </summary>
public sealed class PasskeyException : Exception
{
    public PasskeyException(string message);
    public PasskeyException(string message, Exception? innerException);
}

/// <summary>
/// Represents the result of a passkey attestation operation.
/// </summary>
public sealed class PasskeyAttestationResult
{
    [MemberNotNullWhen(true, nameof(Passkey))]
    [MemberNotNullWhen(false, nameof(Failure))]
    public bool Succeeded { get; }

    public UserPasskeyInfo? Passkey { get; }
    public PasskeyException? Failure { get; }
    public static PasskeyAttestationResult Success(UserPasskeyInfo passkey);
    public static PasskeyAttestationResult Fail(PasskeyException failure);
}

/// <summary>
/// Represents the result of a passkey assertion operation.
/// </summary>
public sealed class PasskeyAssertionResult<TUser>
    where TUser : class
{
    [MemberNotNullWhen(true, nameof(Passkey))]
    [MemberNotNullWhen(true, nameof(User))]
    [MemberNotNullWhen(false, nameof(Failure))]
    public bool Succeeded { get; }

    public UserPasskeyInfo? Passkey { get; }
    public TUser? User { get; }
    public PasskeyException? Failure { get; }
}

/// <summary>
/// A factory class for creating instances of <see cref="PasskeyAssertionResult{TUser}"/>.
/// </summary>
public static class PasskeyAssertionResult
{
    public static PasskeyAssertionResult<TUser> Success<TUser>(UserPasskeyInfo passkey, TUser user)
        where TUser : class;
    public static PasskeyAssertionResult<TUser> Fail<TUser>(PasskeyException failure)
        where TUser : class;
}

Microsoft.Extensions.Identity.Core

Expand to view
public class IdentityOptions
{
+    public PasskeyOptions Passkey { get; set; }
}
/// <summary>
/// Specifies options for passkey requirements.
/// </summary>
public class PasskeyOptions
{
    /// <summary>
    /// Gets or sets the time that the server is willing to wait for a passkey operation to complete.
    /// </summary>
    /// <remarks>
    /// The default value is 1 minute.
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-timeout"/>
    /// and <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-timeout"/>.
    /// </remarks>
    public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(1);

    /// <summary>
    /// The size of the challenge in bytes sent to the client during WebAuthn attestation and assertion.
    /// </summary>
    /// <remarks>
    /// The default value is 16 bytes.
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-challenge"/>
    /// and <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-challenge"/>.
    /// </remarks>
    public int ChallengeSize { get; set; } = 16;

    /// <summary>
    /// The effective domain of the server. Should be unique and will be used as the identity for the server.
    /// </summary>
    /// <remarks>
    /// If left <see langword="null"/>, the server's origin may be used instead.
    /// See <see href="https://www.w3.org/TR/webauthn-3/#rp-id"/>.
    /// </remarks>
    public string? ServerDomain { get; set; }

    /// <summary>
    /// Gets or sets the allowed origins for credential registration and assertion.
    /// When specified, these origins are explicitly allowed in addition to any origins allowed by other settings.
    /// </summary>
    public IList<string> AllowedOrigins { get; set; } = [];

    /// <summary>
    /// Gets or sets whether the current server's origin should be allowed for credentials.
    /// When true, the origin of the current request will be automatically allowed.
    /// </summary>
    /// <remarks>
    /// The default value is <see langword="true"/>.
    /// </remarks>
    public bool AllowCurrentOrigin { get; set; } = true;

    /// <summary>
    /// Gets or sets whether credentials from cross-origin iframes should be allowed.
    /// </summary>
    /// <remarks>
    /// The default value is <see langword="false"/>.
    /// </remarks>
    public bool AllowCrossOriginIframes { get; set; }

    /// <summary>
    /// Whether or not to accept a backup eligible credential.
    /// </summary>
    /// <remarks>
    /// The default value is <see cref="CredentialBackupPolicy.Allowed"/>.
    /// </remarks>
    public CredentialBackupPolicy BackupEligibleCredentialPolicy { get; set; } = CredentialBackupPolicy.Allowed;

    /// <summary>
    /// Whether or not to accept a backed up credential.
    /// </summary>
    /// <remarks>
    /// The default value is <see cref="CredentialBackupPolicy.Allowed"/>.
    /// </remarks>
    public CredentialBackupPolicy BackedUpCredentialPolicy { get; set; } = CredentialBackupPolicy.Allowed;

    /// <summary>
    /// Represents the policy for credential backup eligibility and backup status.
    /// </summary>
    public enum CredentialBackupPolicy
    {
        /// <summary>
        /// Indicates that the credential backup eligibility or backup status is required.
        /// </summary>
        Required = 0,

        /// <summary>
        /// Indicates that the credential backup eligibility or backup status is allowed, but not required.
        /// </summary>
        Allowed = 1,

        /// <summary>
        /// Indicates that the credential backup eligibility or backup status is disallowed.
        /// </summary>
        Disallowed = 2,
    }
}

/// <summary>
/// Provides an abstraction for storing passkey credentials for a user.
/// </summary>
/// <typeparam name="TUser">The type that represents a user.</typeparam>
public interface IUserPasskeyStore<TUser> : IUserStore<TUser>
    where TUser : class
{
    /// <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);

    /// <summary>
    /// Gets the passkey credentials for the specified <paramref name="user"/>.
    /// </summary>
    /// <param name="user">The user whose passkeys should be retrieved.</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, containing a list of the user's passkeys.</returns>
    Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user, CancellationToken cancellationToken);

    /// <summary>
    /// Finds and returns a user, if any, associated with the specified passkey credential identifier.
    /// </summary>
    /// <param name="credentialId">The passkey credential id to search for.</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, containing the user, if any, associated with the specified passkey credential id.
    /// </returns>
    Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken);

    /// <summary>
    /// Finds a passkey for the specified user with the specified credential id.
    /// </summary>
    /// <param name="user">The user whose passkey should be retrieved.</param>
    /// <param name="credentialId">The credential id to search for.</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, containing the user's passkey information.</returns>
    Task<UserPasskeyInfo?> FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);

    /// <summary>
    /// Removes a passkey credential from the specified <paramref name="user"/>.
    /// </summary>
    /// <param name="user">The user to remove the passkey credential from.</param>
    /// <param name="credentialId">The credential id of the passkey to remove.</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 RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
}

/// <summary>
/// Provides information for a user's passkey credential.
/// </summary>
public class UserPasskeyInfo
{
    /// <summary>
    /// Initializes a new instance of <see cref="UserPasskeyInfo"/>.
    /// </summary>
    /// <param name="credentialId">The credential ID for the passkey.</param>
    /// <param name="publicKey">The public key for the passkey.</param>
    /// <param name="name">The friendly name for the passkey.</param>
    /// <param name="createdAt">The time when the passkey was created.</param>
    /// <param name="signCount">The signature counter for the passkey.</param>
    /// <param name="transports">The transports supported by this passkey.</param>
    /// <param name="isUserVerified">Indicates if the passkey has a verified user.</param>
    /// <param name="isBackupEligible">Indicates if the passkey is eligible for backup.</param>
    /// <param name="isBackedUp">Indicates if the passkey is currently backed up.</param>
    /// <param name="attestationObject">The passkey's attestation object.</param>
    /// <param name="clientDataJson">The passkey's client data JSON.</param>
    public UserPasskeyInfo(
        byte[] credentialId,
        byte[] publicKey,
        string? name,
        DateTimeOffset createdAt,
        uint signCount,
        string[]? transports,
        bool isUserVerified,
        bool isBackupEligible,
        bool isBackedUp,
        byte[] attestationObject,
        byte[] clientDataJson);

    public byte[] CredentialId { get; }
    public byte[] PublicKey { get; }
    public string? Name { get; set; }
    public DateTimeOffset CreatedAt { get; }
    public uint SignCount { get; set; }
    public string[]? Transports { get; }
    public bool IsUserVerified { get; set; }
    public bool IsBackupEligible { get; }
    public bool IsBackedUp { get; set; }
    public byte[] AttestationObject { get; }
    public byte[] ClientDataJson { get; }
}
public static class IdentitySchemaVersions
{
+    /// <summary>
+    /// Represents the 3.0 version of the identity schema
+    /// </summary>
+    public static readonly Version Version3 = new Version(3, 0);
}

public class UserManager<TUser> : IDisposable
    where TUser : class
{
+    public virtual bool SupportsUserPasskey { get; }

+    /// <summary>
+    /// Adds a new passkey for the given user or updates an existing one.
+    /// </summary>
+    /// <param name="user">The user for whom the passkey should be added or updated.</param>
+    /// <param name="passkey">The passkey to add or update.</param>
+    /// <returns>Whether the passkey was successfully set.</returns>
+    public virtual async Task<IdentityResult> SetPasskeyAsync(TUser user, UserPasskeyInfo passkey);

+    /// <summary>
+    /// Gets a user's passkeys.
+    /// </summary>
+    /// <param name="user">The user whose passkeys should be retrieved.</param>
+    /// <returns>A list of the user's passkeys.</returns>
+    public virtual Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user);

+    /// <summary>
+    /// Finds a user's passkey by its credential id.
+    /// </summary>
+    /// <param name="user">The user whose passkey should be retrieved.</param>
+    /// <param name="credentialId">The credential ID to search for.</param>
+    /// <returns>The passkey, or <see langword="null"/> if it doesn't exist.</returns>
+    public virtual Task<UserPasskeyInfo?> GetPasskeyAsync(TUser user, byte[] credentialId);

+    /// <summary>
+    /// Finds the user associated with a passkey.
+    /// </summary>
+    /// <param name="credentialId">The credential ID to search for.</param>
+    /// <returns>The user associated with the passkey.</returns>
+    public virtual Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId);

+    /// <summary>
+    /// Removes a passkey credential from a user.
+    /// </summary>
+    /// <param name="user">The user whose passkey should be removed.</param>
+    /// <param name="credentialId">The credential id of the passkey to remove.</param>
+    /// <returns>Whether the passkey was successfully removed.</returns>
+    public virtual async Task<IdentityResult> RemovePasskeyAsync(TUser user, byte[] credentialId);
}

Microsoft.Extensions.Identity.Stores

Expand to view
namespace Microsoft.AspNetCore.Identity;

/// <summary>
/// Represents a passkey credential for a user in the identity system.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#credential-record"/>.
/// </remarks>
/// <typeparam name="TKey">The type used for the primary key for this passkey credential.</typeparam>
public class IdentityUserPasskey<TKey>
    where TKey : IEquatable<TKey>
{
    public virtual TKey UserId { get; set; }
    public virtual byte[] CredentialId { get; set; }
    public virtual byte[] PublicKey { get; set; }
    public virtual string? Name { get; set; }
    public virtual DateTimeOffset CreatedAt { get; set; }
    public virtual uint SignCount { get; set; }
    public virtual string[]? Transports { get; set; }
    public virtual bool IsUserVerified { get; set; }
    public virtual bool IsBackupEligible { get; set; }
    public virtual bool IsBackedUp { get; set; }
    public virtual byte[] AttestationObject { get; set; }
    public virtual byte[] ClientDataJson { get; set; }
}

Microsoft.AspNetCore.Identity.EntityFrameworkCore

Expand to view
namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore;

-public abstract class IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken> :
-   IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken>
+public class IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken> :
+   IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TRole : IdentityRole<TKey>
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>
    where TUserRole : IdentityUserRole<TKey>
    where TUserLogin : IdentityUserLogin<TKey>
    where TRoleClaim : IdentityRoleClaim<TKey>
    where TUserToken : IdentityUserToken<TKey>
{
+    public IdentityDbContext(DbContextOptions options);
+    protected IdentityDbContext();
}

+public abstract class IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken, TUserPasskey> :
+    IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken, TUserPasskey>
+    where TUser : IdentityUser<TKey>
+    where TRole : IdentityRole<TKey>
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>
+    where TUserRole : IdentityUserRole<TKey>
+    where TUserLogin : IdentityUserLogin<TKey>
+    where TRoleClaim : IdentityRoleClaim<TKey>
+    where TUserToken : IdentityUserToken<TKey>
+    where TUserPasskey : IdentityUserPasskey<TKey>
+{
    // Members from IdentityDbContext`8 moved here
+}

-public abstract class IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken> :
-    DbContext
+public class IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken> :
+    IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>
    where TUserLogin : IdentityUserLogin<TKey>
    where TUserToken : IdentityUserToken<TKey>
{
+    public IdentityUserContext(DbContextOptions options);
+    protected IdentityUserContext();
}

+/// <summary>
+/// Base class for the Entity Framework database context used for identity.
+/// </summary>
+/// <typeparam name="TUser">The type of user objects.</typeparam>
+/// <typeparam name="TKey">The type of the primary key for users and roles.</typeparam>
+/// <typeparam name="TUserClaim">The type of the user claim object.</typeparam>
+/// <typeparam name="TUserLogin">The type of the user login object.</typeparam>
+/// <typeparam name="TUserToken">The type of the user token object.</typeparam>
+/// <typeparam name="TUserPasskey">The type of the user passkey object.</typeparam>
+public abstract class IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken, TUserPasskey> : DbContext
+    where TUser : IdentityUser<TKey>
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>
+    where TUserLogin : IdentityUserLogin<TKey>
+    where TUserToken : IdentityUserToken<TKey>
+    where TUserPasskey : IdentityUserPasskey<TKey>
+{
+    /// <summary>
+    /// Gets or sets the <see cref="DbSet{TEntity}"/> of User passkeys.
+    /// </summary>
+    public virtual DbSet<TUserPasskey> UserPasskeys { get; set; }
+}

public class UserOnlyStore<TUser, TContext, TKey, TUserClaim, TUserLogin, TUserToken> :
-    UserStoreBase<TUser, TKey, TUserClaim, TUserLogin, TUserToken>
-    IUserLoginStore<TUser>,
-    IUserClaimStore<TUser>,
-    IUserPasswordStore<TUser>,
-    IUserSecurityStampStore<TUser>,
-    IUserEmailStore<TUser>,
-    IUserLockoutStore<TUser>,
-    IUserPhoneNumberStore<TUser>,
-    IQueryableUserStore<TUser>,
-    IUserTwoFactorStore<TUser>,
-    IUserAuthenticationTokenStore<TUser>,
-    IUserAuthenticatorKeyStore<TUser>,
-    IUserTwoFactorRecoveryCodeStore<TUser>,
-    IProtectedUserStore<TUser>
+    UserOnlyStore<TUser, TContext, TKey, TUserClaim, TUserLogin, TUserToken, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TContext : DbContext
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>, new()
    where TUserLogin : IdentityUserLogin<TKey>, new()
    where TUserToken : IdentityUserToken<TKey>, new()
{
+    public UserOnlyStore(TContext context, IdentityErrorDescriber? describer = null);
}

+public class UserOnlyStore<TUser, TContext, TKey, TUserClaim, TUserLogin, TUserToken, TUserPasskey> :
+    UserStoreBase<TUser, TKey, TUserClaim, TUserLogin, TUserToken>,
+    IUserLoginStore<TUser>,
+    IUserClaimStore<TUser>,
+    IUserPasswordStore<TUser>,
+    IUserSecurityStampStore<TUser>,
+    IUserEmailStore<TUser>,
+    IUserLockoutStore<TUser>,
+    IUserPhoneNumberStore<TUser>,
+    IQueryableUserStore<TUser>,
+    IUserTwoFactorStore<TUser>,
+    IUserAuthenticationTokenStore<TUser>,
+    IUserAuthenticatorKeyStore<TUser>,
+    IUserTwoFactorRecoveryCodeStore<TUser>,
+    IProtectedUserStore<TUser>,
+    IUserPasskeyStore<TUser>
+    where TUser : IdentityUser<TKey>
+    where TContext : DbContext
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>, new()
+    where TUserLogin : IdentityUserLogin<TKey>, new()
+    where TUserToken : IdentityUserToken<TKey>, new()
+    where TUserPasskey : IdentityUserPasskey<TKey>, new()
+{
    // Members from UserOnlyStore`6 moved here

+    /// <summary>
+    /// DbSet of user passkeys.
+    /// </summary>
+    protected DbSet<TUserPasskey> UserPasskeys { get; }

+    /// <summary>
+    /// Called to create a new instance of a <see cref="IdentityUserPasskey{TKey}"/>.
+    /// </summary>
+    /// <param name="user">The user.</param>
+    /// <param name="passkey">The passkey.</param>
+    /// <returns></returns>
+    protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo passkey);

+    /// <summary>
+    /// Find a passkey with the specified credential id for a user.
+    /// </summary>
+    /// <param name="userId">The user's id.</param>
+    /// <param name="credentialId">The credential id to search for.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
+    /// <returns>The user passkey if it exists.</returns>
+    protected virtual Task<TUserPasskey?> FindUserPasskeyAsync(TKey userId, byte[] credentialId, CancellationToken cancellationToken);

+    /// <summary>
+    /// Find a passkey with the specified credential id.
+    /// </summary>
+    /// <param name="credentialId">The credential id to search for.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
+    /// <returns>The user passkey if it exists.</returns>
+    protected virtual Task<TUserPasskey?> FindUserPasskeyByIdAsync(byte[] credentialId, CancellationToken cancellationToken);

+    public virtual async Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken);
+    public virtual async Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user, CancellationToken cancellationToken);
+    public virtual async Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken);
+    public virtual async Task<UserPasskeyInfo?> FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
+    public virtual async Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
+}

public class UserStore<TUser, TRole, TContext, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim> :
-    UserStoreBase<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim>,
-    IProtectedUserStore<TUser>
+    UserStore<TUser, TRole, TContext, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TRole : IdentityRole<TKey>
    where TContext : DbContext
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>, new()
    where TUserRole : IdentityUserRole<TKey>, new()
    where TUserLogin : IdentityUserLogin<TKey>, new()
    where TUserToken : IdentityUserToken<TKey>, new()
    where TRoleClaim : IdentityRoleClaim<TKey>, new()
{
+    public UserStore(TContext context, IdentityErrorDescriber? describer = null);
}

+/// <summary>
+/// Represents a new instance of a persistence store for the specified user and role types.
+/// </summary>
+/// <typeparam name="TUser">The type representing a user.</typeparam>
+/// <typeparam name="TRole">The type representing a role.</typeparam>
+/// <typeparam name="TContext">The type of the data context class used to access the store.</typeparam>
+/// <typeparam name="TKey">The type of the primary key for a role.</typeparam>
+/// <typeparam name="TUserClaim">The type representing a claim.</typeparam>
+/// <typeparam name="TUserRole">The type representing a user role.</typeparam>
+/// <typeparam name="TUserLogin">The type representing a user external login.</typeparam>
+/// <typeparam name="TUserToken">The type representing a user token.</typeparam>
+/// <typeparam name="TRoleClaim">The type representing a role claim.</typeparam>
+/// <typeparam name="TUserPasskey">The type representing a user passkey.</typeparam>
+public class UserStore<TUser, TRole, TContext, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim, TUserPasskey> :
+    UserStoreBase<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim>,
+    IProtectedUserStore<TUser>,
+    IUserPasskeyStore<TUser>
+    where TUser : IdentityUser<TKey>
+    where TRole : IdentityRole<TKey>
+    where TContext : DbContext
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>, new()
+    where TUserRole : IdentityUserRole<TKey>, new()
+    where TUserLogin : IdentityUserLogin<TKey>, new()
+    where TUserToken : IdentityUserToken<TKey>, new()
+    where TRoleClaim : IdentityRoleClaim<TKey>, new()
+    where TUserPasskey : IdentityUserPasskey<TKey>, new()
+{
    // Members from UserStore`9 moved here.

+    /// <summary>
+    /// Called to create a new instance of a <see cref="IdentityUserPasskey{TKey}"/>.
+    /// </summary>
+    /// <param name="user">The user.</param>
+    /// <param name="passkey">The passkey.</param>
+    /// <returns></returns>
+    protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo passkey);

+    /// <summary>
+    /// Find a passkey with the specified credential id for a user.
+    /// </summary>
+    /// <param name="userId">The user's id.</param>
+    /// <param name="credentialId">The credential id to search for.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
+    /// <returns>The user passkey if it exists.</returns>
+    protected virtual Task<TUserPasskey?> FindUserPasskeyAsync(TKey userId, byte[] credentialId, CancellationToken cancellationToken);

+    /// <summary>
+    /// Find a passkey with the specified credential id.
+    /// </summary>
+    /// <param name="credentialId">The credential id to search for.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
+    /// <returns>The user passkey if it exists.</returns>
+    protected virtual Task<TUserPasskey?> FindUserPasskeyByIdAsync(byte[] credentialId, CancellationToken cancellationToken);

+    public virtual async Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken)
+    public virtual async Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user, CancellationToken cancellationToken)
+    public virtual async Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken)
+    public virtual async Task<UserPasskeyInfo?> FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken)
+    public virtual async Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken)
+}

Usage Examples

Adding a passkey to an existing user

// Pre-creation of passkey: configure passkey creation options
async Task<string> GetPasskeyCreationOptionsJsonAsync<TUser>(
    TUser user,
    UserManager<TUser> userManager,
    SignInManager<TUser> signInManager)
    where TUser : class
{
    var userId = await userManager.GetUserIdAsync(user);
    var userName = await userManager.GetUserNameAsync(user) ?? "User";
    var userEntity = new PasskeyUserEntity(userId, userName, displayName: userName);
    var creationArgs = new PasskeyCreationArgs(userEntity)
    {
        AuthenticatorSelection = new AuthenticatorSelectionCriteria
        {
            ResidentKey = "required",
            UserVerification = "preferred",
        },
        Attestation = "none",
    };

    var options = await signInManager.ConfigurePasskeyCreationOptionsAsync(creationArgs);
    return options.AsJson(); // To be returned to the browser
}

// Post-creation of passkey: add the passkey to the user
async Task<IdentityResult> AddPasskeyToUserAsync<TUser>(
    TUser user,
    UserManager<TUser> userManager,
    SignInManager<TUser> signInManager,
    string credentialJson) // Received from the browser
    where TUser : class
{
    // Some error handling omitted for brevity
    var options = await signInManager.RetrievePasskeyCreationOptionsAsync()!;
    var attestationResult = await signInManager.PerformPasskeyAttestationAsync(CredentialJson, options);
    var setPasskeyResult = await userManager.SetPasskeyAsync(user, attestationResult.Passkey);
    return setPasskeyResult;
}

Creating a passwordless user account

// Pre-creation of passkey: configure passkey creation options
async Task<string> GetPasskeyCreationOptionsJsonAsync(
    UserManager<ApplicationUser> userManager,
    SignInManager<ApplicationUser> signInManager,
    string userName,
    string givenName)
    where TUser : IUserWithSettableId
{
    var userId = Guid.NewGuid().ToString();
    var userEntity = new PasskeyUserEntity(userId, userName, displayName: givenName);
    var creationArgs = new PasskeyCreationArgs(userEntity)
    {
        // ...
    };

    var options = await signInManager.ConfigurePasskeyCreationOptionsAsync(creationArgs);
    return options.AsJson(); // To be returned to the browser
}

// Post-creation of passkey: create a new account
async Task<IdentityResult> CreateAccountAsync(
    UserManager<ApplicationUser> userManager,
    SignInManager<ApplicationUser> signInManager,
    string credentialJson) // Received from the browser
    where TUser : class
{
    // Some error handling omitted for brevity
    var options = await signInManager.RetrievePasskeyCreationOptionsAsync()!;
    var attestationResult = await signInManager.PerformPasskeyAttestationAsync(credentialJson, options);
    var userEntity = options.UserEntity;
    var user = new ApplicationUser(userName: userEntity.Name)
    {
        Id = userEntity.Id,
    };
    var createUserResult = await userManager.CreateAsync(user);
    if (!createUserResult.Succeeded)
    {
        return createUserResult;
    }

    var setPasskeyResult = await userManager.SetPasskeyAsync(user, attestationResult.Passkey);
    return setPasskeyResult;
}

Signing in with a passkey

// Pre-retrieval of passkey: configure passkey request options
async Task<string> GetPasskeyRequestOptionsJsonAsync<TUser>(
    UserManager<TUser> userManager,
    SignInManager<TUser> signInManager,
    string? userName)
    where TUser : class
{
    // The username might be missing if the user didn't specify it.
    // In that case, the browser or authenticator will prompt the user to
    // select the account to sign in to.
    var user = string.IsNullOrEmpty(userName) ? null : await userManager.FindByNameAsync(userName);
    var requestArgs = new PasskeyRequestArgs<PocoUser>
    {
        User = user,
        UserVerification = "required",
    };

    var options = await signInManager.ConfigurePasskeyRequestOptionsAsync(requestArgs);
    return options.AsJson(); // To be returned to the browser
}

// Post-retrieval of passkey: sign in with the passkey
async Task<SignInResult> SignInWithPasskeyAsync<TUser>(
    SignInManager<TUser> signInManager,
    string credentialJson) // Received from the browser
{
    // Some error handling omitted for brevity
    var options = await signInManager.RetrievePasskeyRequestOptionsAsync()!;
    var signInResult = await signInManager.PasskeySignInAsync(credentialJson, options);
    return signInResult;
}

Alternative Designs

Some WebAuthn libraries from the community define public .NET types reflecting what's described in the WebAuthn specification. This creates a predictable API and enables convenient JSON-based communication between the browser and the server. The ASP.NET Core Identity implementation is similar, but it keeps WebAuthn-derived types internal for the following reasons:

  • This greatly decreases the amount of new public API that needs to be kept in sync with changes to the WebAuthn spec
  • We are free to adjust our implementation as the spec evolves with less concern of breaking customers
    • This could include completely removing some types in the future and e.g., replacing them with hand-rolled JSON parsing logic
  • In the future, if .NET provides a fully-featured WebAuthn library (not tied to Identity), we could easily adopt our implementation to make use of that library without having to maintain a separate set of abstractions

Because of this design decision, some APIs proposed in this issue accept and produce raw JSON strings matching schemas that browser APIs work with. It's up to the implementations of these .NET APIs to decide how to parse or generate these JSON strings.

An argument against this approach is that there's still an API here, it's just less visible. Even though this approach technically reduces the .NET public API surface, JSON payloads are just a different kind of API. However, JSON-based APIs are able to change freely without our immediate involvement, and customers are able to take advantage of the latest JSON representations if they're willing to provide an implementation that does so.

Risks

Despite efforts to minimize the public API surface, it's still quite large, as evidenced by the collapsible sections above. One potential risk is that the WebAuthn spec could change in a manner that we don't expect, requiring us to make uncomfortable API changes in the future.

MackinnonBuck avatar Jun 09 '25 22:06 MackinnonBuck

A lot of your types are internal sealed, e.g., everything in the PassKey folder in your PR, they are used in the DefaultPasskeyHandler, that one is public and a lot of methods are virtual. May I suggest reconsidering the internal approach here, I understand that it increases the public api surface, but most of the types in there directly represent a concept in the webauthn spec. If someone wants to extend them, either for testing or other purposes it might be useful to have them public. The main dependency I see in context types are for UserManager<TUser>, is it possible to abstract that a bit, so it does not rely on that ASP.NET Identity implementation type, this might allow others who do not fully use ASP.NET Identity to include these types, I think this is mostly FindByIdAsync and GetPasskeyAsync, which mostly point directly to IUserPasskeyStore. So I'd suggest using IUserPassKeyStore<TUser> there instead of UserManager<TUser>

You specifically mentioned that there are other solutions, both FOSS and commercial out there for a more complete WebAuthN experience. But maybe there is a middle ground between these and your current API proposal and I'm only talking about the API not about feature sets of WebAuthN which should or should not be supported.

I understand that you want to reduce the public API surface here, but I'd really to have a more decoupled approach from Identity.

Tornhoof avatar Jun 12 '25 06:06 Tornhoof

Looks good, thanks for your work on this Mackinnon. Based on what I can see, this new functionality is for passkeys only? I assume this won't accommodate a security key for a 2FA option?

I echo what Stefan suggested, hopefully some middle ground can be found (esp. wrt working w/ clientDataJSON & authenticatorData on the backend).

mguinness avatar Jun 12 '25 23:06 mguinness

@MackinnonBuck Before shipping this, I think we should consider adding default support for these APIs:

Signals API

Adding baked-in support for the signals API would have a long-lived and lasting positive effect on the passkey ecosystem and the UX of people using passkeys with ASP.NET Identity.

Not calling these API's will over time (due to users changing their data, removing/adding passkeys etc) cause authentication rot where the user will fail to sign in to the service because their credential manager surfaces old and defunct passkeys.

I think we should consider:

  • Returning the data used for signalUnknownCredential in the exception or as part of the response from PasskeySignInAsync when sign in fails.
  • Returning the data used for signalAllAcceptedCredentials, either as a separate method on UserManageror as part of a successful response fromPasskeySignInAsync`.
  • By default call signalUnknownCredential and signalAllAcceptedCredentials after sign in success / failure in the templates or relevant helper methods.
  • Add a method for retrieving the data for signalCurrentUserDetails data on the UserManager.
  • By default call signalCurrentUserDetails in templates after users have modified their data.
  • Add proper documentation to encourage developers to call these API's.

Add support for the /.well-known/passkey-endpoints

I think we should consider adding a configuration driven implementation for this endpoint. I'm thinking along the lines of adding two properties to IdentityOptions, e.g.

options.passkeyEnrollUrl = "/account/passkey-create";
options.passkeyManageUrl = "/account/passkey-management";

Give those two endpoints, we could automatically implement the following default endpoint response:

HTTP/1.1 200 OK
Content-Type: application/json

{
   "enroll": "https://example.com/account/manage/passkeys/create",
   "manage": "https://example.com/account/manage/passkeys"
}

Ensure support for conditional get and conditional create

If possible, I think the templates or client side helpers should have default for and encourage these two client-side calls. This will have have a long-lasting positive UX effect on RPs passkey implementation.

Since this PR does not reference blazor, I won't add additional detail, except the feedback that we need to think through the API's to make sure they cater to support mediation:conditional.

abergs avatar Jun 16 '25 08:06 abergs

@abergs there is a followup issue: https://github.com/dotnet/aspnetcore/issues/62347 which considers some of your points, not sure why it is not linked here.

Tornhoof avatar Jun 16 '25 08:06 Tornhoof

@Tornhoof: May I suggest reconsidering the internal approach here, I understand that it increases the public api surface, but most of the types in there directly represent a concept in the webauthn spec. If someone wants to extend them, either for testing or other purposes it might be useful to have them public. The main dependency I see in context types are for UserManager<TUser>, is it possible to abstract that a bit, so it does not rely on that ASP.NET Identity implementation type, this might allow others who do not fully use ASP.NET Identity to include these types, I think this is mostly FindByIdAsync and GetPasskeyAsync, which mostly point directly to IUserPasskeyStore. So I'd suggest using IUserPassKeyStore<TUser> there instead of UserManager<TUser>

Thanks for this feedback, @Tornhoof. I'll make sure that we discuss these points in the internal API review meeting. If we don't make these types public for the initial preview release, we might be able to structure the public API in a manner that allows us to expose these internal types in a later release.

@mguinness: Based on what I can see, this new functionality is for passkeys only? I assume this won't accommodate a security key for a 2FA option?

Correct, using security keys for 2FA is considered out of scope for this proposal.

@abergs: Before shipping this, I think we should consider adding default support for these APIs...

Awesome, this is great. I had captured some notes from our offline discussion and summarized some of your suggestions here - thanks for writing out some additional details here 🙂

MackinnonBuck avatar Jun 16 '25 14:06 MackinnonBuck

[!IMPORTANT] The most recent API comment can be found here

API Review Notes (Round 1)

  • Consider an API structure that allows us to decouple the parts of the passkey implementation from Identity in the future in case we eventually want to split it into its own package.
  • In light of the above, remove the UserManager property from PasskeyAttestationContext and PasskeyAssertionContext, instead injecting a UserManager<TUser> into the DefaultPasskeyHandler as a constructor parameter.
  • Should we change the PerformPasskeyAttestation and PerformPasskeyAssertion methods to accept a context object in case we need to add more arguments in the future?
    • Conclusion: We think it's fine as-is
  • Should we use ReadOnlyMemory<byte> instead of string for things that get written as UTF-8?
    • Conclusion: We don't think it's worth the complexity.
  • Rename ConfigurePasskey{Creation|Request}OptionsAsync to CreatePasskey{Creation|Request}OptionsAsync
  • Remove the GeneratePasskey*OptionsAsync methods, as we don't think it will be a common scenario to override how passkey options get stored between requests. We can consider adding new APIs for this in the future.
  • Also remove the RetrievePasskey*OptionsAsync methods from public API and instead call them directly from PerformPasskeyAttestationAsync and PerformPasskeyAssertionAsync. This allows us to remove the PasskeyCreationOptions and PasskeyRequestOptions arguments from PerformAttestationAsync and PerformAssertionAsync.
    • Post API-review thoughts: It's still helpful for the app to have access to the PasskeyCreationOptions, because it can use its UserEntity property to construct the user if it doesn't exist.
    • Counter-proposal: Keep RetrievePasskey*OptionsAsync and leave the args for PerformPasskeyAttestationAsync and PerformPasskeyAssertionAsync as they are, but rename RetrievePasskey*OptionsAsync to GetPasskey*OptionsAsync.
  • Should we rename PasskeyCreationArgs and PasskeyRequestArgs? It feels weird to have both PasskeyCreationArgs and PasskeyCreationOptions, as they sound like they'd have the same purpose
    • PasskeyCreationOptionsArgs?
    • PasskeyCreationOptionsCreationArgs?
    • Conclusion: Let's come back to this later
  • What's the right default value for PasskeyRequestArgs.UserVerification?
    • "preferred" is the default as described by the spec
    • We think "required" might be more secure
    • We could also make the default value null and rely on browser defaults
    • Conclusion: Make the default value null, but we'll review this decision in the upcoming security review
  • Should we remove TUser from PasskeyRequestArgs<TUser> and use a user ID instead?
    • Maybe, but then this can incur more user store lookups than necessary if the caller already has a TUser to provide.
    • Conclusion: It's fine as is.
  • Consider removing constructors where it makes sense and prefer required/init properties.
  • Consider making PasskeyUserEntity.DisplayName a property with a default value of null.

Questions for next round

  • Are we OK with the proposal to keep GetPasskey{Creation|Request}OptionsAsync?
  • Are we fine with having JsonElement in public API?
  • Review other changes since review:
    • Passkey{Creation|Request}Args
      • AsJson() -> Json property
      • Removal of ToString() overloads
      • Use of init-only properties

Updated API

We stopped at PasskeyUserEntity, so let's continue from there next time.

Microsoft.AspNetCore.Identity

Expand to view
namespace Microsoft.AspNetCore.Identity;

// Existing type. Only new members shown.
public class SignInManager<TUser>
    where TUser : class
{
    /// <summary>
    /// Creates a new instance of <see cref="SignInManager{TUser}"/>.
    /// </summary>
    /// <param name="userManager">An instance of <see cref="UserManager"/> used to retrieve users from and persist users.</param>
    /// <param name="contextAccessor">The accessor used to access the <see cref="HttpContext"/>.</param>
    /// <param name="claimsFactory">The factory to use to create claims principals for a user.</param>
    /// <param name="optionsAccessor">The accessor used to access the <see cref="IdentityOptions"/>.</param>
    /// <param name="logger">The logger used to log messages, warnings and errors.</param>
    /// <param name="schemes">The scheme provider that is used enumerate the authentication schemes.</param>
    /// <param name="confirmation">The <see cref="IUserConfirmation{TUser}"/> used check whether a user account is confirmed.</param>
    /// <param name="passkeyHandler">The <see cref="IPasskeyHandler{TUser}"/> used when performing passkey attestation and assertion.</param>
    public SignInManager(
        UserManager<TUser> userManager,
        IHttpContextAccessor contextAccessor,
        IUserClaimsPrincipalFactory<TUser> claimsFactory,
        IOptions<IdentityOptions> optionsAccessor,
        ILogger<SignInManager<TUser>> logger,
        IAuthenticationSchemeProvider schemes,
        IUserConfirmation<TUser> confirmation,
        IPasskeyHandler<TUser> passkeyHandler);

    /// <summary>
    /// Performs passkey attestation for the given <paramref name="credentialJson"/> and <paramref name="options"/>.
    /// </summary>
    /// <param name="credentialJson">The credentials obtained by JSON-serializing the result of the <c>navigator.credentials.create()</c> JavaScript function.</param>
    /// <param name="options">The original passkey creation options provided to the browser.</param>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyAttestationResult"/>.
    /// </returns>
    public virtual async Task<PasskeyAttestationResult> PerformPasskeyAttestationAsync(string credentialJson, PasskeyCreationOptions options);

    /// <summary>
    /// Performs passkey assertion for the given <paramref name="credentialJson"/> and <paramref name="options"/>.
    /// </summary>
    /// <param name="credentialJson">The credentials obtained by JSON-serializing the result of the <c>navigator.credentials.get()</c> JavaScript function.</param>
    /// <param name="options">The original passkey creation options provided to the browser.</param>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyAssertionResult{TUser}"/>.
    /// </returns>
    public virtual async Task<PasskeyAssertionResult<TUser>> PerformPasskeyAssertionAsync(string credentialJson, PasskeyRequestOptions options);

    /// <summary>
    /// Attempts to sign in the user with a passkey.
    /// </summary>
    /// <param name="credentialJson">The credentials obtained by JSON-serializing the result of the <c>navigator.credentials.get()</c> JavaScript function.</param>
    /// <param name="options">The original passkey request options provided to the browser.</param>
    /// <returns>
    /// The task object representing the asynchronous operation containing the <see cref="SignInResult"/>
    /// for the sign-in attempt.
    /// </returns>
    public virtual async Task<SignInResult> PasskeySignInAsync(string credentialJson, PasskeyRequestOptions options);

    /// <summary>
    /// Creates a <see cref="PasskeyCreationOptions"/> and stores it in the current <see cref="HttpContext"/> for later retrieval.
    /// </summary>
    /// <param name="creationArgs">Args for configuring the <see cref="PasskeyCreationOptions"/>.</param>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyCreationOptions"/>.
    /// </returns>
    public virtual async Task<PasskeyCreationOptions> CreatePasskeyCreationOptionsAsync(PasskeyCreationArgs creationArgs);

    /// <summary>
    /// Creates a <see cref="PasskeyRequestOptions"/> and stores it in the current <see cref="HttpContext"/> for later retrieval.
    /// </summary>
    /// <param name="requestArgs">Args for configuring the <see cref="PasskeyRequestOptions"/>.</param>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyRequestOptions"/>.
    /// </returns>
    public virtual async Task<PasskeyRequestOptions> CreatePasskeyRequestOptionsAsync(PasskeyRequestArgs<TUser> requestArgs);

    /// <summary>
    /// Gets the <see cref="PasskeyCreationOptions"/> stored in the current <see cref="HttpContext"/>.
    /// </summary>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyCreationOptions"/>.
    /// </returns>
    public virtual async Task<PasskeyCreationOptions?> GetPasskeyCreationOptionsAsync();

    /// <summary>
    /// Gets the <see cref="PasskeyRequestOptions"/> stored in the current <see cref="HttpContext"/>.
    /// </summary>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyRequestOptions"/>.
    /// </returns>
    public virtual async Task<PasskeyRequestOptions?> GetPasskeyRequestOptionsAsync();
}

/// <summary>
/// Represents arguments for generating <see cref="PasskeyCreationOptions"/>.
/// </summary>
public sealed class PasskeyCreationArgs
{
    /// <summary>
    /// Gets or sets the user entity to be associated with the passkey.
    /// </summary>
    public required PasskeyUserEntity UserEntity { get; init; }

    public AuthenticatorSelectionCriteria? AuthenticatorSelection { get; init; }
    public string? Attestation { get; init; }
    public JsonElement? Extensions { get; init; }
}

/// <summary>
/// Represents options for creating a passkey.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialcreationoptions"/>.
/// </remarks>
public sealed class PasskeyCreationOptions
{
    /// <summary>
    /// Gets or sets the user entity associated with the passkey.
    /// </summary>
    public required PasskeyUserEntity UserEntity { get; init; }

    /// <summary>
    /// Gets or sets 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 required string Json { get; init; }
}

/// <summary>
/// Represents arguments for generating <see cref="PasskeyRequestOptions"/>.
/// </summary>
public sealed class PasskeyRequestArgs<TUser>
    where TUser : class
{
    /// <summary>
    /// Gets or sets the user verification requirement.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-userverification"/>.
    /// Possible values are "required", "preferred", and "discouraged".
    /// When <see langword="null"/>, the browser default is "preferred".
    /// </remarks>
    public string? UserVerification { get; init; }

    /// <summary>
    /// Gets or sets the user to be authenticated.
    /// </summary>
    /// <remarks>
    /// While this value is optional, it should be specified if the authenticating
    /// user can be identified. This can happen if, for example, the user provides
    /// a username before signing in with a passkey.
    /// </remarks>
    public TUser? User { get; init; }

    public JsonElement? Extensions { get; init; }
}

/// <summary>
/// Represents options for a passkey request.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptions"/>.
/// </remarks>
public sealed class PasskeyRequestOptions
{
    /// <summary>
    /// Gets or sets the ID of the user for whom the request is made.
    /// </summary>
    public string? UserId { get; init; }

    /// <summary>
    /// Gets or sets 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-publickeycredentialrequestoptionsjson"/>.
    /// </remarks>
    public required string Json { get; init; }
}

/// <summary>
/// Represents information about the user associated with a passkey.
/// </summary>
public sealed class PasskeyUserEntity
{
    /// <summary>
    /// Gets or sets the user ID.
    /// </summary>
    public string Id { get; init; }

    /// <summary>
    /// Gets or sets the username.
    /// </summary>
    public string Name { get; init; }

    /// <summary>
    /// Gets or sets the user display name.
    /// </summary>
    public string DisplayName { get; init; }
}

/// <summary>
/// Used to specify requirements regarding authenticator attributes.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#dictdef-authenticatorselectioncriteria"/>.
/// </remarks>
public sealed class AuthenticatorSelectionCriteria
{
    /// <summary>
    /// Gets or sets the authenticator attachment.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-authenticatorattachment"/>.
    /// </remarks>
    public string? AuthenticatorAttachment { get; init; }

    /// <summary>
    /// Gets or sets the extent to which the server desires to create a client-side discoverable credential.
    /// Supported values are "discouraged", "preferred", or "required".
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-residentkey"/>
    /// </remarks>
    public string? ResidentKey { get; init; }

    /// <summary>
    /// Gets whether a resident key is required.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-requireresidentkey"/>.
    /// </remarks>
    public bool RequireResidentKey { get; init; }

    /// <summary>
    /// Gets or sets the user verification requirement.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-userverification"/>.
    /// </remarks>
    public string? UserVerification { get; init; }
}

/// <summary>
/// Represents a handler for passkey assertion and attestation.
/// </summary>
public interface IPasskeyHandler<TUser>
    where TUser : class
{
    /// <summary>
    /// Performs passkey attestation using the provided credential JSON and original options JSON.
    /// </summary>
    /// <param name="context">The context containing necessary information for passkey attestation.</param>
    /// <returns>A task object representing the asynchronous operation containing the <see cref="PasskeyAttestationResult"/>.</returns>
    Task<PasskeyAttestationResult> PerformAttestationAsync(PasskeyAttestationContext<TUser> context);

    /// <summary>
    /// Performs passkey assertion using the provided credential JSON, original options JSON, and optional user.
    /// </summary>
    /// <param name="context">The context containing necessary information for passkey assertion.</param>
    /// <returns>A task object representing the asynchronous operation containing the <see cref="PasskeyAssertionResult{TUser}"/>.</returns>
    Task<PasskeyAssertionResult<TUser>> PerformAssertionAsync(PasskeyAssertionContext<TUser> context);
}

/// <summary>
/// Represents the context for passkey attestation.
/// </summary>
/// <typeparam name="TUser">The type of user associated with the passkey.</typeparam>
public sealed class PasskeyAttestationContext<TUser>
    where TUser : class
{
    /// <summary>
    /// Gets or sets the credentials obtained by JSON-serializing the result of the
    /// <c>navigator.credentials.create()</c> JavaScript function.
    /// </summary>
    public required string CredentialJson { get; init; }

    /// <summary>
    /// Gets or sets the JSON representation of the original passkey creation options provided to the browser.
    /// </summary>
    public required string OriginalOptionsJson { get; init; }

    /// <summary>
    /// Gets or sets the <see cref="HttpContext"/> for the current request. 
    /// </summary>
    public required HttpContext HttpContext { get; init; }
}

/// <summary>
/// Represents the context for passkey assertion.
/// </summary>
/// <typeparam name="TUser">The type of user associated with the passkey.</typeparam>
public sealed class PasskeyAssertionContext<TUser>
    where TUser : class
{
    /// <summary>
    /// Gets or sets the user associated with the passkey, if known.
    /// </summary>
    public TUser? User { get; init; }

    /// <summary>
    /// Gets or sets the credentials obtained by JSON-serializing the result of the
    /// <c>navigator.credentials.get()</c> JavaScript function.
    /// </summary>
    public required string CredentialJson { get; init; }

    /// <summary>
    /// Gets or sets the JSON representation of the original passkey creation options provided to the browser.
    /// </summary>
    public required string OriginalOptionsJson { get; init; }

    /// <summary>
    /// Gets or sets the <see cref="HttpContext"/> for the current request. 
    /// </summary>
    public required HttpContext HttpContext { get; init; }
}

/// <summary>
/// The default passkey handler.
/// </summary>
public sealed partial class DefaultPasskeyHandler<TUser> : IPasskeyHandler<TUser>
    where TUser : class
{
    public DefaultPasskeyHandler(IOptions<IdentityOptions> options);
    public Task<PasskeyAttestationResult> PerformAttestationAsync(PasskeyAttestationContext<TUser> context);
    public Task<PasskeyAssertionResult<TUser>> PerformAssertionAsync(PasskeyAssertionContext<TUser> context);
    protected virtual Task<PasskeyAttestationResult> PerformAttestationCoreAsync(PasskeyAttestationContext<TUser> context);
    protected virtual Task<PasskeyAssertionResult<TUser>> PerformAssertionCoreAsync(PasskeyAssertionContext<TUser> context);
    protected virtual Task<bool> IsValidOriginAsync(PasskeyOriginInfo originInfo, HttpContext httpContext);
    protected virtual Task<bool> VerifyAttestationStatementAsync(ReadOnlyMemory<byte> attestationObject, ReadOnlyMemory<byte> clientDataHash, HttpContext httpContext);
}

/// <summary>
/// Contains information used for determining whether a passkey's origin is valid.
/// </summary>
public readonly struct PasskeyOriginInfo
{
    /// <summary>
    /// Gets or sets the fully-qualified origin of the requester.
    /// </summary>
    public required string Origin { get; init; }

    /// <summary>
    /// Gets or sets whether the request came from a cross-origin <c>&lt;iframe&gt;</c>.
    /// </summary>
    public required bool CrossOrigin { get; init; }
}

/// <summary>
/// Represents an error that occurred during passkey attestation or assertion.
/// </summary>
public sealed class PasskeyException : Exception
{
    public PasskeyException(string message);
    public PasskeyException(string message, Exception? innerException);
}

/// <summary>
/// Represents the result of a passkey attestation operation.
/// </summary>
public sealed class PasskeyAttestationResult
{
    [MemberNotNullWhen(true, nameof(Passkey))]
    [MemberNotNullWhen(false, nameof(Failure))]
    public bool Succeeded { get; }

    public UserPasskeyInfo? Passkey { get; }
    public PasskeyException? Failure { get; }
    public static PasskeyAttestationResult Success(UserPasskeyInfo passkey);
    public static PasskeyAttestationResult Fail(PasskeyException failure);
}

/// <summary>
/// Represents the result of a passkey assertion operation.
/// </summary>
public sealed class PasskeyAssertionResult<TUser>
    where TUser : class
{
    [MemberNotNullWhen(true, nameof(Passkey))]
    [MemberNotNullWhen(true, nameof(User))]
    [MemberNotNullWhen(false, nameof(Failure))]
    public bool Succeeded { get; }

    public UserPasskeyInfo? Passkey { get; }
    public TUser? User { get; }
    public PasskeyException? Failure { get; }
}

/// <summary>
/// A factory class for creating instances of <see cref="PasskeyAssertionResult{TUser}"/>.
/// </summary>
public static class PasskeyAssertionResult
{
    public static PasskeyAssertionResult<TUser> Success<TUser>(UserPasskeyInfo passkey, TUser user)
        where TUser : class;
    public static PasskeyAssertionResult<TUser> Fail<TUser>(PasskeyException failure)
        where TUser : class;
}

Microsoft.Extensions.Identity.Core

Expand to view
public class IdentityOptions
{
+    public PasskeyOptions Passkey { get; set; }
}
/// <summary>
/// Specifies options for passkey requirements.
/// </summary>
public class PasskeyOptions
{
    /// <summary>
    /// Gets or sets the time that the server is willing to wait for a passkey operation to complete.
    /// </summary>
    /// <remarks>
    /// The default value is 1 minute.
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-timeout"/>
    /// and <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-timeout"/>.
    /// </remarks>
    public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(1);

    /// <summary>
    /// The size of the challenge in bytes sent to the client during WebAuthn attestation and assertion.
    /// </summary>
    /// <remarks>
    /// The default value is 16 bytes.
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-challenge"/>
    /// and <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-challenge"/>.
    /// </remarks>
    public int ChallengeSize { get; set; } = 16;

    /// <summary>
    /// The effective domain of the server. Should be unique and will be used as the identity for the server.
    /// </summary>
    /// <remarks>
    /// If left <see langword="null"/>, the server's origin may be used instead.
    /// See <see href="https://www.w3.org/TR/webauthn-3/#rp-id"/>.
    /// </remarks>
    public string? ServerDomain { get; set; }

    /// <summary>
    /// Gets or sets the allowed origins for credential registration and assertion.
    /// When specified, these origins are explicitly allowed in addition to any origins allowed by other settings.
    /// </summary>
    public IList<string> AllowedOrigins { get; set; } = [];

    /// <summary>
    /// Gets or sets whether the current server's origin should be allowed for credentials.
    /// When true, the origin of the current request will be automatically allowed.
    /// </summary>
    /// <remarks>
    /// The default value is <see langword="true"/>.
    /// </remarks>
    public bool AllowCurrentOrigin { get; set; } = true;

    /// <summary>
    /// Gets or sets whether credentials from cross-origin iframes should be allowed.
    /// </summary>
    /// <remarks>
    /// The default value is <see langword="false"/>.
    /// </remarks>
    public bool AllowCrossOriginIframes { get; set; }

    /// <summary>
    /// Whether or not to accept a backup eligible credential.
    /// </summary>
    /// <remarks>
    /// The default value is <see cref="CredentialBackupPolicy.Allowed"/>.
    /// </remarks>
    public CredentialBackupPolicy BackupEligibleCredentialPolicy { get; set; } = CredentialBackupPolicy.Allowed;

    /// <summary>
    /// Whether or not to accept a backed up credential.
    /// </summary>
    /// <remarks>
    /// The default value is <see cref="CredentialBackupPolicy.Allowed"/>.
    /// </remarks>
    public CredentialBackupPolicy BackedUpCredentialPolicy { get; set; } = CredentialBackupPolicy.Allowed;

    /// <summary>
    /// Represents the policy for credential backup eligibility and backup status.
    /// </summary>
    public enum CredentialBackupPolicy
    {
        /// <summary>
        /// Indicates that the credential backup eligibility or backup status is required.
        /// </summary>
        Required = 0,

        /// <summary>
        /// Indicates that the credential backup eligibility or backup status is allowed, but not required.
        /// </summary>
        Allowed = 1,

        /// <summary>
        /// Indicates that the credential backup eligibility or backup status is disallowed.
        /// </summary>
        Disallowed = 2,
    }
}

/// <summary>
/// Provides an abstraction for storing passkey credentials for a user.
/// </summary>
/// <typeparam name="TUser">The type that represents a user.</typeparam>
public interface IUserPasskeyStore<TUser> : IUserStore<TUser>
    where TUser : class
{
    /// <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);

    /// <summary>
    /// Gets the passkey credentials for the specified <paramref name="user"/>.
    /// </summary>
    /// <param name="user">The user whose passkeys should be retrieved.</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, containing a list of the user's passkeys.</returns>
    Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user, CancellationToken cancellationToken);

    /// <summary>
    /// Finds and returns a user, if any, associated with the specified passkey credential identifier.
    /// </summary>
    /// <param name="credentialId">The passkey credential id to search for.</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, containing the user, if any, associated with the specified passkey credential id.
    /// </returns>
    Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken);

    /// <summary>
    /// Finds a passkey for the specified user with the specified credential id.
    /// </summary>
    /// <param name="user">The user whose passkey should be retrieved.</param>
    /// <param name="credentialId">The credential id to search for.</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, containing the user's passkey information.</returns>
    Task<UserPasskeyInfo?> FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);

    /// <summary>
    /// Removes a passkey credential from the specified <paramref name="user"/>.
    /// </summary>
    /// <param name="user">The user to remove the passkey credential from.</param>
    /// <param name="credentialId">The credential id of the passkey to remove.</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 RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
}

/// <summary>
/// Provides information for a user's passkey credential.
/// </summary>
public class UserPasskeyInfo
{
    /// <summary>
    /// Initializes a new instance of <see cref="UserPasskeyInfo"/>.
    /// </summary>
    /// <param name="credentialId">The credential ID for the passkey.</param>
    /// <param name="publicKey">The public key for the passkey.</param>
    /// <param name="name">The friendly name for the passkey.</param>
    /// <param name="createdAt">The time when the passkey was created.</param>
    /// <param name="signCount">The signature counter for the passkey.</param>
    /// <param name="transports">The transports supported by this passkey.</param>
    /// <param name="isUserVerified">Indicates if the passkey has a verified user.</param>
    /// <param name="isBackupEligible">Indicates if the passkey is eligible for backup.</param>
    /// <param name="isBackedUp">Indicates if the passkey is currently backed up.</param>
    /// <param name="attestationObject">The passkey's attestation object.</param>
    /// <param name="clientDataJson">The passkey's client data JSON.</param>
    public UserPasskeyInfo(
        byte[] credentialId,
        byte[] publicKey,
        string? name,
        DateTimeOffset createdAt,
        uint signCount,
        string[]? transports,
        bool isUserVerified,
        bool isBackupEligible,
        bool isBackedUp,
        byte[] attestationObject,
        byte[] clientDataJson);

    public byte[] CredentialId { get; }
    public byte[] PublicKey { get; }
    public string? Name { get; set; }
    public DateTimeOffset CreatedAt { get; }
    public uint SignCount { get; set; }
    public string[]? Transports { get; }
    public bool IsUserVerified { get; set; }
    public bool IsBackupEligible { get; }
    public bool IsBackedUp { get; set; }
    public byte[] AttestationObject { get; }
    public byte[] ClientDataJson { get; }
}
public static class IdentitySchemaVersions
{
+    /// <summary>
+    /// Represents the 3.0 version of the identity schema
+    /// </summary>
+    public static readonly Version Version3 = new Version(3, 0);
}

public class UserManager<TUser> : IDisposable
    where TUser : class
{
+    public virtual bool SupportsUserPasskey { get; }

+    /// <summary>
+    /// Adds a new passkey for the given user or updates an existing one.
+    /// </summary>
+    /// <param name="user">The user for whom the passkey should be added or updated.</param>
+    /// <param name="passkey">The passkey to add or update.</param>
+    /// <returns>Whether the passkey was successfully set.</returns>
+    public virtual async Task<IdentityResult> SetPasskeyAsync(TUser user, UserPasskeyInfo passkey);

+    /// <summary>
+    /// Gets a user's passkeys.
+    /// </summary>
+    /// <param name="user">The user whose passkeys should be retrieved.</param>
+    /// <returns>A list of the user's passkeys.</returns>
+    public virtual Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user);

+    /// <summary>
+    /// Finds a user's passkey by its credential id.
+    /// </summary>
+    /// <param name="user">The user whose passkey should be retrieved.</param>
+    /// <param name="credentialId">The credential ID to search for.</param>
+    /// <returns>The passkey, or <see langword="null"/> if it doesn't exist.</returns>
+    public virtual Task<UserPasskeyInfo?> GetPasskeyAsync(TUser user, byte[] credentialId);

+    /// <summary>
+    /// Finds the user associated with a passkey.
+    /// </summary>
+    /// <param name="credentialId">The credential ID to search for.</param>
+    /// <returns>The user associated with the passkey.</returns>
+    public virtual Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId);

+    /// <summary>
+    /// Removes a passkey credential from a user.
+    /// </summary>
+    /// <param name="user">The user whose passkey should be removed.</param>
+    /// <param name="credentialId">The credential id of the passkey to remove.</param>
+    /// <returns>Whether the passkey was successfully removed.</returns>
+    public virtual async Task<IdentityResult> RemovePasskeyAsync(TUser user, byte[] credentialId);
}

Microsoft.Extensions.Identity.Stores

Expand to view
namespace Microsoft.AspNetCore.Identity;

/// <summary>
/// Represents a passkey credential for a user in the identity system.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#credential-record"/>.
/// </remarks>
/// <typeparam name="TKey">The type used for the primary key for this passkey credential.</typeparam>
public class IdentityUserPasskey<TKey>
    where TKey : IEquatable<TKey>
{
    public virtual TKey UserId { get; set; }
    public virtual byte[] CredentialId { get; set; }
    public virtual byte[] PublicKey { get; set; }
    public virtual string? Name { get; set; }
    public virtual DateTimeOffset CreatedAt { get; set; }
    public virtual uint SignCount { get; set; }
    public virtual string[]? Transports { get; set; }
    public virtual bool IsUserVerified { get; set; }
    public virtual bool IsBackupEligible { get; set; }
    public virtual bool IsBackedUp { get; set; }
    public virtual byte[] AttestationObject { get; set; }
    public virtual byte[] ClientDataJson { get; set; }
}

Microsoft.AspNetCore.Identity.EntityFrameworkCore

Expand to view
namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore;

-public abstract class IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken> :
-   IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken>
+public class IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken> :
+   IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TRole : IdentityRole<TKey>
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>
    where TUserRole : IdentityUserRole<TKey>
    where TUserLogin : IdentityUserLogin<TKey>
    where TRoleClaim : IdentityRoleClaim<TKey>
    where TUserToken : IdentityUserToken<TKey>
{
+    public IdentityDbContext(DbContextOptions options);
+    protected IdentityDbContext();
}

+public abstract class IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken, TUserPasskey> :
+    IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken, TUserPasskey>
+    where TUser : IdentityUser<TKey>
+    where TRole : IdentityRole<TKey>
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>
+    where TUserRole : IdentityUserRole<TKey>
+    where TUserLogin : IdentityUserLogin<TKey>
+    where TRoleClaim : IdentityRoleClaim<TKey>
+    where TUserToken : IdentityUserToken<TKey>
+    where TUserPasskey : IdentityUserPasskey<TKey>
+{
    // Members from IdentityDbContext`8 moved here
+}

-public abstract class IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken> :
-    DbContext
+public class IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken> :
+    IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>
    where TUserLogin : IdentityUserLogin<TKey>
    where TUserToken : IdentityUserToken<TKey>
{
+    public IdentityUserContext(DbContextOptions options);
+    protected IdentityUserContext();
}

+/// <summary>
+/// Base class for the Entity Framework database context used for identity.
+/// </summary>
+/// <typeparam name="TUser">The type of user objects.</typeparam>
+/// <typeparam name="TKey">The type of the primary key for users and roles.</typeparam>
+/// <typeparam name="TUserClaim">The type of the user claim object.</typeparam>
+/// <typeparam name="TUserLogin">The type of the user login object.</typeparam>
+/// <typeparam name="TUserToken">The type of the user token object.</typeparam>
+/// <typeparam name="TUserPasskey">The type of the user passkey object.</typeparam>
+public abstract class IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken, TUserPasskey> : DbContext
+    where TUser : IdentityUser<TKey>
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>
+    where TUserLogin : IdentityUserLogin<TKey>
+    where TUserToken : IdentityUserToken<TKey>
+    where TUserPasskey : IdentityUserPasskey<TKey>
+{
+    /// <summary>
+    /// Gets or sets the <see cref="DbSet{TEntity}"/> of User passkeys.
+    /// </summary>
+    public virtual DbSet<TUserPasskey> UserPasskeys { get; set; }
+}

public class UserOnlyStore<TUser, TContext, TKey, TUserClaim, TUserLogin, TUserToken> :
-    UserStoreBase<TUser, TKey, TUserClaim, TUserLogin, TUserToken>
-    IUserLoginStore<TUser>,
-    IUserClaimStore<TUser>,
-    IUserPasswordStore<TUser>,
-    IUserSecurityStampStore<TUser>,
-    IUserEmailStore<TUser>,
-    IUserLockoutStore<TUser>,
-    IUserPhoneNumberStore<TUser>,
-    IQueryableUserStore<TUser>,
-    IUserTwoFactorStore<TUser>,
-    IUserAuthenticationTokenStore<TUser>,
-    IUserAuthenticatorKeyStore<TUser>,
-    IUserTwoFactorRecoveryCodeStore<TUser>,
-    IProtectedUserStore<TUser>
+    UserOnlyStore<TUser, TContext, TKey, TUserClaim, TUserLogin, TUserToken, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TContext : DbContext
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>, new()
    where TUserLogin : IdentityUserLogin<TKey>, new()
    where TUserToken : IdentityUserToken<TKey>, new()
{
+    public UserOnlyStore(TContext context, IdentityErrorDescriber? describer = null);
}

+public class UserOnlyStore<TUser, TContext, TKey, TUserClaim, TUserLogin, TUserToken, TUserPasskey> :
+    UserStoreBase<TUser, TKey, TUserClaim, TUserLogin, TUserToken>,
+    IUserLoginStore<TUser>,
+    IUserClaimStore<TUser>,
+    IUserPasswordStore<TUser>,
+    IUserSecurityStampStore<TUser>,
+    IUserEmailStore<TUser>,
+    IUserLockoutStore<TUser>,
+    IUserPhoneNumberStore<TUser>,
+    IQueryableUserStore<TUser>,
+    IUserTwoFactorStore<TUser>,
+    IUserAuthenticationTokenStore<TUser>,
+    IUserAuthenticatorKeyStore<TUser>,
+    IUserTwoFactorRecoveryCodeStore<TUser>,
+    IProtectedUserStore<TUser>,
+    IUserPasskeyStore<TUser>
+    where TUser : IdentityUser<TKey>
+    where TContext : DbContext
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>, new()
+    where TUserLogin : IdentityUserLogin<TKey>, new()
+    where TUserToken : IdentityUserToken<TKey>, new()
+    where TUserPasskey : IdentityUserPasskey<TKey>, new()
+{
    // Members from UserOnlyStore`6 moved here

+    /// <summary>
+    /// DbSet of user passkeys.
+    /// </summary>
+    protected DbSet<TUserPasskey> UserPasskeys { get; }

+    /// <summary>
+    /// Called to create a new instance of a <see cref="IdentityUserPasskey{TKey}"/>.
+    /// </summary>
+    /// <param name="user">The user.</param>
+    /// <param name="passkey">The passkey.</param>
+    /// <returns></returns>
+    protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo passkey);

+    /// <summary>
+    /// Find a passkey with the specified credential id for a user.
+    /// </summary>
+    /// <param name="userId">The user's id.</param>
+    /// <param name="credentialId">The credential id to search for.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
+    /// <returns>The user passkey if it exists.</returns>
+    protected virtual Task<TUserPasskey?> FindUserPasskeyAsync(TKey userId, byte[] credentialId, CancellationToken cancellationToken);

+    /// <summary>
+    /// Find a passkey with the specified credential id.
+    /// </summary>
+    /// <param name="credentialId">The credential id to search for.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
+    /// <returns>The user passkey if it exists.</returns>
+    protected virtual Task<TUserPasskey?> FindUserPasskeyByIdAsync(byte[] credentialId, CancellationToken cancellationToken);

+    public virtual async Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken);
+    public virtual async Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user, CancellationToken cancellationToken);
+    public virtual async Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken);
+    public virtual async Task<UserPasskeyInfo?> FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
+    public virtual async Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
+}

public class UserStore<TUser, TRole, TContext, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim> :
-    UserStoreBase<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim>,
-    IProtectedUserStore<TUser>
+    UserStore<TUser, TRole, TContext, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TRole : IdentityRole<TKey>
    where TContext : DbContext
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>, new()
    where TUserRole : IdentityUserRole<TKey>, new()
    where TUserLogin : IdentityUserLogin<TKey>, new()
    where TUserToken : IdentityUserToken<TKey>, new()
    where TRoleClaim : IdentityRoleClaim<TKey>, new()
{
+    public UserStore(TContext context, IdentityErrorDescriber? describer = null);
}

+/// <summary>
+/// Represents a new instance of a persistence store for the specified user and role types.
+/// </summary>
+/// <typeparam name="TUser">The type representing a user.</typeparam>
+/// <typeparam name="TRole">The type representing a role.</typeparam>
+/// <typeparam name="TContext">The type of the data context class used to access the store.</typeparam>
+/// <typeparam name="TKey">The type of the primary key for a role.</typeparam>
+/// <typeparam name="TUserClaim">The type representing a claim.</typeparam>
+/// <typeparam name="TUserRole">The type representing a user role.</typeparam>
+/// <typeparam name="TUserLogin">The type representing a user external login.</typeparam>
+/// <typeparam name="TUserToken">The type representing a user token.</typeparam>
+/// <typeparam name="TRoleClaim">The type representing a role claim.</typeparam>
+/// <typeparam name="TUserPasskey">The type representing a user passkey.</typeparam>
+public class UserStore<TUser, TRole, TContext, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim, TUserPasskey> :
+    UserStoreBase<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim>,
+    IProtectedUserStore<TUser>,
+    IUserPasskeyStore<TUser>
+    where TUser : IdentityUser<TKey>
+    where TRole : IdentityRole<TKey>
+    where TContext : DbContext
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>, new()
+    where TUserRole : IdentityUserRole<TKey>, new()
+    where TUserLogin : IdentityUserLogin<TKey>, new()
+    where TUserToken : IdentityUserToken<TKey>, new()
+    where TRoleClaim : IdentityRoleClaim<TKey>, new()
+    where TUserPasskey : IdentityUserPasskey<TKey>, new()
+{
    // Members from UserStore`9 moved here.

+    /// <summary>
+    /// Called to create a new instance of a <see cref="IdentityUserPasskey{TKey}"/>.
+    /// </summary>
+    /// <param name="user">The user.</param>
+    /// <param name="passkey">The passkey.</param>
+    /// <returns></returns>
+    protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo passkey);

+    /// <summary>
+    /// Find a passkey with the specified credential id for a user.
+    /// </summary>
+    /// <param name="userId">The user's id.</param>
+    /// <param name="credentialId">The credential id to search for.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
+    /// <returns>The user passkey if it exists.</returns>
+    protected virtual Task<TUserPasskey?> FindUserPasskeyAsync(TKey userId, byte[] credentialId, CancellationToken cancellationToken);

+    /// <summary>
+    /// Find a passkey with the specified credential id.
+    /// </summary>
+    /// <param name="credentialId">The credential id to search for.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
+    /// <returns>The user passkey if it exists.</returns>
+    protected virtual Task<TUserPasskey?> FindUserPasskeyByIdAsync(byte[] credentialId, CancellationToken cancellationToken);

+    public virtual async Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken)
+    public virtual async Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user, CancellationToken cancellationToken)
+    public virtual async Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken)
+    public virtual async Task<UserPasskeyInfo?> FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken)
+    public virtual async Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken)
+}

MackinnonBuck avatar Jun 19 '25 15:06 MackinnonBuck

[!IMPORTANT] The most recent API comment can be found here

API Review Notes (Round 2)

  • Should we change JsonElement to be a JsonNode in PasskeyCreationArgs and similar APIs?
    • JsonNode is a bit easier to construct, but JsonElement has the nice characteristic of being "read only"
    • Conclusion: We think it's fine the way it is
  • Resuming from last time, are there any new thoughts on the naming for PasskeyCreationArgs and PasskeyRequestArgs?
    • What's the motivation for the Options naming?
      • It mirrors the equivalent WebAuthn concept
    • Should we consider PasskeyCreationData and PasskeyRequestData?
      • Possibly.
    • Conclusion: No conclusion yet on the naming. Will return to this again.
  • Should PasskeyCreationOptions.Json have [StringSyntax(StringSyntaxAttribute.Json)]?
    • Conclusion: Sure - it probably won't be very common to assign a string literal to this property, but it doesn't hurt to add it.
  • PasskeyUserEntity.DisplayName should be nullable with a default value of null, right?
    • Conclusion: Yes. And also update the other members to be required.
  • Make AuthenticatorSelectionCriteria.RequireResidentKey a computed property based on ResidentKey
    • (It already works this way, it's just not reflected in the API review issue)
  • Are we OK with having stringly-typed APIs, such as the properties on AuthenticatorSelectionCriteria?
    • The alternative is to define a bunch of new enum types, which has the downside that developers wouldn't be able to specify values that we don't account for (if, e.g., the spec gets updated).
    • Do we think having string constants defined somewhere would be helpful?
      • Maybe, but this would expand the API surface and probably won't be that discoverable.
    • Conclusion: Yes, we're OK with how it is. Just update the XML docs for each of this kind of property to explicitly list out the currently-known supported values.
  • PasskeyAttestationContext<TUser> doesn't have to be generic anymore, now that we've removed the UserManager<TUser> property.
  • Should we remove the PerformPasskeyAttestationCoreAsync and PerformPasskeyAssertionCoreAsync virtual methods from public API in DefaultPasskeyHandler?
    • It's not super convenient for developers to override these, because they'd need to make sense of the raw JSON payloads even though they're already parsed in the base implementation. If they want to completely replace the base implementation and, e.g., plug in a third-party library to handle attestation or assertion, they should just define their own IPasskeyHandler.
    • The alternative is to expose the internal representations of the CredentialJson and OriginalOptionsJson, but that massively increases the public API surface so we don't feel this is the right decision, at least for now.
    • We can always add these methods back in the future if there's enough demand for it.
    • Conclusion: Remove these methods from public API.
  • Should we add a TopOrigin property to PasskeyOriginInfo? It's present in the WebAuthn spec.
    • Conclusion: Yes.
  • Make DefaultPasskeyHandler.IsValidOriginAsync() and DefaultPasskeyHandler.VerifyAttestationStatementAsync() return ValueTask.
  • Update the API description to not show DefaultPasskeyHandler as sealed.
  • Are we OK with the default PasskeyOptions.Timeout being 1 minute?
    • Conclusion: Let's increase it to 5 minutes.
  • Are we OK that SetPasskeyAsync can either add a new passkey or update an existing one?
    • Conclusion: Yes, it's fine. But let's update the name to AddOrUpdatePasskeyAsync().
  • Should we consider adding a TUser to UserPasskeyInfo and making it nullable?
    • Maybe. Let's revisit this next time.
  • Are we OK introducing a new exception type? Will it cause the debugger to break when the exception is thrown?
    • Probably not, since the exception doesn't usually bubble up to user code.
    • Also, we expect that this exception will be rare in practice.
    • Conclusion: Yes, this is OK.
  • Should we include some kind of "code" or "reason" on the exception type?
    • It's not clear how common it will be to need to observe the failure reason outside of logging it.
    • Conclusion: Let's hold off on exposing a new Code property for now.

Open questions

  • What should be the final names be for the following?
    • PasskeyCreationArgs
    • PasskeyRequestArgs
    • PasskeyCreationOptions
    • PasskeyRequestOptions
  • Should UserPasskeyInfo really be UserPasskeyInfo<TUser> with a nullable User property?

Updated API

Stopped at UserPasskeyInfo. We'll resume from there next time.

Microsoft.AspNetCore.Identity

Expand to view
namespace Microsoft.AspNetCore.Identity;

// Existing type. Only new members shown.
public class SignInManager<TUser>
    where TUser : class
{
    /// <summary>
    /// Creates a new instance of <see cref="SignInManager{TUser}"/>.
    /// </summary>
    /// <param name="userManager">An instance of <see cref="UserManager"/> used to retrieve users from and persist users.</param>
    /// <param name="contextAccessor">The accessor used to access the <see cref="HttpContext"/>.</param>
    /// <param name="claimsFactory">The factory to use to create claims principals for a user.</param>
    /// <param name="optionsAccessor">The accessor used to access the <see cref="IdentityOptions"/>.</param>
    /// <param name="logger">The logger used to log messages, warnings and errors.</param>
    /// <param name="schemes">The scheme provider that is used enumerate the authentication schemes.</param>
    /// <param name="confirmation">The <see cref="IUserConfirmation{TUser}"/> used check whether a user account is confirmed.</param>
    /// <param name="passkeyHandler">The <see cref="IPasskeyHandler{TUser}"/> used when performing passkey attestation and assertion.</param>
    public SignInManager(
        UserManager<TUser> userManager,
        IHttpContextAccessor contextAccessor,
        IUserClaimsPrincipalFactory<TUser> claimsFactory,
        IOptions<IdentityOptions> optionsAccessor,
        ILogger<SignInManager<TUser>> logger,
        IAuthenticationSchemeProvider schemes,
        IUserConfirmation<TUser> confirmation,
        IPasskeyHandler<TUser> passkeyHandler);

    /// <summary>
    /// Performs passkey attestation for the given <paramref name="credentialJson"/> and <paramref name="options"/>.
    /// </summary>
    /// <param name="credentialJson">The credentials obtained by JSON-serializing the result of the <c>navigator.credentials.create()</c> JavaScript function.</param>
    /// <param name="options">The original passkey creation options provided to the browser.</param>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyAttestationResult"/>.
    /// </returns>
    public virtual async Task<PasskeyAttestationResult> PerformPasskeyAttestationAsync(string credentialJson, PasskeyCreationOptions options);

    /// <summary>
    /// Performs passkey assertion for the given <paramref name="credentialJson"/> and <paramref name="options"/>.
    /// </summary>
    /// <param name="credentialJson">The credentials obtained by JSON-serializing the result of the <c>navigator.credentials.get()</c> JavaScript function.</param>
    /// <param name="options">The original passkey creation options provided to the browser.</param>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyAssertionResult{TUser}"/>.
    /// </returns>
    public virtual async Task<PasskeyAssertionResult<TUser>> PerformPasskeyAssertionAsync(string credentialJson, PasskeyRequestOptions options);

    /// <summary>
    /// Attempts to sign in the user with a passkey.
    /// </summary>
    /// <param name="credentialJson">The credentials obtained by JSON-serializing the result of the <c>navigator.credentials.get()</c> JavaScript function.</param>
    /// <param name="options">The original passkey request options provided to the browser.</param>
    /// <returns>
    /// The task object representing the asynchronous operation containing the <see cref="SignInResult"/>
    /// for the sign-in attempt.
    /// </returns>
    public virtual async Task<SignInResult> PasskeySignInAsync(string credentialJson, PasskeyRequestOptions options);

    /// <summary>
    /// Creates a <see cref="PasskeyCreationOptions"/> and stores it in the current <see cref="HttpContext"/> for later retrieval.
    /// </summary>
    /// <param name="creationArgs">Args for configuring the <see cref="PasskeyCreationOptions"/>.</param>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyCreationOptions"/>.
    /// </returns>
    public virtual async Task<PasskeyCreationOptions> CreatePasskeyCreationOptionsAsync(PasskeyCreationArgs creationArgs);

    /// <summary>
    /// Creates a <see cref="PasskeyRequestOptions"/> and stores it in the current <see cref="HttpContext"/> for later retrieval.
    /// </summary>
    /// <param name="requestArgs">Args for configuring the <see cref="PasskeyRequestOptions"/>.</param>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyRequestOptions"/>.
    /// </returns>
    public virtual async Task<PasskeyRequestOptions> CreatePasskeyRequestOptionsAsync(PasskeyRequestArgs<TUser> requestArgs);

    /// <summary>
    /// Gets the <see cref="PasskeyCreationOptions"/> stored in the current <see cref="HttpContext"/>.
    /// </summary>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyCreationOptions"/>.
    /// </returns>
    public virtual async Task<PasskeyCreationOptions?> GetPasskeyCreationOptionsAsync();

    /// <summary>
    /// Gets the <see cref="PasskeyRequestOptions"/> stored in the current <see cref="HttpContext"/>.
    /// </summary>
    /// <returns>
    /// A task object representing the asynchronous operation containing the <see cref="PasskeyRequestOptions"/>.
    /// </returns>
    public virtual async Task<PasskeyRequestOptions?> GetPasskeyRequestOptionsAsync();
}

/// <summary>
/// Represents arguments for generating <see cref="PasskeyCreationOptions"/>.
/// </summary>
public sealed class PasskeyCreationArgs
{
    /// <summary>
    /// Gets or sets the user entity to be associated with the passkey.
    /// </summary>
    public required PasskeyUserEntity UserEntity { get; init; }

    public AuthenticatorSelectionCriteria? AuthenticatorSelection { get; init; }
    public string? Attestation { get; init; }
    public JsonElement? Extensions { get; init; }
}

/// <summary>
/// Represents options for creating a passkey.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialcreationoptions"/>.
/// </remarks>
public sealed class PasskeyCreationOptions
{
    /// <summary>
    /// Gets or sets the user entity associated with the passkey.
    /// </summary>
    public required PasskeyUserEntity UserEntity { get; init; }

    /// <summary>
    /// Gets or sets 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>
    [StringSyntax(StringSyntaxAttribute.Json)]
    public required string Json { get; init; }
}

/// <summary>
/// Represents arguments for generating <see cref="PasskeyRequestOptions"/>.
/// </summary>
public sealed class PasskeyRequestArgs<TUser>
    where TUser : class
{
    /// <summary>
    /// Gets or sets the user verification requirement.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-userverification"/>.
    /// Possible values are "required", "preferred", and "discouraged".
    /// When <see langword="null"/>, the browser default is "preferred".
    /// </remarks>
    public string? UserVerification { get; init; }

    /// <summary>
    /// Gets or sets the user to be authenticated.
    /// </summary>
    /// <remarks>
    /// While this value is optional, it should be specified if the authenticating
    /// user can be identified. This can happen if, for example, the user provides
    /// a username before signing in with a passkey.
    /// </remarks>
    public TUser? User { get; init; }

    public JsonElement? Extensions { get; init; }
}

/// <summary>
/// Represents options for a passkey request.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptions"/>.
/// </remarks>
public sealed class PasskeyRequestOptions
{
    /// <summary>
    /// Gets or sets the ID of the user for whom the request is made.
    /// </summary>
    public string? UserId { get; init; }

    /// <summary>
    /// Gets or sets 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-publickeycredentialrequestoptionsjson"/>.
    /// </remarks>
    [StringSyntax(StringSyntaxAttribute.Json)]
    public required string Json { get; init; }
}

/// <summary>
/// Represents information about the user associated with a passkey.
/// </summary>
public sealed class PasskeyUserEntity
{
    /// <summary>
    /// Gets or sets the user ID.
    /// </summary>
    public required string Id { get; init; }

    /// <summary>
    /// Gets or sets the username.
    /// </summary>
    public required string Name { get; init; }

    /// <summary>
    /// Gets or sets the user display name.
    /// </summary>
    public string? DisplayName { get; init; } = null;
}

/// <summary>
/// Used to specify requirements regarding authenticator attributes.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#dictdef-authenticatorselectioncriteria"/>.
/// </remarks>
public sealed class AuthenticatorSelectionCriteria
{
    /// <summary>
    /// Gets or sets the authenticator attachment.
    /// Supported values are "platform" and "cross-platform".
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-authenticatorattachment"/>.
    /// </remarks>
    public string? AuthenticatorAttachment { get; init; }

    /// <summary>
    /// Gets or sets the extent to which the server desires to create a client-side discoverable credential.
    /// Supported values are "discouraged", "preferred", or "required".
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-residentkey"/>
    /// </remarks>
    public string? ResidentKey { get; init; }

    /// <summary>
    /// Gets whether a resident key is required.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-requireresidentkey"/>.
    /// </remarks>
    public bool RequireResidentKey => string.Equals("required", ResidentKey, StringComparison.Ordinal);

    /// <summary>
    /// Gets or sets the user verification requirement.
    /// Possible values are "required", "preferred", and "discouraged".
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-userverification"/>.
    /// </remarks>
    public string? UserVerification { get; init; }
}

/// <summary>
/// Represents a handler for passkey assertion and attestation.
/// </summary>
public interface IPasskeyHandler<TUser>
    where TUser : class
{
    /// <summary>
    /// Performs passkey attestation using the provided credential JSON and original options JSON.
    /// </summary>
    /// <param name="context">The context containing necessary information for passkey attestation.</param>
    /// <returns>A task object representing the asynchronous operation containing the <see cref="PasskeyAttestationResult"/>.</returns>
    Task<PasskeyAttestationResult> PerformAttestationAsync(PasskeyAttestationContext context);

    /// <summary>
    /// Performs passkey assertion using the provided credential JSON, original options JSON, and optional user.
    /// </summary>
    /// <param name="context">The context containing necessary information for passkey assertion.</param>
    /// <returns>A task object representing the asynchronous operation containing the <see cref="PasskeyAssertionResult{TUser}"/>.</returns>
    Task<PasskeyAssertionResult<TUser>> PerformAssertionAsync(PasskeyAssertionContext<TUser> context);
}

/// <summary>
/// Represents the context for passkey attestation.
/// </summary>
/// <typeparam name="TUser">The type of user associated with the passkey.</typeparam>
public sealed class PasskeyAttestationContext
{
    /// <summary>
    /// Gets or sets the credentials obtained by JSON-serializing the result of the
    /// <c>navigator.credentials.create()</c> JavaScript function.
    /// </summary>
    [StringSyntax(StringSyntaxAttribute.Json)]
    public required string CredentialJson { get; init; }

    /// <summary>
    /// Gets or sets the JSON representation of the original passkey creation options provided to the browser.
    /// </summary>
    [StringSyntax(StringSyntaxAttribute.Json)]
    public required string OriginalOptionsJson { get; init; }

    /// <summary>
    /// Gets or sets the <see cref="HttpContext"/> for the current request. 
    /// </summary>
    public required HttpContext HttpContext { get; init; }
}

/// <summary>
/// Represents the context for passkey assertion.
/// </summary>
/// <typeparam name="TUser">The type of user associated with the passkey.</typeparam>
public sealed class PasskeyAssertionContext<TUser>
    where TUser : class
{
    /// <summary>
    /// Gets or sets the user associated with the passkey, if known.
    /// </summary>
    public TUser? User { get; init; }

    /// <summary>
    /// Gets or sets the credentials obtained by JSON-serializing the result of the
    /// <c>navigator.credentials.get()</c> JavaScript function.
    /// </summary>
    [StringSyntax(StringSyntaxAttribute.Json)]
    public required string CredentialJson { get; init; }

    /// <summary>
    /// Gets or sets the JSON representation of the original passkey creation options provided to the browser.
    /// </summary>
    [StringSyntax(StringSyntaxAttribute.Json)]
    public required string OriginalOptionsJson { get; init; }

    /// <summary>
    /// Gets or sets the <see cref="HttpContext"/> for the current request. 
    /// </summary>
    public required HttpContext HttpContext { get; init; }
}

/// <summary>
/// The default passkey handler.
/// </summary>
public partial class DefaultPasskeyHandler<TUser> : IPasskeyHandler<TUser>
    where TUser : class
{
    public DefaultPasskeyHandler(IOptions<IdentityOptions> options);
    public Task<PasskeyAttestationResult> PerformAttestationAsync(PasskeyAttestationContext context);
    public Task<PasskeyAssertionResult<TUser>> PerformAssertionAsync(PasskeyAssertionContext<TUser> context);
    protected virtual ValueTask<bool> IsValidOriginAsync(PasskeyOriginInfo originInfo, HttpContext httpContext);
    protected virtual ValueTask<bool> VerifyAttestationStatementAsync(ReadOnlyMemory<byte> attestationObject, ReadOnlyMemory<byte> clientDataHash, HttpContext httpContext);
}

/// <summary>
/// Contains information used for determining whether a passkey's origin is valid.
/// </summary>
public readonly struct PasskeyOriginInfo
{
    /// <summary>
    /// Gets or sets the fully-qualified origin of the requester.
    /// </summary>
    public required string Origin { get; init; }

    /// <summary>
    /// Gets or sets whether the request came from a cross-origin <c>&lt;iframe&gt;</c>.
    /// </summary>
    public required bool CrossOrigin { get; init; }

    /// <summary>
    /// Gets or sets the fully-qualified top-level origin of the requester.
    /// </summary>
    public required bool TopOrigin { get; init; }
}

/// <summary>
/// Represents an error that occurred during passkey attestation or assertion.
/// </summary>
public sealed class PasskeyException : Exception
{
    public PasskeyException(string message);
    public PasskeyException(string message, Exception? innerException);
}

/// <summary>
/// Represents the result of a passkey attestation operation.
/// </summary>
public sealed class PasskeyAttestationResult
{
    [MemberNotNullWhen(true, nameof(Passkey))]
    [MemberNotNullWhen(false, nameof(Failure))]
    public bool Succeeded { get; }

    public UserPasskeyInfo? Passkey { get; }
    public PasskeyException? Failure { get; }
    public static PasskeyAttestationResult Success(UserPasskeyInfo passkey);
    public static PasskeyAttestationResult Fail(PasskeyException failure);
}

/// <summary>
/// Represents the result of a passkey assertion operation.
/// </summary>
public sealed class PasskeyAssertionResult<TUser>
    where TUser : class
{
    [MemberNotNullWhen(true, nameof(Passkey))]
    [MemberNotNullWhen(true, nameof(User))]
    [MemberNotNullWhen(false, nameof(Failure))]
    public bool Succeeded { get; }

    public UserPasskeyInfo? Passkey { get; }
    public TUser? User { get; }
    public PasskeyException? Failure { get; }
}

/// <summary>
/// A factory class for creating instances of <see cref="PasskeyAssertionResult{TUser}"/>.
/// </summary>
public static class PasskeyAssertionResult
{
    public static PasskeyAssertionResult<TUser> Success<TUser>(UserPasskeyInfo passkey, TUser user)
        where TUser : class;
    public static PasskeyAssertionResult<TUser> Fail<TUser>(PasskeyException failure)
        where TUser : class;
}

Microsoft.Extensions.Identity.Core

Expand to view
public class IdentityOptions
{
+    public PasskeyOptions Passkey { get; set; }
}
/// <summary>
/// Specifies options for passkey requirements.
/// </summary>
public class PasskeyOptions
{
    /// <summary>
    /// Gets or sets the time that the server is willing to wait for a passkey operation to complete.
    /// </summary>
    /// <remarks>
    /// The default value is 1 minute.
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-timeout"/>
    /// and <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-timeout"/>.
    /// </remarks>
    public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5);

    /// <summary>
    /// The size of the challenge in bytes sent to the client during WebAuthn attestation and assertion.
    /// </summary>
    /// <remarks>
    /// The default value is 16 bytes.
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-challenge"/>
    /// and <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-challenge"/>.
    /// </remarks>
    public int ChallengeSize { get; set; } = 16;

    /// <summary>
    /// The effective domain of the server. Should be unique and will be used as the identity for the server.
    /// </summary>
    /// <remarks>
    /// If left <see langword="null"/>, the server's origin may be used instead.
    /// See <see href="https://www.w3.org/TR/webauthn-3/#rp-id"/>.
    /// </remarks>
    public string? ServerDomain { get; set; }

    /// <summary>
    /// Gets or sets the allowed origins for credential registration and assertion.
    /// When specified, these origins are explicitly allowed in addition to any origins allowed by other settings.
    /// </summary>
    public IList<string> AllowedOrigins { get; set; } = [];

    /// <summary>
    /// Gets or sets whether the current server's origin should be allowed for credentials.
    /// When true, the origin of the current request will be automatically allowed.
    /// </summary>
    /// <remarks>
    /// The default value is <see langword="true"/>.
    /// </remarks>
    public bool AllowCurrentOrigin { get; set; } = true;

    /// <summary>
    /// Gets or sets whether credentials from cross-origin iframes should be allowed.
    /// </summary>
    /// <remarks>
    /// The default value is <see langword="false"/>.
    /// </remarks>
    public bool AllowCrossOriginIframes { get; set; }

    /// <summary>
    /// Whether or not to accept a backup eligible credential.
    /// </summary>
    /// <remarks>
    /// The default value is <see cref="CredentialBackupPolicy.Allowed"/>.
    /// </remarks>
    public CredentialBackupPolicy BackupEligibleCredentialPolicy { get; set; } = CredentialBackupPolicy.Allowed;

    /// <summary>
    /// Whether or not to accept a backed up credential.
    /// </summary>
    /// <remarks>
    /// The default value is <see cref="CredentialBackupPolicy.Allowed"/>.
    /// </remarks>
    public CredentialBackupPolicy BackedUpCredentialPolicy { get; set; } = CredentialBackupPolicy.Allowed;

    /// <summary>
    /// Represents the policy for credential backup eligibility and backup status.
    /// </summary>
    public enum CredentialBackupPolicy
    {
        /// <summary>
        /// Indicates that the credential backup eligibility or backup status is required.
        /// </summary>
        Required = 0,

        /// <summary>
        /// Indicates that the credential backup eligibility or backup status is allowed, but not required.
        /// </summary>
        Allowed = 1,

        /// <summary>
        /// Indicates that the credential backup eligibility or backup status is disallowed.
        /// </summary>
        Disallowed = 2,
    }
}

/// <summary>
/// Provides an abstraction for storing passkey credentials for a user.
/// </summary>
/// <typeparam name="TUser">The type that represents a user.</typeparam>
public interface IUserPasskeyStore<TUser> : IUserStore<TUser>
    where TUser : class
{
    /// <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 AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken);

    /// <summary>
    /// Gets the passkey credentials for the specified <paramref name="user"/>.
    /// </summary>
    /// <param name="user">The user whose passkeys should be retrieved.</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, containing a list of the user's passkeys.</returns>
    Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user, CancellationToken cancellationToken);

    /// <summary>
    /// Finds and returns a user, if any, associated with the specified passkey credential identifier.
    /// </summary>
    /// <param name="credentialId">The passkey credential id to search for.</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, containing the user, if any, associated with the specified passkey credential id.
    /// </returns>
    Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken);

    /// <summary>
    /// Finds a passkey for the specified user with the specified credential id.
    /// </summary>
    /// <param name="user">The user whose passkey should be retrieved.</param>
    /// <param name="credentialId">The credential id to search for.</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, containing the user's passkey information.</returns>
    Task<UserPasskeyInfo?> FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);

    /// <summary>
    /// Removes a passkey credential from the specified <paramref name="user"/>.
    /// </summary>
    /// <param name="user">The user to remove the passkey credential from.</param>
    /// <param name="credentialId">The credential id of the passkey to remove.</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 RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
}

/// <summary>
/// Provides information for a user's passkey credential.
/// </summary>
public class UserPasskeyInfo
{
    /// <summary>
    /// Initializes a new instance of <see cref="UserPasskeyInfo"/>.
    /// </summary>
    /// <param name="credentialId">The credential ID for the passkey.</param>
    /// <param name="publicKey">The public key for the passkey.</param>
    /// <param name="name">The friendly name for the passkey.</param>
    /// <param name="createdAt">The time when the passkey was created.</param>
    /// <param name="signCount">The signature counter for the passkey.</param>
    /// <param name="transports">The transports supported by this passkey.</param>
    /// <param name="isUserVerified">Indicates if the passkey has a verified user.</param>
    /// <param name="isBackupEligible">Indicates if the passkey is eligible for backup.</param>
    /// <param name="isBackedUp">Indicates if the passkey is currently backed up.</param>
    /// <param name="attestationObject">The passkey's attestation object.</param>
    /// <param name="clientDataJson">The passkey's client data JSON.</param>
    public UserPasskeyInfo(
        byte[] credentialId,
        byte[] publicKey,
        string? name,
        DateTimeOffset createdAt,
        uint signCount,
        string[]? transports,
        bool isUserVerified,
        bool isBackupEligible,
        bool isBackedUp,
        byte[] attestationObject,
        byte[] clientDataJson);

    public byte[] CredentialId { get; }
    public byte[] PublicKey { get; }
    public string? Name { get; set; }
    public DateTimeOffset CreatedAt { get; }
    public uint SignCount { get; set; }
    public string[]? Transports { get; }
    public bool IsUserVerified { get; set; }
    public bool IsBackupEligible { get; }
    public bool IsBackedUp { get; set; }
    public byte[] AttestationObject { get; }
    public byte[] ClientDataJson { get; }
}
public static class IdentitySchemaVersions
{
+    /// <summary>
+    /// Represents the 3.0 version of the identity schema
+    /// </summary>
+    public static readonly Version Version3 = new Version(3, 0);
}

public class UserManager<TUser> : IDisposable
    where TUser : class
{
+    public virtual bool SupportsUserPasskey { get; }

+    /// <summary>
+    /// Adds a new passkey for the given user or updates an existing one.
+    /// </summary>
+    /// <param name="user">The user for whom the passkey should be added or updated.</param>
+    /// <param name="passkey">The passkey to add or update.</param>
+    /// <returns>Whether the passkey was successfully set.</returns>
+    public virtual async Task<IdentityResult> AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey);

+    /// <summary>
+    /// Gets a user's passkeys.
+    /// </summary>
+    /// <param name="user">The user whose passkeys should be retrieved.</param>
+    /// <returns>A list of the user's passkeys.</returns>
+    public virtual Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user);

+    /// <summary>
+    /// Finds a user's passkey by its credential id.
+    /// </summary>
+    /// <param name="user">The user whose passkey should be retrieved.</param>
+    /// <param name="credentialId">The credential ID to search for.</param>
+    /// <returns>The passkey, or <see langword="null"/> if it doesn't exist.</returns>
+    public virtual Task<UserPasskeyInfo?> GetPasskeyAsync(TUser user, byte[] credentialId);

+    /// <summary>
+    /// Finds the user associated with a passkey.
+    /// </summary>
+    /// <param name="credentialId">The credential ID to search for.</param>
+    /// <returns>The user associated with the passkey.</returns>
+    public virtual Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId);

+    /// <summary>
+    /// Removes a passkey credential from a user.
+    /// </summary>
+    /// <param name="user">The user whose passkey should be removed.</param>
+    /// <param name="credentialId">The credential id of the passkey to remove.</param>
+    /// <returns>Whether the passkey was successfully removed.</returns>
+    public virtual async Task<IdentityResult> RemovePasskeyAsync(TUser user, byte[] credentialId);
}

Microsoft.Extensions.Identity.Stores

Expand to view
namespace Microsoft.AspNetCore.Identity;

/// <summary>
/// Represents a passkey credential for a user in the identity system.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#credential-record"/>.
/// </remarks>
/// <typeparam name="TKey">The type used for the primary key for this passkey credential.</typeparam>
public class IdentityUserPasskey<TKey>
    where TKey : IEquatable<TKey>
{
    public virtual TKey UserId { get; set; }
    public virtual byte[] CredentialId { get; set; }
    public virtual byte[] PublicKey { get; set; }
    public virtual string? Name { get; set; }
    public virtual DateTimeOffset CreatedAt { get; set; }
    public virtual uint SignCount { get; set; }
    public virtual string[]? Transports { get; set; }
    public virtual bool IsUserVerified { get; set; }
    public virtual bool IsBackupEligible { get; set; }
    public virtual bool IsBackedUp { get; set; }
    public virtual byte[] AttestationObject { get; set; }
    public virtual byte[] ClientDataJson { get; set; }
}

Microsoft.AspNetCore.Identity.EntityFrameworkCore

Expand to view
namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore;

-public abstract class IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken> :
-   IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken>
+public class IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken> :
+   IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TRole : IdentityRole<TKey>
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>
    where TUserRole : IdentityUserRole<TKey>
    where TUserLogin : IdentityUserLogin<TKey>
    where TRoleClaim : IdentityRoleClaim<TKey>
    where TUserToken : IdentityUserToken<TKey>
{
+    public IdentityDbContext(DbContextOptions options);
+    protected IdentityDbContext();
}

+public abstract class IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken, TUserPasskey> :
+    IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken, TUserPasskey>
+    where TUser : IdentityUser<TKey>
+    where TRole : IdentityRole<TKey>
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>
+    where TUserRole : IdentityUserRole<TKey>
+    where TUserLogin : IdentityUserLogin<TKey>
+    where TRoleClaim : IdentityRoleClaim<TKey>
+    where TUserToken : IdentityUserToken<TKey>
+    where TUserPasskey : IdentityUserPasskey<TKey>
+{
    // Members from IdentityDbContext`8 moved here
+}

-public abstract class IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken> :
-    DbContext
+public class IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken> :
+    IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>
    where TUserLogin : IdentityUserLogin<TKey>
    where TUserToken : IdentityUserToken<TKey>
{
+    public IdentityUserContext(DbContextOptions options);
+    protected IdentityUserContext();
}

+/// <summary>
+/// Base class for the Entity Framework database context used for identity.
+/// </summary>
+/// <typeparam name="TUser">The type of user objects.</typeparam>
+/// <typeparam name="TKey">The type of the primary key for users and roles.</typeparam>
+/// <typeparam name="TUserClaim">The type of the user claim object.</typeparam>
+/// <typeparam name="TUserLogin">The type of the user login object.</typeparam>
+/// <typeparam name="TUserToken">The type of the user token object.</typeparam>
+/// <typeparam name="TUserPasskey">The type of the user passkey object.</typeparam>
+public abstract class IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken, TUserPasskey> : DbContext
+    where TUser : IdentityUser<TKey>
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>
+    where TUserLogin : IdentityUserLogin<TKey>
+    where TUserToken : IdentityUserToken<TKey>
+    where TUserPasskey : IdentityUserPasskey<TKey>
+{
+    /// <summary>
+    /// Gets or sets the <see cref="DbSet{TEntity}"/> of User passkeys.
+    /// </summary>
+    public virtual DbSet<TUserPasskey> UserPasskeys { get; set; }
+}

public class UserOnlyStore<TUser, TContext, TKey, TUserClaim, TUserLogin, TUserToken> :
-    UserStoreBase<TUser, TKey, TUserClaim, TUserLogin, TUserToken>
-    IUserLoginStore<TUser>,
-    IUserClaimStore<TUser>,
-    IUserPasswordStore<TUser>,
-    IUserSecurityStampStore<TUser>,
-    IUserEmailStore<TUser>,
-    IUserLockoutStore<TUser>,
-    IUserPhoneNumberStore<TUser>,
-    IQueryableUserStore<TUser>,
-    IUserTwoFactorStore<TUser>,
-    IUserAuthenticationTokenStore<TUser>,
-    IUserAuthenticatorKeyStore<TUser>,
-    IUserTwoFactorRecoveryCodeStore<TUser>,
-    IProtectedUserStore<TUser>
+    UserOnlyStore<TUser, TContext, TKey, TUserClaim, TUserLogin, TUserToken, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TContext : DbContext
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>, new()
    where TUserLogin : IdentityUserLogin<TKey>, new()
    where TUserToken : IdentityUserToken<TKey>, new()
{
+    public UserOnlyStore(TContext context, IdentityErrorDescriber? describer = null);
}

+public class UserOnlyStore<TUser, TContext, TKey, TUserClaim, TUserLogin, TUserToken, TUserPasskey> :
+    UserStoreBase<TUser, TKey, TUserClaim, TUserLogin, TUserToken>,
+    IUserLoginStore<TUser>,
+    IUserClaimStore<TUser>,
+    IUserPasswordStore<TUser>,
+    IUserSecurityStampStore<TUser>,
+    IUserEmailStore<TUser>,
+    IUserLockoutStore<TUser>,
+    IUserPhoneNumberStore<TUser>,
+    IQueryableUserStore<TUser>,
+    IUserTwoFactorStore<TUser>,
+    IUserAuthenticationTokenStore<TUser>,
+    IUserAuthenticatorKeyStore<TUser>,
+    IUserTwoFactorRecoveryCodeStore<TUser>,
+    IProtectedUserStore<TUser>,
+    IUserPasskeyStore<TUser>
+    where TUser : IdentityUser<TKey>
+    where TContext : DbContext
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>, new()
+    where TUserLogin : IdentityUserLogin<TKey>, new()
+    where TUserToken : IdentityUserToken<TKey>, new()
+    where TUserPasskey : IdentityUserPasskey<TKey>, new()
+{
    // Members from UserOnlyStore`6 moved here

+    /// <summary>
+    /// DbSet of user passkeys.
+    /// </summary>
+    protected DbSet<TUserPasskey> UserPasskeys { get; }

+    /// <summary>
+    /// Called to create a new instance of a <see cref="IdentityUserPasskey{TKey}"/>.
+    /// </summary>
+    /// <param name="user">The user.</param>
+    /// <param name="passkey">The passkey.</param>
+    /// <returns></returns>
+    protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo passkey);

+    /// <summary>
+    /// Find a passkey with the specified credential id for a user.
+    /// </summary>
+    /// <param name="userId">The user's id.</param>
+    /// <param name="credentialId">The credential id to search for.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
+    /// <returns>The user passkey if it exists.</returns>
+    protected virtual Task<TUserPasskey?> FindUserPasskeyAsync(TKey userId, byte[] credentialId, CancellationToken cancellationToken);

+    /// <summary>
+    /// Find a passkey with the specified credential id.
+    /// </summary>
+    /// <param name="credentialId">The credential id to search for.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
+    /// <returns>The user passkey if it exists.</returns>
+    protected virtual Task<TUserPasskey?> FindUserPasskeyByIdAsync(byte[] credentialId, CancellationToken cancellationToken);

+    public virtual async Task AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken);
+    public virtual async Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user, CancellationToken cancellationToken);
+    public virtual async Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken);
+    public virtual async Task<UserPasskeyInfo?> FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
+    public virtual async Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
+}

public class UserStore<TUser, TRole, TContext, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim> :
-    UserStoreBase<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim>,
-    IProtectedUserStore<TUser>
+    UserStore<TUser, TRole, TContext, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TRole : IdentityRole<TKey>
    where TContext : DbContext
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>, new()
    where TUserRole : IdentityUserRole<TKey>, new()
    where TUserLogin : IdentityUserLogin<TKey>, new()
    where TUserToken : IdentityUserToken<TKey>, new()
    where TRoleClaim : IdentityRoleClaim<TKey>, new()
{
+    public UserStore(TContext context, IdentityErrorDescriber? describer = null);
}

+/// <summary>
+/// Represents a new instance of a persistence store for the specified user and role types.
+/// </summary>
+/// <typeparam name="TUser">The type representing a user.</typeparam>
+/// <typeparam name="TRole">The type representing a role.</typeparam>
+/// <typeparam name="TContext">The type of the data context class used to access the store.</typeparam>
+/// <typeparam name="TKey">The type of the primary key for a role.</typeparam>
+/// <typeparam name="TUserClaim">The type representing a claim.</typeparam>
+/// <typeparam name="TUserRole">The type representing a user role.</typeparam>
+/// <typeparam name="TUserLogin">The type representing a user external login.</typeparam>
+/// <typeparam name="TUserToken">The type representing a user token.</typeparam>
+/// <typeparam name="TRoleClaim">The type representing a role claim.</typeparam>
+/// <typeparam name="TUserPasskey">The type representing a user passkey.</typeparam>
+public class UserStore<TUser, TRole, TContext, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim, TUserPasskey> :
+    UserStoreBase<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim>,
+    IProtectedUserStore<TUser>,
+    IUserPasskeyStore<TUser>
+    where TUser : IdentityUser<TKey>
+    where TRole : IdentityRole<TKey>
+    where TContext : DbContext
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>, new()
+    where TUserRole : IdentityUserRole<TKey>, new()
+    where TUserLogin : IdentityUserLogin<TKey>, new()
+    where TUserToken : IdentityUserToken<TKey>, new()
+    where TRoleClaim : IdentityRoleClaim<TKey>, new()
+    where TUserPasskey : IdentityUserPasskey<TKey>, new()
+{
    // Members from UserStore`9 moved here.

+    /// <summary>
+    /// Called to create a new instance of a <see cref="IdentityUserPasskey{TKey}"/>.
+    /// </summary>
+    /// <param name="user">The user.</param>
+    /// <param name="passkey">The passkey.</param>
+    /// <returns></returns>
+    protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo passkey);

+    /// <summary>
+    /// Find a passkey with the specified credential id for a user.
+    /// </summary>
+    /// <param name="userId">The user's id.</param>
+    /// <param name="credentialId">The credential id to search for.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
+    /// <returns>The user passkey if it exists.</returns>
+    protected virtual Task<TUserPasskey?> FindUserPasskeyAsync(TKey userId, byte[] credentialId, CancellationToken cancellationToken);

+    /// <summary>
+    /// Find a passkey with the specified credential id.
+    /// </summary>
+    /// <param name="credentialId">The credential id to search for.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
+    /// <returns>The user passkey if it exists.</returns>
+    protected virtual Task<TUserPasskey?> FindUserPasskeyByIdAsync(byte[] credentialId, CancellationToken cancellationToken);

+    public virtual async Task AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken)
+    public virtual async Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user, CancellationToken cancellationToken)
+    public virtual async Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken)
+    public virtual async Task<UserPasskeyInfo?> FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken)
+    public virtual async Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken)
+}

MackinnonBuck avatar Jun 20 '25 20:06 MackinnonBuck

API Review Notes (Round 2.5)

Since our last API review, another internal design review led to some changes to the public API. This comment describes the new API.

Following is a brief summary of the changes:

  • DefaultPasskeyHandler is now sealed
  • PasskeyOptions is now in the Microsoft.AspNetCore.Identity package
  • PasskeyOptions now defines callback properties that can be used to augment passkey validation
  • Most passkey creation/request configuration is now defined in PasskeyOptions
  • IPasskeyHandler has new methods for configuring passkey creation/request options:
    • MakePasskeyCreationOptionsAsync
    • MakePasskeyRequestOptionsAsync
  • SignInManager APIs have been simplified
    • Namely, the persisting and retrieval of transient state is handled completely internally

For more details, see https://github.com/dotnet/aspnetcore/pull/62530.

Updated API

Microsoft.AspNetCore.Identity

Expand to view
namespace Microsoft.AspNetCore.Identity;

// Existing type. Only new members shown.
public class SignInManager<TUser>
    where TUser : class
{
    /// <summary>
    /// Generates passkey creation options for the specified <paramref name="userEntity"/>.
    /// </summary>
    /// <param name="userEntity">The user entity for which to create passkey options.</param>
    /// <returns>A JSON string representing the created passkey options.</returns>
    public virtual Task<string> MakePasskeyCreationOptionsAsync(PasskeyUserEntity userEntity);

    /// <summary>
    /// Creates passkey assertion options for the specified <paramref name="user"/>.
    /// </summary>
    /// <param name="user">The user for whom to create passkey assertion options.</param>
    /// <returns>A JSON string representing the created passkey assertion options.</returns>
    public virtual Task<string> MakePasskeyRequestOptionsAsync(TUser? user);

    /// <summary>
    /// Performs passkey attestation for the given <paramref name="credentialJson"/>.
    /// </summary>
    /// <remarks>
    /// The <paramref name="credentialJson"/> should be obtained by JSON-serializing the result of the
    /// <c>navigator.credentials.create()</c> JavaScript API. The argument to <c>navigator.credentials.create()</c>
    /// should be obtained by calling <see cref="MakePasskeyCreationOptionsAsync(PasskeyUserEntity)"/>.
    /// </remarks>
    /// <param name="credentialJson">The credentials obtained by JSON-serializing the result of the <c>navigator.credentials.create()</c> JavaScript function.</param>
    /// <returns>A <see cref="PasskeyAttestationResult"/> representing the result of the operation.</returns>
    public virtual Task<PasskeyAttestationResult> PerformPasskeyAttestationAsync(string credentialJson);

    /// <summary>
    /// Performs passkey assertion for the given <paramref name="credentialJson"/>.
    /// </summary>
    /// <remarks>
    /// The <paramref name="credentialJson"/> should be obtained by JSON-serializing the result of the 
    /// <c>navigator.credentials.get()</c> JavaScript API. The argument to <c>navigator.credentials.get()</c>
    /// should be obtained by calling <see cref="MakePasskeyRequestOptionsAsync(TUser)"/>.
    /// Upon success, the <see cref="PasskeyAssertionResult{TUser}.Passkey"/> should be stored on the
    /// <see cref="PasskeyAssertionResult{TUser}.User"/> using <see cref="UserManager{TUser}.SetPasskeyAsync(TUser, UserPasskeyInfo)"/>.
    /// </remarks>
    /// <param name="credentialJson">The credentials obtained by JSON-serializing the result of the <c>navigator.credentials.get()</c> JavaScript function.</param>
    /// <returns>A <see cref="PasskeyAssertionResult{TUser}"/> representing the result of the operation.</returns>
    public virtual Task<PasskeyAssertionResult<TUser>> PerformPasskeyAssertionAsync(string credentialJson);

    /// <summary>
    /// Performs a passkey assertion and attempts to sign in the user.
    /// </summary>
    /// <remarks>
    /// The <paramref name="credentialJson"/> should be obtained by JSON-serializing the result of the 
    /// <c>navigator.credentials.get()</c> JavaScript API. The argument to <c>navigator.credentials.get()</c>
    /// should be obtained by calling <see cref="MakePasskeyRequestOptionsAsync(TUser)"/>.
    /// </remarks>
    /// <param name="credentialJson">The credentials obtained by JSON-serializing the result of the <c>navigator.credentials.get()</c> JavaScript function.</param>
    /// <returns>A <see cref="SignInResult"/> with the result of the sign in operation.</returns>
    public virtual Task<SignInResult> PasskeySignInAsync(string credentialJson);
}

/// <summary>
/// Represents information about the user associated with a passkey.
/// </summary>
public sealed class PasskeyUserEntity
{
    /// <summary>
    /// Gets the user ID associated with a passkey.
    /// </summary>
    public required string Id { get; init; }

    /// <summary>
    /// Gets the name of the user associated with a passkey.
    /// </summary>
    public required string Name { get; init; }

    /// <summary>
    /// Gets the display name of the user associated with a passkey.
    /// </summary>
    public required string DisplayName { get; init; }
}

/// <summary>
/// Represents a handler for generating passkey creation and request options and performing
/// passkey assertion and attestation.
/// </summary>
public interface IPasskeyHandler<TUser>
    where TUser : class
{
    /// <summary>
    /// Generates passkey creation options for the specified user entity and HTTP context.
    /// </summary>
    /// <param name="userEntity">The passkey user entity for which to generate creation options.</param>
    /// <param name="httpContext">The HTTP context associated with the request.</param>
    /// <returns>A <see cref="PasskeyCreationOptionsResult"/> representing the result.</returns>
    Task<PasskeyCreationOptionsResult> MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext);

    /// <summary>
    /// Generates passkey request options for the specified user and HTTP context.
    /// </summary>
    /// <param name="user">The user for whom to generate request options.</param>
    /// <param name="httpContext">The HTTP context associated with the request.</param>
    /// <returns>A <see cref="PasskeyRequestOptionsResult"/> representing the result.</returns>
    Task<PasskeyRequestOptionsResult> MakeRequestOptionsAsync(TUser? user, HttpContext httpContext);

    /// <summary>
    /// Performs passkey attestation using the provided <see cref="PasskeyAttestationContext"/>.
    /// </summary>
    /// <param name="context">The context containing necessary information for passkey attestation.</param>
    /// <returns>A <see cref="PasskeyAttestationResult"/> representing the result.</returns>
    Task<PasskeyAttestationResult> PerformAttestationAsync(PasskeyAttestationContext context);

    /// <summary>
    /// Performs passkey assertion using the provided <see cref="PasskeyAssertionContext"/>.
    /// </summary>
    /// <param name="context">The context containing necessary information for passkey assertion.</param>
    /// <returns>A <see cref="PasskeyAssertionResult{TUser}"/> representing the result.</returns>
    Task<PasskeyAssertionResult<TUser>> PerformAssertionAsync(PasskeyAssertionContext context);
}

/// <summary>
/// Represents the result of a passkey creation options generation.
/// </summary>
public sealed class PasskeyCreationOptionsResult
{
    /// <summary>
    /// Gets or sets the JSON representation of the creation options.
    /// </summary>
    /// <remarks>
    /// The structure of this JSON is compatible with
    /// <see href="https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialcreationoptionsjson"/>
    /// and should be used with the <c>navigator.credentials.create()</c> JavaScript API.
    /// </remarks>
    public required string CreationOptionsJson { get; init; }

    /// <summary>
    /// Gets or sets the state to be used in the attestation procedure.
    /// </summary>
    /// <remarks>
    /// This can be later retrieved during assertion with <see cref="PasskeyAttestationContext.AttestationState"/>.
    /// </remarks>
    public string? AttestationState { get; init; }
}

/// <summary>
/// Represents the result of a passkey request options generation.
/// </summary>
public sealed class PasskeyRequestOptionsResult
{
    /// <summary>
    /// Gets or sets the JSON representation of the request options.
    /// </summary>
    /// <remarks>
    /// The structure of this JSON is compatible with
    /// <see href="https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptionsjson"/>
    /// and should be used with the <c>navigator.credentials.get()</c> JavaScript API.
    /// </remarks>
    public required string RequestOptionsJson { get; init; }

    /// <summary>
    /// Gets or sets the state to be used in the assertion procedure.
    /// </summary>
    /// <remarks>
    /// This can be later retrieved during assertion with <see cref="PasskeyAssertionContext.AssertionState"/>.
    /// </remarks>
    public string? AssertionState { get; init; }
}

/// <summary>
/// Represents the context for passkey attestation.
/// </summary>
public sealed class PasskeyAttestationContext
{
    /// <summary>
    /// Gets or sets the <see cref="Http.HttpContext"/> for the current request. 
    /// </summary>
    public required HttpContext HttpContext { get; init; }

    /// <summary>
    /// Gets or sets the credentials obtained by JSON-serializing the result of the
    /// <c>navigator.credentials.create()</c> JavaScript function.
    /// </summary>
    public required string CredentialJson { get; init; }

    /// <summary>
    /// Gets or sets the state to be used in the attestation procedure.
    /// </summary>
    /// <remarks>
    /// This is expected to match the <see cref="PasskeyCreationOptionsResult.AttestationState"/>
    /// previously returned from <see cref="IPasskeyHandler{TUser}.MakeCreationOptionsAsync(PasskeyUserEntity, HttpContext)"/>.
    /// </remarks>
    public required string? AttestationState { get; init; }
}

/// <summary>
/// Represents the context for passkey assertion.
/// </summary>
public sealed class PasskeyAssertionContext
{
    /// <summary>
    /// Gets or sets the <see cref="Http.HttpContext"/> for the current request. 
    /// </summary>
    public required HttpContext HttpContext { get; init; }

    /// <summary>
    /// Gets or sets the credentials obtained by JSON-serializing the result of the
    /// <c>navigator.credentials.get()</c> JavaScript function.
    /// </summary>
    public required string CredentialJson { get; init; }

    /// <summary>
    /// Gets or sets the state to be used in the assertion procedure.
    /// </summary>
    /// <remarks>
    /// This is expected to match the <see cref="PasskeyRequestOptionsResult.AssertionState"/>
    /// previously returned from <see cref="IPasskeyHandler{TUser}.MakeRequestOptionsAsync(TUser, HttpContext)"/>.
    /// </remarks>
    public required string? AssertionState { get; init; }
}

/// <summary>
/// Represents the result of a passkey attestation operation.
/// </summary>
public sealed class PasskeyAttestationResult
{
    [MemberNotNullWhen(true, nameof(Passkey))]
    [MemberNotNullWhen(true, nameof(UserEntity))]
    [MemberNotNullWhen(false, nameof(Failure))]
    public bool Succeeded { get; }

    public UserPasskeyInfo? Passkey { get; }
    public PasskeyUserEntity? UserEntity { get; }
    public PasskeyException? Failure { get; }
    public static PasskeyAttestationResult Success(UserPasskeyInfo passkey, PasskeyUserEntity userEntity);
    public static PasskeyAttestationResult Fail(PasskeyException failure);
}

/// <summary>
/// Represents the result of a passkey assertion operation.
/// </summary>
public sealed class PasskeyAssertionResult<TUser>
    where TUser : class
{
    [MemberNotNullWhen(true, nameof(Passkey))]
    [MemberNotNullWhen(true, nameof(User))]
    [MemberNotNullWhen(false, nameof(Failure))]
    public bool Succeeded { get; }

    public UserPasskeyInfo? Passkey { get; }
    public TUser? User { get; }
    public PasskeyException? Failure { get; }
}

/// <summary>
/// A factory class for creating instances of <see cref="PasskeyAssertionResult{TUser}"/>.
/// </summary>
public static class PasskeyAssertionResult
{
    public static PasskeyAssertionResult<TUser> Success<TUser>(UserPasskeyInfo passkey, TUser user)
        where TUser : class;

    public static PasskeyAssertionResult<TUser> Fail<TUser>(PasskeyException failure)
        where TUser : class;
}

/// <summary>
/// The default passkey handler.
/// </summary>
public sealed class DefaultPasskeyHandler<TUser> : IPasskeyHandler<TUser>
    where TUser : class
{
    public DefaultPasskeyHandler(UserManager<TUser> userManager, IOptions<PasskeyOptions> options);

    public Task<PasskeyCreationOptionsResult> MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext);
    public Task<PasskeyRequestOptionsResult> MakeRequestOptionsAsync(TUser? user, HttpContext httpContext);
    public Task<PasskeyAttestationResult> PerformAttestationAsync(PasskeyAttestationContext context);
    public Task<PasskeyAssertionResult<TUser>> PerformAssertionAsync(PasskeyAssertionContext context);
}

/// <summary>
/// Represents an error that occurred during passkey attestation or assertion.
/// </summary>
public sealed class PasskeyException : Exception
{
    public PasskeyException(string message);
    public PasskeyException(string message, Exception? innerException);
}

/// <summary>
/// Specifies options for passkey requirements.
/// </summary>
public class PasskeyOptions
{
    /// <summary>
    /// Gets or sets the time that the server is willing to wait for a passkey operation to complete.
    /// </summary>
    /// <remarks>
    /// The default value is 5 minutes.
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-timeout"/>
    /// and <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-timeout"/>.
    /// </remarks>
    public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5);

    /// <summary>
    /// The size of the challenge in bytes sent to the client during WebAuthn attestation and assertion.
    /// </summary>
    /// <remarks>
    /// The default value is 32 bytes.
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-challenge"/>
    /// and <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-challenge"/>.
    /// </remarks>
    public int ChallengeSize { get; set; } = 32;

    /// <summary>
    /// The effective domain of the server. Should be unique and will be used as the identity for the server.
    /// </summary>
    /// <remarks>
    /// If left <see langword="null"/>, the server's origin may be used instead.
    /// See <see href="https://www.w3.org/TR/webauthn-3/#rp-id"/>.
    /// </remarks>
    public string? ServerDomain { get; set; }

    /// <summary>
    /// Gets or sets the user verification requirement.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#enumdef-userverificationrequirement"/>.
    /// Possible values are "required", "preferred", and "discouraged".
    /// If left <see langword="null"/>, the browser defaults to "preferred".
    /// </remarks>
    public string? UserVerificationRequirement { get; set; }

    /// <summary>
    /// Gets or sets the extent to which the server desires to create a client-side discoverable credential.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#enumdef-residentkeyrequirement"/>.
    /// Possible values are "discouraged", "preferred", or "required".
    /// If left <see langword="null"/>, the browser defaults to "preferred".
    /// </remarks>
    public string? ResidentKeyRequirement { get; set; }

    /// <summary>
    /// Gets or sets the attestation conveyance preference.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#enumdef-attestationconveyancepreference"/>.
    /// If left <see langword="null"/>, the browser defaults to "none".
    /// </remarks>
    public string? AttestationConveyancePreference { get; set; }

    /// <summary>
    /// Gets or sets the authenticator attachment.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#enumdef-authenticatorattachment"/>.
    /// If left <see langword="null"/>, any authenticator attachment modality is allowed.
    /// </remarks>
    public string? AuthenticatorAttachment { get; set; }

    /// <summary>
    /// Gets or sets a function that determines whether the given COSE algorithm identifier
    /// is allowed for passkey operations.
    /// </summary>
    /// <remarks>
    /// If <see langword="null"/> all supported algorithms are allowed.
    /// See <see href="https://www.iana.org/assignments/cose/cose.xhtml#algorithms"/>.
    /// </remarks>
    public Func<int, bool>? IsAllowedAlgorithm { get; set; }

    /// <summary>
    /// Gets or sets a function that validates the origin of the request.
    /// </summary>
    /// <remarks>
    /// By default, this function disallows cross-origin requests and checks
    /// that the request's origin header matches the credential's origin.
    /// </remarks>
    public Func<PasskeyOriginValidationContext, Task<bool>> ValidateOrigin { get; set; }

    /// <summary>
    /// Gets or sets a function that verifies the attestation statement of a passkey.
    /// </summary>
    /// <remarks>
    /// By default, this function does not perform any verification and always returns <see langword="true"/>.
    /// </remarks>
    public Func<PasskeyAttestationStatementVerificationContext, Task<bool>> VerifyAttestationStatement { get; set; }
}

/// <summary>
/// Contains information used for determining whether a passkey's origin is valid.
/// </summary>
public readonly struct PasskeyOriginValidationContext
{
    /// <summary>
    /// Gets or sets the HTTP context associated with the request.
    /// </summary>
    public required HttpContext HttpContext { get; init; }

    /// <summary>
    /// Gets or sets the fully-qualified origin of the requester.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-origin"/>.
    /// </remarks>
    public required string Origin { get; init; }

    /// <summary>
    /// Gets or sets whether the request came from a cross-origin <c>&lt;iframe&gt;</c>.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-crossorigin"/>.
    /// </remarks>
    public required bool CrossOrigin { get; init; }

    /// <summary>
    /// Gets or sets the fully-qualified top-level origin of the requester.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-toporigin"/>.
    /// </remarks>
    public string? TopOrigin { get; init; }
}

/// <summary>
/// Contains the context for passkey attestation statement verification.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#verification-procedure"/>.
/// </remarks>
public readonly struct PasskeyAttestationStatementVerificationContext
{
    /// <summary>
    /// Gets or sets the <see cref="HttpContext"/> for the current request.
    /// </summary>
    public required HttpContext HttpContext { get; init; }

    /// <summary>
    /// Gets or sets the attestation object as a byte array.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#attestation-object"/>.
    /// </remarks>
    public required ReadOnlyMemory<byte> AttestationObject { get; init; }

    /// <summary>
    /// Gets or sets the hash of the client data as a byte array.
    /// </summary>
    public required ReadOnlyMemory<byte> ClientDataHash { get; init; }
}

Microsoft.Extensions.Identity.Core

Expand to view
namespace Microsoft.AspNetCore.Identity;

/// <summary>
/// Provides an abstraction for storing passkey credentials for a user.
/// </summary>
/// <typeparam name="TUser">The type that represents a user.</typeparam>
public interface IUserPasskeyStore<TUser> : IUserStore<TUser> where TUser : class
{
    /// <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 AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken);

    /// <summary>
    /// Gets the passkey credentials for the specified <paramref name="user"/>.
    /// </summary>
    /// <param name="user">The user whose passkeys should be retrieved.</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, containing a list of the user's passkeys.</returns>
    Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user, CancellationToken cancellationToken);

    /// <summary>
    /// Finds and returns a user, if any, associated with the specified passkey credential identifier.
    /// </summary>
    /// <param name="credentialId">The passkey credential id to search for.</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, containing the user, if any, associated with the specified passkey credential id.
    /// </returns>
    Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken);

    /// <summary>
    /// Finds a passkey for the specified user with the specified credential id.
    /// </summary>
    /// <param name="user">The user whose passkey should be retrieved.</param>
    /// <param name="credentialId">The credential id to search for.</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, containing the user's passkey information.</returns>
    Task<UserPasskeyInfo?> FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);

    /// <summary>
    /// Removes a passkey credential from the specified <paramref name="user"/>.
    /// </summary>
    /// <param name="user">The user to remove the passkey credential from.</param>
    /// <param name="credentialId">The credential id of the passkey to remove.</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 RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
}

/// <summary>
/// Provides information for a user's passkey credential.
/// </summary>
public class UserPasskeyInfo
{
    public UserPasskeyInfo(
        byte[] credentialId,
        byte[] publicKey,
        string? name,
        DateTimeOffset createdAt,
        uint signCount,
        string[]? transports,
        bool isUserVerified,
        bool isBackupEligible,
        bool isBackedUp,
        byte[] attestationObject,
        byte[] clientDataJson);

    /// <summary>
    /// Gets the credential ID for this passkey.
    /// </summary>
    public byte[] CredentialId { get; }

    /// <summary>
    /// Gets the public key associated with this passkey.
    /// </summary>
    public byte[] PublicKey { get; }

    /// <summary>
    /// Gets or sets the friendly name for this passkey.
    /// </summary>
    public string? Name { get; set; }

    /// <summary>
    /// Gets the time this passkey was created.
    /// </summary>
    public DateTimeOffset CreatedAt { get; }

    /// <summary>
    /// Gets or sets the signature counter for this passkey.
    /// </summary>
    public uint SignCount { get; set; }

    /// <summary>
    /// Gets the transports supported by this passkey.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#enumdef-authenticatortransport"/>.
    /// </remarks>
    public string[]? Transports { get; }

    /// <summary>
    /// Gets or sets whether the passkey has a verified user.
    /// </summary>
    public bool IsUserVerified { get; set; }

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

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

    /// <summary>
    /// Gets the attestation object associated with this passkey.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#attestation-object"/>.
    /// </remarks>
    public byte[] AttestationObject { get; }

    /// <summary>
    /// Gets the collected client data JSON associated with this passkey.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dictdef-collectedclientdata"/>.
    /// </remarks>
    public byte[] ClientDataJson { get; }
}
public static class IdentitySchemaVersions
{
+    /// <summary>
+    /// Represents the 3.0 version of the identity schema
+    /// </summary>
+    public static readonly Version Version3 = new Version(3, 0);
}

public class UserManager<TUser> : IDisposable
    where TUser : class
{
+    public IServiceProvider ServiceProvider { get; }
+    public virtual bool SupportsUserPasskey { get; }
+    public virtual Task<IdentityResult> AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey);
+    public virtual Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user);
+    public virtual Task<UserPasskeyInfo?> GetPasskeyAsync(TUser user, byte[] credentialId);
+    public virtual Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId);
+    public virtual Task<IdentityResult> RemovePasskeyAsync(TUser user, byte[] credentialId);
}

Microsoft.Extensions.Identity.Stores

Expand to view
namespace Microsoft.AspNetCore.Identity;

/// <summary>
/// Represents a passkey credential for a user in the identity system.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#credential-record"/>.
/// </remarks>
/// <typeparam name="TKey">The type used for the primary key for this passkey credential.</typeparam>
public class IdentityUserPasskey<TKey>
    where TKey : IEquatable<TKey>
{
    public virtual TKey UserId { get; set; }
    public virtual byte[] CredentialId { get; set; }
    public virtual byte[] PublicKey { get; set; }
    public virtual string? Name { get; set; }
    public virtual DateTimeOffset CreatedAt { get; set; }
    public virtual uint SignCount { get; set; }
    public virtual string[]? Transports { get; set; }
    public virtual bool IsUserVerified { get; set; }
    public virtual bool IsBackupEligible { get; set; }
    public virtual bool IsBackedUp { get; set; }
    public virtual byte[] AttestationObject { get; set; }
    public virtual byte[] ClientDataJson { get; set; }
}

Microsoft.AspNetCore.Identity.EntityFrameworkCore

Expand to view
namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore;

-public abstract class IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken> :
-   IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken>
+public class IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken> :
+   IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TRole : IdentityRole<TKey>
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>
    where TUserRole : IdentityUserRole<TKey>
    where TUserLogin : IdentityUserLogin<TKey>
    where TRoleClaim : IdentityRoleClaim<TKey>
    where TUserToken : IdentityUserToken<TKey>
{
+    public IdentityDbContext(DbContextOptions options);
+    protected IdentityDbContext();
}

+public abstract class IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken, TUserPasskey> :
+    IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken, TUserPasskey>
+    where TUser : IdentityUser<TKey>
+    where TRole : IdentityRole<TKey>
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>
+    where TUserRole : IdentityUserRole<TKey>
+    where TUserLogin : IdentityUserLogin<TKey>
+    where TRoleClaim : IdentityRoleClaim<TKey>
+    where TUserToken : IdentityUserToken<TKey>
+    where TUserPasskey : IdentityUserPasskey<TKey>
+{
    // Members from IdentityDbContext`8 moved here
+}

-public abstract class IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken> :
-    DbContext
+public class IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken> :
+    IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>
    where TUserLogin : IdentityUserLogin<TKey>
    where TUserToken : IdentityUserToken<TKey>
{
+    public IdentityUserContext(DbContextOptions options);
+    protected IdentityUserContext();
}

+/// <summary>
+/// Base class for the Entity Framework database context used for identity.
+/// </summary>
+/// <typeparam name="TUser">The type of user objects.</typeparam>
+/// <typeparam name="TKey">The type of the primary key for users and roles.</typeparam>
+/// <typeparam name="TUserClaim">The type of the user claim object.</typeparam>
+/// <typeparam name="TUserLogin">The type of the user login object.</typeparam>
+/// <typeparam name="TUserToken">The type of the user token object.</typeparam>
+/// <typeparam name="TUserPasskey">The type of the user passkey object.</typeparam>
+public abstract class IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken, TUserPasskey> : DbContext
+    where TUser : IdentityUser<TKey>
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>
+    where TUserLogin : IdentityUserLogin<TKey>
+    where TUserToken : IdentityUserToken<TKey>
+    where TUserPasskey : IdentityUserPasskey<TKey>
+{
+    /// <summary>
+    /// Gets or sets the <see cref="DbSet{TEntity}"/> of User passkeys.
+    /// </summary>
+    public virtual DbSet<TUserPasskey> UserPasskeys { get; set; }
+}

public class UserOnlyStore<TUser, TContext, TKey, TUserClaim, TUserLogin, TUserToken> :
-    UserStoreBase<TUser, TKey, TUserClaim, TUserLogin, TUserToken>
-    IUserLoginStore<TUser>,
-    IUserClaimStore<TUser>,
-    IUserPasswordStore<TUser>,
-    IUserSecurityStampStore<TUser>,
-    IUserEmailStore<TUser>,
-    IUserLockoutStore<TUser>,
-    IUserPhoneNumberStore<TUser>,
-    IQueryableUserStore<TUser>,
-    IUserTwoFactorStore<TUser>,
-    IUserAuthenticationTokenStore<TUser>,
-    IUserAuthenticatorKeyStore<TUser>,
-    IUserTwoFactorRecoveryCodeStore<TUser>,
-    IProtectedUserStore<TUser>
+    UserOnlyStore<TUser, TContext, TKey, TUserClaim, TUserLogin, TUserToken, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TContext : DbContext
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>, new()
    where TUserLogin : IdentityUserLogin<TKey>, new()
    where TUserToken : IdentityUserToken<TKey>, new()
{
+    public UserOnlyStore(TContext context, IdentityErrorDescriber? describer = null);
}

+public class UserOnlyStore<TUser, TContext, TKey, TUserClaim, TUserLogin, TUserToken, TUserPasskey> :
+    UserStoreBase<TUser, TKey, TUserClaim, TUserLogin, TUserToken>,
+    IUserLoginStore<TUser>,
+    IUserClaimStore<TUser>,
+    IUserPasswordStore<TUser>,
+    IUserSecurityStampStore<TUser>,
+    IUserEmailStore<TUser>,
+    IUserLockoutStore<TUser>,
+    IUserPhoneNumberStore<TUser>,
+    IQueryableUserStore<TUser>,
+    IUserTwoFactorStore<TUser>,
+    IUserAuthenticationTokenStore<TUser>,
+    IUserAuthenticatorKeyStore<TUser>,
+    IUserTwoFactorRecoveryCodeStore<TUser>,
+    IProtectedUserStore<TUser>,
+    IUserPasskeyStore<TUser>
+    where TUser : IdentityUser<TKey>
+    where TContext : DbContext
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>, new()
+    where TUserLogin : IdentityUserLogin<TKey>, new()
+    where TUserToken : IdentityUserToken<TKey>, new()
+    where TUserPasskey : IdentityUserPasskey<TKey>, new()
+{
    // Members from UserOnlyStore`6 moved here

+    protected DbSet<TUserPasskey> UserPasskeys { get; }

+    /// <summary>
+    /// Called to create a new instance of a <see cref="IdentityUserPasskey{TKey}"/>.
+    /// </summary>
+    /// <param name="user">The user.</param>
+    /// <param name="passkey">The passkey.</param>
+    /// <returns></returns>
+    protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo passkey);

+    /// <summary>
+    /// Find a passkey with the specified credential id for a user.
+    /// </summary>
+    /// <param name="userId">The user's id.</param>
+    /// <param name="credentialId">The credential id to search for.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
+    /// <returns>The user passkey if it exists.</returns>
+    protected virtual Task<TUserPasskey?> FindUserPasskeyAsync(TKey userId, byte[] credentialId, CancellationToken cancellationToken);

+    /// <summary>
+    /// Find a passkey with the specified credential id.
+    /// </summary>
+    /// <param name="credentialId">The credential id to search for.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
+    /// <returns>The user passkey if it exists.</returns>
+    protected virtual Task<TUserPasskey?> FindUserPasskeyByIdAsync(byte[] credentialId, CancellationToken cancellationToken);

+    Task AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken);
+    Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user, CancellationToken cancellationToken);
+    Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken);
+    Task<UserPasskeyInfo?> FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
+    Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
+}

public class UserStore<TUser, TRole, TContext, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim> :
-    UserStoreBase<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim>,
-    IProtectedUserStore<TUser>
+    UserStore<TUser, TRole, TContext, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TRole : IdentityRole<TKey>
    where TContext : DbContext
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>, new()
    where TUserRole : IdentityUserRole<TKey>, new()
    where TUserLogin : IdentityUserLogin<TKey>, new()
    where TUserToken : IdentityUserToken<TKey>, new()
    where TRoleClaim : IdentityRoleClaim<TKey>, new()
{
+    public UserStore(TContext context, IdentityErrorDescriber? describer = null);
}

+/// <summary>
+/// Represents a new instance of a persistence store for the specified user and role types.
+/// </summary>
+/// <typeparam name="TUser">The type representing a user.</typeparam>
+/// <typeparam name="TRole">The type representing a role.</typeparam>
+/// <typeparam name="TContext">The type of the data context class used to access the store.</typeparam>
+/// <typeparam name="TKey">The type of the primary key for a role.</typeparam>
+/// <typeparam name="TUserClaim">The type representing a claim.</typeparam>
+/// <typeparam name="TUserRole">The type representing a user role.</typeparam>
+/// <typeparam name="TUserLogin">The type representing a user external login.</typeparam>
+/// <typeparam name="TUserToken">The type representing a user token.</typeparam>
+/// <typeparam name="TRoleClaim">The type representing a role claim.</typeparam>
+/// <typeparam name="TUserPasskey">The type representing a user passkey.</typeparam>
+public class UserStore<TUser, TRole, TContext, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim, TUserPasskey> :
+    UserStoreBase<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim>,
+    IProtectedUserStore<TUser>,
+    IUserPasskeyStore<TUser>
+    where TUser : IdentityUser<TKey>
+    where TRole : IdentityRole<TKey>
+    where TContext : DbContext
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>, new()
+    where TUserRole : IdentityUserRole<TKey>, new()
+    where TUserLogin : IdentityUserLogin<TKey>, new()
+    where TUserToken : IdentityUserToken<TKey>, new()
+    where TRoleClaim : IdentityRoleClaim<TKey>, new()
+    where TUserPasskey : IdentityUserPasskey<TKey>, new()
+{
    // Members from UserStore`9 moved here

+    protected DbSet<TUserPasskey> UserPasskeys { get; }

+    /// <summary>
+    /// Called to create a new instance of a <see cref="IdentityUserPasskey{TKey}"/>.
+    /// </summary>
+    /// <param name="user">The user.</param>
+    /// <param name="passkey">The passkey.</param>
+    /// <returns></returns>
+    protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo passkey);

+    /// <summary>
+    /// Find a passkey with the specified credential id for a user.
+    /// </summary>
+    /// <param name="userId">The user's id.</param>
+    /// <param name="credentialId">The credential id to search for.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
+    /// <returns>The user passkey if it exists.</returns>
+    protected virtual Task<TUserPasskey?> FindUserPasskeyAsync(TKey userId, byte[] credentialId, CancellationToken cancellationToken);

+    /// <summary>
+    /// Find a passkey with the specified credential id.
+    /// </summary>
+    /// <param name="credentialId">The credential id to search for.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
+    /// <returns>The user passkey if it exists.</returns>
+    protected virtual Task<TUserPasskey?> FindUserPasskeyByIdAsync(byte[] credentialId, CancellationToken cancellationToken);

+    Task AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken);
+    Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user, CancellationToken cancellationToken);
+    Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken);
+    Task<UserPasskeyInfo?> FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
+    Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
+}

Usage examples

Generating passkey creation/request options

Registering/authenticating a user

MackinnonBuck avatar Jul 07 '25 20:07 MackinnonBuck

API Review Notes (Round 3)

  • Can we remove the constructor for UserPasskeyInfo?
    • Maybe, but it's the only way to make certain properties "required" because it lives in a project targeting netstandard
    • Conclusion: Remove the "name" parameter from the constructor, since it's optional.
  • Should UserPasskeyInfo be sealed?
    • Conclusion: Yes - we can always unseal it in the future
  • Are we sure we have all the right properties in UserPasskeyInfo? We're not missing anything?
    • We can't be 100% sure. For example, the IsBackedUp and IsBackupEligible properties wouldn't have existed prior to the level 3 specification.
    • Is there anything we can do to future-proof the Identity store schema?
    • Yes - we could move most most of the properties in IdentityUserPasskey<TKey> to a separate class that gets stored as a JSON column in the original model.
    • Conclusion: Move properties out of IdentityUserPasskey<TKey> into another type and make it a JSON column on the original entity.
  • Should we remove the FindUserPasskeyAsync() and FindUserPasskeyByIdAsync() methods from public API?
    • Yes - we can always expose them later if needed.
  • For all properties in PasskeyOptions, let's call out the extent of their effects, including:
    • Whether they apply for creating a new passkey, requesting a new passkey, or both
    • Whether they're used for server-side passkey validation
  • For each of the callbacks in PasskeyOptions:
    • Make them nullable, where a null value means "do the default behavior"
    • Make them return ValueTask instead of Task, when applicable
    • Change the context types from structs into sealed classes
  • Change PasskeyOptions.Timeout to PasskeyOptions.AuthenticatorTimeout and change the XML docs to make it clearer that the timeout enforcement happens on the client
  • Should we rename MakeCreationOptionsAsync to CreateCreationOptionsAsync?
    • Conclusion: No
  • Are we okay with both "Validate" and "Verify" for properties in PasskeyOptions?
    • Yes. They have slightly different meanings.

We think we're ready to approve the API, but we also know that this is a huge API surface, and as a result, some parts of it received a less thorough analysis than we would have otherwise done if the API was smaller. Therefore, we're going to continue an "asynchronous" API review to give others a chance to provide feedback on their own time.

After asynchronous feedback is addressed - API approved!

Updated API

Microsoft.AspNetCore.Identity

Expand to view
namespace Microsoft.AspNetCore.Identity;

// Existing type. Only new members shown.
public class SignInManager<TUser>
    where TUser : class
{
    /// <summary>
    /// Generates passkey creation options for the specified <paramref name="userEntity"/>.
    /// </summary>
    /// <param name="userEntity">The user entity for which to create passkey options.</param>
    /// <returns>A JSON string representing the created passkey options.</returns>
    public virtual Task<string> MakePasskeyCreationOptionsAsync(PasskeyUserEntity userEntity);

    /// <summary>
    /// Creates passkey assertion options for the specified <paramref name="user"/>.
    /// </summary>
    /// <param name="user">The user for whom to create passkey assertion options.</param>
    /// <returns>A JSON string representing the created passkey assertion options.</returns>
    public virtual Task<string> MakePasskeyRequestOptionsAsync(TUser? user);

    /// <summary>
    /// Performs passkey attestation for the given <paramref name="credentialJson"/>.
    /// </summary>
    /// <remarks>
    /// The <paramref name="credentialJson"/> should be obtained by JSON-serializing the result of the
    /// <c>navigator.credentials.create()</c> JavaScript API. The argument to <c>navigator.credentials.create()</c>
    /// should be obtained by calling <see cref="MakePasskeyCreationOptionsAsync(PasskeyUserEntity)"/>.
    /// </remarks>
    /// <param name="credentialJson">The credentials obtained by JSON-serializing the result of the <c>navigator.credentials.create()</c> JavaScript function.</param>
    /// <returns>A <see cref="PasskeyAttestationResult"/> representing the result of the operation.</returns>
    public virtual Task<PasskeyAttestationResult> PerformPasskeyAttestationAsync(string credentialJson);

    /// <summary>
    /// Performs passkey assertion for the given <paramref name="credentialJson"/>.
    /// </summary>
    /// <remarks>
    /// The <paramref name="credentialJson"/> should be obtained by JSON-serializing the result of the 
    /// <c>navigator.credentials.get()</c> JavaScript API. The argument to <c>navigator.credentials.get()</c>
    /// should be obtained by calling <see cref="MakePasskeyRequestOptionsAsync(TUser)"/>.
    /// Upon success, the <see cref="PasskeyAssertionResult{TUser}.Passkey"/> should be stored on the
    /// <see cref="PasskeyAssertionResult{TUser}.User"/> using <see cref="UserManager{TUser}.SetPasskeyAsync(TUser, UserPasskeyInfo)"/>.
    /// </remarks>
    /// <param name="credentialJson">The credentials obtained by JSON-serializing the result of the <c>navigator.credentials.get()</c> JavaScript function.</param>
    /// <returns>A <see cref="PasskeyAssertionResult{TUser}"/> representing the result of the operation.</returns>
    public virtual Task<PasskeyAssertionResult<TUser>> PerformPasskeyAssertionAsync(string credentialJson);

    /// <summary>
    /// Performs a passkey assertion and attempts to sign in the user.
    /// </summary>
    /// <remarks>
    /// The <paramref name="credentialJson"/> should be obtained by JSON-serializing the result of the 
    /// <c>navigator.credentials.get()</c> JavaScript API. The argument to <c>navigator.credentials.get()</c>
    /// should be obtained by calling <see cref="MakePasskeyRequestOptionsAsync(TUser)"/>.
    /// </remarks>
    /// <param name="credentialJson">The credentials obtained by JSON-serializing the result of the <c>navigator.credentials.get()</c> JavaScript function.</param>
    /// <returns>A <see cref="SignInResult"/> with the result of the sign in operation.</returns>
    public virtual Task<SignInResult> PasskeySignInAsync(string credentialJson);
}

/// <summary>
/// Represents information about the user associated with a passkey.
/// </summary>
public sealed class PasskeyUserEntity
{
    /// <summary>
    /// Gets the user ID associated with a passkey.
    /// </summary>
    public required string Id { get; init; }

    /// <summary>
    /// Gets the name of the user associated with a passkey.
    /// </summary>
    public required string Name { get; init; }

    /// <summary>
    /// Gets the display name of the user associated with a passkey.
    /// </summary>
    public required string DisplayName { get; init; }
}

/// <summary>
/// Represents a handler for generating passkey creation and request options and performing
/// passkey assertion and attestation.
/// </summary>
public interface IPasskeyHandler<TUser>
    where TUser : class
{
    /// <summary>
    /// Generates passkey creation options for the specified user entity and HTTP context.
    /// </summary>
    /// <param name="userEntity">The passkey user entity for which to generate creation options.</param>
    /// <param name="httpContext">The HTTP context associated with the request.</param>
    /// <returns>A <see cref="PasskeyCreationOptionsResult"/> representing the result.</returns>
    Task<PasskeyCreationOptionsResult> MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext);

    /// <summary>
    /// Generates passkey request options for the specified user and HTTP context.
    /// </summary>
    /// <param name="user">The user for whom to generate request options.</param>
    /// <param name="httpContext">The HTTP context associated with the request.</param>
    /// <returns>A <see cref="PasskeyRequestOptionsResult"/> representing the result.</returns>
    Task<PasskeyRequestOptionsResult> MakeRequestOptionsAsync(TUser? user, HttpContext httpContext);

    /// <summary>
    /// Performs passkey attestation using the provided <see cref="PasskeyAttestationContext"/>.
    /// </summary>
    /// <param name="context">The context containing necessary information for passkey attestation.</param>
    /// <returns>A <see cref="PasskeyAttestationResult"/> representing the result.</returns>
    Task<PasskeyAttestationResult> PerformAttestationAsync(PasskeyAttestationContext context);

    /// <summary>
    /// Performs passkey assertion using the provided <see cref="PasskeyAssertionContext"/>.
    /// </summary>
    /// <param name="context">The context containing necessary information for passkey assertion.</param>
    /// <returns>A <see cref="PasskeyAssertionResult{TUser}"/> representing the result.</returns>
    Task<PasskeyAssertionResult<TUser>> PerformAssertionAsync(PasskeyAssertionContext context);
}

/// <summary>
/// Represents the result of a passkey creation options generation.
/// </summary>
public sealed class PasskeyCreationOptionsResult
{
    /// <summary>
    /// Gets or sets the JSON representation of the creation options.
    /// </summary>
    /// <remarks>
    /// The structure of this JSON is compatible with
    /// <see href="https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialcreationoptionsjson"/>
    /// and should be used with the <c>navigator.credentials.create()</c> JavaScript API.
    /// </remarks>
    public required string CreationOptionsJson { get; init; }

    /// <summary>
    /// Gets or sets the state to be used in the attestation procedure.
    /// </summary>
    /// <remarks>
    /// This can be later retrieved during assertion with <see cref="PasskeyAttestationContext.AttestationState"/>.
    /// </remarks>
    public string? AttestationState { get; init; }
}

/// <summary>
/// Represents the result of a passkey request options generation.
/// </summary>
public sealed class PasskeyRequestOptionsResult
{
    /// <summary>
    /// Gets or sets the JSON representation of the request options.
    /// </summary>
    /// <remarks>
    /// The structure of this JSON is compatible with
    /// <see href="https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptionsjson"/>
    /// and should be used with the <c>navigator.credentials.get()</c> JavaScript API.
    /// </remarks>
    public required string RequestOptionsJson { get; init; }

    /// <summary>
    /// Gets or sets the state to be used in the assertion procedure.
    /// </summary>
    /// <remarks>
    /// This can be later retrieved during assertion with <see cref="PasskeyAssertionContext.AssertionState"/>.
    /// </remarks>
    public string? AssertionState { get; init; }
}

/// <summary>
/// Represents the context for passkey attestation.
/// </summary>
public sealed class PasskeyAttestationContext
{
    /// <summary>
    /// Gets or sets the <see cref="Http.HttpContext"/> for the current request. 
    /// </summary>
    public required HttpContext HttpContext { get; init; }

    /// <summary>
    /// Gets or sets the credentials obtained by JSON-serializing the result of the
    /// <c>navigator.credentials.create()</c> JavaScript function.
    /// </summary>
    public required string CredentialJson { get; init; }

    /// <summary>
    /// Gets or sets the state to be used in the attestation procedure.
    /// </summary>
    /// <remarks>
    /// This is expected to match the <see cref="PasskeyCreationOptionsResult.AttestationState"/>
    /// previously returned from <see cref="IPasskeyHandler{TUser}.MakeCreationOptionsAsync(PasskeyUserEntity, HttpContext)"/>.
    /// </remarks>
    public required string? AttestationState { get; init; }
}

/// <summary>
/// Represents the context for passkey assertion.
/// </summary>
public sealed class PasskeyAssertionContext
{
    /// <summary>
    /// Gets or sets the <see cref="Http.HttpContext"/> for the current request. 
    /// </summary>
    public required HttpContext HttpContext { get; init; }

    /// <summary>
    /// Gets or sets the credentials obtained by JSON-serializing the result of the
    /// <c>navigator.credentials.get()</c> JavaScript function.
    /// </summary>
    public required string CredentialJson { get; init; }

    /// <summary>
    /// Gets or sets the state to be used in the assertion procedure.
    /// </summary>
    /// <remarks>
    /// This is expected to match the <see cref="PasskeyRequestOptionsResult.AssertionState"/>
    /// previously returned from <see cref="IPasskeyHandler{TUser}.MakeRequestOptionsAsync(TUser, HttpContext)"/>.
    /// </remarks>
    public required string? AssertionState { get; init; }
}

/// <summary>
/// Represents the result of a passkey attestation operation.
/// </summary>
public sealed class PasskeyAttestationResult
{
    [MemberNotNullWhen(true, nameof(Passkey))]
    [MemberNotNullWhen(true, nameof(UserEntity))]
    [MemberNotNullWhen(false, nameof(Failure))]
    public bool Succeeded { get; }

    public UserPasskeyInfo? Passkey { get; }
    public PasskeyUserEntity? UserEntity { get; }
    public PasskeyException? Failure { get; }
    public static PasskeyAttestationResult Success(UserPasskeyInfo passkey, PasskeyUserEntity userEntity);
    public static PasskeyAttestationResult Fail(PasskeyException failure);
}

/// <summary>
/// Represents the result of a passkey assertion operation.
/// </summary>
public sealed class PasskeyAssertionResult<TUser>
    where TUser : class
{
    [MemberNotNullWhen(true, nameof(Passkey))]
    [MemberNotNullWhen(true, nameof(User))]
    [MemberNotNullWhen(false, nameof(Failure))]
    public bool Succeeded { get; }

    public UserPasskeyInfo? Passkey { get; }
    public TUser? User { get; }
    public PasskeyException? Failure { get; }
}

/// <summary>
/// A factory class for creating instances of <see cref="PasskeyAssertionResult{TUser}"/>.
/// </summary>
public static class PasskeyAssertionResult
{
    public static PasskeyAssertionResult<TUser> Success<TUser>(UserPasskeyInfo passkey, TUser user)
        where TUser : class;

    public static PasskeyAssertionResult<TUser> Fail<TUser>(PasskeyException failure)
        where TUser : class;
}

/// <summary>
/// The default passkey handler.
/// </summary>
public sealed class PasskeyHandler<TUser> : IPasskeyHandler<TUser>
    where TUser : class
{
    public PasskeyHandler(UserManager<TUser> userManager, IOptions<IdentityPasskeyOptions> options);

    public Task<PasskeyCreationOptionsResult> MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext);
    public Task<PasskeyRequestOptionsResult> MakeRequestOptionsAsync(TUser? user, HttpContext httpContext);
    public Task<PasskeyAttestationResult> PerformAttestationAsync(PasskeyAttestationContext context);
    public Task<PasskeyAssertionResult<TUser>> PerformAssertionAsync(PasskeyAssertionContext context);
}

/// <summary>
/// Represents an error that occurred during passkey attestation or assertion.
/// </summary>
public sealed class PasskeyException : Exception
{
    public PasskeyException(string message);
    public PasskeyException(string message, Exception? innerException);
}

/// <summary>
/// Specifies options for passkey requirements.
/// </summary>
public class IdentityPasskeyOptions
{
    /// <summary>
    /// Gets or sets the time that the browser should wait for the authenticator to provide a passkey.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This option applies to both creating a new passkey and requesting an existing passkey.
    /// This is treated as a hint to the browser, and the browser may choose to ignore it.
    /// </para>
    /// <para>
    /// The default value is 5 minutes.
    /// </para>
    /// <para>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-timeout"/>
    /// and <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-timeout"/>.
    /// </para>
    /// </remarks>
    public TimeSpan AuthenticatorTimeout { get; set; } = TimeSpan.FromMinutes(5);

    /// <summary>
    /// Gets or sets the size of the challenge in bytes sent to the client during attestation and assertion.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This option applies to both creating a new passkey and requesting an existing passkey.
    /// </para>
    /// <para>
    /// The default value is 32 bytes.
    /// </para>
    /// <para>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-challenge"/>
    /// and <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-challenge"/>.
    /// </para>
    /// </remarks>
    public int ChallengeSize { get; set; } = 32;

    /// <summary>
    /// Gets or sets the effective domain of the server.
    /// This should be unique and will be used as the identity for the server.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This option applies to both creating a new passkey and requesting an existing passkey.
    /// </para>
    /// <para>
    /// If left <see langword="null"/>, the server's origin may be used instead.
    /// </para>
    /// <para>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#rp-id"/>.
    /// </para>
    /// </remarks>
    public string? ServerDomain { get; set; }

    /// <summary>
    /// Gets or sets the user verification requirement.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This option applies to both creating a new passkey and requesting an existing passkey.
    /// </para>
    /// <para>
    /// Possible values are "required", "preferred", and "discouraged".
    /// </para>
    /// <para>
    /// If left <see langword="null"/>, the browser defaults to "preferred".
    /// </para>
    /// <para>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#enumdef-userverificationrequirement"/>.
    /// </para>
    /// </remarks>
    public string? UserVerificationRequirement { get; set; }

    /// <summary>
    /// Gets or sets the extent to which the server desires to create a client-side discoverable credential.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This option only applies when creating a new passkey, and is not enforced on the server.
    /// </para>
    /// <para>
    /// Possible values are "discouraged", "preferred", or "required".
    /// </para>
    /// <para>
    /// If left <see langword="null"/>, the browser defaults to "preferred".
    /// </para>
    /// <para>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#enumdef-residentkeyrequirement"/>.
    /// </para>
    /// </remarks>
    public string? ResidentKeyRequirement { get; set; }

    /// <summary>
    /// Gets or sets the attestation conveyance preference.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This option only applies when creating a new passkey, and already-registered passkeys are not affected by it.
    /// To validate the attestation statement of a passkey during passkey creation, provide a value for the
    /// <see cref="VerifyAttestationStatement"/> option.
    /// </para>
    /// <para>
    /// Possible values are "none", "indirect", "direct", and "enterprise".
    /// </para>
    /// <para>
    /// If left <see langword="null"/>, the browser defaults to "none".
    /// </para>
    /// <para>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#enumdef-attestationconveyancepreference"/>.
    /// </para>
    /// </remarks>
    public string? AttestationConveyancePreference { get; set; }

    /// <summary>
    /// Gets or sets the allowed authenticator attachment.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This option only applies when creating a new passkey, and already-registered passkeys are not affected by it.
    /// </para>
    /// <para>
    /// Possible values are "platform" and "cross-platform".
    /// </para>
    /// <para>
    /// If left <see langword="null"/>, any authenticator attachment modality is allowed.
    /// </para>
    /// <para>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#enumdef-authenticatorattachment"/>.
    /// </para>
    /// </remarks>
    public string? AuthenticatorAttachment { get; set; }

    /// <summary>
    /// Gets or sets a function that determines whether the given COSE algorithm identifier
    /// is allowed for passkey operations.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This option only applies when creating a new passkey, and already-registered passkeys are not affected by it.
    /// </para>
    /// <para>
    /// If left <see langword="null"/>, all supported algorithms are allowed.
    /// </para>
    /// <para>
    /// See <see href="https://www.iana.org/assignments/cose/cose.xhtml#algorithms"/>.
    /// </para>
    /// </remarks>
    public Func<int, bool>? IsAllowedAlgorithm { get; set; }

    /// <summary>
    /// Gets or sets a function that validates the origin of the request.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This option applies to both creating a new passkey and requesting an existing passkey.
    /// </para>
    /// <para>
    /// If left <see langword="null"/>, cross-origin requests are disallowed, and the request is only
    /// considered valid if the request's origin header matches the credential's origin.
    /// </para>
    /// </remarks>
    public Func<PasskeyOriginValidationContext, ValueTask<bool>>? ValidateOrigin { get; set; }

    /// <summary>
    /// Gets or sets a function that verifies the attestation statement of a passkey.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This option only applies when creating a new passkey, and already-registered passkeys are not affected by it.
    /// </para>
    /// <para>
    /// If left <see langword="null"/>, this function does not perform any verification and always returns <see langword="true"/>.
    /// </para>
    /// </remarks>
    public Func<PasskeyAttestationStatementVerificationContext, ValueTask<bool>>? VerifyAttestationStatement { get; set; }
}

/// <summary>
/// Contains information used for determining whether a passkey's origin is valid.
/// </summary>
public readonly struct PasskeyOriginValidationContext
{
    /// <summary>
    /// Gets or sets the HTTP context associated with the request.
    /// </summary>
    public required HttpContext HttpContext { get; init; }

    /// <summary>
    /// Gets or sets the fully-qualified origin of the requester.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-origin"/>.
    /// </remarks>
    public required string Origin { get; init; }

    /// <summary>
    /// Gets or sets whether the request came from a cross-origin <c>&lt;iframe&gt;</c>.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-crossorigin"/>.
    /// </remarks>
    public required bool CrossOrigin { get; init; }

    /// <summary>
    /// Gets or sets the fully-qualified top-level origin of the requester.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-toporigin"/>.
    /// </remarks>
    public string? TopOrigin { get; init; }
}

/// <summary>
/// Contains the context for passkey attestation statement verification.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#verification-procedure"/>.
/// </remarks>
public readonly struct PasskeyAttestationStatementVerificationContext
{
    /// <summary>
    /// Gets or sets the <see cref="HttpContext"/> for the current request.
    /// </summary>
    public required HttpContext HttpContext { get; init; }

    /// <summary>
    /// Gets or sets the attestation object as a byte array.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#attestation-object"/>.
    /// </remarks>
    public required ReadOnlyMemory<byte> AttestationObject { get; init; }

    /// <summary>
    /// Gets or sets the hash of the client data as a byte array.
    /// </summary>
    public required ReadOnlyMemory<byte> ClientDataHash { get; init; }
}

Microsoft.Extensions.Identity.Core

Expand to view
namespace Microsoft.AspNetCore.Identity;

/// <summary>
/// Provides an abstraction for storing passkey credentials for a user.
/// </summary>
/// <typeparam name="TUser">The type that represents a user.</typeparam>
public interface IUserPasskeyStore<TUser> : IUserStore<TUser> where TUser : class
{
    /// <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 AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken);

    /// <summary>
    /// Gets the passkey credentials for the specified <paramref name="user"/>.
    /// </summary>
    /// <param name="user">The user whose passkeys should be retrieved.</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, containing a list of the user's passkeys.</returns>
    Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user, CancellationToken cancellationToken);

    /// <summary>
    /// Finds and returns a user, if any, associated with the specified passkey credential identifier.
    /// </summary>
    /// <param name="credentialId">The passkey credential id to search for.</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, containing the user, if any, associated with the specified passkey credential id.
    /// </returns>
    Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken);

    /// <summary>
    /// Finds a passkey for the specified user with the specified credential id.
    /// </summary>
    /// <param name="user">The user whose passkey should be retrieved.</param>
    /// <param name="credentialId">The credential id to search for.</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, containing the user's passkey information.</returns>
    Task<UserPasskeyInfo?> FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);

    /// <summary>
    /// Removes a passkey credential from the specified <paramref name="user"/>.
    /// </summary>
    /// <param name="user">The user to remove the passkey credential from.</param>
    /// <param name="credentialId">The credential id of the passkey to remove.</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 RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
}

/// <summary>
/// Provides information for a user's passkey credential.
/// </summary>
public sealed class UserPasskeyInfo
{
    public UserPasskeyInfo(
        byte[] credentialId,
        byte[] publicKey,
        DateTimeOffset createdAt,
        uint signCount,
        string[]? transports,
        bool isUserVerified,
        bool isBackupEligible,
        bool isBackedUp,
        byte[] attestationObject,
        byte[] clientDataJson);

    /// <summary>
    /// Gets the credential ID for this passkey.
    /// </summary>
    public byte[] CredentialId { get; }

    /// <summary>
    /// Gets the public key associated with this passkey.
    /// </summary>
    public byte[] PublicKey { get; }

    /// <summary>
    /// Gets or sets the friendly name for this passkey.
    /// </summary>
    public string? Name { get; set; }

    /// <summary>
    /// Gets the time this passkey was created.
    /// </summary>
    public DateTimeOffset CreatedAt { get; }

    /// <summary>
    /// Gets or sets the signature counter for this passkey.
    /// </summary>
    public uint SignCount { get; set; }

    /// <summary>
    /// Gets the transports supported by this passkey.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#enumdef-authenticatortransport"/>.
    /// </remarks>
    public string[]? Transports { get; }

    /// <summary>
    /// Gets or sets whether the passkey has a verified user.
    /// </summary>
    public bool IsUserVerified { get; set; }

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

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

    /// <summary>
    /// Gets the attestation object associated with this passkey.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#attestation-object"/>.
    /// </remarks>
    public byte[] AttestationObject { get; }

    /// <summary>
    /// Gets the collected client data JSON associated with this passkey.
    /// </summary>
    /// <remarks>
    /// See <see href="https://www.w3.org/TR/webauthn-3/#dictdef-collectedclientdata"/>.
    /// </remarks>
    public byte[] ClientDataJson { get; }
}
public static class IdentitySchemaVersions
{
+    /// <summary>
+    /// Represents the 3.0 version of the identity schema
+    /// </summary>
+    public static readonly Version Version3 = new Version(3, 0);
}

public class UserManager<TUser> : IDisposable
    where TUser : class
{
+    public IServiceProvider ServiceProvider { get; }
+    public virtual bool SupportsUserPasskey { get; }
+    public virtual Task<IdentityResult> AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey);
+    public virtual Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user);
+    public virtual Task<UserPasskeyInfo?> GetPasskeyAsync(TUser user, byte[] credentialId);
+    public virtual Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId);
+    public virtual Task<IdentityResult> RemovePasskeyAsync(TUser user, byte[] credentialId);
}

Microsoft.Extensions.Identity.Stores

Expand to view
namespace Microsoft.AspNetCore.Identity;

/// <summary>
/// Represents a passkey credential for a user in the identity system.
/// </summary>
/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#credential-record"/>.
/// </remarks>
/// <typeparam name="TKey">The type used for the primary key for this passkey credential.</typeparam>
public class IdentityUserPasskey<TKey>
    where TKey : IEquatable<TKey>
{
    public virtual TKey UserId { get; set; }
    public virtual byte[] CredentialId { get; set; }
    public virtual IdentityPasskeyData Data { get; set; }
}

/// <summary>
/// Represents data associated with a passkey.
/// </summary>
public class IdentityPasskeyData
{
    public virtual byte[] PublicKey { get; set; }
    public virtual string? Name { get; set; }
    public virtual DateTimeOffset CreatedAt { get; set; }
    public virtual uint SignCount { get; set; }
    public virtual string[]? Transports { get; set; }
    public virtual bool IsUserVerified { get; set; }
    public virtual bool IsBackupEligible { get; set; }
    public virtual bool IsBackedUp { get; set; }
    public virtual byte[] AttestationObject { get; set; }
    public virtual byte[] ClientDataJson { get; set; }
}

Microsoft.AspNetCore.Identity.EntityFrameworkCore

Expand to view
namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore;

-public abstract class IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken> :
-   IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken>
+public class IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken> :
+   IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TRole : IdentityRole<TKey>
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>
    where TUserRole : IdentityUserRole<TKey>
    where TUserLogin : IdentityUserLogin<TKey>
    where TRoleClaim : IdentityRoleClaim<TKey>
    where TUserToken : IdentityUserToken<TKey>
{
+    public IdentityDbContext(DbContextOptions options);
+    protected IdentityDbContext();
}

+public abstract class IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TRoleClaim, TUserToken, TUserPasskey> :
+    IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken, TUserPasskey>
+    where TUser : IdentityUser<TKey>
+    where TRole : IdentityRole<TKey>
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>
+    where TUserRole : IdentityUserRole<TKey>
+    where TUserLogin : IdentityUserLogin<TKey>
+    where TRoleClaim : IdentityRoleClaim<TKey>
+    where TUserToken : IdentityUserToken<TKey>
+    where TUserPasskey : IdentityUserPasskey<TKey>
+{
    // Members from IdentityDbContext`8 moved here
+}

-public abstract class IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken> :
-    DbContext
+public class IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken> :
+    IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>
    where TUserLogin : IdentityUserLogin<TKey>
    where TUserToken : IdentityUserToken<TKey>
{
+    public IdentityUserContext(DbContextOptions options);
+    protected IdentityUserContext();
}

+/// <summary>
+/// Base class for the Entity Framework database context used for identity.
+/// </summary>
+/// <typeparam name="TUser">The type of user objects.</typeparam>
+/// <typeparam name="TKey">The type of the primary key for users and roles.</typeparam>
+/// <typeparam name="TUserClaim">The type of the user claim object.</typeparam>
+/// <typeparam name="TUserLogin">The type of the user login object.</typeparam>
+/// <typeparam name="TUserToken">The type of the user token object.</typeparam>
+/// <typeparam name="TUserPasskey">The type of the user passkey object.</typeparam>
+public abstract class IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken, TUserPasskey> : DbContext
+    where TUser : IdentityUser<TKey>
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>
+    where TUserLogin : IdentityUserLogin<TKey>
+    where TUserToken : IdentityUserToken<TKey>
+    where TUserPasskey : IdentityUserPasskey<TKey>
+{
+    /// <summary>
+    /// Gets or sets the <see cref="DbSet{TEntity}"/> of User passkeys.
+    /// </summary>
+    public virtual DbSet<TUserPasskey> UserPasskeys { get; set; }
+}

public class UserOnlyStore<TUser, TContext, TKey, TUserClaim, TUserLogin, TUserToken> :
-    UserStoreBase<TUser, TKey, TUserClaim, TUserLogin, TUserToken>
-    IUserLoginStore<TUser>,
-    IUserClaimStore<TUser>,
-    IUserPasswordStore<TUser>,
-    IUserSecurityStampStore<TUser>,
-    IUserEmailStore<TUser>,
-    IUserLockoutStore<TUser>,
-    IUserPhoneNumberStore<TUser>,
-    IQueryableUserStore<TUser>,
-    IUserTwoFactorStore<TUser>,
-    IUserAuthenticationTokenStore<TUser>,
-    IUserAuthenticatorKeyStore<TUser>,
-    IUserTwoFactorRecoveryCodeStore<TUser>,
-    IProtectedUserStore<TUser>
+    UserOnlyStore<TUser, TContext, TKey, TUserClaim, TUserLogin, TUserToken, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TContext : DbContext
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>, new()
    where TUserLogin : IdentityUserLogin<TKey>, new()
    where TUserToken : IdentityUserToken<TKey>, new()
{
+    public UserOnlyStore(TContext context, IdentityErrorDescriber? describer = null);
}

+public class UserOnlyStore<TUser, TContext, TKey, TUserClaim, TUserLogin, TUserToken, TUserPasskey> :
+    UserStoreBase<TUser, TKey, TUserClaim, TUserLogin, TUserToken>,
+    IUserLoginStore<TUser>,
+    IUserClaimStore<TUser>,
+    IUserPasswordStore<TUser>,
+    IUserSecurityStampStore<TUser>,
+    IUserEmailStore<TUser>,
+    IUserLockoutStore<TUser>,
+    IUserPhoneNumberStore<TUser>,
+    IQueryableUserStore<TUser>,
+    IUserTwoFactorStore<TUser>,
+    IUserAuthenticationTokenStore<TUser>,
+    IUserAuthenticatorKeyStore<TUser>,
+    IUserTwoFactorRecoveryCodeStore<TUser>,
+    IProtectedUserStore<TUser>,
+    IUserPasskeyStore<TUser>
+    where TUser : IdentityUser<TKey>
+    where TContext : DbContext
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>, new()
+    where TUserLogin : IdentityUserLogin<TKey>, new()
+    where TUserToken : IdentityUserToken<TKey>, new()
+    where TUserPasskey : IdentityUserPasskey<TKey>, new()
+{
    // Members from UserOnlyStore`6 moved here

+    protected DbSet<TUserPasskey> UserPasskeys { get; }

+    /// <summary>
+    /// Called to create a new instance of a <see cref="IdentityUserPasskey{TKey}"/>.
+    /// </summary>
+    /// <param name="user">The user.</param>
+    /// <param name="passkey">The passkey.</param>
+    /// <returns></returns>
+    protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo passkey);

+    Task AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken);
+    Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user, CancellationToken cancellationToken);
+    Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken);
+    Task<UserPasskeyInfo?> FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
+    Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
+}

public class UserStore<TUser, TRole, TContext, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim> :
-    UserStoreBase<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim>,
-    IProtectedUserStore<TUser>
+    UserStore<TUser, TRole, TContext, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim, IdentityUserPasskey<TKey>>
    where TUser : IdentityUser<TKey>
    where TRole : IdentityRole<TKey>
    where TContext : DbContext
    where TKey : IEquatable<TKey>
    where TUserClaim : IdentityUserClaim<TKey>, new()
    where TUserRole : IdentityUserRole<TKey>, new()
    where TUserLogin : IdentityUserLogin<TKey>, new()
    where TUserToken : IdentityUserToken<TKey>, new()
    where TRoleClaim : IdentityRoleClaim<TKey>, new()
{
+    public UserStore(TContext context, IdentityErrorDescriber? describer = null);
}

+/// <summary>
+/// Represents a new instance of a persistence store for the specified user and role types.
+/// </summary>
+/// <typeparam name="TUser">The type representing a user.</typeparam>
+/// <typeparam name="TRole">The type representing a role.</typeparam>
+/// <typeparam name="TContext">The type of the data context class used to access the store.</typeparam>
+/// <typeparam name="TKey">The type of the primary key for a role.</typeparam>
+/// <typeparam name="TUserClaim">The type representing a claim.</typeparam>
+/// <typeparam name="TUserRole">The type representing a user role.</typeparam>
+/// <typeparam name="TUserLogin">The type representing a user external login.</typeparam>
+/// <typeparam name="TUserToken">The type representing a user token.</typeparam>
+/// <typeparam name="TRoleClaim">The type representing a role claim.</typeparam>
+/// <typeparam name="TUserPasskey">The type representing a user passkey.</typeparam>
+public class UserStore<TUser, TRole, TContext, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim, TUserPasskey> :
+    UserStoreBase<TUser, TRole, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim>,
+    IProtectedUserStore<TUser>,
+    IUserPasskeyStore<TUser>
+    where TUser : IdentityUser<TKey>
+    where TRole : IdentityRole<TKey>
+    where TContext : DbContext
+    where TKey : IEquatable<TKey>
+    where TUserClaim : IdentityUserClaim<TKey>, new()
+    where TUserRole : IdentityUserRole<TKey>, new()
+    where TUserLogin : IdentityUserLogin<TKey>, new()
+    where TUserToken : IdentityUserToken<TKey>, new()
+    where TRoleClaim : IdentityRoleClaim<TKey>, new()
+    where TUserPasskey : IdentityUserPasskey<TKey>, new()
+{
    // Members from UserStore`9 moved here

+    protected DbSet<TUserPasskey> UserPasskeys { get; }

+    /// <summary>
+    /// Called to create a new instance of a <see cref="IdentityUserPasskey{TKey}"/>.
+    /// </summary>
+    /// <param name="user">The user.</param>
+    /// <param name="passkey">The passkey.</param>
+    /// <returns></returns>
+    protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo passkey);

+    Task AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken);
+    Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user, CancellationToken cancellationToken);
+    Task<TUser?> FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken);
+    Task<UserPasskeyInfo?> FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
+    Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken);
+}

Usage examples

Generating passkey creation/request options

Registering/authenticating a user

MackinnonBuck avatar Jul 09 '25 18:07 MackinnonBuck

Other things that I didn't explicitly call out in the last API review:

  • PasskeyOptions was moved to the Microsoft.AspNetCore.Identity package
    • As a result, there can no longer be an IdentityOptions.Passkeys property, because IdentityOptions lives in Microsoft.Extensions.Identity.Core.
    • To configure PasskeyOptions, customers would use builder.Services.Configure<PasskeyOptions>(...)
  • PasskeyOptions are now only used within DefaultPasskeyHandler. Should we make this relationship more obvious? Some choices are:
    • Rename PasskeyOptions to DefaultPasskeyHandlerOptions
    • Rename DefaultPasskeyHandler to PasskeyHandler and PasskeyOptions to PasskeyHandlerOptions

Edit:

  • PasskeyOptions is now IdentityPasskeyOptions
  • DefaultPasskeyHandler is now PasskeyHandler

I've updated the API comment above to reflect these changes.

MackinnonBuck avatar Jul 09 '25 20:07 MackinnonBuck

Completed in https://github.com/dotnet/aspnetcore/pull/62841

MackinnonBuck avatar Jul 29 '25 13:07 MackinnonBuck