diff --git a/src/Identity/Core/src/DefaultPasskeyHandler.cs b/src/Identity/Core/src/DefaultPasskeyHandler.cs index 3843ea3463ec..609bd59afd8b 100644 --- a/src/Identity/Core/src/DefaultPasskeyHandler.cs +++ b/src/Identity/Core/src/DefaultPasskeyHandler.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using System.Linq; using System.Security.Cryptography; using System.Text; @@ -13,23 +14,154 @@ namespace Microsoft.AspNetCore.Identity; /// /// The default passkey handler. /// -public partial class DefaultPasskeyHandler : IPasskeyHandler +public sealed class DefaultPasskeyHandler : IPasskeyHandler where TUser : class { - private readonly PasskeyOptions _passkeyOptions; + private readonly UserManager _userManager; + private readonly PasskeyOptions _options; /// /// Constructs a new instance. /// + /// The . /// The . - public DefaultPasskeyHandler(IOptions options) + public DefaultPasskeyHandler(UserManager userManager, IOptions options) { - _passkeyOptions = options.Value.Passkey; + ArgumentNullException.ThrowIfNull(userManager); + ArgumentNullException.ThrowIfNull(options); + + _userManager = userManager; + _options = options.Value; + } + + /// + public async Task MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(userEntity); + ArgumentNullException.ThrowIfNull(httpContext); + + var excludeCredentials = await GetExcludeCredentialsAsync().ConfigureAwait(false); + var serverDomain = GetServerDomain(httpContext); + var challenge = RandomNumberGenerator.GetBytes(_options.ChallengeSize); + var pubKeyCredParams = _options.IsAllowedAlgorithm is { } isAllowedAlgorithm + ? [.. CredentialPublicKey.AllSupportedParameters.Where(p => isAllowedAlgorithm((int)p.Alg))] + : CredentialPublicKey.AllSupportedParameters; + var options = new PublicKeyCredentialCreationOptions + { + Rp = new() + { + Name = serverDomain, + Id = serverDomain, + }, + User = new() + { + Id = BufferSource.FromString(userEntity.Id), + Name = userEntity.Name, + DisplayName = userEntity.DisplayName, + }, + Challenge = BufferSource.FromBytes(challenge), + Timeout = (uint)_options.Timeout.TotalMilliseconds, + ExcludeCredentials = excludeCredentials, + PubKeyCredParams = pubKeyCredParams, + AuthenticatorSelection = new() + { + AuthenticatorAttachment = _options.AuthenticatorAttachment, + ResidentKey = _options.ResidentKeyRequirement, + UserVerification = _options.UserVerificationRequirement, + }, + Attestation = _options.AttestationConveyancePreference, + }; + var attestationState = new PasskeyAttestationState + { + Challenge = challenge, + UserEntity = userEntity, + }; + var optionsJson = JsonSerializer.Serialize(options, IdentityJsonSerializerContext.Default.PublicKeyCredentialCreationOptions); + var attestationStateJson = JsonSerializer.Serialize(attestationState, IdentityJsonSerializerContext.Default.PasskeyAttestationState); + var creationOptions = new PasskeyCreationOptionsResult + { + CreationOptionsJson = optionsJson, + AttestationState = attestationStateJson, + }; + + return creationOptions; + + async Task GetExcludeCredentialsAsync() + { + var existingUser = await _userManager.FindByIdAsync(userEntity.Id).ConfigureAwait(false); + if (existingUser is null) + { + return []; + } + + var passkeys = await _userManager.GetPasskeysAsync(existingUser).ConfigureAwait(false); + var excludeCredentials = passkeys + .Select(p => new PublicKeyCredentialDescriptor + { + Type = "public-key", + Id = BufferSource.FromBytes(p.CredentialId), + Transports = p.Transports ?? [], + }); + return [.. excludeCredentials]; + } + } + + /// + public async Task MakeRequestOptionsAsync(TUser? user, HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + var allowCredentials = await GetAllowCredentialsAsync().ConfigureAwait(false); + var serverDomain = _options.ServerDomain ?? httpContext.Request.Host.Host; + var challenge = RandomNumberGenerator.GetBytes(_options.ChallengeSize); + var options = new PublicKeyCredentialRequestOptions + { + Challenge = BufferSource.FromBytes(challenge), + RpId = serverDomain, + Timeout = (uint)_options.Timeout.TotalMilliseconds, + AllowCredentials = allowCredentials, + UserVerification = _options.UserVerificationRequirement, + }; + var userId = user is not null ? await _userManager.GetUserIdAsync(user).ConfigureAwait(false) : null; + var assertionState = new PasskeyAssertionState + { + Challenge = challenge, + UserId = userId, + }; + var optionsJson = JsonSerializer.Serialize(options, IdentityJsonSerializerContext.Default.PublicKeyCredentialRequestOptions); + var assertionStateJson = JsonSerializer.Serialize(assertionState, IdentityJsonSerializerContext.Default.PasskeyAssertionState); + var requestOptions = new PasskeyRequestOptionsResult + { + RequestOptionsJson = optionsJson, + AssertionState = assertionStateJson, + }; + + return requestOptions; + + async Task GetAllowCredentialsAsync() + { + if (user is null) + { + return []; + } + + var passkeys = await _userManager.GetPasskeysAsync(user).ConfigureAwait(false); + var allowCredentials = passkeys + .Select(p => new PublicKeyCredentialDescriptor + { + Type = "public-key", + Id = BufferSource.FromBytes(p.CredentialId), + Transports = p.Transports ?? [], + }); + return [.. allowCredentials]; + } } /// - public async Task PerformAttestationAsync(PasskeyAttestationContext context) + public async Task PerformAttestationAsync(PasskeyAttestationContext context) { + ArgumentNullException.ThrowIfNull(context); + try { return await PerformAttestationCoreAsync(context).ConfigureAwait(false); @@ -50,8 +182,10 @@ public async Task PerformAttestationAsync(PasskeyAttes } /// - public async Task> PerformAssertionAsync(PasskeyAssertionContext context) + public async Task> PerformAssertionAsync(PasskeyAssertionContext context) { + ArgumentNullException.ThrowIfNull(context); + try { return await PerformAssertionCoreAsync(context).ConfigureAwait(false); @@ -71,85 +205,19 @@ public async Task> PerformAssertionAsync(PasskeyAs } } - /// - /// Determines whether the specified origin is valid for passkey operations. - /// - /// Information about the passkey's origin. - /// The HTTP context for the request. - /// true if the origin is valid; otherwise, false. - protected virtual Task IsValidOriginAsync(PasskeyOriginInfo originInfo, HttpContext httpContext) - { - var result = IsValidOrigin(); - return Task.FromResult(result); - - bool IsValidOrigin() - { - if (string.IsNullOrEmpty(originInfo.Origin)) - { - return false; - } - - if (originInfo.CrossOrigin && !_passkeyOptions.AllowCrossOriginIframes) - { - return false; - } - - if (!Uri.TryCreate(originInfo.Origin, UriKind.Absolute, out var originUri)) - { - return false; - } - - if (_passkeyOptions.AllowedOrigins.Count > 0) - { - foreach (var allowedOrigin in _passkeyOptions.AllowedOrigins) - { - // Uri.Equals correctly handles string comparands. - if (originUri.Equals(allowedOrigin)) - { - return true; - } - } - } - - if (_passkeyOptions.AllowCurrentOrigin && httpContext.Request.Headers.Origin is [var origin]) - { - // Uri.Equals correctly handles string comparands. - if (originUri.Equals(origin)) - { - return true; - } - } - - return false; - } - } - - /// - /// Verifies the attestation statement of a passkey. - /// - /// - /// See . - /// - /// The attestation object to verify. See . - /// The hash of the client data used during registration. - /// The HTTP context for the request. - /// A task that represents the asynchronous operation. The task result contains true if the verification is successful; otherwise, false. - protected virtual Task VerifyAttestationStatementAsync(ReadOnlyMemory attestationObject, ReadOnlyMemory clientDataHash, HttpContext httpContext) - => Task.FromResult(true); - /// /// Performs passkey attestation using the provided credential JSON and original options JSON. /// /// The context containing necessary information for passkey attestation. /// A task object representing the asynchronous operation containing the . - protected virtual async Task PerformAttestationCoreAsync(PasskeyAttestationContext context) + private async Task PerformAttestationCoreAsync(PasskeyAttestationContext context) { // See: https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential // NOTE: Quotes from the spec may have been modified. // NOTE: Steps 1-3 are expected to have been performed prior to the execution of this method. PublicKeyCredential credential; - PublicKeyCredentialCreationOptions originalOptions; + PasskeyAttestationState attestationState; try { @@ -163,12 +231,17 @@ protected virtual async Task PerformAttestationCoreAsy try { - originalOptions = JsonSerializer.Deserialize(context.OriginalOptionsJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialCreationOptions) - ?? throw PasskeyException.NullOriginalCreationOptionsJson(); + if (context.AttestationState is not { } attestationStateJson) + { + throw PasskeyException.NullAttestationStateJson(); + } + + attestationState = JsonSerializer.Deserialize(attestationStateJson, IdentityJsonSerializerContext.Default.PasskeyAttestationState) + ?? throw PasskeyException.NullAttestationStateJson(); } catch (JsonException ex) { - throw PasskeyException.InvalidOriginalCreationOptionsJsonFormat(ex); + throw PasskeyException.InvalidAttestationStateJsonFormat(ex); } VerifyCredentialType(credential); @@ -186,7 +259,7 @@ protected virtual async Task PerformAttestationCoreAsy // 9-11. Verify that the value of C.origin matches the Relying Party's origin. await VerifyClientDataAsync( utf8Json: response.ClientDataJSON.AsMemory(), - originalChallenge: originalOptions.Challenge.AsMemory(), + originalChallenge: attestationState.Challenge, expectedType: "webauthn.create", context.HttpContext) .ConfigureAwait(false); @@ -204,14 +277,13 @@ await VerifyClientDataAsync( // 15. If options.mediation is not set to conditional, verify that the UP bit of the flags in authData is set. // 16. If user verification is required for this registration, verify that the User Verified bit of the flags in authData is set. // 17. If the BE bit of the flags in authData is not set, verify that the BS bit is not set. - // 18. If the Relying Party uses the credential’s backup eligibility to inform its user experience flows and/or policies, + // 18. If the Relying Party uses the credential's backup eligibility to inform its user experience flows and/or policies, // evaluate the BE bit of the flags in authData. - // 19. If the Relying Party uses the credential’s backup state to inform its user experience flows and/or policies, evaluate the BS + // 19. If the Relying Party uses the credential's backup state to inform its user experience flows and/or policies, evaluate the BS // bit of the flags in authData. - VerifyAuthenticatorData( - authenticatorData, - originalRpId: originalOptions.Rp.Id, - originalUserVerificationRequirement: originalOptions.AuthenticatorSelection?.UserVerification); + // NOTE: It's up to application code to evaluate BE and BS flags on the returned passkey and determine + // whether any action should be taken based on them. + VerifyAuthenticatorData(authenticatorData, context.HttpContext); if (!authenticatorData.HasAttestedCredentialData) { @@ -220,15 +292,27 @@ await VerifyClientDataAsync( // 20. Verify that the "alg" parameter in the credential public key in authData matches the alg attribute of one of the items in pkOptions.pubKeyCredParams. var attestedCredentialData = authenticatorData.AttestedCredentialData; - if (!originalOptions.PubKeyCredParams.Any(a => attestedCredentialData.CredentialPublicKey.Alg == a.Alg)) + var algorithm = attestedCredentialData.CredentialPublicKey.Alg; + if (!CredentialPublicKey.IsSupportedAlgorithm(algorithm)) { + // The algorithm is not implemented. + throw PasskeyException.UnsupportedCredentialPublicKeyAlgorithm(); + } + if (_options.IsAllowedAlgorithm is { } isAllowedAlgorithm && !isAllowedAlgorithm((int)algorithm)) + { + // The algorithm is disallowed by the application. throw PasskeyException.UnsupportedCredentialPublicKeyAlgorithm(); } // 21-24. Determine the attestation statement format by performing a USASCII case-sensitive match on fmt against the set of supported WebAuthn // Attestation Statement Format Identifier values... // Handles all validation related to the attestation statement (21-24). - var isAttestationStatementValid = await VerifyAttestationStatementAsync(attestationObjectMemory, clientDataHash, context.HttpContext).ConfigureAwait(false); + var isAttestationStatementValid = await _options.VerifyAttestationStatement(new() + { + HttpContext = context.HttpContext, + AttestationObject = attestationObjectMemory, + ClientDataHash = clientDataHash, + }).ConfigureAwait(false); if (!isAttestationStatementValid) { throw PasskeyException.InvalidAttestationStatement(); @@ -244,7 +328,7 @@ await VerifyClientDataAsync( var credentialId = attestedCredentialData.CredentialId.ToArray(); // 26. Verify that the credentialId is not yet registered for any user. - var existingUser = await context.UserManager.FindByPasskeyIdAsync(credentialId).ConfigureAwait(false); + var existingUser = await _userManager.FindByPasskeyIdAsync(credentialId).ConfigureAwait(false); if (existingUser is not null) { throw PasskeyException.CredentialAlreadyRegistered(); @@ -270,7 +354,7 @@ await VerifyClientDataAsync( // 29. If all the above steps are successful, store credentialRecord in the user account that was denoted // and continue the registration ceremony as appropriate. - return PasskeyAttestationResult.Success(credentialRecord); + return PasskeyAttestationResult.Success(credentialRecord, attestationState.UserEntity); } /// @@ -278,14 +362,14 @@ await VerifyClientDataAsync( /// /// The context containing necessary information for passkey assertion. /// A task object representing the asynchronous operation containing the . - protected virtual async Task> PerformAssertionCoreAsync(PasskeyAssertionContext context) + private async Task> PerformAssertionCoreAsync(PasskeyAssertionContext context) { // See https://www.w3.org/TR/webauthn-3/#sctn-verifying-assertion // NOTE: Quotes from the spec may have been modified. // NOTE: Steps 1-3 are expected to have been performed prior to the execution of this method. PublicKeyCredential credential; - PublicKeyCredentialRequestOptions originalOptions; + PasskeyAssertionState assertionState; try { @@ -299,12 +383,25 @@ protected virtual async Task> PerformAssertionCore try { - originalOptions = JsonSerializer.Deserialize(context.OriginalOptionsJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialRequestOptions) - ?? throw PasskeyException.NullOriginalRequestOptionsJson(); + if (context.AssertionState is not { } assertionStateJson) + { + throw PasskeyException.NullAssertionStateJson(); + } + + assertionState = JsonSerializer.Deserialize(assertionStateJson, IdentityJsonSerializerContext.Default.PasskeyAssertionState) + ?? throw PasskeyException.NullAssertionStateJson(); } catch (JsonException ex) { - throw PasskeyException.InvalidOriginalRequestOptionsJsonFormat(ex); + throw PasskeyException.InvalidAssertionStateJsonFormat(ex); + } + + TUser? user = null; + var originalUserId = assertionState.UserId; + if (originalUserId is not null) + { + user = await _userManager.FindByIdAsync(originalUserId).ConfigureAwait(false) + ?? throw PasskeyException.CredentialDoesNotBelongToUser(); } VerifyCredentialType(credential); @@ -317,35 +414,32 @@ protected virtual async Task> PerformAssertionCore // 5. If originalOptions.allowCredentials is not empty, verify that credential.id identifies one of the public key // credentials listed in pkOptions.allowCredentials. - if (originalOptions.AllowCredentials is { Count: > 0 } allowCredentials && - !originalOptions.AllowCredentials.Any(c => c.Id.Equals(credential.Id))) - { - throw PasskeyException.CredentialNotAllowed(); - } + // NOTE: Since we always include the user's full list of credentials in the options, + // we can simply check that the credential ID is present on the user. + // If we change this behavior, we may need to explicitly handle this step. var credentialId = credential.Id.ToArray(); var userHandle = response.UserHandle?.ToString(); UserPasskeyInfo? storedPasskey; // 6. Identify the user being authenticated and let credentialRecord be the credential record for the credential: - if (context.User is { } user) + if (user is not null) { + // The user should only be non-null if the user ID was provided in the properties. + Debug.Assert(originalUserId is not null); + // * If the user was identified before the authentication ceremony was initiated, e.g., via a username or cookie, // verify that the identified user account contains a credential record whose id equals // credential.rawId. Let credentialRecord be that credential record. If response.userHandle is // present, verify that it equals the user handle of the user account. - storedPasskey = await context.UserManager.GetPasskeyAsync(user, credentialId).ConfigureAwait(false); + storedPasskey = await _userManager.GetPasskeyAsync(user, credentialId).ConfigureAwait(false); if (storedPasskey is null) { throw PasskeyException.CredentialDoesNotBelongToUser(); } - if (userHandle is not null) + if (userHandle is not null && !string.Equals(originalUserId, userHandle, StringComparison.Ordinal)) { - var userId = await context.UserManager.GetUserIdAsync(user).ConfigureAwait(false); - if (!string.Equals(userHandle, userId, StringComparison.Ordinal)) - { - throw PasskeyException.UserHandleMismatch(userId, userHandle); - } + throw PasskeyException.UserHandleMismatch(originalUserId, userHandle); } } else @@ -359,19 +453,19 @@ protected virtual async Task> PerformAssertionCore throw PasskeyException.MissingUserHandle(); } - user = await context.UserManager.FindByIdAsync(userHandle).ConfigureAwait(false); + user = await _userManager.FindByIdAsync(userHandle).ConfigureAwait(false); if (user is null) { throw PasskeyException.CredentialDoesNotBelongToUser(); } - storedPasskey = await context.UserManager.GetPasskeyAsync(user, credentialId).ConfigureAwait(false); + storedPasskey = await _userManager.GetPasskeyAsync(user, credentialId).ConfigureAwait(false); if (storedPasskey is null) { throw PasskeyException.CredentialDoesNotBelongToUser(); } } - // 7. Let cData, authData and sig denote the value of response’s clientDataJSON, authenticatorData, and signature respectively. + // 7. Let cData, authData and sig denote the value of response's clientDataJSON, authenticatorData, and signature respectively. var authenticatorData = AuthenticatorData.Parse(response.AuthenticatorData.AsMemory()); // 8. Let JSONtext be the result of running UTF-8 decode on the value of cData. @@ -381,7 +475,7 @@ protected virtual async Task> PerformAssertionCore // 12-14. Verify that the value of C.origin is an origin expected by the Relying Party. await VerifyClientDataAsync( utf8Json: response.ClientDataJSON.AsMemory(), - originalChallenge: originalOptions.Challenge.AsMemory(), + originalChallenge: assertionState.Challenge, expectedType: "webauthn.get", context.HttpContext) .ConfigureAwait(false); @@ -391,10 +485,7 @@ await VerifyClientDataAsync( // 17. If user verification was determined to be required, verify that the UV bit of the flags in authData is set. // Otherwise, ignore the value of the UV flag. // 18. If the BE bit of the flags in authData is not set, verify that the BS bit is not set. - VerifyAuthenticatorData( - authenticatorData, - originalRpId: originalOptions.RpId, - originalUserVerificationRequirement: originalOptions.UserVerification); + VerifyAuthenticatorData(authenticatorData, context.HttpContext); // 19. If the credential backup state is used as part of Relying Party business logic or policy, let currentBe and currentBs // be the values of the BE and BS bits, respectively, of the flags in authData. Compare currentBe and currentBs with @@ -402,7 +493,7 @@ await VerifyClientDataAsync( // 1. If credentialRecord.backupEligible is set, verify that currentBe is set. // 2. If credentialRecord.backupEligible is not set, verify that currentBe is not set. // 3. Apply Relying Party policy, if any. - // NOTE: RP policy applied in VerifyAuthenticatorData() above. + // NOTE: Additional RP policies should be handled by application code. if (storedPasskey.IsBackupEligible && !authenticatorData.IsBackupEligible) { throw PasskeyException.ExpectedBackupEligibleCredential(); @@ -500,11 +591,14 @@ private async Task VerifyClientDataAsync( } // Verify that the value of C.origin is an origin expected by the Relying Party. - // NOTE: The level 3 draft permits having multiple origins and validating the "top origin" when a cross-origin request is made. - // For future-proofing, we pass a PasskeyOriginInfo to the origin validator so that we're able to add more properties to - // it later. - var originInfo = new PasskeyOriginInfo(clientData.Origin, clientData.CrossOrigin == true); - var isOriginValid = await IsValidOriginAsync(originInfo, httpContext).ConfigureAwait(false); + var originInfo = new PasskeyOriginValidationContext + { + HttpContext = httpContext, + Origin = clientData.Origin, + CrossOrigin = clientData.CrossOrigin == true, + TopOrigin = clientData.TopOrigin, + }; + var isOriginValid = await _options.ValidateOrigin(originInfo).ConfigureAwait(false); if (!isOriginValid) { throw PasskeyException.InvalidOrigin(clientData.Origin); @@ -526,10 +620,10 @@ private async Task VerifyClientDataAsync( private void VerifyAuthenticatorData( AuthenticatorData authenticatorData, - string? originalRpId, - string? originalUserVerificationRequirement) + HttpContext httpContext) { // Verify that the rpIdHash in authenticatorData is the SHA-256 hash of the RP ID expected by the Relying Party. + var originalRpId = GetServerDomain(httpContext); var originalRpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(originalRpId ?? string.Empty)); if (!CryptographicOperations.FixedTimeEquals(authenticatorData.RpIdHash.Span, originalRpIdHash.AsSpan())) { @@ -545,6 +639,7 @@ private void VerifyAuthenticatorData( } // If user verification is required for this registration, verify that the User Verified bit of the flags in authData is set. + var originalUserVerificationRequirement = _options.UserVerificationRequirement; if (string.Equals("required", originalUserVerificationRequirement, StringComparison.Ordinal) && !authenticatorData.IsUserVerified) { throw PasskeyException.UserNotVerified(); @@ -555,27 +650,8 @@ private void VerifyAuthenticatorData( { throw PasskeyException.NotBackupEligibleYetBackedUp(); } - - // If the Relying Party uses the credential’s backup eligibility to inform its user experience flows and/or policies, - // evaluate the BE bit of the flags in authData. - if (authenticatorData.IsBackupEligible && _passkeyOptions.BackupEligibleCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Disallowed) - { - throw PasskeyException.BackupEligibilityDisallowedYetBackupEligible(); - } - if (!authenticatorData.IsBackupEligible && _passkeyOptions.BackupEligibleCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Required) - { - throw PasskeyException.BackupEligibilityRequiredYetNotBackupEligible(); - } - - // If the Relying Party uses the credential’s backup state to inform its user experience flows and/or policies, evaluate the BS - // bit of the flags in authData. - if (authenticatorData.IsBackedUp && _passkeyOptions.BackedUpCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Disallowed) - { - throw PasskeyException.BackupDisallowedYetBackedUp(); - } - if (!authenticatorData.IsBackedUp && _passkeyOptions.BackedUpCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Required) - { - throw PasskeyException.BackupRequiredYetNotBackedUp(); - } } + + private string GetServerDomain(HttpContext httpContext) + => _options.ServerDomain ?? httpContext.Request.Host.Host; } diff --git a/src/Identity/Core/src/IPasskeyHandler.cs b/src/Identity/Core/src/IPasskeyHandler.cs index be2a68a48d68..50898659edf0 100644 --- a/src/Identity/Core/src/IPasskeyHandler.cs +++ b/src/Identity/Core/src/IPasskeyHandler.cs @@ -1,25 +1,44 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.AspNetCore.Http; + namespace Microsoft.AspNetCore.Identity; /// -/// Represents a handler for passkey assertion and attestation. +/// Represents a handler for generating passkey creation and request options and performing +/// passkey assertion and attestation. /// public interface IPasskeyHandler where TUser : class { /// - /// Performs passkey attestation using the provided credential JSON and original options JSON. + /// Generates passkey creation options for the specified user entity and HTTP context. + /// + /// The passkey user entity for which to generate creation options. + /// The HTTP context associated with the request. + /// A representing the result. + Task MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext); + + /// + /// Generates passkey request options for the specified user and HTTP context. + /// + /// The user for whom to generate request options. + /// The HTTP context associated with the request. + /// A representing the result. + Task MakeRequestOptionsAsync(TUser? user, HttpContext httpContext); + + /// + /// Performs passkey attestation using the provided . /// /// The context containing necessary information for passkey attestation. - /// A task object representing the asynchronous operation containing the . - Task PerformAttestationAsync(PasskeyAttestationContext context); + /// A representing the result. + Task PerformAttestationAsync(PasskeyAttestationContext context); /// - /// Performs passkey assertion using the provided credential JSON, original options JSON, and optional user. + /// Performs passkey assertion using the provided . /// /// The context containing necessary information for passkey assertion. - /// A task object representing the asynchronous operation containing the . - Task> PerformAssertionAsync(PasskeyAssertionContext context); + /// A representing the result. + Task> PerformAssertionAsync(PasskeyAssertionContext context); } diff --git a/src/Identity/Core/src/IdentityJsonSerializerContext.cs b/src/Identity/Core/src/IdentityJsonSerializerContext.cs index 81ddf44b6acc..34b5843b8f89 100644 --- a/src/Identity/Core/src/IdentityJsonSerializerContext.cs +++ b/src/Identity/Core/src/IdentityJsonSerializerContext.cs @@ -11,6 +11,8 @@ namespace Microsoft.AspNetCore.Identity; [JsonSerializable(typeof(PublicKeyCredentialRequestOptions))] [JsonSerializable(typeof(PublicKeyCredential))] [JsonSerializable(typeof(PublicKeyCredential))] +[JsonSerializable(typeof(PasskeyAttestationState))] +[JsonSerializable(typeof(PasskeyAssertionState))] [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, diff --git a/src/Identity/Core/src/PasskeyAssertionContext.cs b/src/Identity/Core/src/PasskeyAssertionContext.cs index 0c748f5e907c..c80a5c5394d6 100644 --- a/src/Identity/Core/src/PasskeyAssertionContext.cs +++ b/src/Identity/Core/src/PasskeyAssertionContext.cs @@ -8,14 +8,12 @@ namespace Microsoft.AspNetCore.Identity; /// /// Represents the context for passkey assertion. /// -/// The type of user associated with the passkey. -public sealed class PasskeyAssertionContext - where TUser : class +public sealed class PasskeyAssertionContext { /// - /// Gets or sets the user associated with the passkey, if known. + /// Gets or sets the for the current request. /// - public TUser? User { get; init; } + public required HttpContext HttpContext { get; init; } /// /// Gets or sets the credentials obtained by JSON-serializing the result of the @@ -24,17 +22,11 @@ public sealed class PasskeyAssertionContext public required string CredentialJson { get; init; } /// - /// Gets or sets the JSON representation of the original passkey creation options provided to the browser. - /// - public required string OriginalOptionsJson { get; init; } - - /// - /// Gets or sets the to retrieve user information from. + /// Gets or sets the state to be used in the assertion procedure. /// - public required UserManager UserManager { get; init; } - - /// - /// Gets or sets the for the current request. - /// - public required HttpContext HttpContext { get; init; } + /// + /// This is expected to match the + /// previously returned from . + /// + public required string? AssertionState { get; init; } } diff --git a/src/Identity/Core/src/PasskeyAttestationContext.cs b/src/Identity/Core/src/PasskeyAttestationContext.cs index 8ee14b31fa64..90e5687d2232 100644 --- a/src/Identity/Core/src/PasskeyAttestationContext.cs +++ b/src/Identity/Core/src/PasskeyAttestationContext.cs @@ -8,28 +8,25 @@ namespace Microsoft.AspNetCore.Identity; /// /// Represents the context for passkey attestation. /// -/// The type of user associated with the passkey. -public sealed class PasskeyAttestationContext - where TUser : class +public sealed class PasskeyAttestationContext { /// - /// Gets or sets the credentials obtained by JSON-serializing the result of the - /// navigator.credentials.create() JavaScript function. - /// - public required string CredentialJson { get; init; } - - /// - /// Gets or sets the JSON representation of the original passkey creation options provided to the browser. + /// Gets or sets the for the current request. /// - public required string OriginalOptionsJson { get; init; } + public required HttpContext HttpContext { get; init; } /// - /// Gets or sets the to retrieve user information from. + /// Gets or sets the credentials obtained by JSON-serializing the result of the + /// navigator.credentials.create() JavaScript function. /// - public required UserManager UserManager { get; init; } + public required string CredentialJson { get; init; } /// - /// Gets or sets the for the current request. + /// Gets or sets the state to be used in the attestation procedure. /// - public required HttpContext HttpContext { get; init; } + /// + /// This is expected to match the + /// previously returned from . + /// + public required string? AttestationState { get; init; } } diff --git a/src/Identity/Core/src/PasskeyAttestationResult.cs b/src/Identity/Core/src/PasskeyAttestationResult.cs index 3034cb3d5c45..7d0966087512 100644 --- a/src/Identity/Core/src/PasskeyAttestationResult.cs +++ b/src/Identity/Core/src/PasskeyAttestationResult.cs @@ -14,6 +14,7 @@ public sealed class PasskeyAttestationResult /// Gets whether the attestation was successful. /// [MemberNotNullWhen(true, nameof(Passkey))] + [MemberNotNullWhen(true, nameof(UserEntity))] [MemberNotNullWhen(false, nameof(Failure))] public bool Succeeded { get; } @@ -22,15 +23,21 @@ public sealed class PasskeyAttestationResult /// public UserPasskeyInfo? Passkey { get; } + /// + /// Gets the user entity associated with the passkey when successful. + /// + public PasskeyUserEntity? UserEntity { get; } + /// /// Gets the error that occurred during attestation. /// public PasskeyException? Failure { get; } - private PasskeyAttestationResult(UserPasskeyInfo passkey) + private PasskeyAttestationResult(UserPasskeyInfo passkey, PasskeyUserEntity userEntity) { Succeeded = true; Passkey = passkey; + UserEntity = userEntity; } private PasskeyAttestationResult(PasskeyException failure) @@ -43,11 +50,12 @@ private PasskeyAttestationResult(PasskeyException failure) /// Creates a successful result for a passkey attestation operation. /// /// The passkey information associated with the attestation. + /// The user entity associated with the attestation. /// A instance representing a successful attestation. - public static PasskeyAttestationResult Success(UserPasskeyInfo passkey) + public static PasskeyAttestationResult Success(UserPasskeyInfo passkey, PasskeyUserEntity userEntity) { ArgumentNullException.ThrowIfNull(passkey); - return new PasskeyAttestationResult(passkey); + return new PasskeyAttestationResult(passkey, userEntity); } /// diff --git a/src/Identity/Core/src/PasskeyAttestationStatementVerificationContext.cs b/src/Identity/Core/src/PasskeyAttestationStatementVerificationContext.cs new file mode 100644 index 000000000000..b3035dccb6ad --- /dev/null +++ b/src/Identity/Core/src/PasskeyAttestationStatementVerificationContext.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Contains the context for passkey attestation statement verification. +/// +/// +/// See . +/// +public readonly struct PasskeyAttestationStatementVerificationContext +{ + /// + /// Gets or sets the for the current request. + /// + public required HttpContext HttpContext { get; init; } + + /// + /// Gets or sets the attestation object as a byte array. + /// + /// + /// See . + /// + public required ReadOnlyMemory AttestationObject { get; init; } + + /// + /// Gets or sets the hash of the client data as a byte array. + /// + public required ReadOnlyMemory ClientDataHash { get; init; } +} diff --git a/src/Identity/Core/src/PasskeyCreationArgs.cs b/src/Identity/Core/src/PasskeyCreationArgs.cs deleted file mode 100644 index 9db4f97ac269..000000000000 --- a/src/Identity/Core/src/PasskeyCreationArgs.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json; - -namespace Microsoft.AspNetCore.Identity; - -/// -/// Represents arguments for generating . -/// -/// The passkey user entity. -public sealed class PasskeyCreationArgs(PasskeyUserEntity userEntity) -{ - /// - /// Gets the passkey user entity. - /// - /// - /// See . - /// - public PasskeyUserEntity UserEntity { get; } = userEntity; - - /// - /// Gets or sets the authenticator selection criteria. - /// - /// - /// See . - /// - public AuthenticatorSelectionCriteria? AuthenticatorSelection { get; set; } - - /// - /// Gets or sets the attestation conveyance preference. - /// - /// - /// See . - /// The default value is "none". - /// - public string Attestation { get; set; } = "none"; - - /// - /// Gets or sets the client extension inputs. - /// - /// - /// See . - /// - public JsonElement? Extensions { get; set; } -} diff --git a/src/Identity/Core/src/PasskeyCreationOptions.cs b/src/Identity/Core/src/PasskeyCreationOptions.cs deleted file mode 100644 index f784b0afe461..000000000000 --- a/src/Identity/Core/src/PasskeyCreationOptions.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.AspNetCore.Identity; - -/// -/// Represents options for creating a passkey. -/// -/// The user entity associated with the passkey. -/// The JSON representation of the options. -/// -/// See . -/// -public sealed class PasskeyCreationOptions(PasskeyUserEntity userEntity, string optionsJson) -{ - private readonly string _optionsJson = optionsJson; - - /// - /// Gets the user entity associated with the passkey. - /// - /// - /// See . - /// > - public PasskeyUserEntity UserEntity { get; } = userEntity; - - /// - /// Gets the JSON representation of the options. - /// - /// - /// The structure of the JSON string matches the description in the WebAuthn specification. - /// See . - /// - public string AsJson() - => _optionsJson; - - /// - /// Gets the JSON representation of the options. - /// - /// - /// The structure of the JSON string matches the description in the WebAuthn specification. - /// See . - /// - public override string ToString() - => _optionsJson; -} diff --git a/src/Identity/Core/src/PasskeyCreationOptionsResult.cs b/src/Identity/Core/src/PasskeyCreationOptionsResult.cs new file mode 100644 index 000000000000..2c1565815744 --- /dev/null +++ b/src/Identity/Core/src/PasskeyCreationOptionsResult.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents the result of a passkey creation options generation. +/// +public sealed class PasskeyCreationOptionsResult +{ + /// + /// Gets or sets the JSON representation of the creation options. + /// + /// + /// The structure of this JSON is compatible with + /// + /// and should be used with the navigator.credentials.create() JavaScript API. + /// + public required string CreationOptionsJson { get; init; } + + /// + /// Gets or sets the state to be used in the attestation procedure. + /// + /// + /// This can be later retrieved during assertion with . + /// + public string? AttestationState { get; init; } +} diff --git a/src/Identity/Core/src/PasskeyExceptionExtensions.cs b/src/Identity/Core/src/PasskeyExceptionExtensions.cs index 82fc60c92464..3caac0ba81cd 100644 --- a/src/Identity/Core/src/PasskeyExceptionExtensions.cs +++ b/src/Identity/Core/src/PasskeyExceptionExtensions.cs @@ -33,18 +33,6 @@ public static PasskeyException UserNotVerified() public static PasskeyException NotBackupEligibleYetBackedUp() => new("The credential is backed up, but the authenticator data flags did not have the 'BackupEligible' flag."); - public static PasskeyException BackupEligibilityDisallowedYetBackupEligible() - => new("Credential backup eligibility is disallowed, but the credential was eligible for backup."); - - public static PasskeyException BackupEligibilityRequiredYetNotBackupEligible() - => new("Credential backup eligibility is required, but the credential was not eligible for backup."); - - public static PasskeyException BackupDisallowedYetBackedUp() - => new("Credential backup is disallowed, but the credential was backed up."); - - public static PasskeyException BackupRequiredYetNotBackedUp() - => new("Credential backup is required, but the credential was not backed up."); - public static PasskeyException MissingAttestedCredentialData() => new("No attested credential data was provided by the authenticator."); @@ -63,9 +51,6 @@ public static PasskeyException CredentialIdMismatch() public static PasskeyException CredentialAlreadyRegistered() => new("The credential is already registered for a user."); - public static PasskeyException CredentialNotAllowed() - => new("The provided credential ID was not in the list of allowed credentials."); - public static PasskeyException CredentialDoesNotBelongToUser() => new("The provided credential does not belong to the specified user."); @@ -123,24 +108,12 @@ public static PasskeyException NullAttestationCredentialJson() public static PasskeyException InvalidAttestationCredentialJsonFormat(JsonException ex) => new($"The attestation credential JSON had an invalid format: {ex.Message}", ex); - public static PasskeyException NullOriginalCreationOptionsJson() - => new("The original passkey creation options were unexpectedly null."); - - public static PasskeyException InvalidOriginalCreationOptionsJsonFormat(JsonException ex) - => new($"The original passkey creation options had an invalid format: {ex.Message}", ex); - public static PasskeyException NullAssertionCredentialJson() => new("The assertion credential JSON was unexpectedly null."); public static PasskeyException InvalidAssertionCredentialJsonFormat(JsonException ex) => new($"The assertion credential JSON had an invalid format: {ex.Message}", ex); - public static PasskeyException NullOriginalRequestOptionsJson() - => new("The original passkey request options were unexpectedly null."); - - public static PasskeyException InvalidOriginalRequestOptionsJsonFormat(JsonException ex) - => new($"The original passkey request options had an invalid format: {ex.Message}", ex); - public static PasskeyException NullClientDataJson() => new("The client data JSON was unexpectedly null."); @@ -149,5 +122,17 @@ public static PasskeyException InvalidClientDataJsonFormat(JsonException ex) public static PasskeyException InvalidCredentialPublicKey(Exception ex) => new($"The credential public key was invalid.", ex); + + public static PasskeyException NullAttestationStateJson() + => new("the assertion state json was unexpectedly null."); + + public static PasskeyException NullAssertionStateJson() + => new("the assertion state json was unexpectedly null."); + + public static PasskeyException InvalidAttestationStateJsonFormat(JsonException ex) + => new($"The attestation state JSON had an invalid format: {ex.Message}", ex); + + public static PasskeyException InvalidAssertionStateJsonFormat(JsonException ex) + => new($"The assertion state JSON had an invalid format: {ex.Message}", ex); } } diff --git a/src/Identity/Core/src/PasskeyOptions.cs b/src/Identity/Core/src/PasskeyOptions.cs new file mode 100644 index 000000000000..6e123ec3c5fd --- /dev/null +++ b/src/Identity/Core/src/PasskeyOptions.cs @@ -0,0 +1,124 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Specifies options for passkey requirements. +/// +public class PasskeyOptions +{ + /// + /// Gets or sets the time that the server is willing to wait for a passkey operation to complete. + /// + /// + /// The default value is 5 minutes. + /// See + /// and . + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// The size of the challenge in bytes sent to the client during WebAuthn attestation and assertion. + /// + /// + /// The default value is 32 bytes. + /// See + /// and . + /// + public int ChallengeSize { get; set; } = 32; + + /// + /// The effective domain of the server. Should be unique and will be used as the identity for the server. + /// + /// + /// If left , the server's origin may be used instead. + /// See . + /// + public string? ServerDomain { get; set; } + + /// + /// Gets or sets the user verification requirement. + /// + /// + /// See . + /// Possible values are "required", "preferred", and "discouraged". + /// The default value is "preferred". + /// + public string? UserVerificationRequirement { get; set; } + + /// + /// Gets or sets the extent to which the server desires to create a client-side discoverable credential. + /// Supported values are "discouraged", "preferred", or "required". + /// + /// + /// See . + /// + public string? ResidentKeyRequirement { get; set; } + + /// + /// Gets or sets the attestation conveyance preference. + /// + /// + /// See . + /// The default value is "none". + /// + public string? AttestationConveyancePreference { get; set; } + + /// + /// Gets or sets the authenticator attachment. + /// + /// + /// See . + /// + public string? AuthenticatorAttachment { get; set; } + + /// + /// Gets or sets a function that determines whether the given COSE algorithm identifier + /// is allowed for passkey operations. + /// + /// + /// If all supported algorithms are allowed. + /// See . + /// + public Func? IsAllowedAlgorithm { get; set; } + + /// + /// Gets or sets a function that validates the origin of the request. + /// + /// + /// By default, this function disallows cross-origin requests and checks + /// that the request's origin header matches the credential's origin. + /// + public Func> ValidateOrigin { get; set; } = DefaultValidateOrigin; + + /// + /// Gets or sets a function that verifies the attestation statement of a passkey. + /// + /// + /// By default, this function does not perform any verification and always returns . + /// + public Func> VerifyAttestationStatement { get; set; } = DefaultVerifyAttestationStatement; + + private static Task DefaultValidateOrigin(PasskeyOriginValidationContext context) + { + var result = IsValidOrigin(); + return Task.FromResult(result); + + bool IsValidOrigin() + { + if (string.IsNullOrEmpty(context.Origin) || + context.CrossOrigin || + !Uri.TryCreate(context.Origin, UriKind.Absolute, out var originUri)) + { + return false; + } + + // Uri.Equals correctly handles string comparands. + return context.HttpContext.Request.Headers.Origin is [var origin] && originUri.Equals(origin); + } + } + + private static Task DefaultVerifyAttestationStatement(PasskeyAttestationStatementVerificationContext context) + => Task.FromResult(true); +} diff --git a/src/Identity/Core/src/PasskeyOriginInfo.cs b/src/Identity/Core/src/PasskeyOriginInfo.cs deleted file mode 100644 index 30576f1609fc..000000000000 --- a/src/Identity/Core/src/PasskeyOriginInfo.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.AspNetCore.Identity; - -/// -/// Contains information used for determining whether a passkey's origin is valid. -/// -/// The fully-qualified origin of the requester. -/// Whether the request came from a cross-origin <iframe> -public readonly struct PasskeyOriginInfo(string origin, bool crossOrigin) -{ - /// - /// Gets the fully-qualified origin of the requester. - /// - public string Origin { get; } = origin; - - /// - /// Gets whether the request came from a cross-origin <iframe>. - /// - public bool CrossOrigin { get; } = crossOrigin; -} diff --git a/src/Identity/Core/src/PasskeyOriginValidationContext.cs b/src/Identity/Core/src/PasskeyOriginValidationContext.cs new file mode 100644 index 000000000000..14bc735282e8 --- /dev/null +++ b/src/Identity/Core/src/PasskeyOriginValidationContext.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Contains information used for determining whether a passkey's origin is valid. +/// +public readonly struct PasskeyOriginValidationContext +{ + /// + /// Gets or sets the HTTP context associated with the request. + /// + public required HttpContext HttpContext { get; init; } + + /// + /// Gets or sets the fully-qualified origin of the requester. + /// + /// + /// See . + /// + public required string Origin { get; init; } + + /// + /// Gets or sets whether the request came from a cross-origin <iframe>. + /// + /// + /// See . + /// + public required bool CrossOrigin { get; init; } + + /// + /// Gets or sets the fully-qualified top-level origin of the requester. + /// + /// + /// See . + /// + public string? TopOrigin { get; init; } +} diff --git a/src/Identity/Core/src/PasskeyRequestArgs.cs b/src/Identity/Core/src/PasskeyRequestArgs.cs deleted file mode 100644 index 25df25909e49..000000000000 --- a/src/Identity/Core/src/PasskeyRequestArgs.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json; - -namespace Microsoft.AspNetCore.Identity; - -/// -/// Represents arguments for generating . -/// -public sealed class PasskeyRequestArgs - where TUser : class -{ - /// - /// Gets or sets the user verification requirement. - /// - /// - /// See . - /// Possible values are "required", "preferred", and "discouraged". - /// The default value is "preferred". - /// - public string UserVerification { get; set; } = "preferred"; - - /// - /// Gets or sets the user to be authenticated. - /// - /// - /// 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. - /// - public TUser? User { get; set; } - - /// - /// Gets or sets the client extension inputs. - /// - /// - /// See . - /// - public JsonElement? Extensions { get; set; } -} diff --git a/src/Identity/Core/src/PasskeyRequestOptions.cs b/src/Identity/Core/src/PasskeyRequestOptions.cs deleted file mode 100644 index ac034c8711e7..000000000000 --- a/src/Identity/Core/src/PasskeyRequestOptions.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.AspNetCore.Identity; - -/// -/// Represents options for a passkey request. -/// -/// The ID of the user for whom this request is made. -/// The JSON representation of the options. -/// -/// See . -/// -public sealed class PasskeyRequestOptions(string? userId, string optionsJson) -{ - private readonly string _optionsJson = optionsJson; - - /// - /// Gets the ID of the user for whom this request is made. - /// - public string? UserId { get; } = userId; - - /// - /// Gets the JSON representation of the options. - /// - /// - /// The structure of the JSON string matches the description in the WebAuthn specification. - /// See . - /// - public string AsJson() - => _optionsJson; - - /// - /// Gets the JSON representation of the options. - /// - /// - /// The structure of the JSON string matches the description in the WebAuthn specification. - /// See . - /// - public override string ToString() - => _optionsJson; -} diff --git a/src/Identity/Core/src/PasskeyRequestOptionsResult.cs b/src/Identity/Core/src/PasskeyRequestOptionsResult.cs new file mode 100644 index 000000000000..f5e78a2bcb3b --- /dev/null +++ b/src/Identity/Core/src/PasskeyRequestOptionsResult.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents the result of a passkey request options generation. +/// +public sealed class PasskeyRequestOptionsResult +{ + /// + /// Gets or sets the JSON representation of the request options. + /// + /// + /// The structure of this JSON is compatible with + /// + /// and should be used with the navigator.credentials.get() JavaScript API. + /// + public required string RequestOptionsJson { get; init; } + + /// + /// Gets or sets the state to be used in the assertion procedure. + /// + /// + /// This can be later retrieved during assertion with . + /// + public string? AssertionState { get; init; } +} diff --git a/src/Identity/Core/src/PasskeyUserEntity.cs b/src/Identity/Core/src/PasskeyUserEntity.cs index 91e8de5ea09c..8b19c0d38783 100644 --- a/src/Identity/Core/src/PasskeyUserEntity.cs +++ b/src/Identity/Core/src/PasskeyUserEntity.cs @@ -6,23 +6,20 @@ namespace Microsoft.AspNetCore.Identity; /// /// Represents information about the user associated with a passkey. /// -/// The user ID. -/// The name of the user. -/// The display name of the user. When omitted, defaults to the user's name. -public sealed class PasskeyUserEntity(string id, string name, string? displayName) +public sealed class PasskeyUserEntity { /// /// Gets the user ID associated with a passkey. /// - public string Id { get; } = id; + public required string Id { get; init; } /// /// Gets the name of the user associated with a passkey. /// - public string Name { get; } = name; + public required string Name { get; init; } /// /// Gets the display name of the user associated with a passkey. /// - public string DisplayName { get; } = displayName ?? name; + public required string DisplayName { get; init; } } diff --git a/src/Identity/Core/src/AuthenticatorSelectionCriteria.cs b/src/Identity/Core/src/Passkeys/AuthenticatorSelectionCriteria.cs similarity index 93% rename from src/Identity/Core/src/AuthenticatorSelectionCriteria.cs rename to src/Identity/Core/src/Passkeys/AuthenticatorSelectionCriteria.cs index fd834ad3e516..0247bab57954 100644 --- a/src/Identity/Core/src/AuthenticatorSelectionCriteria.cs +++ b/src/Identity/Core/src/Passkeys/AuthenticatorSelectionCriteria.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.Identity; /// /// See . /// -public sealed class AuthenticatorSelectionCriteria +internal sealed class AuthenticatorSelectionCriteria { /// /// Gets or sets the authenticator attachment. @@ -42,5 +42,5 @@ public sealed class AuthenticatorSelectionCriteria /// /// See . /// - public string UserVerification { get; set; } = "preferred"; + public string? UserVerification { get; set; } } diff --git a/src/Identity/Core/src/Passkeys/CollectedClientData.cs b/src/Identity/Core/src/Passkeys/CollectedClientData.cs index 8e2e747283da..0d94864fcd6a 100644 --- a/src/Identity/Core/src/Passkeys/CollectedClientData.cs +++ b/src/Identity/Core/src/Passkeys/CollectedClientData.cs @@ -35,6 +35,11 @@ internal sealed class CollectedClientData /// public bool? CrossOrigin { get; init; } + /// + /// Gets or sets the fully qualified top-level origin of the requester. + /// + public string? TopOrigin { get; init; } + /// /// Gets or sets information about the state of the token binding protocol. /// diff --git a/src/Identity/Core/src/Passkeys/CredentialPublicKey.cs b/src/Identity/Core/src/Passkeys/CredentialPublicKey.cs index 27a322cf6741..8774524c2edb 100644 --- a/src/Identity/Core/src/Passkeys/CredentialPublicKey.cs +++ b/src/Identity/Core/src/Passkeys/CredentialPublicKey.cs @@ -16,6 +16,46 @@ internal sealed class CredentialPublicKey public COSEAlgorithmIdentifier Alg => _alg; + /// + /// Contains all supported public key credential parameters. + /// + /// + /// This list is sorted in the order of preference, with the most preferred algorithm first. + /// + internal static IReadOnlyList 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 }, + ]; + + /// + /// Gets whether the specified COSE algorithm identifier is supported. + /// + /// The algorithm identifier. + 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, + }; + private CredentialPublicKey(ReadOnlyMemory bytes) { var reader = Ctap2CborReader.Create(bytes); diff --git a/src/Identity/Core/src/Passkeys/PasskeyAssertionState.cs b/src/Identity/Core/src/Passkeys/PasskeyAssertionState.cs new file mode 100644 index 000000000000..fd5f2ca01095 --- /dev/null +++ b/src/Identity/Core/src/Passkeys/PasskeyAssertionState.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity; + +// Represents the state to persist between creating the passkey request options +// and performing passkey assertion. +internal sealed class PasskeyAssertionState +{ + public required ReadOnlyMemory Challenge { get; init; } + + public string? UserId { get; init; } +} diff --git a/src/Identity/Core/src/Passkeys/PasskeyAttestationState.cs b/src/Identity/Core/src/Passkeys/PasskeyAttestationState.cs new file mode 100644 index 000000000000..95c91639c71e --- /dev/null +++ b/src/Identity/Core/src/Passkeys/PasskeyAttestationState.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity; + +// Represents the state to persist between creating the passkey creation options +// and performing passkey attestation. +internal sealed class PasskeyAttestationState +{ + public required ReadOnlyMemory Challenge { get; init; } + + public required PasskeyUserEntity UserEntity { get; init; } +} diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs index 2f07198a61db..5d92df9a796f 100644 --- a/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs @@ -56,7 +56,7 @@ internal sealed class PublicKeyCredentialCreationOptions /// /// Gets or sets the attestation conveyance preference for the relying party. /// - public string Attestation { get; init; } = "none"; + public string? Attestation { get; init; } /// /// Gets or sets the attestation statement format preferences of the relying party, ordered from most preferred to least preferred. diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredentialParameters.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialParameters.cs index d6abed1c1d6a..399e81221e03 100644 --- a/src/Identity/Core/src/Passkeys/PublicKeyCredentialParameters.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialParameters.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Text.Json.Serialization; - namespace Microsoft.AspNetCore.Identity; /// @@ -11,33 +9,8 @@ namespace Microsoft.AspNetCore.Identity; /// /// See /// -[method: JsonConstructor] -internal readonly struct PublicKeyCredentialParameters(string type, COSEAlgorithmIdentifier alg) +internal readonly struct PublicKeyCredentialParameters() { - /// - /// Contains all supported public key credential parameters. - /// - /// - /// Keep this list in sync with the supported algorithms in . - /// This list is sorted in the order of preference, with the most preferred algorithm first. - /// - internal static IReadOnlyList AllSupportedParameters { get; } = - [ - new(COSEAlgorithmIdentifier.ES256), - new(COSEAlgorithmIdentifier.PS256), - new(COSEAlgorithmIdentifier.ES384), - new(COSEAlgorithmIdentifier.PS384), - new(COSEAlgorithmIdentifier.PS512), - new(COSEAlgorithmIdentifier.RS256), - new(COSEAlgorithmIdentifier.ES512), - new(COSEAlgorithmIdentifier.RS384), - new(COSEAlgorithmIdentifier.RS512), - ]; - - public PublicKeyCredentialParameters(COSEAlgorithmIdentifier alg) - : this(type: "public-key", alg) - { - } /// /// Gets the type of the credential. @@ -45,7 +18,7 @@ public PublicKeyCredentialParameters(COSEAlgorithmIdentifier alg) /// /// See . /// - public string Type { get; } = type; + public required string Type { get; init; } /// /// Gets or sets the cryptographic signature algorithm identifier. @@ -53,5 +26,5 @@ public PublicKeyCredentialParameters(COSEAlgorithmIdentifier alg) /// /// See . /// - public COSEAlgorithmIdentifier Alg { get; } = alg; + public required COSEAlgorithmIdentifier Alg { get; init; } } diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs index 3978cd795693..cd4a7fdc1b9e 100644 --- a/src/Identity/Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs @@ -36,7 +36,7 @@ internal sealed class PublicKeyCredentialRequestOptions /// /// Gets or sets the user verification requirement for the request. /// - public string UserVerification { get; init; } = "preferred"; + public string? UserVerification { get; init; } /// /// Gets or sets hints that guide the user agent in interacting with the user. diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 9594235ec62f..603bd5e20e68 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1,107 +1,111 @@ #nullable enable -Microsoft.AspNetCore.Identity.AuthenticatorSelectionCriteria -Microsoft.AspNetCore.Identity.AuthenticatorSelectionCriteria.AuthenticatorAttachment.get -> string? -Microsoft.AspNetCore.Identity.AuthenticatorSelectionCriteria.AuthenticatorAttachment.set -> void -Microsoft.AspNetCore.Identity.AuthenticatorSelectionCriteria.AuthenticatorSelectionCriteria() -> void -Microsoft.AspNetCore.Identity.AuthenticatorSelectionCriteria.RequireResidentKey.get -> bool -Microsoft.AspNetCore.Identity.AuthenticatorSelectionCriteria.ResidentKey.get -> string? -Microsoft.AspNetCore.Identity.AuthenticatorSelectionCriteria.ResidentKey.set -> void -Microsoft.AspNetCore.Identity.AuthenticatorSelectionCriteria.UserVerification.get -> string! -Microsoft.AspNetCore.Identity.AuthenticatorSelectionCriteria.UserVerification.set -> void Microsoft.AspNetCore.Identity.DefaultPasskeyHandler -Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.DefaultPasskeyHandler(Microsoft.Extensions.Options.IOptions! options) -> void -Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.PerformAssertionAsync(Microsoft.AspNetCore.Identity.PasskeyAssertionContext! context) -> System.Threading.Tasks.Task!>! -Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.PerformAttestationAsync(Microsoft.AspNetCore.Identity.PasskeyAttestationContext! context) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.DefaultPasskeyHandler(Microsoft.AspNetCore.Identity.UserManager! userManager, Microsoft.Extensions.Options.IOptions! options) -> void +Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.MakeCreationOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.MakeRequestOptionsAsync(TUser? user, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.PerformAssertionAsync(Microsoft.AspNetCore.Identity.PasskeyAssertionContext! context) -> System.Threading.Tasks.Task!>! +Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.PerformAttestationAsync(Microsoft.AspNetCore.Identity.PasskeyAttestationContext! context) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Identity.IPasskeyHandler -Microsoft.AspNetCore.Identity.IPasskeyHandler.PerformAssertionAsync(Microsoft.AspNetCore.Identity.PasskeyAssertionContext! context) -> System.Threading.Tasks.Task!>! -Microsoft.AspNetCore.Identity.IPasskeyHandler.PerformAttestationAsync(Microsoft.AspNetCore.Identity.PasskeyAttestationContext! context) -> System.Threading.Tasks.Task! -Microsoft.AspNetCore.Identity.PasskeyAssertionContext -Microsoft.AspNetCore.Identity.PasskeyAssertionContext.CredentialJson.get -> string! -Microsoft.AspNetCore.Identity.PasskeyAssertionContext.CredentialJson.init -> void -Microsoft.AspNetCore.Identity.PasskeyAssertionContext.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext! -Microsoft.AspNetCore.Identity.PasskeyAssertionContext.HttpContext.init -> void -Microsoft.AspNetCore.Identity.PasskeyAssertionContext.OriginalOptionsJson.get -> string! -Microsoft.AspNetCore.Identity.PasskeyAssertionContext.OriginalOptionsJson.init -> void -Microsoft.AspNetCore.Identity.PasskeyAssertionContext.PasskeyAssertionContext() -> void -Microsoft.AspNetCore.Identity.PasskeyAssertionContext.User.get -> TUser? -Microsoft.AspNetCore.Identity.PasskeyAssertionContext.User.init -> void -Microsoft.AspNetCore.Identity.PasskeyAssertionContext.UserManager.get -> Microsoft.AspNetCore.Identity.UserManager! -Microsoft.AspNetCore.Identity.PasskeyAssertionContext.UserManager.init -> void +Microsoft.AspNetCore.Identity.IPasskeyHandler.MakeCreationOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IPasskeyHandler.MakeRequestOptionsAsync(TUser? user, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IPasskeyHandler.PerformAssertionAsync(Microsoft.AspNetCore.Identity.PasskeyAssertionContext! context) -> System.Threading.Tasks.Task!>! +Microsoft.AspNetCore.Identity.IPasskeyHandler.PerformAttestationAsync(Microsoft.AspNetCore.Identity.PasskeyAttestationContext! context) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.PasskeyAssertionContext +Microsoft.AspNetCore.Identity.PasskeyAssertionContext.AssertionState.get -> string? +Microsoft.AspNetCore.Identity.PasskeyAssertionContext.AssertionState.init -> void +Microsoft.AspNetCore.Identity.PasskeyAssertionContext.CredentialJson.get -> string! +Microsoft.AspNetCore.Identity.PasskeyAssertionContext.CredentialJson.init -> void +Microsoft.AspNetCore.Identity.PasskeyAssertionContext.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext! +Microsoft.AspNetCore.Identity.PasskeyAssertionContext.HttpContext.init -> void +Microsoft.AspNetCore.Identity.PasskeyAssertionContext.PasskeyAssertionContext() -> void Microsoft.AspNetCore.Identity.PasskeyAssertionResult Microsoft.AspNetCore.Identity.PasskeyAssertionResult Microsoft.AspNetCore.Identity.PasskeyAssertionResult.Failure.get -> Microsoft.AspNetCore.Identity.PasskeyException? Microsoft.AspNetCore.Identity.PasskeyAssertionResult.Passkey.get -> Microsoft.AspNetCore.Identity.UserPasskeyInfo? Microsoft.AspNetCore.Identity.PasskeyAssertionResult.Succeeded.get -> bool Microsoft.AspNetCore.Identity.PasskeyAssertionResult.User.get -> TUser? -Microsoft.AspNetCore.Identity.PasskeyAttestationContext -Microsoft.AspNetCore.Identity.PasskeyAttestationContext.CredentialJson.get -> string! -Microsoft.AspNetCore.Identity.PasskeyAttestationContext.CredentialJson.init -> void -Microsoft.AspNetCore.Identity.PasskeyAttestationContext.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext! -Microsoft.AspNetCore.Identity.PasskeyAttestationContext.HttpContext.init -> void -Microsoft.AspNetCore.Identity.PasskeyAttestationContext.OriginalOptionsJson.get -> string! -Microsoft.AspNetCore.Identity.PasskeyAttestationContext.OriginalOptionsJson.init -> void -Microsoft.AspNetCore.Identity.PasskeyAttestationContext.PasskeyAttestationContext() -> void -Microsoft.AspNetCore.Identity.PasskeyAttestationContext.UserManager.get -> Microsoft.AspNetCore.Identity.UserManager! -Microsoft.AspNetCore.Identity.PasskeyAttestationContext.UserManager.init -> void +Microsoft.AspNetCore.Identity.PasskeyAttestationContext +Microsoft.AspNetCore.Identity.PasskeyAttestationContext.AttestationState.get -> string? +Microsoft.AspNetCore.Identity.PasskeyAttestationContext.AttestationState.init -> void +Microsoft.AspNetCore.Identity.PasskeyAttestationContext.CredentialJson.get -> string! +Microsoft.AspNetCore.Identity.PasskeyAttestationContext.CredentialJson.init -> void +Microsoft.AspNetCore.Identity.PasskeyAttestationContext.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext! +Microsoft.AspNetCore.Identity.PasskeyAttestationContext.HttpContext.init -> void +Microsoft.AspNetCore.Identity.PasskeyAttestationContext.PasskeyAttestationContext() -> void Microsoft.AspNetCore.Identity.PasskeyAttestationResult Microsoft.AspNetCore.Identity.PasskeyAttestationResult.Failure.get -> Microsoft.AspNetCore.Identity.PasskeyException? Microsoft.AspNetCore.Identity.PasskeyAttestationResult.Passkey.get -> Microsoft.AspNetCore.Identity.UserPasskeyInfo? Microsoft.AspNetCore.Identity.PasskeyAttestationResult.Succeeded.get -> bool -Microsoft.AspNetCore.Identity.PasskeyCreationArgs -Microsoft.AspNetCore.Identity.PasskeyCreationArgs.Attestation.get -> string! -Microsoft.AspNetCore.Identity.PasskeyCreationArgs.Attestation.set -> void -Microsoft.AspNetCore.Identity.PasskeyCreationArgs.AuthenticatorSelection.get -> Microsoft.AspNetCore.Identity.AuthenticatorSelectionCriteria? -Microsoft.AspNetCore.Identity.PasskeyCreationArgs.AuthenticatorSelection.set -> void -Microsoft.AspNetCore.Identity.PasskeyCreationArgs.Extensions.get -> System.Text.Json.JsonElement? -Microsoft.AspNetCore.Identity.PasskeyCreationArgs.Extensions.set -> void -Microsoft.AspNetCore.Identity.PasskeyCreationArgs.PasskeyCreationArgs(Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity) -> void -Microsoft.AspNetCore.Identity.PasskeyCreationArgs.UserEntity.get -> Microsoft.AspNetCore.Identity.PasskeyUserEntity! -Microsoft.AspNetCore.Identity.PasskeyCreationOptions -Microsoft.AspNetCore.Identity.PasskeyCreationOptions.AsJson() -> string! -Microsoft.AspNetCore.Identity.PasskeyCreationOptions.PasskeyCreationOptions(Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, string! optionsJson) -> void -Microsoft.AspNetCore.Identity.PasskeyCreationOptions.UserEntity.get -> Microsoft.AspNetCore.Identity.PasskeyUserEntity! +Microsoft.AspNetCore.Identity.PasskeyAttestationResult.UserEntity.get -> Microsoft.AspNetCore.Identity.PasskeyUserEntity? +Microsoft.AspNetCore.Identity.PasskeyAttestationStatementVerificationContext +Microsoft.AspNetCore.Identity.PasskeyAttestationStatementVerificationContext.AttestationObject.get -> System.ReadOnlyMemory +Microsoft.AspNetCore.Identity.PasskeyAttestationStatementVerificationContext.AttestationObject.init -> void +Microsoft.AspNetCore.Identity.PasskeyAttestationStatementVerificationContext.ClientDataHash.get -> System.ReadOnlyMemory +Microsoft.AspNetCore.Identity.PasskeyAttestationStatementVerificationContext.ClientDataHash.init -> void +Microsoft.AspNetCore.Identity.PasskeyAttestationStatementVerificationContext.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext! +Microsoft.AspNetCore.Identity.PasskeyAttestationStatementVerificationContext.HttpContext.init -> void +Microsoft.AspNetCore.Identity.PasskeyAttestationStatementVerificationContext.PasskeyAttestationStatementVerificationContext() -> void +Microsoft.AspNetCore.Identity.PasskeyCreationOptionsResult +Microsoft.AspNetCore.Identity.PasskeyCreationOptionsResult.AttestationState.get -> string? +Microsoft.AspNetCore.Identity.PasskeyCreationOptionsResult.AttestationState.init -> void +Microsoft.AspNetCore.Identity.PasskeyCreationOptionsResult.CreationOptionsJson.get -> string! +Microsoft.AspNetCore.Identity.PasskeyCreationOptionsResult.CreationOptionsJson.init -> void +Microsoft.AspNetCore.Identity.PasskeyCreationOptionsResult.PasskeyCreationOptionsResult() -> void Microsoft.AspNetCore.Identity.PasskeyException Microsoft.AspNetCore.Identity.PasskeyException.PasskeyException(string! message) -> void Microsoft.AspNetCore.Identity.PasskeyException.PasskeyException(string! message, System.Exception? innerException) -> void -Microsoft.AspNetCore.Identity.PasskeyOriginInfo -Microsoft.AspNetCore.Identity.PasskeyOriginInfo.CrossOrigin.get -> bool -Microsoft.AspNetCore.Identity.PasskeyOriginInfo.Origin.get -> string! -Microsoft.AspNetCore.Identity.PasskeyOriginInfo.PasskeyOriginInfo() -> void -Microsoft.AspNetCore.Identity.PasskeyOriginInfo.PasskeyOriginInfo(string! origin, bool crossOrigin) -> void -Microsoft.AspNetCore.Identity.PasskeyRequestArgs -Microsoft.AspNetCore.Identity.PasskeyRequestArgs.Extensions.get -> System.Text.Json.JsonElement? -Microsoft.AspNetCore.Identity.PasskeyRequestArgs.Extensions.set -> void -Microsoft.AspNetCore.Identity.PasskeyRequestArgs.PasskeyRequestArgs() -> void -Microsoft.AspNetCore.Identity.PasskeyRequestArgs.User.get -> TUser? -Microsoft.AspNetCore.Identity.PasskeyRequestArgs.User.set -> void -Microsoft.AspNetCore.Identity.PasskeyRequestArgs.UserVerification.get -> string! -Microsoft.AspNetCore.Identity.PasskeyRequestArgs.UserVerification.set -> void -Microsoft.AspNetCore.Identity.PasskeyRequestOptions -Microsoft.AspNetCore.Identity.PasskeyRequestOptions.AsJson() -> string! -Microsoft.AspNetCore.Identity.PasskeyRequestOptions.PasskeyRequestOptions(string? userId, string! optionsJson) -> void -Microsoft.AspNetCore.Identity.PasskeyRequestOptions.UserId.get -> string? +Microsoft.AspNetCore.Identity.PasskeyOptions +Microsoft.AspNetCore.Identity.PasskeyOptions.AttestationConveyancePreference.get -> string? +Microsoft.AspNetCore.Identity.PasskeyOptions.AttestationConveyancePreference.set -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.AuthenticatorAttachment.get -> string? +Microsoft.AspNetCore.Identity.PasskeyOptions.AuthenticatorAttachment.set -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.ChallengeSize.get -> int +Microsoft.AspNetCore.Identity.PasskeyOptions.ChallengeSize.set -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.IsAllowedAlgorithm.get -> System.Func? +Microsoft.AspNetCore.Identity.PasskeyOptions.IsAllowedAlgorithm.set -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.PasskeyOptions() -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.ResidentKeyRequirement.get -> string? +Microsoft.AspNetCore.Identity.PasskeyOptions.ResidentKeyRequirement.set -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.ServerDomain.get -> string? +Microsoft.AspNetCore.Identity.PasskeyOptions.ServerDomain.set -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.Timeout.get -> System.TimeSpan +Microsoft.AspNetCore.Identity.PasskeyOptions.Timeout.set -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.UserVerificationRequirement.get -> string? +Microsoft.AspNetCore.Identity.PasskeyOptions.UserVerificationRequirement.set -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.ValidateOrigin.get -> System.Func!>! +Microsoft.AspNetCore.Identity.PasskeyOptions.ValidateOrigin.set -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.VerifyAttestationStatement.get -> System.Func!>! +Microsoft.AspNetCore.Identity.PasskeyOptions.VerifyAttestationStatement.set -> void +Microsoft.AspNetCore.Identity.PasskeyOriginValidationContext +Microsoft.AspNetCore.Identity.PasskeyOriginValidationContext.CrossOrigin.get -> bool +Microsoft.AspNetCore.Identity.PasskeyOriginValidationContext.CrossOrigin.init -> void +Microsoft.AspNetCore.Identity.PasskeyOriginValidationContext.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext! +Microsoft.AspNetCore.Identity.PasskeyOriginValidationContext.HttpContext.init -> void +Microsoft.AspNetCore.Identity.PasskeyOriginValidationContext.Origin.get -> string! +Microsoft.AspNetCore.Identity.PasskeyOriginValidationContext.Origin.init -> void +Microsoft.AspNetCore.Identity.PasskeyOriginValidationContext.PasskeyOriginValidationContext() -> void +Microsoft.AspNetCore.Identity.PasskeyOriginValidationContext.TopOrigin.get -> string? +Microsoft.AspNetCore.Identity.PasskeyOriginValidationContext.TopOrigin.init -> void +Microsoft.AspNetCore.Identity.PasskeyRequestOptionsResult +Microsoft.AspNetCore.Identity.PasskeyRequestOptionsResult.AssertionState.get -> string? +Microsoft.AspNetCore.Identity.PasskeyRequestOptionsResult.AssertionState.init -> void +Microsoft.AspNetCore.Identity.PasskeyRequestOptionsResult.PasskeyRequestOptionsResult() -> void +Microsoft.AspNetCore.Identity.PasskeyRequestOptionsResult.RequestOptionsJson.get -> string! +Microsoft.AspNetCore.Identity.PasskeyRequestOptionsResult.RequestOptionsJson.init -> void Microsoft.AspNetCore.Identity.PasskeyUserEntity Microsoft.AspNetCore.Identity.PasskeyUserEntity.DisplayName.get -> string! +Microsoft.AspNetCore.Identity.PasskeyUserEntity.DisplayName.init -> void Microsoft.AspNetCore.Identity.PasskeyUserEntity.Id.get -> string! +Microsoft.AspNetCore.Identity.PasskeyUserEntity.Id.init -> void Microsoft.AspNetCore.Identity.PasskeyUserEntity.Name.get -> string! -Microsoft.AspNetCore.Identity.PasskeyUserEntity.PasskeyUserEntity(string! id, string! name, string? displayName) -> void -Microsoft.AspNetCore.Identity.SignInManager.SignInManager(Microsoft.AspNetCore.Identity.UserManager! userManager, Microsoft.AspNetCore.Http.IHttpContextAccessor! contextAccessor, Microsoft.AspNetCore.Identity.IUserClaimsPrincipalFactory! claimsFactory, Microsoft.Extensions.Options.IOptions! optionsAccessor, Microsoft.Extensions.Logging.ILogger!>! logger, Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider! schemes, Microsoft.AspNetCore.Identity.IUserConfirmation! confirmation, Microsoft.AspNetCore.Identity.IPasskeyHandler! passkeyHandler) -> void -override Microsoft.AspNetCore.Identity.PasskeyCreationOptions.ToString() -> string! -override Microsoft.AspNetCore.Identity.PasskeyRequestOptions.ToString() -> string! +Microsoft.AspNetCore.Identity.PasskeyUserEntity.Name.init -> void +Microsoft.AspNetCore.Identity.PasskeyUserEntity.PasskeyUserEntity() -> void static Microsoft.AspNetCore.Identity.PasskeyAssertionResult.Fail(Microsoft.AspNetCore.Identity.PasskeyException! failure) -> Microsoft.AspNetCore.Identity.PasskeyAssertionResult! static Microsoft.AspNetCore.Identity.PasskeyAssertionResult.Success(Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey, TUser! user) -> Microsoft.AspNetCore.Identity.PasskeyAssertionResult! static Microsoft.AspNetCore.Identity.PasskeyAttestationResult.Fail(Microsoft.AspNetCore.Identity.PasskeyException! failure) -> Microsoft.AspNetCore.Identity.PasskeyAttestationResult! -static Microsoft.AspNetCore.Identity.PasskeyAttestationResult.Success(Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey) -> Microsoft.AspNetCore.Identity.PasskeyAttestationResult! -virtual Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.IsValidOriginAsync(Microsoft.AspNetCore.Identity.PasskeyOriginInfo originInfo, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.PerformAssertionCoreAsync(Microsoft.AspNetCore.Identity.PasskeyAssertionContext! context) -> System.Threading.Tasks.Task!>! -virtual Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.PerformAttestationCoreAsync(Microsoft.AspNetCore.Identity.PasskeyAttestationContext! context) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.VerifyAttestationStatementAsync(System.ReadOnlyMemory attestationObject, System.ReadOnlyMemory clientDataHash, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.SignInManager.ConfigurePasskeyCreationOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyCreationArgs! creationArgs) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.SignInManager.ConfigurePasskeyRequestOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyRequestArgs! requestArgs) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.SignInManager.GeneratePasskeyCreationOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyCreationArgs! creationArgs) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.SignInManager.GeneratePasskeyRequestOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyRequestArgs! requestArgs) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.SignInManager.PasskeySignInAsync(string! credentialJson, Microsoft.AspNetCore.Identity.PasskeyRequestOptions! options) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.SignInManager.PerformPasskeyAssertionAsync(string! credentialJson, Microsoft.AspNetCore.Identity.PasskeyRequestOptions! options) -> System.Threading.Tasks.Task!>! -virtual Microsoft.AspNetCore.Identity.SignInManager.PerformPasskeyAttestationAsync(string! credentialJson, Microsoft.AspNetCore.Identity.PasskeyCreationOptions! options) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.SignInManager.RetrievePasskeyCreationOptionsAsync() -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.SignInManager.RetrievePasskeyRequestOptionsAsync() -> System.Threading.Tasks.Task! +static Microsoft.AspNetCore.Identity.PasskeyAttestationResult.Success(Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity) -> Microsoft.AspNetCore.Identity.PasskeyAttestationResult! +virtual Microsoft.AspNetCore.Identity.SignInManager.MakePasskeyCreationOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.SignInManager.MakePasskeyRequestOptionsAsync(TUser? user) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.SignInManager.PasskeySignInAsync(string! credentialJson) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.SignInManager.PerformPasskeyAssertionAsync(string! credentialJson) -> System.Threading.Tasks.Task!>! +virtual Microsoft.AspNetCore.Identity.SignInManager.PerformPasskeyAttestationAsync(string! credentialJson) -> System.Threading.Tasks.Task! diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index a41cc20d01f8..5d9e7659b4b5 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -4,11 +4,10 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Security.Claims; -using System.Security.Cryptography; using System.Text; -using System.Text.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -21,9 +20,9 @@ namespace Microsoft.AspNetCore.Identity; public class SignInManager where TUser : class { private const string LoginProviderKey = "LoginProvider"; - private const string PasskeyCreationOptionsKey = "PasskeyCreationOptions"; - private const string PasskeyRequestOptionsKey = "PasskeyRequestOptions"; private const string XsrfKey = "XsrfId"; + private const string PasskeyOperationKey = "PasskeyOperation"; + private const string PasskeyStateKey = "PasskeyState"; private readonly IHttpContextAccessor _contextAccessor; private readonly IAuthenticationSchemeProvider _schemes; @@ -31,8 +30,7 @@ public class SignInManager where TUser : class private readonly IPasskeyHandler? _passkeyHandler; private HttpContext? _context; private TwoFactorAuthenticationInfo? _twoFactorInfo; - private PasskeyCreationOptions? _passkeyCreationOptions; - private PasskeyRequestOptions? _passkeyRequestOptions; + private PasskeyAuthenticationInfo? _passkeyInfo; /// /// Creates a new instance of . @@ -63,32 +61,7 @@ public SignInManager(UserManager userManager, Logger = logger; _schemes = schemes; _confirmation = confirmation; - } - - /// - /// Creates a new instance of . - /// - /// An instance of used to retrieve users from and persist users. - /// The accessor used to access the . - /// The factory to use to create claims principals for a user. - /// The accessor used to access the . - /// The logger used to log messages, warnings and errors. - /// The scheme provider that is used enumerate the authentication schemes. - /// The used check whether a user account is confirmed. - /// The used when performing passkey attestation and assertion. - public SignInManager(UserManager userManager, - IHttpContextAccessor contextAccessor, - IUserClaimsPrincipalFactory claimsFactory, - IOptions optionsAccessor, - ILogger> logger, - IAuthenticationSchemeProvider schemes, - IUserConfirmation confirmation, - IPasskeyHandler passkeyHandler) - : this(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) - { - ArgumentNullException.ThrowIfNull(passkeyHandler); - - _passkeyHandler = passkeyHandler; + _passkeyHandler = userManager.ServiceProvider?.GetService>(); } /// @@ -465,27 +438,68 @@ public virtual async Task CheckPasswordSignInAsync(TUser user, str } /// - /// Performs passkey attestation for the given and . + /// Generates passkey creation options for the specified . /// + /// The user entity for which to create passkey options. + /// A JSON string representing the created passkey options. + public virtual async Task MakePasskeyCreationOptionsAsync(PasskeyUserEntity userEntity) + { + ThrowIfNoPasskeyHandler(); + ArgumentNullException.ThrowIfNull(userEntity); + + var result = await _passkeyHandler.MakeCreationOptionsAsync(userEntity, Context); + await StorePasskeyAuthenticationInfoAsync(PasskeyOperations.Attestation, result.AttestationState); + return result.CreationOptionsJson; + } + + /// + /// Creates passkey assertion options for the specified . + /// + /// The user for whom to create passkey assertion options. + /// A JSON string representing the created passkey assertion options. + public virtual async Task MakePasskeyRequestOptionsAsync(TUser? user) + { + ThrowIfNoPasskeyHandler(); + + var result = await _passkeyHandler.MakeRequestOptionsAsync(user, Context); + await StorePasskeyAuthenticationInfoAsync(PasskeyOperations.Assertion, result.AssertionState); + return result.RequestOptionsJson; + } + + /// + /// Performs passkey attestation for the given . + /// + /// + /// The should be obtained by JSON-serializing the result of the + /// navigator.credentials.create() JavaScript API. The argument to navigator.credentials.create() + /// should be obtained by calling . + /// /// The credentials obtained by JSON-serializing the result of the navigator.credentials.create() JavaScript function. - /// The original passkey creation options provided to the browser. /// /// A task object representing the asynchronous operation containing the . /// - public virtual async Task PerformPasskeyAttestationAsync(string credentialJson, PasskeyCreationOptions options) + public virtual async Task PerformPasskeyAttestationAsync(string credentialJson) { ThrowIfNoPasskeyHandler(); ArgumentException.ThrowIfNullOrEmpty(credentialJson); - ArgumentNullException.ThrowIfNull(options); - var context = new PasskeyAttestationContext + var passkeyInfo = await RetrievePasskeyAuthenticationInfoAsync() + ?? throw new InvalidOperationException( + "No passkey attestation is underway. " + + $"Make sure to call '{nameof(SignInManager<>)}.{nameof(MakePasskeyCreationOptionsAsync)}()' to initiate a passkey attestation."); + if (!string.Equals(PasskeyOperations.Attestation, passkeyInfo.Operation, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Expected passkey operation '{PasskeyOperations.Attestation}', but got '{passkeyInfo.Operation}'. " + + $"This may indicate that you have not previously called '{nameof(SignInManager<>)}.{nameof(MakePasskeyCreationOptionsAsync)}()'."); + } + var context = new PasskeyAttestationContext { CredentialJson = credentialJson, - OriginalOptionsJson = options.AsJson(), - UserManager = UserManager, + AttestationState = passkeyInfo.State, HttpContext = Context, }; - var result = await _passkeyHandler.PerformAttestationAsync(context).ConfigureAwait(false); + var result = await _passkeyHandler.PerformAttestationAsync(context); if (!result.Succeeded) { Logger.LogDebug(EventIds.PasskeyAttestationFailed, "Passkey attestation failed: {message}", result.Failure.Message); @@ -495,26 +509,38 @@ public virtual async Task PerformPasskeyAttestationAsy } /// - /// Performs passkey assertion for the given and . + /// Performs passkey assertion for the given . /// + /// + /// The should be obtained by JSON-serializing the result of the + /// navigator.credentials.get() JavaScript API. The argument to navigator.credentials.get() + /// should be obtained by calling . + /// Upon success, the should be stored on the + /// using . + /// /// The credentials obtained by JSON-serializing the result of the navigator.credentials.get() JavaScript function. - /// The original passkey creation options provided to the browser. /// /// A task object representing the asynchronous operation containing the . /// - public virtual async Task> PerformPasskeyAssertionAsync(string credentialJson, PasskeyRequestOptions options) + public virtual async Task> PerformPasskeyAssertionAsync(string credentialJson) { ThrowIfNoPasskeyHandler(); ArgumentException.ThrowIfNullOrEmpty(credentialJson); - ArgumentNullException.ThrowIfNull(options); - var user = options.UserId is { Length: > 0 } userId ? await UserManager.FindByIdAsync(userId) : null; - var context = new PasskeyAssertionContext + var passkeyInfo = await RetrievePasskeyAuthenticationInfoAsync() + ?? throw new InvalidOperationException( + "No passkey assertion is underway. " + + $"Make sure to call '{nameof(SignInManager<>)}.{nameof(MakePasskeyRequestOptionsAsync)}()' to initiate a passkey assertion."); + if (!string.Equals(PasskeyOperations.Assertion, passkeyInfo.Operation, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Expected passkey operation '{PasskeyOperations.Assertion}', but got '{passkeyInfo.Operation}'. " + + $"This may indicate that you have not previously called '{nameof(SignInManager<>)}.{nameof(MakePasskeyRequestOptionsAsync)}()'."); + } + var context = new PasskeyAssertionContext { - User = user, CredentialJson = credentialJson, - OriginalOptionsJson = options.AsJson(), - UserManager = UserManager, + AssertionState = passkeyInfo.State, HttpContext = Context, }; var result = await _passkeyHandler.PerformAssertionAsync(context); @@ -526,30 +552,24 @@ public virtual async Task> PerformPasskeyAssertion return result; } - [MemberNotNull(nameof(_passkeyHandler))] - private void ThrowIfNoPasskeyHandler() - { - if (_passkeyHandler is null) - { - throw new InvalidOperationException( - $"This operation requires an {nameof(IPasskeyHandler<>)} service to be registered."); - } - } - /// /// Performs a passkey assertion and attempts to sign in the user. /// + /// + /// The should be obtained by JSON-serializing the result of the + /// navigator.credentials.get() JavaScript API. The argument to navigator.credentials.get() + /// should be obtained by calling . + /// /// The credentials obtained by JSON-serializing the result of the navigator.credentials.get() JavaScript function. - /// The original passkey request options provided to the browser. /// /// The task object representing the asynchronous operation containing the /// for the sign-in attempt. /// - public virtual async Task PasskeySignInAsync(string credentialJson, PasskeyRequestOptions options) + public virtual async Task PasskeySignInAsync(string credentialJson) { ArgumentException.ThrowIfNullOrEmpty(credentialJson); - var assertionResult = await PerformPasskeyAssertionAsync(credentialJson, options); + var assertionResult = await PerformPasskeyAssertionAsync(credentialJson); if (!assertionResult.Succeeded) { return SignInResult.Failed; @@ -566,250 +586,52 @@ public virtual async Task PasskeySignInAsync(string credentialJson return await SignInOrTwoFactorAsync(assertionResult.User, isPersistent: false, bypassTwoFactor: true); } - /// - /// Generates a and stores it in the current for later retrieval. - /// - /// Args for configuring the . - /// - /// The returned options should be passed to the navigator.credentials.create() JavaScript function. - /// The credentials returned from that function can then be passed to the . - /// - /// - /// A task object representing the asynchronous operation containing the . - /// - public virtual async Task ConfigurePasskeyCreationOptionsAsync(PasskeyCreationArgs creationArgs) - { - ArgumentNullException.ThrowIfNull(creationArgs); - - var options = await GeneratePasskeyCreationOptionsAsync(creationArgs); - - var props = new AuthenticationProperties(); - props.Items[PasskeyCreationOptionsKey] = options.AsJson(); - var claimsIdentity = new ClaimsIdentity(IdentityConstants.TwoFactorUserIdScheme); - claimsIdentity.AddClaim(new Claim(ClaimTypes.NameIdentifier, options.UserEntity.Id)); - claimsIdentity.AddClaim(new Claim(ClaimTypes.Email, options.UserEntity.Name)); - claimsIdentity.AddClaim(new Claim(ClaimTypes.Name, options.UserEntity.DisplayName)); - var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); - await Context.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, claimsPrincipal, props); - - return options; - } - - /// - /// Generates a to create a new passkey for a user. - /// - /// Args for configuring the . - /// - /// The returned options should be passed to the navigator.credentials.create() JavaScript function. - /// The credentials returned from that function can then be passed to the . - /// - /// - /// A task object representing the asynchronous operation containing the . - /// - public virtual async Task GeneratePasskeyCreationOptionsAsync(PasskeyCreationArgs creationArgs) + [MemberNotNull(nameof(_passkeyHandler))] + private void ThrowIfNoPasskeyHandler() { - ArgumentNullException.ThrowIfNull(creationArgs); - - var excludeCredentials = await GetExcludeCredentialsAsync(); - var serverDomain = Options.Passkey.ServerDomain ?? Context.Request.Host.Host; - var rpEntity = new PublicKeyCredentialRpEntity - { - Name = serverDomain, - Id = serverDomain, - }; - var userEntity = new PublicKeyCredentialUserEntity - { - Id = BufferSource.FromString(creationArgs.UserEntity.Id), - Name = creationArgs.UserEntity.Name, - DisplayName = creationArgs.UserEntity.DisplayName, - }; - var challenge = RandomNumberGenerator.GetBytes(Options.Passkey.ChallengeSize); - var options = new PublicKeyCredentialCreationOptions - { - Rp = rpEntity, - User = userEntity, - Challenge = BufferSource.FromBytes(challenge), - Timeout = (uint)Options.Passkey.Timeout.TotalMilliseconds, - ExcludeCredentials = excludeCredentials, - PubKeyCredParams = PublicKeyCredentialParameters.AllSupportedParameters, - AuthenticatorSelection = creationArgs.AuthenticatorSelection, - Attestation = creationArgs.Attestation, - Extensions = creationArgs.Extensions, - }; - var optionsJson = JsonSerializer.Serialize(options, IdentityJsonSerializerContext.Default.PublicKeyCredentialCreationOptions); - return new(creationArgs.UserEntity, optionsJson); - - async Task GetExcludeCredentialsAsync() + if (_passkeyHandler is null) { - var existingUser = await UserManager.FindByIdAsync(creationArgs.UserEntity.Id); - if (existingUser is null) - { - return []; - } - - var passkeys = await UserManager.GetPasskeysAsync(existingUser); - var excludeCredentials = passkeys - .Select(p => new PublicKeyCredentialDescriptor - { - Type = "public-key", - Id = BufferSource.FromBytes(p.CredentialId), - Transports = p.Transports ?? [], - }); - return [.. excludeCredentials]; + throw new InvalidOperationException( + $"This operation requires an {nameof(IPasskeyHandler<>)} service to be registered."); } } - /// - /// Generates a and stores it in the current for later retrieval. - /// - /// Args for configuring the . - /// - /// The returned options should be passed to the navigator.credentials.get() JavaScript function. - /// The credentials returned from that function can then be passed to the or - /// methods. - /// - /// - /// A task object representing the asynchronous operation containing the . - /// - public virtual async Task ConfigurePasskeyRequestOptionsAsync(PasskeyRequestArgs requestArgs) + private async Task StorePasskeyAuthenticationInfoAsync(string operation, string? state) { - ArgumentNullException.ThrowIfNull(requestArgs); - - var options = await GeneratePasskeyRequestOptionsAsync(requestArgs); - var props = new AuthenticationProperties(); - props.Items[PasskeyRequestOptionsKey] = options.AsJson(); + props.Items[PasskeyOperationKey] = operation; + props.Items[PasskeyStateKey] = state; var claimsIdentity = new ClaimsIdentity(IdentityConstants.TwoFactorUserIdScheme); - - if (options.UserId is { } userId) - { - claimsIdentity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId)); - } - var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); await Context.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, claimsPrincipal, props); - return options; } - /// - /// Generates a to request an existing passkey for a user. - /// - /// Args for configuring the . - /// - /// The returned options should be passed to the navigator.credentials.get() JavaScript function. - /// The credentials returned from that function can then be passed to the method. - /// - /// - /// A task object representing the asynchronous operation containing the . - /// - public virtual async Task GeneratePasskeyRequestOptionsAsync(PasskeyRequestArgs requestArgs) + private async Task RetrievePasskeyAuthenticationInfoAsync() { - ArgumentNullException.ThrowIfNull(requestArgs); - - var allowCredentials = await GetAllowCredentialsAsync(); - var serverDomain = Options.Passkey.ServerDomain ?? Context.Request.Host.Host; - var challenge = RandomNumberGenerator.GetBytes(Options.Passkey.ChallengeSize); - var options = new PublicKeyCredentialRequestOptions - { - Challenge = BufferSource.FromBytes(challenge), - RpId = serverDomain, - Timeout = (uint)Options.Passkey.Timeout.TotalMilliseconds, - AllowCredentials = allowCredentials, - UserVerification = requestArgs.UserVerification, - Extensions = requestArgs.Extensions, - }; - var userId = requestArgs?.User is { } user - ? await UserManager.GetUserIdAsync(user).ConfigureAwait(false) - : null; - var optionsJson = JsonSerializer.Serialize(options, IdentityJsonSerializerContext.Default.PublicKeyCredentialRequestOptions); - return new(userId, optionsJson); + return _passkeyInfo ??= await RetrievePasskeyInfoCoreAsync(); - async Task GetAllowCredentialsAsync() + async Task RetrievePasskeyInfoCoreAsync() { - if (requestArgs?.User is not { } user) + var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); + await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); + + if (result.Properties is not { } properties) { - return []; + return null; } - var passkeys = await UserManager.GetPasskeysAsync(user); - var allowCredentials = passkeys - .Select(p => new PublicKeyCredentialDescriptor - { - Type = "public-key", - Id = BufferSource.FromBytes(p.CredentialId), - Transports = p.Transports ?? [], - }); - return [.. allowCredentials]; - } - } - - /// - /// Retrieves the stored in the current . - /// - /// - /// A task object representing the asynchronous operation containing the . - /// - public virtual async Task RetrievePasskeyCreationOptionsAsync() - { - if (_passkeyCreationOptions is not null) - { - return _passkeyCreationOptions; - } - - var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); - await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); - - if (result?.Principal == null || result.Properties is not { } properties) - { - return null; - } - - if (!properties.Items.TryGetValue(PasskeyCreationOptionsKey, out var optionsJson) || optionsJson is null) - { - return null; - } - - if (result.Principal.FindFirstValue(ClaimTypes.NameIdentifier) is not { Length: > 0 } userId || - result.Principal.FindFirstValue(ClaimTypes.Email) is not { Length: > 0 } userName || - result.Principal.FindFirstValue(ClaimTypes.Name) is not { Length: > 0 } userDisplayName) - { - return null; - } - - var userEntity = new PasskeyUserEntity(userId, userName, userDisplayName); - _passkeyCreationOptions = new(userEntity, optionsJson); - return _passkeyCreationOptions; - } - - /// - /// Retrieves the stored in the current . - /// - /// - /// A task object representing the asynchronous operation containing the . - /// - public virtual async Task RetrievePasskeyRequestOptionsAsync() - { - if (_passkeyRequestOptions is not null) - { - return _passkeyRequestOptions; - } - - var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); - await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); - - if (result?.Principal == null || result.Properties is not { } properties) - { - return null; - } + if (!properties.Items.TryGetValue(PasskeyOperationKey, out var operation) || + !properties.Items.TryGetValue(PasskeyStateKey, out var state)) + { + return null; + } - if (!properties.Items.TryGetValue(PasskeyRequestOptionsKey, out var optionsJson) || optionsJson is null) - { - return null; + return new() + { + Operation = operation, + State = state, + }; } - - var userId = result.Principal.FindFirstValue(ClaimTypes.NameIdentifier); - _passkeyRequestOptions = new(userId, optionsJson); - return _passkeyRequestOptions; } /// @@ -1403,4 +1225,17 @@ internal sealed class TwoFactorAuthenticationInfo public required TUser User { get; init; } public string? LoginProvider { get; init; } } + + internal sealed class PasskeyAuthenticationInfo + { + public required string? Operation { get; init; } + public required string? State { get; init; } + + } + + private static class PasskeyOperations + { + public const string Attestation = "Attestation"; + public const string Assertion = "Assertion"; + } } diff --git a/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs b/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs index 9135b94d309f..f798c06f42c6 100644 --- a/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs @@ -758,7 +758,8 @@ private void ThrowIfPasskeysNotSupported() throw new InvalidOperationException( $"This operation is not permitted because the underlying '{nameof(DbContext)}' does not include '{typeof(TUserPasskey).Name}' in its model. " + $"When using '{nameof(IdentityDbContext)}', make sure that '{nameof(IdentityOptions)}.{nameof(IdentityOptions.Stores)}.{nameof(StoreOptions.SchemaVersion)}' " + - $"is set to '{nameof(IdentitySchemaVersions)}.{nameof(IdentitySchemaVersions.Version3)}' or higher."); + $"is set to '{nameof(IdentitySchemaVersions)}.{nameof(IdentitySchemaVersions.Version3)}' or higher. " + + $"See https://aka.ms/aspnet/passkeys for more information."); } } } diff --git a/src/Identity/EntityFrameworkCore/src/UserStore.cs b/src/Identity/EntityFrameworkCore/src/UserStore.cs index 0165b2fc01e4..4de45e258787 100644 --- a/src/Identity/EntityFrameworkCore/src/UserStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserStore.cs @@ -138,6 +138,8 @@ public class UserStore, new() where TUserPasskey : IdentityUserPasskey, new() { + private bool? _dbContextSupportsPasskeys; + /// /// Creates a new instance of the store. /// @@ -160,7 +162,14 @@ public class UserStore UserRoles { get { return Context.Set(); } } private DbSet UserLogins { get { return Context.Set(); } } private DbSet UserTokens { get { return Context.Set(); } } - private DbSet UserPasskeys { get { return Context.Set(); } } + private DbSet UserPasskeys + { + get + { + ThrowIfPasskeysNotSupported(); + return Context.Set(); + } + } /// /// Gets or sets a flag indicating if changes should be persisted after CreateAsync, UpdateAsync and DeleteAsync are called. @@ -879,4 +888,22 @@ public virtual async Task RemovePasskeyAsync(TUser user, byte[] credentialId, Ca await SaveChanges(cancellationToken).ConfigureAwait(false); } } + + private void ThrowIfPasskeysNotSupported() + { + if (_dbContextSupportsPasskeys == true) + { + return; + } + + _dbContextSupportsPasskeys ??= Context.Model.FindEntityType(typeof(TUserPasskey)) is not null; + if (_dbContextSupportsPasskeys == false) + { + throw new InvalidOperationException( + $"This operation is not permitted because the underlying '{nameof(DbContext)}' does not include '{typeof(TUserPasskey).Name}' in its model. " + + $"When using '{nameof(IdentityDbContext)}', make sure that '{nameof(IdentityOptions)}.{nameof(IdentityOptions.Stores)}.{nameof(StoreOptions.SchemaVersion)}' " + + $"is set to '{nameof(IdentitySchemaVersions)}.{nameof(IdentitySchemaVersions.Version3)}' or higher. " + + $"See https://aka.ms/aspnet/passkeys for more information."); + } + } } diff --git a/src/Identity/Extensions.Core/src/IdentityOptions.cs b/src/Identity/Extensions.Core/src/IdentityOptions.cs index 458d46f16a96..57ab10a6f9c1 100644 --- a/src/Identity/Extensions.Core/src/IdentityOptions.cs +++ b/src/Identity/Extensions.Core/src/IdentityOptions.cs @@ -32,14 +32,6 @@ public class IdentityOptions /// public PasswordOptions Password { get; set; } = new PasswordOptions(); - /// - /// Gets or sets the for the identity system. - /// - /// - /// The for the identity system. - /// - public PasskeyOptions Passkey { get; set; } = new PasskeyOptions(); - /// /// Gets or sets the for the identity system. /// diff --git a/src/Identity/Extensions.Core/src/PasskeyOptions.cs b/src/Identity/Extensions.Core/src/PasskeyOptions.cs deleted file mode 100644 index e274a3c3762a..000000000000 --- a/src/Identity/Extensions.Core/src/PasskeyOptions.cs +++ /dev/null @@ -1,105 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Microsoft.AspNetCore.Identity; - -/// -/// Specifies options for passkey requirements. -/// -public class PasskeyOptions -{ - /// - /// Gets or sets the time that the server is willing to wait for a passkey operation to complete. - /// - /// - /// The default value is 1 minute. - /// See - /// and . - /// - public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(1); - - /// - /// The size of the challenge in bytes sent to the client during WebAuthn attestation and assertion. - /// - /// - /// The default value is 16 bytes. - /// See - /// and . - /// - public int ChallengeSize { get; set; } = 16; - - /// - /// The effective domain of the server. Should be unique and will be used as the identity for the server. - /// - /// - /// If left , the server's origin may be used instead. - /// See . - /// - public string? ServerDomain { get; set; } - - /// - /// 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. - /// - public IList AllowedOrigins { get; set; } = []; - - /// - /// 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. - /// - /// - /// The default value is . - /// - public bool AllowCurrentOrigin { get; set; } = true; - - /// - /// Gets or sets whether credentials from cross-origin iframes should be allowed. - /// - /// - /// The default value is . - /// - public bool AllowCrossOriginIframes { get; set; } - - /// - /// Whether or not to accept a backup eligible credential. - /// - /// - /// The default value is . - /// - public CredentialBackupPolicy BackupEligibleCredentialPolicy { get; set; } = CredentialBackupPolicy.Allowed; - - /// - /// Whether or not to accept a backed up credential. - /// - /// - /// The default value is . - /// - public CredentialBackupPolicy BackedUpCredentialPolicy { get; set; } = CredentialBackupPolicy.Allowed; - - /// - /// Represents the policy for credential backup eligibility and backup status. - /// - public enum CredentialBackupPolicy - { - /// - /// Indicates that the credential backup eligibility or backup status is required. - /// - Required = 0, - - /// - /// Indicates that the credential backup eligibility or backup status is allowed, but not required. - /// - Allowed = 1, - - /// - /// Indicates that the credential backup eligibility or backup status is disallowed. - /// - Disallowed = 2, - } -} diff --git a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt index 52862f56815d..f1fb6dd136f4 100644 --- a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt @@ -1,36 +1,13 @@ #nullable enable *REMOVED*Microsoft.AspNetCore.Identity.UserLoginInfo.UserLoginInfo(string! loginProvider, string! providerKey, string? displayName) -> void -Microsoft.AspNetCore.Identity.IdentityOptions.Passkey.get -> Microsoft.AspNetCore.Identity.PasskeyOptions! -Microsoft.AspNetCore.Identity.IdentityOptions.Passkey.set -> void Microsoft.AspNetCore.Identity.IUserPasskeyStore Microsoft.AspNetCore.Identity.IUserPasskeyStore.FindByPasskeyIdAsync(byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Identity.IUserPasskeyStore.FindPasskeyAsync(TUser! user, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Identity.IUserPasskeyStore.GetPasskeysAsync(TUser! user, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! Microsoft.AspNetCore.Identity.IUserPasskeyStore.RemovePasskeyAsync(TUser! user, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Identity.IUserPasskeyStore.SetPasskeyAsync(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -Microsoft.AspNetCore.Identity.PasskeyOptions -Microsoft.AspNetCore.Identity.PasskeyOptions.AllowCrossOriginIframes.get -> bool -Microsoft.AspNetCore.Identity.PasskeyOptions.AllowCrossOriginIframes.set -> void -Microsoft.AspNetCore.Identity.PasskeyOptions.AllowCurrentOrigin.get -> bool -Microsoft.AspNetCore.Identity.PasskeyOptions.AllowCurrentOrigin.set -> void -Microsoft.AspNetCore.Identity.PasskeyOptions.AllowedOrigins.get -> System.Collections.Generic.IList! -Microsoft.AspNetCore.Identity.PasskeyOptions.AllowedOrigins.set -> void -Microsoft.AspNetCore.Identity.PasskeyOptions.BackedUpCredentialPolicy.get -> Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy -Microsoft.AspNetCore.Identity.PasskeyOptions.BackedUpCredentialPolicy.set -> void -Microsoft.AspNetCore.Identity.PasskeyOptions.BackupEligibleCredentialPolicy.get -> Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy -Microsoft.AspNetCore.Identity.PasskeyOptions.BackupEligibleCredentialPolicy.set -> void -Microsoft.AspNetCore.Identity.PasskeyOptions.ChallengeSize.get -> int -Microsoft.AspNetCore.Identity.PasskeyOptions.ChallengeSize.set -> void -Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy -Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy.Allowed = 1 -> Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy -Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy.Disallowed = 2 -> Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy -Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy.Required = 0 -> Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy -Microsoft.AspNetCore.Identity.PasskeyOptions.PasskeyOptions() -> void -Microsoft.AspNetCore.Identity.PasskeyOptions.ServerDomain.get -> string? -Microsoft.AspNetCore.Identity.PasskeyOptions.ServerDomain.set -> void -Microsoft.AspNetCore.Identity.PasskeyOptions.Timeout.get -> System.TimeSpan -Microsoft.AspNetCore.Identity.PasskeyOptions.Timeout.set -> void Microsoft.AspNetCore.Identity.UserLoginInfo.UserLoginInfo(string! loginProvider, string! providerKey, string? providerDisplayName) -> void +Microsoft.AspNetCore.Identity.UserManager.ServiceProvider.get -> System.IServiceProvider! Microsoft.AspNetCore.Identity.UserPasskeyInfo Microsoft.AspNetCore.Identity.UserPasskeyInfo.AttestationObject.get -> byte[]! Microsoft.AspNetCore.Identity.UserPasskeyInfo.ClientDataJson.get -> byte[]! diff --git a/src/Identity/Extensions.Core/src/UserManager.cs b/src/Identity/Extensions.Core/src/UserManager.cs index 73b9a28c6b94..aefcf3bd9997 100644 --- a/src/Identity/Extensions.Core/src/UserManager.cs +++ b/src/Identity/Extensions.Core/src/UserManager.cs @@ -47,7 +47,6 @@ public class UserManager : IDisposable where TUser : class #if NETSTANDARD2_0 || NETFRAMEWORK private static readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create(); #endif - private readonly IServiceProvider _services; /// /// The cancellation token used to cancel operations. @@ -83,6 +82,7 @@ public UserManager(IUserStore store, KeyNormalizer = keyNormalizer; ErrorDescriber = errors; Logger = logger; + ServiceProvider = services; if (userValidators != null) { @@ -99,7 +99,6 @@ public UserManager(IUserStore store, } } - _services = services; if (services != null) { foreach (var providerName in Options.Tokens.ProviderMap.Keys) @@ -176,6 +175,11 @@ public UserManager(IUserStore store, /// public IdentityOptions Options { get; set; } + /// + /// The used to resolve Identity services. + /// + public IServiceProvider ServiceProvider { get; } + /// /// Gets a flag indicating whether the backing user store supports authentication tokens. /// @@ -555,8 +559,8 @@ public virtual Task DeleteAsync(TUser user) // Need to potentially check all keys if (user == null && Options.Stores.ProtectPersonalData) { - var keyRing = _services.GetService(); - var protector = _services.GetService(); + var keyRing = ServiceProvider.GetService(); + var protector = ServiceProvider.GetService(); if (keyRing != null && protector != null) { foreach (var key in keyRing.GetAllKeyIds()) @@ -620,8 +624,8 @@ public virtual async Task CreateAsync(TUser user, string passwor { if (Options.Stores.ProtectPersonalData) { - var keyRing = _services.GetRequiredService(); - var protector = _services.GetRequiredService(); + var keyRing = ServiceProvider.GetRequiredService(); + var protector = ServiceProvider.GetRequiredService(); return protector.Protect(keyRing.CurrentKeyId, data); } return data; @@ -1310,8 +1314,8 @@ public virtual async Task SetEmailAsync(TUser user, string? emai // Need to potentially check all keys if (user == null && Options.Stores.ProtectPersonalData) { - var keyRing = _services.GetService(); - var protector = _services.GetService(); + var keyRing = ServiceProvider.GetService(); + var protector = ServiceProvider.GetService(); if (keyRing != null && protector != null) { foreach (var key in keyRing.GetAllKeyIds()) diff --git a/src/Identity/Identity.slnf b/src/Identity/Identity.slnf index 81bcc742f00a..d45d04e724b5 100644 --- a/src/Identity/Identity.slnf +++ b/src/Identity/Identity.slnf @@ -3,13 +3,17 @@ "path": "..\\..\\AspNetCore.slnx", "projects": [ "src\\Antiforgery\\src\\Microsoft.AspNetCore.Antiforgery.csproj", + "src\\Components\\Authorization\\src\\Microsoft.AspNetCore.Components.Authorization.csproj", "src\\Components\\Components\\src\\Microsoft.AspNetCore.Components.csproj", + "src\\Components\\Endpoints\\src\\Microsoft.AspNetCore.Components.Endpoints.csproj", + "src\\Components\\Forms\\src\\Microsoft.AspNetCore.Components.Forms.csproj", "src\\Components\\Web\\src\\Microsoft.AspNetCore.Components.Web.csproj", "src\\DataProtection\\Abstractions\\src\\Microsoft.AspNetCore.DataProtection.Abstractions.csproj", "src\\DataProtection\\Cryptography.Internal\\src\\Microsoft.AspNetCore.Cryptography.Internal.csproj", "src\\DataProtection\\Cryptography.KeyDerivation\\src\\Microsoft.AspNetCore.Cryptography.KeyDerivation.csproj", "src\\DataProtection\\DataProtection\\src\\Microsoft.AspNetCore.DataProtection.csproj", "src\\DataProtection\\Extensions\\src\\Microsoft.AspNetCore.DataProtection.Extensions.csproj", + "src\\DefaultBuilder\\src\\Microsoft.AspNetCore.csproj", "src\\Extensions\\Features\\src\\Microsoft.Extensions.Features.csproj", "src\\Features\\JsonPatch\\src\\Microsoft.AspNetCore.JsonPatch.csproj", "src\\Hosting\\Abstractions\\src\\Microsoft.AspNetCore.Hosting.Abstractions.csproj", @@ -47,14 +51,18 @@ "src\\Identity\\test\\Identity.Test\\Microsoft.AspNetCore.Identity.Test.csproj", "src\\Identity\\test\\InMemory.Test\\Microsoft.AspNetCore.Identity.InMemory.Test.csproj", "src\\Identity\\testassets\\Identity.DefaultUI.WebSite\\Identity.DefaultUI.WebSite.csproj", + "src\\JSInterop\\Microsoft.JSInterop\\src\\Microsoft.JSInterop.csproj", "src\\Localization\\Abstractions\\src\\Microsoft.Extensions.Localization.Abstractions.csproj", + "src\\Localization\\Localization\\src\\Microsoft.Extensions.Localization.csproj", "src\\Middleware\\CORS\\src\\Microsoft.AspNetCore.Cors.csproj", "src\\Middleware\\Diagnostics.Abstractions\\src\\Microsoft.AspNetCore.Diagnostics.Abstractions.csproj", "src\\Middleware\\Diagnostics.EntityFrameworkCore\\src\\Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore.csproj", "src\\Middleware\\Diagnostics\\src\\Microsoft.AspNetCore.Diagnostics.csproj", + "src\\Middleware\\HostFiltering\\src\\Microsoft.AspNetCore.HostFiltering.csproj", "src\\Middleware\\HttpOverrides\\src\\Microsoft.AspNetCore.HttpOverrides.csproj", "src\\Middleware\\HttpsPolicy\\src\\Microsoft.AspNetCore.HttpsPolicy.csproj", "src\\Middleware\\Localization\\src\\Microsoft.AspNetCore.Localization.csproj", + "src\\Middleware\\OutputCaching\\src\\Microsoft.AspNetCore.OutputCaching.csproj", "src\\Middleware\\ResponseCaching.Abstractions\\src\\Microsoft.AspNetCore.ResponseCaching.Abstractions.csproj", "src\\Middleware\\Rewrite\\src\\Microsoft.AspNetCore.Rewrite.csproj", "src\\Middleware\\StaticFiles\\src\\Microsoft.AspNetCore.StaticFiles.csproj", @@ -64,6 +72,7 @@ "src\\Mvc\\Mvc.Core\\src\\Microsoft.AspNetCore.Mvc.Core.csproj", "src\\Mvc\\Mvc.Cors\\src\\Microsoft.AspNetCore.Mvc.Cors.csproj", "src\\Mvc\\Mvc.DataAnnotations\\src\\Microsoft.AspNetCore.Mvc.DataAnnotations.csproj", + "src\\Mvc\\Mvc.Formatters.Json\\src\\Microsoft.AspNetCore.Mvc.Formatters.Json.csproj", "src\\Mvc\\Mvc.Localization\\src\\Microsoft.AspNetCore.Mvc.Localization.csproj", "src\\Mvc\\Mvc.NewtonsoftJson\\src\\Microsoft.AspNetCore.Mvc.NewtonsoftJson.csproj", "src\\Mvc\\Mvc.RazorPages\\src\\Microsoft.AspNetCore.Mvc.RazorPages.csproj", @@ -88,14 +97,18 @@ "src\\Security\\Authorization\\Policy\\src\\Microsoft.AspNetCore.Authorization.Policy.csproj", "src\\Security\\CookiePolicy\\src\\Microsoft.AspNetCore.CookiePolicy.csproj", "src\\Servers\\Connections.Abstractions\\src\\Microsoft.AspNetCore.Connections.Abstractions.csproj", + "src\\Servers\\HttpSys\\src\\Microsoft.AspNetCore.Server.HttpSys.csproj", "src\\Servers\\IIS\\IISIntegration\\src\\Microsoft.AspNetCore.Server.IISIntegration.csproj", + "src\\Servers\\IIS\\IIS\\src\\Microsoft.AspNetCore.Server.IIS.csproj", "src\\Servers\\Kestrel\\Core\\src\\Microsoft.AspNetCore.Server.Kestrel.Core.csproj", "src\\Servers\\Kestrel\\Kestrel\\src\\Microsoft.AspNetCore.Server.Kestrel.csproj", "src\\Servers\\Kestrel\\Transport.NamedPipes\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.csproj", "src\\Servers\\Kestrel\\Transport.Quic\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.Quic.csproj", "src\\Servers\\Kestrel\\Transport.Sockets\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.csproj", + "src\\StaticAssets\\src\\Microsoft.AspNetCore.StaticAssets.csproj", "src\\Testing\\src\\Microsoft.AspNetCore.InternalTesting.csproj", + "src\\Validation\\src\\Microsoft.Extensions.Validation.csproj", "src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj" ] } -} +} \ No newline at end of file diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Data/PasskeyAssertionState.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/PasskeyAssertionState.cs new file mode 100644 index 000000000000..a0ebcd439240 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/PasskeyAssertionState.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace IdentitySample.PasskeyConformance.Data; + +internal sealed class PasskeyAssertionState +{ + public required ServerPublicKeyCredentialGetOptionsRequest Request { get; init; } + public required string? AssertionState { get; init; } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Data/PasskeyAttestationState.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/PasskeyAttestationState.cs new file mode 100644 index 000000000000..3c9d3f699712 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/PasskeyAttestationState.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace IdentitySample.PasskeyConformance.Data; + +internal sealed class PasskeyAttestationState +{ + public required ServerPublicKeyCredentialCreationOptionsRequest Request { get; init; } + public required string? AttestationState { get; init; } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialCreationOptionsRequest.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialCreationOptionsRequest.cs index 3f87115bf849..ad4b541aaddb 100644 --- a/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialCreationOptionsRequest.cs +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialCreationOptionsRequest.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; -using Microsoft.AspNetCore.Identity; namespace IdentitySample.PasskeyConformance.Data; @@ -13,4 +12,11 @@ internal sealed class ServerPublicKeyCredentialCreationOptionsRequest(string use public AuthenticatorSelectionCriteria? AuthenticatorSelection { get; set; } public JsonElement? Extensions { get; set; } public string? Attestation { get; set; } = "none"; + + internal sealed class AuthenticatorSelectionCriteria + { + public string? ResidentKey { get; set; } + public string? AuthenticatorAttachment { get; set; } + public string? UserVerification { get; set; } + } } diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs index 45a5dcc163f8..0c0740c31aeb 100644 --- a/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.Test; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; var builder = WebApplication.CreateBuilder(args); @@ -24,73 +25,91 @@ }); }); -builder.Services.AddIdentityCore(options => - { - // The origin can't be inferred from the request, since the conformance testing tool - // does not send the Origin header. Therefore, we need to explicitly set the allowed origins. - options.Passkey.AllowedOrigins = [ - "http://localhost:7020", - "https://localhost:7020" - ]; - }) +builder.Services.AddIdentityCore() .AddSignInManager(); builder.Services.AddSingleton, InMemoryUserStore>(); builder.Services.AddSingleton, InMemoryUserStore>(); +// In a real app, you'd rely on the SignInManager to securely store passkey state in +// 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 +{ + Name = "PasskeyConformance.PasskeyState", + HttpOnly = true, + SameSite = SameSiteMode.None, + SecurePolicy = CookieSecurePolicy.SameAsRequest, +}; + var app = builder.Build(); var attestationGroup = app.MapGroup("/attestation"); attestationGroup.MapPost("/options", async ( [FromServices] UserManager userManager, - [FromServices] SignInManager signInManager, - [FromBody] ServerPublicKeyCredentialCreationOptionsRequest request) => + [FromBody] ServerPublicKeyCredentialCreationOptionsRequest request, + HttpContext context) => { + var passkeyOptions = GetPasskeyOptionsFromCreationRequest(request); var userId = (await userManager.FindByNameAsync(request.Username) ?? new PocoUser()).Id; - var userEntity = new PasskeyUserEntity(userId, request.Username, request.DisplayName); - var creationArgs = new PasskeyCreationArgs(userEntity) + var userEntity = new PasskeyUserEntity { - AuthenticatorSelection = request.AuthenticatorSelection, - Extensions = request.Extensions, + Id = userId, + Name = request.Username, + DisplayName = request.DisplayName }; - - if (request.Attestation is { Length: > 0 } attestation) + var passkeyHandler = new DefaultPasskeyHandler(userManager, passkeyOptions); + var result = await passkeyHandler.MakeCreationOptionsAsync(userEntity, context); + var response = new ServerPublicKeyCredentialOptionsResponse(result.CreationOptionsJson); + var state = new PasskeyAttestationState { - creationArgs.Attestation = attestation; - } - - var options = await signInManager.ConfigurePasskeyCreationOptionsAsync(creationArgs); - var response = new ServerPublicKeyCredentialOptionsResponse(options.AsJson()); + Request = request, + AttestationState = result.AttestationState, + }; + var stateJson = JsonSerializer.Serialize(state, JsonSerializerOptions.Web); + context.Response.Cookies.Append(passkeyStateCookie.Name, stateJson, passkeyStateCookie.Build(context)); return Results.Ok(response); }); attestationGroup.MapPost("/result", async ( [FromServices] IUserPasskeyStore passkeyStore, [FromServices] UserManager userManager, - [FromServices] SignInManager signInManager, [FromBody] JsonElement result, + HttpContext context, CancellationToken cancellationToken) => { var credentialJson = ServerPublicKeyCredentialToJson(result); - var options = await signInManager.RetrievePasskeyCreationOptionsAsync(); - - await signInManager.SignOutAsync(); + if (!context.Request.Cookies.TryGetValue(passkeyStateCookie.Name, out var stateJson)) + { + return Results.BadRequest(new FailedResponse("There is no passkey attestation state present.")); + } - if (options is null) + var state = JsonSerializer.Deserialize(stateJson, JsonSerializerOptions.Web); + if (state is null) { - return Results.BadRequest(new FailedResponse("There are no original passkey options present.")); + return Results.BadRequest(new FailedResponse("The passkey attestation state is invalid or missing.")); } - var attestationResult = await signInManager.PerformPasskeyAttestationAsync(credentialJson, options); + var passkeyOptions = GetPasskeyOptionsFromCreationRequest(state.Request); + var passkeyHandler = new DefaultPasskeyHandler(userManager, passkeyOptions); + + var attestationResult = await passkeyHandler.PerformAttestationAsync(new() + { + HttpContext = context, + AttestationState = state.AttestationState, + CredentialJson = credentialJson, + }); + if (!attestationResult.Succeeded) { return Results.BadRequest(new FailedResponse($"Attestation failed: {attestationResult.Failure.Message}")); } // Create the user if they don't exist yet. - var userEntity = options.UserEntity; + var userEntity = attestationResult.UserEntity; var user = await userManager.FindByIdAsync(userEntity.Id); if (user is null) { @@ -119,8 +138,8 @@ assertionGroup.MapPost("/options", async ( [FromServices] UserManager userManager, - [FromServices] SignInManager signInManager, - [FromBody] ServerPublicKeyCredentialGetOptionsRequest request) => + [FromBody] ServerPublicKeyCredentialGetOptionsRequest request, + HttpContext context) => { var user = await userManager.FindByNameAsync(request.Username); if (user is null) @@ -128,33 +147,48 @@ return Results.BadRequest($"User with username {request.Username} does not exist."); } - var requestArgs = new PasskeyRequestArgs + var passkeyOptions = GetPasskeyOptionsFromGetRequest(request); + var passkeyHandler = new DefaultPasskeyHandler(userManager, passkeyOptions); + + var result = await passkeyHandler.MakeRequestOptionsAsync(user, context); + var response = new ServerPublicKeyCredentialOptionsResponse(result.RequestOptionsJson); + var state = new PasskeyAssertionState { - User = user, - UserVerification = request.UserVerification, - Extensions = request.Extensions, + Request = request, + AssertionState = result.AssertionState, }; - var options = await signInManager.ConfigurePasskeyRequestOptionsAsync(requestArgs); - var response = new ServerPublicKeyCredentialOptionsResponse(options.AsJson()); + var stateJson = JsonSerializer.Serialize(state, JsonSerializerOptions.Web); + context.Response.Cookies.Append(passkeyStateCookie.Name, stateJson, passkeyStateCookie.Build(context)); return Results.Ok(response); }); assertionGroup.MapPost("/result", async ( - [FromServices] SignInManager signInManager, [FromServices] UserManager userManager, - [FromBody] JsonElement result) => + [FromBody] JsonElement result, + HttpContext context) => { var credentialJson = ServerPublicKeyCredentialToJson(result); - var options = await signInManager.RetrievePasskeyRequestOptionsAsync(); - await signInManager.SignOutAsync(); + if (!context.Request.Cookies.TryGetValue(passkeyStateCookie.Name, out var stateJson)) + { + return Results.BadRequest(new FailedResponse("There is no passkey assertion state present.")); + } - if (options is null) + var state = JsonSerializer.Deserialize(stateJson, JsonSerializerOptions.Web); + if (state is null) { - return Results.BadRequest(new FailedResponse("There are no original passkey options present.")); + return Results.BadRequest(new FailedResponse("The passkey assertion state is invalid or missing.")); } - var assertionResult = await signInManager.PerformPasskeyAssertionAsync(credentialJson, options); + var passkeyOptions = GetPasskeyOptionsFromGetRequest(state.Request); + var passkeyHandler = new DefaultPasskeyHandler(userManager, passkeyOptions); + + var assertionResult = await passkeyHandler.PerformAssertionAsync(new() + { + HttpContext = context, + CredentialJson = credentialJson, + AssertionState = state.AssertionState, + }); if (!assertionResult.Succeeded) { return Results.BadRequest(new FailedResponse($"Assertion failed: {assertionResult.Failure.Message}")); @@ -201,3 +235,30 @@ static string ServerPublicKeyCredentialToJson(JsonElement serverPublicKeyCredent var resultJson = Encoding.UTF8.GetString(resultBytes); return resultJson; } + +static Task ValidateOriginAsync(PasskeyOriginValidationContext context) +{ + if (!Uri.TryCreate(context.Origin, UriKind.Absolute, out var uri)) + { + return Task.FromResult(false); + } + + return Task.FromResult(uri.Host == "localhost" && uri.Port == 7020); +} + +static IOptions GetPasskeyOptionsFromCreationRequest(ServerPublicKeyCredentialCreationOptionsRequest request) + => Options.Create(new PasskeyOptions() + { + ValidateOrigin = ValidateOriginAsync, + AttestationConveyancePreference = request.Attestation, + AuthenticatorAttachment = request.AuthenticatorSelection?.AuthenticatorAttachment, + ResidentKeyRequirement = request.AuthenticatorSelection?.ResidentKey, + UserVerificationRequirement = request.AuthenticatorSelection?.UserVerification, + }); + +static IOptions GetPasskeyOptionsFromGetRequest(ServerPublicKeyCredentialGetOptionsRequest request) + => Options.Create(new PasskeyOptions() + { + ValidateOrigin = ValidateOriginAsync, + UserVerificationRequirement = request.UserVerification, + }); diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor index 04c98b9ce58e..b90e05300fe7 100644 --- a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor @@ -77,14 +77,7 @@ return; } - 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}"; @@ -92,7 +85,7 @@ } // Create the user if they don't exist yet. - var userEntity = options.UserEntity; + var userEntity = attestationResult.UserEntity; var user = await UserManager.FindByIdAsync(userEntity.Id); if (user is null) { @@ -126,14 +119,7 @@ return; } - var options = await SignInManager.RetrievePasskeyRequestOptionsAsync(); - if (options is null) - { - statusMessage = "Error: There are no original passkey options present."; - return; - } - - var signInResult = await SignInManager.PasskeySignInAsync(CredentialJson, options); + var signInResult = await SignInManager.PasskeySignInAsync(CredentialJson); if (!signInResult.Succeeded) { statusMessage = "Error: Could not sign in with the provided credential."; diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Program.cs b/src/Identity/samples/IdentitySample.PasskeyUI/Program.cs index 665f1d87f06e..178973e84b93 100644 --- a/src/Identity/samples/IdentitySample.PasskeyUI/Program.cs +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Program.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Text.Json; using IdentitySample.PasskeyUI; using IdentitySample.PasskeyUI.Components; using Microsoft.AspNetCore.Identity; @@ -51,20 +50,14 @@ [FromBody] PublicKeyCredentialCreationOptionsRequest request) => { var userId = (await userManager.FindByNameAsync(request.Username) ?? new PocoUser()).Id; - var userEntity = new PasskeyUserEntity(userId, request.Username, null); - var creationArgs = new PasskeyCreationArgs(userEntity) + var userEntity = new PasskeyUserEntity { - AuthenticatorSelection = request.AuthenticatorSelection, - Extensions = request.Extensions, + Id = userId, + Name = request.Username, + DisplayName = request.Username }; - - if (!string.IsNullOrEmpty(request.Attestation)) - { - creationArgs.Attestation = request.Attestation; - } - - var options = await signInManager.ConfigurePasskeyCreationOptionsAsync(creationArgs); - return Results.Content(options.AsJson(), contentType: "application/json"); + var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(userEntity); + return Results.Content(optionsJson, contentType: "application/json"); }); app.MapPost("assertion/options", async ( @@ -72,23 +65,9 @@ [FromServices] SignInManager signInManager, [FromBody] PublicKeyCredentialGetOptionsRequest request) => { - var user = !string.IsNullOrEmpty(request.Username) - ? await userManager.FindByNameAsync(request.Username) - : null; - - var requestArgs = new PasskeyRequestArgs - { - User = user, - Extensions = request.Extensions, - }; - - if (!string.IsNullOrEmpty(request.UserVerification)) - { - requestArgs.UserVerification = request.UserVerification; - } - - var options = await signInManager.ConfigurePasskeyRequestOptionsAsync(requestArgs); - return Results.Content(options.AsJson(), contentType: "application/json"); + var user = !string.IsNullOrEmpty(request.Username) ? await userManager.FindByNameAsync(request.Username) : null; + var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user); + return Results.Content(optionsJson, contentType: "application/json"); }); app.MapPost("account/logout", async ( @@ -100,17 +79,12 @@ app.Run(); -sealed class PublicKeyCredentialCreationOptionsRequest(string username) +sealed class PublicKeyCredentialCreationOptionsRequest { - public string Username { get; } = username; - public AuthenticatorSelectionCriteria? AuthenticatorSelection { get; set; } - public JsonElement? Extensions { get; set; } - public string? Attestation { get; set; } = "none"; + public required string Username { get; set; } } sealed class PublicKeyCredentialGetOptionsRequest { public string? Username { get; set; } - public string? UserVerification { get; set; } - public JsonElement? Extensions { get; set; } } diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.js b/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.js index 9f344b4413cd..43df207e5ace 100644 --- a/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.js +++ b/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.js @@ -36,10 +36,6 @@ method: 'POST', body: JSON.stringify({ username, - authenticatorSelection: { - residentKey: 'preferred', - } - // TODO: Allow configuration of other options. }), headers: { 'Content-Type': 'application/json', @@ -62,7 +58,6 @@ method: 'POST', body: JSON.stringify({ username, - // TODO: Allow configuration of other options. }), headers: { 'Content-Type': 'application/json', diff --git a/src/Identity/test/Identity.Test/IdentityOptionsTest.cs b/src/Identity/test/Identity.Test/IdentityOptionsTest.cs index 9f53948d4e57..a078cea31911 100644 --- a/src/Identity/test/Identity.Test/IdentityOptionsTest.cs +++ b/src/Identity/test/Identity.Test/IdentityOptionsTest.cs @@ -32,12 +32,6 @@ public void VerifyDefaultOptions() Assert.Equal(ClaimTypes.Name, options.ClaimsIdentity.UserNameClaimType); Assert.Equal(ClaimTypes.NameIdentifier, options.ClaimsIdentity.UserIdClaimType); Assert.Equal("AspNet.Identity.SecurityStamp", options.ClaimsIdentity.SecurityStampClaimType); - - Assert.Equal(TimeSpan.FromMinutes(1), options.Passkey.Timeout); - Assert.Equal(16, options.Passkey.ChallengeSize); - Assert.True(options.Passkey.AllowCurrentOrigin); - Assert.Equal(PasskeyOptions.CredentialBackupPolicy.Allowed, options.Passkey.BackupEligibleCredentialPolicy); - Assert.Equal(PasskeyOptions.CredentialBackupPolicy.Allowed, options.Passkey.BackedUpCredentialPolicy); } [Fact] diff --git a/src/Identity/test/Identity.Test/PasskeyOptionsTest.cs b/src/Identity/test/Identity.Test/PasskeyOptionsTest.cs new file mode 100644 index 000000000000..1bf1a339590e --- /dev/null +++ b/src/Identity/test/Identity.Test/PasskeyOptionsTest.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity.Test; + +public class PasskeyOptionsTest +{ + [Fact] + public void VerifyDefaultOptions() + { + var options = new PasskeyOptions(); + + Assert.Equal(TimeSpan.FromMinutes(5), options.Timeout); + Assert.Equal(32, options.ChallengeSize); + Assert.Null(options.ServerDomain); + Assert.Null(options.UserVerificationRequirement); + Assert.Null(options.ResidentKeyRequirement); + Assert.Null(options.AttestationConveyancePreference); + Assert.Null(options.AuthenticatorAttachment); + Assert.NotNull(options.ValidateOrigin); + Assert.NotNull(options.VerifyAttestationStatement); + } +} diff --git a/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAssertionTest.cs b/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAssertionTest.cs index 4cc77b0e88d7..b7642f8036d5 100644 --- a/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAssertionTest.cs +++ b/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAssertionTest.cs @@ -165,10 +165,10 @@ public async Task Fails_WhenCredentialResponseIsNotAnObject(string jsonValue) } [Fact] - public async Task Fails_WhenOriginalOptionsChallengeIsMissing() + public async Task Fails_WhenAssertionStateChallengeIsMissing() { var test = new AssertionTest(); - test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + test.AssertionStateJson.TransformAsJsonObject(originalOptionsJson => { Assert.True(originalOptionsJson.Remove("challenge")); }); @@ -177,43 +177,25 @@ public async Task Fails_WhenOriginalOptionsChallengeIsMissing() Assert.False(result.Succeeded); - Assert.StartsWith("The original passkey request options had an invalid format", result.Failure.Message); + Assert.StartsWith("The assertion state JSON had an invalid format", result.Failure.Message); Assert.Contains("was missing required properties including: 'challenge'", result.Failure.Message); } - [Fact] - public async Task Fails_WhenOriginalOptionsChallengeIsNotBase64UrlEncoded() - { - var test = new AssertionTest(); - test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => - { - var base64UrlChallenge = (string)originalOptionsJson["challenge"]!; - originalOptionsJson["challenge"] = GetInvalidBase64UrlValue(base64UrlChallenge); - }); - - var result = await test.RunAsync(); - - Assert.False(result.Succeeded); - Assert.StartsWith("The original passkey request options had an invalid format", result.Failure.Message); - Assert.Contains("base64url string", result.Failure.Message); - } - [Theory] [InlineData("42")] - [InlineData("null")] [InlineData("{}")] - public async Task Fails_WhenOriginalOptionsChallengeIsNotString(string jsonValue) + public async Task Fails_WhenAssertionStateChallengeIsNotString(string jsonValue) { var test = new AssertionTest(); - test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + test.AssertionStateJson.TransformAsJsonObject(assertionStateJson => { - originalOptionsJson["challenge"] = JsonNode.Parse(jsonValue); + assertionStateJson["challenge"] = JsonNode.Parse(jsonValue); }); var result = await test.RunAsync(); Assert.False(result.Succeeded); - Assert.StartsWith("The original passkey request options had an invalid format", result.Failure.Message); + Assert.StartsWith("The assertion state JSON had an invalid format", result.Failure.Message); } [Fact] @@ -590,15 +572,11 @@ public async Task Fails_WhenClientDataJsonChallengeIsNotBase64UrlEncoded() public async Task Fails_WhenClientDataJsonChallengeIsNotRequestChallenge() { var test = new AssertionTest(); - var modifiedChallenge = (byte[])[.. test.Challenge.Span]; - for (var i = 0; i < modifiedChallenge.Length; i++) - { - modifiedChallenge[i]++; - } - test.ClientDataJson.TransformAsJsonObject(clientDataJson => { - clientDataJson["challenge"] = Base64Url.EncodeToString(modifiedChallenge); + var challenge = Base64Url.DecodeFromChars((string)clientDataJson["challenge"]!); + challenge[0]++; + clientDataJson["challenge"] = Base64Url.EncodeToString(challenge); }); var result = await test.RunAsync(); @@ -734,10 +712,7 @@ public async Task Fails_WhenClientDataJsonTokenBindingStatusIsInvalid() public async Task Succeeds_WhenUserVerificationIsRequiredAndUserIsVerified() { var test = new AssertionTest(); - test.OriginalOptionsJson.TransformAsJsonObject(optionsJson => - { - optionsJson["userVerification"] = "required"; - }); + test.PasskeyOptions.UserVerificationRequirement = "required"; test.AuthenticatorDataArgs.Transform(args => args with { Flags = args.Flags | AuthenticatorDataFlags.UserVerified, @@ -752,10 +727,7 @@ public async Task Succeeds_WhenUserVerificationIsRequiredAndUserIsVerified() public async Task Succeeds_WhenUserVerificationIsDiscouragedAndUserIsVerified() { var test = new AssertionTest(); - test.OriginalOptionsJson.TransformAsJsonObject(optionsJson => - { - optionsJson["userVerification"] = "discouraged"; - }); + test.PasskeyOptions.UserVerificationRequirement = "discouraged"; test.AuthenticatorDataArgs.Transform(args => args with { Flags = args.Flags | AuthenticatorDataFlags.UserVerified, @@ -770,10 +742,7 @@ public async Task Succeeds_WhenUserVerificationIsDiscouragedAndUserIsVerified() public async Task Fails_WhenUserVerificationIsRequiredAndUserIsNotVerified() { var test = new AssertionTest(); - test.OriginalOptionsJson.TransformAsJsonObject(optionsJson => - { - optionsJson["userVerification"] = "required"; - }); + test.PasskeyOptions.UserVerificationRequirement = "required"; var result = await test.RunAsync(); @@ -948,144 +917,6 @@ public async Task Fails_WhenAuthenticatorDataIsNotBackupEligibleButBackedUp() Assert.StartsWith("The credential is backed up, but the authenticator data flags did not have the 'BackupEligible' flag", result.Failure.Message); } - [Theory] - [InlineData(PasskeyOptions.CredentialBackupPolicy.Allowed)] - [InlineData(PasskeyOptions.CredentialBackupPolicy.Required)] - public async Task Succeeds_WhenAuthenticatorDataIsBackupEligible(PasskeyOptions.CredentialBackupPolicy backupEligibility) - { - var test = new AssertionTest(); - test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = backupEligibility; - test.AuthenticatorDataArgs.Transform(args => args with - { - Flags = args.Flags | AuthenticatorDataFlags.BackupEligible, - }); - - // This test simulates an RP policy failure, not a mismatch between the stored passkey - // and the authenticator data flags, so we'll make the stored passkey match the - // authenticator data flags - test.IsStoredPasskeyBackupEligible = true; - - var result = await test.RunAsync(); - - Assert.True(result.Succeeded); - } - - [Fact] - public async Task Fails_WhenAuthenticatorDataIsBackupEligibleButDisallowed() - { - var test = new AssertionTest(); - test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Disallowed; - test.AuthenticatorDataArgs.Transform(args => args with - { - Flags = args.Flags | AuthenticatorDataFlags.BackupEligible, - }); - - // This test simulates an RP policy failure, not a mismatch between the stored passkey - // and the authenticator data flags, so we'll make the stored passkey match the - // authenticator data flags - test.IsStoredPasskeyBackupEligible = true; - - var result = await test.RunAsync(); - - Assert.False(result.Succeeded); - Assert.StartsWith( - "Credential backup eligibility is disallowed, but the credential was eligible for backup", - result.Failure.Message); - } - - [Fact] - public async Task Fails_WhenAuthenticatorDataIsNotBackupEligibleButRequired() - { - var test = new AssertionTest(); - test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Required; - test.AuthenticatorDataArgs.Transform(args => args with - { - Flags = args.Flags & ~AuthenticatorDataFlags.BackupEligible, - }); - - // This test simulates an RP policy failure, not a mismatch between the stored passkey - // and the authenticator data flags, so we'll make the stored passkey match the - // authenticator data flags - test.IsStoredPasskeyBackupEligible = false; - - var result = await test.RunAsync(); - - Assert.False(result.Succeeded); - Assert.StartsWith( - "Credential backup eligibility is required, but the credential was not eligible for backup", - result.Failure.Message); - } - - [Theory] - [InlineData(PasskeyOptions.CredentialBackupPolicy.Allowed)] - [InlineData(PasskeyOptions.CredentialBackupPolicy.Required)] - public async Task Attestation_Fails_WhenAuthenticatorDataIsBackedUp(PasskeyOptions.CredentialBackupPolicy backedUpPolicy) - { - var test = new AssertionTest(); - test.IdentityOptions.Passkey.BackedUpCredentialPolicy = backedUpPolicy; - test.AuthenticatorDataArgs.Transform(args => args with - { - Flags = args.Flags | AuthenticatorDataFlags.BackupEligible | AuthenticatorDataFlags.BackedUp, - }); - - // This test simulates an RP policy failure, not a mismatch between the stored passkey - // and the authenticator data flags, so we'll make the stored passkey match the - // authenticator data flags - test.IsStoredPasskeyBackupEligible = true; - test.IsStoredPasskeyBackedUp = true; - - var result = await test.RunAsync(); - - Assert.True(result.Succeeded); - } - - [Fact] - public async Task Fails_WhenAuthenticatorDataIsBackedUpButDisallowed() - { - var test = new AssertionTest(); - test.IdentityOptions.Passkey.BackedUpCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Disallowed; - test.AuthenticatorDataArgs.Transform(args => args with - { - Flags = args.Flags | AuthenticatorDataFlags.BackupEligible | AuthenticatorDataFlags.BackedUp, - }); - - // This test simulates an RP policy failure, not a mismatch between the stored passkey - // and the authenticator data flags, so we'll make the stored passkey match the - // authenticator data flags - test.IsStoredPasskeyBackupEligible = true; - test.IsStoredPasskeyBackedUp = true; - - var result = await test.RunAsync(); - - Assert.False(result.Succeeded); - Assert.StartsWith( - "Credential backup is disallowed, but the credential was backed up", - result.Failure.Message); - } - - [Fact] - public async Task Fails_WhenAuthenticatorDataIsNotBackedUpButRequired() - { - var test = new AssertionTest(); - test.IdentityOptions.Passkey.BackedUpCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Required; - test.AuthenticatorDataArgs.Transform(args => args with - { - Flags = args.Flags & ~AuthenticatorDataFlags.BackedUp, - }); - - // This test simulates an RP policy failure, not a mismatch between the stored passkey - // and the authenticator data flags, so we'll make the stored passkey match the - // authenticator data flags - test.IsStoredPasskeyBackedUp = false; - - var result = await test.RunAsync(); - - Assert.False(result.Succeeded); - Assert.StartsWith( - "Credential backup is required, but the credential was not backed up", - result.Failure.Message); - } - [Fact] public async Task Fails_WhenAuthenticatorDataIsNotBackupEligibleButStoredPasskeyIs() { @@ -1122,36 +953,6 @@ public async Task Fails_WhenAuthenticatorDataIsBackupEligibleButStoredPasskeyIsN result.Failure.Message); } - [Fact] - public async Task Fails_WhenProvidedCredentialIsNotInAllowedCredentials() - { - var test = new AssertionTest(); - var allowedCredentialId = test.CredentialId.ToArray(); - allowedCredentialId[0]++; - test.AddAllowedCredential(allowedCredentialId); - - var result = await test.RunAsync(); - - Assert.False(result.Succeeded); - Assert.StartsWith( - "The provided credential ID was not in the list of allowed credentials", - result.Failure.Message); - } - - [Fact] - public async Task Succeeds_WhenProvidedCredentialIsInAllowedCredentials() - { - var test = new AssertionTest(); - var otherAllowedCredentialId = test.CredentialId.ToArray(); - otherAllowedCredentialId[0]++; - test.AddAllowedCredential(test.CredentialId); - test.AddAllowedCredential(otherAllowedCredentialId); - - var result = await test.RunAsync(); - - Assert.True(result.Succeeded); - } - [Theory] [InlineData(false)] [InlineData(true)] @@ -1177,14 +978,10 @@ private static string GetInvalidBase64UrlValue(string base64UrlValue) private sealed class AssertionTest : PasskeyScenarioTest> { - private static readonly byte[] _defaultChallenge = [1, 2, 3, 4, 5, 6, 7, 8]; private static readonly byte[] _defaultCredentialId = [1, 2, 3, 4, 5, 6, 7, 8]; - private readonly List _allowCredentials = []; - - public IdentityOptions IdentityOptions { get; } = new(); - public string? RpId { get; set; } = "example.com"; - public string? Origin { get; set; } = "https://example.com"; + public PasskeyOptions PasskeyOptions { get; } = new(); + public string Origin { get; set; } = "https://example.com"; public PocoUser User { get; set; } = new() { Id = "df0a3af4-bd65-440f-82bd-5b839e300dcd", @@ -1195,78 +992,19 @@ private sealed class AssertionTest : PasskeyScenarioTest Challenge { get; set; } = _defaultChallenge; public ReadOnlyMemory CredentialId { get; set; } = _defaultCredentialId; public ComputedValue AuthenticatorDataArgs { get; } = new(); public ComputedValue> AuthenticatorData { get; } = new(); public ComputedValue> ClientDataHash { get; } = new(); public ComputedValue> Signature { get; } = new(); - public ComputedJsonObject OriginalOptionsJson { get; } = new(); + public ComputedJsonObject AssertionStateJson { get; } = new(); public ComputedJsonObject ClientDataJson { get; } = new(); public ComputedJsonObject CredentialJson { get; } = new(); public ComputedValue StoredPasskey { get; } = new(); - public void AddAllowedCredential(ReadOnlyMemory credentialId) - { - _allowCredentials.Add(new() - { - Id = BufferSource.FromBytes(credentialId), - Type = "public-key", - Transports = ["internal"], - }); - } - protected override async Task> RunCoreAsync() { - var identityOptions = Options.Create(IdentityOptions); - var handler = new DefaultPasskeyHandler(identityOptions); var credential = CredentialKeyPair.Generate(Algorithm); - var allowCredentialsJson = JsonSerializer.Serialize( - _allowCredentials, - IdentityJsonSerializerContext.Default.IReadOnlyListPublicKeyCredentialDescriptor); - var originalOptionsJson = OriginalOptionsJson.Compute($$""" - { - "challenge": {{ToBase64UrlJsonValue(Challenge)}}, - "rpId": {{ToJsonValue(RpId)}}, - "allowCredentials": {{allowCredentialsJson}}, - "timeout": 60000, - "userVerification": "preferred", - "hints": [] - } - """); - var authenticatorDataArgs = AuthenticatorDataArgs.Compute(new() - { - RpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(RpId ?? string.Empty)), - Flags = AuthenticatorDataFlags.UserPresent, - SignCount = 1, - }); - var authenticatorData = AuthenticatorData.Compute(MakeAuthenticatorData(authenticatorDataArgs)); - var clientDataJson = ClientDataJson.Compute($$""" - { - "challenge": {{ToBase64UrlJsonValue(Challenge)}}, - "origin": {{ToJsonValue(Origin)}}, - "type": "webauthn.get" - } - """); - var clientDataJsonBytes = Encoding.UTF8.GetBytes(clientDataJson?.ToString() ?? string.Empty); - var clientDataHash = ClientDataHash.Compute(SHA256.HashData(clientDataJsonBytes)); - var dataToSign = (byte[])[.. authenticatorData.Span, .. clientDataHash.Span]; - var signature = Signature.Compute(credential.SignData(dataToSign)); - var credentialJson = CredentialJson.Compute($$""" - { - "id": {{ToBase64UrlJsonValue(CredentialId)}}, - "response": { - "authenticatorData": {{ToBase64UrlJsonValue(authenticatorData)}}, - "clientDataJSON": {{ToBase64UrlJsonValue(clientDataJson)}}, - "signature": {{ToBase64UrlJsonValue(signature)}}, - "userHandle": {{ToBase64UrlJsonValue(User.Id)}} - }, - "type": "public-key", - "clientExtensionResults": {}, - "authenticatorAttachment": "platform" - } - """); - var credentialPublicKey = credential.EncodePublicKeyCbor(); var storedPasskey = StoredPasskey.Compute(new( CredentialId.ToArray(), @@ -1294,6 +1032,10 @@ protected override async Task> RunCoreAsync() DoesCredentialExistOnUser && user == User && CredentialId.Span.SequenceEqual(credentialId) ? storedPasskey : null)); + userManager + .Setup(m => m.GetPasskeysAsync(It.IsAny())) + .Returns((PocoUser user) => Task.FromResult>( + DoesCredentialExistOnUser && user == User ? [storedPasskey] : [])); if (IsUserIdentified) { @@ -1302,13 +1044,56 @@ protected override async Task> RunCoreAsync() .Returns(Task.FromResult(User.Id)); } - var context = new PasskeyAssertionContext + var passkeyOptions = Options.Create(PasskeyOptions); + var handler = new DefaultPasskeyHandler(userManager.Object, passkeyOptions); + + var requestOptionsResult = await handler.MakeRequestOptionsAsync( + IsUserIdentified ? User : null, + httpContext.Object); + + var requestOptions = JsonSerializer.Deserialize( + requestOptionsResult.RequestOptionsJson, + IdentityJsonSerializerContext.Default.PublicKeyCredentialRequestOptions) + ?? throw new InvalidOperationException("Failed to deserialize request options JSON."); + var assertionStateJson = AssertionStateJson.Compute(requestOptionsResult.AssertionState); + var authenticatorDataArgs = AuthenticatorDataArgs.Compute(new() + { + RpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(requestOptions.RpId ?? string.Empty)), + Flags = AuthenticatorDataFlags.UserPresent, + SignCount = 1, + }); + var authenticatorData = AuthenticatorData.Compute(MakeAuthenticatorData(authenticatorDataArgs)); + var clientDataJson = ClientDataJson.Compute($$""" + { + "challenge": {{ToBase64UrlJsonValue(requestOptions.Challenge.AsMemory())}}, + "origin": {{ToJsonValue(Origin)}}, + "type": "webauthn.get" + } + """); + var clientDataJsonBytes = Encoding.UTF8.GetBytes(clientDataJson?.ToString() ?? string.Empty); + var clientDataHash = ClientDataHash.Compute(SHA256.HashData(clientDataJsonBytes)); + var dataToSign = (byte[])[.. authenticatorData.Span, .. clientDataHash.Span]; + var signature = Signature.Compute(credential.SignData(dataToSign)); + var credentialJson = CredentialJson.Compute($$""" + { + "id": {{ToBase64UrlJsonValue(CredentialId)}}, + "response": { + "authenticatorData": {{ToBase64UrlJsonValue(authenticatorData)}}, + "clientDataJSON": {{ToBase64UrlJsonValue(clientDataJson)}}, + "signature": {{ToBase64UrlJsonValue(signature)}}, + "userHandle": {{ToBase64UrlJsonValue(User.Id)}} + }, + "type": "public-key", + "clientExtensionResults": {}, + "authenticatorAttachment": "platform" + } + """); + + var context = new PasskeyAssertionContext { - CredentialJson = credentialJson, - OriginalOptionsJson = originalOptionsJson, + CredentialJson = credentialJson!, + AssertionState = assertionStateJson, HttpContext = httpContext.Object, - UserManager = userManager.Object, - User = IsUserIdentified ? User : null, }; return await handler.PerformAssertionAsync(context); diff --git a/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAttestationTest.cs b/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAttestationTest.cs index bb15253efbcc..24b995b50a61 100644 --- a/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAttestationTest.cs +++ b/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAttestationTest.cs @@ -165,122 +165,54 @@ public async Task Fails_WhenCredentialResponseIsNotAnObject(string jsonValue) } [Fact] - public async Task Fails_WhenOriginalOptionsRpNameIsMissing() + public async Task Fails_WhenAttestationStateUserEntityIdIsMissing() { var test = new AttestationTest(); - test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + test.AttestationStateJson.TransformAsJsonObject(attestationStateJson => { - var rp = originalOptionsJson["rp"]!.AsObject(); - Assert.True(rp.Remove("name")); - }); - - var result = await test.RunAsync(); - - Assert.False(result.Succeeded); - Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); - Assert.Contains("was missing required properties including: 'name'", result.Failure.Message); - } - - [Theory] - [InlineData("42")] - [InlineData("null")] - [InlineData("{}")] - public async Task Fails_WhenOriginalOptionsRpNameIsNotString(string jsonValue) - { - var test = new AttestationTest(); - test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => - { - originalOptionsJson["rp"]!["name"] = JsonNode.Parse(jsonValue); - }); - - var result = await test.RunAsync(); - - Assert.False(result.Succeeded); - Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); - } - - [Fact] - public async Task Fails_WhenOriginalOptionsRpIsMissing() - { - var test = new AttestationTest(); - test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => - { - Assert.True(originalOptionsJson.Remove("rp")); - }); - - var result = await test.RunAsync(); - - Assert.False(result.Succeeded); - Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); - Assert.Contains("was missing required properties including: 'rp'", result.Failure.Message); - } - - [Fact] - public async Task Fails_WhenOriginalOptionsUserIdIsMissing() - { - var test = new AttestationTest(); - test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => - { - var user = originalOptionsJson["user"]!.AsObject(); + var user = attestationStateJson["userEntity"]!.AsObject(); Assert.True(user.Remove("id")); }); var result = await test.RunAsync(); Assert.False(result.Succeeded); - Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + Assert.StartsWith("The attestation state JSON had an invalid format", result.Failure.Message); Assert.Contains("was missing required properties including: 'id'", result.Failure.Message); } - [Fact] - public async Task Fails_WhenOriginalOptionsUserIdIsNotBase64UrlEncoded() - { - var test = new AttestationTest(); - test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => - { - var base64UrlUserId = (string)originalOptionsJson["user"]!["id"]!; - originalOptionsJson["user"]!["id"] = GetInvalidBase64UrlValue(base64UrlUserId); - }); - - var result = await test.RunAsync(); - - Assert.False(result.Succeeded); - Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); - Assert.Contains("base64url string", result.Failure.Message); - } - [Theory] [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Fails_WhenOriginalOptionsUserIdIsNotString(string jsonValue) + public async Task Fails_WhenAttestationStateUserEntityIdIsNotString(string jsonValue) { var test = new AttestationTest(); - test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + test.AttestationStateJson.TransformAsJsonObject(attestationStateJson => { - originalOptionsJson["user"]!["id"] = JsonNode.Parse(jsonValue); + attestationStateJson["userEntity"]!["id"] = JsonNode.Parse(jsonValue); }); var result = await test.RunAsync(); Assert.False(result.Succeeded); - Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + Assert.StartsWith("The attestation state JSON had an invalid format", result.Failure.Message); } [Fact] - public async Task Fails_WhenOriginalOptionsUserNameIsMissing() + public async Task Fails_WhenAttestationStateUserEntityNameIsMissing() { var test = new AttestationTest(); - test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + test.AttestationStateJson.TransformAsJsonObject(attestationStateJson => { - var user = originalOptionsJson["user"]!.AsObject(); + var user = attestationStateJson["userEntity"]!.AsObject(); Assert.True(user.Remove("name")); }); var result = await test.RunAsync(); Assert.False(result.Succeeded); - Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + Assert.StartsWith("The attestation state JSON had an invalid format", result.Failure.Message); Assert.Contains("was missing required properties including: 'name'", result.Failure.Message); } @@ -288,34 +220,34 @@ public async Task Fails_WhenOriginalOptionsUserNameIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Fails_WhenOriginalOptionsUserNameIsNotString(string jsonValue) + public async Task Fails_WhenAttestationStateUserEntityNameIsNotString(string jsonValue) { var test = new AttestationTest(); - test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + test.AttestationStateJson.TransformAsJsonObject(attestationStateJson => { - originalOptionsJson["user"]!["name"] = JsonNode.Parse(jsonValue); + attestationStateJson["userEntity"]!["name"] = JsonNode.Parse(jsonValue); }); var result = await test.RunAsync(); Assert.False(result.Succeeded); - Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + Assert.StartsWith("The attestation state JSON had an invalid format", result.Failure.Message); } [Fact] - public async Task Fails_WhenOriginalOptionsUserDisplayNameIsMissing() + public async Task Fails_WhenAttestationStateUserEntityDisplayNameIsMissing() { var test = new AttestationTest(); - test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + test.AttestationStateJson.TransformAsJsonObject(attestationStateJson => { - var user = originalOptionsJson["user"]!.AsObject(); + var user = attestationStateJson["userEntity"]!.AsObject(); Assert.True(user.Remove("displayName")); }); var result = await test.RunAsync(); Assert.False(result.Succeeded); - Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + Assert.StartsWith("The attestation state JSON had an invalid format", result.Failure.Message); Assert.Contains("was missing required properties including: 'displayName'", result.Failure.Message); } @@ -323,87 +255,68 @@ public async Task Fails_WhenOriginalOptionsUserDisplayNameIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Fails_WhenOriginalOptionsUserDisplayNameIsNotString(string jsonValue) + public async Task Fails_WhenAttestationStateUserEntityDisplayNameIsNotString(string jsonValue) { var test = new AttestationTest(); - test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + test.AttestationStateJson.TransformAsJsonObject(attestationStateJson => { - originalOptionsJson["user"]!["displayName"] = JsonNode.Parse(jsonValue); + attestationStateJson["userEntity"]!["displayName"] = JsonNode.Parse(jsonValue); }); var result = await test.RunAsync(); Assert.False(result.Succeeded); - Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + Assert.StartsWith("The attestation state JSON had an invalid format", result.Failure.Message); } [Fact] - public async Task Fails_WhenOriginalOptionsUserIsMissing() + public async Task Fails_WhenAttestationStateUserEntityIsMissing() { var test = new AttestationTest(); - test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + test.AttestationStateJson.TransformAsJsonObject(attestationStateJson => { - Assert.True(originalOptionsJson.Remove("user")); + Assert.True(attestationStateJson.Remove("userEntity")); }); var result = await test.RunAsync(); Assert.False(result.Succeeded); - Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); - Assert.Contains("was missing required properties including: 'user'", result.Failure.Message); + Assert.StartsWith("The attestation state JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'userEntity'", result.Failure.Message); } [Fact] - public async Task Fails_WhenOriginalOptionsChallengeIsMissing() + public async Task Fails_WhenAttestationStateChallengeIsMissing() { var test = new AttestationTest(); - test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + test.AttestationStateJson.TransformAsJsonObject(attestationStateJson => { - Assert.True(originalOptionsJson.Remove("challenge")); + Assert.True(attestationStateJson.Remove("challenge")); }); var result = await test.RunAsync(); Assert.False(result.Succeeded); - Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + Assert.StartsWith("The attestation state JSON had an invalid format", result.Failure.Message); Assert.Contains("was missing required properties including: 'challenge'", result.Failure.Message); } - [Fact] - public async Task Fails_WhenOriginalOptionsChallengeIsNotBase64UrlEncoded() - { - var test = new AttestationTest(); - - test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => - { - var base64UrlChallenge = (string)originalOptionsJson["challenge"]!; - originalOptionsJson["challenge"] = GetInvalidBase64UrlValue(base64UrlChallenge); - }); - - var result = await test.RunAsync(); - - Assert.False(result.Succeeded); - Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); - Assert.Contains("base64url string", result.Failure.Message); - } - [Theory] [InlineData("42")] - [InlineData("null")] [InlineData("{}")] - public async Task Fails_WhenOriginalOptionsChallengeIsNotString(string jsonValue) + public async Task Fails_WhenAttestationStateChallengeIsNotString(string jsonValue) { var test = new AttestationTest(); - test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + test.AttestationStateJson.TransformAsJsonObject(attestationStateJson => { - originalOptionsJson["challenge"] = JsonNode.Parse(jsonValue); + attestationStateJson["challenge"] = JsonNode.Parse(jsonValue); }); var result = await test.RunAsync(); Assert.False(result.Succeeded); - Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + Assert.StartsWith("The attestation state JSON had an invalid format", result.Failure.Message); } [Fact] @@ -628,15 +541,11 @@ public async Task Fails_WhenClientDataJsonChallengeIsNotBase64UrlEncoded() public async Task Fails_WhenClientDataJsonChallengeIsNotRequestChallenge() { var test = new AttestationTest(); - var modifiedChallenge = (byte[])[.. test.Challenge.Span]; - for (var i = 0; i < modifiedChallenge.Length; i++) - { - modifiedChallenge[i]++; - } - test.ClientDataJson.TransformAsJsonObject(clientDataJson => { - clientDataJson["challenge"] = Base64Url.EncodeToString(modifiedChallenge); + var challenge = Base64Url.DecodeFromChars((string)clientDataJson["challenge"]!); + challenge[0]++; + clientDataJson["challenge"] = Base64Url.EncodeToString(challenge); }); var result = await test.RunAsync(); @@ -798,111 +707,19 @@ public async Task Fails_WhenAuthDataIsNotBackupEligibleButBackedUp() Assert.StartsWith("The credential is backed up, but the authenticator data flags did not have the 'BackupEligible' flag", result.Failure.Message); } - [Theory] - [InlineData(PasskeyOptions.CredentialBackupPolicy.Allowed)] - [InlineData(PasskeyOptions.CredentialBackupPolicy.Required)] - public async Task Succeeds_WhenAuthDataIsBackupEligible(PasskeyOptions.CredentialBackupPolicy backupEligibility) - { - var test = new AttestationTest(); - test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = backupEligibility; - test.AuthenticatorDataArgs.Transform(args => args with - { - Flags = args.Flags | AuthenticatorDataFlags.BackupEligible, - }); - - var result = await test.RunAsync(); - Assert.True(result.Succeeded); - } - [Fact] - public async Task Fails_WhenAuthDataIsBackupEligibleButDisallowed() + public async Task Succeeds_WhenAuthDataIsBackupEligible() { var test = new AttestationTest(); - test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Disallowed; test.AuthenticatorDataArgs.Transform(args => args with { Flags = args.Flags | AuthenticatorDataFlags.BackupEligible, }); var result = await test.RunAsync(); - - Assert.False(result.Succeeded); - Assert.StartsWith( - "Credential backup eligibility is disallowed, but the credential was eligible for backup", - result.Failure.Message); - } - - [Fact] - public async Task Fails_WhenAuthDataIsNotBackupEligibleButRequired() - { - var test = new AttestationTest(); - test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Required; - test.AuthenticatorDataArgs.Transform(args => args with - { - Flags = args.Flags & ~AuthenticatorDataFlags.BackupEligible, - }); - - var result = await test.RunAsync(); - - Assert.False(result.Succeeded); - Assert.StartsWith( - "Credential backup eligibility is required, but the credential was not eligible for backup", - result.Failure.Message); - } - - [Theory] - [InlineData(PasskeyOptions.CredentialBackupPolicy.Allowed)] - [InlineData(PasskeyOptions.CredentialBackupPolicy.Required)] - public async Task Fails_WhenAuthDataIsBackedUp(PasskeyOptions.CredentialBackupPolicy backedUpPolicy) - { - var test = new AttestationTest(); - test.IdentityOptions.Passkey.BackedUpCredentialPolicy = backedUpPolicy; - test.AuthenticatorDataArgs.Transform(args => args with - { - Flags = args.Flags | AuthenticatorDataFlags.BackupEligible | AuthenticatorDataFlags.BackedUp, - }); - - var result = await test.RunAsync(); - Assert.True(result.Succeeded); } - [Fact] - public async Task Fails_WhenAuthDataIsBackedUpButDisallowed() - { - var test = new AttestationTest(); - test.IdentityOptions.Passkey.BackedUpCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Disallowed; - test.AuthenticatorDataArgs.Transform(args => args with - { - Flags = args.Flags | AuthenticatorDataFlags.BackupEligible | AuthenticatorDataFlags.BackedUp, - }); - - var result = await test.RunAsync(); - - Assert.False(result.Succeeded); - Assert.StartsWith( - "Credential backup is disallowed, but the credential was backed up", - result.Failure.Message); - } - - [Fact] - public async Task Fails_WhenAuthDataIsNotBackedUpButRequired() - { - var test = new AttestationTest(); - test.IdentityOptions.Passkey.BackedUpCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Required; - test.AuthenticatorDataArgs.Transform(args => args with - { - Flags = args.Flags & ~AuthenticatorDataFlags.BackedUp, - }); - - var result = await test.RunAsync(); - - Assert.False(result.Succeeded); - Assert.StartsWith( - "Credential backup is required, but the credential was not backed up", - result.Failure.Message); - } - [Fact] public async Task Fails_WhenAttestationObjectIsNotCborEncoded() { @@ -1057,10 +874,7 @@ public async Task Succeeds_WithSupportedAlgorithms(int algorithm) { Algorithm = (COSEAlgorithmIdentifier)algorithm, }; - - // Only include the specific algorithm we're testing, - // just to sanity check that we're using the algorithm we expect - test.SupportedPublicKeyCredentialParameters.Transform(_ => [new((COSEAlgorithmIdentifier)algorithm)]); + test.PasskeyOptions.IsAllowedAlgorithm = alg => alg == algorithm; var result = await test.RunAsync(); @@ -1083,11 +897,7 @@ public async Task Fails_WhenAlgorithmIsNotSupported(int algorithm) { Algorithm = (COSEAlgorithmIdentifier)algorithm, }; - test.SupportedPublicKeyCredentialParameters.Transform(parameters => - { - // Exclude the specific algorithm we're testing, which should cause the failure - return [.. parameters.Where(p => p.Alg != (COSEAlgorithmIdentifier)algorithm)]; - }); + test.PasskeyOptions.IsAllowedAlgorithm = alg => alg != algorithm; var result = await test.RunAsync(); @@ -1098,10 +908,8 @@ public async Task Fails_WhenAlgorithmIsNotSupported(int algorithm) [Fact] public async Task Fails_WhenVerifyAttestationStatementAsyncReturnsFalse() { - var test = new AttestationTest - { - ShouldFailAttestationStatementVerification = true, - }; + var test = new AttestationTest(); + test.PasskeyOptions.VerifyAttestationStatement = context => Task.FromResult(false); var result = await test.RunAsync(); @@ -1166,66 +974,67 @@ private static string GetInvalidBase64UrlValue(string base64UrlValue) private sealed class AttestationTest : PasskeyScenarioTest { - private static readonly byte[] _defaultChallenge = [1, 2, 3, 4, 5, 6, 7, 8]; private static readonly byte[] _defaultCredentialId = [1, 2, 3, 4, 5, 6, 7, 8]; private static readonly byte[] _defaultAaguid = new byte[16]; private static readonly byte[] _defaultAttestationStatement = [0xA0]; // Empty CBOR map - public IdentityOptions IdentityOptions { get; } = new(); - public string? RpId { get; set; } = "example.com"; - public string? RpName { get; set; } = "Example"; + public PasskeyOptions PasskeyOptions { get; } = new(); public string? UserId { get; set; } = "df0a3af4-bd65-440f-82bd-5b839e300dcd"; public string? UserName { get; set; } = "johndoe"; public string? UserDisplayName { get; set; } = "John Doe"; public string? Origin { get; set; } = "https://example.com"; - public bool ShouldFailAttestationStatementVerification { get; set; } public bool DoesCredentialAlreadyExistForAnotherUser { get; set; } public COSEAlgorithmIdentifier Algorithm { get; set; } = COSEAlgorithmIdentifier.ES256; - public ReadOnlyMemory Challenge { get; set; } = _defaultChallenge; public ReadOnlyMemory CredentialId { get; set; } = _defaultCredentialId; - public ComputedValue> SupportedPublicKeyCredentialParameters { get; } = new(); public ComputedValue AttestedCredentialDataArgs { get; } = new(); public ComputedValue AuthenticatorDataArgs { get; } = new(); public ComputedValue AttestationObjectArgs { get; } = new(); public ComputedValue> AttestedCredentialData { get; } = new(); public ComputedValue> AuthenticatorData { get; } = new(); public ComputedValue> AttestationObject { get; } = new(); - public ComputedJsonObject OriginalOptionsJson { get; } = new(); + public ComputedJsonObject AttestationStateJson { get; } = new(); public ComputedJsonObject ClientDataJson { get; } = new(); public ComputedJsonObject CredentialJson { get; } = new(); protected override async Task RunCoreAsync() { - var identityOptions = Options.Create(IdentityOptions); - var handler = new TestPasskeyHandler(identityOptions) + var httpContext = new Mock(); + httpContext.Setup(c => c.Request.Headers.Origin).Returns(new StringValues(Origin)); + + var userManager = MockHelpers.MockUserManager(); + + if (DoesCredentialAlreadyExistForAnotherUser) + { + var existingUser = new PocoUser(userName: "existing_user"); + userManager + .Setup(m => m.FindByPasskeyIdAsync(It.IsAny())) + .Returns((byte[] credentialId) => + { + if (CredentialId.Span.SequenceEqual(credentialId)) + { + return Task.FromResult(existingUser); + } + + return Task.FromResult(null); + }); + } + + var passkeyOptions = Options.Create(PasskeyOptions); + var handler = new DefaultPasskeyHandler(userManager.Object, passkeyOptions); + var userEntity = new PasskeyUserEntity() { - ShouldFailAttestationStatementVerification = ShouldFailAttestationStatementVerification, + Id = UserId!, + Name = UserName!, + DisplayName = UserDisplayName!, }; - var supportedPublicKeyCredentialParameters = SupportedPublicKeyCredentialParameters.Compute( - PublicKeyCredentialParameters.AllSupportedParameters); - var pubKeyCredParamsJson = JsonSerializer.Serialize( - supportedPublicKeyCredentialParameters, - IdentityJsonSerializerContext.Default.IReadOnlyListPublicKeyCredentialParameters); - var originalOptionsJson = OriginalOptionsJson.Compute($$""" - { - "rp": { - "name": {{ToJsonValue(RpName)}}, - "id": {{ToJsonValue(RpId)}} - }, - "user": { - "id": {{ToBase64UrlJsonValue(UserId)}}, - "name": {{ToJsonValue(UserName)}}, - "displayName": {{ToJsonValue(UserDisplayName)}} - }, - "challenge": {{ToBase64UrlJsonValue(Challenge)}}, - "pubKeyCredParams": {{pubKeyCredParamsJson}}, - "timeout": 60000, - "excludeCredentials": [], - "attestation": "none", - "hints": [], - "extensions": {} - } - """); + + var creationOptionsResult = await handler.MakeCreationOptionsAsync(userEntity, httpContext.Object); + var creationOptions = JsonSerializer.Deserialize( + creationOptionsResult.CreationOptionsJson, + IdentityJsonSerializerContext.Default.PublicKeyCredentialCreationOptions) + ?? throw new InvalidOperationException("Failed to deserialize creation options JSON."); + + var attestationState = AttestationStateJson.Compute(creationOptionsResult.AttestationState); var credential = CredentialKeyPair.Generate(Algorithm); var credentialPublicKey = credential.EncodePublicKeyCbor(); var attestedCredentialDataArgs = AttestedCredentialDataArgs.Compute(new() @@ -1238,7 +1047,7 @@ protected override async Task RunCoreAsync() var authenticatorDataArgs = AuthenticatorDataArgs.Compute(new() { SignCount = 1, - RpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(RpId ?? string.Empty)), + RpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(creationOptions.Rp.Id ?? string.Empty)), AttestedCredentialData = attestedCredentialData, Flags = AuthenticatorDataFlags.UserPresent | AuthenticatorDataFlags.HasAttestedCredentialData, }); @@ -1253,7 +1062,7 @@ protected override async Task RunCoreAsync() var attestationObject = AttestationObject.Compute(MakeAttestationObject(attestationObjectArgs)); var clientDataJson = ClientDataJson.Compute($$""" { - "challenge": {{ToBase64UrlJsonValue(Challenge)}}, + "challenge": {{ToBase64UrlJsonValue(creationOptions.Challenge.AsMemory())}}, "origin": {{ToJsonValue(Origin)}}, "type": "webauthn.create" } @@ -1273,58 +1082,14 @@ protected override async Task RunCoreAsync() "authenticatorAttachment": "platform" } """); - - var httpContext = new Mock(); - httpContext.Setup(c => c.Request.Headers.Origin).Returns(new StringValues(Origin)); - - var userManager = MockHelpers.MockUserManager(); - - if (DoesCredentialAlreadyExistForAnotherUser) + var context = new PasskeyAttestationContext { - var existingUser = new PocoUser(userName: "existing_user"); - userManager - .Setup(m => m.FindByPasskeyIdAsync(It.IsAny())) - .Returns((byte[] credentialId) => - { - if (CredentialId.Span.SequenceEqual(credentialId)) - { - return Task.FromResult(existingUser); - } - - return Task.FromResult(null); - }); - } - - var context = new PasskeyAttestationContext - { - CredentialJson = credentialJson, - OriginalOptionsJson = originalOptionsJson, + CredentialJson = credentialJson!, HttpContext = httpContext.Object, - UserManager = userManager.Object, + AttestationState = attestationState, }; return await handler.PerformAttestationAsync(context); } - - private sealed class TestPasskeyHandler(IOptions options) : DefaultPasskeyHandler(options) - { - public bool ShouldFailAttestationStatementVerification { get; init; } - - protected override Task VerifyAttestationStatementAsync( - ReadOnlyMemory attestationObject, - ReadOnlyMemory clientDataHash, - HttpContext httpContext) - { - if (ShouldFailAttestationStatementVerification) - { - return Task.FromResult(false); - } - - return base.VerifyAttestationStatementAsync( - attestationObject, - clientDataHash, - httpContext); - } - } } } diff --git a/src/Identity/test/Identity.Test/Passkeys/PasskeyScenarioTest.cs b/src/Identity/test/Identity.Test/Passkeys/PasskeyScenarioTest.cs index 8f34905bbfbb..fa8d552a2a60 100644 --- a/src/Identity/test/Identity.Test/Passkeys/PasskeyScenarioTest.cs +++ b/src/Identity/test/Identity.Test/Passkeys/PasskeyScenarioTest.cs @@ -74,7 +74,7 @@ public virtual void Transform(Func transform) } } - public sealed class ComputedJsonObject : ComputedValue + public sealed class ComputedJsonObject : ComputedValue { private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { @@ -107,6 +107,11 @@ public void TransformAsJsonObject(Action transform) { try { + if (value is null) + { + throw new InvalidOperationException("Cannot transform a null JSON value."); + } + var jsonObject = JsonNode.Parse(value)?.AsObject() ?? throw new InvalidOperationException("Could not transform the JSON value because it was unexpectedly null."); transform(jsonObject); diff --git a/src/Identity/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index e40efe48fab4..8c98cc127508 100644 --- a/src/Identity/test/Identity.Test/SignInManagerTest.cs +++ b/src/Identity/test/Identity.Test/SignInManagerTest.cs @@ -90,9 +90,9 @@ public async Task CheckPasswordSignInReturnsLockedOutWhenLockedOut() manager.Verify(); } - private static Mock> SetupUserManager(PocoUser user) + private static Mock> SetupUserManager(PocoUser user, IServiceProvider services = null) { - var manager = MockHelpers.MockUserManager(); + var manager = MockHelpers.MockUserManager(services); manager.Setup(m => m.FindByNameAsync(user.UserName)).ReturnsAsync(user); manager.Setup(m => m.FindByIdAsync(user.Id)).ReturnsAsync(user); manager.Setup(m => m.GetUserIdAsync(user)).ReturnsAsync(user.Id.ToString()); @@ -105,8 +105,7 @@ private static SignInManager SetupSignInManager( HttpContext context, ILogger logger = null, IdentityOptions identityOptions = null, - IAuthenticationSchemeProvider schemeProvider = null, - IPasskeyHandler passkeyHandler = null) + IAuthenticationSchemeProvider schemeProvider = null) { var contextAccessor = new Mock(); contextAccessor.Setup(a => a.HttpContext).Returns(context); @@ -116,7 +115,6 @@ private static SignInManager SetupSignInManager( options.Setup(a => a.Value).Returns(identityOptions); var claimsFactory = new UserClaimsPrincipalFactory(manager, roleManager.Object, options.Object); schemeProvider = schemeProvider ?? new MockSchemeProvider(); - passkeyHandler = passkeyHandler ?? Mock.Of>(); var sm = new SignInManager( manager, contextAccessor.Object, @@ -124,8 +122,7 @@ private static SignInManager SetupSignInManager( options.Object, null, schemeProvider, - new DefaultUserConfirmation(), - passkeyHandler); + new DefaultUserConfirmation()); sm.Logger = logger ?? NullLogger>.Instance; return sm; } @@ -365,10 +362,21 @@ public async Task CanPasskeySignIn() var passkey = new UserPasskeyInfo(null, null, null, default, 0, null, false, false, false, null, null); var assertionResult = PasskeyAssertionResult.Success(passkey, user); var passkeyHandler = new Mock>(); + var expectedOptionsJson = ""; + passkeyHandler + .Setup(h => h.MakeRequestOptionsAsync(user, It.IsAny())) + .Returns(Task.FromResult(new PasskeyRequestOptionsResult + { + AssertionState = "", + RequestOptionsJson = expectedOptionsJson, + })); passkeyHandler - .Setup(h => h.PerformAssertionAsync(It.IsAny>())) + .Setup(h => h.PerformAssertionAsync(It.IsAny())) .Returns(Task.FromResult(assertionResult)); - var manager = SetupUserManager(user); + var serviceProvider = new ServiceCollection() + .AddSingleton(passkeyHandler.Object) + .BuildServiceProvider(); + var manager = SetupUserManager(user, serviceProvider); manager .Setup(m => m.SetPasskeyAsync(user, passkey)) .Returns(Task.FromResult(IdentityResult.Success)) @@ -376,19 +384,60 @@ public async Task CanPasskeySignIn() var context = new DefaultHttpContext(); var auth = MockAuth(context); SetupSignIn(context, auth, user.Id, isPersistent: false, loginProvider: null); - var helper = SetupSignInManager(manager.Object, context, passkeyHandler: passkeyHandler.Object); + SetupPasskeyAuth(context, auth); + var helper = SetupSignInManager(manager.Object, context); // Act - var passkeyRequestOptions = new PasskeyRequestOptions(userId: user.Id, ""); - var signInResult = await helper.PasskeySignInAsync(credentialJson: "", passkeyRequestOptions); + var optionsJson = await helper.MakePasskeyRequestOptionsAsync(user); + var signInResult = await helper.PasskeySignInAsync(credentialJson: ""); // Assert + Assert.Equal(expectedOptionsJson, optionsJson); Assert.True(assertionResult.Succeeded); Assert.Same(SignInResult.Success, signInResult); manager.Verify(); auth.Verify(); } + private static void SetupPasskeyAuth(HttpContext context, Mock auth) + { + // Calling AuthenticateAsync will return a failure result + // unless SignInAsync has been called first. + var failedAuthenticateResult = AuthenticateResult.Fail("Not currently signed in."); + var authenticateResult = failedAuthenticateResult; + + auth.Setup(a => a.SignInAsync( + context, + IdentityConstants.TwoFactorUserIdScheme, + It.IsAny(), + It.IsAny())) + .Callback((HttpContext context, string scheme, ClaimsPrincipal claimsPrincipal, AuthenticationProperties authenticationProperties) => + { + var authenticationTicket = new AuthenticationTicket( + claimsPrincipal, + authenticationProperties, + IdentityConstants.TwoFactorUserIdScheme); + authenticateResult = AuthenticateResult.Success(authenticationTicket); + }) + .Returns(Task.CompletedTask) + .Verifiable(); + + auth.Setup(a => a.SignOutAsync( + context, + IdentityConstants.TwoFactorUserIdScheme, + It.IsAny())) + .Callback(() => + { + authenticateResult = failedAuthenticateResult; + }) + .Returns(Task.CompletedTask) + .Verifiable(); + + auth.Setup(a => a.AuthenticateAsync(context, IdentityConstants.TwoFactorUserIdScheme)) + .Returns(() => Task.FromResult(authenticateResult)) + .Verifiable(); + } + private class GoodTokenProvider : AuthenticatorTokenProvider { public override Task ValidateAsync(string purpose, string token, UserManager manager, PocoUser user) @@ -1378,114 +1427,6 @@ public async Task TwoFactorSignInLockedOutResultIsDependentOnTheAccessFailedAsyn auth.Verify(); } - [Fact] - public async Task GeneratePasskeyCreationOptionsAsyncReturnsExpectedOptions() - { - // Arrange - var user = new PocoUser { UserName = "Foo" }; - var userManager = SetupUserManager(user); - var context = new DefaultHttpContext(); - var identityOptions = new IdentityOptions() - { - Passkey = new() - { - ChallengeSize = 32, - Timeout = TimeSpan.FromMinutes(10), - ServerDomain = "example.com", - }, - }; - var signInManager = SetupSignInManager(userManager.Object, context, identityOptions: identityOptions); - var userEntity = new PasskeyUserEntity(id: "1234", name: "Foo", displayName: "Foo"); - var creationArgs = new PasskeyCreationArgs(userEntity) - { - Attestation = "some-attestation-value", - AuthenticatorSelection = new AuthenticatorSelectionCriteria - { - AuthenticatorAttachment = "cross-platform", - ResidentKey = "required", - UserVerification = "preferred" - }, - Extensions = JsonElement.Parse(""" - { - "my.bool.extension": true, - "my.object.extension": { - "key": "value" - } - } - """), - }; - - // Act - var options = await signInManager.GeneratePasskeyCreationOptionsAsync(creationArgs); - var optionsJson = JsonNode.Parse(options.AsJson()).AsObject(); - var challenge = Base64Url.DecodeFromChars(optionsJson["challenge"].ToString()); - - // Assert - Assert.NotNull(options); - Assert.Same(userEntity, options.UserEntity); - Assert.Equal(identityOptions.Passkey.ServerDomain, optionsJson["rp"]["id"].ToString()); - Assert.Equal(identityOptions.Passkey.ServerDomain, optionsJson["rp"]["name"].ToString()); - Assert.Equal(identityOptions.Passkey.ChallengeSize, challenge.Length); - Assert.Equal((uint)identityOptions.Passkey.Timeout.TotalMilliseconds, (uint)optionsJson["timeout"]); - Assert.Equal(creationArgs.Attestation, optionsJson["attestation"].ToString()); - Assert.Equal( - creationArgs.AuthenticatorSelection.AuthenticatorAttachment, - optionsJson["authenticatorSelection"]["authenticatorAttachment"].ToString()); - Assert.Equal( - creationArgs.AuthenticatorSelection.ResidentKey, - optionsJson["authenticatorSelection"]["residentKey"].ToString()); - Assert.Equal( - creationArgs.AuthenticatorSelection.UserVerification, - optionsJson["authenticatorSelection"]["userVerification"].ToString()); - Assert.True((bool)optionsJson["extensions"]["my.bool.extension"]); - Assert.Equal("value", optionsJson["extensions"]["my.object.extension"]["key"].ToString()); - } - - [Fact] - public async Task GeneratePasskeyRequestOptionsAsyncReturnsExpectedOptions() - { - // Arrange - var user = new PocoUser { UserName = "Foo" }; - var userManager = SetupUserManager(user); - var context = new DefaultHttpContext(); - var identityOptions = new IdentityOptions() - { - Passkey = new() - { - ChallengeSize = 32, - Timeout = TimeSpan.FromMinutes(10), - ServerDomain = "example.com", - }, - }; - var signInManager = SetupSignInManager(userManager.Object, context, identityOptions: identityOptions); - var requestArgs = new PasskeyRequestArgs - { - UserVerification = "preferred", - Extensions = JsonElement.Parse(""" - { - "my.bool.extension": true, - "my.object.extension": { - "key": "value" - } - } - """), - }; - - // Act - var options = await signInManager.GeneratePasskeyRequestOptionsAsync(requestArgs); - var optionsJson = JsonNode.Parse(options.AsJson()).AsObject(); - var challenge = Base64Url.DecodeFromChars(optionsJson["challenge"].ToString()); - - // Assert - Assert.NotNull(options); - Assert.Equal(identityOptions.Passkey.ServerDomain, optionsJson["rpId"].ToString()); - Assert.Equal(identityOptions.Passkey.ChallengeSize, challenge.Length); - Assert.Equal((uint)identityOptions.Passkey.Timeout.TotalMilliseconds, (uint)optionsJson["timeout"]); - Assert.Equal(requestArgs.UserVerification, optionsJson["userVerification"].ToString()); - Assert.True((bool)optionsJson["extensions"]["my.bool.extension"]); - Assert.Equal("value", optionsJson["extensions"]["my.object.extension"]["key"].ToString()); - } - private static SignInManager SetupSignInManagerType(UserManager manager, HttpContext context, string typeName) { var contextAccessor = new Mock(); diff --git a/src/Identity/test/Shared/MockHelpers.cs b/src/Identity/test/Shared/MockHelpers.cs index 3e3371083bda..ed5feda6c0d9 100644 --- a/src/Identity/test/Shared/MockHelpers.cs +++ b/src/Identity/test/Shared/MockHelpers.cs @@ -12,10 +12,10 @@ public static class MockHelpers { public static StringBuilder LogMessage = new StringBuilder(); - public static Mock> MockUserManager() where TUser : class + public static Mock> MockUserManager(IServiceProvider services = null) where TUser : class { var store = new Mock>(); - var mgr = new Mock>(store.Object, null, null, null, null, null, null, null, null); + var mgr = new Mock>(store.Object, null, null, null, null, null, null, services, null); mgr.Object.UserValidators.Add(new UserValidator()); mgr.Object.PasswordValidators.Add(new PasswordValidator()); return mgr; diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs index 4a791f890e13..b0a6c5c7c734 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs @@ -62,10 +62,13 @@ public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEn var userId = await userManager.GetUserIdAsync(user); var userName = await userManager.GetUserNameAsync(user) ?? "User"; - var userEntity = new PasskeyUserEntity(userId, userName, displayName: userName); - var passkeyCreationArgs = new PasskeyCreationArgs(userEntity); - var options = await signInManager.ConfigurePasskeyCreationOptionsAsync(passkeyCreationArgs); - return TypedResults.Content(options.AsJson(), contentType: "application/json"); + var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(new() + { + Id = userId, + Name = userName, + DisplayName = userName + }); + return TypedResults.Content(optionsJson, contentType: "application/json"); }); accountGroup.MapPost("/PasskeyRequestOptions", async ( @@ -74,12 +77,8 @@ public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEn [FromQuery] string? username) => { var user = string.IsNullOrEmpty(username) ? null : await userManager.FindByNameAsync(username); - var passkeyRequestArgs = new PasskeyRequestArgs - { - User = user, - }; - var options = await signInManager.ConfigurePasskeyRequestOptionsAsync(passkeyRequestArgs); - return TypedResults.Content(options.AsJson(), contentType: "application/json"); + var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user); + return TypedResults.Content(optionsJson, contentType: "application/json"); }); var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization(); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor index 502bda03638c..9071c0a9b15a 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor @@ -109,14 +109,7 @@ if (!string.IsNullOrEmpty(Input.Passkey?.CredentialJson)) { // When performing passkey sign-in, don't perform form validation. - var options = await SignInManager.RetrievePasskeyRequestOptionsAsync(); - if (options is null) - { - errorMessage = "Error: Could not complete passkey login. Please try again."; - return; - } - - result = await SignInManager.PasskeySignInAsync(Input.Passkey.CredentialJson, options); + result = await SignInManager.PasskeySignInAsync(Input.Passkey.CredentialJson); } else { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor index ae3dcd9ab0d2..4b698ef84385 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor @@ -100,14 +100,7 @@ else return; } - var options = await SignInManager.RetrievePasskeyCreationOptionsAsync(); - if (options is null) - { - RedirectManager.RedirectToCurrentPageWithStatus("Error: Could not retrieve passkey creation options.", HttpContext); - return; - } - - var attestationResult = await SignInManager.PerformPasskeyAttestationAsync(Input.CredentialJson, options); + var attestationResult = await SignInManager.PerformPasskeyAttestationAsync(Input.CredentialJson); if (!attestationResult.Succeeded) { RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add the passkey: {attestationResult.Failure.Message}", HttpContext);