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