From e3db9981fc9e1e863355b08094c920136a87e075 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 1 Jul 2025 12:52:09 -0400 Subject: [PATCH 01/10] Design updates --- .../Core/src/DefaultPasskeyHandler.cs | 364 ++++++++++------ src/Identity/Core/src/IPasskeyHandler.cs | 33 +- .../Core/src/IdentityJsonSerializerContext.cs | 2 + .../Core/src/PasskeyAssertionContext.cs | 26 +- .../Core/src/PasskeyAttestationContext.cs | 27 +- .../Core/src/PasskeyAttestationResult.cs | 14 +- ...AttestationStatementVerificationContext.cs | 33 ++ src/Identity/Core/src/PasskeyCreationArgs.cs | 46 -- .../Core/src/PasskeyCreationOptions.cs | 45 -- .../Core/src/PasskeyCreationOptionsResult.cs | 28 ++ .../Core/src/PasskeyExceptionExtensions.cs | 39 +- src/Identity/Core/src/PasskeyOptions.cs | 124 ++++++ src/Identity/Core/src/PasskeyOriginInfo.cs | 22 - .../src/PasskeyOriginValidationContext.cs | 41 ++ src/Identity/Core/src/PasskeyRequestArgs.cs | 41 -- .../Core/src/PasskeyRequestOptions.cs | 42 -- .../Core/src/PasskeyRequestOptionsResult.cs | 28 ++ src/Identity/Core/src/PasskeyUserEntity.cs | 11 +- .../AuthenticatorSelectionCriteria.cs | 6 +- .../Core/src/Passkeys/CollectedClientData.cs | 5 + .../Core/src/Passkeys/CredentialPublicKey.cs | 40 ++ .../src/Passkeys/PasskeyAssertionState.cs | 13 + .../src/Passkeys/PasskeyAttestationState.cs | 13 + .../PublicKeyCredentialCreationOptions.cs | 3 +- .../Passkeys/PublicKeyCredentialParameters.cs | 33 +- .../PublicKeyCredentialRequestOptions.cs | 2 +- src/Identity/Core/src/PublicAPI.Unshipped.txt | 171 ++++---- src/Identity/Core/src/SignInManager.cs | 379 +++++----------- .../Extensions.Core/src/IdentityOptions.cs | 8 - .../Extensions.Core/src/PasskeyOptions.cs | 105 ----- .../src/PublicAPI.Unshipped.txt | 24 - src/Identity/Identity.slnf | 15 +- .../Data/PasskeyAssertionState.cs | 10 + .../Data/PasskeyAttestationState.cs | 10 + ...blicKeyCredentialCreationOptionsRequest.cs | 8 +- .../Program.cs | 151 +++++-- .../Components/Pages/Home.razor | 20 +- .../IdentitySample.PasskeyUI/Program.cs | 48 +- .../IdentitySample.PasskeyUI/wwwroot/app.js | 5 - .../test/Identity.Test/IdentityOptionsTest.cs | 6 - .../test/Identity.Test/PasskeyOptionsTest.cs | 23 + .../DefaultPasskeyHandlerAssertionTest.cs | 351 +++------------ .../DefaultPasskeyHandlerAttestationTest.cs | 409 ++++-------------- .../Passkeys/PasskeyScenarioTest.cs | 7 +- .../test/Identity.Test/SignInManagerTest.cs | 163 +++---- ...omponentsEndpointRouteBuilderExtensions.cs | 19 +- .../Components/Account/Pages/Login.razor | 9 +- .../Account/Pages/Manage/Passkeys.razor | 9 +- 48 files changed, 1245 insertions(+), 1786 deletions(-) create mode 100644 src/Identity/Core/src/PasskeyAttestationStatementVerificationContext.cs delete mode 100644 src/Identity/Core/src/PasskeyCreationArgs.cs delete mode 100644 src/Identity/Core/src/PasskeyCreationOptions.cs create mode 100644 src/Identity/Core/src/PasskeyCreationOptionsResult.cs create mode 100644 src/Identity/Core/src/PasskeyOptions.cs delete mode 100644 src/Identity/Core/src/PasskeyOriginInfo.cs create mode 100644 src/Identity/Core/src/PasskeyOriginValidationContext.cs delete mode 100644 src/Identity/Core/src/PasskeyRequestArgs.cs delete mode 100644 src/Identity/Core/src/PasskeyRequestOptions.cs create mode 100644 src/Identity/Core/src/PasskeyRequestOptionsResult.cs rename src/Identity/Core/src/{ => Passkeys}/AuthenticatorSelectionCriteria.cs (91%) create mode 100644 src/Identity/Core/src/Passkeys/PasskeyAssertionState.cs create mode 100644 src/Identity/Core/src/Passkeys/PasskeyAttestationState.cs delete mode 100644 src/Identity/Extensions.Core/src/PasskeyOptions.cs create mode 100644 src/Identity/samples/IdentitySample.PasskeyConformance/Data/PasskeyAssertionState.cs create mode 100644 src/Identity/samples/IdentitySample.PasskeyConformance/Data/PasskeyAttestationState.cs create mode 100644 src/Identity/test/Identity.Test/PasskeyOptionsTest.cs 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 91% rename from src/Identity/Core/src/AuthenticatorSelectionCriteria.cs rename to src/Identity/Core/src/Passkeys/AuthenticatorSelectionCriteria.cs index fd834ad3e516..ed6d8b1fb493 100644 --- a/src/Identity/Core/src/AuthenticatorSelectionCriteria.cs +++ b/src/Identity/Core/src/Passkeys/AuthenticatorSelectionCriteria.cs @@ -1,7 +1,7 @@ // 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; +namespace Microsoft.AspNetCore.Identity.Passkeys; /// /// Used to specify requirements regarding authenticator attributes. @@ -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..85bf1f6dcc47 100644 --- a/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; +using Microsoft.AspNetCore.Identity.Passkeys; namespace Microsoft.AspNetCore.Identity; @@ -56,7 +57,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..0bfb6dcb5ac6 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1,107 +1,112 @@ #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.PasskeyUserEntity.Name.init -> void +Microsoft.AspNetCore.Identity.PasskeyUserEntity.PasskeyUserEntity() -> 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! 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..5e63a8702559 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -4,9 +4,7 @@ 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.Logging; @@ -21,9 +19,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 +29,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 . @@ -465,27 +462,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 +533,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 +576,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 +610,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 +1249,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/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..a04781c77da5 100644 --- a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt @@ -1,35 +1,11 @@ #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.UserPasskeyInfo Microsoft.AspNetCore.Identity.UserPasskeyInfo.AttestationObject.get -> byte[]! 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..abb3f8a358e9 100644 --- a/src/Identity/test/Identity.Test/SignInManagerTest.cs +++ b/src/Identity/test/Identity.Test/SignInManagerTest.cs @@ -365,8 +365,16 @@ 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.PerformAssertionAsync(It.IsAny>())) + .Setup(h => h.MakeRequestOptionsAsync(user, It.IsAny())) + .Returns(Task.FromResult(new PasskeyRequestOptionsResult + { + AssertionState = "", + RequestOptionsJson = expectedOptionsJson, + })); + passkeyHandler + .Setup(h => h.PerformAssertionAsync(It.IsAny())) .Returns(Task.FromResult(assertionResult)); var manager = SetupUserManager(user); manager @@ -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); + SetupPasskeyAuth(context, auth); var helper = SetupSignInManager(manager.Object, context, passkeyHandler: passkeyHandler.Object); // 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/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); From 5c080ace7f08fe8f49657cb843d035295a094ee4 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 1 Jul 2025 16:30:59 -0400 Subject: [PATCH 02/10] Fix namespace --- .../Core/src/Passkeys/AuthenticatorSelectionCriteria.cs | 2 +- .../Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Identity/Core/src/Passkeys/AuthenticatorSelectionCriteria.cs b/src/Identity/Core/src/Passkeys/AuthenticatorSelectionCriteria.cs index ed6d8b1fb493..0247bab57954 100644 --- a/src/Identity/Core/src/Passkeys/AuthenticatorSelectionCriteria.cs +++ b/src/Identity/Core/src/Passkeys/AuthenticatorSelectionCriteria.cs @@ -1,7 +1,7 @@ // 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.Passkeys; +namespace Microsoft.AspNetCore.Identity; /// /// Used to specify requirements regarding authenticator attributes. diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs index 85bf1f6dcc47..5d92df9a796f 100644 --- a/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.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.Passkeys; namespace Microsoft.AspNetCore.Identity; From 362a8f64fcd1c9e8572cee35812ce5c92e2c97ea Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 2 Jul 2025 11:01:08 -0400 Subject: [PATCH 03/10] Remove new SignInManager constructor --- src/Identity/Core/src/PublicAPI.Unshipped.txt | 1 - src/Identity/Core/src/SignInManager.cs | 28 ++----------------- .../src/PublicAPI.Unshipped.txt | 1 + .../Extensions.Core/src/UserManager.cs | 20 +++++++------ .../test/Identity.Test/SignInManagerTest.cs | 18 ++++++------ src/Identity/test/Shared/MockHelpers.cs | 4 +-- 6 files changed, 26 insertions(+), 46 deletions(-) diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 0bfb6dcb5ac6..603bd5e20e68 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -100,7 +100,6 @@ Microsoft.AspNetCore.Identity.PasskeyUserEntity.Id.init -> void Microsoft.AspNetCore.Identity.PasskeyUserEntity.Name.get -> string! Microsoft.AspNetCore.Identity.PasskeyUserEntity.Name.init -> void Microsoft.AspNetCore.Identity.PasskeyUserEntity.PasskeyUserEntity() -> 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 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! diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index 5e63a8702559..5d9e7659b4b5 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -7,6 +7,7 @@ using System.Text; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -60,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>(); } /// diff --git a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt index a04781c77da5..f1fb6dd136f4 100644 --- a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt @@ -7,6 +7,7 @@ Microsoft.AspNetCore.Identity.IUserPasskeyStore.GetPasskeysAsync(TUser! u 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.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/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index abb3f8a358e9..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; } @@ -376,7 +373,10 @@ public async Task CanPasskeySignIn() passkeyHandler .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)) @@ -385,7 +385,7 @@ public async Task CanPasskeySignIn() var auth = MockAuth(context); SetupSignIn(context, auth, user.Id, isPersistent: false, loginProvider: null); SetupPasskeyAuth(context, auth); - var helper = SetupSignInManager(manager.Object, context, passkeyHandler: passkeyHandler.Object); + var helper = SetupSignInManager(manager.Object, context); // Act var optionsJson = await helper.MakePasskeyRequestOptionsAsync(user); 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; From f5ee6b59c724a2af9a80b57b07db2df0a4e6b5ca Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 2 Jul 2025 11:27:23 -0400 Subject: [PATCH 04/10] Add passkey help link --- .../EntityFrameworkCore/src/UserOnlyStore.cs | 3 +- .../EntityFrameworkCore/src/UserStore.cs | 29 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) 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."); + } + } } From c21e32e7e6e2f6e01d4e6850edd092d20fc7ea7b Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 7 Jul 2025 16:25:06 -0400 Subject: [PATCH 05/10] Cleanups --- src/Identity/Core/src/PasskeyOptions.cs | 8 +++++--- src/Identity/Core/src/SignInManager.cs | 4 ++-- .../src/PublicAPI.Unshipped.txt | 4 ++-- .../EntityFrameworkCore/src/UserOnlyStore.cs | 2 +- .../EntityFrameworkCore/src/UserStore.cs | 2 +- .../Extensions.Core/src/IUserPasskeyStore.cs | 2 +- .../Extensions.Core/src/PublicAPI.Unshipped.txt | 4 ++-- src/Identity/Extensions.Core/src/UserManager.cs | 4 ++-- .../InMemoryUserStore.cs | 2 +- .../IdentitySample.PasskeyConformance/Program.cs | 4 ++-- .../Components/Pages/Home.razor | 2 +- .../InMemoryUserStore.cs | 2 +- .../test/Identity.Test/SignInManagerTest.cs | 2 +- .../test/Identity.Test/UserManagerTest.cs | 16 ++++++++-------- .../Account/Pages/Manage/Passkeys.razor | 4 ++-- .../Account/Pages/Manage/RenamePasskey.razor | 2 +- 16 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/Identity/Core/src/PasskeyOptions.cs b/src/Identity/Core/src/PasskeyOptions.cs index 6e123ec3c5fd..98b8d6ec43b3 100644 --- a/src/Identity/Core/src/PasskeyOptions.cs +++ b/src/Identity/Core/src/PasskeyOptions.cs @@ -43,16 +43,17 @@ public class PasskeyOptions /// /// See . /// Possible values are "required", "preferred", and "discouraged". - /// The default value is "preferred". + /// If left , the browser defaults to "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 . + /// Possible values are "discouraged", "preferred", or "required". + /// If left , the browser defaults to "preferred". /// public string? ResidentKeyRequirement { get; set; } @@ -61,7 +62,7 @@ public class PasskeyOptions /// /// /// See . - /// The default value is "none". + /// If left , the browser defaults to "none". /// public string? AttestationConveyancePreference { get; set; } @@ -70,6 +71,7 @@ public class PasskeyOptions /// /// /// See . + /// If left , any authenticator attachment modality is allowed. /// public string? AuthenticatorAttachment { get; set; } diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index 5d9e7659b4b5..50e8a832ed68 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -516,7 +516,7 @@ public virtual async Task PerformPasskeyAttestationAsy /// 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 . + /// using . /// /// The credentials obtained by JSON-serializing the result of the navigator.credentials.get() JavaScript function. /// @@ -577,7 +577,7 @@ public virtual async Task PasskeySignInAsync(string credentialJson // After a successful assertion, we need to update the passkey so that it has the latest // sign count and authenticator data. - var setPasskeyResult = await UserManager.SetPasskeyAsync(assertionResult.User, assertionResult.Passkey); + var setPasskeyResult = await UserManager.AddOrUpdatePasskeyAsync(assertionResult.User, assertionResult.Passkey); if (!setPasskeyResult.Succeeded) { return SignInResult.Failed; diff --git a/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt b/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt index c91fed9a68a2..37d11c313bd9 100644 --- a/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt +++ b/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt @@ -90,6 +90,7 @@ virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.Users.set -> void virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserTokens.get -> Microsoft.EntityFrameworkCore.DbSet! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserTokens.set -> void +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AddOrUpdatePasskeyAsync(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.Context.get -> TContext! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.CreateUserPasskey(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey) -> TUserPasskey! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByPasskeyIdAsync(byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! @@ -98,7 +99,7 @@ virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserPasskeyByIdAsync(byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.GetPasskeysAsync(TUser! user, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.RemovePasskeyAsync(TUser! user, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.SetPasskeyAsync(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddOrUpdatePasskeyAsync(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.Context.get -> TContext! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.CreateUserPasskey(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey) -> TUserPasskey! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByPasskeyIdAsync(byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! @@ -107,7 +108,6 @@ virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserPasskeyByIdAsync(byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetPasskeysAsync(TUser! user, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemovePasskeyAsync(TUser! user, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.SetPasskeyAsync(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! *REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AutoSaveChanges.get -> bool *REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AutoSaveChanges.set -> void *REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.SaveChanges(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! diff --git a/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs b/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs index f798c06f42c6..e59df1c1e06f 100644 --- a/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs @@ -612,7 +612,7 @@ protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo pas /// /// The used to propagate notifications that the operation should be canceled. /// The that represents the asynchronous operation. - public virtual async Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) + public virtual async Task AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); diff --git a/src/Identity/EntityFrameworkCore/src/UserStore.cs b/src/Identity/EntityFrameworkCore/src/UserStore.cs index 4de45e258787..7ebe6760e2d2 100644 --- a/src/Identity/EntityFrameworkCore/src/UserStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserStore.cs @@ -757,7 +757,7 @@ protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo pas /// /// The used to propagate notifications that the operation should be canceled. /// The that represents the asynchronous operation. - public virtual async Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) + public virtual async Task AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); diff --git a/src/Identity/Extensions.Core/src/IUserPasskeyStore.cs b/src/Identity/Extensions.Core/src/IUserPasskeyStore.cs index 0e25bb3c3753..4ab531a19150 100644 --- a/src/Identity/Extensions.Core/src/IUserPasskeyStore.cs +++ b/src/Identity/Extensions.Core/src/IUserPasskeyStore.cs @@ -22,7 +22,7 @@ public interface IUserPasskeyStore : IUserStore where TUser : clas /// The passkey to add. /// The used to propagate notifications that the operation should be canceled. /// The that represents the asynchronous operation. - Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken); + Task AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken); /// /// Gets the passkey credentials for the specified . diff --git a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt index f1fb6dd136f4..24d1250dade4 100644 --- a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt @@ -1,11 +1,11 @@ #nullable enable *REMOVED*Microsoft.AspNetCore.Identity.UserLoginInfo.UserLoginInfo(string! loginProvider, string! providerKey, string? displayName) -> void Microsoft.AspNetCore.Identity.IUserPasskeyStore +Microsoft.AspNetCore.Identity.IUserPasskeyStore.AddOrUpdatePasskeyAsync(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! 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.UserLoginInfo.UserLoginInfo(string! loginProvider, string! providerKey, string? providerDisplayName) -> void Microsoft.AspNetCore.Identity.UserManager.ServiceProvider.get -> System.IServiceProvider! Microsoft.AspNetCore.Identity.UserPasskeyInfo @@ -26,9 +26,9 @@ Microsoft.AspNetCore.Identity.UserPasskeyInfo.SignCount.set -> void Microsoft.AspNetCore.Identity.UserPasskeyInfo.Transports.get -> string![]? Microsoft.AspNetCore.Identity.UserPasskeyInfo.UserPasskeyInfo(byte[]! credentialId, byte[]! publicKey, string? name, System.DateTimeOffset createdAt, uint signCount, string![]? transports, bool isUserVerified, bool isBackupEligible, bool isBackedUp, byte[]! attestationObject, byte[]! clientDataJson) -> void static readonly Microsoft.AspNetCore.Identity.IdentitySchemaVersions.Version3 -> System.Version! +virtual Microsoft.AspNetCore.Identity.UserManager.AddOrUpdatePasskeyAsync(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserManager.FindByPasskeyIdAsync(byte[]! credentialId) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserManager.GetPasskeyAsync(TUser! user, byte[]! credentialId) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserManager.GetPasskeysAsync(TUser! user) -> System.Threading.Tasks.Task!>! virtual Microsoft.AspNetCore.Identity.UserManager.RemovePasskeyAsync(TUser! user, byte[]! credentialId) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.UserManager.SetPasskeyAsync(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserManager.SupportsUserPasskey.get -> bool diff --git a/src/Identity/Extensions.Core/src/UserManager.cs b/src/Identity/Extensions.Core/src/UserManager.cs index aefcf3bd9997..3c96595c13d9 100644 --- a/src/Identity/Extensions.Core/src/UserManager.cs +++ b/src/Identity/Extensions.Core/src/UserManager.cs @@ -2153,14 +2153,14 @@ public virtual Task CountRecoveryCodesAsync(TUser user) /// The user for whom the passkey should be added or updated. /// The passkey to add or update. /// Whether the passkey was successfully set. - public virtual async Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey) + public virtual async Task AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey) { ThrowIfDisposed(); var passkeyStore = GetUserPasskeyStore(); ArgumentNullThrowHelper.ThrowIfNull(user); ArgumentNullThrowHelper.ThrowIfNull(passkey); - await passkeyStore.SetPasskeyAsync(user, passkey, CancellationToken).ConfigureAwait(false); + await passkeyStore.AddOrUpdatePasskeyAsync(user, passkey, CancellationToken).ConfigureAwait(false); return await UpdateUserAsync(user).ConfigureAwait(false); } diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/InMemoryUserStore.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/InMemoryUserStore.cs index 2e5b13750492..595637090c50 100644 --- a/src/Identity/samples/IdentitySample.PasskeyConformance/InMemoryUserStore.cs +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/InMemoryUserStore.cs @@ -70,7 +70,7 @@ public Task UpdateAsync(TUser user, CancellationToken cancellati return Task.FromResult(IdentityResult.Success); } - public Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) + public Task AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) { var passkeyEntity = user.Passkeys.FirstOrDefault(p => p.CredentialId.SequenceEqual(passkey.CredentialId)); if (passkeyEntity is null) diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs index 0c0740c31aeb..b992cdb1e8c5 100644 --- a/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs @@ -124,7 +124,7 @@ } } - await passkeyStore.SetPasskeyAsync(user, attestationResult.Passkey, cancellationToken).ConfigureAwait(false); + await passkeyStore.AddOrUpdatePasskeyAsync(user, attestationResult.Passkey, cancellationToken).ConfigureAwait(false); var updateResult = await userManager.UpdateAsync(user).ConfigureAwait(false); if (!updateResult.Succeeded) { @@ -194,7 +194,7 @@ return Results.BadRequest(new FailedResponse($"Assertion failed: {assertionResult.Failure.Message}")); } - await userManager.SetPasskeyAsync(assertionResult.User, assertionResult.Passkey); + await userManager.AddOrUpdatePasskeyAsync(assertionResult.User, assertionResult.Passkey); return Results.Ok(new OkResponse()); }); diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor index b90e05300fe7..86603f89a268 100644 --- a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor @@ -101,7 +101,7 @@ } } - var setPasskeyResult = await UserManager.SetPasskeyAsync(user, attestationResult.Passkey); + var setPasskeyResult = await UserManager.AddOrUpdatePasskeyAsync(user, attestationResult.Passkey); if (!setPasskeyResult.Succeeded) { statusMessage = "Error: Could not update the user with the new passkey."; diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/InMemoryUserStore.cs b/src/Identity/samples/IdentitySample.PasskeyUI/InMemoryUserStore.cs index cb98946b3436..8772ca095106 100644 --- a/src/Identity/samples/IdentitySample.PasskeyUI/InMemoryUserStore.cs +++ b/src/Identity/samples/IdentitySample.PasskeyUI/InMemoryUserStore.cs @@ -70,7 +70,7 @@ public Task UpdateAsync(TUser user, CancellationToken cancellati return Task.FromResult(IdentityResult.Success); } - public Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) + public Task AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) { var passkeyEntity = user.Passkeys.FirstOrDefault(p => p.CredentialId.SequenceEqual(passkey.CredentialId)); if (passkeyEntity is null) diff --git a/src/Identity/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index 8c98cc127508..e512c2bd874b 100644 --- a/src/Identity/test/Identity.Test/SignInManagerTest.cs +++ b/src/Identity/test/Identity.Test/SignInManagerTest.cs @@ -378,7 +378,7 @@ public async Task CanPasskeySignIn() .BuildServiceProvider(); var manager = SetupUserManager(user, serviceProvider); manager - .Setup(m => m.SetPasskeyAsync(user, passkey)) + .Setup(m => m.AddOrUpdatePasskeyAsync(user, passkey)) .Returns(Task.FromResult(IdentityResult.Success)) .Verifiable(); var context = new DefaultHttpContext(); diff --git a/src/Identity/test/Identity.Test/UserManagerTest.cs b/src/Identity/test/Identity.Test/UserManagerTest.cs index 5dc451f83ba3..116d1f153150 100644 --- a/src/Identity/test/Identity.Test/UserManagerTest.cs +++ b/src/Identity/test/Identity.Test/UserManagerTest.cs @@ -669,18 +669,18 @@ public async Task RemoveClaimCallsStore() } [Fact] - public async Task SetPasskeyAsyncCallsStore() + public async Task AddOrUpdatePasskeyAsyncCallsStore() { // Setup var store = new Mock>(); var user = new PocoUser { UserName = "Foo" }; var passkey = new UserPasskeyInfo(null, null, null, default, 0, null, false, false, false, null, null); - store.Setup(s => s.SetPasskeyAsync(user, passkey, CancellationToken.None)).Returns(Task.CompletedTask).Verifiable(); + store.Setup(s => s.AddOrUpdatePasskeyAsync(user, passkey, CancellationToken.None)).Returns(Task.CompletedTask).Verifiable(); store.Setup(s => s.UpdateAsync(user, CancellationToken.None)).ReturnsAsync(IdentityResult.Success).Verifiable(); var userManager = MockHelpers.TestUserManager(store.Object); // Act - var result = await userManager.SetPasskeyAsync(user, passkey); + var result = await userManager.AddOrUpdatePasskeyAsync(user, passkey); // Assert Assert.True(result.Succeeded); @@ -1115,7 +1115,7 @@ await Assert.ThrowsAsync("providerKey", Assert.Throws("provider", () => manager.RegisterTokenProvider("whatever", null)); await Assert.ThrowsAsync("roles", async () => await manager.AddToRolesAsync(new PocoUser(), null)); await Assert.ThrowsAsync("roles", async () => await manager.RemoveFromRolesAsync(new PocoUser(), null)); - await Assert.ThrowsAsync("passkey", async () => await manager.SetPasskeyAsync(new PocoUser(), null)); + await Assert.ThrowsAsync("passkey", async () => await manager.AddOrUpdatePasskeyAsync(new PocoUser(), null)); await Assert.ThrowsAsync("credentialId", async () => await manager.GetPasskeyAsync(new PocoUser(), null)); await Assert.ThrowsAsync("credentialId", async () => await manager.FindByPasskeyIdAsync(null)); await Assert.ThrowsAsync("credentialId", async () => await manager.RemovePasskeyAsync(new PocoUser(), null)); @@ -1221,7 +1221,7 @@ await Assert.ThrowsAsync("user", await Assert.ThrowsAsync("user", async () => await manager.IsLockedOutAsync(null)); await Assert.ThrowsAsync("user", - async () => await manager.SetPasskeyAsync(null, null)); + async () => await manager.AddOrUpdatePasskeyAsync(null, null)); await Assert.ThrowsAsync("user", async () => await manager.GetPasskeysAsync(null)); await Assert.ThrowsAsync("user", @@ -1267,7 +1267,7 @@ public async Task MethodsThrowWhenDisposedTest() await Assert.ThrowsAsync(() => manager.GenerateEmailConfirmationTokenAsync(null)); await Assert.ThrowsAsync(() => manager.IsEmailConfirmedAsync(null)); await Assert.ThrowsAsync(() => manager.ConfirmEmailAsync(null, null)); - await Assert.ThrowsAsync(() => manager.SetPasskeyAsync(null, null)); + await Assert.ThrowsAsync(() => manager.AddOrUpdatePasskeyAsync(null, null)); await Assert.ThrowsAsync(() => manager.GetPasskeysAsync(null)); await Assert.ThrowsAsync(() => manager.GetPasskeyAsync(null, null)); await Assert.ThrowsAsync(() => manager.FindByPasskeyIdAsync(null)); @@ -1557,7 +1557,7 @@ public void Dispose() return Task.FromResult(0); } - public Task SetPasskeyAsync(PocoUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) + public Task AddOrUpdatePasskeyAsync(PocoUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) { return Task.CompletedTask; } @@ -1854,7 +1854,7 @@ Task IUserStore.DeleteAsync(PocoUser user, Cancellatio throw new NotImplementedException(); } - public Task SetPasskeyAsync(PocoUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) + public Task AddOrUpdatePasskeyAsync(PocoUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) { throw new NotImplementedException(); } 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 4b698ef84385..06f448b0ba9a 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 @@ -107,8 +107,8 @@ else return; } - var setPasskeyResult = await UserManager.SetPasskeyAsync(user, attestationResult.Passkey); - if (!setPasskeyResult.Succeeded) + var addPasskeyResult = await UserManager.AddOrUpdatePasskeyAsync(user, attestationResult.Passkey); + if (!addPasskeyResult.Succeeded) { RedirectManager.RedirectToCurrentPageWithStatus("Error: The passkey could not be added to your account.", HttpContext); return; diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor index e89253cc3a38..3338e0ce32d5 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor @@ -76,7 +76,7 @@ private async Task Rename() { passkey!.Name = Input.Name; - var result = await UserManager.SetPasskeyAsync(user!, passkey); + var result = await UserManager.AddOrUpdatePasskeyAsync(user!, passkey); if (!result.Succeeded) { RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Error: The passkey could not be updated.", HttpContext); From 332a97f93436f1fd05b3642e7b3a096bb73f1245 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 9 Jul 2025 12:44:28 -0400 Subject: [PATCH 06/10] API review feedback --- .../Core/src/DefaultPasskeyHandler.cs | 62 +++++--- ...AttestationStatementVerificationContext.cs | 2 +- src/Identity/Core/src/PasskeyOptions.cs | 132 +++++++++++++----- .../src/PasskeyOriginValidationContext.cs | 2 +- src/Identity/Core/src/PublicAPI.Unshipped.txt | 8 +- .../src/IdentityUserContext.cs | 2 +- .../src/PublicAPI.Unshipped.txt | 4 - .../EntityFrameworkCore/src/UserOnlyStore.cs | 79 ++++++----- .../EntityFrameworkCore/src/UserStore.cs | 77 +++++----- .../src/PublicAPI.Unshipped.txt | 2 +- .../Extensions.Core/src/UserPasskeyInfo.cs | 5 +- .../src/IdentityPasskeyData.cs | 75 ++++++++++ .../src/IdentityUserPasskey.cs | 58 +------- .../src/PublicAPI.Unshipped.txt | 44 +++--- .../InMemoryUserStore.cs | 1 - .../Program.cs | 6 +- .../InMemoryUserStore.cs | 6 +- .../test/Identity.Test/PasskeyOptionsTest.cs | 7 +- .../DefaultPasskeyHandlerAssertionTest.cs | 1 - .../DefaultPasskeyHandlerAttestationTest.cs | 2 +- .../test/Identity.Test/SignInManagerTest.cs | 2 +- .../test/Identity.Test/UserManagerTest.cs | 4 +- ...000000000_CreateIdentitySchema.Designer.cs | 85 ++++++----- .../00000000000000_CreateIdentitySchema.cs | 11 +- .../ApplicationDbContextModelSnapshot.cs | 85 ++++++----- ...000000000_CreateIdentitySchema.Designer.cs | 85 ++++++----- .../00000000000000_CreateIdentitySchema.cs | 14 +- .../ApplicationDbContextModelSnapshot.cs | 85 ++++++----- .../BlazorWeb-CSharp/Data/app.db | Bin 118784 -> 118784 bytes 29 files changed, 556 insertions(+), 390 deletions(-) create mode 100644 src/Identity/Extensions.Stores/src/IdentityPasskeyData.cs diff --git a/src/Identity/Core/src/DefaultPasskeyHandler.cs b/src/Identity/Core/src/DefaultPasskeyHandler.cs index 609bd59afd8b..d222ff04ad62 100644 --- a/src/Identity/Core/src/DefaultPasskeyHandler.cs +++ b/src/Identity/Core/src/DefaultPasskeyHandler.cs @@ -60,7 +60,7 @@ public async Task MakeCreationOptionsAsync(Passkey DisplayName = userEntity.DisplayName, }, Challenge = BufferSource.FromBytes(challenge), - Timeout = (uint)_options.Timeout.TotalMilliseconds, + Timeout = (uint)_options.AuthenticatorTimeout.TotalMilliseconds, ExcludeCredentials = excludeCredentials, PubKeyCredParams = pubKeyCredParams, AuthenticatorSelection = new() @@ -112,13 +112,13 @@ public async Task MakeRequestOptionsAsync(TUser? us ArgumentNullException.ThrowIfNull(httpContext); var allowCredentials = await GetAllowCredentialsAsync().ConfigureAwait(false); - var serverDomain = _options.ServerDomain ?? httpContext.Request.Host.Host; + var serverDomain = GetServerDomain(httpContext); var challenge = RandomNumberGenerator.GetBytes(_options.ChallengeSize); var options = new PublicKeyCredentialRequestOptions { Challenge = BufferSource.FromBytes(challenge), RpId = serverDomain, - Timeout = (uint)_options.Timeout.TotalMilliseconds, + Timeout = (uint)_options.AuthenticatorTimeout.TotalMilliseconds, AllowCredentials = allowCredentials, UserVerification = _options.UserVerificationRequirement, }; @@ -307,15 +307,19 @@ await VerifyClientDataAsync( // 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 _options.VerifyAttestationStatement(new() - { - HttpContext = context.HttpContext, - AttestationObject = attestationObjectMemory, - ClientDataHash = clientDataHash, - }).ConfigureAwait(false); - if (!isAttestationStatementValid) + if (_options.VerifyAttestationStatement is { } verifyAttestationStatement) { - throw PasskeyException.InvalidAttestationStatement(); + var isAttestationStatementValid = await verifyAttestationStatement(new() + { + HttpContext = context.HttpContext, + AttestationObject = attestationObjectMemory, + ClientDataHash = clientDataHash, + }).ConfigureAwait(false); + + if (!isAttestationStatementValid) + { + throw PasskeyException.InvalidAttestationStatement(); + } } // 25. Verify that the credentialId is <= 1023 bytes. @@ -338,7 +342,6 @@ await VerifyClientDataAsync( var credentialRecord = new UserPasskeyInfo( credentialId, publicKey: attestedCredentialData.CredentialPublicKey.ToArray(), - name: null, createdAt: DateTime.UtcNow, signCount: authenticatorData.SignCount, transports: response.Transports, @@ -591,14 +594,7 @@ private async Task VerifyClientDataAsync( } // Verify that the value of C.origin is an origin expected by the Relying Party. - var originInfo = new PasskeyOriginValidationContext - { - HttpContext = httpContext, - Origin = clientData.Origin, - CrossOrigin = clientData.CrossOrigin == true, - TopOrigin = clientData.TopOrigin, - }; - var isOriginValid = await _options.ValidateOrigin(originInfo).ConfigureAwait(false); + var isOriginValid = await ValidateOriginAsync(clientData, httpContext).ConfigureAwait(false); if (!isOriginValid) { throw PasskeyException.InvalidOrigin(clientData.Origin); @@ -652,6 +648,32 @@ private void VerifyAuthenticatorData( } } + private ValueTask ValidateOriginAsync(CollectedClientData clientData, HttpContext httpContext) + { + if (_options.ValidateOrigin is { } validateOrigin) + { + // The user has overridden the default origin validation, + // so we'll use that instead of the default behavior. + return validateOrigin(new PasskeyOriginValidationContext + { + HttpContext = httpContext, + Origin = clientData.Origin, + CrossOrigin = clientData.CrossOrigin == true, + TopOrigin = clientData.TopOrigin, + }); + } + + if (string.IsNullOrEmpty(clientData.Origin) || + clientData.CrossOrigin == true || + !Uri.TryCreate(clientData.Origin, UriKind.Absolute, out var originUri)) + { + return ValueTask.FromResult(false); + } + + // Uri.Equals correctly handles string comparands. + return ValueTask.FromResult(httpContext.Request.Headers.Origin is [var origin] && originUri.Equals(origin)); + } + private string GetServerDomain(HttpContext httpContext) => _options.ServerDomain ?? httpContext.Request.Host.Host; } diff --git a/src/Identity/Core/src/PasskeyAttestationStatementVerificationContext.cs b/src/Identity/Core/src/PasskeyAttestationStatementVerificationContext.cs index b3035dccb6ad..2e986ff71ff0 100644 --- a/src/Identity/Core/src/PasskeyAttestationStatementVerificationContext.cs +++ b/src/Identity/Core/src/PasskeyAttestationStatementVerificationContext.cs @@ -11,7 +11,7 @@ namespace Microsoft.AspNetCore.Identity; /// /// See . /// -public readonly struct PasskeyAttestationStatementVerificationContext +public sealed class PasskeyAttestationStatementVerificationContext { /// /// Gets or sets the for the current request. diff --git a/src/Identity/Core/src/PasskeyOptions.cs b/src/Identity/Core/src/PasskeyOptions.cs index 98b8d6ec43b3..feb450d50a88 100644 --- a/src/Identity/Core/src/PasskeyOptions.cs +++ b/src/Identity/Core/src/PasskeyOptions.cs @@ -9,31 +9,54 @@ namespace Microsoft.AspNetCore.Identity; public class PasskeyOptions { /// - /// Gets or sets the time that the server is willing to wait for a passkey operation to complete. + /// Gets or sets the time that the browser should wait for the authenticator to provide a passkey. /// /// + /// + /// This option applies to both creating a new passkey and requesting an existing passkey. + /// This is treated as a hint to the browser, and the browser may choose to ignore it. + /// + /// /// The default value is 5 minutes. + /// + /// /// See /// and . + /// /// - public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5); + public TimeSpan AuthenticatorTimeout { get; set; } = TimeSpan.FromMinutes(5); /// - /// The size of the challenge in bytes sent to the client during WebAuthn attestation and assertion. + /// Gets or sets the size of the challenge in bytes sent to the client during attestation and assertion. /// /// + /// + /// This option applies to both creating a new passkey and requesting an existing passkey. + /// + /// /// 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. + /// Gets or sets the effective domain of the server. + /// This should be unique and will be used as the identity for the server. /// /// + /// + /// This option applies to both creating a new passkey and requesting an existing passkey. + /// + /// /// If left , the server's origin may be used instead. + /// + /// /// See . + /// /// public string? ServerDomain { get; set; } @@ -41,9 +64,18 @@ public class PasskeyOptions /// Gets or sets the user verification requirement. /// /// - /// See . + /// + /// This option applies to both creating a new passkey and requesting an existing passkey. + /// + /// /// Possible values are "required", "preferred", and "discouraged". + /// + /// /// If left , the browser defaults to "preferred". + /// + /// + /// See . + /// /// public string? UserVerificationRequirement { get; set; } @@ -51,9 +83,18 @@ public class PasskeyOptions /// Gets or sets the extent to which the server desires to create a client-side discoverable credential. /// /// - /// See . + /// + /// This option only applies when creating a new passkey, and is not enforced on the server. + /// + /// /// Possible values are "discouraged", "preferred", or "required". + /// + /// /// If left , the browser defaults to "preferred". + /// + /// + /// See . + /// /// public string? ResidentKeyRequirement { get; set; } @@ -61,17 +102,39 @@ public class PasskeyOptions /// Gets or sets the attestation conveyance preference. /// /// - /// See . + /// + /// This option only applies when creating a new passkey, and already-registered passkeys are not affected by it. + /// To validate the attestation statement of a passkey during passkey creation, provide a value for the + /// option. + /// + /// + /// Possible values are "none", "indirect", "direct", and "enterprise". + /// + /// /// If left , the browser defaults to "none". + /// + /// + /// See . + /// /// public string? AttestationConveyancePreference { get; set; } /// - /// Gets or sets the authenticator attachment. + /// Gets or sets the allowed authenticator attachment. /// /// - /// See . + /// + /// This option only applies when creating a new passkey, and already-registered passkeys are not affected by it. + /// + /// + /// Possible values are "platform" and "cross-platform". + /// + /// /// If left , any authenticator attachment modality is allowed. + /// + /// + /// See . + /// /// public string? AuthenticatorAttachment { get; set; } @@ -80,8 +143,15 @@ public class PasskeyOptions /// is allowed for passkey operations. /// /// - /// If all supported algorithms are allowed. + /// + /// This option only applies when creating a new passkey, and already-registered passkeys are not affected by it. + /// + /// + /// If left , all supported algorithms are allowed. + /// + /// /// See . + /// /// public Func? IsAllowedAlgorithm { get; set; } @@ -89,38 +159,26 @@ public class PasskeyOptions /// 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. + /// + /// This option applies to both creating a new passkey and requesting an existing passkey. + /// + /// + /// If left , cross-origin requests are disallowed, and the request is only + /// considered valid if the request's origin header matches the credential's origin. + /// /// - public Func> ValidateOrigin { get; set; } = DefaultValidateOrigin; + public Func>? ValidateOrigin { get; set; } /// /// 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 . + /// + /// This option only applies when creating a new passkey, and already-registered passkeys are not affected by it. + /// + /// + /// If left , 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); + public Func>? VerifyAttestationStatement { get; set; } } diff --git a/src/Identity/Core/src/PasskeyOriginValidationContext.cs b/src/Identity/Core/src/PasskeyOriginValidationContext.cs index 14bc735282e8..c7580eecdd34 100644 --- a/src/Identity/Core/src/PasskeyOriginValidationContext.cs +++ b/src/Identity/Core/src/PasskeyOriginValidationContext.cs @@ -8,7 +8,7 @@ namespace Microsoft.AspNetCore.Identity; /// /// Contains information used for determining whether a passkey's origin is valid. /// -public readonly struct PasskeyOriginValidationContext +public sealed class PasskeyOriginValidationContext { /// /// Gets or sets the HTTP context associated with the request. diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 603bd5e20e68..0efeca9f129c 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -59,6 +59,8 @@ Microsoft.AspNetCore.Identity.PasskeyOptions.AttestationConveyancePreference.get 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.AuthenticatorTimeout.get -> System.TimeSpan +Microsoft.AspNetCore.Identity.PasskeyOptions.AuthenticatorTimeout.set -> void Microsoft.AspNetCore.Identity.PasskeyOptions.ChallengeSize.get -> int Microsoft.AspNetCore.Identity.PasskeyOptions.ChallengeSize.set -> void Microsoft.AspNetCore.Identity.PasskeyOptions.IsAllowedAlgorithm.get -> System.Func? @@ -68,13 +70,11 @@ Microsoft.AspNetCore.Identity.PasskeyOptions.ResidentKeyRequirement.get -> strin 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.get -> System.Func>? Microsoft.AspNetCore.Identity.PasskeyOptions.ValidateOrigin.set -> void -Microsoft.AspNetCore.Identity.PasskeyOptions.VerifyAttestationStatement.get -> System.Func!>! +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 diff --git a/src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs b/src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs index fa9968d844c4..60a3d5d887d3 100644 --- a/src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs +++ b/src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs @@ -287,7 +287,7 @@ internal virtual void OnModelCreatingVersion3(ModelBuilder builder) b.HasKey(p => p.CredentialId); b.ToTable("AspNetUserPasskeys"); b.Property(p => p.CredentialId).HasMaxLength(1024); // Defined in WebAuthn spec to be no longer than 1023 bytes - b.Property(p => p.PublicKey).HasMaxLength(1024); // Safe upper limit + b.OwnsOne(p => p.Data).ToJson(); }); } diff --git a/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt b/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt index 37d11c313bd9..ce6365507d75 100644 --- a/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt +++ b/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt @@ -95,8 +95,6 @@ virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.CreateUserPasskey(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey) -> TUserPasskey! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByPasskeyIdAsync(byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindPasskeyAsync(TUser! user, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserPasskeyAsync(TKey userId, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserPasskeyByIdAsync(byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.GetPasskeysAsync(TUser! user, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.RemovePasskeyAsync(TUser! user, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddOrUpdatePasskeyAsync(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! @@ -104,8 +102,6 @@ virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.CreateUserPasskey(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey) -> TUserPasskey! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByPasskeyIdAsync(byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindPasskeyAsync(TUser! user, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserPasskeyAsync(TKey userId, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserPasskeyByIdAsync(byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetPasskeysAsync(TUser! user, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemovePasskeyAsync(TUser! user, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! *REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AutoSaveChanges.get -> bool diff --git a/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs b/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs index e59df1c1e06f..732e7ccee9fa 100644 --- a/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs @@ -566,16 +566,19 @@ protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo pas { UserId = user.Id, CredentialId = passkey.CredentialId, - PublicKey = passkey.PublicKey, - Name = passkey.Name, - CreatedAt = passkey.CreatedAt, - Transports = passkey.Transports, - SignCount = passkey.SignCount, - IsUserVerified = passkey.IsUserVerified, - IsBackupEligible = passkey.IsBackupEligible, - IsBackedUp = passkey.IsBackedUp, - AttestationObject = passkey.AttestationObject, - ClientDataJson = passkey.ClientDataJson, + Data = new IdentityPasskeyData() + { + PublicKey = passkey.PublicKey, + Name = passkey.Name, + CreatedAt = passkey.CreatedAt, + Transports = passkey.Transports, + SignCount = passkey.SignCount, + IsUserVerified = passkey.IsUserVerified, + IsBackupEligible = passkey.IsBackupEligible, + IsBackedUp = passkey.IsBackedUp, + AttestationObject = passkey.AttestationObject, + ClientDataJson = passkey.ClientDataJson, + }, }; } @@ -586,7 +589,7 @@ protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo pas /// The credential id to search for. /// The used to propagate notifications that the operation should be canceled. /// The user passkey if it exists. - protected virtual Task FindUserPasskeyAsync(TKey userId, byte[] credentialId, CancellationToken cancellationToken) + private Task FindUserPasskeyAsync(TKey userId, byte[] credentialId, CancellationToken cancellationToken) { return UserPasskeys.SingleOrDefaultAsync( userPasskey => userPasskey.UserId.Equals(userId) && userPasskey.CredentialId.SequenceEqual(credentialId), @@ -599,7 +602,7 @@ protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo pas /// The credential id to search for. /// The used to propagate notifications that the operation should be canceled. /// The user passkey if it exists. - protected virtual Task FindUserPasskeyByIdAsync(byte[] credentialId, CancellationToken cancellationToken) + private Task FindUserPasskeyByIdAsync(byte[] credentialId, CancellationToken cancellationToken) { return UserPasskeys.SingleOrDefaultAsync(userPasskey => userPasskey.CredentialId.SequenceEqual(credentialId), cancellationToken); } @@ -622,10 +625,10 @@ public virtual async Task AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo pa var userPasskey = await FindUserPasskeyByIdAsync(passkey.CredentialId, cancellationToken).ConfigureAwait(false); if (userPasskey != null) { - userPasskey.Name = passkey.Name; - userPasskey.SignCount = passkey.SignCount; - userPasskey.IsBackedUp = passkey.IsBackedUp; - userPasskey.IsUserVerified = passkey.IsUserVerified; + userPasskey.Data.Name = passkey.Name; + userPasskey.Data.SignCount = passkey.SignCount; + userPasskey.Data.IsBackedUp = passkey.IsBackedUp; + userPasskey.Data.IsUserVerified = passkey.IsUserVerified; UserPasskeys.Update(userPasskey); } else @@ -654,16 +657,18 @@ public virtual async Task> GetPasskeysAsync(TUser user, C .Where(p => p.UserId.Equals(userId)) .Select(p => new UserPasskeyInfo( p.CredentialId, - p.PublicKey, - p.Name, - p.CreatedAt, - p.SignCount, - p.Transports, - p.IsUserVerified, - p.IsBackupEligible, - p.IsBackedUp, - p.AttestationObject, - p.ClientDataJson)) + p.Data.PublicKey, + p.Data.CreatedAt, + p.Data.SignCount, + p.Data.Transports, + p.Data.IsUserVerified, + p.Data.IsBackupEligible, + p.Data.IsBackedUp, + p.Data.AttestationObject, + p.Data.ClientDataJson) + { + Name = p.Data.Name, + }) .ToListAsync(cancellationToken) .ConfigureAwait(false); @@ -709,16 +714,18 @@ public virtual async Task> GetPasskeysAsync(TUser user, C { return new UserPasskeyInfo( passkey.CredentialId, - passkey.PublicKey, - passkey.Name, - passkey.CreatedAt, - passkey.SignCount, - passkey.Transports, - passkey.IsUserVerified, - passkey.IsBackupEligible, - passkey.IsBackedUp, - passkey.AttestationObject, - passkey.ClientDataJson); + passkey.Data.PublicKey, + passkey.Data.CreatedAt, + passkey.Data.SignCount, + passkey.Data.Transports, + passkey.Data.IsUserVerified, + passkey.Data.IsBackupEligible, + passkey.Data.IsBackedUp, + passkey.Data.AttestationObject, + passkey.Data.ClientDataJson) + { + Name = passkey.Data.Name, + }; } return null; } diff --git a/src/Identity/EntityFrameworkCore/src/UserStore.cs b/src/Identity/EntityFrameworkCore/src/UserStore.cs index 7ebe6760e2d2..f3347a97259f 100644 --- a/src/Identity/EntityFrameworkCore/src/UserStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserStore.cs @@ -711,16 +711,19 @@ protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo pas { UserId = user.Id, CredentialId = passkey.CredentialId, - PublicKey = passkey.PublicKey, - Name = passkey.Name, - CreatedAt = passkey.CreatedAt, - Transports = passkey.Transports, - SignCount = passkey.SignCount, - IsUserVerified = passkey.IsUserVerified, - IsBackupEligible = passkey.IsBackupEligible, - IsBackedUp = passkey.IsBackedUp, - AttestationObject = passkey.AttestationObject, - ClientDataJson = passkey.ClientDataJson, + Data = new() + { + PublicKey = passkey.PublicKey, + Name = passkey.Name, + CreatedAt = passkey.CreatedAt, + Transports = passkey.Transports, + SignCount = passkey.SignCount, + IsUserVerified = passkey.IsUserVerified, + IsBackupEligible = passkey.IsBackupEligible, + IsBackedUp = passkey.IsBackedUp, + AttestationObject = passkey.AttestationObject, + ClientDataJson = passkey.ClientDataJson, + } }; } @@ -731,7 +734,7 @@ protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo pas /// The credential id to search for. /// The used to propagate notifications that the operation should be canceled. /// The user passkey if it exists. - protected virtual Task FindUserPasskeyAsync(TKey userId, byte[] credentialId, CancellationToken cancellationToken) + private Task FindUserPasskeyAsync(TKey userId, byte[] credentialId, CancellationToken cancellationToken) { return UserPasskeys.SingleOrDefaultAsync( userPasskey => userPasskey.UserId.Equals(userId) && userPasskey.CredentialId.SequenceEqual(credentialId), @@ -744,7 +747,7 @@ protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo pas /// The credential id to search for. /// The used to propagate notifications that the operation should be canceled. /// The user passkey if it exists. - protected virtual Task FindUserPasskeyByIdAsync(byte[] credentialId, CancellationToken cancellationToken) + private Task FindUserPasskeyByIdAsync(byte[] credentialId, CancellationToken cancellationToken) { return UserPasskeys.SingleOrDefaultAsync(userPasskey => userPasskey.CredentialId.SequenceEqual(credentialId), cancellationToken); } @@ -767,9 +770,9 @@ public virtual async Task AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo pa var userPasskey = await FindUserPasskeyByIdAsync(passkey.CredentialId, cancellationToken).ConfigureAwait(false); if (userPasskey != null) { - userPasskey.SignCount = passkey.SignCount; - userPasskey.IsBackedUp = passkey.IsBackedUp; - userPasskey.IsUserVerified = passkey.IsUserVerified; + userPasskey.Data.SignCount = passkey.SignCount; + userPasskey.Data.IsBackedUp = passkey.IsBackedUp; + userPasskey.Data.IsUserVerified = passkey.IsUserVerified; UserPasskeys.Update(userPasskey); } else @@ -798,16 +801,18 @@ public virtual async Task> GetPasskeysAsync(TUser user, C .Where(p => p.UserId.Equals(userId)) .Select(p => new UserPasskeyInfo( p.CredentialId, - p.PublicKey, - p.Name, - p.CreatedAt, - p.SignCount, - p.Transports, - p.IsUserVerified, - p.IsBackupEligible, - p.IsBackedUp, - p.AttestationObject, - p.ClientDataJson)) + p.Data.PublicKey, + p.Data.CreatedAt, + p.Data.SignCount, + p.Data.Transports, + p.Data.IsUserVerified, + p.Data.IsBackupEligible, + p.Data.IsBackedUp, + p.Data.AttestationObject, + p.Data.ClientDataJson) + { + Name = p.Data.Name + }) .ToListAsync(cancellationToken) .ConfigureAwait(false); @@ -853,16 +858,18 @@ public virtual async Task> GetPasskeysAsync(TUser user, C { return new UserPasskeyInfo( passkey.CredentialId, - passkey.PublicKey, - passkey.Name, - passkey.CreatedAt, - passkey.SignCount, - passkey.Transports, - passkey.IsUserVerified, - passkey.IsBackupEligible, - passkey.IsBackedUp, - passkey.AttestationObject, - passkey.ClientDataJson); + passkey.Data.PublicKey, + passkey.Data.CreatedAt, + passkey.Data.SignCount, + passkey.Data.Transports, + passkey.Data.IsUserVerified, + passkey.Data.IsBackupEligible, + passkey.Data.IsBackedUp, + passkey.Data.AttestationObject, + passkey.Data.ClientDataJson) + { + Name = passkey.Data.Name + }; } return null; } diff --git a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt index 24d1250dade4..74c0d8a3a43a 100644 --- a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt @@ -24,7 +24,7 @@ Microsoft.AspNetCore.Identity.UserPasskeyInfo.PublicKey.get -> byte[]! Microsoft.AspNetCore.Identity.UserPasskeyInfo.SignCount.get -> uint Microsoft.AspNetCore.Identity.UserPasskeyInfo.SignCount.set -> void Microsoft.AspNetCore.Identity.UserPasskeyInfo.Transports.get -> string![]? -Microsoft.AspNetCore.Identity.UserPasskeyInfo.UserPasskeyInfo(byte[]! credentialId, byte[]! publicKey, string? name, System.DateTimeOffset createdAt, uint signCount, string![]? transports, bool isUserVerified, bool isBackupEligible, bool isBackedUp, byte[]! attestationObject, byte[]! clientDataJson) -> void +Microsoft.AspNetCore.Identity.UserPasskeyInfo.UserPasskeyInfo(byte[]! credentialId, byte[]! publicKey, System.DateTimeOffset createdAt, uint signCount, string![]? transports, bool isUserVerified, bool isBackupEligible, bool isBackedUp, byte[]! attestationObject, byte[]! clientDataJson) -> void static readonly Microsoft.AspNetCore.Identity.IdentitySchemaVersions.Version3 -> System.Version! virtual Microsoft.AspNetCore.Identity.UserManager.AddOrUpdatePasskeyAsync(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserManager.FindByPasskeyIdAsync(byte[]! credentialId) -> System.Threading.Tasks.Task! diff --git a/src/Identity/Extensions.Core/src/UserPasskeyInfo.cs b/src/Identity/Extensions.Core/src/UserPasskeyInfo.cs index c96335cafba1..9fde211e9390 100644 --- a/src/Identity/Extensions.Core/src/UserPasskeyInfo.cs +++ b/src/Identity/Extensions.Core/src/UserPasskeyInfo.cs @@ -8,14 +8,13 @@ namespace Microsoft.AspNetCore.Identity; /// /// Provides information for a user's passkey credential. /// -public class UserPasskeyInfo +public sealed class UserPasskeyInfo { /// /// Initializes a new instance of . /// /// The credential ID for the passkey. /// The public key for the passkey. - /// The friendly name for the passkey. /// The time when the passkey was created. /// The signature counter for the passkey. /// The passkey's attestation object. @@ -27,7 +26,6 @@ public class UserPasskeyInfo public UserPasskeyInfo( byte[] credentialId, byte[] publicKey, - string? name, DateTimeOffset createdAt, uint signCount, string[]? transports, @@ -39,7 +37,6 @@ public UserPasskeyInfo( { CredentialId = credentialId; PublicKey = publicKey; - Name = name; CreatedAt = createdAt; SignCount = signCount; Transports = transports; diff --git a/src/Identity/Extensions.Stores/src/IdentityPasskeyData.cs b/src/Identity/Extensions.Stores/src/IdentityPasskeyData.cs new file mode 100644 index 000000000000..4fee0dd892d3 --- /dev/null +++ b/src/Identity/Extensions.Stores/src/IdentityPasskeyData.cs @@ -0,0 +1,75 @@ +// 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; + +/// +/// Represents data associated with a passkey. +/// +public class IdentityPasskeyData +{ + /// + /// Gets or sets the public key associated with this passkey. + /// + public virtual byte[] PublicKey { get; set; } = default!; + + /// + /// Gets or sets the friendly name for this passkey. + /// + public virtual string? Name { get; set; } + + /// + /// Gets or sets the time this passkey was created. + /// + public virtual DateTimeOffset CreatedAt { get; set; } + + /// + /// Gets or sets the signature counter for this passkey. + /// + public virtual uint SignCount { get; set; } + + /// + /// Gets or sets the transports supported by this passkey. + /// + /// + /// See . + /// + public virtual string[]? Transports { get; set; } + + /// + /// Gets or sets whether the passkey has a verified user. + /// + public virtual bool IsUserVerified { get; set; } + + /// + /// Gets or sets whether the passkey is eligible for backup. + /// + public virtual bool IsBackupEligible { get; set; } + + /// + /// Gets or sets whether the passkey is currently backed up. + /// + public virtual bool IsBackedUp { get; set; } + + /// + /// Gets or sets the attestation object associated with this passkey. + /// + /// + /// See . + /// + public virtual byte[] AttestationObject { get; set; } = default!; + + /// + /// Gets or sets the collected client data JSON associated with this passkey. + /// + /// + /// See . + /// + public virtual byte[] ClientDataJson { get; set; } = default!; +} diff --git a/src/Identity/Extensions.Stores/src/IdentityUserPasskey.cs b/src/Identity/Extensions.Stores/src/IdentityUserPasskey.cs index d05f940bc79b..dad396fd68f2 100644 --- a/src/Identity/Extensions.Stores/src/IdentityUserPasskey.cs +++ b/src/Identity/Extensions.Stores/src/IdentityUserPasskey.cs @@ -25,61 +25,7 @@ public class IdentityUserPasskey where TKey : IEquatable public virtual byte[] CredentialId { get; set; } = default!; /// - /// Gets or sets the public key associated with this passkey. + /// Gets or sets additional data associated with this passkey. /// - public virtual byte[] PublicKey { get; set; } = default!; - - /// - /// Gets or sets the friendly name for this passkey. - /// - public virtual string? Name { get; set; } - - /// - /// Gets or sets the time this passkey was created. - /// - public virtual DateTimeOffset CreatedAt { get; set; } - - /// - /// Gets or sets the signature counter for this passkey. - /// - public virtual uint SignCount { get; set; } - - /// - /// Gets or sets the transports supported by this passkey. - /// - /// - /// See . - /// - public virtual string[]? Transports { get; set; } - - /// - /// Gets or sets whether the passkey has a verified user. - /// - public virtual bool IsUserVerified { get; set; } - - /// - /// Gets or sets whether the passkey is eligible for backup. - /// - public virtual bool IsBackupEligible { get; set; } - - /// - /// Gets or sets whether the passkey is currently backed up. - /// - public virtual bool IsBackedUp { get; set; } - - /// - /// Gets or sets the attestation object associated with this passkey. - /// - /// - /// See . - /// - public virtual byte[] AttestationObject { get; set; } = default!; - - /// - /// Gets or sets the collected client data JSON associated with this passkey. - /// - /// - /// See . - /// - public virtual byte[] ClientDataJson { get; set; } = default!; + public virtual IdentityPasskeyData Data { get; set; } = default!; } diff --git a/src/Identity/Extensions.Stores/src/PublicAPI.Unshipped.txt b/src/Identity/Extensions.Stores/src/PublicAPI.Unshipped.txt index e6dce7cbb561..6c0240f93f85 100644 --- a/src/Identity/Extensions.Stores/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Stores/src/PublicAPI.Unshipped.txt @@ -1,27 +1,31 @@ #nullable enable +Microsoft.AspNetCore.Identity.IdentityPasskeyData +Microsoft.AspNetCore.Identity.IdentityPasskeyData.IdentityPasskeyData() -> void Microsoft.AspNetCore.Identity.IdentityUserPasskey Microsoft.AspNetCore.Identity.IdentityUserPasskey.IdentityUserPasskey() -> void -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.AttestationObject.get -> byte[]! -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.AttestationObject.set -> void -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.ClientDataJson.get -> byte[]! -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.ClientDataJson.set -> void -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.CreatedAt.get -> System.DateTimeOffset -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.CreatedAt.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.AttestationObject.get -> byte[]! +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.AttestationObject.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.ClientDataJson.get -> byte[]! +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.ClientDataJson.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.CreatedAt.get -> System.DateTimeOffset +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.CreatedAt.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.IsBackedUp.get -> bool +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.IsBackedUp.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.IsBackupEligible.get -> bool +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.IsBackupEligible.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.IsUserVerified.get -> bool +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.IsUserVerified.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.Name.get -> string? +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.Name.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.PublicKey.get -> byte[]! +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.PublicKey.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.SignCount.get -> uint +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.SignCount.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.Transports.get -> string![]? +virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.Transports.set -> void virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.CredentialId.get -> byte[]! virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.CredentialId.set -> void -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.IsBackedUp.get -> bool -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.IsBackedUp.set -> void -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.IsBackupEligible.get -> bool -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.IsBackupEligible.set -> void -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.IsUserVerified.get -> bool -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.IsUserVerified.set -> void -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.Name.get -> string? -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.Name.set -> void -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.PublicKey.get -> byte[]! -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.PublicKey.set -> void -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.SignCount.get -> uint -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.SignCount.set -> void -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.Transports.get -> string![]? -virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.Transports.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.Data.get -> Microsoft.AspNetCore.Identity.IdentityPasskeyData! +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.Data.set -> void virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.UserId.get -> TKey virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.UserId.set -> void diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/InMemoryUserStore.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/InMemoryUserStore.cs index 595637090c50..5f7fccdf9b4f 100644 --- a/src/Identity/samples/IdentitySample.PasskeyConformance/InMemoryUserStore.cs +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/InMemoryUserStore.cs @@ -106,7 +106,6 @@ public Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToke => p is null ? null : new( p.CredentialId, p.PublicKey, - p.Name, p.CreatedAt, p.SignCount, p.Transports, diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs index b992cdb1e8c5..85b18086fde0 100644 --- a/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs @@ -236,14 +236,14 @@ static string ServerPublicKeyCredentialToJson(JsonElement serverPublicKeyCredent return resultJson; } -static Task ValidateOriginAsync(PasskeyOriginValidationContext context) +static ValueTask ValidateOriginAsync(PasskeyOriginValidationContext context) { if (!Uri.TryCreate(context.Origin, UriKind.Absolute, out var uri)) { - return Task.FromResult(false); + return ValueTask.FromResult(false); } - return Task.FromResult(uri.Host == "localhost" && uri.Port == 7020); + return ValueTask.FromResult(uri.Host == "localhost" && uri.Port == 7020); } static IOptions GetPasskeyOptionsFromCreationRequest(ServerPublicKeyCredentialCreationOptionsRequest request) diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/InMemoryUserStore.cs b/src/Identity/samples/IdentitySample.PasskeyUI/InMemoryUserStore.cs index 8772ca095106..b45a7d255a40 100644 --- a/src/Identity/samples/IdentitySample.PasskeyUI/InMemoryUserStore.cs +++ b/src/Identity/samples/IdentitySample.PasskeyUI/InMemoryUserStore.cs @@ -106,7 +106,6 @@ public Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToke => p is null ? null : new( p.CredentialId, p.PublicKey, - p.Name, p.CreatedAt, p.SignCount, p.Transports, @@ -114,7 +113,10 @@ public Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToke p.IsBackupEligible, p.IsBackedUp, p.AttestationObject, - p.ClientDataJson); + p.ClientDataJson) + { + Name = p.Name, + }; [return: NotNullIfNotNull(nameof(p))] private static PocoUserPasskey? ToPocoUserPasskey(TUser user, UserPasskeyInfo? p) diff --git a/src/Identity/test/Identity.Test/PasskeyOptionsTest.cs b/src/Identity/test/Identity.Test/PasskeyOptionsTest.cs index 1bf1a339590e..3219ad8f83b9 100644 --- a/src/Identity/test/Identity.Test/PasskeyOptionsTest.cs +++ b/src/Identity/test/Identity.Test/PasskeyOptionsTest.cs @@ -10,14 +10,15 @@ public void VerifyDefaultOptions() { var options = new PasskeyOptions(); - Assert.Equal(TimeSpan.FromMinutes(5), options.Timeout); + Assert.Equal(TimeSpan.FromMinutes(5), options.AuthenticatorTimeout); 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); + Assert.Null(options.IsAllowedAlgorithm); + Assert.Null(options.ValidateOrigin); + Assert.Null(options.VerifyAttestationStatement); } } diff --git a/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAssertionTest.cs b/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAssertionTest.cs index b7642f8036d5..3e974e9c9714 100644 --- a/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAssertionTest.cs +++ b/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAssertionTest.cs @@ -1009,7 +1009,6 @@ protected override async Task> RunCoreAsync() var storedPasskey = StoredPasskey.Compute(new( CredentialId.ToArray(), credentialPublicKey.ToArray(), - name: null, createdAt: default, signCount: 0, transports: null, diff --git a/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAttestationTest.cs b/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAttestationTest.cs index 24b995b50a61..3535d1114597 100644 --- a/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAttestationTest.cs +++ b/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAttestationTest.cs @@ -909,7 +909,7 @@ public async Task Fails_WhenAlgorithmIsNotSupported(int algorithm) public async Task Fails_WhenVerifyAttestationStatementAsyncReturnsFalse() { var test = new AttestationTest(); - test.PasskeyOptions.VerifyAttestationStatement = context => Task.FromResult(false); + test.PasskeyOptions.VerifyAttestationStatement = context => ValueTask.FromResult(false); var result = await test.RunAsync(); diff --git a/src/Identity/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index e512c2bd874b..9ad842405a27 100644 --- a/src/Identity/test/Identity.Test/SignInManagerTest.cs +++ b/src/Identity/test/Identity.Test/SignInManagerTest.cs @@ -359,7 +359,7 @@ public async Task CanPasskeySignIn() { // Setup var user = new PocoUser { UserName = "Foo" }; - var passkey = new UserPasskeyInfo(null, null, null, default, 0, null, false, false, false, null, null); + var passkey = new UserPasskeyInfo(null, null, default, 0, null, false, false, false, null, null); var assertionResult = PasskeyAssertionResult.Success(passkey, user); var passkeyHandler = new Mock>(); var expectedOptionsJson = ""; diff --git a/src/Identity/test/Identity.Test/UserManagerTest.cs b/src/Identity/test/Identity.Test/UserManagerTest.cs index 116d1f153150..88a02a2d0380 100644 --- a/src/Identity/test/Identity.Test/UserManagerTest.cs +++ b/src/Identity/test/Identity.Test/UserManagerTest.cs @@ -674,7 +674,7 @@ public async Task AddOrUpdatePasskeyAsyncCallsStore() // Setup var store = new Mock>(); var user = new PocoUser { UserName = "Foo" }; - var passkey = new UserPasskeyInfo(null, null, null, default, 0, null, false, false, false, null, null); + var passkey = new UserPasskeyInfo(null, null, default, 0, null, false, false, false, null, null); store.Setup(s => s.AddOrUpdatePasskeyAsync(user, passkey, CancellationToken.None)).Returns(Task.CompletedTask).Verifiable(); store.Setup(s => s.UpdateAsync(user, CancellationToken.None)).ReturnsAsync(IdentityResult.Success).Verifiable(); var userManager = MockHelpers.TestUserManager(store.Object); @@ -693,7 +693,7 @@ public async Task GetPasskeysAsyncCallsStore() // Setup var store = new Mock>(); var user = new PocoUser { UserName = "Foo" }; - var passkey = new UserPasskeyInfo(null, null, null, default, 0, null, false, false, false, null, null); + var passkey = new UserPasskeyInfo(null, null, default, 0, null, false, false, false, null, null); var passkeys = (IList)[passkey]; store.Setup(s => s.GetPasskeysAsync(user, CancellationToken.None)).Returns(Task.FromResult(passkeys)).Verifiable(); var userManager = MockHelpers.TestUserManager(store.Object); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/00000000000000_CreateIdentitySchema.Designer.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/00000000000000_CreateIdentitySchema.Designer.cs index ae1764e8d38c..659ea417e753 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/00000000000000_CreateIdentitySchema.Designer.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/00000000000000_CreateIdentitySchema.Designer.cs @@ -187,40 +187,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(1024) .HasColumnType("BLOB"); - b.Property("AttestationObject") - .IsRequired() - .HasColumnType("BLOB"); - - b.Property("ClientDataJson") - .IsRequired() - .HasColumnType("BLOB"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("IsBackedUp") - .HasColumnType("INTEGER"); - - b.Property("IsBackupEligible") - .HasColumnType("INTEGER"); - - b.Property("IsUserVerified") - .HasColumnType("INTEGER"); - - b.Property("Name") - .HasColumnType("TEXT"); - - b.Property("PublicKey") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("BLOB"); - - b.Property("SignCount") - .HasColumnType("INTEGER"); - - b.PrimitiveCollection("Transports") - .HasColumnType("TEXT"); - b.Property("UserId") .IsRequired() .HasColumnType("TEXT"); @@ -302,6 +268,57 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + + b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => + { + b1.Property("IdentityUserPasskeyCredentialId") + .HasColumnType("BLOB"); + + b1.Property("AttestationObject") + .IsRequired() + .HasColumnType("BLOB"); + + b1.Property("ClientDataJson") + .IsRequired() + .HasColumnType("BLOB"); + + b1.Property("CreatedAt") + .HasColumnType("TEXT"); + + b1.Property("IsBackedUp") + .HasColumnType("INTEGER"); + + b1.Property("IsBackupEligible") + .HasColumnType("INTEGER"); + + b1.Property("IsUserVerified") + .HasColumnType("INTEGER"); + + b1.Property("Name") + .HasColumnType("TEXT"); + + b1.Property("PublicKey") + .IsRequired() + .HasColumnType("BLOB"); + + b1.Property("SignCount") + .HasColumnType("INTEGER"); + + b1.PrimitiveCollection("Transports") + .HasColumnType("TEXT"); + + b1.HasKey("IdentityUserPasskeyCredentialId"); + + b1.ToTable("AspNetUserPasskeys"); + + b1.ToJson("Data"); + + b1.WithOwner() + .HasForeignKey("IdentityUserPasskeyCredentialId"); + }); + + b.Navigation("Data") + .IsRequired(); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/00000000000000_CreateIdentitySchema.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/00000000000000_CreateIdentitySchema.cs index 92e0a0ea2cc2..b8e73dede86f 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/00000000000000_CreateIdentitySchema.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/00000000000000_CreateIdentitySchema.cs @@ -118,16 +118,7 @@ protected override void Up(MigrationBuilder migrationBuilder) { CredentialId = table.Column(type: "BLOB", maxLength: 1024, nullable: false), UserId = table.Column(type: "TEXT", nullable: false), - PublicKey = table.Column(type: "BLOB", maxLength: 1024, nullable: false), - Name = table.Column(type: "TEXT", nullable: true), - CreatedAt = table.Column(type: "TEXT", nullable: false), - SignCount = table.Column(type: "INTEGER", nullable: false), - Transports = table.Column(type: "TEXT", nullable: true), - IsUserVerified = table.Column(type: "INTEGER", nullable: false), - IsBackupEligible = table.Column(type: "INTEGER", nullable: false), - IsBackedUp = table.Column(type: "INTEGER", nullable: false), - AttestationObject = table.Column(type: "BLOB", nullable: false), - ClientDataJson = table.Column(type: "BLOB", nullable: false) + Data = table.Column(type: "TEXT", nullable: false) }, constraints: table => { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/ApplicationDbContextModelSnapshot.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/ApplicationDbContextModelSnapshot.cs index f6bdeaf144ec..005717115ac3 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/ApplicationDbContextModelSnapshot.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/ApplicationDbContextModelSnapshot.cs @@ -184,40 +184,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(1024) .HasColumnType("BLOB"); - b.Property("AttestationObject") - .IsRequired() - .HasColumnType("BLOB"); - - b.Property("ClientDataJson") - .IsRequired() - .HasColumnType("BLOB"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("IsBackedUp") - .HasColumnType("INTEGER"); - - b.Property("IsBackupEligible") - .HasColumnType("INTEGER"); - - b.Property("IsUserVerified") - .HasColumnType("INTEGER"); - - b.Property("Name") - .HasColumnType("TEXT"); - - b.Property("PublicKey") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("BLOB"); - - b.Property("SignCount") - .HasColumnType("INTEGER"); - - b.PrimitiveCollection("Transports") - .HasColumnType("TEXT"); - b.Property("UserId") .IsRequired() .HasColumnType("TEXT"); @@ -299,6 +265,57 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + + b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => + { + b1.Property("IdentityUserPasskeyCredentialId") + .HasColumnType("BLOB"); + + b1.Property("AttestationObject") + .IsRequired() + .HasColumnType("BLOB"); + + b1.Property("ClientDataJson") + .IsRequired() + .HasColumnType("BLOB"); + + b1.Property("CreatedAt") + .HasColumnType("TEXT"); + + b1.Property("IsBackedUp") + .HasColumnType("INTEGER"); + + b1.Property("IsBackupEligible") + .HasColumnType("INTEGER"); + + b1.Property("IsUserVerified") + .HasColumnType("INTEGER"); + + b1.Property("Name") + .HasColumnType("TEXT"); + + b1.Property("PublicKey") + .IsRequired() + .HasColumnType("BLOB"); + + b1.Property("SignCount") + .HasColumnType("INTEGER"); + + b1.PrimitiveCollection("Transports") + .HasColumnType("TEXT"); + + b1.HasKey("IdentityUserPasskeyCredentialId"); + + b1.ToTable("AspNetUserPasskeys"); + + b1.ToJson("Data"); + + b1.WithOwner() + .HasForeignKey("IdentityUserPasskeyCredentialId"); + }); + + b.Navigation("Data") + .IsRequired(); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/00000000000000_CreateIdentitySchema.Designer.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/00000000000000_CreateIdentitySchema.Designer.cs index c92d84f26d83..19e1603ce9bb 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/00000000000000_CreateIdentitySchema.Designer.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/00000000000000_CreateIdentitySchema.Designer.cs @@ -198,40 +198,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(1024) .HasColumnType("varbinary(1024)"); - b.Property("AttestationObject") - .IsRequired() - .HasColumnType("varbinary(max)"); - - b.Property("ClientDataJson") - .IsRequired() - .HasColumnType("varbinary(max)"); - - b.Property("CreatedAt") - .HasColumnType("datetimeoffset"); - - b.Property("IsBackedUp") - .HasColumnType("bit"); - - b.Property("IsBackupEligible") - .HasColumnType("bit"); - - b.Property("IsUserVerified") - .HasColumnType("bit"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.Property("PublicKey") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("varbinary(1024)"); - - b.Property("SignCount") - .HasColumnType("bigint"); - - b.PrimitiveCollection("Transports") - .HasColumnType("nvarchar(max)"); - b.Property("UserId") .IsRequired() .HasColumnType("nvarchar(450)"); @@ -313,6 +279,57 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + + b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => + { + b1.Property("IdentityUserPasskeyCredentialId") + .HasColumnType("varbinary(1024)"); + + b1.Property("AttestationObject") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b1.Property("ClientDataJson") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b1.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b1.Property("IsBackedUp") + .HasColumnType("bit"); + + b1.Property("IsBackupEligible") + .HasColumnType("bit"); + + b1.Property("IsUserVerified") + .HasColumnType("bit"); + + b1.Property("Name") + .HasColumnType("nvarchar(max)"); + + b1.Property("PublicKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b1.Property("SignCount") + .HasColumnType("bigint"); + + b1.PrimitiveCollection("Transports") + .HasColumnType("nvarchar(max)"); + + b1.HasKey("IdentityUserPasskeyCredentialId"); + + b1.ToTable("AspNetUserPasskeys"); + + b1.ToJson("Data"); + + b1.WithOwner() + .HasForeignKey("IdentityUserPasskeyCredentialId"); + }); + + b.Navigation("Data") + .IsRequired(); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/00000000000000_CreateIdentitySchema.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/00000000000000_CreateIdentitySchema.cs index 8d26035044d5..aa1117d1b0db 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/00000000000000_CreateIdentitySchema.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/00000000000000_CreateIdentitySchema.cs @@ -118,16 +118,7 @@ protected override void Up(MigrationBuilder migrationBuilder) { CredentialId = table.Column(type: "varbinary(1024)", maxLength: 1024, nullable: false), UserId = table.Column(type: "nvarchar(450)", nullable: false), - PublicKey = table.Column(type: "varbinary(1024)", maxLength: 1024, nullable: false), - Name = table.Column(type: "nvarchar(max)", nullable: true), - CreatedAt = table.Column(type: "datetimeoffset", nullable: false), - SignCount = table.Column(type: "bigint", nullable: false), - Transports = table.Column(type: "nvarchar(max)", nullable: true), - IsUserVerified = table.Column(type: "bit", nullable: false), - IsBackupEligible = table.Column(type: "bit", nullable: false), - IsBackedUp = table.Column(type: "bit", nullable: false), - AttestationObject = table.Column(type: "varbinary(max)", nullable: false), - ClientDataJson = table.Column(type: "varbinary(max)", nullable: false) + Data = table.Column(type: "nvarchar(max)", nullable: false) }, constraints: table => { @@ -241,6 +232,9 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "AspNetUserLogins"); + migrationBuilder.DropTable( + name: "AspNetUserPasskeys"); + migrationBuilder.DropTable( name: "AspNetUserRoles"); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/ApplicationDbContextModelSnapshot.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/ApplicationDbContextModelSnapshot.cs index 3f5e8fe8be78..6e9366f22db7 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/ApplicationDbContextModelSnapshot.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/ApplicationDbContextModelSnapshot.cs @@ -195,40 +195,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(1024) .HasColumnType("varbinary(1024)"); - b.Property("AttestationObject") - .IsRequired() - .HasColumnType("varbinary(max)"); - - b.Property("ClientDataJson") - .IsRequired() - .HasColumnType("varbinary(max)"); - - b.Property("CreatedAt") - .HasColumnType("datetimeoffset"); - - b.Property("IsBackedUp") - .HasColumnType("bit"); - - b.Property("IsBackupEligible") - .HasColumnType("bit"); - - b.Property("IsUserVerified") - .HasColumnType("bit"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.Property("PublicKey") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("varbinary(1024)"); - - b.Property("SignCount") - .HasColumnType("bigint"); - - b.PrimitiveCollection("Transports") - .HasColumnType("nvarchar(max)"); - b.Property("UserId") .IsRequired() .HasColumnType("nvarchar(450)"); @@ -310,6 +276,57 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + + b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => + { + b1.Property("IdentityUserPasskeyCredentialId") + .HasColumnType("varbinary(1024)"); + + b1.Property("AttestationObject") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b1.Property("ClientDataJson") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b1.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b1.Property("IsBackedUp") + .HasColumnType("bit"); + + b1.Property("IsBackupEligible") + .HasColumnType("bit"); + + b1.Property("IsUserVerified") + .HasColumnType("bit"); + + b1.Property("Name") + .HasColumnType("nvarchar(max)"); + + b1.Property("PublicKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b1.Property("SignCount") + .HasColumnType("bigint"); + + b1.PrimitiveCollection("Transports") + .HasColumnType("nvarchar(max)"); + + b1.HasKey("IdentityUserPasskeyCredentialId"); + + b1.ToTable("AspNetUserPasskeys"); + + b1.ToJson("Data"); + + b1.WithOwner() + .HasForeignKey("IdentityUserPasskeyCredentialId"); + }); + + b.Navigation("Data") + .IsRequired(); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/app.db b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/app.db index 74d0af218938386bbd548461afe84706dcda3179..a8da6a470d4d80fb0b6ffd0cf22245f0ac1b407c 100644 GIT binary patch delta 450 zcmZozz}~QceL@mnn7{ZN|eaE{P?H(;L(o#Wvfs?C0hY z;o=1uD!{Aj3!^ySK&6-Gcq+cHqbLPFy3Tv7mE_nO%99~7Bf01OlK5elt=Tz zb*x@s;WuG^FkL{C(Hgfuw%dy^9^zI9d4gpE1D`J+56=>wc&;ukZ;n+Qne0#5%h^7& z<+DC!O<`qXnXp+6hd4hAewY|xfFmv$+I}LIJGD&wJ0+! zb8;fLjDTmcQ(|&3mQj%I+l30?NpXZ;Hm6}|F z<|3Hk&N-Q>c_l81C5c|e`FRjUoAp`FbMpvs@q)ZAz`(!{^s>VRegzdqW(I#F10z#i z15;fia|Hu4D+5C-LlZqi17kA-W0TFlMS-H+{L0q zbdv+)h3Sk2jPl5#ylGg6@^*a_#!KAloGc6i%$^Kyqnn*nCKLlw@+|kyvaO$l?S6fqsa7HKSqh|pF9{N8JX;brt|qRs!i5r pl;9KvMj8hw=C%WqXe84xO{0_bStf4h@nihLxX3|aQGfte1OQ}<7W4oB From abc664f97e8ee9f062dd5b74fe76fb35c766b2a7 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 10 Jul 2025 13:42:09 -0400 Subject: [PATCH 07/10] Test fixes --- .../test/EF.Test/VersionThreeSchemaTest.cs | 4 +--- src/Identity/test/Shared/MockHelpers.cs | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Identity/EntityFrameworkCore/test/EF.Test/VersionThreeSchemaTest.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/VersionThreeSchemaTest.cs index bb5814150f9d..6df5064bf8cc 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.Test/VersionThreeSchemaTest.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/VersionThreeSchemaTest.cs @@ -58,9 +58,7 @@ internal static void VerifyVersion3Schema(DbContext dbContext) Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserClaims", "Id", "UserId", "ClaimType", "ClaimValue")); Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserLogins", "UserId", "ProviderKey", "LoginProvider", "ProviderDisplayName")); Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserTokens", "UserId", "LoginProvider", "Name", "Value")); - Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserPasskeys", "UserId", "CredentialId", "PublicKey", "Name", "CreatedAt", - "SignCount", "Transports", "IsUserVerified", "IsBackupEligible", "IsBackedUp", "AttestationObject", - "ClientDataJson")); + Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserPasskeys", "UserId", "CredentialId", "Data")); Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetUsers", 256, "UserName", "Email", "NormalizedUserName", "NormalizedEmail", "PhoneNumber")); Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetRoles", 256, "Name", "NormalizedName")); diff --git a/src/Identity/test/Shared/MockHelpers.cs b/src/Identity/test/Shared/MockHelpers.cs index 01b2282f7d37..f6d374c6769e 100644 --- a/src/Identity/test/Shared/MockHelpers.cs +++ b/src/Identity/test/Shared/MockHelpers.cs @@ -31,7 +31,7 @@ public static Mock> MockUserManager( } var store = new Mock>(); - var mgr = new Mock>(store.Object, null, null, null, null, null, null, services, null); + var mgr = new Mock>(store.Object, null, null, null, null, null, null, services.BuildServiceProvider(), null); mgr.Object.UserValidators.Add(new UserValidator()); mgr.Object.PasswordValidators.Add(new PasswordValidator()); return mgr; From 6e62bc26b6795dc26e8ba2e7b98cc2f005967eee Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 10 Jul 2025 13:47:51 -0400 Subject: [PATCH 08/10] DefaultPasskeyHandler -> PasskeyHandler --- src/Identity/Core/src/IdentityBuilderExtensions.cs | 2 +- .../Core/src/IdentityServiceCollectionExtensions.cs | 2 +- .../{DefaultPasskeyHandler.cs => PasskeyHandler.cs} | 6 +++--- src/Identity/Core/src/PublicAPI.Unshipped.txt | 12 ++++++------ .../IdentitySample.PasskeyConformance/Program.cs | 8 ++++---- ...sertionTest.cs => PasskeyHandlerAssertionTest.cs} | 4 ++-- ...ationTest.cs => PasskeyHandlerAttestationTest.cs} | 4 ++-- 7 files changed, 19 insertions(+), 19 deletions(-) rename src/Identity/Core/src/{DefaultPasskeyHandler.cs => PasskeyHandler.cs} (99%) rename src/Identity/test/Identity.Test/Passkeys/{DefaultPasskeyHandlerAssertionTest.cs => PasskeyHandlerAssertionTest.cs} (99%) rename src/Identity/test/Identity.Test/Passkeys/{DefaultPasskeyHandlerAttestationTest.cs => PasskeyHandlerAttestationTest.cs} (99%) diff --git a/src/Identity/Core/src/IdentityBuilderExtensions.cs b/src/Identity/Core/src/IdentityBuilderExtensions.cs index afa44b51e528..e97fec9ad6ac 100644 --- a/src/Identity/Core/src/IdentityBuilderExtensions.cs +++ b/src/Identity/Core/src/IdentityBuilderExtensions.cs @@ -41,7 +41,7 @@ public static IdentityBuilder AddDefaultTokenProviders(this IdentityBuilder buil private static void AddSignInManagerDeps(this IdentityBuilder builder) { builder.Services.AddHttpContextAccessor(); - builder.Services.AddScoped(typeof(IPasskeyHandler<>).MakeGenericType(builder.UserType), typeof(DefaultPasskeyHandler<>).MakeGenericType(builder.UserType)); + builder.Services.AddScoped(typeof(IPasskeyHandler<>).MakeGenericType(builder.UserType), typeof(PasskeyHandler<>).MakeGenericType(builder.UserType)); builder.Services.AddScoped(typeof(ISecurityStampValidator), typeof(SecurityStampValidator<>).MakeGenericType(builder.UserType)); builder.Services.AddScoped(typeof(ITwoFactorSecurityStampValidator), typeof(TwoFactorSecurityStampValidator<>).MakeGenericType(builder.UserType)); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, PostConfigureSecurityStampValidatorOptions>()); diff --git a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs index 43f81cccfbbb..eceb93d32034 100644 --- a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs +++ b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs @@ -102,7 +102,7 @@ public static class IdentityServiceCollectionExtensions services.TryAddScoped>(); services.TryAddScoped, UserClaimsPrincipalFactory>(); services.TryAddScoped, DefaultUserConfirmation>(); - services.TryAddScoped, DefaultPasskeyHandler>(); + services.TryAddScoped, PasskeyHandler>(); services.TryAddScoped>(); services.TryAddScoped>(); services.TryAddScoped>(); diff --git a/src/Identity/Core/src/DefaultPasskeyHandler.cs b/src/Identity/Core/src/PasskeyHandler.cs similarity index 99% rename from src/Identity/Core/src/DefaultPasskeyHandler.cs rename to src/Identity/Core/src/PasskeyHandler.cs index d222ff04ad62..d243b56c0d5b 100644 --- a/src/Identity/Core/src/DefaultPasskeyHandler.cs +++ b/src/Identity/Core/src/PasskeyHandler.cs @@ -14,18 +14,18 @@ namespace Microsoft.AspNetCore.Identity; /// /// The default passkey handler. /// -public sealed class DefaultPasskeyHandler : IPasskeyHandler +public sealed class PasskeyHandler : IPasskeyHandler where TUser : class { private readonly UserManager _userManager; private readonly PasskeyOptions _options; /// - /// Constructs a new instance. + /// Constructs a new instance. /// /// The . /// The . - public DefaultPasskeyHandler(UserManager userManager, IOptions options) + public PasskeyHandler(UserManager userManager, IOptions options) { ArgumentNullException.ThrowIfNull(userManager); ArgumentNullException.ThrowIfNull(options); diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 0efeca9f129c..269b4d0273a3 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1,10 +1,4 @@ #nullable enable -Microsoft.AspNetCore.Identity.DefaultPasskeyHandler -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.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! @@ -54,6 +48,12 @@ Microsoft.AspNetCore.Identity.PasskeyCreationOptionsResult.PasskeyCreationOption 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.PasskeyHandler +Microsoft.AspNetCore.Identity.PasskeyHandler.MakeCreationOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.PasskeyHandler.MakeRequestOptionsAsync(TUser? user, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.PasskeyHandler.PasskeyHandler(Microsoft.AspNetCore.Identity.UserManager! userManager, Microsoft.Extensions.Options.IOptions! options) -> void +Microsoft.AspNetCore.Identity.PasskeyHandler.PerformAssertionAsync(Microsoft.AspNetCore.Identity.PasskeyAssertionContext! context) -> System.Threading.Tasks.Task!>! +Microsoft.AspNetCore.Identity.PasskeyHandler.PerformAttestationAsync(Microsoft.AspNetCore.Identity.PasskeyAttestationContext! context) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Identity.PasskeyOptions Microsoft.AspNetCore.Identity.PasskeyOptions.AttestationConveyancePreference.get -> string? Microsoft.AspNetCore.Identity.PasskeyOptions.AttestationConveyancePreference.set -> void diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs index 85b18086fde0..cd34c12735a5 100644 --- a/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs @@ -60,7 +60,7 @@ Name = request.Username, DisplayName = request.DisplayName }; - var passkeyHandler = new DefaultPasskeyHandler(userManager, passkeyOptions); + var passkeyHandler = new PasskeyHandler(userManager, passkeyOptions); var result = await passkeyHandler.MakeCreationOptionsAsync(userEntity, context); var response = new ServerPublicKeyCredentialOptionsResponse(result.CreationOptionsJson); var state = new PasskeyAttestationState @@ -94,7 +94,7 @@ } var passkeyOptions = GetPasskeyOptionsFromCreationRequest(state.Request); - var passkeyHandler = new DefaultPasskeyHandler(userManager, passkeyOptions); + var passkeyHandler = new PasskeyHandler(userManager, passkeyOptions); var attestationResult = await passkeyHandler.PerformAttestationAsync(new() { @@ -148,7 +148,7 @@ } var passkeyOptions = GetPasskeyOptionsFromGetRequest(request); - var passkeyHandler = new DefaultPasskeyHandler(userManager, passkeyOptions); + var passkeyHandler = new PasskeyHandler(userManager, passkeyOptions); var result = await passkeyHandler.MakeRequestOptionsAsync(user, context); var response = new ServerPublicKeyCredentialOptionsResponse(result.RequestOptionsJson); @@ -181,7 +181,7 @@ } var passkeyOptions = GetPasskeyOptionsFromGetRequest(state.Request); - var passkeyHandler = new DefaultPasskeyHandler(userManager, passkeyOptions); + var passkeyHandler = new PasskeyHandler(userManager, passkeyOptions); var assertionResult = await passkeyHandler.PerformAssertionAsync(new() { diff --git a/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAssertionTest.cs b/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerAssertionTest.cs similarity index 99% rename from src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAssertionTest.cs rename to src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerAssertionTest.cs index 3e974e9c9714..18e603ec717b 100644 --- a/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAssertionTest.cs +++ b/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerAssertionTest.cs @@ -18,7 +18,7 @@ namespace Microsoft.AspNetCore.Identity.Test; using static JsonHelpers; using static CredentialHelpers; -public class DefaultPasskeyHandlerAssertionTest +public class PasskeyHandlerAssertionTest { [Fact] public async Task CanSucceed() @@ -1044,7 +1044,7 @@ protected override async Task> RunCoreAsync() } var passkeyOptions = Options.Create(PasskeyOptions); - var handler = new DefaultPasskeyHandler(userManager.Object, passkeyOptions); + var handler = new PasskeyHandler(userManager.Object, passkeyOptions); var requestOptionsResult = await handler.MakeRequestOptionsAsync( IsUserIdentified ? User : null, diff --git a/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAttestationTest.cs b/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerAttestationTest.cs similarity index 99% rename from src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAttestationTest.cs rename to src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerAttestationTest.cs index 3535d1114597..e8328966d4c1 100644 --- a/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAttestationTest.cs +++ b/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerAttestationTest.cs @@ -18,7 +18,7 @@ namespace Microsoft.AspNetCore.Identity.Test; using static JsonHelpers; using static CredentialHelpers; -public class DefaultPasskeyHandlerAttestationTest +public class PasskeyHandlerAttestationTest { [Fact] public async Task CanSucceed() @@ -1020,7 +1020,7 @@ protected override async Task RunCoreAsync() } var passkeyOptions = Options.Create(PasskeyOptions); - var handler = new DefaultPasskeyHandler(userManager.Object, passkeyOptions); + var handler = new PasskeyHandler(userManager.Object, passkeyOptions); var userEntity = new PasskeyUserEntity() { Id = UserId!, From 9b0676c6013ef53929d278a5c129f69ae7b8fe08 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 18 Jul 2025 10:58:57 -0400 Subject: [PATCH 09/10] Require antiforgery in options endpoints --- ...omponentsEndpointRouteBuilderExtensions.cs | 10 ++++++- .../Account/Shared/PasskeySubmit.razor | 23 ++++++++++++++-- .../Account/Shared/PasskeySubmit.razor.js | 27 +++++++++++++------ 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs index ad850aac4afc..96565a6ec3a8 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using System.Text.Json; +using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Http.Extensions; @@ -52,8 +53,11 @@ public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEn accountGroup.MapPost("/PasskeyCreationOptions", async ( HttpContext context, [FromServices] UserManager userManager, - [FromServices] SignInManager signInManager) => + [FromServices] SignInManager signInManager, + [FromServices] IAntiforgery antiforgery) => { + await antiforgery.ValidateRequestAsync(context); + var user = await userManager.GetUserAsync(context.User); if (user is null) { @@ -72,10 +76,14 @@ public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEn }); accountGroup.MapPost("/PasskeyRequestOptions", async ( + HttpContext context, [FromServices] UserManager userManager, [FromServices] SignInManager signInManager, + [FromServices] IAntiforgery antiforgery, [FromQuery] string? username) => { + await antiforgery.ValidateRequestAsync(context); + var user = string.IsNullOrEmpty(username) ? null : await userManager.FindByNameAsync(username); var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user); return TypedResults.Content(optionsJson, contentType: "application/json"); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor index 9cc8e57fe8ec..f3d6f52c21be 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor @@ -1,7 +1,21 @@ - - +@using Microsoft.AspNetCore.Antiforgery +@inject IServiceProvider Services + + + + @code { + private AntiforgeryTokenSet? tokens; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + [Parameter] [EditorRequired] public PasskeyOperation Operation { get; set; } @@ -18,4 +32,9 @@ [Parameter(CaptureUnmatchedValues = true)] public IDictionary? AdditionalAttributes { get; set; } + + protected override void OnInitialized() + { + tokens = Services.GetRequiredService()?.GetTokens(HttpContext); + } } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor.js index 42d5d150aa70..55a83bcc7ba1 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor.js +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor.js @@ -17,9 +17,10 @@ async function fetchWithErrorHandling(url, options = {}) { return response; } -async function createCredential(signal) { +async function createCredential(headers, signal) { const optionsResponse = await fetchWithErrorHandling('/Account/PasskeyCreationOptions', { method: 'POST', + headers, signal, }); const optionsJson = await optionsResponse.json(); @@ -27,9 +28,10 @@ async function createCredential(signal) { return await navigator.credentials.create({ publicKey: options, signal }); } -async function requestCredential(email, mediation, signal) { +async function requestCredential(email, mediation, headers, signal) { const optionsResponse = await fetchWithErrorHandling(`/Account/PasskeyRequestOptions?username=${email}`, { method: 'POST', + headers, signal, }); const optionsJson = await optionsResponse.json(); @@ -46,6 +48,8 @@ customElements.define('passkey-submit', class extends HTMLElement { operation: this.getAttribute('operation'), name: this.getAttribute('name'), emailName: this.getAttribute('email-name'), + requestTokenName: this.getAttribute('request-token-name'), + requestTokenValue: this.getAttribute('request-token-value'), }; this.internals.form.addEventListener('submit', (event) => { @@ -67,12 +71,16 @@ customElements.define('passkey-submit', class extends HTMLElement { throw new Error('Some passkey features are missing. Please update your browser.'); } + const headers = { + [this.attrs.requestTokenName]: this.attrs.requestTokenValue, + }; + if (this.attrs.operation === 'Create') { - return await createCredential(signal); + return await createCredential(headers, signal); } else if (this.attrs.operation === 'Request') { const email = new FormData(this.internals.form).get(this.attrs.emailName); const mediation = useConditionalMediation ? 'conditional' : undefined; - return await requestCredential(email, mediation, signal); + return await requestCredential(email, mediation, headers, signal); } else { throw new Error(`Unknown passkey operation '${this.attrs.operation}'.`); } @@ -88,11 +96,14 @@ customElements.define('passkey-submit', class extends HTMLElement { const credentialJson = JSON.stringify(credential); formData.append(`${this.attrs.name}.CredentialJson`, credentialJson); } catch (error) { + if (error.name === 'AbortError') { + // The user explicitly canceled the operation - return without error. + return; + } console.error(error); - if (useConditionalMediation || error.name === 'AbortError') { - // We do not relay the error to the user if: - // 1. We are attempting conditional mediation, meaning the user did not initiate the operation. - // 2. The user explicitly canceled the operation. + if (useConditionalMediation) { + // An error occurred during conditional mediation, which is not user-initiated. + // We log the error in the console but do not relay it to the user. return; } const errorMessage = error.name === 'NotAllowedError' From 320f991d66f776c171e3fa1985910f7b2a0fb5bf Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 18 Jul 2025 15:54:56 -0400 Subject: [PATCH 10/10] PasskeyOptions -> IdentityPasskeyOptions --- ...eyOptions.cs => IdentityPasskeyOptions.cs} | 2 +- src/Identity/Core/src/PasskeyHandler.cs | 4 +- src/Identity/Core/src/PublicAPI.Unshipped.txt | 46 +++++++++---------- .../Program.cs | 8 ++-- ...sTest.cs => IdentityPasskeyOptionsTest.cs} | 4 +- .../Passkeys/PasskeyHandlerAssertionTest.cs | 2 +- .../Passkeys/PasskeyHandlerAttestationTest.cs | 2 +- 7 files changed, 34 insertions(+), 34 deletions(-) rename src/Identity/Core/src/{PasskeyOptions.cs => IdentityPasskeyOptions.cs} (99%) rename src/Identity/test/Identity.Test/{PasskeyOptionsTest.cs => IdentityPasskeyOptionsTest.cs} (89%) diff --git a/src/Identity/Core/src/PasskeyOptions.cs b/src/Identity/Core/src/IdentityPasskeyOptions.cs similarity index 99% rename from src/Identity/Core/src/PasskeyOptions.cs rename to src/Identity/Core/src/IdentityPasskeyOptions.cs index feb450d50a88..e8827cd18a71 100644 --- a/src/Identity/Core/src/PasskeyOptions.cs +++ b/src/Identity/Core/src/IdentityPasskeyOptions.cs @@ -6,7 +6,7 @@ namespace Microsoft.AspNetCore.Identity; /// /// Specifies options for passkey requirements. /// -public class PasskeyOptions +public class IdentityPasskeyOptions { /// /// Gets or sets the time that the browser should wait for the authenticator to provide a passkey. diff --git a/src/Identity/Core/src/PasskeyHandler.cs b/src/Identity/Core/src/PasskeyHandler.cs index d243b56c0d5b..c3c7ff3e7cd2 100644 --- a/src/Identity/Core/src/PasskeyHandler.cs +++ b/src/Identity/Core/src/PasskeyHandler.cs @@ -18,14 +18,14 @@ public sealed class PasskeyHandler : IPasskeyHandler where TUser : class { private readonly UserManager _userManager; - private readonly PasskeyOptions _options; + private readonly IdentityPasskeyOptions _options; /// /// Constructs a new instance. /// /// The . /// The . - public PasskeyHandler(UserManager userManager, IOptions options) + public PasskeyHandler(UserManager userManager, IOptions options) { ArgumentNullException.ThrowIfNull(userManager); ArgumentNullException.ThrowIfNull(options); diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 269b4d0273a3..a98e87ddd425 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1,4 +1,26 @@ #nullable enable +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.AttestationConveyancePreference.get -> string? +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.AttestationConveyancePreference.set -> void +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.AuthenticatorAttachment.get -> string? +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.AuthenticatorAttachment.set -> void +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.AuthenticatorTimeout.get -> System.TimeSpan +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.AuthenticatorTimeout.set -> void +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.ChallengeSize.get -> int +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.ChallengeSize.set -> void +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.IdentityPasskeyOptions() -> void +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.IsAllowedAlgorithm.get -> System.Func? +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.IsAllowedAlgorithm.set -> void +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.ResidentKeyRequirement.get -> string? +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.ResidentKeyRequirement.set -> void +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.ServerDomain.get -> string? +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.ServerDomain.set -> void +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.UserVerificationRequirement.get -> string? +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.UserVerificationRequirement.set -> void +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.ValidateOrigin.get -> System.Func>? +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.ValidateOrigin.set -> void +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.VerifyAttestationStatement.get -> System.Func>? +Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.VerifyAttestationStatement.set -> void Microsoft.AspNetCore.Identity.IPasskeyHandler 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! @@ -51,31 +73,9 @@ Microsoft.AspNetCore.Identity.PasskeyException.PasskeyException(string! message, Microsoft.AspNetCore.Identity.PasskeyHandler Microsoft.AspNetCore.Identity.PasskeyHandler.MakeCreationOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Identity.PasskeyHandler.MakeRequestOptionsAsync(TUser? user, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! -Microsoft.AspNetCore.Identity.PasskeyHandler.PasskeyHandler(Microsoft.AspNetCore.Identity.UserManager! userManager, Microsoft.Extensions.Options.IOptions! options) -> void +Microsoft.AspNetCore.Identity.PasskeyHandler.PasskeyHandler(Microsoft.AspNetCore.Identity.UserManager! userManager, Microsoft.Extensions.Options.IOptions! options) -> void Microsoft.AspNetCore.Identity.PasskeyHandler.PerformAssertionAsync(Microsoft.AspNetCore.Identity.PasskeyAssertionContext! context) -> System.Threading.Tasks.Task!>! Microsoft.AspNetCore.Identity.PasskeyHandler.PerformAttestationAsync(Microsoft.AspNetCore.Identity.PasskeyAttestationContext! context) -> System.Threading.Tasks.Task! -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.AuthenticatorTimeout.get -> System.TimeSpan -Microsoft.AspNetCore.Identity.PasskeyOptions.AuthenticatorTimeout.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.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 diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs index cd34c12735a5..f8a0e95eebd4 100644 --- a/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs @@ -246,8 +246,8 @@ static ValueTask ValidateOriginAsync(PasskeyOriginValidationContext contex return ValueTask.FromResult(uri.Host == "localhost" && uri.Port == 7020); } -static IOptions GetPasskeyOptionsFromCreationRequest(ServerPublicKeyCredentialCreationOptionsRequest request) - => Options.Create(new PasskeyOptions() +static IOptions GetPasskeyOptionsFromCreationRequest(ServerPublicKeyCredentialCreationOptionsRequest request) + => Options.Create(new IdentityPasskeyOptions() { ValidateOrigin = ValidateOriginAsync, AttestationConveyancePreference = request.Attestation, @@ -256,8 +256,8 @@ static IOptions GetPasskeyOptionsFromCreationRequest(ServerPubli UserVerificationRequirement = request.AuthenticatorSelection?.UserVerification, }); -static IOptions GetPasskeyOptionsFromGetRequest(ServerPublicKeyCredentialGetOptionsRequest request) - => Options.Create(new PasskeyOptions() +static IOptions GetPasskeyOptionsFromGetRequest(ServerPublicKeyCredentialGetOptionsRequest request) + => Options.Create(new IdentityPasskeyOptions() { ValidateOrigin = ValidateOriginAsync, UserVerificationRequirement = request.UserVerification, diff --git a/src/Identity/test/Identity.Test/PasskeyOptionsTest.cs b/src/Identity/test/Identity.Test/IdentityPasskeyOptionsTest.cs similarity index 89% rename from src/Identity/test/Identity.Test/PasskeyOptionsTest.cs rename to src/Identity/test/Identity.Test/IdentityPasskeyOptionsTest.cs index 3219ad8f83b9..c539eb35e56b 100644 --- a/src/Identity/test/Identity.Test/PasskeyOptionsTest.cs +++ b/src/Identity/test/Identity.Test/IdentityPasskeyOptionsTest.cs @@ -3,12 +3,12 @@ namespace Microsoft.AspNetCore.Identity.Test; -public class PasskeyOptionsTest +public class IdentityPasskeyOptionsTest { [Fact] public void VerifyDefaultOptions() { - var options = new PasskeyOptions(); + var options = new IdentityPasskeyOptions(); Assert.Equal(TimeSpan.FromMinutes(5), options.AuthenticatorTimeout); Assert.Equal(32, options.ChallengeSize); diff --git a/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerAssertionTest.cs b/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerAssertionTest.cs index 18e603ec717b..0e603eedb047 100644 --- a/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerAssertionTest.cs +++ b/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerAssertionTest.cs @@ -980,7 +980,7 @@ private sealed class AssertionTest : PasskeyScenarioTest pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy