diff --git a/AspNetCore.slnx b/AspNetCore.slnx index b73adbbb4b85..2b5b7180956c 100644 --- a/AspNetCore.slnx +++ b/AspNetCore.slnx @@ -409,6 +409,8 @@ + + diff --git a/eng/Dependencies.props b/eng/Dependencies.props index 6f79305687c0..564d8e2c4612 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -83,6 +83,7 @@ and are generated based on the last package release. + diff --git a/eng/SharedFramework.External.props b/eng/SharedFramework.External.props index 9096f00ebe40..aef0802bc4d5 100644 --- a/eng/SharedFramework.External.props +++ b/eng/SharedFramework.External.props @@ -42,6 +42,7 @@ + diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index dcb405a76b81..516e1442fc84 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -215,6 +215,10 @@ https://github.com/dotnet/dotnet 005f36cd1953e1308c1882a9d2e1fc1e84e6b2e4 + + https://github.com/dotnet/dotnet + 005f36cd1953e1308c1882a9d2e1fc1e84e6b2e4 + https://github.com/dotnet/dotnet 005f36cd1953e1308c1882a9d2e1fc1e84e6b2e4 diff --git a/eng/Versions.props b/eng/Versions.props index fbb7ec3a9b1e..b02db4ba888a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -116,6 +116,7 @@ 10.0.0-preview.6.25314.101 10.0.0-preview.6.25314.101 10.0.0-preview.6.25314.101 + 10.0.0-preview.6.25314.101 10.0.0-preview.6.25314.101 10.0.0-preview.6.25314.101 10.0.0-preview.6.25314.101 diff --git a/eng/common/build.ps1 b/eng/common/build.ps1 index 8cfee107e7a3..89de806aef2c 100644 --- a/eng/common/build.ps1 +++ b/eng/common/build.ps1 @@ -37,7 +37,7 @@ Param( # Unset 'Platform' environment variable to avoid unwanted collision in InstallDotNetCore.targets file # some computer has this env var defined (e.g. Some HP) if($env:Platform) { - $env:Platform="" + $env:Platform="" } function Print-Usage() { Write-Host "Common settings:" @@ -108,10 +108,10 @@ function Build { # Re-assign properties to a new variable because PowerShell doesn't let us append properties directly for unclear reasons. # Explicitly set the type as string[] because otherwise PowerShell would make this char[] if $properties is empty. [string[]] $msbuildArgs = $properties - - # Resolve relative project paths into full paths + + # Resolve relative project paths into full paths $projects = ($projects.Split(';').ForEach({Resolve-Path $_}) -join ';') - + $msbuildArgs += "/p:Projects=$projects" $properties = $msbuildArgs } diff --git a/eng/tools/GenerateFiles/Directory.Build.targets.in b/eng/tools/GenerateFiles/Directory.Build.targets.in index ff0fee210597..60823e68259d 100644 --- a/eng/tools/GenerateFiles/Directory.Build.targets.in +++ b/eng/tools/GenerateFiles/Directory.Build.targets.in @@ -133,14 +133,14 @@ - + false false false - + diff --git a/src/Components/ComponentsNoDeps.slnf b/src/Components/ComponentsNoDeps.slnf index 357c9fd1ccc8..da9756cc39ed 100644 --- a/src/Components/ComponentsNoDeps.slnf +++ b/src/Components/ComponentsNoDeps.slnf @@ -62,4 +62,4 @@ "src\\Components\\test\\testassets\\TestContentPackage\\TestContentPackage.csproj" ] } -} \ No newline at end of file +} diff --git a/src/Framework/test/TestData.cs b/src/Framework/test/TestData.cs index cd182da81e4f..4464073671bc 100644 --- a/src/Framework/test/TestData.cs +++ b/src/Framework/test/TestData.cs @@ -156,6 +156,7 @@ static TestData() "Microsoft.Net.Http.Headers", "System.Diagnostics.EventLog", "System.Diagnostics.EventLog.Messages", + "System.Formats.Cbor", "System.Security.Cryptography.Pkcs", "System.Security.Cryptography.Xml", "System.Threading.AccessControl", @@ -308,6 +309,7 @@ static TestData() { "Microsoft.JSInterop" }, { "Microsoft.Net.Http.Headers" }, { "System.Diagnostics.EventLog" }, + { "System.Formats.Cbor" }, { "System.Security.Cryptography.Xml" }, { "System.Threading.AccessControl" }, { "System.Threading.RateLimiting" }, diff --git a/src/Identity/Core/src/AuthenticatorSelectionCriteria.cs b/src/Identity/Core/src/AuthenticatorSelectionCriteria.cs new file mode 100644 index 000000000000..fd834ad3e516 --- /dev/null +++ b/src/Identity/Core/src/AuthenticatorSelectionCriteria.cs @@ -0,0 +1,46 @@ +// 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; + +/// +/// Used to specify requirements regarding authenticator attributes. +/// +/// +/// See . +/// +public sealed class AuthenticatorSelectionCriteria +{ + /// + /// Gets or sets the authenticator attachment. + /// + /// + /// See . + /// + public string? AuthenticatorAttachment { 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? ResidentKey { get; set; } + + /// + /// Gets whether a resident key is required. + /// + /// + /// See . + /// + public bool RequireResidentKey => string.Equals("required", ResidentKey, StringComparison.Ordinal); + + /// + /// Gets or sets the user verification requirement. + /// + /// + /// See . + /// + public string UserVerification { get; set; } = "preferred"; +} diff --git a/src/Identity/Core/src/DefaultPasskeyHandler.cs b/src/Identity/Core/src/DefaultPasskeyHandler.cs new file mode 100644 index 000000000000..dc9aa9c03509 --- /dev/null +++ b/src/Identity/Core/src/DefaultPasskeyHandler.cs @@ -0,0 +1,581 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// The default passkey handler. +/// +public partial class DefaultPasskeyHandler : IPasskeyHandler + where TUser : class +{ + private readonly PasskeyOptions _passkeyOptions; + + /// + /// Constructs a new instance. + /// + /// The . + public DefaultPasskeyHandler(IOptions options) + { + _passkeyOptions = options.Value.Passkey; + } + + /// + public async Task PerformAttestationAsync(PasskeyAttestationContext context) + { + try + { + return await PerformAttestationCoreAsync(context).ConfigureAwait(false); + } + catch (PasskeyException ex) + { + return PasskeyAttestationResult.Fail(ex); + } + catch (Exception ex) + { + if (ex is OperationCanceledException) + { + throw; + } + + return PasskeyAttestationResult.Fail(new PasskeyException($"An unexpected error occurred during passkey attestation: {ex.Message}", ex)); + } + } + + /// + public async Task> PerformAssertionAsync(PasskeyAssertionContext context) + { + try + { + return await PerformAssertionCoreAsync(context).ConfigureAwait(false); + } + catch (PasskeyException ex) + { + return PasskeyAssertionResult.Fail(ex); + } + catch (Exception ex) + { + if (ex is OperationCanceledException) + { + throw; + } + + return PasskeyAssertionResult.Fail(new PasskeyException($"An unexpected error occurred during passkey assertion: {ex.Message}", ex)); + } + } + + /// + /// 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) + { + // 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; + + try + { + credential = JsonSerializer.Deserialize(context.CredentialJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialAuthenticatorAttestationResponse) + ?? throw PasskeyException.NullAttestationCredentialJson(); + } + catch (JsonException ex) + { + throw PasskeyException.InvalidAttestationCredentialJsonFormat(ex); + } + + try + { + originalOptions = JsonSerializer.Deserialize(context.OriginalOptionsJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialCreationOptions) + ?? throw PasskeyException.NullOriginalCreationOptionsJson(); + } + catch (JsonException ex) + { + throw PasskeyException.InvalidOriginalCreationOptionsJsonFormat(ex); + } + + VerifyCredentialType(credential); + + // 3. Let response be credential.response. + var response = credential.Response; + + // 4. Let clientExtensionResults be the result of calling credential.getClientExtensionResults(). + // NOTE: Not currently supported. + + // 5. Let JSONtext be the result of running UTF-8 decode on the value of response.clientDataJSON. + // 6. Let clientData, claimed as collected during the credential creation, be the result of running an implementation-specific JSON parser on JSONtext. + // 7. Verify that the value of clientData.type is webauthn.create. + // 8. Verify that the value of clientData.challenge equals the base64url encoding of pkOptions.challenge. + // 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(), + expectedType: "webauthn.create", + context.HttpContext) + .ConfigureAwait(false); + + // 12. Let clientDataHash be the result of computing a hash over response.clientDataJSON using SHA-256. + var clientDataHash = SHA256.HashData(response.ClientDataJSON.AsSpan()); + + // 13. Perform CBOR decoding on the attestationObject field of the AuthenticatorAttestationResponse structure and obtain the + // the authenticator data authenticatorData. + var attestationObjectMemory = response.AttestationObject.AsMemory(); + var attestationObject = AttestationObject.Parse(attestationObjectMemory); + var authenticatorData = AuthenticatorData.Parse(attestationObject.AuthenticatorData); + + // 14. Verify that the rpIdHash in authenticatorData is the SHA-256 hash of the RP ID expected by the Relying Party. + // 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, + // 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 + // bit of the flags in authData. + VerifyAuthenticatorData( + authenticatorData, + originalRpId: originalOptions.Rp.Id, + originalUserVerificationRequirement: originalOptions.AuthenticatorSelection?.UserVerification); + + if (!authenticatorData.HasAttestedCredentialData) + { + throw PasskeyException.MissingAttestedCredentialData(); + } + + // 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)) + { + 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); + if (!isAttestationStatementValid) + { + throw PasskeyException.InvalidAttestationStatement(); + } + + // 25. Verify that the credentialId is <= 1023 bytes. + // NOTE: Handled while parsing the attested credential data. + if (!credential.Id.AsSpan().SequenceEqual(attestedCredentialData.CredentialId.Span)) + { + throw PasskeyException.CredentialIdMismatch(); + } + + 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); + if (existingUser is not null) + { + throw PasskeyException.CredentialAlreadyRegistered(); + } + + // 27. Let credentialRecord be a new credential record with the following contents: + var credentialRecord = new UserPasskeyInfo( + credentialId, + publicKey: attestedCredentialData.CredentialPublicKey.ToArray(), + name: null, + createdAt: DateTime.UtcNow, + signCount: authenticatorData.SignCount, + transports: response.Transports, + isUserVerified: authenticatorData.IsUserVerified, + isBackupEligible: authenticatorData.IsBackupEligible, + isBackedUp: authenticatorData.IsBackedUp, + attestationObject: response.AttestationObject.ToArray(), + clientDataJson: response.ClientDataJSON.ToArray()); + + // 28. Process the client extension outputs in clientExtensionResults and the authenticator extension + // outputs in the extensions in authData as required by the Relying Party. + // NOTE: Not currently supported. + + // 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); + } + + /// + /// Performs passkey assertion using the provided credential JSON, original options JSON, and optional user. + /// + /// The context containing necessary information for passkey assertion. + /// A task object representing the asynchronous operation containing the . + protected virtual 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; + + try + { + credential = JsonSerializer.Deserialize(context.CredentialJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialAuthenticatorAssertionResponse) + ?? throw PasskeyException.NullAssertionCredentialJson(); + } + catch (JsonException ex) + { + throw PasskeyException.InvalidAssertionCredentialJsonFormat(ex); + } + + try + { + originalOptions = JsonSerializer.Deserialize(context.OriginalOptionsJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialRequestOptions) + ?? throw PasskeyException.NullOriginalRequestOptionsJson(); + } + catch (JsonException ex) + { + throw PasskeyException.InvalidOriginalRequestOptionsJsonFormat(ex); + } + + VerifyCredentialType(credential); + + // 3. Let response be credential.response. + var response = credential.Response; + + // 4. Let clientExtensionResults be the result of calling credential.getClientExtensionResults(). + // NOTE: Not currently supported. + + // 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(); + } + + 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 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); + if (storedPasskey is null) + { + throw PasskeyException.CredentialDoesNotBelongToUser(); + } + if (userHandle is not null) + { + var userId = await context.UserManager.GetUserIdAsync(user).ConfigureAwait(false); + if (!string.Equals(userHandle, userId, StringComparison.Ordinal)) + { + throw PasskeyException.UserHandleMismatch(userId, userHandle); + } + } + } + else + { + // * If the user was not identified before the authentication ceremony was initiated, + // verify that response.userHandle is present. Verify that the user account identified by + // response.userHandle contains a credential record whose id equals credential.rawId. Let + // credentialRecord be that credential record. + if (userHandle is null) + { + throw PasskeyException.MissingUserHandle(); + } + + user = await context.UserManager.FindByIdAsync(userHandle).ConfigureAwait(false); + if (user is null) + { + throw PasskeyException.CredentialDoesNotBelongToUser(); + } + storedPasskey = await context.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. + var authenticatorData = AuthenticatorData.Parse(response.AuthenticatorData.AsMemory()); + + // 8. Let JSONtext be the result of running UTF-8 decode on the value of cData. + // 9. Let C, the client data claimed as used for the signature, be the result of running an implementation-specific JSON parser on JSONtext. + // 10. Verify that the value of C.type is the string webauthn.get. + // 11. Verify that the value of C.challenge equals the base64url encoding of originalOptions.challenge. + // 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(), + expectedType: "webauthn.get", + context.HttpContext) + .ConfigureAwait(false); + + // 15. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party. + // 16. Verify that the UP bit of the flags in authData is set. + // 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); + + // 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 + // credentialRecord.backupEligible and credentialRecord.backupState: + // 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. + if (storedPasskey.IsBackupEligible && !authenticatorData.IsBackupEligible) + { + throw PasskeyException.ExpectedBackupEligibleCredential(); + } + if (!storedPasskey.IsBackupEligible && authenticatorData.IsBackupEligible) + { + throw PasskeyException.ExpectedBackupIneligibleCredential(); + } + + // 20. Let clientDataHash be the result of computing a hash over the cData using SHA-256. + var clientDataHash = SHA256.HashData(response.ClientDataJSON.AsSpan()); + + // 21. Using credentialRecord.publicKey, verify that sig is a valid signature over the binary concatenation of authData and hash. + byte[] data = [.. response.AuthenticatorData.AsSpan(), .. clientDataHash]; + var cpk = CredentialPublicKey.Decode(storedPasskey.PublicKey); + if (!cpk.Verify(data, response.Signature.AsSpan())) + { + throw PasskeyException.InvalidAssertionSignature(); + } + + // 22. If authData.signCount is nonzero or credentialRecord.signCount is nonzero, then run the following sub-step: + if (authenticatorData.SignCount != 0 || storedPasskey.SignCount != 0) + { + // * If authData.signCount is greater than credentialRecord.signCount: + // The signature counter is valid. + // * If authData.signCount is less than or equal to credentialRecord.signCount + // This is a signal, but not proof, that the authenticator may be cloned. + // NOTE: We simply fail the ceremony in this case. + if (authenticatorData.SignCount <= storedPasskey.SignCount) + { + throw PasskeyException.SignCountLessThanStoredSignCount(); + } + } + + // 23. Process the client extension outputs in clientExtensionResults and the authenticator extension outputs + // in the extensions in authData as required by the Relying Party. + // NOTE: Not currently supported. + + // 24. Update credentialRecord with new state values + // 1. Update credentialRecord.signCount to the value of authData.signCount. + storedPasskey.SignCount = authenticatorData.SignCount; + + // 2. Update credentialRecord.backupState to the value of currentBs. + storedPasskey.IsBackedUp = authenticatorData.IsBackedUp; + + // 3. If credentialRecord.uvInitialized is false, update it to the value of the UV bit in the flags in authData. + // This change SHOULD require authorization by an additional authentication factor equivalent to WebAuthn user verification; + // if not authorized, skip this step. + // NOTE: Not currently supported. + + // 25. If all the above steps are successful, continue the authentication ceremony as appropriate. + return PasskeyAssertionResult.Success(storedPasskey, user); + } + + private static void VerifyCredentialType(PublicKeyCredential credential) + where TResponse : AuthenticatorResponse + { + const string ExpectedType = "public-key"; + if (!string.Equals(ExpectedType, credential.Type, StringComparison.Ordinal)) + { + throw PasskeyException.InvalidCredentialType(ExpectedType, credential.Type); + } + } + + private async Task VerifyClientDataAsync( + ReadOnlyMemory utf8Json, + ReadOnlyMemory originalChallenge, + string expectedType, + HttpContext httpContext) + { + // Let JSONtext be the result of running UTF-8 decode on the value of cData. + // Let C, the client data claimed as used for the signature, be the result of running an implementation-specific JSON parser on JSONtext. + CollectedClientData clientData; + try + { + clientData = JsonSerializer.Deserialize(utf8Json.Span, IdentityJsonSerializerContext.Default.CollectedClientData) + ?? throw PasskeyException.NullClientDataJson(); + } + catch (JsonException ex) + { + throw PasskeyException.InvalidClientDataJsonFormat(ex); + } + + // Verify that the value of C.type is either the string webauthn.create or webauthn.get. + // NOTE: The expected value depends on whether we're performing attestation or assertion. + if (!string.Equals(expectedType, clientData.Type, StringComparison.Ordinal)) + { + throw PasskeyException.InvalidClientDataType(expectedType, clientData.Type); + } + + // Verify that the value of C.challenge equals the base64url encoding of originalOptions.challenge. + if (!CryptographicOperations.FixedTimeEquals(clientData.Challenge.AsSpan(), originalChallenge.Span)) + { + throw PasskeyException.InvalidChallenge(); + } + + // 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); + if (!isOriginValid) + { + throw PasskeyException.InvalidOrigin(clientData.Origin); + } + + // NOTE: The level 2 spec requires token binding validation, but the level 3 spec does not. + // We'll just validate that the token binding object doesn't have an unexpected format. + if (clientData.TokenBinding is { } tokenBinding) + { + var status = tokenBinding.Status; + if (!string.Equals("supported", status, StringComparison.Ordinal) && + !string.Equals("present", status, StringComparison.Ordinal) && + !string.Equals("not-supported", status, StringComparison.Ordinal)) + { + throw PasskeyException.InvalidTokenBindingStatus(status); + } + } + } + + private void VerifyAuthenticatorData( + AuthenticatorData authenticatorData, + string? originalRpId, + string? originalUserVerificationRequirement) + { + // Verify that the rpIdHash in authenticatorData is the SHA-256 hash of the RP ID expected by the Relying Party. + var originalRpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(originalRpId ?? string.Empty)); + if (!CryptographicOperations.FixedTimeEquals(authenticatorData.RpIdHash.Span, originalRpIdHash.AsSpan())) + { + throw PasskeyException.InvalidRelyingPartyIDHash(); + } + + // If options.mediation is not set to conditional, verify that the UP bit of the flags in authData is set. + // NOTE: We currently check for the UserPresent flag unconditionally. Consider making this optional via options.mediation + // after the level 3 draft becomes standard. + if (!authenticatorData.IsUserPresent) + { + throw PasskeyException.UserNotPresent(); + } + + // If user verification is required for this registration, verify that the User Verified bit of the flags in authData is set. + if (string.Equals("required", originalUserVerificationRequirement, StringComparison.Ordinal) && !authenticatorData.IsUserVerified) + { + throw PasskeyException.UserNotVerified(); + } + + // If the BE bit of the flags in authData is not set, verify that the BS bit is not set. + if (!authenticatorData.IsBackupEligible && authenticatorData.IsBackedUp) + { + 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(); + } + } +} diff --git a/src/Identity/Core/src/EventIds.cs b/src/Identity/Core/src/EventIds.cs index 9c4aa5ed7e78..05c7e6c25d16 100644 --- a/src/Identity/Core/src/EventIds.cs +++ b/src/Identity/Core/src/EventIds.cs @@ -7,12 +7,16 @@ namespace Microsoft.AspNetCore.Identity; internal static class EventIds { - public static EventId UserCannotSignInWithoutConfirmedEmail = new EventId(0, "UserCannotSignInWithoutConfirmedEmail"); - public static EventId SecurityStampValidationFailed = new EventId(0, "SecurityStampValidationFailed"); - public static EventId SecurityStampValidationFailedId4 = new EventId(4, "SecurityStampValidationFailed"); - public static EventId UserCannotSignInWithoutConfirmedPhoneNumber = new EventId(1, "UserCannotSignInWithoutConfirmedPhoneNumber"); - public static EventId InvalidPassword = new EventId(2, "InvalidPassword"); - public static EventId UserLockedOut = new EventId(3, "UserLockedOut"); - public static EventId UserCannotSignInWithoutConfirmedAccount = new EventId(4, "UserCannotSignInWithoutConfirmedAccount"); - public static EventId TwoFactorSecurityStampValidationFailed = new EventId(5, "TwoFactorSecurityStampValidationFailed"); + public static readonly EventId UserCannotSignInWithoutConfirmedEmail = new(0, "UserCannotSignInWithoutConfirmedEmail"); + public static readonly EventId SecurityStampValidationFailed = new(0, "SecurityStampValidationFailed"); + public static readonly EventId SecurityStampValidationFailedId4 = new(4, "SecurityStampValidationFailed"); + public static readonly EventId UserCannotSignInWithoutConfirmedPhoneNumber = new(1, "UserCannotSignInWithoutConfirmedPhoneNumber"); + public static readonly EventId InvalidPassword = new(2, "InvalidPassword"); + public static readonly EventId UserLockedOut = new(3, "UserLockedOut"); + public static readonly EventId UserCannotSignInWithoutConfirmedAccount = new(4, "UserCannotSignInWithoutConfirmedAccount"); + public static readonly EventId TwoFactorSecurityStampValidationFailed = new(5, "TwoFactorSecurityStampValidationFailed"); + public static readonly EventId NoPasskeyCreationOptions = new(6, "NoPasskeyCreationOptions"); + public static readonly EventId UserDoesNotMatchPasskeyCreationOptions = new(7, "UserDoesNotMatchPasskeyCreationOptions"); + public static readonly EventId PasskeyAttestationFailed = new(8, "PasskeyAttestationFailed"); + public static readonly EventId PasskeyAssertionFailed = new(9, "PasskeyAssertionFailed"); } diff --git a/src/Identity/Core/src/IPasskeyHandler.cs b/src/Identity/Core/src/IPasskeyHandler.cs new file mode 100644 index 000000000000..be2a68a48d68 --- /dev/null +++ b/src/Identity/Core/src/IPasskeyHandler.cs @@ -0,0 +1,25 @@ +// 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 a handler for passkey assertion and attestation. +/// +public interface IPasskeyHandler + where TUser : class +{ + /// + /// 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 . + Task PerformAttestationAsync(PasskeyAttestationContext context); + + /// + /// Performs passkey assertion using the provided credential JSON, original options JSON, and optional user. + /// + /// The context containing necessary information for passkey assertion. + /// A task object representing the asynchronous operation containing the . + Task> PerformAssertionAsync(PasskeyAssertionContext context); +} diff --git a/src/Identity/Core/src/IdentityBuilderExtensions.cs b/src/Identity/Core/src/IdentityBuilderExtensions.cs index 264a88a5c23a..afa44b51e528 100644 --- a/src/Identity/Core/src/IdentityBuilderExtensions.cs +++ b/src/Identity/Core/src/IdentityBuilderExtensions.cs @@ -41,6 +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(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/IdentityJsonSerializerContext.cs b/src/Identity/Core/src/IdentityJsonSerializerContext.cs new file mode 100644 index 000000000000..81ddf44b6acc --- /dev/null +++ b/src/Identity/Core/src/IdentityJsonSerializerContext.cs @@ -0,0 +1,18 @@ +// 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 System.Text.Json.Serialization; + +namespace Microsoft.AspNetCore.Identity; + +[JsonSerializable(typeof(CollectedClientData))] +[JsonSerializable(typeof(PublicKeyCredentialCreationOptions))] +[JsonSerializable(typeof(PublicKeyCredentialRequestOptions))] +[JsonSerializable(typeof(PublicKeyCredential))] +[JsonSerializable(typeof(PublicKeyCredential))] +[JsonSourceGenerationOptions( + JsonSerializerDefaults.Web, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + RespectNullableAnnotations = true)] +internal partial class IdentityJsonSerializerContext : JsonSerializerContext; diff --git a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs index 12034b3c8971..43f81cccfbbb 100644 --- a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs +++ b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs @@ -102,6 +102,7 @@ public static class IdentityServiceCollectionExtensions services.TryAddScoped>(); services.TryAddScoped, UserClaimsPrincipalFactory>(); services.TryAddScoped, DefaultUserConfirmation>(); + services.TryAddScoped, DefaultPasskeyHandler>(); services.TryAddScoped>(); services.TryAddScoped>(); services.TryAddScoped>(); diff --git a/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj b/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj index f3faa30a2af4..6a77e34972cf 100644 --- a/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj +++ b/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Identity/Core/src/PasskeyAssertionContext.cs b/src/Identity/Core/src/PasskeyAssertionContext.cs new file mode 100644 index 000000000000..0c748f5e907c --- /dev/null +++ b/src/Identity/Core/src/PasskeyAssertionContext.cs @@ -0,0 +1,40 @@ +// 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 the context for passkey assertion. +/// +/// The type of user associated with the passkey. +public sealed class PasskeyAssertionContext + where TUser : class +{ + /// + /// Gets or sets the user associated with the passkey, if known. + /// + public TUser? User { get; init; } + + /// + /// Gets or sets the credentials obtained by JSON-serializing the result of the + /// navigator.credentials.get() JavaScript function. + /// + 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. + /// + public required UserManager UserManager { get; init; } + + /// + /// Gets or sets the for the current request. + /// + public required HttpContext HttpContext { get; init; } +} diff --git a/src/Identity/Core/src/PasskeyAssertionResult.cs b/src/Identity/Core/src/PasskeyAssertionResult.cs new file mode 100644 index 000000000000..088ddf4797c5 --- /dev/null +++ b/src/Identity/Core/src/PasskeyAssertionResult.cs @@ -0,0 +1,80 @@ +// 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.CodeAnalysis; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents the result of a passkey assertion operation. +/// +public sealed class PasskeyAssertionResult + where TUser : class +{ + /// + /// Gets whether the assertion was successful. + /// + [MemberNotNullWhen(true, nameof(Passkey))] + [MemberNotNullWhen(true, nameof(User))] + [MemberNotNullWhen(false, nameof(Failure))] + public bool Succeeded { get; } + + /// + /// Gets the updated passkey information when assertion succeeds. + /// + public UserPasskeyInfo? Passkey { get; } + + /// + /// Gets the user associated with the passkey when assertion succeeds. + /// + public TUser? User { get; } + + /// + /// Gets the error that occurred during assertion. + /// + public PasskeyException? Failure { get; } + + internal PasskeyAssertionResult(UserPasskeyInfo passkey, TUser user) + { + Succeeded = true; + Passkey = passkey; + User = user; + } + + internal PasskeyAssertionResult(PasskeyException failure) + { + Succeeded = false; + Failure = failure; + } +} + +/// +/// A factory class for creating instances of . +/// +public static class PasskeyAssertionResult +{ + /// + /// Creates a successful result for a passkey assertion operation. + /// + /// The passkey information associated with the assertion. + /// The user associated with the passkey. + /// A instance representing a successful assertion. + public static PasskeyAssertionResult Success(UserPasskeyInfo passkey, TUser user) + where TUser : class + { + ArgumentNullException.ThrowIfNull(passkey); + ArgumentNullException.ThrowIfNull(user); + return new PasskeyAssertionResult(passkey, user); + } + + /// + /// Creates a failed result for a passkey assertion operation. + /// + /// The exception that describes the reason for the failure. + /// A instance representing the failure. + public static PasskeyAssertionResult Fail(PasskeyException failure) + where TUser : class + { + return new PasskeyAssertionResult(failure); + } +} diff --git a/src/Identity/Core/src/PasskeyAttestationContext.cs b/src/Identity/Core/src/PasskeyAttestationContext.cs new file mode 100644 index 000000000000..8ee14b31fa64 --- /dev/null +++ b/src/Identity/Core/src/PasskeyAttestationContext.cs @@ -0,0 +1,35 @@ +// 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 the context for passkey attestation. +/// +/// The type of user associated with the passkey. +public sealed class PasskeyAttestationContext + where TUser : class +{ + /// + /// 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. + /// + public required string OriginalOptionsJson { get; init; } + + /// + /// Gets or sets the to retrieve user information from. + /// + public required UserManager UserManager { get; init; } + + /// + /// Gets or sets the for the current request. + /// + public required HttpContext HttpContext { get; init; } +} diff --git a/src/Identity/Core/src/PasskeyAttestationResult.cs b/src/Identity/Core/src/PasskeyAttestationResult.cs new file mode 100644 index 000000000000..3034cb3d5c45 --- /dev/null +++ b/src/Identity/Core/src/PasskeyAttestationResult.cs @@ -0,0 +1,62 @@ +// 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.CodeAnalysis; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents the result of a passkey attestation operation. +/// +public sealed class PasskeyAttestationResult +{ + /// + /// Gets whether the attestation was successful. + /// + [MemberNotNullWhen(true, nameof(Passkey))] + [MemberNotNullWhen(false, nameof(Failure))] + public bool Succeeded { get; } + + /// + /// Gets the passkey information collected during attestation when successful. + /// + public UserPasskeyInfo? Passkey { get; } + + /// + /// Gets the error that occurred during attestation. + /// + public PasskeyException? Failure { get; } + + private PasskeyAttestationResult(UserPasskeyInfo passkey) + { + Succeeded = true; + Passkey = passkey; + } + + private PasskeyAttestationResult(PasskeyException failure) + { + Succeeded = false; + Failure = failure; + } + + /// + /// Creates a successful result for a passkey attestation operation. + /// + /// The passkey information associated with the attestation. + /// A instance representing a successful attestation. + public static PasskeyAttestationResult Success(UserPasskeyInfo passkey) + { + ArgumentNullException.ThrowIfNull(passkey); + return new PasskeyAttestationResult(passkey); + } + + /// + /// Creates a failed result for a passkey attestation operation. + /// + /// The exception that describes the reason for the failure. + /// A instance representing the failure. + public static PasskeyAttestationResult Fail(PasskeyException failure) + { + return new PasskeyAttestationResult(failure); + } +} diff --git a/src/Identity/Core/src/PasskeyCreationArgs.cs b/src/Identity/Core/src/PasskeyCreationArgs.cs new file mode 100644 index 000000000000..9db4f97ac269 --- /dev/null +++ b/src/Identity/Core/src/PasskeyCreationArgs.cs @@ -0,0 +1,46 @@ +// 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 new file mode 100644 index 000000000000..f784b0afe461 --- /dev/null +++ b/src/Identity/Core/src/PasskeyCreationOptions.cs @@ -0,0 +1,45 @@ +// 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/PasskeyException.cs b/src/Identity/Core/src/PasskeyException.cs new file mode 100644 index 000000000000..f10a926fd333 --- /dev/null +++ b/src/Identity/Core/src/PasskeyException.cs @@ -0,0 +1,26 @@ +// 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 an error that occurred during passkey attestation or assertion. +/// +public sealed class PasskeyException : Exception +{ + /// + /// Constructs a new instance. + /// + public PasskeyException(string message) + : base(message) + { + } + + /// + /// Constructs a new instance. + /// + public PasskeyException(string message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/src/Identity/Core/src/PasskeyExceptionExtensions.cs b/src/Identity/Core/src/PasskeyExceptionExtensions.cs new file mode 100644 index 000000000000..9c640cb4edc0 --- /dev/null +++ b/src/Identity/Core/src/PasskeyExceptionExtensions.cs @@ -0,0 +1,153 @@ +// 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; + +internal static class PasskeyExceptionExtensions +{ + extension(PasskeyException) + { + public static PasskeyException InvalidCredentialType(string expectedType, string actualType) + => new($"Expected credential type '{expectedType}', got '{actualType}'."); + + public static PasskeyException InvalidClientDataType(string expectedType, string actualType) + => new($"Expected the client data JSON 'type' field to be '{expectedType}', got '{actualType}'."); + + public static PasskeyException InvalidChallenge() + => new("The authenticator response challenge does not match original challenge."); + + public static PasskeyException InvalidOrigin(string origin) + => new($"The authenticator response had an invalid origin '{origin}'."); + + public static PasskeyException InvalidRelyingPartyIDHash() + => new("The authenticator data included an invalid Relying Party ID hash."); + + public static PasskeyException UserNotPresent() + => new("The authenticator data flags did not include the 'UserPresent' flag."); + + public static PasskeyException UserNotVerified() + => new("User verification is required, but the authenticator data flags did not have the 'UserVerified' flag."); + + 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."); + + public static PasskeyException UnsupportedCredentialPublicKeyAlgorithm() + => new("The credential public key algorithm does not match any of the supported algorithms."); + + public static PasskeyException InvalidAttestationStatement() + => new("The attestation statement was not valid."); + + public static PasskeyException InvalidCredentialIdLength(int length) + => new($"Expected the credential ID to have a length between 1 and 1023 bytes, but got {length}."); + + public static PasskeyException CredentialIdMismatch() + => new("The provided credential ID does not match the credential ID in the attested credential data."); + + 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."); + + public static PasskeyException UserHandleMismatch(string providedUserHandle, string credentialUserHandle) + => new($"The provided user handle '{providedUserHandle}' does not match the credential's user handle '{credentialUserHandle}'."); + + public static PasskeyException MissingUserHandle() + => new("The authenticator response was missing a user handle."); + + public static PasskeyException ExpectedBackupEligibleCredential() + => new("The stored credential is eligible for backup, but the provided credential was unexpectedly ineligible for backup."); + + public static PasskeyException ExpectedBackupIneligibleCredential() + => new("The stored credential is ineligible for backup, but the provided credential was unexpectedly eligible for backup."); + + public static PasskeyException InvalidAssertionSignature() + => new("The assertion signature was invalid."); + + public static PasskeyException SignCountLessThanStoredSignCount() + => new("The authenticator's signature counter is unexpectedly less than or equal to the stored signature counter."); + + public static PasskeyException InvalidAttestationObject(Exception ex) + => new($"An exception occurred while parsing the attestation object: {ex.Message}", ex); + + public static PasskeyException InvalidAttestationObjectFormat(Exception ex) + => new("The attestation object had an invalid format.", ex); + + public static PasskeyException MissingAttestationStatementFormat() + => new("The attestation object did not include an attestation statement format."); + + public static PasskeyException MissingAttestationStatement() + => new("The attestation object did not include an attestation statement."); + + public static PasskeyException MissingAuthenticatorData() + => new("The attestation object did not include authenticator data."); + + public static PasskeyException InvalidAuthenticatorDataLength(int length) + => new($"The authenticator data had an invalid byte count of {length}."); + + public static PasskeyException InvalidAuthenticatorDataFormat(Exception? ex = null) + => new($"The authenticator data had an invalid format.", ex); + + public static PasskeyException InvalidAttestedCredentialDataLength(int length) + => new($"The attested credential data had an invalid byte count of {length}."); + + public static PasskeyException InvalidAttestedCredentialDataFormat(Exception? ex = null) + => new($"The attested credential data had an invalid format.", ex); + + public static PasskeyException InvalidTokenBindingStatus(string tokenBindingStatus) + => new($"Invalid token binding status '{tokenBindingStatus}'."); + + public static PasskeyException NullAttestationCredentialJson() + => new("The attestation credential JSON was unexpectedly null."); + + 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."); + + public static PasskeyException InvalidClientDataJsonFormat(JsonException ex) + => new($"The client data JSON had an invalid format: {ex.Message}", ex); + + public static PasskeyException InvalidCredentialPublicKey(Exception ex) + => new($"The credential public key was invalid.", ex); + } +} diff --git a/src/Identity/Core/src/PasskeyOriginInfo.cs b/src/Identity/Core/src/PasskeyOriginInfo.cs new file mode 100644 index 000000000000..30576f1609fc --- /dev/null +++ b/src/Identity/Core/src/PasskeyOriginInfo.cs @@ -0,0 +1,22 @@ +// 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/PasskeyRequestArgs.cs b/src/Identity/Core/src/PasskeyRequestArgs.cs new file mode 100644 index 000000000000..25df25909e49 --- /dev/null +++ b/src/Identity/Core/src/PasskeyRequestArgs.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 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 new file mode 100644 index 000000000000..ac034c8711e7 --- /dev/null +++ b/src/Identity/Core/src/PasskeyRequestOptions.cs @@ -0,0 +1,42 @@ +// 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/PasskeyUserEntity.cs b/src/Identity/Core/src/PasskeyUserEntity.cs new file mode 100644 index 000000000000..91e8de5ea09c --- /dev/null +++ b/src/Identity/Core/src/PasskeyUserEntity.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 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) +{ + /// + /// Gets the user ID associated with a passkey. + /// + public string Id { get; } = id; + + /// + /// Gets the name of the user associated with a passkey. + /// + public string Name { get; } = name; + + /// + /// Gets the display name of the user associated with a passkey. + /// + public string DisplayName { get; } = displayName ?? name; +} diff --git a/src/Identity/Core/src/Passkeys/AttestationObject.cs b/src/Identity/Core/src/Passkeys/AttestationObject.cs new file mode 100644 index 000000000000..649df7249a6e --- /dev/null +++ b/src/Identity/Core/src/Passkeys/AttestationObject.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Formats.Cbor; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents an authenticator attestation object, which contains the attestation statement and authenticator data. +/// +/// +/// See . +/// +internal sealed class AttestationObject +{ + /// + /// Gets or sets the attestation statement format. + /// + /// + /// See . + /// + public required string Format { get; init; } + + /// + /// Gets or sets the attestation statement. + /// + /// + /// See . + /// + public required ReadOnlyMemory AttestationStatement { get; init; } + + /// + /// Gets or sets the authenticator data. + /// + /// + /// See . + /// + public required ReadOnlyMemory AuthenticatorData { get; init; } + + public static AttestationObject Parse(ReadOnlyMemory data) + { + try + { + return ParseCore(data); + } + catch (PasskeyException) + { + throw; + } + catch (CborContentException ex) + { + throw PasskeyException.InvalidAttestationObjectFormat(ex); + } + catch (InvalidOperationException ex) + { + throw PasskeyException.InvalidAttestationObjectFormat(ex); + } + catch (Exception ex) + { + throw PasskeyException.InvalidAttestationObject(ex); + } + } + + private static AttestationObject ParseCore(ReadOnlyMemory data) + { + var reader = new CborReader(data); + _ = reader.ReadStartMap(); + + string? format = null; + ReadOnlyMemory? attestationStatement = default; + ReadOnlyMemory? authenticatorData = default; + + while (reader.PeekState() != CborReaderState.EndMap) + { + var key = reader.ReadTextString(); + switch (key) + { + case "fmt": + format = reader.ReadTextString(); + break; + case "attStmt": + attestationStatement = reader.ReadEncodedValue(); + break; + case "authData": + authenticatorData = reader.ReadByteString(); + break; + default: + // Unknown key - skip. + reader.SkipValue(); + break; + } + } + + if (format is null) + { + throw PasskeyException.MissingAttestationStatementFormat(); + } + + if (!attestationStatement.HasValue) + { + throw PasskeyException.MissingAttestationStatement(); + } + + if (!authenticatorData.HasValue) + { + throw PasskeyException.MissingAuthenticatorData(); + } + + return new() + { + Format = format, + AttestationStatement = attestationStatement.Value, + AuthenticatorData = authenticatorData.Value + }; + } +} diff --git a/src/Identity/Core/src/Passkeys/AttestedCredentialData.cs b/src/Identity/Core/src/Passkeys/AttestedCredentialData.cs new file mode 100644 index 000000000000..7c09c3ba5e9c --- /dev/null +++ b/src/Identity/Core/src/Passkeys/AttestedCredentialData.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents attested credential data in an . +/// +/// +/// See . +/// +internal sealed class AttestedCredentialData +{ + /// + /// Gets or sets the AAGUID of the authenticator that created the credential. + /// + public required ReadOnlyMemory Aaguid { get; init; } + + /// + /// Gets or sets the credential ID. + /// + public required ReadOnlyMemory CredentialId { get; init; } + + /// + /// Gets or sets the credential public key. + /// + public required CredentialPublicKey CredentialPublicKey { get; init; } + + public static AttestedCredentialData Parse(ReadOnlyMemory data, out int bytesRead) + { + try + { + return ParseCore(data, out bytesRead); + } + catch (PasskeyException) + { + throw; + } + catch (Exception ex) + { + throw PasskeyException.InvalidAttestedCredentialDataFormat(ex); + } + } + + private static AttestedCredentialData ParseCore(ReadOnlyMemory data, out int bytesRead) + { + const int AaguidLength = 16; + const int CredentialIdLengthLength = 2; + const int MinLength = AaguidLength + CredentialIdLengthLength; + const int MaxCredentialIdLength = 1023; + + var offset = 0; + + if (data.Length < MinLength) + { + throw PasskeyException.InvalidAttestedCredentialDataLength(data.Length); + } + + var aaguid = data.Slice(offset, AaguidLength); + offset += AaguidLength; + + var credentialIdLength = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, CredentialIdLengthLength).Span); + offset += CredentialIdLengthLength; + + if (credentialIdLength > MaxCredentialIdLength) + { + throw PasskeyException.InvalidCredentialIdLength(credentialIdLength); + } + + var credentialId = data.Slice(offset, credentialIdLength).ToArray(); + offset += credentialIdLength; + + var credentialPublicKey = CredentialPublicKey.Decode(data[offset..], out var read); + offset += read; + + bytesRead = offset; + return new() + { + Aaguid = aaguid, + CredentialId = credentialId, + CredentialPublicKey = credentialPublicKey, + }; + } +} diff --git a/src/Identity/Core/src/Passkeys/AuthenticatorAssertionResponse.cs b/src/Identity/Core/src/Passkeys/AuthenticatorAssertionResponse.cs new file mode 100644 index 000000000000..3468ac46bffd --- /dev/null +++ b/src/Identity/Core/src/Passkeys/AuthenticatorAssertionResponse.cs @@ -0,0 +1,29 @@ +// 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 response returned by an authenticator during the assertion phase of a WebAuthn login +/// process. +/// +/// +/// See . +/// +internal sealed class AuthenticatorAssertionResponse : AuthenticatorResponse +{ + /// + /// Gets or sets the authenticator data. + /// + public required BufferSource AuthenticatorData { get; init; } + + /// + /// Gets or sets the assertion signature. + /// + public required BufferSource Signature { get; init; } + + /// + /// Gets or sets the opaque user identifier. + /// + public BufferSource? UserHandle { get; init; } +} diff --git a/src/Identity/Core/src/Passkeys/AuthenticatorAttestationResponse.cs b/src/Identity/Core/src/Passkeys/AuthenticatorAttestationResponse.cs new file mode 100644 index 000000000000..b7dad16c0f25 --- /dev/null +++ b/src/Identity/Core/src/Passkeys/AuthenticatorAttestationResponse.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 response returned by an authenticator during the attestation phase of a WebAuthn registration +/// process. +/// +/// +/// See . +/// +internal sealed class AuthenticatorAttestationResponse : AuthenticatorResponse +{ + /// + /// Gets or sets the attestation object. + /// + public required BufferSource AttestationObject { get; init; } + + /// + /// Gets or sets the strings describing which transport methods (e.g., usb, nfc) are believed + /// to be supported with the authenticator. + /// + /// + /// May be empty or null if the information is not available. + /// + public string[]? Transports { get; init; } = []; +} diff --git a/src/Identity/Core/src/Passkeys/AuthenticatorData.cs b/src/Identity/Core/src/Passkeys/AuthenticatorData.cs new file mode 100644 index 000000000000..bff5c1f5c246 --- /dev/null +++ b/src/Identity/Core/src/Passkeys/AuthenticatorData.cs @@ -0,0 +1,147 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Formats.Cbor; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Encodes contextual bindings made by an authenticator. +/// +/// +/// See +/// +internal sealed class AuthenticatorData +{ + /// + /// Gets or sets the SHA-256 hash of the Relying Party ID the credential is scoped to. + /// + public required ReadOnlyMemory RpIdHash { get; init; } + + /// + /// Gets or sets the flags for this authenticator data. + /// + public required AuthenticatorDataFlags Flags { get; init; } + + /// + /// Gets or sets the signature counter. + /// + public required uint SignCount { get; init; } + + /// + /// Gets or sets the attested credential data. + /// + public AttestedCredentialData? AttestedCredentialData { get; init; } + + /// + /// Gets or sets the extension-defined authenticator data. + /// + public ReadOnlyMemory? Extensions { get; init; } + + /// + /// Gets whether the user is present. + /// + public bool IsUserPresent => Flags.HasFlag(AuthenticatorDataFlags.UserPresent); + + /// + /// Gets whether the user is verified. + /// + public bool IsUserVerified => Flags.HasFlag(AuthenticatorDataFlags.UserVerified); + + /// + /// Gets whether the public key credential source is backup eligible. + /// + public bool IsBackupEligible => Flags.HasFlag(AuthenticatorDataFlags.BackupEligible); + + /// + /// Gets whether the public key credential source is currently backed up. + /// + public bool IsBackedUp => Flags.HasFlag(AuthenticatorDataFlags.BackedUp); + + /// + /// Gets whether the authenticator data has extensions. + /// + public bool HasExtensionsData => Flags.HasFlag(AuthenticatorDataFlags.HasExtensionData); + + /// + /// Gets whether the authenticator added attested credential data. + /// + [MemberNotNullWhen(true, nameof(AttestedCredentialData))] + public bool HasAttestedCredentialData => Flags.HasFlag(AuthenticatorDataFlags.HasAttestedCredentialData); + + public static AuthenticatorData Parse(ReadOnlyMemory bytes) + { + try + { + return ParseCore(bytes); + } + catch (PasskeyException) + { + throw; + } + catch (Exception ex) + { + throw PasskeyException.InvalidAuthenticatorDataFormat(ex); + } + } + + private static AuthenticatorData ParseCore(ReadOnlyMemory bytes) + { + const int RpIdHashLength = 32; + const int AuthenticatorDataFlagsLength = 1; + const int SignCountLength = 4; + const int MinLength = RpIdHashLength + AuthenticatorDataFlagsLength + SignCountLength; + + // Min length specified in https://www.w3.org/TR/webauthn-3/#authenticator-data + Debug.Assert(MinLength == 37); + if (bytes.Length < MinLength) + { + throw PasskeyException.InvalidAuthenticatorDataLength(bytes.Length); + } + + var offset = 0; + + var rpIdHash = bytes.Slice(offset, RpIdHashLength); + offset += RpIdHashLength; + + var flags = (AuthenticatorDataFlags)bytes.Span[offset]; + offset += AuthenticatorDataFlagsLength; + + var signCount = BinaryPrimitives.ReadUInt32BigEndian(bytes.Slice(offset, SignCountLength).Span); + offset += SignCountLength; + + AttestedCredentialData? attestedCredentialData = null; + if (flags.HasFlag(AuthenticatorDataFlags.HasAttestedCredentialData)) + { + var remaining = bytes[offset..]; + attestedCredentialData = AttestedCredentialData.Parse(remaining, out var bytesRead); + offset += bytesRead; + } + + ReadOnlyMemory? extensions = default; + if (flags.HasFlag(AuthenticatorDataFlags.HasExtensionData)) + { + var reader = new CborReader(bytes[offset..]); + extensions = reader.ReadEncodedValue(); + offset += extensions.Value.Length; + } + + if (offset != bytes.Length) + { + // Leftover bytes signifies a possible parsing error. + throw PasskeyException.InvalidAuthenticatorDataFormat(); + } + + return new() + { + RpIdHash = rpIdHash, + Flags = flags, + SignCount = signCount, + AttestedCredentialData = attestedCredentialData, + Extensions = extensions, + }; + } +} diff --git a/src/Identity/Core/src/Passkeys/AuthenticatorDataFlags.cs b/src/Identity/Core/src/Passkeys/AuthenticatorDataFlags.cs new file mode 100644 index 000000000000..ad9ff726855e --- /dev/null +++ b/src/Identity/Core/src/Passkeys/AuthenticatorDataFlags.cs @@ -0,0 +1,44 @@ +// 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 flags for . +/// +/// +/// See . +/// +[Flags] +internal enum AuthenticatorDataFlags : byte +{ + /// + /// Indicates that the user is present. + /// + UserPresent = 1 << 0, + + /// + /// Indicates that the user is verified. + /// + UserVerified = 1 << 2, + + /// + /// Indicates that the public key credential source is backup eligible. + /// + BackupEligible = 1 << 3, + + /// + /// Indicates that the public key credential source is currently backed up. + /// + BackedUp = 1 << 4, + + /// + /// Indicates that the authenticator added attested credential data. + /// + HasAttestedCredentialData = 1 << 6, + + /// + /// Indicates that the authenticator data has extensions. + /// + HasExtensionData = 1 << 7, +} diff --git a/src/Identity/Core/src/Passkeys/AuthenticatorResponse.cs b/src/Identity/Core/src/Passkeys/AuthenticatorResponse.cs new file mode 100644 index 000000000000..d2760169faf2 --- /dev/null +++ b/src/Identity/Core/src/Passkeys/AuthenticatorResponse.cs @@ -0,0 +1,17 @@ +// 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 base class for responses returned by an authenticator during credential creation or retrieval +/// operations. +/// +internal abstract class AuthenticatorResponse +{ + /// + /// Gets or sets the client data passed to + /// navigator.credentials.create() or navigator.credentials.get(). + /// + public required BufferSource ClientDataJSON { get; init; } +} diff --git a/src/Identity/Core/src/Passkeys/BufferSource.cs b/src/Identity/Core/src/Passkeys/BufferSource.cs new file mode 100644 index 000000000000..0db74ebac1f2 --- /dev/null +++ b/src/Identity/Core/src/Passkeys/BufferSource.cs @@ -0,0 +1,126 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents a base64url-encoded byte buffer for use in passkey operations. +/// +/// +/// This type is named after the JavaScript BufferSource type. +/// When included in a JSON payload, it is serialized as a base64url-encoded string. +/// When a member of type BufferSource is mentioned in the WebAuthn specification, +/// this type can be used to represent it in .NET. +/// +[JsonConverter(typeof(BufferSourceJsonConverter))] +internal sealed class BufferSource : IEquatable +{ + private readonly ReadOnlyMemory _bytes; + + /// + /// Gets the length of the byte buffer. + /// + public int Length => _bytes.Length; + + /// + /// Creates a new instance of from a byte array. + /// + public static BufferSource FromBytes(ReadOnlyMemory bytes) + => new(bytes); + + /// + /// Creates a new instance of from a string. + /// + public static BufferSource FromString(string value) + { + var buffer = Encoding.UTF8.GetBytes(value); + return new(buffer); + } + + private BufferSource(ReadOnlyMemory buffer) + { + _bytes = buffer; + } + + /// + /// Gets the byte buffer as a . + /// + public ReadOnlyMemory AsMemory() + => _bytes; + + /// + /// Gets the byte buffer as a . + /// + public ReadOnlySpan AsSpan() + => _bytes.Span; + + /// + /// Gets the byte buffer as a byte array. + /// + public byte[] ToArray() + => _bytes.ToArray(); + + /// + /// Performs a value-based equality comparison with another instance. + /// + public bool Equals(BufferSource? other) + { + if (ReferenceEquals(this, other)) + { + return true; + } + + return other is not null && _bytes.Span.SequenceEqual(other._bytes.Span); + } + + /// + public override bool Equals(object? obj) + => obj is BufferSource other && Equals(other); + + /// + public override int GetHashCode() + => _bytes.GetHashCode(); + + /// + /// Performs a value-based equality comparison between two instances. + /// + public static bool operator ==(BufferSource? left, BufferSource? right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + if (left is null || right is null) + { + return false; + } + + return left.Equals(right); + } + + /// + /// Performs a value-based inequality comparison between two instances. + /// + public static bool operator !=(BufferSource? left, BufferSource? right) + => !(left == right); + + /// + /// Gets the UTF-8 string representation of the byte buffer. + /// + public override string ToString() + { + var span = _bytes.Span; + + if (span.IsEmpty) + { + return string.Empty; + } + + return Encoding.UTF8.GetString(span); + } +} diff --git a/src/Identity/Core/src/Passkeys/BufferSourceJsonConverter.cs b/src/Identity/Core/src/Passkeys/BufferSourceJsonConverter.cs new file mode 100644 index 000000000000..64d1618ac33b --- /dev/null +++ b/src/Identity/Core/src/Passkeys/BufferSourceJsonConverter.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Buffers.Text; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.AspNetCore.Identity; + +internal sealed class BufferSourceJsonConverter : JsonConverter +{ + private const int StackallocByteThreshold = 256; + + public override BufferSource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.ValueIsEscaped) + { + // We currently don't handle escaped base64url values, as we don't expect + // to encounter them when reading payloads produced by WebAuthn clients. + // See: https://www.w3.org/TR/webauthn-3/#base64url-encoding + throw new JsonException("Unexpected escaped value in base64url string."); + } + + var span = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan; + if (!TryDecodeBase64Url(span, out var bytes)) + { + throw new JsonException("Expected a valid base64url string."); + } + + return BufferSource.FromBytes(bytes); + } + + public override void Write(Utf8JsonWriter writer, BufferSource value, JsonSerializerOptions options) + { + var bytes = value.AsSpan(); + WriteBase64UrlStringValue(writer, bytes); + } + + // Based on https://github.com/dotnet/runtime/blob/624737eb3796e1a760465912b27ac349965d8ba5/src/libraries/System.Text.Json/src/System/Text/Json/Reader/JsonReaderHelper.Unescaping.cs#L218 + private static bool TryDecodeBase64Url(ReadOnlySpan utf8Unescaped, [NotNullWhen(true)] out byte[]? bytes) + { + byte[]? pooledArray = null; + + Span byteSpan = utf8Unescaped.Length <= StackallocByteThreshold ? + stackalloc byte[StackallocByteThreshold] : + (pooledArray = ArrayPool.Shared.Rent(utf8Unescaped.Length)); + + var status = Base64Url.DecodeFromUtf8(utf8Unescaped, byteSpan, out var bytesConsumed, out var bytesWritten); + if (status != OperationStatus.Done) + { + bytes = null; + + if (pooledArray != null) + { + ArrayPool.Shared.Return(pooledArray); + } + + return false; + } + Debug.Assert(bytesConsumed == utf8Unescaped.Length); + + bytes = byteSpan[..bytesWritten].ToArray(); + + if (pooledArray != null) + { + ArrayPool.Shared.Return(pooledArray); + } + + return true; + } + + private static void WriteBase64UrlStringValue(Utf8JsonWriter writer, ReadOnlySpan bytes) + { + byte[]? pooledArray = null; + + var encodedLength = Base64Url.GetEncodedLength(bytes.Length); + var byteSpan = encodedLength <= StackallocByteThreshold ? + stackalloc byte[encodedLength] : + (pooledArray = ArrayPool.Shared.Rent(encodedLength)); + + var status = Base64Url.EncodeToUtf8(bytes, byteSpan, out var bytesConsumed, out var bytesWritten); + Debug.Assert(status == OperationStatus.Done); + Debug.Assert(bytesConsumed == bytes.Length); + + var base64UrlUtf8 = byteSpan[..bytesWritten]; + writer.WriteStringValue(base64UrlUtf8); + + if (pooledArray != null) + { + ArrayPool.Shared.Return(pooledArray); + } + } +} diff --git a/src/Identity/Core/src/Passkeys/COSEAlgorithmIdentifier.cs b/src/Identity/Core/src/Passkeys/COSEAlgorithmIdentifier.cs new file mode 100644 index 000000000000..c90dba23a77a --- /dev/null +++ b/src/Identity/Core/src/Passkeys/COSEAlgorithmIdentifier.cs @@ -0,0 +1,26 @@ +// 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 a number identifying a cryptographic algorithm. +/// +/// +/// See . +/// +internal enum COSEAlgorithmIdentifier : int +{ + RS1 = -65535, + RS512 = -259, + RS384 = -258, + RS256 = -257, + PS512 = -39, + PS384 = -38, + PS256 = -37, + ES512 = -36, + ES384 = -35, + EdDSA = -8, + ES256 = -7, + ES256K = -47, +} diff --git a/src/Identity/Core/src/Passkeys/CollectedClientData.cs b/src/Identity/Core/src/Passkeys/CollectedClientData.cs new file mode 100644 index 000000000000..8e2e747283da --- /dev/null +++ b/src/Identity/Core/src/Passkeys/CollectedClientData.cs @@ -0,0 +1,42 @@ +// 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 client data passed to navigator.credentials.get() or navigator.credentials.create(). +/// +/// +/// See +/// +internal sealed class CollectedClientData +{ + /// + /// Gets or sets the type of the operation that produced the client data. + /// + /// + /// Will be either "webauthn.create" or "webauthn.get". + /// + public required string Type { get; init; } + + /// + /// Gets or sets the challenge provided by the relying party. + /// + public required BufferSource Challenge { get; init; } + + /// + /// Gets or sets the fully qualified origin of the requester. + /// + public required string Origin { get; init; } + + /// + /// Gets or sets whether the credential creation request was initiated from + /// a different origin than the one associated with the relying party. + /// + public bool? CrossOrigin { get; init; } + + /// + /// Gets or sets information about the state of the token binding protocol. + /// + public TokenBinding? TokenBinding { get; init; } +} diff --git a/src/Identity/Core/src/Passkeys/CredentialPublicKey.cs b/src/Identity/Core/src/Passkeys/CredentialPublicKey.cs new file mode 100644 index 000000000000..27a322cf6741 --- /dev/null +++ b/src/Identity/Core/src/Passkeys/CredentialPublicKey.cs @@ -0,0 +1,251 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Formats.Cbor; +using System.Security.Cryptography; + +namespace Microsoft.AspNetCore.Identity; + +internal sealed class CredentialPublicKey +{ + private readonly COSEKeyType _type; + private readonly COSEAlgorithmIdentifier _alg; + private readonly ReadOnlyMemory _bytes; + private readonly RSA? _rsa; + private readonly ECDsa? _ecdsa; + + public COSEAlgorithmIdentifier Alg => _alg; + + private CredentialPublicKey(ReadOnlyMemory bytes) + { + var reader = Ctap2CborReader.Create(bytes); + + reader.ReadCoseKeyLabel((int)COSEKeyParameter.KeyType); + _type = (COSEKeyType)reader.ReadInt32(); + _alg = ParseCoseKeyCommonParameters(reader); + + switch (_type) + { + case COSEKeyType.EC2: + case COSEKeyType.OKP: + _ecdsa = ParseECDsa(_type, reader); + break; + case COSEKeyType.RSA: + _rsa = ParseRSA(reader); + break; + default: + throw new InvalidOperationException($"Unsupported key type '{_type}'."); + } + + var keyLength = bytes.Length - reader.BytesRemaining; + _bytes = bytes[..keyLength]; + } + + public static CredentialPublicKey Decode(ReadOnlyMemory bytes) + { + try + { + return new CredentialPublicKey(bytes); + } + catch (PasskeyException) + { + throw; + } + catch (Exception ex) + { + throw PasskeyException.InvalidCredentialPublicKey(ex); + } + } + + public static CredentialPublicKey Decode(ReadOnlyMemory bytes, out int bytesRead) + { + var key = Decode(bytes); + bytesRead = key._bytes.Length; + return key; + } + + public bool Verify(ReadOnlySpan data, ReadOnlySpan signature) + { + return _type switch + { + COSEKeyType.EC2 => _ecdsa!.VerifyData(data, signature, HashAlgFromCOSEAlg(_alg), DSASignatureFormat.Rfc3279DerSequence), + COSEKeyType.RSA => _rsa!.VerifyData(data, signature, HashAlgFromCOSEAlg(_alg), GetRSASignaturePadding()), + _ => throw new InvalidOperationException($"Missing or unknown kty {_type}"), + }; + } + + private static COSEAlgorithmIdentifier ParseCoseKeyCommonParameters(Ctap2CborReader reader) + { + reader.ReadCoseKeyLabel((int)COSEKeyParameter.Alg); + var alg = (COSEAlgorithmIdentifier)reader.ReadInt32(); + + if (reader.TryReadCoseKeyLabel((int)COSEKeyParameter.KeyOps)) + { + // No-op, simply tolerate potential key_ops labels + reader.SkipValue(); + } + + return alg; + } + + private static RSA ParseRSA(Ctap2CborReader reader) + { + var rsaParams = new RSAParameters(); + + reader.ReadCoseKeyLabel((int)COSEKeyParameter.N); + rsaParams.Modulus = reader.ReadByteString(); + + if (!reader.TryReadCoseKeyLabel((int)COSEKeyParameter.E)) + { + throw new CborContentException("The COSE key encodes a private key."); + } + rsaParams.Exponent = reader.ReadByteString(); + + reader.ReadEndMap(); + + return RSA.Create(rsaParams); + } + + private static ECDsa ParseECDsa(COSEKeyType kty, Ctap2CborReader reader) + { + var ecParams = new ECParameters(); + + reader.ReadCoseKeyLabel((int)COSEKeyParameter.Crv); + var crv = (COSEEllipticCurve)reader.ReadInt32(); + + if (IsValidKtyCrvCombination(kty, crv)) + { + ecParams.Curve = MapCoseCrvToECCurve(crv); + } + + reader.ReadCoseKeyLabel((int)COSEKeyParameter.X); + ecParams.Q.X = reader.ReadByteString(); + + reader.ReadCoseKeyLabel((int)COSEKeyParameter.Y); + ecParams.Q.Y = reader.ReadByteString(); + + if (reader.TryReadCoseKeyLabel((int)COSEKeyParameter.D)) + { + throw new CborContentException("The COSE key encodes a private key."); + } + + reader.ReadEndMap(); + + return ECDsa.Create(ecParams); + + static ECCurve MapCoseCrvToECCurve(COSEEllipticCurve crv) + { + return crv switch + { + COSEEllipticCurve.P256 => ECCurve.NamedCurves.nistP256, + COSEEllipticCurve.P384 => ECCurve.NamedCurves.nistP384, + COSEEllipticCurve.P521 => ECCurve.NamedCurves.nistP521, + COSEEllipticCurve.X25519 or + COSEEllipticCurve.X448 or + COSEEllipticCurve.Ed25519 or + COSEEllipticCurve.Ed448 => throw new NotSupportedException("OKP type curves not supported."), + _ => throw new CborContentException($"Unrecognized COSE crv value {crv}"), + }; + } + + static bool IsValidKtyCrvCombination(COSEKeyType kty, COSEEllipticCurve crv) + { + return (kty, crv) switch + { + (COSEKeyType.EC2, COSEEllipticCurve.P256 or COSEEllipticCurve.P384 or COSEEllipticCurve.P521) => true, + (COSEKeyType.OKP, COSEEllipticCurve.X25519 or COSEEllipticCurve.X448 or COSEEllipticCurve.Ed25519 or COSEEllipticCurve.Ed448) => true, + _ => false, + }; + } + } + + private RSASignaturePadding GetRSASignaturePadding() + { + if (_type != COSEKeyType.RSA) + { + throw new InvalidOperationException($"Cannot get RSA signature padding for key type {_type}."); + } + + // https://www.iana.org/assignments/cose/cose.xhtml#algorithms + return _alg switch + { + COSEAlgorithmIdentifier.PS256 or + COSEAlgorithmIdentifier.PS384 or + COSEAlgorithmIdentifier.PS512 + => RSASignaturePadding.Pss, + + COSEAlgorithmIdentifier.RS1 or + COSEAlgorithmIdentifier.RS256 or + COSEAlgorithmIdentifier.RS384 or + COSEAlgorithmIdentifier.RS512 + => RSASignaturePadding.Pkcs1, + + _ => throw new InvalidOperationException($"Missing or unknown alg {_alg}"), + }; + } + + private static HashAlgorithmName HashAlgFromCOSEAlg(COSEAlgorithmIdentifier alg) + { + return alg switch + { + COSEAlgorithmIdentifier.RS1 => HashAlgorithmName.SHA1, + COSEAlgorithmIdentifier.ES256 => HashAlgorithmName.SHA256, + COSEAlgorithmIdentifier.ES384 => HashAlgorithmName.SHA384, + COSEAlgorithmIdentifier.ES512 => HashAlgorithmName.SHA512, + COSEAlgorithmIdentifier.PS256 => HashAlgorithmName.SHA256, + COSEAlgorithmIdentifier.PS384 => HashAlgorithmName.SHA384, + COSEAlgorithmIdentifier.PS512 => HashAlgorithmName.SHA512, + COSEAlgorithmIdentifier.RS256 => HashAlgorithmName.SHA256, + COSEAlgorithmIdentifier.RS384 => HashAlgorithmName.SHA384, + COSEAlgorithmIdentifier.RS512 => HashAlgorithmName.SHA512, + COSEAlgorithmIdentifier.ES256K => HashAlgorithmName.SHA256, + (COSEAlgorithmIdentifier)4 => HashAlgorithmName.SHA1, + (COSEAlgorithmIdentifier)11 => HashAlgorithmName.SHA256, + (COSEAlgorithmIdentifier)12 => HashAlgorithmName.SHA384, + (COSEAlgorithmIdentifier)13 => HashAlgorithmName.SHA512, + COSEAlgorithmIdentifier.EdDSA => HashAlgorithmName.SHA512, + _ => throw new InvalidOperationException("Invalid COSE algorithm value."), + }; + } + + public ReadOnlyMemory AsMemory() => _bytes; + + public byte[] ToArray() => _bytes.ToArray(); + + private enum COSEKeyType + { + OKP = 1, + EC2 = 2, + RSA = 3, + Symmetric = 4 + } + + private enum COSEKeyParameter + { + Crv = -1, + K = -1, + X = -2, + Y = -3, + D = -4, + N = -1, + E = -2, + KeyType = 1, + KeyId = 2, + Alg = 3, + KeyOps = 4, + BaseIV = 5 + } + + private enum COSEEllipticCurve + { + Reserved = 0, + P256 = 1, + P384 = 2, + P521 = 3, + X25519 = 4, + X448 = 5, + Ed25519 = 6, + Ed448 = 7, + P256K = 8, + } +} diff --git a/src/Identity/Core/src/Passkeys/Ctap2CborReader.cs b/src/Identity/Core/src/Passkeys/Ctap2CborReader.cs new file mode 100644 index 000000000000..f8c916c6cc7f --- /dev/null +++ b/src/Identity/Core/src/Passkeys/Ctap2CborReader.cs @@ -0,0 +1,66 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Formats.Cbor; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// A variation of that is used to read COSE keys in a CTAP2 canonical CBOR encoding form. +/// +internal sealed class Ctap2CborReader : CborReader +{ + private int _remainingKeys; + private int? _lastReadLabel; + + public static Ctap2CborReader Create(ReadOnlyMemory data) + { + var reader = new Ctap2CborReader(data); + if (reader.ReadStartMap() is not { } keyCount) + { + throw new CborContentException("CTAP2 canonical CBOR encoding form requires there to be a definite number of keys."); + } + reader._remainingKeys = keyCount; + return reader; + } + + private Ctap2CborReader(ReadOnlyMemory data) + : base(data, CborConformanceMode.Ctap2Canonical) + { + } + + public bool TryReadCoseKeyLabel(int expectedLabel) + { + // The 'expectedLabel' parameter can hold a label that + // was read when handling a previous optional field. + // We only need to read the next label if uninhabited. + if (_lastReadLabel is null) + { + // Check that we have not reached the end of the COSE key object. + if (_remainingKeys == 0) + { + return false; + } + + _lastReadLabel = ReadInt32(); + } + + if (expectedLabel != _lastReadLabel.Value) + { + return false; + } + + // Read was successful - vacate '_lastReadLabel' to advance reads. + _lastReadLabel = null; + _remainingKeys--; + return true; + } + + public void ReadCoseKeyLabel(int expectedLabel) + { + if (!TryReadCoseKeyLabel(expectedLabel)) + { + throw new CborContentException("Unexpected COSE key label"); + } + } +} diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredential.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredential.cs new file mode 100644 index 000000000000..fd702da5e272 --- /dev/null +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredential.cs @@ -0,0 +1,45 @@ +// 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 information about a public key/private key pair. +/// +/// +/// See +/// +internal sealed class PublicKeyCredential + where TResponse : notnull, AuthenticatorResponse +{ + /// + /// Gets or sets the credential ID. + /// + public required BufferSource Id { get; init; } + + /// + /// Gets the type of the public key credential. + /// + /// + /// This is always expected to have the value "public-key". + /// + public required string Type { get; init; } + + /// + /// Gets the client extensions map. + /// + public required JsonElement ClientExtensionResults { get; init; } + + /// + /// Gets or sets the authenticator response. + /// + public required TResponse Response { get; init; } + + /// + /// Gets or sets a string indicating the mechanism by which the WebAuthn implementation + /// is attached to the authenticator. + /// + public string? AuthenticatorAttachment { get; init; } +} diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs new file mode 100644 index 000000000000..2f07198a61db --- /dev/null +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs @@ -0,0 +1,70 @@ +// 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 options for credential creation. +/// +/// +/// See . +/// +internal sealed class PublicKeyCredentialCreationOptions +{ + /// + /// Gets or sets the name and identifier for the relying party requesting attestation. + /// + public required PublicKeyCredentialRpEntity Rp { get; init; } + + /// + /// Gets or sets the names and and identifier for the user account performing the registration. + /// + public required PublicKeyCredentialUserEntity User { get; init; } + + /// + /// Gets or sets a challenge that the authenticator signs when producing an attestation object for the newly created credential. + /// + public required BufferSource Challenge { get; init; } + + /// + /// Gets or sets the key types and signature algorithms the relying party supports, ordered from most preferred to least preferred. + /// + public IReadOnlyList PubKeyCredParams { get; init; } = []; + + /// + /// Gets or sets the time, in milliseconds, that the relying party is willing to wait for the call to complete. + /// + public ulong? Timeout { get; init; } + + /// + /// Gets or sets the existing credentials mapped to the user account. + /// + public IReadOnlyList ExcludeCredentials { get; init; } = []; + + /// + /// Gets or sets settings that the authenticator should satisfy when creating a new credential. + /// + public AuthenticatorSelectionCriteria? AuthenticatorSelection { get; init; } + + /// + /// Gets or sets hints that guide the user agent in interacting with the user. + /// + public IReadOnlyList Hints { get; init; } = []; + + /// + /// Gets or sets the attestation conveyance preference for the relying party. + /// + public string Attestation { get; init; } = "none"; + + /// + /// Gets or sets the attestation statement format preferences of the relying party, ordered from most preferred to least preferred. + /// + public IReadOnlyList AttestationFormats { get; init; } = []; + + /// + /// Gets or sets the client extension inputs that the relying party supports. + /// + public JsonElement? Extensions { get; init; } +} diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredentialDescriptor.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialDescriptor.cs new file mode 100644 index 000000000000..2cf52c82b72f --- /dev/null +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialDescriptor.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; + +/// +/// Identifies a specific public key credential. +/// +/// +/// See +/// +internal sealed class PublicKeyCredentialDescriptor +{ + /// + /// Gets or sets the type of the public key credential. + /// + public required string Type { get; init; } + + /// + /// Gets or sets the identifier of the public key credential. + /// + public required BufferSource Id { get; init; } + + /// + /// Gets or sets hints as to how the client might communicate with the authenticator. + /// + public IReadOnlyList Transports { get; init; } = []; +} diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredentialParameters.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialParameters.cs new file mode 100644 index 000000000000..d6abed1c1d6a --- /dev/null +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialParameters.cs @@ -0,0 +1,57 @@ +// 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; + +/// +/// Used to supply additional parameters when creating a new credential. +/// +/// +/// See +/// +[method: JsonConstructor] +internal readonly struct PublicKeyCredentialParameters(string type, COSEAlgorithmIdentifier alg) +{ + /// + /// 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. + /// + /// + /// See . + /// + public string Type { get; } = type; + + /// + /// Gets or sets the cryptographic signature algorithm identifier. + /// + /// + /// See . + /// + public COSEAlgorithmIdentifier Alg { get; } = alg; +} diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs new file mode 100644 index 000000000000..3978cd795693 --- /dev/null +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs @@ -0,0 +1,50 @@ +// 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 options for requesting a credential. +/// +/// +/// See +/// +internal sealed class PublicKeyCredentialRequestOptions +{ + /// + /// Gets or sets the challenge that the authenticator signs when producing an assertion for the requested credential. + /// + public required BufferSource Challenge { get; init; } + + /// + /// Gets or sets a time in milliseconds that the server is willing to wait for the call to complete. + /// + public ulong? Timeout { get; init; } + + /// + /// Gets or sets the relying party identifier. + /// + public string? RpId { get; init; } + + /// + /// Gets or sets the credentials of the identified user account, if any. + /// + public IReadOnlyList AllowCredentials { get; init; } = []; + + /// + /// Gets or sets the user verification requirement for the request. + /// + public string UserVerification { get; init; } = "preferred"; + + /// + /// Gets or sets hints that guide the user agent in interacting with the user. + /// + public IReadOnlyList Hints { get; init; } = []; + + /// + /// Gets or sets the client extension inputs that the relying party supports. + /// + public JsonElement? Extensions { get; init; } +} diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredentialRpEntity.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialRpEntity.cs new file mode 100644 index 000000000000..3b4fa13af2b0 --- /dev/null +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialRpEntity.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; + +/// +/// Used to supply Relying Party attributes when creating a new credential. +/// +/// +/// See . +/// +internal sealed class PublicKeyCredentialRpEntity +{ + /// + /// Gets or sets the human-palatable name for the entity. + /// + public required string Name { get; init; } + + /// + /// Gets or sets the unique identifier for the replying party entity. + /// + public string? Id { get; init; } +} diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredentialUserEntity.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialUserEntity.cs new file mode 100644 index 000000000000..ca1d2613a4cc --- /dev/null +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialUserEntity.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; + +/// +/// Used to supply additional user account attributes when creating a new credential. +/// +/// +/// See . +/// +internal sealed class PublicKeyCredentialUserEntity +{ + /// + /// Gets or sets the user handle of the user account. + /// + public required BufferSource Id { get; init; } + + /// + /// Gets or sets the human-palatable name for the entity. + /// + public required string Name { get; init; } + + /// + /// Gets or sets the human-palatable name for the user account, intended only for display. + /// + public required string DisplayName { get; init; } +} diff --git a/src/Identity/Core/src/Passkeys/TokenBinding.cs b/src/Identity/Core/src/Passkeys/TokenBinding.cs new file mode 100644 index 000000000000..2a46b2a8656d --- /dev/null +++ b/src/Identity/Core/src/Passkeys/TokenBinding.cs @@ -0,0 +1,30 @@ +// 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 about the state of the token binding protocol. +/// +/// +/// See . +/// +internal sealed class TokenBinding +{ + /// + /// Gets or sets the token binding status. + /// + /// + /// Supported values are "supported", "present", and "not-supported". + /// See . + /// + public required string Status { get; init; } + + /// + /// Gets or sets the token binding ID. + /// + /// + /// See . + /// + public string? Id { get; init; } +} diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..9594235ec62f 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1 +1,107 @@ #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.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.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.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.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.PasskeyUserEntity +Microsoft.AspNetCore.Identity.PasskeyUserEntity.DisplayName.get -> string! +Microsoft.AspNetCore.Identity.PasskeyUserEntity.Id.get -> string! +Microsoft.AspNetCore.Identity.PasskeyUserEntity.Name.get -> string! +Microsoft.AspNetCore.Identity.PasskeyUserEntity.PasskeyUserEntity(string! id, string! name, string? displayName) -> void +Microsoft.AspNetCore.Identity.SignInManager.SignInManager(Microsoft.AspNetCore.Identity.UserManager! userManager, Microsoft.AspNetCore.Http.IHttpContextAccessor! contextAccessor, Microsoft.AspNetCore.Identity.IUserClaimsPrincipalFactory! claimsFactory, Microsoft.Extensions.Options.IOptions! optionsAccessor, Microsoft.Extensions.Logging.ILogger!>! logger, Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider! schemes, Microsoft.AspNetCore.Identity.IUserConfirmation! confirmation, Microsoft.AspNetCore.Identity.IPasskeyHandler! passkeyHandler) -> void +override Microsoft.AspNetCore.Identity.PasskeyCreationOptions.ToString() -> string! +override Microsoft.AspNetCore.Identity.PasskeyRequestOptions.ToString() -> string! +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! diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index 66f06c4d3465..a41cc20d01f8 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -4,7 +4,9 @@ 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; @@ -19,13 +21,18 @@ 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 readonly IHttpContextAccessor _contextAccessor; private readonly IAuthenticationSchemeProvider _schemes; private readonly IUserConfirmation _confirmation; + private readonly IPasskeyHandler? _passkeyHandler; private HttpContext? _context; private TwoFactorAuthenticationInfo? _twoFactorInfo; + private PasskeyCreationOptions? _passkeyCreationOptions; + private PasskeyRequestOptions? _passkeyRequestOptions; /// /// Creates a new instance of . @@ -58,6 +65,32 @@ public SignInManager(UserManager userManager, _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; + } + /// /// Gets the used to log messages from the manager. /// @@ -340,7 +373,7 @@ public virtual async Task ValidateSecurityStampAsync(TUser? user, string? /// The password to attempt to sign in with. /// Flag indicating whether the sign-in cookie should persist after the browser is closed. /// Flag indicating if the user account should be locked if the sign in fails. - /// The task object representing the asynchronous operation containing the + /// The task object representing the asynchronous operation containing the /// for the sign-in attempt. public virtual async Task PasswordSignInAsync(TUser user, string password, bool isPersistent, bool lockoutOnFailure) @@ -361,7 +394,7 @@ public virtual async Task PasswordSignInAsync(TUser user, string p /// The password to attempt to sign in with. /// Flag indicating whether the sign-in cookie should persist after the browser is closed. /// Flag indicating if the user account should be locked if the sign in fails. - /// The task object representing the asynchronous operation containing the + /// The task object representing the asynchronous operation containing the /// for the sign-in attempt. public virtual async Task PasswordSignInAsync(string userName, string password, bool isPersistent, bool lockoutOnFailure) @@ -381,9 +414,8 @@ public virtual async Task PasswordSignInAsync(string userName, str /// The user to sign in. /// The password to attempt to sign in with. /// Flag indicating if the user account should be locked if the sign in fails. - /// The task object representing the asynchronous operation containing the + /// The task object representing the asynchronous operation containing the /// for the sign-in attempt. - /// public virtual async Task CheckPasswordSignInAsync(TUser user, string password, bool lockoutOnFailure) { ArgumentNullException.ThrowIfNull(user); @@ -432,6 +464,354 @@ public virtual async Task CheckPasswordSignInAsync(TUser user, str return SignInResult.Failed; } + /// + /// Performs passkey attestation for the given and . + /// + /// 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) + { + ThrowIfNoPasskeyHandler(); + ArgumentException.ThrowIfNullOrEmpty(credentialJson); + ArgumentNullException.ThrowIfNull(options); + + var context = new PasskeyAttestationContext + { + CredentialJson = credentialJson, + OriginalOptionsJson = options.AsJson(), + UserManager = UserManager, + HttpContext = Context, + }; + var result = await _passkeyHandler.PerformAttestationAsync(context).ConfigureAwait(false); + if (!result.Succeeded) + { + Logger.LogDebug(EventIds.PasskeyAttestationFailed, "Passkey attestation failed: {message}", result.Failure.Message); + } + + return result; + } + + /// + /// Performs passkey assertion for the given and . + /// + /// 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) + { + ThrowIfNoPasskeyHandler(); + ArgumentException.ThrowIfNullOrEmpty(credentialJson); + ArgumentNullException.ThrowIfNull(options); + + var user = options.UserId is { Length: > 0 } userId ? await UserManager.FindByIdAsync(userId) : null; + var context = new PasskeyAssertionContext + { + User = user, + CredentialJson = credentialJson, + OriginalOptionsJson = options.AsJson(), + UserManager = UserManager, + HttpContext = Context, + }; + var result = await _passkeyHandler.PerformAssertionAsync(context); + if (!result.Succeeded) + { + Logger.LogDebug(EventIds.PasskeyAssertionFailed, "Passkey assertion failed: {message}", result.Failure.Message); + } + + 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 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) + { + ArgumentException.ThrowIfNullOrEmpty(credentialJson); + + var assertionResult = await PerformPasskeyAssertionAsync(credentialJson, options); + if (!assertionResult.Succeeded) + { + return SignInResult.Failed; + } + + // 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); + if (!setPasskeyResult.Succeeded) + { + return SignInResult.Failed; + } + + 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) + { + 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() + { + 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]; + } + } + + /// + /// 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) + { + ArgumentNullException.ThrowIfNull(requestArgs); + + var options = await GeneratePasskeyRequestOptionsAsync(requestArgs); + + var props = new AuthenticationProperties(); + props.Items[PasskeyRequestOptionsKey] = options.AsJson(); + 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) + { + 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); + + async Task GetAllowCredentialsAsync() + { + if (requestArgs?.User is not { } user) + { + return []; + } + + 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(PasskeyRequestOptionsKey, out var optionsJson) || optionsJson is null) + { + return null; + } + + var userId = result.Principal.FindFirstValue(ClaimTypes.NameIdentifier); + _passkeyRequestOptions = new(userId, optionsJson); + return _passkeyRequestOptions; + } + /// /// Returns a flag indicating if the current client browser has been remembered by two factor authentication /// for the user attempting to login, as an asynchronous operation. @@ -542,7 +922,7 @@ private async Task DoTwoFactorSignInAsync(TUser user, TwoFactorAut /// Flag indicating whether the sign-in cookie should persist after the browser is closed. /// Flag indicating whether the current browser should be remember, suppressing all further /// two factor authentication prompts. - /// The task object representing the asynchronous operation containing the + /// The task object representing the asynchronous operation containing the /// for the sign-in attempt. public virtual async Task TwoFactorAuthenticatorSignInAsync(string code, bool isPersistent, bool rememberClient) { @@ -590,7 +970,7 @@ public virtual async Task TwoFactorAuthenticatorSignInAsync(string /// Flag indicating whether the sign-in cookie should persist after the browser is closed. /// Flag indicating whether the current browser should be remember, suppressing all further /// two factor authentication prompts. - /// The task object representing the asynchronous operation containing the + /// The task object representing the asynchronous operation containing the /// for the sign-in attempt. public virtual async Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberClient) { @@ -651,7 +1031,7 @@ public virtual async Task TwoFactorSignInAsync(string provider, st /// The login provider to use. /// The unique provider identifier for the user. /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// The task object representing the asynchronous operation containing the + /// The task object representing the asynchronous operation containing the /// for the sign-in attempt. public virtual Task ExternalLoginSignInAsync(string loginProvider, string providerKey, bool isPersistent) => ExternalLoginSignInAsync(loginProvider, providerKey, isPersistent, bypassTwoFactor: false); @@ -663,7 +1043,7 @@ public virtual Task ExternalLoginSignInAsync(string loginProvider, /// The unique provider identifier for the user. /// Flag indicating whether the sign-in cookie should persist after the browser is closed. /// Flag indicating whether to bypass two factor authentication. - /// The task object representing the asynchronous operation containing the + /// The task object representing the asynchronous operation containing the /// for the sign-in attempt. public virtual async Task ExternalLoginSignInAsync(string loginProvider, string providerKey, bool isPersistent, bool bypassTwoFactor) { @@ -695,7 +1075,7 @@ public virtual async Task> GetExternalAuthenti /// Gets the external login information for the current login, as an asynchronous operation. /// /// Flag indication whether a Cross Site Request Forgery token was expected in the current request. - /// The task object representing the asynchronous operation containing the + /// The task object representing the asynchronous operation containing the /// for the sign-in attempt. public virtual async Task GetExternalLoginInfoAsync(string? expectedXsrf = null) { diff --git a/src/Identity/EntityFrameworkCore/src/IdentityDbContext.cs b/src/Identity/EntityFrameworkCore/src/IdentityDbContext.cs index fd297f425093..25f5076bc6ca 100644 --- a/src/Identity/EntityFrameworkCore/src/IdentityDbContext.cs +++ b/src/Identity/EntityFrameworkCore/src/IdentityDbContext.cs @@ -74,7 +74,7 @@ protected IdentityDbContext() { } /// The type of the user login object. /// The type of the role claim object. /// The type of the user token object. -public abstract class IdentityDbContext : IdentityUserContext +public class IdentityDbContext : IdentityDbContext> where TUser : IdentityUser where TRole : IdentityRole where TKey : IEquatable @@ -83,6 +83,41 @@ public abstract class IdentityDbContext where TRoleClaim : IdentityRoleClaim where TUserToken : IdentityUserToken +{ + /// + /// Initializes a new instance of the db context. + /// + /// The options to be used by a . + public IdentityDbContext(DbContextOptions options) : base(options) { } + + /// + /// Initializes a new instance of the class. + /// + protected IdentityDbContext() { } +} + +/// +/// Base class for the Entity Framework database context used for identity. +/// +/// The type of user objects. +/// The type of role objects. +/// The type of the primary key for users and roles. +/// The type of the user claim object. +/// The type of the user role object. +/// The type of the user login object. +/// The type of the role claim object. +/// The type of the user token object. +/// The type of the user token object. +public abstract class IdentityDbContext : IdentityUserContext + where TUser : IdentityUser + where TRole : IdentityRole + where TKey : IEquatable + where TUserClaim : IdentityUserClaim + where TUserRole : IdentityUserRole + where TUserLogin : IdentityUserLogin + where TRoleClaim : IdentityRoleClaim + where TUserToken : IdentityUserToken + where TUserPasskey : IdentityUserPasskey { /// /// Initializes a new instance of the class. @@ -121,6 +156,49 @@ protected override void OnModelCreating(ModelBuilder builder) base.OnModelCreating(builder); } + /// + /// Configures the schema needed for the identity framework for schema version 3.0 + /// + /// + /// The builder being used to construct the model for this context. + /// + internal override void OnModelCreatingVersion3(ModelBuilder builder) + { + base.OnModelCreatingVersion3(builder); + + // Currently no differences between Version 3 and Version 2 + builder.Entity(b => + { + b.HasMany().WithOne().HasForeignKey(ur => ur.UserId).IsRequired(); + }); + + builder.Entity(b => + { + b.HasKey(r => r.Id); + b.HasIndex(r => r.NormalizedName).HasDatabaseName("RoleNameIndex").IsUnique(); + b.ToTable("AspNetRoles"); + b.Property(r => r.ConcurrencyStamp).IsConcurrencyToken(); + + b.Property(u => u.Name).HasMaxLength(256); + b.Property(u => u.NormalizedName).HasMaxLength(256); + + b.HasMany().WithOne().HasForeignKey(ur => ur.RoleId).IsRequired(); + b.HasMany().WithOne().HasForeignKey(rc => rc.RoleId).IsRequired(); + }); + + builder.Entity(b => + { + b.HasKey(rc => rc.Id); + b.ToTable("AspNetRoleClaims"); + }); + + builder.Entity(b => + { + b.HasKey(r => new { r.UserId, r.RoleId }); + b.ToTable("AspNetUserRoles"); + }); + } + /// /// Configures the schema needed for the identity framework for schema version 2.0 /// @@ -131,7 +209,7 @@ internal override void OnModelCreatingVersion2(ModelBuilder builder) { base.OnModelCreatingVersion2(builder); - // Current no differences between Version 2 and Version 1 + // No differences between Version 2 and Version 1 builder.Entity(b => { b.HasMany().WithOne().HasForeignKey(ur => ur.UserId).IsRequired(); diff --git a/src/Identity/EntityFrameworkCore/src/IdentityEntityFrameworkBuilderExtensions.cs b/src/Identity/EntityFrameworkCore/src/IdentityEntityFrameworkBuilderExtensions.cs index 52beacac6f6d..50584e72a1af 100644 --- a/src/Identity/EntityFrameworkCore/src/IdentityEntityFrameworkBuilderExtensions.cs +++ b/src/Identity/EntityFrameworkCore/src/IdentityEntityFrameworkBuilderExtensions.cs @@ -46,7 +46,7 @@ private static void AddStores(IServiceCollection services, Type userType, Type? Type userStoreType; Type roleStoreType; - var identityContext = FindGenericBaseType(contextType, typeof(IdentityDbContext<,,,,,,,>)); + var identityContext = FindGenericBaseType(contextType, typeof(IdentityDbContext<,,,,,,,,>)); if (identityContext == null) { // If its a custom DbContext, we can only add the default POCOs @@ -55,13 +55,14 @@ private static void AddStores(IServiceCollection services, Type userType, Type? } else { - userStoreType = typeof(UserStore<,,,,,,,,>).MakeGenericType(userType, roleType, contextType, + userStoreType = typeof(UserStore<,,,,,,,,,>).MakeGenericType(userType, roleType, contextType, identityContext.GenericTypeArguments[2], identityContext.GenericTypeArguments[3], identityContext.GenericTypeArguments[4], identityContext.GenericTypeArguments[5], identityContext.GenericTypeArguments[7], - identityContext.GenericTypeArguments[6]); + identityContext.GenericTypeArguments[6], + identityContext.GenericTypeArguments[8]); roleStoreType = typeof(RoleStore<,,,,>).MakeGenericType(roleType, contextType, identityContext.GenericTypeArguments[2], identityContext.GenericTypeArguments[4], @@ -73,7 +74,7 @@ private static void AddStores(IServiceCollection services, Type userType, Type? else { // No Roles Type userStoreType; - var identityContext = FindGenericBaseType(contextType, typeof(IdentityUserContext<,,,,>)); + var identityContext = FindGenericBaseType(contextType, typeof(IdentityUserContext<,,,,,>)); if (identityContext == null) { // If its a custom DbContext, we can only add the default POCOs diff --git a/src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs b/src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs index 84e7bfc5282f..fa9968d844c4 100644 --- a/src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs +++ b/src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs @@ -57,12 +57,41 @@ protected IdentityUserContext() { } /// The type of the user claim object. /// The type of the user login object. /// The type of the user token object. -public abstract class IdentityUserContext : DbContext +public class IdentityUserContext : IdentityUserContext> where TUser : IdentityUser where TKey : IEquatable where TUserClaim : IdentityUserClaim where TUserLogin : IdentityUserLogin where TUserToken : IdentityUserToken +{ + /// + /// Initializes a new instance of the db context. + /// + /// The options to be used by a . + public IdentityUserContext(DbContextOptions options) : base(options) { } + + /// + /// Initializes a new instance of the class. + /// + protected IdentityUserContext() { } +} + +/// +/// Base class for the Entity Framework database context used for identity. +/// +/// The type of user objects. +/// The type of the primary key for users and roles. +/// The type of the user claim object. +/// The type of the user login object. +/// The type of the user token object. +/// The type of the user passkey object. +public abstract class IdentityUserContext : DbContext + where TUser : IdentityUser + where TKey : IEquatable + where TUserClaim : IdentityUserClaim + where TUserLogin : IdentityUserLogin + where TUserToken : IdentityUserToken + where TUserPasskey : IdentityUserPasskey { /// /// Initializes a new instance of the class. @@ -95,6 +124,11 @@ protected IdentityUserContext() { } /// public virtual DbSet UserTokens { get; set; } = default!; + /// + /// Gets or sets the of User passkeys. + /// + public virtual DbSet UserPasskeys { get; set; } = default!; + /// /// Gets the schema version used for versioning. /// @@ -133,7 +167,11 @@ protected override void OnModelCreating(ModelBuilder builder) /// The schema version. internal virtual void OnModelCreatingVersion(ModelBuilder builder, Version schemaVersion) { - if (schemaVersion >= IdentitySchemaVersions.Version2) + if (schemaVersion >= IdentitySchemaVersions.Version3) + { + OnModelCreatingVersion3(builder); + } + else if (schemaVersion >= IdentitySchemaVersions.Version2) { OnModelCreatingVersion2(builder); } @@ -143,6 +181,116 @@ internal virtual void OnModelCreatingVersion(ModelBuilder builder, Version schem } } + /// + /// Configures the schema needed for the identity framework for schema version 3.0 + /// + /// + /// The builder being used to construct the model for this context. + /// + internal virtual void OnModelCreatingVersion3(ModelBuilder builder) + { + // Differences from Version 2: + // - Add a passkey entity + + var storeOptions = GetStoreOptions(); + var maxKeyLength = storeOptions?.MaxLengthForKeys ?? 0; + if (maxKeyLength == 0) + { + maxKeyLength = 128; + } + var encryptPersonalData = storeOptions?.ProtectPersonalData ?? false; + PersonalDataConverter? converter = null; + + builder.Entity(b => + { + b.HasKey(u => u.Id); + b.HasIndex(u => u.NormalizedUserName).HasDatabaseName("UserNameIndex").IsUnique(); + b.HasIndex(u => u.NormalizedEmail).HasDatabaseName("EmailIndex"); + b.ToTable("AspNetUsers"); + b.Property(u => u.ConcurrencyStamp).IsConcurrencyToken(); + + b.Property(u => u.UserName).HasMaxLength(256); + b.Property(u => u.NormalizedUserName).HasMaxLength(256); + b.Property(u => u.Email).HasMaxLength(256); + b.Property(u => u.NormalizedEmail).HasMaxLength(256); + b.Property(u => u.PhoneNumber).HasMaxLength(256); + + if (encryptPersonalData) + { + converter = new PersonalDataConverter(this.GetService()); + var personalDataProps = typeof(TUser).GetProperties().Where( + prop => Attribute.IsDefined(prop, typeof(ProtectedPersonalDataAttribute))); + foreach (var p in personalDataProps) + { + if (p.PropertyType != typeof(string)) + { + throw new InvalidOperationException(Resources.CanOnlyProtectStrings); + } + b.Property(typeof(string), p.Name).HasConversion(converter); + } + } + + b.HasMany().WithOne().HasForeignKey(uc => uc.UserId).IsRequired(); + b.HasMany().WithOne().HasForeignKey(ul => ul.UserId).IsRequired(); + b.HasMany().WithOne().HasForeignKey(ut => ut.UserId).IsRequired(); + b.HasMany().WithOne().HasForeignKey(up => up.UserId).IsRequired(); + }); + + builder.Entity(b => + { + b.HasKey(uc => uc.Id); + b.ToTable("AspNetUserClaims"); + }); + + builder.Entity(b => + { + b.HasKey(l => new { l.LoginProvider, l.ProviderKey }); + + if (maxKeyLength > 0) + { + b.Property(l => l.LoginProvider).HasMaxLength(maxKeyLength); + b.Property(l => l.ProviderKey).HasMaxLength(maxKeyLength); + } + + b.ToTable("AspNetUserLogins"); + }); + + builder.Entity(b => + { + b.HasKey(t => new { t.UserId, t.LoginProvider, t.Name }); + + if (maxKeyLength > 0) + { + b.Property(t => t.LoginProvider).HasMaxLength(maxKeyLength); + b.Property(t => t.Name).HasMaxLength(maxKeyLength); + } + + if (encryptPersonalData) + { + var tokenProps = typeof(TUserToken).GetProperties().Where( + prop => Attribute.IsDefined(prop, typeof(ProtectedPersonalDataAttribute))); + foreach (var p in tokenProps) + { + if (p.PropertyType != typeof(string)) + { + throw new InvalidOperationException(Resources.CanOnlyProtectStrings); + } + b.Property(typeof(string), p.Name).HasConversion(converter); + } + } + + b.ToTable("AspNetUserTokens"); + }); + + builder.Entity(b => + { + 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 + }); + } + /// /// Configures the schema needed for the identity framework for schema version 2.0 /// @@ -243,6 +391,8 @@ internal virtual void OnModelCreatingVersion2(ModelBuilder builder) b.ToTable("AspNetUserTokens"); }); + + builder.Ignore(); } /// @@ -336,5 +486,7 @@ internal virtual void OnModelCreatingVersion1(ModelBuilder builder) b.ToTable("AspNetUserTokens"); }); + + builder.Ignore(); } } diff --git a/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt b/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..c91fed9a68a2 100644 --- a/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt +++ b/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt @@ -1 +1,190 @@ #nullable enable +Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext +Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.IdentityDbContext() -> void +Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.IdentityDbContext(Microsoft.EntityFrameworkCore.DbContextOptions! options) -> void +Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext +Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.IdentityUserContext() -> void +Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.IdentityUserContext(Microsoft.EntityFrameworkCore.DbContextOptions! options) -> void +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AutoSaveChanges.get -> bool +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AutoSaveChanges.set -> void +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.SaveChanges(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UserClaims.get -> Microsoft.EntityFrameworkCore.DbSet! +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UserLogins.get -> Microsoft.EntityFrameworkCore.DbSet! +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UserOnlyStore(TContext! context, Microsoft.AspNetCore.Identity.IdentityErrorDescriber? describer = null) -> void +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UserPasskeys.get -> Microsoft.EntityFrameworkCore.DbSet! +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UsersSet.get -> Microsoft.EntityFrameworkCore.DbSet! +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UserTokens.get -> Microsoft.EntityFrameworkCore.DbSet! +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AutoSaveChanges.get -> bool +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AutoSaveChanges.set -> void +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.SaveChanges(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.UserStore(TContext! context, Microsoft.AspNetCore.Identity.IdentityErrorDescriber? describer = null) -> void +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.OnModelCreating(Microsoft.EntityFrameworkCore.ModelBuilder! builder) -> void +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.OnModelCreating(Microsoft.EntityFrameworkCore.ModelBuilder! builder) -> void +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AddClaimsAsync(TUser! user, System.Collections.Generic.IEnumerable! claims, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AddLoginAsync(TUser! user, Microsoft.AspNetCore.Identity.UserLoginInfo! login, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AddUserTokenAsync(TUserToken! token) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.CreateAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.DeleteAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByEmailAsync(string! normalizedEmail, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByIdAsync(string! userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByNameAsync(string! normalizedUserName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindTokenAsync(TUser! user, string! loginProvider, string! name, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserAsync(TKey userId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserLoginAsync(TKey userId, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.GetClaimsAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.GetLoginsAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.GetUsersForClaimAsync(System.Security.Claims.Claim! claim, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.RemoveClaimsAsync(TUser! user, System.Collections.Generic.IEnumerable! claims, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.RemoveLoginAsync(TUser! user, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.RemoveUserTokenAsync(TUserToken! token) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.ReplaceClaimAsync(TUser! user, System.Security.Claims.Claim! claim, System.Security.Claims.Claim! newClaim, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UpdateAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.Users.get -> System.Linq.IQueryable! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddClaimsAsync(TUser! user, System.Collections.Generic.IEnumerable! claims, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddLoginAsync(TUser! user, Microsoft.AspNetCore.Identity.UserLoginInfo! login, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddToRoleAsync(TUser! user, string! normalizedRoleName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddUserTokenAsync(TUserToken! token) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.CreateAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.DeleteAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByEmailAsync(string! normalizedEmail, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByIdAsync(string! userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByNameAsync(string! normalizedUserName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindRoleAsync(string! normalizedRoleName, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindTokenAsync(TUser! user, string! loginProvider, string! name, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserAsync(TKey userId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserLoginAsync(TKey userId, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserRoleAsync(TKey userId, TKey roleId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetClaimsAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetLoginsAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetRolesAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetUsersForClaimAsync(System.Security.Claims.Claim! claim, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetUsersInRoleAsync(string! normalizedRoleName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.IsInRoleAsync(TUser! user, string! normalizedRoleName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemoveClaimsAsync(TUser! user, System.Collections.Generic.IEnumerable! claims, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemoveFromRoleAsync(TUser! user, string! normalizedRoleName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemoveLoginAsync(TUser! user, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemoveUserTokenAsync(TUserToken! token) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.ReplaceClaimAsync(TUser! user, System.Security.Claims.Claim! claim, System.Security.Claims.Claim! newClaim, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.UpdateAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.Users.get -> System.Linq.IQueryable! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.RoleClaims.get -> Microsoft.EntityFrameworkCore.DbSet! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.RoleClaims.set -> void +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.Roles.get -> Microsoft.EntityFrameworkCore.DbSet! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.Roles.set -> void +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.UserRoles.get -> Microsoft.EntityFrameworkCore.DbSet! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.UserRoles.set -> void +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.SchemaVersion.get -> System.Version! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserClaims.get -> Microsoft.EntityFrameworkCore.DbSet! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserClaims.set -> void +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserLogins.get -> Microsoft.EntityFrameworkCore.DbSet! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserLogins.set -> void +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserPasskeys.get -> Microsoft.EntityFrameworkCore.DbSet! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserPasskeys.set -> void +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.Users.get -> Microsoft.EntityFrameworkCore.DbSet! +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.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! +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.UserOnlyStore.SetPasskeyAsync(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! +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! +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! +*REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UserClaims.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UserLogins.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UsersSet.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UserTokens.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AutoSaveChanges.get -> bool +*REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AutoSaveChanges.set -> void +*REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.SaveChanges(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.OnModelCreating(Microsoft.EntityFrameworkCore.ModelBuilder! builder) -> void +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.OnModelCreating(Microsoft.EntityFrameworkCore.ModelBuilder! builder) -> void +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AddClaimsAsync(TUser! user, System.Collections.Generic.IEnumerable! claims, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AddLoginAsync(TUser! user, Microsoft.AspNetCore.Identity.UserLoginInfo! login, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AddUserTokenAsync(TUserToken! token) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.CreateAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.DeleteAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByEmailAsync(string! normalizedEmail, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByIdAsync(string! userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByNameAsync(string! normalizedUserName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindTokenAsync(TUser! user, string! loginProvider, string! name, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserAsync(TKey userId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserLoginAsync(TKey userId, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.GetClaimsAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.GetLoginsAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.GetUsersForClaimAsync(System.Security.Claims.Claim! claim, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.RemoveClaimsAsync(TUser! user, System.Collections.Generic.IEnumerable! claims, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.RemoveLoginAsync(TUser! user, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.RemoveUserTokenAsync(TUserToken! token) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.ReplaceClaimAsync(TUser! user, System.Security.Claims.Claim! claim, System.Security.Claims.Claim! newClaim, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UpdateAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.Users.get -> System.Linq.IQueryable! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddClaimsAsync(TUser! user, System.Collections.Generic.IEnumerable! claims, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddLoginAsync(TUser! user, Microsoft.AspNetCore.Identity.UserLoginInfo! login, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddToRoleAsync(TUser! user, string! normalizedRoleName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddUserTokenAsync(TUserToken! token) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.CreateAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.DeleteAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByEmailAsync(string! normalizedEmail, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByIdAsync(string! userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByNameAsync(string! normalizedUserName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindRoleAsync(string! normalizedRoleName, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindTokenAsync(TUser! user, string! loginProvider, string! name, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserAsync(TKey userId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserLoginAsync(TKey userId, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserRoleAsync(TKey userId, TKey roleId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetClaimsAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetLoginsAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetRolesAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetUsersForClaimAsync(System.Security.Claims.Claim! claim, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetUsersInRoleAsync(string! normalizedRoleName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.IsInRoleAsync(TUser! user, string! normalizedRoleName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemoveClaimsAsync(TUser! user, System.Collections.Generic.IEnumerable! claims, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemoveFromRoleAsync(TUser! user, string! normalizedRoleName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemoveLoginAsync(TUser! user, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemoveUserTokenAsync(TUserToken! token) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.ReplaceClaimAsync(TUser! user, System.Security.Claims.Claim! claim, System.Security.Claims.Claim! newClaim, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.UpdateAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.Users.get -> System.Linq.IQueryable! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.RoleClaims.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.RoleClaims.set -> void +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.Roles.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.Roles.set -> void +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.UserRoles.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.UserRoles.set -> void +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.SchemaVersion.get -> System.Version! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserClaims.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserClaims.set -> void +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserLogins.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserLogins.set -> void +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.Users.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.Users.set -> void +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserTokens.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserTokens.set -> void +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.Context.get -> TContext! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.Context.get -> TContext! diff --git a/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs b/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs index 7a7974c74791..9135b94d309f 100644 --- a/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs @@ -67,6 +67,33 @@ public UserOnlyStore(TContext context, IdentityErrorDescriber? describer = null) /// The type representing a user external login. /// The type representing a user token. public class UserOnlyStore : + UserOnlyStore> + where TUser : IdentityUser + where TContext : DbContext + where TKey : IEquatable + where TUserClaim : IdentityUserClaim, new() + where TUserLogin : IdentityUserLogin, new() + where TUserToken : IdentityUserToken, new() +{ + /// + /// Constructs a new instance of . + /// + /// The . + /// The . + public UserOnlyStore(TContext context, IdentityErrorDescriber? describer = null) : base(context, describer) { } +} + +/// +/// Represents a new instance of a persistence store for the specified user and role types. +/// +/// The type representing a user. +/// The type of the data context class used to access the store. +/// The type of the primary key for a role. +/// The type representing a claim. +/// The type representing a user external login. +/// The type representing a user token. +/// The type representing a user passkey. +public class UserOnlyStore : UserStoreBase, IUserLoginStore, IUserClaimStore, @@ -80,14 +107,18 @@ public class UserOnlyStore, IUserAuthenticatorKeyStore, IUserTwoFactorRecoveryCodeStore, - IProtectedUserStore + IProtectedUserStore, + IUserPasskeyStore where TUser : IdentityUser where TContext : DbContext where TKey : IEquatable where TUserClaim : IdentityUserClaim, new() where TUserLogin : IdentityUserLogin, new() where TUserToken : IdentityUserToken, new() + where TUserPasskey : IdentityUserPasskey, new() { + private bool? _dbContextSupportsPasskeys; + /// /// Creates a new instance of the store. /// @@ -124,6 +155,18 @@ public class UserOnlyStore protected DbSet UserTokens { get { return Context.Set(); } } + /// + /// DbSet of user passkeys. + /// + protected 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. /// @@ -510,4 +553,212 @@ protected override Task RemoveUserTokenAsync(TUserToken token) UserTokens.Remove(token); return Task.CompletedTask; } + + /// + /// Called to create a new instance of a . + /// + /// The user. + /// The passkey. + /// + protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo passkey) + { + return new TUserPasskey + { + 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, + }; + } + + /// + /// Find a passkey with the specified credential id for a user. + /// + /// The user's id. + /// 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) + { + return UserPasskeys.SingleOrDefaultAsync( + userPasskey => userPasskey.UserId.Equals(userId) && userPasskey.CredentialId.SequenceEqual(credentialId), + cancellationToken); + } + + /// + /// Find a passkey with the specified credential id. + /// + /// 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) + { + return UserPasskeys.SingleOrDefaultAsync(userPasskey => userPasskey.CredentialId.SequenceEqual(credentialId), cancellationToken); + } + + /// + /// Creates a new passkey credential in the store for the specified , + /// or updates an existing passkey. + /// + /// The user to create the passkey credential for. + /// + /// 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) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(passkey); + + 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; + UserPasskeys.Update(userPasskey); + } + else + { + userPasskey = CreateUserPasskey(user, passkey); + UserPasskeys.Add(userPasskey); + } + + await SaveChanges(cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets the passkey credentials for the specified . + /// + /// The user whose passkeys should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing a list of the user's passkeys. + public virtual async Task> GetPasskeysAsync(TUser user, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + var userId = user.Id; + var passkeys = await UserPasskeys + .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)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return passkeys; + } + + /// + /// Finds and returns a user, if any, associated with the specified passkey credential identifier. + /// + /// The passkey credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, containing the user, if any, associated with the specified passkey credential id. + /// + public virtual async Task FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + var passkey = await FindUserPasskeyByIdAsync(credentialId, cancellationToken).ConfigureAwait(false); + if (passkey != null) + { + return await FindUserAsync(passkey.UserId, cancellationToken).ConfigureAwait(false); + } + return null; + } + + /// + /// Finds a passkey for the specified user with the specified credential id. + /// + /// The user whose passkey should be retrieved. + /// The credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the user's passkey information. + public virtual async Task FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + var passkey = await FindUserPasskeyAsync(user.Id, credentialId, cancellationToken).ConfigureAwait(false); + if (passkey != null) + { + 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); + } + return null; + } + + /// + /// Removes a passkey credential from the specified . + /// + /// The user to remove the passkey credential from. + /// The credential id of the passkey to remove. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual async Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(credentialId); + + var passkey = await FindUserPasskeyAsync(user.Id, credentialId, cancellationToken).ConfigureAwait(false); + if (passkey != null) + { + UserPasskeys.Remove(passkey); + 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."); + } + } } diff --git a/src/Identity/EntityFrameworkCore/src/UserStore.cs b/src/Identity/EntityFrameworkCore/src/UserStore.cs index 51c63c694928..0165b2fc01e4 100644 --- a/src/Identity/EntityFrameworkCore/src/UserStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserStore.cs @@ -91,8 +91,42 @@ public UserStore(TContext context, IdentityErrorDescriber? describer = null) : b /// The type representing a user token. /// The type representing a role claim. public class UserStore : + UserStore> + where TUser : IdentityUser + where TRole : IdentityRole + where TContext : DbContext + where TKey : IEquatable + where TUserClaim : IdentityUserClaim, new() + where TUserRole : IdentityUserRole, new() + where TUserLogin : IdentityUserLogin, new() + where TUserToken : IdentityUserToken, new() + where TRoleClaim : IdentityRoleClaim, new() +{ + /// + /// Constructs a new instance of . + /// + /// The . + /// The . + public UserStore(TContext context, IdentityErrorDescriber? describer = null) : base(context, describer) { } +} + +/// +/// Represents a new instance of a persistence store for the specified user and role types. +/// +/// The type representing a user. +/// The type representing a role. +/// The type of the data context class used to access the store. +/// The type of the primary key for a role. +/// The type representing a claim. +/// The type representing a user role. +/// The type representing a user external login. +/// The type representing a user token. +/// The type representing a role claim. +/// The type representing a user passkey. +public class UserStore : UserStoreBase, - IProtectedUserStore + IProtectedUserStore, + IUserPasskeyStore where TUser : IdentityUser where TRole : IdentityRole where TContext : DbContext @@ -102,6 +136,7 @@ public class UserStore, new() where TUserToken : IdentityUserToken, new() where TRoleClaim : IdentityRoleClaim, new() + where TUserPasskey : IdentityUserPasskey, new() { /// /// Creates a new instance of the store. @@ -125,6 +160,7 @@ 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(); } } /// /// Gets or sets a flag indicating if changes should be persisted after CreateAsync, UpdateAsync and DeleteAsync are called. @@ -653,4 +689,194 @@ protected override Task RemoveUserTokenAsync(TUserToken token) UserTokens.Remove(token); return Task.CompletedTask; } + + /// + /// Called to create a new instance of a . + /// + /// The user. + /// The passkey. + /// + protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo passkey) + { + return new TUserPasskey + { + 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, + }; + } + + /// + /// Find a passkey with the specified credential id for a user. + /// + /// The user's id. + /// 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) + { + return UserPasskeys.SingleOrDefaultAsync( + userPasskey => userPasskey.UserId.Equals(userId) && userPasskey.CredentialId.SequenceEqual(credentialId), + cancellationToken); + } + + /// + /// Find a passkey with the specified credential id. + /// + /// 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) + { + return UserPasskeys.SingleOrDefaultAsync(userPasskey => userPasskey.CredentialId.SequenceEqual(credentialId), cancellationToken); + } + + /// + /// Creates a new passkey credential in the store for the specified , + /// or updates an existing passkey. + /// + /// The user to create the passkey credential for. + /// + /// 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) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(passkey); + + var userPasskey = await FindUserPasskeyByIdAsync(passkey.CredentialId, cancellationToken).ConfigureAwait(false); + if (userPasskey != null) + { + userPasskey.SignCount = passkey.SignCount; + userPasskey.IsBackedUp = passkey.IsBackedUp; + userPasskey.IsUserVerified = passkey.IsUserVerified; + UserPasskeys.Update(userPasskey); + } + else + { + userPasskey = CreateUserPasskey(user, passkey); + UserPasskeys.Add(userPasskey); + } + + await SaveChanges(cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets the passkey credentials for the specified . + /// + /// The user whose passkeys should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing a list of the user's passkeys. + public virtual async Task> GetPasskeysAsync(TUser user, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + var userId = user.Id; + var passkeys = await UserPasskeys + .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)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return passkeys; + } + + /// + /// Finds and returns a user, if any, associated with the specified passkey credential identifier. + /// + /// The passkey credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, containing the user, if any, associated with the specified passkey credential id. + /// + public virtual async Task FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + var passkey = await FindUserPasskeyByIdAsync(credentialId, cancellationToken).ConfigureAwait(false); + if (passkey != null) + { + return await FindUserAsync(passkey.UserId, cancellationToken).ConfigureAwait(false); + } + return null; + } + + /// + /// Finds a passkey for the specified user with the specified credential id. + /// + /// The user whose passkey should be retrieved. + /// The credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the user's passkey information. + public virtual async Task FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(credentialId); + + var passkey = await FindUserPasskeyAsync(user.Id, credentialId, cancellationToken).ConfigureAwait(false); + if (passkey != null) + { + 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); + } + return null; + } + + /// + /// Removes a passkey credential from the specified . + /// + /// The user to remove the passkey credential from. + /// The credential id of the passkey to remove. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual async Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(credentialId); + + var passkey = await FindUserPasskeyAsync(user.Id, credentialId, cancellationToken).ConfigureAwait(false); + if (passkey != null) + { + UserPasskeys.Remove(passkey); + await SaveChanges(cancellationToken).ConfigureAwait(false); + } + } } diff --git a/src/Identity/EntityFrameworkCore/test/EF.Test/VersionTestDbContext.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/VersionTestDbContext.cs index 0e0c29407052..959fbb142a46 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.Test/VersionTestDbContext.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/VersionTestDbContext.cs @@ -49,6 +49,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Ignore>(); + builder.Ignore>(); } else { diff --git a/src/Identity/Extensions.Core/src/IUserPasskeyStore.cs b/src/Identity/Extensions.Core/src/IUserPasskeyStore.cs new file mode 100644 index 000000000000..0e25bb3c3753 --- /dev/null +++ b/src/Identity/Extensions.Core/src/IUserPasskeyStore.cs @@ -0,0 +1,62 @@ +// 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.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Provides an abstraction for storing passkey credentials for a user. +/// +/// The type that represents a user. +public interface IUserPasskeyStore : IUserStore where TUser : class +{ + /// + /// Adds a new passkey credential in the store for the specified , + /// or updates an existing passkey. + /// + /// The user to create the passkey credential for. + /// 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); + + /// + /// Gets the passkey credentials for the specified . + /// + /// The user whose passkeys should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing a list of the user's passkeys. + Task> GetPasskeysAsync(TUser user, CancellationToken cancellationToken); + + /// + /// Finds and returns a user, if any, associated with the specified passkey credential identifier. + /// + /// The passkey credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, containing the user, if any, associated with the specified passkey credential id. + /// + Task FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken); + + /// + /// Finds a passkey for the specified user with the specified credential id. + /// + /// The user whose passkey should be retrieved. + /// The credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the user's passkey information. + Task FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken); + + /// + /// Removes a passkey credential from the specified . + /// + /// The user to remove the passkey credential from. + /// The credential id of the passkey to remove. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken); +} diff --git a/src/Identity/Extensions.Core/src/IdentityOptions.cs b/src/Identity/Extensions.Core/src/IdentityOptions.cs index 57ab10a6f9c1..458d46f16a96 100644 --- a/src/Identity/Extensions.Core/src/IdentityOptions.cs +++ b/src/Identity/Extensions.Core/src/IdentityOptions.cs @@ -32,6 +32,14 @@ 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/IdentitySchemaVersions.cs b/src/Identity/Extensions.Core/src/IdentitySchemaVersions.cs index 76f8e892485a..973e4c9c42ab 100644 --- a/src/Identity/Extensions.Core/src/IdentitySchemaVersions.cs +++ b/src/Identity/Extensions.Core/src/IdentitySchemaVersions.cs @@ -25,4 +25,8 @@ public static class IdentitySchemaVersions /// public static readonly Version Version2 = new Version(2, 0); + /// + /// Represents the 3.0 version of the identity schema + /// + public static readonly Version Version3 = new Version(3, 0); } diff --git a/src/Identity/Extensions.Core/src/Microsoft.Extensions.Identity.Core.csproj b/src/Identity/Extensions.Core/src/Microsoft.Extensions.Identity.Core.csproj index 1174c460f250..a1315dd79254 100644 --- a/src/Identity/Extensions.Core/src/Microsoft.Extensions.Identity.Core.csproj +++ b/src/Identity/Extensions.Core/src/Microsoft.Extensions.Identity.Core.csproj @@ -17,12 +17,12 @@ + - + diff --git a/src/Identity/Extensions.Core/src/PasskeyOptions.cs b/src/Identity/Extensions.Core/src/PasskeyOptions.cs new file mode 100644 index 000000000000..e274a3c3762a --- /dev/null +++ b/src/Identity/Extensions.Core/src/PasskeyOptions.cs @@ -0,0 +1,105 @@ +// 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 58c861652420..52862f56815d 100644 --- a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt @@ -1,3 +1,57 @@ #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[]! +Microsoft.AspNetCore.Identity.UserPasskeyInfo.ClientDataJson.get -> byte[]! +Microsoft.AspNetCore.Identity.UserPasskeyInfo.CreatedAt.get -> System.DateTimeOffset +Microsoft.AspNetCore.Identity.UserPasskeyInfo.CredentialId.get -> byte[]! +Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsBackedUp.get -> bool +Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsBackedUp.set -> void +Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsBackupEligible.get -> bool +Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsUserVerified.get -> bool +Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsUserVerified.set -> void +Microsoft.AspNetCore.Identity.UserPasskeyInfo.Name.get -> string? +Microsoft.AspNetCore.Identity.UserPasskeyInfo.Name.set -> void +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 +static readonly Microsoft.AspNetCore.Identity.IdentitySchemaVersions.Version3 -> System.Version! +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/Resources.resx b/src/Identity/Extensions.Core/src/Resources.resx index fe35a8b7868a..f95299d9d219 100644 --- a/src/Identity/Extensions.Core/src/Resources.resx +++ b/src/Identity/Extensions.Core/src/Resources.resx @@ -1,17 +1,17 @@  - @@ -269,6 +269,10 @@ Store does not implement IUserTwoFactorStore<TUser>. Error when the store does not implement this interface + + Store does not implement IUserPasskeyStore<TUser>. + Error when the store does not implement this interface + Recovery code redemption failed. Error when a recovery code is not redeemed. diff --git a/src/Identity/Extensions.Core/src/UserManager.cs b/src/Identity/Extensions.Core/src/UserManager.cs index cca2005d10d0..f7bcbf38368d 100644 --- a/src/Identity/Extensions.Core/src/UserManager.cs +++ b/src/Identity/Extensions.Core/src/UserManager.cs @@ -373,6 +373,21 @@ public virtual bool SupportsQueryableUsers } } + /// + /// Gets a flag indicating whether the backing user store supports passkeys. + /// + /// + /// true if the backing user store supports passkeys, otherwise false. + /// + public virtual bool SupportsUserPasskey + { + get + { + ThrowIfDisposed(); + return Store is IUserPasskeyStore; + } + } + /// /// Returns an IQueryable of users if the store is an IQueryableUserStore /// @@ -2128,6 +2143,92 @@ public virtual Task CountRecoveryCodesAsync(TUser user) return store.CountCodesAsync(user, CancellationToken); } + /// + /// Adds a new passkey for the given user or updates an existing one. + /// + /// 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) + { + ThrowIfDisposed(); + var passkeyStore = GetUserPasskeyStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); + ArgumentNullThrowHelper.ThrowIfNull(passkey); + + await passkeyStore.SetPasskeyAsync(user, passkey, CancellationToken).ConfigureAwait(false); + return await UpdateUserAsync(user).ConfigureAwait(false); + } + + /// + /// Gets a user's passkeys. + /// + /// The user whose passkeys should be retrieved. + /// + /// The that represents the asynchronous operation, containing a list of the user's passkeys. + /// + public virtual Task> GetPasskeysAsync(TUser user) + { + ThrowIfDisposed(); + var passkeyStore = GetUserPasskeyStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); + + return passkeyStore.GetPasskeysAsync(user, CancellationToken); + } + + /// + /// Finds a user's passkey by its credential id. + /// + /// The user whose passkey should be retrieved. + /// The credential ID to search for. + /// + /// The that represents the asynchronous operation, containing the passkey if found; otherwise . + /// + public virtual Task GetPasskeyAsync(TUser user, byte[] credentialId) + { + ThrowIfDisposed(); + var passkeyStore = GetUserPasskeyStore(); + ArgumentNullThrowHelper.ThrowIfNull(credentialId); + + return passkeyStore.FindPasskeyAsync(user, credentialId, CancellationToken); + } + + /// + /// Finds the user associated with a passkey. + /// + /// The credential ID to search for. + /// + /// The that represents the asynchronous operation, containing the user if found, otherwise . + /// + public virtual Task FindByPasskeyIdAsync(byte[] credentialId) + { + ThrowIfDisposed(); + var passkeyStore = GetUserPasskeyStore(); + ArgumentNullThrowHelper.ThrowIfNull(credentialId); + + return passkeyStore.FindByPasskeyIdAsync(credentialId, CancellationToken); + } + + /// + /// Removes a passkey credential from a user. + /// + /// The user whose passkey should be removed. + /// The credential id of the passkey to remove. + /// + /// The that represents the asynchronous operation, containing the + /// of the operation. + /// + public virtual async Task RemovePasskeyAsync(TUser user, byte[] credentialId) + { + ThrowIfDisposed(); + var passkeyStore = GetUserPasskeyStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); + ArgumentNullThrowHelper.ThrowIfNull(credentialId); + + await passkeyStore.RemovePasskeyAsync(user, credentialId, CancellationToken).ConfigureAwait(false); + return await UpdateUserAsync(user).ConfigureAwait(false); + } + /// /// Releases the unmanaged resources used by the role manager and optionally releases the managed resources. /// @@ -2420,6 +2521,16 @@ private IUserPasswordStore GetPasswordStore() return cast; } + private IUserPasskeyStore GetUserPasskeyStore() + { + var cast = Store as IUserPasskeyStore; + if (cast == null) + { + throw new NotSupportedException(Resources.StoreNotIUserPasskeyStore); + } + return cast; + } + /// /// Throws if this class has been disposed. /// diff --git a/src/Identity/Extensions.Core/src/UserPasskeyInfo.cs b/src/Identity/Extensions.Core/src/UserPasskeyInfo.cs new file mode 100644 index 000000000000..c96335cafba1 --- /dev/null +++ b/src/Identity/Extensions.Core/src/UserPasskeyInfo.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Provides information for a user's passkey credential. +/// +public 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. + /// The passkey's client data JSON. + /// The transports supported by this passkey. + /// Indicates if the passkey has a verified user. + /// Indicates if the passkey is eligible for backup. + /// Indicates if the passkey is currently backed up. + public UserPasskeyInfo( + byte[] credentialId, + byte[] publicKey, + string? name, + DateTimeOffset createdAt, + uint signCount, + string[]? transports, + bool isUserVerified, + bool isBackupEligible, + bool isBackedUp, + byte[] attestationObject, + byte[] clientDataJson) + { + CredentialId = credentialId; + PublicKey = publicKey; + Name = name; + CreatedAt = createdAt; + SignCount = signCount; + Transports = transports; + IsUserVerified = isUserVerified; + IsBackupEligible = isBackupEligible; + IsBackedUp = isBackedUp; + AttestationObject = attestationObject; + ClientDataJson = clientDataJson; + } + + /// + /// Gets the credential ID for this passkey. + /// + public byte[] CredentialId { get; } + + /// + /// Gets the public key associated with this passkey. + /// + public byte[] PublicKey { get; } + + /// + /// Gets or sets the friendly name for this passkey. + /// + public string? Name { get; set; } + + /// + /// Gets the time this passkey was created. + /// + public DateTimeOffset CreatedAt { get; } + + /// + /// Gets or sets the signature counter for this passkey. + /// + public uint SignCount { get; set; } + + /// + /// Gets the transports supported by this passkey. + /// + /// + /// See . + /// + public string[]? Transports { get; } + + /// + /// Gets or sets whether the passkey has a verified user. + /// + public bool IsUserVerified { get; set; } + + /// + /// Gets whether the passkey is eligible for backup. + /// + public bool IsBackupEligible { get; } + + /// + /// Gets or sets whether the passkey is currently backed up. + /// + public bool IsBackedUp { get; set; } + + /// + /// Gets the attestation object associated with this passkey. + /// + /// + /// See . + /// + public byte[] AttestationObject { get; } + + /// + /// Gets the collected client data JSON associated with this passkey. + /// + /// + /// See . + /// + public byte[] ClientDataJson { get; } +} diff --git a/src/Identity/Extensions.Stores/src/IdentityUserPasskey.cs b/src/Identity/Extensions.Stores/src/IdentityUserPasskey.cs new file mode 100644 index 000000000000..d05f940bc79b --- /dev/null +++ b/src/Identity/Extensions.Stores/src/IdentityUserPasskey.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents a passkey credential for a user in the identity system. +/// +/// +/// See . +/// +/// The type used for the primary key for this passkey credential. +public class IdentityUserPasskey where TKey : IEquatable +{ + /// + /// Gets or sets the primary key of the user that owns this passkey. + /// + public virtual TKey UserId { get; set; } = default!; + + /// + /// Gets or sets the credential ID for this passkey. + /// + public virtual byte[] CredentialId { get; set; } = default!; + + /// + /// 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/PublicAPI.Unshipped.txt b/src/Identity/Extensions.Stores/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..e6dce7cbb561 100644 --- a/src/Identity/Extensions.Stores/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Stores/src/PublicAPI.Unshipped.txt @@ -1 +1,27 @@ #nullable enable +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.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.UserId.get -> TKey +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.UserId.set -> void diff --git a/src/Identity/Identity.slnf b/src/Identity/Identity.slnf index 8e6b1617cb3d..81bcc742f00a 100644 --- a/src/Identity/Identity.slnf +++ b/src/Identity/Identity.slnf @@ -41,6 +41,8 @@ "src\\Identity\\samples\\IdentitySample.ApiEndpoints\\IdentitySample.ApiEndpoints.csproj", "src\\Identity\\samples\\IdentitySample.DefaultUI\\IdentitySample.DefaultUI.csproj", "src\\Identity\\samples\\IdentitySample.Mvc\\IdentitySample.Mvc.csproj", + "src\\Identity\\samples\\IdentitySample.PasskeyConformance\\IdentitySample.PasskeyConformance.csproj", + "src\\Identity\\samples\\IdentitySample.PasskeyUI\\IdentitySample.PasskeyUI.csproj", "src\\Identity\\test\\Identity.FunctionalTests\\Microsoft.AspNetCore.Identity.FunctionalTests.csproj", "src\\Identity\\test\\Identity.Test\\Microsoft.AspNetCore.Identity.Test.csproj", "src\\Identity\\test\\InMemory.Test\\Microsoft.AspNetCore.Identity.InMemory.Test.csproj", diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Data/FailedResponse.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/FailedResponse.cs new file mode 100644 index 000000000000..f55774624040 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/FailedResponse.cs @@ -0,0 +1,6 @@ +// 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 FailedResponse(string errorMessage) : ServerResponse(status: "failed", errorMessage); diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Data/OkResponse.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/OkResponse.cs new file mode 100644 index 000000000000..9ba9a1d5e8b4 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/OkResponse.cs @@ -0,0 +1,6 @@ +// 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 class OkResponse() : ServerResponse(status: "ok"); diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialCreationOptionsRequest.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialCreationOptionsRequest.cs new file mode 100644 index 000000000000..3f87115bf849 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialCreationOptionsRequest.cs @@ -0,0 +1,16 @@ +// 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 Microsoft.AspNetCore.Identity; + +namespace IdentitySample.PasskeyConformance.Data; + +internal sealed class ServerPublicKeyCredentialCreationOptionsRequest(string username, string displayName) +{ + public string Username { get; } = username; + public string DisplayName { get; } = displayName; + public AuthenticatorSelectionCriteria? AuthenticatorSelection { get; set; } + public JsonElement? Extensions { get; set; } + public string? Attestation { get; set; } = "none"; +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialGetOptionsRequest.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialGetOptionsRequest.cs new file mode 100644 index 000000000000..6fa8873ed441 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialGetOptionsRequest.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. + +using System.Text.Json; + +namespace IdentitySample.PasskeyConformance.Data; + +internal sealed class ServerPublicKeyCredentialGetOptionsRequest(string username, string userVerification) +{ + public string Username { get; } = username; + public string UserVerification { get; } = userVerification; + public JsonElement? Extensions { get; set; } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialOptionsResponse.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialOptionsResponse.cs new file mode 100644 index 000000000000..c2a3e70b245b --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialOptionsResponse.cs @@ -0,0 +1,43 @@ +// 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 System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace IdentitySample.PasskeyConformance.Data; + +[JsonConverter(typeof(JsonConverter))] +internal sealed class ServerPublicKeyCredentialOptionsResponse(string optionsJson) : OkResponse() +{ + public string OptionsJson { get; } = optionsJson; + + public sealed class JsonConverter : JsonConverter + { + public override ServerPublicKeyCredentialOptionsResponse? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, ServerPublicKeyCredentialOptionsResponse value, JsonSerializerOptions options) + { + var optionsObject = JsonNode.Parse(value.OptionsJson)?.AsObject() + ?? throw new JsonException("Could not parse the creation options JSON."); + + writer.WriteStartObject(); + writer.WriteString("status", value.Status); + writer.WriteString("errorMessage", value.ErrorMessage); + foreach (var (propertyName, propertyValue) in optionsObject) + { + writer.WritePropertyName(propertyName); + if (propertyValue is not null) + { + propertyValue.WriteTo(writer); + } + else + { + writer.WriteNullValue(); + } + } + writer.WriteEndObject(); + } + } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerResponse.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerResponse.cs new file mode 100644 index 000000000000..2446c5a1e414 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerResponse.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 abstract class ServerResponse(string status, string errorMessage = "") +{ + public string Status { get; } = status; + public string ErrorMessage { get; } = errorMessage; +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/IdentitySample.PasskeyConformance.csproj b/src/Identity/samples/IdentitySample.PasskeyConformance/IdentitySample.PasskeyConformance.csproj new file mode 100644 index 000000000000..3e50af823bc5 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/IdentitySample.PasskeyConformance.csproj @@ -0,0 +1,26 @@ + + + + Passkey conformance testing for ASP.NET Core Identity + $(DefaultNetCoreTargetFramework) + false + enable + + + + + + + + + + + + + + + + + + + diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/InMemoryUserStore.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/InMemoryUserStore.cs new file mode 100644 index 000000000000..2e5b13750492 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/InMemoryUserStore.cs @@ -0,0 +1,140 @@ +// 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.CodeAnalysis; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.Test; + +namespace IdentitySample.PasskeyConformance; + +public sealed class InMemoryUserStore : + IQueryableUserStore, + IUserPasskeyStore + where TUser : PocoUser +{ + private readonly Dictionary _users = []; + + public IQueryable Users => _users.Values.AsQueryable(); + + public Task CreateAsync(TUser user, CancellationToken cancellationToken) + { + _users[user.Id] = user; + return Task.FromResult(IdentityResult.Success); + } + + public Task DeleteAsync(TUser user, CancellationToken cancellationToken) + { + if (!_users.Remove(user.Id)) + { + throw new InvalidOperationException($"Unknown user with ID '{user.Id}'."); + } + return Task.FromResult(IdentityResult.Success); + } + + public Task FindByIdAsync(string userId, CancellationToken cancellationToken) + => Task.FromResult(_users.TryGetValue(userId, out var result) ? result : null); + + public Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken) + => Task.FromResult(_users.Values.FirstOrDefault(u => string.Equals(u.NormalizedUserName, normalizedUserName, StringComparison.Ordinal))); + + public Task FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken) + => Task.FromResult(_users.Values.FirstOrDefault(u => u.Passkeys.Any(p => p.CredentialId.SequenceEqual(credentialId)))); + + public Task FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + => Task.FromResult(ToUserPasskeyInfo(user.Passkeys.FirstOrDefault(p => p.CredentialId.SequenceEqual(credentialId)))); + + public Task GetNormalizedUserNameAsync(TUser user, CancellationToken cancellationToken) + => Task.FromResult(user.NormalizedUserName); + + public Task GetUserIdAsync(TUser user, CancellationToken cancellationToken) + => Task.FromResult(user.Id); + + public Task GetUserNameAsync(TUser user, CancellationToken cancellationToken) + => Task.FromResult(user.UserName); + + public Task SetNormalizedUserNameAsync(TUser user, string? normalizedName, CancellationToken cancellationToken) + { + user.NormalizedUserName = normalizedName; + return Task.CompletedTask; + } + + public Task SetUserNameAsync(TUser user, string? userName, CancellationToken cancellationToken) + { + user.UserName = userName; + return Task.CompletedTask; + } + + public Task UpdateAsync(TUser user, CancellationToken cancellationToken) + { + _users[user.Id] = user; + return Task.FromResult(IdentityResult.Success); + } + + public Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) + { + var passkeyEntity = user.Passkeys.FirstOrDefault(p => p.CredentialId.SequenceEqual(passkey.CredentialId)); + if (passkeyEntity is null) + { + user.Passkeys.Add(ToPocoUserPasskey(user, passkey)); + } + else + { + passkeyEntity.Name = passkey.Name; + passkeyEntity.SignCount = passkey.SignCount; + passkeyEntity.IsBackedUp = passkey.IsBackedUp; + passkeyEntity.IsUserVerified = passkey.IsUserVerified; + } + return Task.CompletedTask; + } + + public Task> GetPasskeysAsync(TUser user, CancellationToken cancellationToken) + => Task.FromResult>(user.Passkeys.Select(ToUserPasskeyInfo).ToList()!); + + public Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + { + var passkey = user.Passkeys.SingleOrDefault(p => p.CredentialId.SequenceEqual(credentialId)); + if (passkey is not null) + { + user.Passkeys.Remove(passkey); + } + + return Task.CompletedTask; + } + + [return: NotNullIfNotNull(nameof(p))] + private static UserPasskeyInfo? ToUserPasskeyInfo(PocoUserPasskey? p) + => p is null ? null : new( + p.CredentialId, + p.PublicKey, + p.Name, + p.CreatedAt, + p.SignCount, + p.Transports, + p.IsUserVerified, + p.IsBackupEligible, + p.IsBackedUp, + p.AttestationObject, + p.ClientDataJson); + + [return: NotNullIfNotNull(nameof(p))] + private static PocoUserPasskey? ToPocoUserPasskey(TUser user, UserPasskeyInfo? p) + => p is null ? null : new PocoUserPasskey + { + UserId = user.Id, + CredentialId = p.CredentialId, + PublicKey = p.PublicKey, + Name = p.Name, + CreatedAt = p.CreatedAt, + Transports = p.Transports, + SignCount = p.SignCount, + IsUserVerified = p.IsUserVerified, + IsBackupEligible = p.IsBackupEligible, + IsBackedUp = p.IsBackedUp, + AttestationObject = p.AttestationObject, + ClientDataJson = p.ClientDataJson, + }; + + public void Dispose() + { + } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs new file mode 100644 index 000000000000..45a5dcc163f8 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs @@ -0,0 +1,203 @@ +// 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; +using System.Text.Json; +using IdentitySample.PasskeyConformance; +using IdentitySample.PasskeyConformance.Data; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.Test; +using Microsoft.AspNetCore.Mvc; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddAuthentication(options => + { + options.DefaultScheme = IdentityConstants.ApplicationScheme; + options.DefaultSignInScheme = IdentityConstants.ExternalScheme; + }) + .AddIdentityCookies(builder => + { + builder.TwoFactorUserIdCookie!.Configure(options => + { + options.Cookie.SameSite = SameSiteMode.None; + }); + }); + +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" + ]; + }) + .AddSignInManager(); + +builder.Services.AddSingleton, InMemoryUserStore>(); +builder.Services.AddSingleton, InMemoryUserStore>(); + +var app = builder.Build(); + +var attestationGroup = app.MapGroup("/attestation"); + +attestationGroup.MapPost("/options", async ( + [FromServices] UserManager userManager, + [FromServices] SignInManager signInManager, + [FromBody] ServerPublicKeyCredentialCreationOptionsRequest 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) + { + AuthenticatorSelection = request.AuthenticatorSelection, + Extensions = request.Extensions, + }; + + if (request.Attestation is { Length: > 0 } attestation) + { + creationArgs.Attestation = attestation; + } + + var options = await signInManager.ConfigurePasskeyCreationOptionsAsync(creationArgs); + var response = new ServerPublicKeyCredentialOptionsResponse(options.AsJson()); + return Results.Ok(response); +}); + +attestationGroup.MapPost("/result", async ( + [FromServices] IUserPasskeyStore passkeyStore, + [FromServices] UserManager userManager, + [FromServices] SignInManager signInManager, + [FromBody] JsonElement result, + CancellationToken cancellationToken) => +{ + var credentialJson = ServerPublicKeyCredentialToJson(result); + + var options = await signInManager.RetrievePasskeyCreationOptionsAsync(); + + await signInManager.SignOutAsync(); + + if (options is null) + { + return Results.BadRequest(new FailedResponse("There are no original passkey options present.")); + } + + var attestationResult = await signInManager.PerformPasskeyAttestationAsync(credentialJson, options); + 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 user = await userManager.FindByIdAsync(userEntity.Id); + if (user is null) + { + user = new PocoUser(userName: userEntity.Name) + { + Id = userEntity.Id, + }; + var createUserResult = await userManager.CreateAsync(user); + if (!createUserResult.Succeeded) + { + return Results.InternalServerError(new FailedResponse("Failed to create the user.")); + } + } + + await passkeyStore.SetPasskeyAsync(user, attestationResult.Passkey, cancellationToken).ConfigureAwait(false); + var updateResult = await userManager.UpdateAsync(user).ConfigureAwait(false); + if (!updateResult.Succeeded) + { + return Results.InternalServerError(new FailedResponse("Unable to update the user.")); + } + + return Results.Ok(new OkResponse()); +}); + +var assertionGroup = app.MapGroup("/assertion"); + +assertionGroup.MapPost("/options", async ( + [FromServices] UserManager userManager, + [FromServices] SignInManager signInManager, + [FromBody] ServerPublicKeyCredentialGetOptionsRequest request) => +{ + var user = await userManager.FindByNameAsync(request.Username); + if (user is null) + { + return Results.BadRequest($"User with username {request.Username} does not exist."); + } + + var requestArgs = new PasskeyRequestArgs + { + User = user, + UserVerification = request.UserVerification, + Extensions = request.Extensions, + }; + var options = await signInManager.ConfigurePasskeyRequestOptionsAsync(requestArgs); + var response = new ServerPublicKeyCredentialOptionsResponse(options.AsJson()); + return Results.Ok(response); +}); + +assertionGroup.MapPost("/result", async ( + [FromServices] SignInManager signInManager, + [FromServices] UserManager userManager, + [FromBody] JsonElement result) => +{ + var credentialJson = ServerPublicKeyCredentialToJson(result); + + var options = await signInManager.RetrievePasskeyRequestOptionsAsync(); + await signInManager.SignOutAsync(); + + if (options is null) + { + return Results.BadRequest(new FailedResponse("There are no original passkey options present.")); + } + + var assertionResult = await signInManager.PerformPasskeyAssertionAsync(credentialJson, options); + if (!assertionResult.Succeeded) + { + return Results.BadRequest(new FailedResponse($"Assertion failed: {assertionResult.Failure.Message}")); + } + + await userManager.SetPasskeyAsync(assertionResult.User, assertionResult.Passkey); + + return Results.Ok(new OkResponse()); +}); + +app.UseHttpsRedirection(); + +app.Run(); + +static string ServerPublicKeyCredentialToJson(JsonElement serverPublicKeyCredential) +{ + // The response from the conformance testing tool comes in this format: + // https://github.com/fido-alliance/conformance-test-tools-resources/blob/main/docs/FIDO2/Server/Conformance-Test-API.md#serverpublickeycredential + // ...but we want it to be in this format: + // https://www.w3.org/TR/webauthn-3/#dictdef-registrationresponsejson + // This mainly entails renaming the 'getClientExtensionResults' property + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions() + { + Indented = true, + }); + writer.WriteStartObject(); + foreach (var property in serverPublicKeyCredential.EnumerateObject()) + { + switch (property.Name) + { + case "getClientExtensionResults": + writer.WritePropertyName("clientExtensionResults"); + break; + default: + writer.WritePropertyName(property.Name); + break; + } + property.Value.WriteTo(writer); + } + writer.WriteEndObject(); + writer.Flush(); + var resultBytes = stream.ToArray(); + var resultJson = Encoding.UTF8.GetString(resultBytes); + return resultJson; +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Properties/launchSettings.json b/src/Identity/samples/IdentitySample.PasskeyConformance/Properties/launchSettings.json new file mode 100644 index 000000000000..d8a374fe0b6f --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:7020", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7020", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.Development.json b/src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.Development.json new file mode 100644 index 000000000000..00520a6ab864 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.Identity": "Debug" + } + } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.json b/src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.json new file mode 100644 index 000000000000..10f68b8c8b4f --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/App.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/App.razor new file mode 100644 index 000000000000..0bd026cc6a0e --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/App.razor @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Authenticated.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Authenticated.razor new file mode 100644 index 000000000000..79d19ae4e32d --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Authenticated.razor @@ -0,0 +1,18 @@ +@page "/authenticated" + +@using Microsoft.AspNetCore.Authorization + +@attribute [Authorize] + +Authenticated + +

You are authenticated!

+ + +

Hello, @context.User.Identity?.Name!

+
+ +
+ + + diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor new file mode 100644 index 000000000000..04c98b9ce58e --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor @@ -0,0 +1,145 @@ +@page "/" + +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.Identity.Test + +@inject NavigationManager NavigationManager +@inject SignInManager SignInManager +@inject UserManager UserManager + +

Welcome!

+ +Passkey sample + +

+ This app demonstrates how to use passkeys for authentication with ASP.NET Core Identity. +

+ +

+ See these docs + to learn how to simplify passkey testing with a virtual authenticator. +

+ +

+ NOTE: For simplicity, users are stored in memory, so passkeys will be lost when the app restarts. +

+ +

Log in or register here

+ +
+ +
+ +
+
+ + +
+ + +

@statusMessage

+ +@code { + private string? statusMessage; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm(Name = "username")] + private string? Username { get; set; } + + [SupplyParameterFromForm(Name = "credential")] + private string? CredentialJson { get; set; } + + [SupplyParameterFromForm(Name = "action")] + private string? Action { get; set; } + + private Task OnSubmitAsync() + => Action switch + { + "register" => RegisterAsync(), + "authenticate" => AuthenticateAsync(), + var x => throw new InvalidOperationException($"Unknown action '{x}'"), + }; + + private async Task RegisterAsync() + { + if (string.IsNullOrWhiteSpace(Username)) + { + statusMessage = "Error: A username is required for registration."; + return; + } + + if (string.IsNullOrWhiteSpace(CredentialJson)) + { + statusMessage = "Error: No credential was submitted by the browser."; + 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); + if (!attestationResult.Succeeded) + { + statusMessage = $"Error: Could not validate credential: {attestationResult.Failure.Message}"; + return; + } + + // Create the user if they don't exist yet. + var userEntity = options.UserEntity; + var user = await UserManager.FindByIdAsync(userEntity.Id); + if (user is null) + { + user = new PocoUser(userName: userEntity.Name) + { + Id = userEntity.Id, + }; + var createUserResult = await UserManager.CreateAsync(user); + if (!createUserResult.Succeeded) + { + statusMessage = "Error: Could not create a new user."; + return; + } + } + + var setPasskeyResult = await UserManager.SetPasskeyAsync(user, attestationResult.Passkey); + if (!setPasskeyResult.Succeeded) + { + statusMessage = "Error: Could not update the user with the new passkey."; + return; + } + + statusMessage = "Registration successful! You can now authenticate with your passkey."; + } + + private async Task AuthenticateAsync() + { + if (string.IsNullOrWhiteSpace(CredentialJson)) + { + statusMessage = "Error: No credential was submitted by the browser."; + 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); + if (!signInResult.Succeeded) + { + statusMessage = "Error: Could not sign in with the provided credential."; + return; + } + + NavigationManager.NavigateTo("authenticated", forceLoad: true); + } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/NotFound.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/NotFound.razor new file mode 100644 index 000000000000..0fc601939bfd --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/NotFound.razor @@ -0,0 +1,3 @@ +@page "/not-found" + +

Not Found

diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/RedirectToHome.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/RedirectToHome.razor new file mode 100644 index 000000000000..95d0281f7a1f --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/RedirectToHome.razor @@ -0,0 +1,8 @@ +@inject NavigationManager NavigationManager + +@code { + protected override void OnInitialized() + { + NavigationManager.NavigateTo("/"); + } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Routes.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Routes.razor new file mode 100644 index 000000000000..0499aaa02370 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Routes.razor @@ -0,0 +1,12 @@ +@inject NavigationManager NavigationManager + + + + + + + + + + + diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/_Imports.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/_Imports.razor new file mode 100644 index 000000000000..2c9c57bfc8b9 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/_Imports.razor @@ -0,0 +1,9 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/IdentitySample.PasskeyUI.csproj b/src/Identity/samples/IdentitySample.PasskeyUI/IdentitySample.PasskeyUI.csproj new file mode 100644 index 000000000000..92a0db3006a0 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/IdentitySample.PasskeyUI.csproj @@ -0,0 +1,40 @@ + + + + Passkey conformance testing for ASP.NET Core Identity + $(DefaultNetCoreTargetFramework) + false + enable + $(TS6385);$(NoWarn) + + + + + + + + + + + + + + + + + + + + + + + + + true + $(RepoRoot)src\Components\Web.JS\dist\Debug + $(RepoRoot)src\Components\Web.JS\dist\Release + + + + + diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/InMemoryUserStore.cs b/src/Identity/samples/IdentitySample.PasskeyUI/InMemoryUserStore.cs new file mode 100644 index 000000000000..cb98946b3436 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/InMemoryUserStore.cs @@ -0,0 +1,140 @@ +// 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.CodeAnalysis; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.Test; + +namespace IdentitySample.PasskeyUI; + +public sealed class InMemoryUserStore : + IQueryableUserStore, + IUserPasskeyStore + where TUser : PocoUser +{ + private readonly Dictionary _users = []; + + public IQueryable Users => _users.Values.AsQueryable(); + + public Task CreateAsync(TUser user, CancellationToken cancellationToken) + { + _users[user.Id] = user; + return Task.FromResult(IdentityResult.Success); + } + + public Task DeleteAsync(TUser user, CancellationToken cancellationToken) + { + if (!_users.Remove(user.Id)) + { + throw new InvalidOperationException($"Unknown user with ID '{user.Id}'."); + } + return Task.FromResult(IdentityResult.Success); + } + + public Task FindByIdAsync(string userId, CancellationToken cancellationToken) + => Task.FromResult(_users.TryGetValue(userId, out var result) ? result : null); + + public Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken) + => Task.FromResult(_users.Values.FirstOrDefault(u => string.Equals(u.NormalizedUserName, normalizedUserName, StringComparison.Ordinal))); + + public Task FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken) + => Task.FromResult(_users.Values.FirstOrDefault(u => u.Passkeys.Any(p => p.CredentialId.SequenceEqual(credentialId)))); + + public Task FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + => Task.FromResult(ToUserPasskeyInfo(user.Passkeys.FirstOrDefault(p => p.CredentialId.SequenceEqual(credentialId)))); + + public Task GetNormalizedUserNameAsync(TUser user, CancellationToken cancellationToken) + => Task.FromResult(user.NormalizedUserName); + + public Task GetUserIdAsync(TUser user, CancellationToken cancellationToken) + => Task.FromResult(user.Id); + + public Task GetUserNameAsync(TUser user, CancellationToken cancellationToken) + => Task.FromResult(user.UserName); + + public Task SetNormalizedUserNameAsync(TUser user, string? normalizedName, CancellationToken cancellationToken) + { + user.NormalizedUserName = normalizedName; + return Task.CompletedTask; + } + + public Task SetUserNameAsync(TUser user, string? userName, CancellationToken cancellationToken) + { + user.UserName = userName; + return Task.CompletedTask; + } + + public Task UpdateAsync(TUser user, CancellationToken cancellationToken) + { + _users[user.Id] = user; + return Task.FromResult(IdentityResult.Success); + } + + public Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) + { + var passkeyEntity = user.Passkeys.FirstOrDefault(p => p.CredentialId.SequenceEqual(passkey.CredentialId)); + if (passkeyEntity is null) + { + user.Passkeys.Add(ToPocoUserPasskey(user, passkey)); + } + else + { + passkeyEntity.Name = passkey.Name; + passkeyEntity.SignCount = passkey.SignCount; + passkeyEntity.IsBackedUp = passkey.IsBackedUp; + passkeyEntity.IsUserVerified = passkey.IsUserVerified; + } + return Task.CompletedTask; + } + + public Task> GetPasskeysAsync(TUser user, CancellationToken cancellationToken) + => Task.FromResult>(user.Passkeys.Select(ToUserPasskeyInfo).ToList()!); + + public Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + { + var passkey = user.Passkeys.SingleOrDefault(p => p.CredentialId.SequenceEqual(credentialId)); + if (passkey is not null) + { + user.Passkeys.Remove(passkey); + } + + return Task.CompletedTask; + } + + [return: NotNullIfNotNull(nameof(p))] + private static UserPasskeyInfo? ToUserPasskeyInfo(PocoUserPasskey? p) + => p is null ? null : new( + p.CredentialId, + p.PublicKey, + p.Name, + p.CreatedAt, + p.SignCount, + p.Transports, + p.IsUserVerified, + p.IsBackupEligible, + p.IsBackedUp, + p.AttestationObject, + p.ClientDataJson); + + [return: NotNullIfNotNull(nameof(p))] + private static PocoUserPasskey? ToPocoUserPasskey(TUser user, UserPasskeyInfo? p) + => p is null ? null : new PocoUserPasskey + { + UserId = user.Id, + CredentialId = p.CredentialId, + PublicKey = p.PublicKey, + Name = p.Name, + CreatedAt = p.CreatedAt, + Transports = p.Transports, + SignCount = p.SignCount, + IsUserVerified = p.IsUserVerified, + IsBackupEligible = p.IsBackupEligible, + IsBackedUp = p.IsBackedUp, + AttestationObject = p.AttestationObject, + ClientDataJson = p.ClientDataJson, + }; + + public void Dispose() + { + } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Program.cs b/src/Identity/samples/IdentitySample.PasskeyUI/Program.cs new file mode 100644 index 000000000000..665f1d87f06e --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Program.cs @@ -0,0 +1,116 @@ +// 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; +using Microsoft.AspNetCore.Identity.Test; +using Microsoft.AspNetCore.Mvc; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddRazorComponents(); +builder.Services.AddCascadingAuthenticationState(); + +builder.Services.AddAuthentication(options => + { + options.DefaultScheme = IdentityConstants.ApplicationScheme; + options.DefaultSignInScheme = IdentityConstants.ExternalScheme; + }) + .AddIdentityCookies(); + +builder.Services.ConfigureApplicationCookie(options => +{ + options.Events.OnRedirectToLogin = options => + { + options.HttpContext.Response.Redirect("/"); + return Task.CompletedTask; + }; +}); + +builder.Services.AddAuthorization(); + +builder.Services.AddIdentityCore() + .AddSignInManager() + .AddDefaultTokenProviders(); + +builder.Services.AddSingleton, InMemoryUserStore>(); +builder.Services.AddSingleton, InMemoryUserStore>(); + +var app = builder.Build(); + +app.UseHttpsRedirection(); +app.UseAntiforgery(); +app.MapStaticAssets(); +app.MapRazorComponents(); + +app.MapPost("attestation/options", async ( + [FromServices] UserManager userManager, + [FromServices] SignInManager signInManager, + [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) + { + AuthenticatorSelection = request.AuthenticatorSelection, + Extensions = request.Extensions, + }; + + if (!string.IsNullOrEmpty(request.Attestation)) + { + creationArgs.Attestation = request.Attestation; + } + + var options = await signInManager.ConfigurePasskeyCreationOptionsAsync(creationArgs); + return Results.Content(options.AsJson(), contentType: "application/json"); +}); + +app.MapPost("assertion/options", async ( + [FromServices] UserManager userManager, + [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"); +}); + +app.MapPost("account/logout", async ( + [FromServices] SignInManager signInManager) => +{ + await signInManager.SignOutAsync(); + return TypedResults.LocalRedirect($"~/"); +}); + +app.Run(); + +sealed class PublicKeyCredentialCreationOptionsRequest(string username) +{ + public string Username { get; } = username; + public AuthenticatorSelectionCriteria? AuthenticatorSelection { get; set; } + public JsonElement? Extensions { get; set; } + public string? Attestation { get; set; } = "none"; +} + +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/Properties/launchSettings.json b/src/Identity/samples/IdentitySample.PasskeyUI/Properties/launchSettings.json new file mode 100644 index 000000000000..d8362d8fcaf9 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5021", + "hotReloadEnabled": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7021;http://localhost:5021", + "hotReloadEnabled": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/appsettings.Development.json b/src/Identity/samples/IdentitySample.PasskeyUI/appsettings.Development.json new file mode 100644 index 000000000000..0c208ae9181e --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/appsettings.json b/src/Identity/samples/IdentitySample.PasskeyUI/appsettings.json new file mode 100644 index 000000000000..10f68b8c8b4f --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.css b/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.css new file mode 100644 index 000000000000..0f16b65a571e --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.css @@ -0,0 +1,17 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +h1:focus { + outline: none; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.js b/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.js new file mode 100644 index 000000000000..9f344b4413cd --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.js @@ -0,0 +1,122 @@ +(function () { + // Following is a quick and dirty way to execute scripts based on the current route. + const routeScripts = {}; + + function addRouteScript(path, callback) { + routeScripts[path] = callback; + } + + function executeScript() { + const routeScript = routeScripts[location.pathname]; + routeScript?.(); + } + + function enableRouteScripts() { + Blazor.addEventListener('enhancednavigationend', executeScript); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', executeScript); + } else { + executeScript(); + } + } + + // Define home page JS functionality. + addRouteScript('/', async () => { + let abortController; + const form = document.getElementById('auth-form'); + const statusMessage = document.getElementById('status-message'); + + async function fetchNewCredential(username) { + if (!username) { + throw new Error('Please enter a username.'); + } + + const optionsResponse = await fetch('/attestation/options', { + method: 'POST', + body: JSON.stringify({ + username, + authenticatorSelection: { + residentKey: 'preferred', + } + // TODO: Allow configuration of other options. + }), + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }); + const optionsJson = await optionsResponse.json(); + const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson); + abortController?.abort(); + abortController = new AbortController(); + return await navigator.credentials.create({ + publicKey: options, + signal: abortController.signal, + }); + } + + async function fetchExistingCredential(username, useConditionalMediation) { + // The username is optional for authentication, so we don't validate it here. + const optionsResponse = await fetch('/assertion/options', { + method: 'POST', + body: JSON.stringify({ + username, + // TODO: Allow configuration of other options. + }), + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }); + const optionsJson = await optionsResponse.json(); + const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson); + abortController?.abort(); + abortController = new AbortController(); + return await navigator.credentials.get({ + publicKey: options, + mediation: useConditionalMediation ? 'conditional' : undefined, + signal: abortController.signal, + }); + } + + async function fetchAndSubmitCredential(action, useConditionalMediation = false) { + try { + const username = new FormData(form).get('username'); + let credential; + if (action === 'register') { + credential = await fetchNewCredential(username); + } else if (action === 'authenticate') { + credential = await fetchExistingCredential(username, useConditionalMediation); + } else { + throw new Error('Unknown action: ' + action); + } + var credentialJson = JSON.stringify(credential); + form.addEventListener('formdata', (e) => { + e.formData.append('action', action); + e.formData.append('credential', credentialJson); + }, { once: true }); + form.submit(); + } catch (error) { + // Ignore abort errors, they are expected when the user cancels the operation. + if (error.name !== 'AbortError') { + statusMessage.textContent = 'Error: ' + error.message; + throw error; + } + } + } + + form.addEventListener('submit', (e) => { + if (e.submitter?.name == 'action') { + e.preventDefault(); + fetchAndSubmitCredential(e.submitter.value); + } + }); + + if (await PublicKeyCredential.isConditionalMediationAvailable()) { + await fetchAndSubmitCredential('authenticate', /* useConditionalMediation */ true); + } + }); + + enableRouteScripts(); +})(); diff --git a/src/Identity/startvscode.cmd b/src/Identity/startvscode.cmd new file mode 100644 index 000000000000..d403f3028231 --- /dev/null +++ b/src/Identity/startvscode.cmd @@ -0,0 +1,3 @@ +@ECHO OFF + +%~dp0..\..\startvscode.cmd %~dp0 diff --git a/src/Identity/test/Identity.Test/IdentityOptionsTest.cs b/src/Identity/test/Identity.Test/IdentityOptionsTest.cs index 0b6e7d67343d..9f53948d4e57 100644 --- a/src/Identity/test/Identity.Test/IdentityOptionsTest.cs +++ b/src/Identity/test/Identity.Test/IdentityOptionsTest.cs @@ -32,6 +32,12 @@ 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] @@ -89,5 +95,4 @@ public void CanConfigureCookieOptions() Assert.Equal("c", options.Get(IdentityConstants.TwoFactorRememberMeScheme).Cookie.Name); Assert.Equal("d", options.Get(IdentityConstants.TwoFactorUserIdScheme).Cookie.Name); } - } diff --git a/src/Identity/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index 73fe6d6be218..99e525cb275e 100644 --- a/src/Identity/test/Identity.Test/SignInManagerTest.cs +++ b/src/Identity/test/Identity.Test/SignInManagerTest.cs @@ -97,7 +97,13 @@ private static Mock> SetupUserManager(PocoUser user) return manager; } - private static SignInManager SetupSignInManager(UserManager manager, HttpContext context, ILogger logger = null, IdentityOptions identityOptions = null, IAuthenticationSchemeProvider schemeProvider = null) + private static SignInManager SetupSignInManager( + UserManager manager, + HttpContext context, + ILogger logger = null, + IdentityOptions identityOptions = null, + IAuthenticationSchemeProvider schemeProvider = null, + IPasskeyHandler passkeyHandler = null) { var contextAccessor = new Mock(); contextAccessor.Setup(a => a.HttpContext).Returns(context); @@ -107,7 +113,16 @@ private static SignInManager SetupSignInManager(UserManager options.Setup(a => a.Value).Returns(identityOptions); var claimsFactory = new UserClaimsPrincipalFactory(manager, roleManager.Object, options.Object); schemeProvider = schemeProvider ?? new MockSchemeProvider(); - var sm = new SignInManager(manager, contextAccessor.Object, claimsFactory, options.Object, null, schemeProvider, new DefaultUserConfirmation()); + passkeyHandler = passkeyHandler ?? Mock.Of>(); + var sm = new SignInManager( + manager, + contextAccessor.Object, + claimsFactory, + options.Object, + null, + schemeProvider, + new DefaultUserConfirmation(), + passkeyHandler); sm.Logger = logger ?? NullLogger>.Instance; return sm; } @@ -339,6 +354,38 @@ public async Task ExternalSignInRequiresVerificationIfNotBypassed(bool bypass) auth.Verify(); } + [Fact] + 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 assertionResult = PasskeyAssertionResult.Success(passkey, user); + var passkeyHandler = new Mock>(); + passkeyHandler + .Setup(h => h.PerformAssertionAsync(It.IsAny>())) + .Returns(Task.FromResult(assertionResult)); + var manager = SetupUserManager(user); + manager + .Setup(m => m.SetPasskeyAsync(user, passkey)) + .Returns(Task.FromResult(IdentityResult.Success)) + .Verifiable(); + var context = new DefaultHttpContext(); + var auth = MockAuth(context); + SetupSignIn(context, auth, user.Id, isPersistent: false, loginProvider: null); + var helper = SetupSignInManager(manager.Object, context, passkeyHandler: passkeyHandler.Object); + + // Act + var passkeyRequestOptions = new PasskeyRequestOptions(userId: user.Id, ""); + var signInResult = await helper.PasskeySignInAsync(credentialJson: "", passkeyRequestOptions); + + // Assert + Assert.True(assertionResult.Succeeded); + Assert.Same(SignInResult.Success, signInResult); + manager.Verify(); + auth.Verify(); + } + private class GoodTokenProvider : AuthenticatorTokenProvider { public override Task ValidateAsync(string purpose, string token, UserManager manager, PocoUser user) diff --git a/src/Identity/test/Shared/PocoModel/PocoRole.cs b/src/Identity/test/Shared/PocoModel/PocoRole.cs index 5b0b84e65ab4..b5d5c61b310b 100644 --- a/src/Identity/test/Shared/PocoModel/PocoRole.cs +++ b/src/Identity/test/Shared/PocoModel/PocoRole.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + namespace Microsoft.AspNetCore.Identity.Test; /// diff --git a/src/Identity/test/Shared/PocoModel/PocoRoleClaim.cs b/src/Identity/test/Shared/PocoModel/PocoRoleClaim.cs index 5b251c20537b..c8c6b921a924 100644 --- a/src/Identity/test/Shared/PocoModel/PocoRoleClaim.cs +++ b/src/Identity/test/Shared/PocoModel/PocoRoleClaim.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + namespace Microsoft.AspNetCore.Identity.Test; /// diff --git a/src/Identity/test/Shared/PocoModel/PocoUser.cs b/src/Identity/test/Shared/PocoModel/PocoUser.cs index 342506ae1c0a..b9a04eb52894 100644 --- a/src/Identity/test/Shared/PocoModel/PocoUser.cs +++ b/src/Identity/test/Shared/PocoModel/PocoUser.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + namespace Microsoft.AspNetCore.Identity.Test; /// @@ -144,4 +146,8 @@ public PocoUser(string userName) : this() /// Navigation property /// public virtual ICollection> Tokens { get; private set; } = new List>(); + /// + /// Navigation property + /// + public virtual ICollection> Passkeys { get; private set; } = new List>(); } diff --git a/src/Identity/test/Shared/PocoModel/PocoUserClaim.cs b/src/Identity/test/Shared/PocoModel/PocoUserClaim.cs index ebd5a24515c2..2d1343891410 100644 --- a/src/Identity/test/Shared/PocoModel/PocoUserClaim.cs +++ b/src/Identity/test/Shared/PocoModel/PocoUserClaim.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + namespace Microsoft.AspNetCore.Identity.Test; /// diff --git a/src/Identity/test/Shared/PocoModel/PocoUserLogin.cs b/src/Identity/test/Shared/PocoModel/PocoUserLogin.cs index ab7162fe230a..e62fd55aad6c 100644 --- a/src/Identity/test/Shared/PocoModel/PocoUserLogin.cs +++ b/src/Identity/test/Shared/PocoModel/PocoUserLogin.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + namespace Microsoft.AspNetCore.Identity.Test; /// diff --git a/src/Identity/test/Shared/PocoModel/PocoUserPasskey.cs b/src/Identity/test/Shared/PocoModel/PocoUserPasskey.cs new file mode 100644 index 000000000000..9a4ec0e610a5 --- /dev/null +++ b/src/Identity/test/Shared/PocoModel/PocoUserPasskey.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace Microsoft.AspNetCore.Identity.Test; + +public class PocoUserPasskey : PocoUserPasskey; + +/// +/// Represents a passkey credential for a user in the identity system. +/// +/// +/// See . +/// +/// The type used for the primary key for this passkey credential. +public class PocoUserPasskey where TKey : IEquatable +{ + /// + /// Gets or sets the primary key of the user that owns this passkey. + /// + public virtual TKey UserId { get; set; } = default!; + + /// + /// Gets or sets the credential ID for this passkey. + /// + public virtual byte[] CredentialId { get; set; } = []; + + /// + /// Gets or sets the public key associated with this passkey. + /// + public virtual byte[] PublicKey { get; set; } = []; + + /// + /// 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; } = []; + + /// + /// Gets or sets the collected client data JSON associated with this passkey. + /// + /// + /// See . + /// + public virtual byte[] ClientDataJson { get; set; } = []; +} diff --git a/src/Identity/test/Shared/PocoModel/PocoUserRole.cs b/src/Identity/test/Shared/PocoModel/PocoUserRole.cs index 19b7d1b0c56a..5637d35cff5d 100644 --- a/src/Identity/test/Shared/PocoModel/PocoUserRole.cs +++ b/src/Identity/test/Shared/PocoModel/PocoUserRole.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + namespace Microsoft.AspNetCore.Identity.Test; /// diff --git a/src/Identity/test/Shared/PocoModel/PocoUserToken.cs b/src/Identity/test/Shared/PocoModel/PocoUserToken.cs index 22af469fd298..a57721225449 100644 --- a/src/Identity/test/Shared/PocoModel/PocoUserToken.cs +++ b/src/Identity/test/Shared/PocoModel/PocoUserToken.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + namespace Microsoft.AspNetCore.Identity.Test; /// diff --git a/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets b/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets index 4868bb529620..1d4a655b7c61 100644 --- a/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets +++ b/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets @@ -30,7 +30,7 @@ - + <_DevCertFileName>aspnetcore-https.pfx @@ -49,8 +49,8 @@ - - + + 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 fd9fe31ab31a..4a791f890e13 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 @@ -49,6 +49,39 @@ public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEn return TypedResults.LocalRedirect($"~/{returnUrl}"); }); + accountGroup.MapPost("/PasskeyCreationOptions", async ( + HttpContext context, + [FromServices] UserManager userManager, + [FromServices] SignInManager signInManager) => + { + var user = await userManager.GetUserAsync(context.User); + if (user is null) + { + return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'."); + } + + 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"); + }); + + accountGroup.MapPost("/PasskeyRequestOptions", async ( + [FromServices] UserManager userManager, + [FromServices] SignInManager signInManager, + [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 manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization(); manageGroup.MapPost("/LinkExternalLogin", async ( diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ExternalLogin.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ExternalLogin.razor index 3dbb3c412a41..0bd33362fe9c 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ExternalLogin.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ExternalLogin.razor @@ -54,7 +54,7 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } = default!; [SupplyParameterFromQuery] private string? RemoteError { get; set; } @@ -69,6 +69,8 @@ protected override async Task OnInitializedAsync() { + Input ??= new(); + if (RemoteError is not null) { RedirectManager.RedirectToWithStatus("Account/Login", $"Error from external provider: {RemoteError}", HttpContext); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ForgotPassword.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ForgotPassword.razor index 1ab1bfa5fcb0..2a8a903a0300 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ForgotPassword.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ForgotPassword.razor @@ -35,7 +35,12 @@ @code { [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } = default!; + + protected override void OnInitialized() + { + Input ??= new(); + } private async Task OnValidSubmitAsync() { 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 292750b79a4c..ce7cd0dc1d68 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 @@ -5,6 +5,7 @@ @using Microsoft.AspNetCore.Identity @using BlazorWeb_CSharp.Data +@inject UserManager UserManager @inject SignInManager SignInManager @inject ILogger Logger @inject NavigationManager NavigationManager @@ -17,13 +18,13 @@
- +

Use a local account to log in.


- +
@@ -41,6 +42,12 @@
+
+
+ OR + Log in with a passkey +
+

Forgot your password? @@ -66,18 +73,23 @@ @code { private string? errorMessage; + private EditContext editContext = default!; [CascadingParameter] private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } = default!; [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } protected override async Task OnInitializedAsync() { + Input ??= new(); + + editContext = new EditContext(Input); + if (HttpMethods.IsGet(HttpContext.Request.Method)) { // Clear the existing external cookie to ensure a clean login process @@ -87,9 +99,38 @@ public async Task LoginUser() { - // This doesn't count login failures towards account lockout - // To enable password failures to trigger account lockout, set lockoutOnFailure: true - var result = await SignInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false); + if (!string.IsNullOrEmpty(Input.Passkey?.Error)) + { + errorMessage = $"Error: Could not log in using the provided passkey: {Input.Passkey.Error}"; + return; + } + + SignInResult result; + 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); + } + else + { + // If doing a password sign-in, validate the form. + if (!editContext.Validate()) + { + return; + } + + // This doesn't count login failures towards account lockout + // To enable password failures to trigger account lockout, set lockoutOnFailure: true + result = await SignInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false); + } + if (result.Succeeded) { Logger.LogInformation("User logged in."); @@ -124,5 +165,7 @@ [Display(Name = "Remember me?")] public bool RememberMe { get; set; } + + public PasskeyInputModel? Passkey { get; set; } } } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWith2fa.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWith2fa.razor index 20cebaaef191..8019df6775e4 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWith2fa.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWith2fa.razor @@ -49,7 +49,7 @@ private ApplicationUser user = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } = default!; [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } @@ -59,6 +59,8 @@ protected override async Task OnInitializedAsync() { + Input ??= new(); + // Ensure the user has gone through the username & password screen first user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? throw new InvalidOperationException("Unable to load two-factor authentication user."); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWithRecoveryCode.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWithRecoveryCode.razor index b984545c18d2..d0f289c8653e 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWithRecoveryCode.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWithRecoveryCode.razor @@ -38,13 +38,15 @@ private ApplicationUser user = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } = default!; [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } protected override async Task OnInitializedAsync() { + Input ??= new(); + // Ensure the user has gone through the username & password screen first user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? throw new InvalidOperationException("Unable to load two-factor authentication user."); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/ChangePassword.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/ChangePassword.razor index 1764bde9919f..28d5673a7fab 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/ChangePassword.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/ChangePassword.razor @@ -48,10 +48,12 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } = default!; protected override async Task OnInitializedAsync() { + Input ??= new(); + user = await UserManager.GetUserAsync(HttpContext.User); if (user is null) { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/DeletePersonalData.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/DeletePersonalData.razor index 12dcd63bd78a..4ae6422d02b5 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/DeletePersonalData.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/DeletePersonalData.razor @@ -46,11 +46,12 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } = default!; protected override async Task OnInitializedAsync() { Input ??= new(); + user = await UserManager.GetUserAsync(HttpContext.User); if (user is null) { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Email.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Email.razor index 5cbfdcb75024..a619c85987d5 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Email.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Email.razor @@ -63,10 +63,12 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm(FormName = "change-email")] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } = default!; protected override async Task OnInitializedAsync() { + Input ??= new(); + user = await UserManager.GetUserAsync(HttpContext.User); if (user is null) { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/EnableAuthenticator.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/EnableAuthenticator.razor index b1e7f7ca41a9..7a85dbd303db 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/EnableAuthenticator.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/EnableAuthenticator.razor @@ -78,10 +78,12 @@ else private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } = default!; protected override async Task OnInitializedAsync() { + Input ??= new(); + user = await UserManager.GetUserAsync(HttpContext.User); if (user is null) { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Index.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Index.razor index 0663ca6ebed6..a3e4a0b3e997 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Index.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Index.razor @@ -41,10 +41,12 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } = default!; protected override async Task OnInitializedAsync() { + Input ??= new(); + user = await UserManager.GetUserAsync(HttpContext.User); if (user is null) { 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 new file mode 100644 index 000000000000..948711d5a201 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor @@ -0,0 +1,173 @@ +@page "/Account/Manage/Passkeys" + +@using BlazorWeb_CSharp.Data +@using Microsoft.AspNetCore.Identity +@using System.ComponentModel.DataAnnotations +@using System.Buffers.Text + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityRedirectManager RedirectManager + +Manage your passkeys + +

Manage your passkeys

+ + + +@if (currentPasskeys is { Count: > 0 }) +{ + + + @foreach (var passkey in currentPasskeys) + { + + + + + } + +
@(passkey.Name ?? "Unnamed passkey") + @{ + var credentialId = Base64Url.EncodeToString(passkey.CredentialId); + } +
+ +
+ + + +
+ +
+} +else +{ +

No passkeys are registered.

+} + +
+ + Add a new passkey + + +@code { + private ApplicationUser? user; + private IList? currentPasskeys; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private string? Action { get; set; } + + [SupplyParameterFromForm] + private string? CredentialId { get; set; } + + [SupplyParameterFromForm(FormName = "add-passkey")] + private PasskeyInputModel Input { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + Input ??= new(); + + user = await UserManager.GetUserAsync(HttpContext.User); + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + currentPasskeys = await UserManager.GetPasskeysAsync(user); + } + + private async Task AddPasskey() + { + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + if (!string.IsNullOrEmpty(Input.Error)) + { + RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add a passkey: {Input.Error}", HttpContext); + return; + } + + if (string.IsNullOrEmpty(Input.CredentialJson)) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: The browser did not provide a passkey.", HttpContext); + 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); + if (!attestationResult.Succeeded) + { + RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add the passkey: {attestationResult.Failure.Message}.", HttpContext); + return; + } + + var setPasskeyResult = await UserManager.SetPasskeyAsync(user, attestationResult.Passkey); + if (!setPasskeyResult.Succeeded) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: The passkey could not be added to your account.", HttpContext); + return; + } + + // Immediately prompt the user to enter a name for the credential + var credentialIdBase64Url = Base64Url.EncodeToString(attestationResult.Passkey.CredentialId); + RedirectManager.RedirectTo($"Account/Manage/RenamePasskey/{credentialIdBase64Url}"); + } + + private async Task UpdatePasskey() + { + switch (Action) + { + case "rename": + RedirectManager.RedirectTo($"Account/Manage/RenamePasskey/{CredentialId}"); + break; + case "delete": + await DeletePasskey(); + break; + default: + RedirectManager.RedirectToCurrentPageWithStatus($"Error: Unknown action '{Action}'.", HttpContext); + break; + } + } + + private async Task DeletePasskey() + { + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + byte[] credentialId; + try + { + credentialId = Base64Url.DecodeFromChars(CredentialId); + } + catch (FormatException) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: The specified passkey ID had an invalid format.", HttpContext); + return; + } + + var result = await UserManager.RemovePasskeyAsync(user, credentialId); + if (!result.Succeeded) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: The passkey could not be deleted.", HttpContext); + return; + } + + RedirectManager.RedirectToCurrentPageWithStatus("Passkey deleted successfully.", HttpContext); + } +} 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 new file mode 100644 index 000000000000..e89253cc3a38 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor @@ -0,0 +1,94 @@ +@page "/Account/Manage/RenamePasskey/{Id}" + +@using BlazorWeb_CSharp.Data +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Identity +@using System.Buffers.Text + +@inject UserManager UserManager +@inject IdentityRedirectManager RedirectManager + + + + @if (passkey?.Name is { } name) + { +

Enter a new name for your "@name" passkey

+ } + else + { +

Enter a name for your passkey

+ } +
+ +
+ + + +
+
+ +
+
+ +@code { + private ApplicationUser? user; + private UserPasskeyInfo? passkey; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [Parameter] + public string? Id { get; set; } + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + Input ??= new(); + + user = (await UserManager.GetUserAsync(HttpContext.User))!; + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + byte[] credentialId; + try + { + credentialId = Base64Url.DecodeFromChars(Id); + } + catch (FormatException) + { + RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Error: The specified passkey ID had an invalid format.", HttpContext); + return; + } + + passkey = await UserManager.GetPasskeyAsync(user, credentialId); + if (passkey is null) + { + RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Error: The specified passkey could not be found.", HttpContext); + return; + } + } + + private async Task Rename() + { + passkey!.Name = Input.Name; + var result = await UserManager.SetPasskeyAsync(user!, passkey); + if (!result.Succeeded) + { + RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Error: The passkey could not be updated.", HttpContext); + return; + } + + RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Passkey updated successfully.", HttpContext); + } + + private sealed class InputModel + { + [Required] + public string Name { get; set; } = ""; + } +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/SetPassword.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/SetPassword.razor index c9c7b26317cf..62ace2f15001 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/SetPassword.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/SetPassword.razor @@ -44,10 +44,12 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } = default!; protected override async Task OnInitializedAsync() { + Input ??= new(); + user = await UserManager.GetUserAsync(HttpContext.User); if (user is null) { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Register.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Register.razor index ce5f49387419..0a22f43cf4e0 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Register.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Register.razor @@ -58,13 +58,18 @@ private IEnumerable? identityErrors; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } = default!; [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } private string? Message => identityErrors is null ? null : $"Error: {string.Join(", ", identityErrors.Select(error => error.Description))}"; + protected override void OnInitialized() + { + Input ??= new(); + } + public async Task RegisterUser(EditContext editContext) { var user = CreateUser(); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResendEmailConfirmation.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResendEmailConfirmation.razor index 8024beaeeee2..964de23cbc67 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResendEmailConfirmation.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResendEmailConfirmation.razor @@ -37,7 +37,12 @@ private string? message; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } = default!; + + protected override void OnInitialized() + { + Input ??= new(); + } private async Task OnValidSubmitAsync() { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResetPassword.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResetPassword.razor index fd0945f58a04..ab835031c871 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResetPassword.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResetPassword.razor @@ -46,7 +46,7 @@ private IEnumerable? identityErrors; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } = default!; [SupplyParameterFromQuery] private string? Code { get; set; } @@ -55,6 +55,8 @@ protected override void OnInitialized() { + Input ??= new(); + if (Code is null) { RedirectManager.RedirectTo("Account/InvalidPasswordReset"); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/PasskeyInputModel.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/PasskeyInputModel.cs new file mode 100644 index 000000000000..6a5e0eb99320 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/PasskeyInputModel.cs @@ -0,0 +1,7 @@ +namespace BlazorWeb_CSharp.Components.Account; + +public class PasskeyInputModel +{ + public string? CredentialJson { get; set; } + public string? Error { get; set; } +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/PasskeyOperation.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/PasskeyOperation.cs new file mode 100644 index 000000000000..3b4b291aacb8 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/PasskeyOperation.cs @@ -0,0 +1,7 @@ +namespace BlazorWeb_CSharp.Components.Account; + +public enum PasskeyOperation +{ + Create = 0, + Request = 1, +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/ManageNavMenu.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/ManageNavMenu.razor index 29d1ec6e7d8a..6621996dd33f 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/ManageNavMenu.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/ManageNavMenu.razor @@ -22,6 +22,9 @@ + diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor new file mode 100644 index 000000000000..9cc8e57fe8ec --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor @@ -0,0 +1,21 @@ + + + +@code { + [Parameter] + [EditorRequired] + public PasskeyOperation Operation { get; set; } + + [Parameter] + [EditorRequired] + public string Name { get; set; } = default!; + + [Parameter] + public string? EmailName { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter(CaptureUnmatchedValues = true)] + public IDictionary? AdditionalAttributes { get; set; } +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js new file mode 100644 index 000000000000..f234215ef2d8 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js @@ -0,0 +1,94 @@ +async function fetchWithErrorHandling(url, options = {}) { + const response = await fetch(url, { + credentials: 'include', + ...options + }); + if (!response.ok) { + const text = await response.text(); + console.error(text); + throw new Error(`The server responded with status ${response.status}.`); + } + return response; +} + +async function createCredential(signal) { + const optionsResponse = await fetchWithErrorHandling('/Account/PasskeyCreationOptions', { + method: 'POST', + signal, + }); + const optionsJson = await optionsResponse.json(); + const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson); + return await navigator.credentials.create({ publicKey: options, signal }); +} + +async function requestCredential(email, mediation, signal) { + const optionsResponse = await fetchWithErrorHandling(`/Account/PasskeyRequestOptions?username=${email}`, { + method: 'POST', + signal, + }); + const optionsJson = await optionsResponse.json(); + const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson); + return await navigator.credentials.get({ publicKey: options, mediation, signal }); +} + +customElements.define('passkey-submit', class extends HTMLElement { + static formAssociated = true; + + connectedCallback() { + this.internals = this.attachInternals(); + this.attrs = { + operation: this.getAttribute('operation'), + name: this.getAttribute('name'), + emailName: this.getAttribute('email-name'), + }; + + this.internals.form.addEventListener('submit', (event) => { + if (event.submitter?.name === '__passkeySubmit') { + event.preventDefault(); + this.obtainCredentialAndSubmit(); + } + }); + + this.tryAutofillPasskey(); + } + + disconnectedCallback() { + this.abortController?.abort(); + } + + async obtainCredentialAndSubmit(useConditionalMediation = false) { + this.abortController?.abort(); + this.abortController = new AbortController(); + const signal = this.abortController.signal; + const formData = new FormData(); + try { + let credential; + if (this.attrs.operation === 'Create') { + credential = await createCredential(signal); + } else if (this.attrs.operation === 'Request') { + const email = new FormData(this.internals.form).get(this.attrs.emailName); + const mediation = useConditionalMediation ? 'conditional' : undefined; + credential = await requestCredential(email, mediation, signal); + } else { + throw new Error(`Unknown passkey operation '${operation}'.`); + } + const credentialJson = JSON.stringify(credential); + formData.append(`${this.attrs.name}.CredentialJson`, credentialJson); + } catch (error) { + if (error.name === 'AbortError') { + // Canceled by user action, do not submit the form + return; + } + formData.append(`${this.attrs.name}.Error`, error.message); + console.error(error); + } + this.internals.setFormValue(formData); + this.internals.form.submit(); + } + + async tryAutofillPasskey() { + if (this.attrs.operation === 'Request' && await PublicKeyCredential.isConditionalMediationAvailable()) { + await this.obtainCredentialAndSubmit(/* useConditionalMediation */ true); + } + } +}); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/App.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/App.razor index 4238ebdc95e9..ee525516a15c 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/App.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/App.razor @@ -44,6 +44,9 @@ ##endif*@ + @*#if (IndividualLocalAuth) + + ##endif*@ 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 ba5b97c07dff..ae1764e8d38c 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 @@ -18,7 +18,7 @@ partial class CreateIdentitySchema protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); modelBuilder.Entity("BlazorWeb_CSharp.Data.ApplicationUser", b => { @@ -57,6 +57,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("PhoneNumber") + .HasMaxLength(256) .HasColumnType("TEXT"); b.Property("PhoneNumberConfirmed") @@ -159,9 +160,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { b.Property("LoginProvider") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("ProviderKey") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("ProviderDisplayName") @@ -178,6 +181,57 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserLogins", (string)null); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.Property("CredentialId") + .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"); + + b.HasKey("CredentialId"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserPasskeys", (string)null); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.Property("UserId") @@ -199,9 +253,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("LoginProvider") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Name") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Value") @@ -239,6 +295,15 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.HasOne("BlazorWeb_CSharp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) 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 bf83fc9a9f27..92e0a0ea2cc2 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 @@ -38,7 +38,7 @@ protected override void Up(MigrationBuilder migrationBuilder) PasswordHash = table.Column(type: "TEXT", nullable: true), SecurityStamp = table.Column(type: "TEXT", nullable: true), ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), - PhoneNumber = table.Column(type: "TEXT", nullable: true), + PhoneNumber = table.Column(type: "TEXT", maxLength: 256, nullable: true), PhoneNumberConfirmed = table.Column(type: "INTEGER", nullable: false), TwoFactorEnabled = table.Column(type: "INTEGER", nullable: false), LockoutEnd = table.Column(type: "TEXT", nullable: true), @@ -96,8 +96,8 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "AspNetUserLogins", columns: table => new { - LoginProvider = table.Column(type: "TEXT", nullable: false), - ProviderKey = table.Column(type: "TEXT", nullable: false), + LoginProvider = table.Column(type: "TEXT", maxLength: 128, nullable: false), + ProviderKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), ProviderDisplayName = table.Column(type: "TEXT", nullable: true), UserId = table.Column(type: "TEXT", nullable: false) }, @@ -112,6 +112,34 @@ protected override void Up(MigrationBuilder migrationBuilder) onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "AspNetUserPasskeys", + columns: table => new + { + 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) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserPasskeys", x => x.CredentialId); + table.ForeignKey( + name: "FK_AspNetUserPasskeys_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "AspNetUserRoles", columns: table => new @@ -141,8 +169,8 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: table => new { UserId = table.Column(type: "TEXT", nullable: false), - LoginProvider = table.Column(type: "TEXT", nullable: false), - Name = table.Column(type: "TEXT", nullable: false), + LoginProvider = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Name = table.Column(type: "TEXT", maxLength: 128, nullable: false), Value = table.Column(type: "TEXT", nullable: true) }, constraints: table => @@ -177,6 +205,11 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "AspNetUserLogins", column: "UserId"); + migrationBuilder.CreateIndex( + name: "IX_AspNetUserPasskeys_UserId", + table: "AspNetUserPasskeys", + column: "UserId"); + migrationBuilder.CreateIndex( name: "IX_AspNetUserRoles_RoleId", table: "AspNetUserRoles", @@ -206,6 +239,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/SqlLite/ApplicationDbContextModelSnapshot.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/ApplicationDbContextModelSnapshot.cs index 930cebc1857d..f6bdeaf144ec 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 @@ -15,7 +15,7 @@ partial class ApplicationDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); modelBuilder.Entity("BlazorWeb_CSharp.Data.ApplicationUser", b => { @@ -54,6 +54,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("PhoneNumber") + .HasMaxLength(256) .HasColumnType("TEXT"); b.Property("PhoneNumberConfirmed") @@ -156,9 +157,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { b.Property("LoginProvider") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("ProviderKey") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("ProviderDisplayName") @@ -175,6 +178,57 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserLogins", (string)null); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.Property("CredentialId") + .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"); + + b.HasKey("CredentialId"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserPasskeys", (string)null); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.Property("UserId") @@ -196,9 +250,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("LoginProvider") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Name") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Value") @@ -236,6 +292,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.HasOne("BlazorWeb_CSharp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) 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 34d2b6df1a30..c92d84f26d83 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 @@ -20,7 +20,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("ProductVersion", "10.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -62,7 +62,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(max)"); b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); b.Property("PhoneNumberConfirmed") .HasColumnType("bit"); @@ -170,10 +171,12 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); b.Property("ProviderDisplayName") .HasColumnType("nvarchar(max)"); @@ -189,6 +192,57 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserLogins", (string)null); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.Property("CredentialId") + .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)"); + + b.HasKey("CredentialId"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserPasskeys", (string)null); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.Property("UserId") @@ -210,10 +264,12 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(450)"); b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); b.Property("Name") - .HasColumnType("nvarchar(450)"); + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); b.Property("Value") .HasColumnType("nvarchar(max)"); @@ -250,6 +306,15 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.HasOne("BlazorWeb_CSharp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) 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 ec47e9f16b95..8d26035044d5 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 @@ -38,7 +38,7 @@ protected override void Up(MigrationBuilder migrationBuilder) PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), - PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), TwoFactorEnabled = table.Column(type: "bit", nullable: false), LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), @@ -96,8 +96,8 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "AspNetUserLogins", columns: table => new { - LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), - ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), + LoginProvider = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + ProviderKey = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), UserId = table.Column(type: "nvarchar(450)", nullable: false) }, @@ -112,6 +112,34 @@ protected override void Up(MigrationBuilder migrationBuilder) onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "AspNetUserPasskeys", + columns: table => new + { + 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) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserPasskeys", x => x.CredentialId); + table.ForeignKey( + name: "FK_AspNetUserPasskeys_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "AspNetUserRoles", columns: table => new @@ -141,8 +169,8 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: table => new { UserId = table.Column(type: "nvarchar(450)", nullable: false), - LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), - Name = table.Column(type: "nvarchar(450)", nullable: false), + LoginProvider = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), Value = table.Column(type: "nvarchar(max)", nullable: true) }, constraints: table => @@ -178,6 +206,11 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "AspNetUserLogins", column: "UserId"); + migrationBuilder.CreateIndex( + name: "IX_AspNetUserPasskeys_UserId", + table: "AspNetUserPasskeys", + column: "UserId"); + migrationBuilder.CreateIndex( name: "IX_AspNetUserRoles_RoleId", table: "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 a6b6896c65dd..3f5e8fe8be78 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 @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("ProductVersion", "10.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -59,7 +59,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(max)"); b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); b.Property("PhoneNumberConfirmed") .HasColumnType("bit"); @@ -167,10 +168,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); b.Property("ProviderDisplayName") .HasColumnType("nvarchar(max)"); @@ -186,6 +189,57 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserLogins", (string)null); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.Property("CredentialId") + .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)"); + + b.HasKey("CredentialId"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserPasskeys", (string)null); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.Property("UserId") @@ -207,10 +261,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(450)"); b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); b.Property("Name") - .HasColumnType("nvarchar(450)"); + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); b.Property("Value") .HasColumnType("nvarchar(max)"); @@ -247,6 +303,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.HasOne("BlazorWeb_CSharp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) 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 769de58a9edc..74d0af218938 100644 Binary files a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/app.db and b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/app.db differ diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs index b0b8d955cc6d..fcccced38c3d 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs @@ -70,7 +70,11 @@ public static void Main(string[] args) #endif builder.Services.AddDatabaseDeveloperPageExceptionFilter(); - builder.Services.AddIdentityCore(options => options.SignIn.RequireConfirmedAccount = true) + builder.Services.AddIdentityCore(options => + { + options.SignIn.RequireConfirmedAccount = true; + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; + }) .AddEntityFrameworkStores() .AddSignInManager() .AddDefaultTokenProviders(); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.cs index 8eb1deacb1fa..ba2f522dc4e7 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.cs @@ -21,21 +21,21 @@ builder.Services.AddRazorComponents(); #else builder.Services.AddRazorComponents() - #if (UseServer && UseWebAssembly && IndividualLocalAuth) +#if (UseServer && UseWebAssembly && IndividualLocalAuth) .AddInteractiveServerComponents() .AddInteractiveWebAssemblyComponents() .AddAuthenticationStateSerialization(); - #elif (UseServer && UseWebAssembly) +#elif (UseServer && UseWebAssembly) .AddInteractiveServerComponents() .AddInteractiveWebAssemblyComponents(); - #elif (UseServer) +#elif (UseServer) .AddInteractiveServerComponents(); - #elif (UseWebAssembly && IndividualLocalAuth) +#elif (UseWebAssembly && IndividualLocalAuth) .AddInteractiveWebAssemblyComponents() .AddAuthenticationStateSerialization(); - #elif (UseWebAssembly) +#elif (UseWebAssembly) .AddInteractiveWebAssemblyComponents(); - #endif +#endif #endif #if (IndividualLocalAuth) @@ -64,7 +64,11 @@ #endif builder.Services.AddDatabaseDeveloperPageExceptionFilter(); -builder.Services.AddIdentityCore(options => options.SignIn.RequireConfirmedAccount = true) +builder.Services.AddIdentityCore(options => + { + options.SignIn.RequireConfirmedAccount = true; + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; + }) .AddEntityFrameworkStores() .AddSignInManager() .AddDefaultTokenProviders(); diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs index 60d372d34dee..50a93deeebe9 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs @@ -31,6 +31,7 @@ protected async Task CreateBuildPublishAsync( { // Additional arguments are needed. See: https://github.com/dotnet/aspnetcore/issues/24278 Environment.SetEnvironmentVariable("EnableDefaultScopedCssItems", "true"); + Environment.SetEnvironmentVariable("AllowMissingPrunePackageData", "true"); var project = await ProjectFactory.CreateProject(Output); if (targetFramework != null) @@ -86,7 +87,7 @@ protected async Task TestBasicInteractionInNewPageAsync( string listeningUri, string appName, BlazorTemplatePages pagesToExclude = BlazorTemplatePages.None, - bool usesAuth = false) + AuthenticationFeatures authenticationFeatures = AuthenticationFeatures.None) { if (!BrowserManager.IsAvailable(browserKind)) { @@ -100,16 +101,17 @@ protected async Task TestBasicInteractionInNewPageAsync( Output.WriteLine($"Opening browser at {listeningUri}..."); await page.GotoAsync(listeningUri, new() { WaitUntil = WaitUntilState.NetworkIdle }); - await TestBasicInteractionAsync(page, appName, pagesToExclude, usesAuth); + await TestBasicInteractionAsync(browser, page, appName, pagesToExclude, authenticationFeatures); await page.CloseAsync(); } protected async Task TestBasicInteractionAsync( + IBrowserContext browser, IPage page, string appName, BlazorTemplatePages pagesToExclude = BlazorTemplatePages.None, - bool usesAuth = false) + AuthenticationFeatures authenticationFeatures = AuthenticationFeatures.None) { await page.WaitForSelectorAsync("nav"); @@ -134,17 +136,39 @@ await Task.WhenAll( await IncrementCounterAsync(page); } - if (usesAuth) + if (authenticationFeatures.HasFlag(AuthenticationFeatures.RegisterAndLogIn)) { + // Start a new CDP session with WebAuthn enabled and add a virtual authenticator. + // We do this regardless of whether we're testing passkeys, because passkey + // gets attempted unconditionally on the login page, and this utilizes the WebAuthn API. + await using var cdpSession = await browser.NewCDPSessionAsync(page); + await cdpSession.SendAsync("WebAuthn.enable"); + var result = await cdpSession.SendAsync("WebAuthn.addVirtualAuthenticator", new Dictionary + { + ["options"] = new + { + protocol = "ctap2", + transport = "internal", + hasResidentKey = false, + hasUserIdentification = true, + isUserVerified = true, + automaticPresenceSimulation = true, + } + }); + + Assert.True(result.HasValue); + var authenticatorId = result.Value.GetProperty("authenticatorId").GetString(); + await Task.WhenAll( - page.WaitForURLAsync("**/Identity/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), - page.ClickAsync("text=Log in")); + page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Login")); await Task.WhenAll( - page.WaitForSelectorAsync("[name=\"Input.Email\"]"), - page.WaitForURLAsync("**/Identity/Account/Register**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.WaitForURLAsync("**/Account/Register**", new() { WaitUntil = WaitUntilState.NetworkIdle }), page.ClickAsync("text=Register as a new user")); + await page.WaitForSelectorAsync("text=Create a new account."); + var userName = $"{Guid.NewGuid()}@example.com"; var password = "[PLACEHOLDER]-1a"; @@ -154,12 +178,12 @@ await Task.WhenAll( // We will be redirected to the RegisterConfirmation await Task.WhenAll( - page.WaitForURLAsync("**/Identity/Account/RegisterConfirmation**", new() { WaitUntil = WaitUntilState.NetworkIdle }), - page.ClickAsync("#registerSubmit")); + page.WaitForURLAsync("**/Account/RegisterConfirmation**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("button[type=\"submit\"]")); // We will be redirected to the ConfirmEmail await Task.WhenAll( - page.WaitForURLAsync("**/Identity/Account/ConfirmEmail**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.WaitForURLAsync("**/Account/ConfirmEmail**", new() { WaitUntil = WaitUntilState.NetworkIdle }), page.ClickAsync("text=Click here to confirm your account")); // Now we can login @@ -167,11 +191,52 @@ await Task.WhenAll( await page.WaitForSelectorAsync("[name=\"Input.Email\"]"); await page.FillAsync("[name=\"Input.Email\"]", userName); await page.FillAsync("[name=\"Input.Password\"]", password); - await page.ClickAsync("#login-submit"); + await page.ClickAsync("button[type=\"submit\"]"); + + // Verify that we can visit the "Auth Required" page + await Task.WhenAll( + page.WaitForURLAsync("**/auth", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Auth Required")); + await page.WaitForSelectorAsync("text=You are authenticated"); + + if (authenticationFeatures.HasFlag(AuthenticationFeatures.Passkeys)) + { + // Navigate to the passkey management page + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Manage**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("a[href=\"Account/Manage\"]")); + + await page.WaitForSelectorAsync("text=Manage your account"); + + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Manage/Passkeys**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("a[href=\"Account/Manage/Passkeys\"]")); - // Need to navigate to fetch page - await page.GotoAsync(new Uri(page.Url).GetLeftPart(UriPartial.Authority)); - Assert.Equal(appName.Trim(), (await page.TitleAsync()).Trim()); + // Register a new passkey + await page.ClickAsync("text=Add a new passkey"); + + await page.WaitForSelectorAsync("text=Enter a name for your passkey"); + await page.FillAsync("[name=\"Input.Name\"]", "My passkey"); + await page.ClickAsync("text=Continue"); + + await page.WaitForSelectorAsync("text=Passkey updated successfully"); + + // Login with the passkey + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Logout")); + + await page.WaitForSelectorAsync("[name=\"Input.Email\"]"); + await page.FillAsync("[name=\"Input.Email\"]", userName); + + await page.ClickAsync("text=Log in with a passkey"); + + // Verify that we can visit the "Auth Required" page + await Task.WhenAll( + page.WaitForURLAsync("**/auth", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Auth Required")); + await page.WaitForSelectorAsync("text=You are authenticated"); + } } if (!pagesToExclude.HasFlag(BlazorTemplatePages.Weather)) @@ -232,4 +297,12 @@ protected enum BlazorTemplatePages Weather = 4, All = ~0, } + + [Flags] + protected enum AuthenticationFeatures + { + None = 0, + RegisterAndLogIn = 1, + Passkeys = 2, + } } diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWasmTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWasmTemplateTest.cs index 69269843c9ba..d74b67207a5c 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWasmTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWasmTemplateTest.cs @@ -78,7 +78,7 @@ public async Task BlazorWasmStandalonePwaTemplate_Works(BrowserKind browserKind) await page.GotoAsync(listeningUri, new() { WaitUntil = WaitUntilState.NetworkIdle }); using (serveProcess) { - await TestBasicInteractionAsync(page, project.ProjectName); + await TestBasicInteractionAsync(browser, page, project.ProjectName); } // The PWA template supports offline use. By now, the browser should have cached everything it needs, @@ -86,7 +86,7 @@ public async Task BlazorWasmStandalonePwaTemplate_Works(BrowserKind browserKind) await page.GotoAsync("about:blank"); await browser.SetOfflineAsync(true); await page.GotoAsync(listeningUri); - await TestBasicInteractionAsync(page, project.ProjectName, pagesToExclude: BlazorTemplatePages.Weather); + await TestBasicInteractionAsync(browser, page, project.ProjectName, pagesToExclude: BlazorTemplatePages.Weather); await page.CloseAsync(); } else diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs index dc2cf5b63982..cff83ab7b3fb 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs @@ -2,28 +2,25 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; using Microsoft.AspNetCore.BrowserTesting; -using Microsoft.AspNetCore.InternalTesting; using Templates.Test.Helpers; namespace BlazorTemplates.Tests; -public class BlazorWebTemplateTest(ProjectFactoryFixture projectFactory) : BlazorTemplateTest(projectFactory) +public class BlazorWebTemplateTest(ProjectFactoryFixture projectFactory) : BlazorTemplateTest(projectFactory), IClassFixture { public override string ProjectType => "blazor"; - [ConditionalTheory] - [SkipNonHelix] + [Theory] [InlineData(BrowserKind.Chromium, "None")] [InlineData(BrowserKind.Chromium, "Server")] [InlineData(BrowserKind.Chromium, "WebAssembly")] [InlineData(BrowserKind.Chromium, "Auto")] - public async Task BlazorWebTemplate_Works(BrowserKind browserKind, string interactivityOption) + [InlineData(BrowserKind.Chromium, "None", "Individual")] + public async Task BlazorWebTemplate_Works(BrowserKind browserKind, string interactivityOption, string authOption = "None") { var project = await CreateBuildPublishAsync( - args: ["-int", interactivityOption], + args: ["-int", interactivityOption, "-au", authOption], getTargetProject: GetTargetProject); // There won't be a counter page when the 'None' interactivity option is used @@ -31,35 +28,11 @@ public async Task BlazorWebTemplate_Works(BrowserKind browserKind, string intera ? BlazorTemplatePages.Counter : BlazorTemplatePages.None; - var appName = project.ProjectName; + var authenticationFeatures = authOption is "None" + ? AuthenticationFeatures.None + : AuthenticationFeatures.RegisterAndLogIn; - // Test the built project - using (var aspNetProcess = project.StartBuiltProjectAsync()) - { - Assert.False( - aspNetProcess.Process.HasExited, - ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", project, aspNetProcess.Process)); - - await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); - await TestBasicInteractionInNewPageAsync(browserKind, aspNetProcess.ListeningUri.AbsoluteUri, appName, pagesToExclude); - } - - // Test the published project - using (var aspNetProcess = project.StartPublishedProjectAsync()) - { - Assert.False( - aspNetProcess.Process.HasExited, - ErrorMessages.GetFailedProcessMessageOrEmpty("Run published project", project, aspNetProcess.Process)); - - await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); - - if (HasClientProject()) - { - await AssertWebAssemblyCompressionFormatAsync(aspNetProcess, "br"); - } - - await TestBasicInteractionInNewPageAsync(browserKind, aspNetProcess.ListeningUri.AbsoluteUri, appName, pagesToExclude); - } + await TestProjectCoreAsync(project, browserKind, pagesToExclude, authenticationFeatures); bool HasClientProject() => interactivityOption is "WebAssembly" or "Auto"; @@ -78,18 +51,41 @@ Project GetTargetProject(Project rootProject) } } - private static async Task AssertWebAssemblyCompressionFormatAsync(AspNetProcess aspNetProcess, string expectedEncoding) + [Theory] + [InlineData(BrowserKind.Chromium)] + public async Task BlazorWebTemplate_CanUsePasskeys(BrowserKind browserKind) { - var response = await aspNetProcess.SendRequest(() => + var project = await CreateBuildPublishAsync(args: ["-int", "None", "-au", "Individual"]); + var pagesToExclude = BlazorTemplatePages.Counter; + var authenticationFeatures = AuthenticationFeatures.RegisterAndLogIn | AuthenticationFeatures.Passkeys; + + await TestProjectCoreAsync(project, browserKind, pagesToExclude, authenticationFeatures); + } + + private async Task TestProjectCoreAsync(Project project, BrowserKind browserKind, BlazorTemplatePages pagesToExclude, AuthenticationFeatures authenticationFeatures) + { + var appName = project.ProjectName; + + // Test the built project + using (var aspNetProcess = project.StartBuiltProjectAsync()) + { + Assert.False( + aspNetProcess.Process.HasExited, + ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", project, aspNetProcess.Process)); + + await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); + await TestBasicInteractionInNewPageAsync(browserKind, aspNetProcess.ListeningUri.AbsoluteUri, appName, pagesToExclude, authenticationFeatures); + } + + // Test the published project + using (var aspNetProcess = project.StartPublishedProjectAsync()) { - var request = new HttpRequestMessage(HttpMethod.Get, new Uri(aspNetProcess.ListeningUri, "/_framework/blazor.boot.json")); - // These are the same as chrome - request.Headers.AcceptEncoding.Clear(); - request.Headers.AcceptEncoding.Add(StringWithQualityHeaderValue.Parse("gzip")); - request.Headers.AcceptEncoding.Add(StringWithQualityHeaderValue.Parse("deflate")); - request.Headers.AcceptEncoding.Add(StringWithQualityHeaderValue.Parse("br")); - return request; - }); - Assert.Equal(expectedEncoding, response.Content.Headers.ContentEncoding.Single()); + Assert.False( + aspNetProcess.Process.HasExited, + ErrorMessages.GetFailedProcessMessageOrEmpty("Run published project", project, aspNetProcess.Process)); + + await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); + await TestBasicInteractionInNewPageAsync(browserKind, aspNetProcess.ListeningUri.AbsoluteUri, appName, pagesToExclude, authenticationFeatures); + } } } diff --git a/src/ProjectTemplates/test/Templates.Tests/template-baselines.json b/src/ProjectTemplates/test/Templates.Tests/template-baselines.json index a4420fbb65c7..61b2ffddf049 100644 --- a/src/ProjectTemplates/test/Templates.Tests/template-baselines.json +++ b/src/ProjectTemplates/test/Templates.Tests/template-baselines.json @@ -620,7 +620,9 @@ "Components/Account/Pages/Manage/ExternalLogins.razor", "Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "Components/Account/Pages/Manage/Index.razor", + "Components/Account/Pages/Manage/Passkeys.razor", "Components/Account/Pages/Manage/PersonalData.razor", + "Components/Account/Pages/Manage/RenamePasskey.razor", "Components/Account/Pages/Manage/ResetAuthenticator.razor", "Components/Account/Pages/Manage/SetPassword.razor", "Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -631,9 +633,13 @@ "Components/Account/Pages/ResetPassword.razor", "Components/Account/Pages/ResetPasswordConfirmation.razor", "Components/Account/Pages/_Imports.razor", + "Components/Account/PasskeyInputModel.cs", + "Components/Account/PasskeyOperation.cs", "Components/Account/Shared/ExternalLoginPicker.razor", "Components/Account/Shared/ManageLayout.razor", "Components/Account/Shared/ManageNavMenu.razor", + "Components/Account/Shared/PasskeySubmit.razor", + "Components/Account/Shared/PasskeySubmit.razor.js", "Components/Account/Shared/RedirectToLogin.razor", "Components/Account/Shared/ShowRecoveryCodes.razor", "Components/Account/Shared/StatusMessage.razor", @@ -810,7 +816,9 @@ "Components/Account/Pages/Manage/ExternalLogins.razor", "Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "Components/Account/Pages/Manage/Index.razor", + "Components/Account/Pages/Manage/Passkeys.razor", "Components/Account/Pages/Manage/PersonalData.razor", + "Components/Account/Pages/Manage/RenamePasskey.razor", "Components/Account/Pages/Manage/ResetAuthenticator.razor", "Components/Account/Pages/Manage/SetPassword.razor", "Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -821,9 +829,13 @@ "Components/Account/Pages/ResetPassword.razor", "Components/Account/Pages/ResetPasswordConfirmation.razor", "Components/Account/Pages/_Imports.razor", + "Components/Account/PasskeyInputModel.cs", + "Components/Account/PasskeyOperation.cs", "Components/Account/Shared/ExternalLoginPicker.razor", "Components/Account/Shared/ManageLayout.razor", "Components/Account/Shared/ManageNavMenu.razor", + "Components/Account/Shared/PasskeySubmit.razor", + "Components/Account/Shared/PasskeySubmit.razor.js", "Components/Account/Shared/RedirectToLogin.razor", "Components/Account/Shared/ShowRecoveryCodes.razor", "Components/Account/Shared/StatusMessage.razor", @@ -931,7 +943,9 @@ "Components/Account/Pages/Manage/ExternalLogins.razor", "Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "Components/Account/Pages/Manage/Index.razor", + "Components/Account/Pages/Manage/Passkeys.razor", "Components/Account/Pages/Manage/PersonalData.razor", + "Components/Account/Pages/Manage/RenamePasskey.razor", "Components/Account/Pages/Manage/ResetAuthenticator.razor", "Components/Account/Pages/Manage/SetPassword.razor", "Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -942,9 +956,13 @@ "Components/Account/Pages/ResetPassword.razor", "Components/Account/Pages/ResetPasswordConfirmation.razor", "Components/Account/Pages/_Imports.razor", + "Components/Account/PasskeyInputModel.cs", + "Components/Account/PasskeyOperation.cs", "Components/Account/Shared/ExternalLoginPicker.razor", "Components/Account/Shared/ManageLayout.razor", "Components/Account/Shared/ManageNavMenu.razor", + "Components/Account/Shared/PasskeySubmit.razor", + "Components/Account/Shared/PasskeySubmit.razor.js", "Components/Account/Shared/RedirectToLogin.razor", "Components/Account/Shared/ShowRecoveryCodes.razor", "Components/Account/Shared/StatusMessage.razor", @@ -1135,7 +1153,9 @@ "{ProjectName}/Components/Account/Pages/Manage/ExternalLogins.razor", "{ProjectName}/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "{ProjectName}/Components/Account/Pages/Manage/Index.razor", + "{ProjectName}/Components/Account/Pages/Manage/Passkeys.razor", "{ProjectName}/Components/Account/Pages/Manage/PersonalData.razor", + "{ProjectName}/Components/Account/Pages/Manage/RenamePasskey.razor", "{ProjectName}/Components/Account/Pages/Manage/ResetAuthenticator.razor", "{ProjectName}/Components/Account/Pages/Manage/SetPassword.razor", "{ProjectName}/Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -1146,9 +1166,13 @@ "{ProjectName}/Components/Account/Pages/ResetPassword.razor", "{ProjectName}/Components/Account/Pages/ResetPasswordConfirmation.razor", "{ProjectName}/Components/Account/Pages/_Imports.razor", + "{ProjectName}/Components/Account/PasskeyInputModel.cs", + "{ProjectName}/Components/Account/PasskeyOperation.cs", "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", "{ProjectName}/Components/Account/Shared/StatusMessage.razor", "{ProjectName}/Components/App.razor", @@ -1338,7 +1362,9 @@ "{ProjectName}/Components/Account/Pages/Manage/ExternalLogins.razor", "{ProjectName}/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "{ProjectName}/Components/Account/Pages/Manage/Index.razor", + "{ProjectName}/Components/Account/Pages/Manage/Passkeys.razor", "{ProjectName}/Components/Account/Pages/Manage/PersonalData.razor", + "{ProjectName}/Components/Account/Pages/Manage/RenamePasskey.razor", "{ProjectName}/Components/Account/Pages/Manage/ResetAuthenticator.razor", "{ProjectName}/Components/Account/Pages/Manage/SetPassword.razor", "{ProjectName}/Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -1349,9 +1375,13 @@ "{ProjectName}/Components/Account/Pages/ResetPassword.razor", "{ProjectName}/Components/Account/Pages/ResetPasswordConfirmation.razor", "{ProjectName}/Components/Account/Pages/_Imports.razor", + "{ProjectName}/Components/Account/PasskeyInputModel.cs", + "{ProjectName}/Components/Account/PasskeyOperation.cs", "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", "{ProjectName}/Components/Account/Shared/StatusMessage.razor", "{ProjectName}/Components/App.razor", @@ -1787,7 +1817,9 @@ "{ProjectName}/Components/Account/Pages/Manage/ExternalLogins.razor", "{ProjectName}/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "{ProjectName}/Components/Account/Pages/Manage/Index.razor", + "{ProjectName}/Components/Account/Pages/Manage/Passkeys.razor", "{ProjectName}/Components/Account/Pages/Manage/PersonalData.razor", + "{ProjectName}/Components/Account/Pages/Manage/RenamePasskey.razor", "{ProjectName}/Components/Account/Pages/Manage/ResetAuthenticator.razor", "{ProjectName}/Components/Account/Pages/Manage/SetPassword.razor", "{ProjectName}/Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -1798,9 +1830,13 @@ "{ProjectName}/Components/Account/Pages/ResetPassword.razor", "{ProjectName}/Components/Account/Pages/ResetPasswordConfirmation.razor", "{ProjectName}/Components/Account/Pages/_Imports.razor", + "{ProjectName}/Components/Account/PasskeyInputModel.cs", + "{ProjectName}/Components/Account/PasskeyOperation.cs", "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", "{ProjectName}/Components/Account/Shared/StatusMessage.razor", "{ProjectName}/Components/App.razor", @@ -1857,7 +1893,9 @@ "Components/Account/Pages/Manage/ExternalLogins.razor", "Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "Components/Account/Pages/Manage/Index.razor", + "Components/Account/Pages/Manage/Passkeys.razor", "Components/Account/Pages/Manage/PersonalData.razor", + "Components/Account/Pages/Manage/RenamePasskey.razor", "Components/Account/Pages/Manage/ResetAuthenticator.razor", "Components/Account/Pages/Manage/SetPassword.razor", "Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -1868,9 +1906,13 @@ "Components/Account/Pages/ResetPassword.razor", "Components/Account/Pages/ResetPasswordConfirmation.razor", "Components/Account/Pages/_Imports.razor", + "Components/Account/PasskeyInputModel.cs", + "Components/Account/PasskeyOperation.cs", "Components/Account/Shared/ExternalLoginPicker.razor", "Components/Account/Shared/ManageLayout.razor", "Components/Account/Shared/ManageNavMenu.razor", + "Components/Account/Shared/PasskeySubmit.razor", + "Components/Account/Shared/PasskeySubmit.razor.js", "Components/Account/Shared/RedirectToLogin.razor", "Components/Account/Shared/ShowRecoveryCodes.razor", "Components/Account/Shared/StatusMessage.razor", @@ -1994,7 +2036,9 @@ "{ProjectName}/Components/Account/Pages/Manage/ExternalLogins.razor", "{ProjectName}/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "{ProjectName}/Components/Account/Pages/Manage/Index.razor", + "{ProjectName}/Components/Account/Pages/Manage/Passkeys.razor", "{ProjectName}/Components/Account/Pages/Manage/PersonalData.razor", + "{ProjectName}/Components/Account/Pages/Manage/RenamePasskey.razor", "{ProjectName}/Components/Account/Pages/Manage/ResetAuthenticator.razor", "{ProjectName}/Components/Account/Pages/Manage/SetPassword.razor", "{ProjectName}/Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -2005,9 +2049,13 @@ "{ProjectName}/Components/Account/Pages/ResetPassword.razor", "{ProjectName}/Components/Account/Pages/ResetPasswordConfirmation.razor", "{ProjectName}/Components/Account/Pages/_Imports.razor", + "{ProjectName}/Components/Account/PasskeyInputModel.cs", + "{ProjectName}/Components/Account/PasskeyOperation.cs", "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", "{ProjectName}/Components/Account/Shared/StatusMessage.razor", "{ProjectName}/Components/App.razor", @@ -2122,7 +2170,9 @@ "{ProjectName}/Components/Account/Pages/Manage/ExternalLogins.razor", "{ProjectName}/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "{ProjectName}/Components/Account/Pages/Manage/Index.razor", + "{ProjectName}/Components/Account/Pages/Manage/Passkeys.razor", "{ProjectName}/Components/Account/Pages/Manage/PersonalData.razor", + "{ProjectName}/Components/Account/Pages/Manage/RenamePasskey.razor", "{ProjectName}/Components/Account/Pages/Manage/ResetAuthenticator.razor", "{ProjectName}/Components/Account/Pages/Manage/SetPassword.razor", "{ProjectName}/Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -2133,9 +2183,13 @@ "{ProjectName}/Components/Account/Pages/ResetPassword.razor", "{ProjectName}/Components/Account/Pages/ResetPasswordConfirmation.razor", "{ProjectName}/Components/Account/Pages/_Imports.razor", + "{ProjectName}/Components/Account/PasskeyInputModel.cs", + "{ProjectName}/Components/Account/PasskeyOperation.cs", "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", "{ProjectName}/Components/Account/Shared/StatusMessage.razor", "{ProjectName}/Components/App.razor", diff --git a/src/Security/Authentication/Core/src/AuthenticationHandler.cs b/src/Security/Authentication/Core/src/AuthenticationHandler.cs index 242bee0c3424..2695e668cd57 100644 --- a/src/Security/Authentication/Core/src/AuthenticationHandler.cs +++ b/src/Security/Authentication/Core/src/AuthenticationHandler.cs @@ -205,7 +205,7 @@ protected string BuildRedirectUri(string targetPath) { var target = scheme ?? Options.ForwardDefaultSelector?.Invoke(Context) ?? Options.ForwardDefault; - // Prevent self targetting + // Prevent self targeting return string.Equals(target, Scheme.Name, StringComparison.Ordinal) ? null : target;