-
Notifications
You must be signed in to change notification settings - Fork 10.4k
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
base: main
Are you sure you want to change the base?
Passkey design follow-ups #62530
Conversation
|
||
/// <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 |
There was a problem hiding this comment.
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.
/// <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, | ||
}; |
There was a problem hiding this comment.
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
.
// 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; } | ||
} |
There was a problem hiding this comment.
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.
@@ -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> |
There was a problem hiding this comment.
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.
public async Task<PasskeyCreationOptionsResult> MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext) | ||
{ | ||
ArgumentNullException.ThrowIfNull(userEntity); | ||
ArgumentNullException.ThrowIfNull(httpContext); | ||
|
||
var excludeCredentials = await GetExcludeCredentialsAsync().ConfigureAwait(false); |
There was a problem hiding this comment.
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.
/// <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; } |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
.
|
||
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; |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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:
- Make it the responsibility of the
IPasskeyHandler
implementation to compute the per-request options values. - Make the app add entries to
HttpContext.Items
that serve as args to the passkey handler to determine per-request options values.
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.