8000 Passkey design follow-ups by MackinnonBuck · Pull Request #62530 · dotnet/aspnetcore · GitHub
[go: up one dir, main page]

Skip to content

Passkey design follow-ups #62530

New issue

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

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

Already on GitHub? Sign in to your account

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

Conversation

MackinnonBuck
Copy link
Member

Passkey design follow-ups

This PR addresses design follow-ups for passkey support in ASP.NET Core Identity.

Note

This change is a work in progress. More changes may occur until the API proposal gets approved.

@github-actions github-actions bot added the area-identity Includes: Identity and providers label Jul 1, 2025

/// <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
internal sealed class AuthenticatorSelectionCriteria
Copy link
Member Author

Choose a reason for hiding this comment

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

This is internal now becuase the settings here have been replicated in PasskeyOptions. This type is just used for serializing into passkey creation/request JSON options.

Comment on lines +19 to +57
/// <summary>
/// Contains all supported public key credential parameters.
/// </summary>
/// <remarks>
/// This list is sorted in the order of preference, with the most preferred algorithm first.
/// </remarks>
internal static IReadOnlyList<PublicKeyCredentialParameters> AllSupportedParameters { get; } =
// Keep this list in sync with IsSupportedAlgorithm.
[
new() { Type = "public-key", Alg = COSEAlgorithmIdentifier.ES256 },
new() { Type = "public-key", Alg = COSEAlgorithmIdentifier.PS256 },
new() { Type = "public-key", Alg = COSEAlgorithmIdentifier.ES384 },
new() { Type = "public-key", Alg = COSEAlgorithmIdentifier.PS384 },
new() { Type = "public-key", Alg = COSEAlgorithmIdentifier.PS512 },
new() { Type = "public-key", Alg = COSEAlgorithmIdentifier.RS256 },
new() { Type = "public-key", Alg = COSEAlgorithmIdentifier.ES512 },
new() { Type = "public-key", Alg = COSEAlgorithmIdentifier.RS384 },
new() { Type = "public-key", Alg = COSEAlgorithmIdentifier.RS512 },
];

/// <summary>
/// Gets whether the specified COSE algorithm identifier is supported.
/// </summary>
/// <param name="alg">The algorithm identifier.</param>
internal static bool IsSupportedAlgorithm(COSEAlgorithmIdentifier alg)
// Keep this in sync with AllSupportedParameters.
=> alg switch
{
COSEAlgorithmIdentifier.ES256 or
COSEAlgorithmIdentifier.PS256 or
COSEAlgorithmIdentifier.ES384 or
COSEAlgorithmIdentifier.PS384 or
COSEAlgorithmIdentifier.PS512 or
COSEAlgorithmIdentifier.RS256 or
COSEAlgorithmIdentifier.ES512 or
COSEAlgorithmIdentifier.RS384 or
COSEAlgorithmIdentifier.RS512 => true,
_ => false,
};
Copy link
Member Author

Choose a reason for hiding this comment

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

Moved these to this class from PublicKeyCredentialParameters because they represent which algorithms we currently implement, and that's controlled entirely by CredentialPublicKey.

Comment on lines +6 to +13
// Represents the state to persist between creating the passkey request options
// and performing passkey assertion.
internal sealed class PasskeyAssertionState
{
public required ReadOnlyMemory<byte> Challenge { get; init; }

public string? UserId { get; init; }
}
Copy link
Member Author

Choose a reason for hiding this comment

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

Prior to this PR, we were persisting the entire passkey creation/request options JSON strings in the auth cookie and using them during the attestation and assertion procedures. With these changes, we only store the state we absolutely need to, and we rely on global configuration (in PasskeyOptions) for everything else. This new type defines which state gets persisted during the assertion procedure (options creation + credential validation), and PasskeyAttestationState is similar but for attestation.

10000
@@ -13,23 +14,154 @@ namespace Microsoft.AspNetCore.Identity;
/// <summary>
/// The default passkey handler.
/// </summary>
public partial class DefaultPasskeyHandler<TUser> : IPasskeyHandler<TUser>
public sealed class DefaultPasskeyHandler<TUser> : IPasskeyHandler<TUser>
Copy link
Member Author

Choose a reason for hiding this comment

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

This class was unsealed before because it had virtual methods that could be overridden for handling origin validation and attestation statement verification. However, that logic is now defined in callbacks in PasskeyOptions, and it no longer makes sense to extend this type. Therefore, I'm marking this class as sealed now.

Comment on lines +38 to +43
public async Task<PasskeyCreationOptionsResult> MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext)
{
ArgumentNullException.ThrowIfNull(userEntity);
ArgumentNullException.ThrowIfNull(httpContext);

var excludeCredentials = await GetExcludeCredentialsAsync().ConfigureAwait(false);
Copy link
Member Author
@MackinnonBuck MackinnonBuck Jul 1, 2025

Choose a reason for hiding this comment

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

Prior to this change, the SignInManager handled the both the creation and storage of passkey creation/request options, and the IPasskeyHandler handled the validation of a credential given:

  • The full credential JSON
  • The full original options sent to the browser

The IPasskeyHandler expected that the caller (e.g., SignInManager) would always be able to provide a fully-constructed options object. However, with the updated design, we've made the deliberate decision to keep most passkey-related configuration global and only store truly per-user state in the auth cookie.

If we wanted to avoid changing the IPasskeyHandler interface, we'd need to have the SignInManager reconstruct the options JSON before passing it into the IPasskeyHandler. This is a bit silly, because we'd be deserializing the data in the auth cookie, re-serializing it back into a larger JSON object, then deserializing it again in the passkey handler.

One alternative is to modify the PasskeyAttestationContext and PasskeyAssertionContext types to directly store the state that we expect to be configured on a per-user basis. For example, the challenge and user information could be exposed as properties on PasskeyAttestationContext and PasskeyAssertionContext, and everything else would be considered global configuration to be read from PasskeyOptions.

However, this decision tightly couples SignInManager, PasskeyOptions, and DefaultPasskeyHandler and results in implementation details of DefaultPasskeyHandler leaking into the IPasskeyHandler interface and related types. For example, if I wanted to provide my own IPasskeyHandler implementation, I would inherit all the same limitations of DefaultPasskeyHandler, and I wouldn't be able to store any per-user state in the auth cookie that SignInManager hadn't accounted for.

So, this PR makes generating the options the responsibility of the IPasskeyHandler. That way, the class that serializes the options is the same class that deserializes them, and there's far less coupling. The SignInManager's responsibility is to find a way to store the state returned alongside the IPasskeyHandler's options, and retrieve it before passing it back into the IPasskeyHandler for the attestation/assertion procedures.

Also, this PR embraces the fact that PasskeyOptions is inherently coupled to the DefaultPasskeyHandler by moving it to the Microsoft.AspNetCore.Identity package and removing the Passkey property from IdentityOptions. This also allows us to put callbacks on PasskeyOptions that accept an HttpContext argument, which isn't possible without a dependency on ASP.NET Core.

Comment on lines +76 to +84
/// <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; }
Copy link
Member Author

Choose a reason for hiding this comment

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

One alternative to this API would be to allow the developer to specify a list of supported algorithms. However, we don't support all the algorithms anyway, so we'd have to figure out what to do if the developer specifies an algorithm that isn't yet implemented. Also, the Func approach allows the developer to specify an "exclude list" if they whish, and if we support more algorithms in the future, the app will automatically support them as well.

/// <summary>
/// Specifies options for passkey requirements.
/// </summary>
public class PasskeyOptions
Copy link
Member Author

Choose a reason for hiding this comment

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

I wonder if we should consider renaming DefaultPasskeyHandler to PasskeyHandler and PasskeyOptions to PasskeyHandlerOptions. That might make the correlation between the implementation and the options clearer. This is similar to SecurityStampValidator and SecurityStampValidatorOptions which exist today.

// an auth cookie, but we bypass the SignInManager for this sample so that we can
// customize the passkey options on a per-request basis. This cookie is a simple
// way for us to persist passkey attestation and assertion state across requests.
var passkeyStateCookie = new CookieBuilder
Copy link
Member Author
@MackinnonBuck MackinnonBuck Jul 2, 2025

Choose a reason for hiding this comment

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

We had discussed offline that some of these design changes might make our implementation incompatible with the official conformance testing tool. Thankfully, we can still make the tool work by bypassing the SignInManager and manually constructing the DefaultPasskeyHandler using per-request PasskeyOptions.

Comment on lines 79 to +88

var options = await SignInManager.RetrievePasskeyCreationOptionsAsync();
if (options is null)
{
statusMessage = "Error: There are no original passkey options present.";
return;
}

var attestationResult = await SignInManager.PerformPasskeyAttestationAsync(CredentialJson, options);
var attestationResult = await SignInManager.PerformPasskeyAttestationAsync(CredentialJson);
if (!attestationResult.Succeeded)
{
statusMessage = $"Error: Could not validate credential: {attestationResult.Failure.Message}";
return;
}

// Create the user if they don't exist yet.
var userEntity = options.UserEntity;
var userEntity = attestationResult.UserEntity;
Copy link
Member Author

Choose a reason for hiding this comment

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

One nice side effect of these changes is it does simplify the usage a bit (the app doesn't need to manually retrieve the stored options).

/// </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 async Task<string> MakePasskeyCreationOptionsAsync(PasskeyUserEntity userEntity)
Copy link
Member Author

Choose a reason for hiding this comment

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

The SignInManager methods for constructing creation/request options no longer accept an "args" object and just take the user information. This is because user-agnostic configuration happens globally in PasskeyOptions.

If someone wanted to implement a custom IPasskeyHandler that configured certain options on a per-request basis, there are ways they could still do this:

  1. Make it the responsibility of the IPasskeyHandler implementation to compute the per-request options values.
  2. Make the app add entries to HttpContext.Items that serve as args to the passkey handler to determine per-request options values.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-identity Includes: Identity and providers
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant
0