From 01104ac138747b0a8b0baee9a124e724d307466d Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 26 May 2025 21:59:45 -0700 Subject: [PATCH 01/31] Initial commit --- AspNetCore.slnx | 1 + eng/Dependencies.props | 1 + eng/SharedFramework.External.props | 1 + eng/Version.Details.xml | 8 + eng/Versions.props | 1 + eng/common/build.ps1 | 8 +- src/Components/Components.slnf | 7 + src/Components/ComponentsNoDeps.slnf | 2 +- src/Identity/Core/src/EventIds.cs | 2 + .../src/HttpPasskeyRequestContextProvider.cs | 25 + .../Core/src/IdentityBuilderExtensions.cs | 1 + .../IdentityServiceCollectionExtensions.cs | 3 + src/Identity/Core/src/PublicAPI.Unshipped.txt | 5 + src/Identity/Core/src/SignInManager.cs | 172 +++++- .../src/IdentityDbContext.cs | 82 ++- ...dentityEntityFrameworkBuilderExtensions.cs | 9 +- .../src/IdentityUserContext.cs | 156 +++++- .../src/PublicAPI.Unshipped.txt | 189 +++++++ .../EntityFrameworkCore/src/UserOnlyStore.cs | 253 ++++++++- .../EntityFrameworkCore/src/UserStore.cs | 228 +++++++- .../src/DefaultPasskeyHandler.cs | 525 ++++++++++++++++++ .../src/DefaultPasskeyOriginValidator.cs | 80 +++ .../DefaultPasskeyRequestContextProvider.cs | 28 + .../IPasskeyAttestationStatementVerifier.cs | 27 + .../Extensions.Core/src/IPasskeyHandler.cs | 36 ++ .../src/IPasskeyOriginValidator.cs | 23 + .../src/IPasskeyRequestContextProvider.cs | 21 + .../Extensions.Core/src/IUserPasskeyStore.cs | 62 +++ .../src/IdentityJsonSerializerContext.cs | 15 + .../Extensions.Core/src/IdentityOptions.cs | 8 + .../src/IdentitySchemaVersions.cs | 4 + .../IdentityServiceCollectionExtensions.cs | 3 + .../Extensions.Core/src/LoggerEventIds.cs | 2 + .../Microsoft.Extensions.Identity.Core.csproj | 8 +- .../src/PasskeyAssertionResult.cs | 86 +++ .../src/PasskeyAttestationResult.cs | 68 +++ .../src/PasskeyCreationArgs.cs | 52 ++ .../src/PasskeyCreationOptions.cs | 51 ++ .../Extensions.Core/src/PasskeyException.cs | 32 ++ .../src/PasskeyExceptionExtensions.cs | 104 ++++ .../Extensions.Core/src/PasskeyOptions.cs | 105 ++++ .../Extensions.Core/src/PasskeyOriginInfo.cs | 28 + .../Extensions.Core/src/PasskeyRequestArgs.cs | 46 ++ .../src/PasskeyRequestContext.cs | 26 + .../src/PasskeyRequestOptions.cs | 48 ++ .../Extensions.Core/src/PasskeyUserEntity.cs | 34 ++ .../src/Passkeys/AttestationObject.cs | 67 +++ .../src/Passkeys/AttestedCredentialData.cs | 78 +++ .../AuthenticatorAssertionResponse.cs | 35 ++ .../AuthenticatorAttestationResponse.cs | 34 ++ .../src/Passkeys/AuthenticatorData.cs | 140 +++++ .../src/Passkeys/AuthenticatorDataFlags.cs | 50 ++ .../src/Passkeys/AuthenticatorResponse.cs | 24 + .../AuthenticatorSelectionCriteria.cs | 52 ++ .../src/Passkeys/BufferSource.cs | 169 ++++++ .../src/Passkeys/BufferSourceJsonConverter.cs | 35 ++ .../src/Passkeys/COSEAlgorithmIdentifier.cs | 32 ++ .../src/Passkeys/CollectedClientData.cs | 48 ++ .../src/Passkeys/CredentialPublicKey.cs | 264 +++++++++ .../src/Passkeys/Ctap2CborReader.cs | 69 +++ .../src/Passkeys/PublicKeyCredential.cs | 51 ++ .../PublicKeyCredentialCreationOptions.cs | 77 +++ .../Passkeys/PublicKeyCredentialDescriptor.cs | 35 ++ .../Passkeys/PublicKeyCredentialParameters.cs | 61 ++ .../PublicKeyCredentialRequestOptions.cs | 56 ++ .../Passkeys/PublicKeyCredentialRpEntity.cs | 30 + .../Passkeys/PublicKeyCredentialUserEntity.cs | 35 ++ .../src/Passkeys/TokenBinding.cs | 36 ++ .../src/PublicAPI.Unshipped.txt | 143 +++++ .../Extensions.Core/src/Resources.resx | 58 +- .../Extensions.Core/src/UserManager.cs | 312 +++++++++++ .../Extensions.Core/src/UserPasskeyInfo.cs | 116 ++++ .../src/IdentityUserPasskey.cs | 85 +++ .../src/PublicAPI.Unshipped.txt | 26 + src/Identity/Identity.slnf | 1 + .../Data/FailedResponse.cs | 6 + .../Data/OkResponse.cs | 6 + ...blicKeyCredentialCreationOptionsRequest.cs | 16 + ...verPublicKeyCredentialGetOptionsRequest.cs | 13 + ...erverPublicKeyCredentialOptionsResponse.cs | 43 ++ .../Data/ServerResponse.cs | 10 + .../IdentitySample.PasskeyConformance.csproj | 26 + .../InMemoryUserStore.cs | 140 +++++ .../Program.cs | 203 +++++++ .../Properties/launchSettings.json | 23 + .../appsettings.Development.json | 8 + .../appsettings.json | 9 + src/Identity/startvscode.cmd | 3 + .../test/Shared/PocoModel/PocoRole.cs | 2 + .../test/Shared/PocoModel/PocoRoleClaim.cs | 2 + .../test/Shared/PocoModel/PocoUser.cs | 6 + .../test/Shared/PocoModel/PocoUserClaim.cs | 2 + .../test/Shared/PocoModel/PocoUserLogin.cs | 2 + .../test/Shared/PocoModel/PocoUserPasskey.cs | 87 +++ .../test/Shared/PocoModel/PocoUserRole.cs | 2 + .../test/Shared/PocoModel/PocoUserToken.cs | 2 + .../Components/Account/Pages/Login.razor | 71 ++- .../Account/Pages/Manage/Passkeys.razor | 238 ++++++++ .../Account/Shared/ManageNavMenu.razor | 3 + .../Account/Shared/PasskeyHandler.razor | 76 +++ .../Account/Shared/PasskeyHandler.razor.js | 78 +++ ...000000000_CreateIdentitySchema.Designer.cs | 67 ++- .../00000000000000_CreateIdentitySchema.cs | 46 +- .../ApplicationDbContextModelSnapshot.cs | 67 ++- ...000000000_CreateIdentitySchema.Designer.cs | 77 ++- .../00000000000000_CreateIdentitySchema.cs | 43 +- .../ApplicationDbContextModelSnapshot.cs | 77 ++- .../BlazorWeb-CSharp/Data/app.db | Bin 102400 -> 118784 bytes .../BlazorWeb-CSharp/Program.Main.cs | 6 +- .../BlazorWeb-CSharp/Program.cs | 18 +- .../Core/src/AuthenticationHandler.cs | 2 +- 111 files changed, 6286 insertions(+), 89 deletions(-) create mode 100644 src/Identity/Core/src/HttpPasskeyRequestContextProvider.cs create mode 100644 src/Identity/Extensions.Core/src/DefaultPasskeyHandler.cs create mode 100644 src/Identity/Extensions.Core/src/DefaultPasskeyOriginValidator.cs create mode 100644 src/Identity/Extensions.Core/src/DefaultPasskeyRequestContextProvider.cs create mode 100644 src/Identity/Extensions.Core/src/IPasskeyAttestationStatementVerifier.cs create mode 100644 src/Identity/Extensions.Core/src/IPasskeyHandler.cs create mode 100644 src/Identity/Extensions.Core/src/IPasskeyOriginValidator.cs create mode 100644 src/Identity/Extensions.Core/src/IPasskeyRequestContextProvider.cs create mode 100644 src/Identity/Extensions.Core/src/IUserPasskeyStore.cs create mode 100644 src/Identity/Extensions.Core/src/IdentityJsonSerializerContext.cs create mode 100644 src/Identity/Extensions.Core/src/PasskeyAssertionResult.cs create mode 100644 src/Identity/Extensions.Core/src/PasskeyAttestationResult.cs create mode 100644 src/Identity/Extensions.Core/src/PasskeyCreationArgs.cs create mode 100644 src/Identity/Extensions.Core/src/PasskeyCreationOptions.cs create mode 100644 src/Identity/Extensions.Core/src/PasskeyException.cs create mode 100644 src/Identity/Extensions.Core/src/PasskeyExceptionExtensions.cs create mode 100644 src/Identity/Extensions.Core/src/PasskeyOptions.cs create mode 100644 src/Identity/Extensions.Core/src/PasskeyOriginInfo.cs create mode 100644 src/Identity/Extensions.Core/src/PasskeyRequestArgs.cs create mode 100644 src/Identity/Extensions.Core/src/PasskeyRequestContext.cs create mode 100644 src/Identity/Extensions.Core/src/PasskeyRequestOptions.cs create mode 100644 src/Identity/Extensions.Core/src/PasskeyUserEntity.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/AttestationObject.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/AttestedCredentialData.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/AuthenticatorAssertionResponse.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/AuthenticatorAttestationResponse.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/AuthenticatorData.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/AuthenticatorDataFlags.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/AuthenticatorResponse.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/AuthenticatorSelectionCriteria.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/BufferSource.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/BufferSourceJsonConverter.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/COSEAlgorithmIdentifier.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/CollectedClientData.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/CredentialPublicKey.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/Ctap2CborReader.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredential.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialDescriptor.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialParameters.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialRpEntity.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialUserEntity.cs create mode 100644 src/Identity/Extensions.Core/src/Passkeys/TokenBinding.cs create mode 100644 src/Identity/Extensions.Core/src/UserPasskeyInfo.cs create mode 100644 src/Identity/Extensions.Stores/src/IdentityUserPasskey.cs create mode 100644 src/Identity/samples/IdentitySample.PasskeyConformance/Data/FailedResponse.cs create mode 100644 src/Identity/samples/IdentitySample.PasskeyConformance/Data/OkResponse.cs create mode 100644 src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialCreationOptionsRequest.cs create mode 100644 src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialGetOptionsRequest.cs create mode 100644 src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialOptionsResponse.cs create mode 100644 src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerResponse.cs create mode 100644 src/Identity/samples/IdentitySample.PasskeyConformance/IdentitySample.PasskeyConformance.csproj create mode 100644 src/Identity/samples/IdentitySample.PasskeyConformance/InMemoryUserStore.cs create mode 100644 src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs create mode 100644 src/Identity/samples/IdentitySample.PasskeyConformance/Properties/launchSettings.json create mode 100644 src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.Development.json create mode 100644 src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.json create mode 100644 src/Identity/startvscode.cmd create mode 100644 src/Identity/test/Shared/PocoModel/PocoUserPasskey.cs create mode 100644 src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor create mode 100644 src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeyHandler.razor create mode 100644 src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeyHandler.razor.js diff --git a/AspNetCore.slnx b/AspNetCore.slnx index 78284eb90b2c..d2c15b537600 100644 --- a/AspNetCore.slnx +++ b/AspNetCore.slnx @@ -392,6 +392,7 @@ + diff --git a/eng/Dependencies.props b/eng/Dependencies.props index 7d944888c843..a97282f8c248 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -82,6 +82,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 3362eb701e39..e7145153685a 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -211,6 +211,10 @@ https://github.com/dotnet/dotnet a4d6fdc935d5da12efb00a0b3b693ff1439e0b41 + + https://github.com/dotnet/dotnet + a4d6fdc935d5da12efb00a0b3b693ff1439e0b41 + https://github.com/dotnet/dotnet a4d6fdc935d5da12efb00a0b3b693ff1439e0b41 @@ -291,6 +295,10 @@ https://github.com/dotnet/dotnet a4d6fdc935d5da12efb00a0b3b693ff1439e0b41 + + https://github.com/dotnet/runtime + fa004fb5ce5ec9f99d1c3ba3adc48c9473cc8eaa + https://github.com/dotnet/dotnet diff --git a/eng/Versions.props b/eng/Versions.props index 715a5afa37d6..a5d7c09d0bf7 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -117,6 +117,7 @@ 10.0.0-preview.5.25270.108 10.0.0-preview.5.25270.108 10.0.0-preview.5.25270.108 + 10.0.0-preview.5.25270.108 10.0.0-preview.5.25270.108 10.0.0-preview.5.25270.108 10.0.0-preview.5.25270.108 diff --git a/eng/common/build.ps1 b/eng/common/build.ps1 index ae2309e312d7..71f41a1fb248 100644 --- a/eng/common/build.ps1 +++ b/eng/common/build.ps1 @@ -36,7 +36,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:" @@ -106,10 +106,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/src/Components/Components.slnf b/src/Components/Components.slnf index cc79f35b9b88..af7dca18742d 100644 --- a/src/Components/Components.slnf +++ b/src/Components/Components.slnf @@ -83,8 +83,10 @@ "src\\Http\\Authentication.Core\\src\\Microsoft.AspNetCore.Authentication.Core.csproj", "src\\Http\\Headers\\src\\Microsoft.Net.Http.Headers.csproj", "src\\Http\\Http.Abstractions\\src\\Microsoft.AspNetCore.Http.Abstractions.csproj", + "src\\Http\\Http.Extensions\\gen\\Microsoft.AspNetCore.Http.RequestDelegateGenerator\\Microsoft.AspNetCore.Http.RequestDelegateGenerator.csproj", "src\\Http\\Http.Extensions\\src\\Microsoft.AspNetCore.Http.Extensions.csproj", "src\\Http\\Http.Features\\src\\Microsoft.AspNetCore.Http.Features.csproj", + "src\\Http\\Http.Results\\src\\Microsoft.AspNetCore.Http.Results.csproj", "src\\Http\\Http\\src\\Microsoft.AspNetCore.Http.csproj", "src\\Http\\Metadata\\src\\Microsoft.AspNetCore.Metadata.csproj", "src\\Http\\Routing.Abstractions\\src\\Microsoft.AspNetCore.Routing.Abstractions.csproj", @@ -100,13 +102,16 @@ "src\\Localization\\Localization\\src\\Microsoft.Extensions.Localization.csproj", "src\\Middleware\\CORS\\src\\Microsoft.AspNetCore.Cors.csproj", "src\\Middleware\\Diagnostics.Abstractions\\src\\Microsoft.AspNetCore.Diagnostics.Abstractions.csproj", + "src\\Middleware\\Diagnostics.EntityFrameworkCore\\src\\Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore.csproj", "src\\Middleware\\Diagnostics\\src\\Microsoft.AspNetCore.Diagnostics.csproj", "src\\Middleware\\HostFiltering\\src\\Microsoft.AspNetCore.HostFiltering.csproj", "src\\Middleware\\HttpOverrides\\src\\Microsoft.AspNetCore.HttpOverrides.csproj", "src\\Middleware\\HttpsPolicy\\src\\Microsoft.AspNetCore.HttpsPolicy.csproj", "src\\Middleware\\Localization\\src\\Microsoft.AspNetCore.Localization.csproj", + "src\\Middleware\\OutputCaching\\src\\Microsoft.AspNetCore.OutputCaching.csproj", "src\\Middleware\\ResponseCaching.Abstractions\\src\\Microsoft.AspNetCore.ResponseCaching.Abstractions.csproj", "src\\Middleware\\ResponseCompression\\src\\Microsoft.AspNetCore.ResponseCompression.csproj", + "src\\Middleware\\Session\\src\\Microsoft.AspNetCore.Session.csproj", "src\\Middleware\\StaticFiles\\src\\Microsoft.AspNetCore.StaticFiles.csproj", "src\\Middleware\\WebSockets\\src\\Microsoft.AspNetCore.WebSockets.csproj", "src\\Mvc\\Mvc.Abstractions\\src\\Microsoft.AspNetCore.Mvc.Abstractions.csproj", @@ -126,12 +131,14 @@ "src\\ObjectPool\\src\\Microsoft.Extensions.ObjectPool.csproj", "src\\Razor\\Razor.Runtime\\src\\Microsoft.AspNetCore.Razor.Runtime.csproj", "src\\Razor\\Razor\\src\\Microsoft.AspNetCore.Razor.csproj", + "src\\Security\\Authentication\\BearerToken\\src\\Microsoft.AspNetCore.Authentication.BearerToken.csproj", "src\\Security\\Authentication\\Cookies\\src\\Microsoft.AspNetCore.Authentication.Cookies.csproj", "src\\Security\\Authentication\\Core\\src\\Microsoft.AspNetCore.Authentication.csproj", "src\\Security\\Authorization\\Core\\src\\Microsoft.AspNetCore.Authorization.csproj", "src\\Security\\Authorization\\Policy\\src\\Microsoft.AspNetCore.Authorization.Policy.csproj", "src\\Security\\CookiePolicy\\src\\Microsoft.AspNetCore.CookiePolicy.csproj", "src\\Servers\\Connections.Abstractions\\src\\Microsoft.AspNetCore.Connections.Abstractions.csproj", + "src\\Servers\\HttpSys\\src\\Microsoft.AspNetCore.Server.HttpSys.csproj", "src\\Servers\\IIS\\IISIntegration\\src\\Microsoft.AspNetCore.Server.IISIntegration.csproj", "src\\Servers\\IIS\\IIS\\src\\Microsoft.AspNetCore.Server.IIS.csproj", "src\\Servers\\Kestrel\\Core\\src\\Microsoft.AspNetCore.Server.Kestrel.Core.csproj", 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/Identity/Core/src/EventIds.cs b/src/Identity/Core/src/EventIds.cs index 9c4aa5ed7e78..55766f83482f 100644 --- a/src/Identity/Core/src/EventIds.cs +++ b/src/Identity/Core/src/EventIds.cs @@ -15,4 +15,6 @@ internal static class EventIds 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 EventId NoPasskeyCreationOptions = new EventId(6, "NoPasskeyCreationOptions"); + public static EventId UserDoesNotMatchPasskeyCreationOptions = new EventId(7, "UserDoesNotMatchPasskeyCreationOptions"); } diff --git a/src/Identity/Core/src/HttpPasskeyRequestContextProvider.cs b/src/Identity/Core/src/HttpPasskeyRequestContextProvider.cs new file mode 100644 index 000000000000..75d57e41aff0 --- /dev/null +++ b/src/Identity/Core/src/HttpPasskeyRequestContextProvider.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. + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Identity; + +internal sealed class HttpPasskeyRequestContextProvider(IHttpContextAccessor httpContextAccessor, IOptions options) : IPasskeyRequestContextProvider +{ + private PasskeyRequestContext? _context; + + public PasskeyRequestContext Context => _context ??= GetPasskeyRequestContext(); + + private PasskeyRequestContext GetPasskeyRequestContext() + { + var passkeyOptions = options.Value.Passkey; + var httpContext = httpContextAccessor.HttpContext; + return new() + { + Domain = passkeyOptions.ServerDomain ?? httpContext?.Request.Host.Host, + Origin = httpContext?.Request.Headers.Origin, + }; + } +} diff --git a/src/Identity/Core/src/IdentityBuilderExtensions.cs b/src/Identity/Core/src/IdentityBuilderExtensions.cs index 264a88a5c23a..2aed6b55d67e 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(); builder.Services.AddScoped(typeof(ISecurityStampValidator), typeof(SecurityStampValidator<>).MakeGenericType(builder.UserType)); builder.Services.AddScoped(typeof(ITwoFactorSecurityStampValidator), typeof(TwoFactorSecurityStampValidator<>).MakeGenericType(builder.UserType)); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, PostConfigureSecurityStampValidatorOptions>()); diff --git a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs index 12034b3c8971..d86d128f4d9d 100644 --- a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs +++ b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs @@ -102,6 +102,9 @@ public static class IdentityServiceCollectionExtensions services.TryAddScoped>(); services.TryAddScoped, UserClaimsPrincipalFactory>(); services.TryAddScoped, DefaultUserConfirmation>(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped, DefaultPasskeyHandler>(); services.TryAddScoped>(); services.TryAddScoped>(); services.TryAddScoped>(); diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..9a6a595e8c45 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1 +1,6 @@ #nullable enable +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.PasskeySignInAsync(string! credentialJson, Microsoft.AspNetCore.Identity.PasskeyRequestOptions! 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..d9e7354eb02e 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -19,6 +19,8 @@ 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; @@ -26,6 +28,8 @@ public class SignInManager where TUser : class private readonly IUserConfirmation _confirmation; private HttpContext? _context; private TwoFactorAuthenticationInfo? _twoFactorInfo; + private PasskeyCreationOptions? _passkeyCreationOptions; + private PasskeyRequestOptions? _passkeyRequestOptions; /// /// Creates a new instance of . @@ -340,7 +344,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 +365,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 +385,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 +435,157 @@ public virtual async Task CheckPasswordSignInAsync(TUser user, str return SignInResult.Failed; } + /// + /// Attempts to sign in the user with a passkey, as an asynchronous operation. + /// + /// 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 UserManager.PerformPasskeyAssertionAsync(credentialJson, options); + if (!assertionResult.Succeeded) + { + return SignInResult.Failed; + } + + var setPasskeyResult = await UserManager.SetPasskeyAsync(assertionResult.User, assertionResult.Passkey).ConfigureAwait(false); + 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 . + /// + /// A task object representing the asynchronous operation containing the . + /// + public virtual async Task ConfigurePasskeyCreationOptionsAsync(PasskeyCreationArgs creationArgs) + { + ArgumentNullException.ThrowIfNull(creationArgs); + + var options = await UserManager.GeneratePasskeyCreationOptionsAsync(creationArgs); + + var props = new AuthenticationProperties(); + props.Items[PasskeyCreationOptionsKey] = options.AsJson(); + var claimsIdentity = new 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 and stores it in the current for later retrieval. + /// + /// Args for configuring the . + /// + /// A task object representing the asynchronous operation containing the . + /// + public virtual async Task ConfigurePasskeyRequestOptionsAsync(PasskeyRequestArgs requestArgs) + { + ArgumentNullException.ThrowIfNull(requestArgs); + + var options = await UserManager.GeneratePasskeyRequestOptionsAsync(requestArgs); + + var props = new AuthenticationProperties(); + props.Items[PasskeyRequestOptionsKey] = options.AsJson(); + var claimsIdentity = new 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; + } + + /// + /// 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) + { + return null; + } + + var optionsJson = result.Properties?.Items[PasskeyCreationOptionsKey]; + if (optionsJson == 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) + { + return null; + } + + var optionsJson = result.Properties?.Items[PasskeyRequestOptionsKey]; + if (optionsJson == 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 +696,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 +744,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 +805,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 +817,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 +849,7 @@ public virtual async Task> GetExternalAuthenti /// Gets the external login information for the current login, as an asynchronous operation. /// /// Flag indication whether a Cross Site Request Forgery token was expected in the current request. - /// The task object representing the asynchronous operation containing the + /// The task object representing the asynchronous operation containing the /// for the sign-in attempt. public virtual async Task GetExternalLoginInfoAsync(string? expectedXsrf = null) { diff --git a/src/Identity/EntityFrameworkCore/src/IdentityDbContext.cs b/src/Identity/EntityFrameworkCore/src/IdentityDbContext.cs index fd297f425093..25f5076bc6ca 100644 --- a/src/Identity/EntityFrameworkCore/src/IdentityDbContext.cs +++ b/src/Identity/EntityFrameworkCore/src/IdentityDbContext.cs @@ -74,7 +74,7 @@ protected IdentityDbContext() { } /// The type of the user login object. /// The type of the role claim object. /// The type of the user token object. -public abstract class IdentityDbContext : IdentityUserContext +public class IdentityDbContext : IdentityDbContext> where TUser : IdentityUser where TRole : IdentityRole where TKey : IEquatable @@ -83,6 +83,41 @@ public abstract class IdentityDbContext where TRoleClaim : IdentityRoleClaim where TUserToken : IdentityUserToken +{ + /// + /// Initializes a new instance of the db context. + /// + /// The options to be used by a . + public IdentityDbContext(DbContextOptions options) : base(options) { } + + /// + /// Initializes a new instance of the class. + /// + protected IdentityDbContext() { } +} + +/// +/// Base class for the Entity Framework database context used for identity. +/// +/// The type of user objects. +/// The type of role objects. +/// The type of the primary key for users and roles. +/// The type of the user claim object. +/// The type of the user role object. +/// The type of the user login object. +/// The type of the role claim object. +/// The type of the user token object. +/// The type of the user token object. +public abstract class IdentityDbContext : IdentityUserContext + where TUser : IdentityUser + where TRole : IdentityRole + where TKey : IEquatable + where TUserClaim : IdentityUserClaim + where TUserRole : IdentityUserRole + where TUserLogin : IdentityUserLogin + where TRoleClaim : IdentityRoleClaim + where TUserToken : IdentityUserToken + where TUserPasskey : IdentityUserPasskey { /// /// Initializes a new instance of the class. @@ -121,6 +156,49 @@ protected override void OnModelCreating(ModelBuilder builder) base.OnModelCreating(builder); } + /// + /// Configures the schema needed for the identity framework for schema version 3.0 + /// + /// + /// The builder being used to construct the model for this context. + /// + internal override void OnModelCreatingVersion3(ModelBuilder builder) + { + base.OnModelCreatingVersion3(builder); + + // Currently no differences between Version 3 and Version 2 + builder.Entity(b => + { + b.HasMany().WithOne().HasForeignKey(ur => ur.UserId).IsRequired(); + }); + + builder.Entity(b => + { + b.HasKey(r => r.Id); + b.HasIndex(r => r.NormalizedName).HasDatabaseName("RoleNameIndex").IsUnique(); + b.ToTable("AspNetRoles"); + b.Property(r => r.ConcurrencyStamp).IsConcurrencyToken(); + + b.Property(u => u.Name).HasMaxLength(256); + b.Property(u => u.NormalizedName).HasMaxLength(256); + + b.HasMany().WithOne().HasForeignKey(ur => ur.RoleId).IsRequired(); + b.HasMany().WithOne().HasForeignKey(rc => rc.RoleId).IsRequired(); + }); + + builder.Entity(b => + { + b.HasKey(rc => rc.Id); + b.ToTable("AspNetRoleClaims"); + }); + + builder.Entity(b => + { + b.HasKey(r => new { r.UserId, r.RoleId }); + b.ToTable("AspNetUserRoles"); + }); + } + /// /// Configures the schema needed for the identity framework for schema version 2.0 /// @@ -131,7 +209,7 @@ internal override void OnModelCreatingVersion2(ModelBuilder builder) { base.OnModelCreatingVersion2(builder); - // Current no differences between Version 2 and Version 1 + // No differences between Version 2 and Version 1 builder.Entity(b => { b.HasMany().WithOne().HasForeignKey(ur => ur.UserId).IsRequired(); diff --git a/src/Identity/EntityFrameworkCore/src/IdentityEntityFrameworkBuilderExtensions.cs b/src/Identity/EntityFrameworkCore/src/IdentityEntityFrameworkBuilderExtensions.cs index 52beacac6f6d..50584e72a1af 100644 --- a/src/Identity/EntityFrameworkCore/src/IdentityEntityFrameworkBuilderExtensions.cs +++ b/src/Identity/EntityFrameworkCore/src/IdentityEntityFrameworkBuilderExtensions.cs @@ -46,7 +46,7 @@ private static void AddStores(IServiceCollection services, Type userType, Type? Type userStoreType; Type roleStoreType; - var identityContext = FindGenericBaseType(contextType, typeof(IdentityDbContext<,,,,,,,>)); + var identityContext = FindGenericBaseType(contextType, typeof(IdentityDbContext<,,,,,,,,>)); if (identityContext == null) { // If its a custom DbContext, we can only add the default POCOs @@ -55,13 +55,14 @@ private static void AddStores(IServiceCollection services, Type userType, Type? } else { - userStoreType = typeof(UserStore<,,,,,,,,>).MakeGenericType(userType, roleType, contextType, + userStoreType = typeof(UserStore<,,,,,,,,,>).MakeGenericType(userType, roleType, contextType, identityContext.GenericTypeArguments[2], identityContext.GenericTypeArguments[3], identityContext.GenericTypeArguments[4], identityContext.GenericTypeArguments[5], identityContext.GenericTypeArguments[7], - identityContext.GenericTypeArguments[6]); + identityContext.GenericTypeArguments[6], + identityContext.GenericTypeArguments[8]); roleStoreType = typeof(RoleStore<,,,,>).MakeGenericType(roleType, contextType, identityContext.GenericTypeArguments[2], identityContext.GenericTypeArguments[4], @@ -73,7 +74,7 @@ private static void AddStores(IServiceCollection services, Type userType, Type? else { // No Roles Type userStoreType; - var identityContext = FindGenericBaseType(contextType, typeof(IdentityUserContext<,,,,>)); + var identityContext = FindGenericBaseType(contextType, typeof(IdentityUserContext<,,,,,>)); if (identityContext == null) { // If its a custom DbContext, we can only add the default POCOs diff --git a/src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs b/src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs index 84e7bfc5282f..fa9968d844c4 100644 --- a/src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs +++ b/src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs @@ -57,12 +57,41 @@ protected IdentityUserContext() { } /// The type of the user claim object. /// The type of the user login object. /// The type of the user token object. -public abstract class IdentityUserContext : DbContext +public class IdentityUserContext : IdentityUserContext> where TUser : IdentityUser where TKey : IEquatable where TUserClaim : IdentityUserClaim where TUserLogin : IdentityUserLogin where TUserToken : IdentityUserToken +{ + /// + /// Initializes a new instance of the db context. + /// + /// The options to be used by a . + public IdentityUserContext(DbContextOptions options) : base(options) { } + + /// + /// Initializes a new instance of the class. + /// + protected IdentityUserContext() { } +} + +/// +/// Base class for the Entity Framework database context used for identity. +/// +/// The type of user objects. +/// The type of the primary key for users and roles. +/// The type of the user claim object. +/// The type of the user login object. +/// The type of the user token object. +/// The type of the user passkey object. +public abstract class IdentityUserContext : DbContext + where TUser : IdentityUser + where TKey : IEquatable + where TUserClaim : IdentityUserClaim + where TUserLogin : IdentityUserLogin + where TUserToken : IdentityUserToken + where TUserPasskey : IdentityUserPasskey { /// /// Initializes a new instance of the class. @@ -95,6 +124,11 @@ protected IdentityUserContext() { } /// public virtual DbSet UserTokens { get; set; } = default!; + /// + /// Gets or sets the of User passkeys. + /// + public virtual DbSet UserPasskeys { get; set; } = default!; + /// /// Gets the schema version used for versioning. /// @@ -133,7 +167,11 @@ protected override void OnModelCreating(ModelBuilder builder) /// The schema version. internal virtual void OnModelCreatingVersion(ModelBuilder builder, Version schemaVersion) { - if (schemaVersion >= IdentitySchemaVersions.Version2) + if (schemaVersion >= IdentitySchemaVersions.Version3) + { + OnModelCreatingVersion3(builder); + } + else if (schemaVersion >= IdentitySchemaVersions.Version2) { OnModelCreatingVersion2(builder); } @@ -143,6 +181,116 @@ internal virtual void OnModelCreatingVersion(ModelBuilder builder, Version schem } } + /// + /// Configures the schema needed for the identity framework for schema version 3.0 + /// + /// + /// The builder being used to construct the model for this context. + /// + internal virtual void OnModelCreatingVersion3(ModelBuilder builder) + { + // Differences from Version 2: + // - Add a passkey entity + + var storeOptions = GetStoreOptions(); + var maxKeyLength = storeOptions?.MaxLengthForKeys ?? 0; + if (maxKeyLength == 0) + { + maxKeyLength = 128; + } + var encryptPersonalData = storeOptions?.ProtectPersonalData ?? false; + PersonalDataConverter? converter = null; + + builder.Entity(b => + { + b.HasKey(u => u.Id); + b.HasIndex(u => u.NormalizedUserName).HasDatabaseName("UserNameIndex").IsUnique(); + b.HasIndex(u => u.NormalizedEmail).HasDatabaseName("EmailIndex"); + b.ToTable("AspNetUsers"); + b.Property(u => u.ConcurrencyStamp).IsConcurrencyToken(); + + b.Property(u => u.UserName).HasMaxLength(256); + b.Property(u => u.NormalizedUserName).HasMaxLength(256); + b.Property(u => u.Email).HasMaxLength(256); + b.Property(u => u.NormalizedEmail).HasMaxLength(256); + b.Property(u => u.PhoneNumber).HasMaxLength(256); + + if (encryptPersonalData) + { + converter = new PersonalDataConverter(this.GetService()); + var personalDataProps = typeof(TUser).GetProperties().Where( + prop => Attribute.IsDefined(prop, typeof(ProtectedPersonalDataAttribute))); + foreach (var p in personalDataProps) + { + if (p.PropertyType != typeof(string)) + { + throw new InvalidOperationException(Resources.CanOnlyProtectStrings); + } + b.Property(typeof(string), p.Name).HasConversion(converter); + } + } + + b.HasMany().WithOne().HasForeignKey(uc => uc.UserId).IsRequired(); + b.HasMany().WithOne().HasForeignKey(ul => ul.UserId).IsRequired(); + b.HasMany().WithOne().HasForeignKey(ut => ut.UserId).IsRequired(); + b.HasMany().WithOne().HasForeignKey(up => up.UserId).IsRequired(); + }); + + builder.Entity(b => + { + b.HasKey(uc => uc.Id); + b.ToTable("AspNetUserClaims"); + }); + + builder.Entity(b => + { + b.HasKey(l => new { l.LoginProvider, l.ProviderKey }); + + if (maxKeyLength > 0) + { + b.Property(l => l.LoginProvider).HasMaxLength(maxKeyLength); + b.Property(l => l.ProviderKey).HasMaxLength(maxKeyLength); + } + + b.ToTable("AspNetUserLogins"); + }); + + builder.Entity(b => + { + b.HasKey(t => new { t.UserId, t.LoginProvider, t.Name }); + + if (maxKeyLength > 0) + { + b.Property(t => t.LoginProvider).HasMaxLength(maxKeyLength); + b.Property(t => t.Name).HasMaxLength(maxKeyLength); + } + + if (encryptPersonalData) + { + var tokenProps = typeof(TUserToken).GetProperties().Where( + prop => Attribute.IsDefined(prop, typeof(ProtectedPersonalDataAttribute))); + foreach (var p in tokenProps) + { + if (p.PropertyType != typeof(string)) + { + throw new InvalidOperationException(Resources.CanOnlyProtectStrings); + } + b.Property(typeof(string), p.Name).HasConversion(converter); + } + } + + b.ToTable("AspNetUserTokens"); + }); + + builder.Entity(b => + { + b.HasKey(p => p.CredentialId); + b.ToTable("AspNetUserPasskeys"); + b.Property(p => p.CredentialId).HasMaxLength(1024); // Defined in WebAuthn spec to be no longer than 1023 bytes + b.Property(p => p.PublicKey).HasMaxLength(1024); // Safe upper limit + }); + } + /// /// Configures the schema needed for the identity framework for schema version 2.0 /// @@ -243,6 +391,8 @@ internal virtual void OnModelCreatingVersion2(ModelBuilder builder) b.ToTable("AspNetUserTokens"); }); + + builder.Ignore(); } /// @@ -336,5 +486,7 @@ internal virtual void OnModelCreatingVersion1(ModelBuilder builder) b.ToTable("AspNetUserTokens"); }); + + builder.Ignore(); } } diff --git a/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt b/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..c91fed9a68a2 100644 --- a/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt +++ b/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt @@ -1 +1,190 @@ #nullable enable +Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext +Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.IdentityDbContext() -> void +Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.IdentityDbContext(Microsoft.EntityFrameworkCore.DbContextOptions! options) -> void +Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext +Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.IdentityUserContext() -> void +Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.IdentityUserContext(Microsoft.EntityFrameworkCore.DbContextOptions! options) -> void +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AutoSaveChanges.get -> bool +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AutoSaveChanges.set -> void +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.SaveChanges(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UserClaims.get -> Microsoft.EntityFrameworkCore.DbSet! +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UserLogins.get -> Microsoft.EntityFrameworkCore.DbSet! +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UserOnlyStore(TContext! context, Microsoft.AspNetCore.Identity.IdentityErrorDescriber? describer = null) -> void +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UserPasskeys.get -> Microsoft.EntityFrameworkCore.DbSet! +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UsersSet.get -> Microsoft.EntityFrameworkCore.DbSet! +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UserTokens.get -> Microsoft.EntityFrameworkCore.DbSet! +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AutoSaveChanges.get -> bool +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AutoSaveChanges.set -> void +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.SaveChanges(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.UserStore(TContext! context, Microsoft.AspNetCore.Identity.IdentityErrorDescriber? describer = null) -> void +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.OnModelCreating(Microsoft.EntityFrameworkCore.ModelBuilder! builder) -> void +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.OnModelCreating(Microsoft.EntityFrameworkCore.ModelBuilder! builder) -> void +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AddClaimsAsync(TUser! user, System.Collections.Generic.IEnumerable! claims, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AddLoginAsync(TUser! user, Microsoft.AspNetCore.Identity.UserLoginInfo! login, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AddUserTokenAsync(TUserToken! token) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.CreateAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.DeleteAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByEmailAsync(string! normalizedEmail, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByIdAsync(string! userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByNameAsync(string! normalizedUserName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindTokenAsync(TUser! user, string! loginProvider, string! name, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserAsync(TKey userId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserLoginAsync(TKey userId, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.GetClaimsAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.GetLoginsAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.GetUsersForClaimAsync(System.Security.Claims.Claim! claim, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.RemoveClaimsAsync(TUser! user, System.Collections.Generic.IEnumerable! claims, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.RemoveLoginAsync(TUser! user, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.RemoveUserTokenAsync(TUserToken! token) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.ReplaceClaimAsync(TUser! user, System.Security.Claims.Claim! claim, System.Security.Claims.Claim! newClaim, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UpdateAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.Users.get -> System.Linq.IQueryable! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddClaimsAsync(TUser! user, System.Collections.Generic.IEnumerable! claims, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddLoginAsync(TUser! user, Microsoft.AspNetCore.Identity.UserLoginInfo! login, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddToRoleAsync(TUser! user, string! normalizedRoleName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddUserTokenAsync(TUserToken! token) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.CreateAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.DeleteAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByEmailAsync(string! normalizedEmail, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByIdAsync(string! userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByNameAsync(string! normalizedUserName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindRoleAsync(string! normalizedRoleName, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindTokenAsync(TUser! user, string! loginProvider, string! name, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserAsync(TKey userId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserLoginAsync(TKey userId, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserRoleAsync(TKey userId, TKey roleId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetClaimsAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetLoginsAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetRolesAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetUsersForClaimAsync(System.Security.Claims.Claim! claim, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetUsersInRoleAsync(string! normalizedRoleName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.IsInRoleAsync(TUser! user, string! normalizedRoleName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemoveClaimsAsync(TUser! user, System.Collections.Generic.IEnumerable! claims, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemoveFromRoleAsync(TUser! user, string! normalizedRoleName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemoveLoginAsync(TUser! user, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemoveUserTokenAsync(TUserToken! token) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.ReplaceClaimAsync(TUser! user, System.Security.Claims.Claim! claim, System.Security.Claims.Claim! newClaim, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.UpdateAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.Users.get -> System.Linq.IQueryable! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.RoleClaims.get -> Microsoft.EntityFrameworkCore.DbSet! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.RoleClaims.set -> void +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.Roles.get -> Microsoft.EntityFrameworkCore.DbSet! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.Roles.set -> void +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.UserRoles.get -> Microsoft.EntityFrameworkCore.DbSet! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.UserRoles.set -> void +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.SchemaVersion.get -> System.Version! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserClaims.get -> Microsoft.EntityFrameworkCore.DbSet! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserClaims.set -> void +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserLogins.get -> Microsoft.EntityFrameworkCore.DbSet! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserLogins.set -> void +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserPasskeys.get -> Microsoft.EntityFrameworkCore.DbSet! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserPasskeys.set -> void +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.Users.get -> Microsoft.EntityFrameworkCore.DbSet! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.Users.set -> void +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserTokens.get -> Microsoft.EntityFrameworkCore.DbSet! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserTokens.set -> void +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.Context.get -> TContext! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.CreateUserPasskey(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey) -> TUserPasskey! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByPasskeyIdAsync(byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindPasskeyAsync(TUser! user, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserPasskeyAsync(TKey userId, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserPasskeyByIdAsync(byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.GetPasskeysAsync(TUser! user, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.RemovePasskeyAsync(TUser! user, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.SetPasskeyAsync(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.Context.get -> TContext! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.CreateUserPasskey(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey) -> TUserPasskey! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByPasskeyIdAsync(byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindPasskeyAsync(TUser! user, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserPasskeyAsync(TKey userId, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserPasskeyByIdAsync(byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetPasskeysAsync(TUser! user, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemovePasskeyAsync(TUser! user, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.SetPasskeyAsync(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AutoSaveChanges.get -> bool +*REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AutoSaveChanges.set -> void +*REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.SaveChanges(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UserClaims.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UserLogins.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UsersSet.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UserTokens.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AutoSaveChanges.get -> bool +*REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AutoSaveChanges.set -> void +*REMOVED*Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.SaveChanges(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.OnModelCreating(Microsoft.EntityFrameworkCore.ModelBuilder! builder) -> void +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.OnModelCreating(Microsoft.EntityFrameworkCore.ModelBuilder! builder) -> void +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AddClaimsAsync(TUser! user, System.Collections.Generic.IEnumerable! claims, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AddLoginAsync(TUser! user, Microsoft.AspNetCore.Identity.UserLoginInfo! login, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.AddUserTokenAsync(TUserToken! token) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.CreateAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.DeleteAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByEmailAsync(string! normalizedEmail, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByIdAsync(string! userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByNameAsync(string! normalizedUserName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindTokenAsync(TUser! user, string! loginProvider, string! name, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserAsync(TKey userId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserLoginAsync(TKey userId, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.GetClaimsAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.GetLoginsAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.GetUsersForClaimAsync(System.Security.Claims.Claim! claim, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.RemoveClaimsAsync(TUser! user, System.Collections.Generic.IEnumerable! claims, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.RemoveLoginAsync(TUser! user, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.RemoveUserTokenAsync(TUserToken! token) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.ReplaceClaimAsync(TUser! user, System.Security.Claims.Claim! claim, System.Security.Claims.Claim! newClaim, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.UpdateAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.Users.get -> System.Linq.IQueryable! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddClaimsAsync(TUser! user, System.Collections.Generic.IEnumerable! claims, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddLoginAsync(TUser! user, Microsoft.AspNetCore.Identity.UserLoginInfo! login, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddToRoleAsync(TUser! user, string! normalizedRoleName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.AddUserTokenAsync(TUserToken! token) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.CreateAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.DeleteAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByEmailAsync(string! normalizedEmail, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByIdAsync(string! userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByNameAsync(string! normalizedUserName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindRoleAsync(string! normalizedRoleName, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindTokenAsync(TUser! user, string! loginProvider, string! name, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserAsync(TKey userId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserLoginAsync(TKey userId, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserRoleAsync(TKey userId, TKey roleId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetClaimsAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetLoginsAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetRolesAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetUsersForClaimAsync(System.Security.Claims.Claim! claim, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.GetUsersInRoleAsync(string! normalizedRoleName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.IsInRoleAsync(TUser! user, string! normalizedRoleName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemoveClaimsAsync(TUser! user, System.Collections.Generic.IEnumerable! claims, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemoveFromRoleAsync(TUser! user, string! normalizedRoleName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemoveLoginAsync(TUser! user, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.RemoveUserTokenAsync(TUserToken! token) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.ReplaceClaimAsync(TUser! user, System.Security.Claims.Claim! claim, System.Security.Claims.Claim! newClaim, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.UpdateAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +*REMOVED*override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.Users.get -> System.Linq.IQueryable! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.RoleClaims.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.RoleClaims.set -> void +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.Roles.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.Roles.set -> void +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.UserRoles.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext.UserRoles.set -> void +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.SchemaVersion.get -> System.Version! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserClaims.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserClaims.set -> void +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserLogins.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserLogins.set -> void +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.Users.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.Users.set -> void +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserTokens.get -> Microsoft.EntityFrameworkCore.DbSet! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext.UserTokens.set -> void +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.Context.get -> TContext! +*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.Context.get -> TContext! diff --git a/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs b/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs index 7a7974c74791..9135b94d309f 100644 --- a/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs @@ -67,6 +67,33 @@ public UserOnlyStore(TContext context, IdentityErrorDescriber? describer = null) /// The type representing a user external login. /// The type representing a user token. public class UserOnlyStore : + UserOnlyStore> + where TUser : IdentityUser + where TContext : DbContext + where TKey : IEquatable + where TUserClaim : IdentityUserClaim, new() + where TUserLogin : IdentityUserLogin, new() + where TUserToken : IdentityUserToken, new() +{ + /// + /// Constructs a new instance of . + /// + /// The . + /// The . + public UserOnlyStore(TContext context, IdentityErrorDescriber? describer = null) : base(context, describer) { } +} + +/// +/// Represents a new instance of a persistence store for the specified user and role types. +/// +/// The type representing a user. +/// The type of the data context class used to access the store. +/// The type of the primary key for a role. +/// The type representing a claim. +/// The type representing a user external login. +/// The type representing a user token. +/// The type representing a user passkey. +public class UserOnlyStore : UserStoreBase, IUserLoginStore, IUserClaimStore, @@ -80,14 +107,18 @@ public class UserOnlyStore, IUserAuthenticatorKeyStore, IUserTwoFactorRecoveryCodeStore, - IProtectedUserStore + IProtectedUserStore, + IUserPasskeyStore where TUser : IdentityUser where TContext : DbContext where TKey : IEquatable where TUserClaim : IdentityUserClaim, new() where TUserLogin : IdentityUserLogin, new() where TUserToken : IdentityUserToken, new() + where TUserPasskey : IdentityUserPasskey, new() { + private bool? _dbContextSupportsPasskeys; + /// /// Creates a new instance of the store. /// @@ -124,6 +155,18 @@ public class UserOnlyStore protected DbSet UserTokens { get { return Context.Set(); } } + /// + /// DbSet of user passkeys. + /// + protected DbSet UserPasskeys + { + get + { + ThrowIfPasskeysNotSupported(); + return Context.Set(); + } + } + /// /// Gets or sets a flag indicating if changes should be persisted after CreateAsync, UpdateAsync and DeleteAsync are called. /// @@ -510,4 +553,212 @@ protected override Task RemoveUserTokenAsync(TUserToken token) UserTokens.Remove(token); return Task.CompletedTask; } + + /// + /// Called to create a new instance of a . + /// + /// The user. + /// The passkey. + /// + protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo passkey) + { + return new TUserPasskey + { + UserId = user.Id, + CredentialId = passkey.CredentialId, + PublicKey = passkey.PublicKey, + Name = passkey.Name, + CreatedAt = passkey.CreatedAt, + Transports = passkey.Transports, + SignCount = passkey.SignCount, + IsUserVerified = passkey.IsUserVerified, + IsBackupEligible = passkey.IsBackupEligible, + IsBackedUp = passkey.IsBackedUp, + AttestationObject = passkey.AttestationObject, + ClientDataJson = passkey.ClientDataJson, + }; + } + + /// + /// Find a passkey with the specified credential id for a user. + /// + /// The user's id. + /// The credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// The user passkey if it exists. + protected virtual Task FindUserPasskeyAsync(TKey userId, byte[] credentialId, CancellationToken cancellationToken) + { + return UserPasskeys.SingleOrDefaultAsync( + userPasskey => userPasskey.UserId.Equals(userId) && userPasskey.CredentialId.SequenceEqual(credentialId), + cancellationToken); + } + + /// + /// Find a passkey with the specified credential id. + /// + /// The credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// The user passkey if it exists. + protected virtual Task FindUserPasskeyByIdAsync(byte[] credentialId, CancellationToken cancellationToken) + { + return UserPasskeys.SingleOrDefaultAsync(userPasskey => userPasskey.CredentialId.SequenceEqual(credentialId), cancellationToken); + } + + /// + /// Creates a new passkey credential in the store for the specified , + /// or updates an existing passkey. + /// + /// The user to create the passkey credential for. + /// + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual async Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(passkey); + + var userPasskey = await FindUserPasskeyByIdAsync(passkey.CredentialId, cancellationToken).ConfigureAwait(false); + if (userPasskey != null) + { + userPasskey.Name = passkey.Name; + userPasskey.SignCount = passkey.SignCount; + userPasskey.IsBackedUp = passkey.IsBackedUp; + userPasskey.IsUserVerified = passkey.IsUserVerified; + UserPasskeys.Update(userPasskey); + } + else + { + userPasskey = CreateUserPasskey(user, passkey); + UserPasskeys.Add(userPasskey); + } + + await SaveChanges(cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets the passkey credentials for the specified . + /// + /// The user whose passkeys should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing a list of the user's passkeys. + public virtual async Task> GetPasskeysAsync(TUser user, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + var userId = user.Id; + var passkeys = await UserPasskeys + .Where(p => p.UserId.Equals(userId)) + .Select(p => new UserPasskeyInfo( + p.CredentialId, + p.PublicKey, + p.Name, + p.CreatedAt, + p.SignCount, + p.Transports, + p.IsUserVerified, + p.IsBackupEligible, + p.IsBackedUp, + p.AttestationObject, + p.ClientDataJson)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return passkeys; + } + + /// + /// Finds and returns a user, if any, associated with the specified passkey credential identifier. + /// + /// The passkey credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, containing the user, if any, associated with the specified passkey credential id. + /// + public virtual async Task FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + var passkey = await FindUserPasskeyByIdAsync(credentialId, cancellationToken).ConfigureAwait(false); + if (passkey != null) + { + return await FindUserAsync(passkey.UserId, cancellationToken).ConfigureAwait(false); + } + return null; + } + + /// + /// Finds a passkey for the specified user with the specified credential id. + /// + /// The user whose passkey should be retrieved. + /// The credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the user's passkey information. + public virtual async Task FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + var passkey = await FindUserPasskeyAsync(user.Id, credentialId, cancellationToken).ConfigureAwait(false); + if (passkey != null) + { + return new UserPasskeyInfo( + passkey.CredentialId, + passkey.PublicKey, + passkey.Name, + passkey.CreatedAt, + passkey.SignCount, + passkey.Transports, + passkey.IsUserVerified, + passkey.IsBackupEligible, + passkey.IsBackedUp, + passkey.AttestationObject, + passkey.ClientDataJson); + } + return null; + } + + /// + /// Removes a passkey credential from the specified . + /// + /// The user to remove the passkey credential from. + /// The credential id of the passkey to remove. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual async Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(credentialId); + + var passkey = await FindUserPasskeyAsync(user.Id, credentialId, cancellationToken).ConfigureAwait(false); + if (passkey != null) + { + UserPasskeys.Remove(passkey); + await SaveChanges(cancellationToken).ConfigureAwait(false); + } + } + + private void ThrowIfPasskeysNotSupported() + { + if (_dbContextSupportsPasskeys == true) + { + return; + } + + _dbContextSupportsPasskeys ??= Context.Model.FindEntityType(typeof(TUserPasskey)) is not null; + if (_dbContextSupportsPasskeys == false) + { + throw new InvalidOperationException( + $"This operation is not permitted because the underlying '{nameof(DbContext)}' does not include '{typeof(TUserPasskey).Name}' in its model. " + + $"When using '{nameof(IdentityDbContext)}', make sure that '{nameof(IdentityOptions)}.{nameof(IdentityOptions.Stores)}.{nameof(StoreOptions.SchemaVersion)}' " + + $"is set to '{nameof(IdentitySchemaVersions)}.{nameof(IdentitySchemaVersions.Version3)}' or higher."); + } + } } diff --git a/src/Identity/EntityFrameworkCore/src/UserStore.cs b/src/Identity/EntityFrameworkCore/src/UserStore.cs index 51c63c694928..29c1b1493fc1 100644 --- a/src/Identity/EntityFrameworkCore/src/UserStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserStore.cs @@ -91,7 +91,7 @@ public UserStore(TContext context, IdentityErrorDescriber? describer = null) : b /// The type representing a user token. /// The type representing a role claim. public class UserStore : - UserStoreBase, + UserStore>, IProtectedUserStore where TUser : IdentityUser where TRole : IdentityRole @@ -102,6 +102,41 @@ public class UserStore, new() where TUserToken : IdentityUserToken, new() where TRoleClaim : IdentityRoleClaim, new() +{ + /// + /// Constructs a new instance of . + /// + /// The . + /// The . + public UserStore(TContext context, IdentityErrorDescriber? describer = null) : base(context, describer) { } +} + +/// +/// Represents a new instance of a persistence store for the specified user and role types. +/// +/// The type representing a user. +/// The type representing a role. +/// The type of the data context class used to access the store. +/// The type of the primary key for a role. +/// The type representing a claim. +/// The type representing a user role. +/// The type representing a user external login. +/// The type representing a user token. +/// The type representing a role claim. +/// The type representing a user passkey. +public class UserStore : + UserStoreBase, + IUserPasskeyStore + where TUser : IdentityUser + where TRole : IdentityRole + where TContext : DbContext + where TKey : IEquatable + where TUserClaim : IdentityUserClaim, new() + where TUserRole : IdentityUserRole, new() + where TUserLogin : IdentityUserLogin, new() + where TUserToken : IdentityUserToken, new() + where TRoleClaim : IdentityRoleClaim, new() + where TUserPasskey : IdentityUserPasskey, new() { /// /// Creates a new instance of the store. @@ -125,6 +160,7 @@ public class UserStore UserRoles { get { return Context.Set(); } } private DbSet UserLogins { get { return Context.Set(); } } private DbSet UserTokens { get { return Context.Set(); } } + private DbSet UserPasskeys { get { return Context.Set(); } } /// /// Gets or sets a flag indicating if changes should be persisted after CreateAsync, UpdateAsync and DeleteAsync are called. @@ -653,4 +689,194 @@ protected override Task RemoveUserTokenAsync(TUserToken token) UserTokens.Remove(token); return Task.CompletedTask; } + + /// + /// Called to create a new instance of a . + /// + /// The user. + /// The passkey. + /// + protected virtual TUserPasskey CreateUserPasskey(TUser user, UserPasskeyInfo passkey) + { + return new TUserPasskey + { + UserId = user.Id, + CredentialId = passkey.CredentialId, + PublicKey = passkey.PublicKey, + Name = passkey.Name, + CreatedAt = passkey.CreatedAt, + Transports = passkey.Transports, + SignCount = passkey.SignCount, + IsUserVerified = passkey.IsUserVerified, + IsBackupEligible = passkey.IsBackupEligible, + IsBackedUp = passkey.IsBackedUp, + AttestationObject = passkey.AttestationObject, + ClientDataJson = passkey.ClientDataJson, + }; + } + + /// + /// Find a passkey with the specified credential id for a user. + /// + /// The user's id. + /// The credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// The user passkey if it exists. + protected virtual Task FindUserPasskeyAsync(TKey userId, byte[] credentialId, CancellationToken cancellationToken) + { + return UserPasskeys.SingleOrDefaultAsync( + userPasskey => userPasskey.UserId.Equals(userId) && userPasskey.CredentialId.SequenceEqual(credentialId), + cancellationToken); + } + + /// + /// Find a passkey with the specified credential id. + /// + /// The credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// The user passkey if it exists. + protected virtual Task FindUserPasskeyByIdAsync(byte[] credentialId, CancellationToken cancellationToken) + { + return UserPasskeys.SingleOrDefaultAsync(userPasskey => userPasskey.CredentialId.SequenceEqual(credentialId), cancellationToken); + } + + /// + /// Creates a new passkey credential in the store for the specified , + /// or updates an existing passkey. + /// + /// The user to create the passkey credential for. + /// + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual async Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(passkey); + + var userPasskey = await FindUserPasskeyByIdAsync(passkey.CredentialId, cancellationToken).ConfigureAwait(false); + if (userPasskey != null) + { + userPasskey.SignCount = passkey.SignCount; + userPasskey.IsBackedUp = passkey.IsBackedUp; + userPasskey.IsUserVerified = passkey.IsUserVerified; + UserPasskeys.Update(userPasskey); + } + else + { + userPasskey = CreateUserPasskey(user, passkey); + UserPasskeys.Add(userPasskey); + } + + await SaveChanges(cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets the passkey credentials for the specified . + /// + /// The user whose passkeys should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing a list of the user's passkeys. + public virtual async Task> GetPasskeysAsync(TUser user, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + var userId = user.Id; + var passkeys = await UserPasskeys + .Where(p => p.UserId.Equals(userId)) + .Select(p => new UserPasskeyInfo( + p.CredentialId, + p.PublicKey, + p.Name, + p.CreatedAt, + p.SignCount, + p.Transports, + p.IsUserVerified, + p.IsBackupEligible, + p.IsBackedUp, + p.AttestationObject, + p.ClientDataJson)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return passkeys; + } + + /// + /// Finds and returns a user, if any, associated with the specified passkey credential identifier. + /// + /// The passkey credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, containing the user, if any, associated with the specified passkey credential id. + /// + public virtual async Task FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + var passkey = await FindUserPasskeyByIdAsync(credentialId, cancellationToken).ConfigureAwait(false); + if (passkey != null) + { + return await FindUserAsync(passkey.UserId, cancellationToken).ConfigureAwait(false); + } + return null; + } + + /// + /// Finds a passkey for the specified user with the specified credential id. + /// + /// The user whose passkey should be retrieved. + /// The credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the user's passkey information. + public virtual async Task FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(credentialId); + + var passkey = await FindUserPasskeyAsync(user.Id, credentialId, cancellationToken).ConfigureAwait(false); + if (passkey != null) + { + return new UserPasskeyInfo( + passkey.CredentialId, + passkey.PublicKey, + passkey.Name, + passkey.CreatedAt, + passkey.SignCount, + passkey.Transports, + passkey.IsUserVerified, + passkey.IsBackupEligible, + passkey.IsBackedUp, + passkey.AttestationObject, + passkey.ClientDataJson); + } + return null; + } + + /// + /// Removes a passkey credential from the specified . + /// + /// The user to remove the passkey credential from. + /// The credential id of the passkey to remove. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual async Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(credentialId); + + var passkey = await FindUserPasskeyAsync(user.Id, credentialId, cancellationToken).ConfigureAwait(false); + if (passkey != null) + { + UserPasskeys.Remove(passkey); + await SaveChanges(cancellationToken).ConfigureAwait(false); + } + } } diff --git a/src/Identity/Extensions.Core/src/DefaultPasskeyHandler.cs b/src/Identity/Extensions.Core/src/DefaultPasskeyHandler.cs new file mode 100644 index 000000000000..44daa15d5b9f --- /dev/null +++ b/src/Identity/Extensions.Core/src/DefaultPasskeyHandler.cs @@ -0,0 +1,525 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// The default passkey handler. +/// +public sealed partial class DefaultPasskeyHandler : IPasskeyHandler + where TUser : class +{ + private readonly IPasskeyOriginValidator _originValidator; + private readonly IPasskeyAttestationStatementVerifier? _attestationStatementVerifier; + private readonly PasskeyOptions _passkeyOptions; + + /// + /// Constructs a new instance. + /// + /// The . + /// The for validating origins. + /// An optional for verifying attestation statements. + public DefaultPasskeyHandler( + IOptions options, + IPasskeyOriginValidator originValidator, + IPasskeyAttestationStatementVerifier? attestationStatementVerifier = null) + { + _originValidator = originValidator; + _attestationStatementVerifier = attestationStatementVerifier; + _passkeyOptions = options.Value.Passkey; + } + + /// + public async Task PerformAttestationAsync(string credentialJson, string originalOptionsJson, UserManager userManager) + { + try + { + return await PerformAttestationCoreAsync(credentialJson, originalOptionsJson, userManager).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(TUser? user, string credentialJson, string originalOptionsJson, UserManager userManager) + { + try + { + return await PerformAssertionCoreAsync(user, credentialJson, originalOptionsJson, userManager).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)); + } + } + + private async Task PerformAttestationCoreAsync( + string credentialJson, + string originalOptionsJson, + UserManager userManager) + { + // 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. + + var credential = JsonSerializer.Deserialize(credentialJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialAuthenticatorAttestationResponse) + ?? throw new InvalidOperationException("The attestation JSON was unexpectedly null."); + var originalOptions = JsonSerializer.Deserialize(originalOptionsJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialCreationOptions) + ?? throw new InvalidOperationException("The original passkey creation options were unexpectedly null."); + + if (!string.Equals("public-key", credential.Type, StringComparison.Ordinal)) + { + throw PasskeyException.InvalidCredentialType("public-key", credential.Type); + } + + // 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. + var clientData = JsonSerializer.Deserialize(response.ClientDataJSON.AsSpan(), IdentityJsonSerializerContext.Default.CollectedClientData) + ?? throw new InvalidOperationException("The client data JSON was unexpectedly null."); + + // 7. Verify that the value of clientData.type is webauthn.create. + if (!string.Equals("webauthn.create", clientData.Type, StringComparison.Ordinal)) + { + throw PasskeyException.InvalidClientDataType("webauthn.create", clientData.Type); + } + + // 8. Verify that the value of clientData.challenge equals the base64url encoding of pkOptions.challenge. + if (!clientData.Challenge.Equals(originalOptions.Challenge)) + { + throw PasskeyException.InvalidChallenge(); + } + + // 9-11. Verify that the value of C.origin matches the Relying Party's origin. + // 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); + if (!_originValidator.IsValidOrigin(originInfo)) + { + 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); + } + } + + // 12. Let clientDataHash be the result of computing a hash over response.clientDataJSON using SHA-256. + var clientDataHash = ComputeSHA256Hash(response.ClientDataJSON); + + // 13. Perform CBOR decoding on the attestationObject field of the AuthenticatorAttestationResponse structure and obtain the + // the authenticator data authenticatorData. + var attestationObjectMemory = response.AttestationObject.AsMemory(); + if (!AttestationObject.TryParse(attestationObjectMemory, out var attestationObject)) + { + throw PasskeyException.InvalidAttestationObject(); + } + if (!AuthenticatorData.TryParse(attestationObject.AuthData, out var authenticatorData)) + { + throw PasskeyException.InvalidAuthenticatorData(); + } + + // 14. Verify that the rpIdHash in authenticatorData is the SHA-256 hash of the RP ID expected by the Relying Party. + var rpIdHash = ComputeSHA256Hash(Encoding.UTF8.GetBytes(originalOptions.Rp.Id ?? string.Empty)); + if (!authenticatorData.RpIdHash.Span.SequenceEqual(rpIdHash.AsSpan())) + { + throw PasskeyException.InvalidRelyingPartyIDHash(); + } + + // 15. 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(); + } + + // 16. 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", originalOptions.AuthenticatorSelection?.UserVerification, StringComparison.Ordinal) && !authenticatorData.IsUserVerified) + { + throw PasskeyException.UserNotVerified(); + } + + // 17. 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(); + } + + // 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. + if (authenticatorData.IsBackupEligible && _passkeyOptions.BackupEligibleCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Disallowed) + { + throw PasskeyException.BackupEligibilityDisallowedYetBackupEligible(); + } + if (!authenticatorData.IsBackupEligible && _passkeyOptions.BackupEligibleCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Required) + { + throw PasskeyException.BackupEligibilityRequiredYetNotBackupEligible(); + } + + // 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. + if (authenticatorData.IsBackedUp && _passkeyOptions.BackedUpCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Disallowed) + { + throw PasskeyException.BackupDisallowedYetBackedUp(); + } + if (!authenticatorData.IsBackedUp && _passkeyOptions.BackedUpCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Required) + { + throw PasskeyException.BackupRequiredYetNotBackedUp(); + } + + // 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. + if (!authenticatorData.HasAttestedCredentialData) + { + throw PasskeyException.MissingAttestedCredentialData(); + } + + // The attested credential data should always be non-null if the 'HasAttestedCredentialData' flag is set. + Debug.Assert(authenticatorData.AttestedCredentialData is not null); + + if (!originalOptions.PubKeyCredParams.Any(a => authenticatorData.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... + if (_attestationStatementVerifier is not null) + { + // Handles all validation related to the attestation statement (21-24). + var isAttestationStatementValid = await _attestationStatementVerifier.VerifyAsync(attestationObjectMemory, clientDataHash).ConfigureAwait(false); + if (!isAttestationStatementValid) + { + throw PasskeyException.InvalidAttestationStatement(); + } + } + + // 25. Verify that the credentialId is <= 1023 bytes. + if (credential.Id is not { } credentialIdBufferSource) + { + throw PasskeyException.MissingCredentialId(); + } + if (credentialIdBufferSource.Length is not > 0 and <= 1023) + { + throw PasskeyException.InvalidCredentialIdLength(credentialIdBufferSource.Length); + } + + // 26. Verify that the credentialId is not yet registered for any user. + var credentialId = credentialIdBufferSource.ToArray(); + var existingUser = await 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 attestedCredentialData = authenticatorData.AttestedCredentialData; + var credentialRecord = new UserPasskeyInfo( + credentialId: credentialId, + publicKey: attestedCredentialData.CredentialPublicKey.ToArray(), + name: null, + createdAt: DateTime.Now, + 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); + } + + private async Task> PerformAssertionCoreAsync( + TUser? user, + string credentialJson, + string originalOptionsJson, + UserManager userManager) + { + // 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. + + var credential = JsonSerializer.Deserialize(credentialJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialAuthenticatorAssertionResponse) + ?? throw new InvalidOperationException("The assertion JSON was unexpectedly null."); + var originalOptions = JsonSerializer.Deserialize(originalOptionsJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialRequestOptions) + ?? throw new InvalidOperationException("The original passkey request options were unexpectedly null."); + + if (!string.Equals("public-key", credential.Type, StringComparison.Ordinal)) + { + throw PasskeyException.InvalidCredentialType("public-key", credential.Type); + } + + // 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 (user is not null) + { + // * If the user was identified before the authentication ceremony was initiated, e.g., via a username or cookie, + // verify that the identified user account contains a credential record whose id equals + // credential.rawId. Let credentialRecord be that credential record. If response.userHandle is + // present, verify that it equals the user handle of the user account. + storedPasskey = await userManager.GetPasskeyAsync(user, credentialId).ConfigureAwait(false); + if (storedPasskey is null) + { + throw PasskeyException.CredentialDoesNotBelongToUser(); + } + if (userHandle is not null) + { + var userId = await 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 userManager.FindByIdAsync(userHandle).ConfigureAwait(false); + if (user is null) + { + throw PasskeyException.CredentialDoesNotBelongToUser(); + } + storedPasskey = await userManager.GetPasskeyAsync(user, credentialId).ConfigureAwait(false); + if (storedPasskey is null) + { + throw PasskeyException.CredentialDoesNotBelongToUser(); + } + } + + // 7. Let cData, authData and sig denote the value of response’s clientDataJSON, authenticatorData, and signature respectively. + if (!AuthenticatorData.TryParse(response.AuthenticatorData.AsMemory(), out var authenticatorData)) + { + throw PasskeyException.InvalidAuthenticatorData(); + } + // 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. + var clientData = JsonSerializer.Deserialize(response.ClientDataJSON.AsSpan(), IdentityJsonSerializerContext.Default.CollectedClientData) + ?? throw new InvalidOperationException("The client data JSON was unexpectedly null."); + + // 10. Verify that the value of C.type is the string webauthn.get. + if (!string.Equals("webauthn.get", clientData.Type, StringComparison.Ordinal)) + { + throw PasskeyException.InvalidClientDataType("webauthn.get", clientData.Type); + } + + // 11. Verify that the value of C.challenge equals the base64url encoding of originalOptions.challenge. + if (!clientData.Challenge.Equals(originalOptions.Challenge)) + { + throw PasskeyException.InvalidChallenge(); + } + + // 12-14. 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); + if (!_originValidator.IsValidOrigin(originInfo)) + { + 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); + } + } + + // 15. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party. + var rpIdHash = ComputeSHA256Hash(Encoding.UTF8.GetBytes(originalOptions.RpId ?? string.Empty)); + if (!authenticatorData.RpIdHash.Span.SequenceEqual(rpIdHash.AsSpan())) + { + throw PasskeyException.InvalidRelyingPartyIDHash(); + } + + // 16. Verify that the UP bit of the flags in authData is set. + if (!authenticatorData.IsUserPresent) + { + throw PasskeyException.UserNotPresent(); + } + + // 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. + if (string.Equals("required", originalOptions.UserVerification, StringComparison.Ordinal) && !authenticatorData.IsUserVerified) + { + throw PasskeyException.UserNotVerified(); + } + + // 18. 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(); + } + + // 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. + if (storedPasskey.IsBackupEligible && !authenticatorData.IsBackupEligible) + { + throw PasskeyException.ExpectedBackupEligibleCredential(); + } + if (!storedPasskey.IsBackupEligible && authenticatorData.IsBackupEligible) + { + throw PasskeyException.ExpectedBackupIneligibleCredential(); + } + if (authenticatorData.IsBackedUp && _passkeyOptions.BackedUpCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Disallowed) + { + throw PasskeyException.BackupDisallowedYetBackedUp(); + } + if (!authenticatorData.IsBackedUp && _passkeyOptions.BackedUpCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Required) + { + throw PasskeyException.BackupRequiredYetNotBackedUp(); + } + + // 20. Let clientDataHash be the result of computing a hash over the cData using SHA-256. + var clientDataHash = ComputeSHA256Hash(response.ClientDataJSON); + + // 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 = new CredentialPublicKey(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 byte[] ComputeSHA256Hash(byte[] data) + { +#if NETCOREAPP + return SHA256.HashData(data); +#else + using var sha256 = SHA256.Create(); + return sha256.ComputeHash(data); +#endif + } + + private static byte[] ComputeSHA256Hash(BufferSource data) + { +#if NETCOREAPP + return SHA256.HashData(data.AsSpan()); +#else + using var sha256 = SHA256.Create(); + return sha256.ComputeHash(data.ToArray()); +#endif + } +} diff --git a/src/Identity/Extensions.Core/src/DefaultPasskeyOriginValidator.cs b/src/Identity/Extensions.Core/src/DefaultPasskeyOriginValidator.cs new file mode 100644 index 000000000000..54b6dbcacf14 --- /dev/null +++ b/src/Identity/Extensions.Core/src/DefaultPasskeyOriginValidator.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; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// The default passkey origin validator. +/// +public sealed class DefaultPasskeyOriginValidator : IPasskeyOriginValidator +{ + private readonly IPasskeyRequestContextProvider _requestContextProvider; + private readonly PasskeyOptions _options; + + /// + /// Constructs a new . + /// + public DefaultPasskeyOriginValidator( + IPasskeyRequestContextProvider requestContextProvider, + IOptions options) + { + _requestContextProvider = requestContextProvider; + _options = options.Value.Passkey; + } + + /// + public bool IsValidOrigin(PasskeyOriginInfo originInfo) + { + if (string.IsNullOrEmpty(originInfo.Origin)) + { + return false; + } + + if (originInfo.CrossOrigin == true && !_options.AllowCrossOriginIframes) + { + return false; + } + + try + { + var originUri = new Uri(originInfo.Origin); + + if (_options.AllowedOrigins.Count > 0) + { + foreach (var allowedOrigin in _options.AllowedOrigins) + { + // Uri.Equals correctly handles string comparands. + if (originUri.Equals(allowedOrigin)) + { + return true; + } + } + } + + if (_options.AllowCurrentOrigin) + { + var context = _requestContextProvider.Context; + + // Uri.Equals correctly handles string comparands. + if (originUri.Equals(context.Origin)) + { + return true; + } + } + + return false; + } + catch (UriFormatException) + { + return false; + } + } +} diff --git a/src/Identity/Extensions.Core/src/DefaultPasskeyRequestContextProvider.cs b/src/Identity/Extensions.Core/src/DefaultPasskeyRequestContextProvider.cs new file mode 100644 index 000000000000..5b7e0fcbaf0f --- /dev/null +++ b/src/Identity/Extensions.Core/src/DefaultPasskeyRequestContextProvider.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. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Identity; + +internal sealed class DefaultPasskeyRequestContextProvider(IOptions options) : IPasskeyRequestContextProvider +{ + private PasskeyRequestContext? _context; + + public PasskeyRequestContext Context => _context ??= GetPasskeyRequestContext(); + + private PasskeyRequestContext GetPasskeyRequestContext() + { + var passkeyOptions = options.Value.Passkey; + return new() + { + Domain = passkeyOptions.ServerDomain, + Origin = null, + }; + } +} diff --git a/src/Identity/Extensions.Core/src/IPasskeyAttestationStatementVerifier.cs b/src/Identity/Extensions.Core/src/IPasskeyAttestationStatementVerifier.cs new file mode 100644 index 000000000000..d3144a1b4da2 --- /dev/null +++ b/src/Identity/Extensions.Core/src/IPasskeyAttestationStatementVerifier.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Interface for verifying passkey attestation statements. +/// +public interface IPasskeyAttestationStatementVerifier +{ + /// + /// Verifies the attestation statement of a passkey. + /// + /// + /// See . + /// + /// The attestation object to verify. See . + /// The hash of the client data used during registration. + /// A task that represents the asynchronous operation. The task result contains true if the verification is successful; otherwise, false. + Task VerifyAsync(ReadOnlyMemory attestationObject, ReadOnlyMemory clientDataHash); +} diff --git a/src/Identity/Extensions.Core/src/IPasskeyHandler.cs b/src/Identity/Extensions.Core/src/IPasskeyHandler.cs new file mode 100644 index 000000000000..964de8d65699 --- /dev/null +++ b/src/Identity/Extensions.Core/src/IPasskeyHandler.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents 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 credentials obtained by JSON-serializing the result of the navigator.credentials.create() JavaScript function. + /// The JSON representation of the original passkey creation options provided to the browser. + /// The to retrieve user information from. + /// A task object representing the asynchronous operation containing the . + Task PerformAttestationAsync(string credentialJson, string originalOptionsJson, UserManager userManager); + + /// + /// Performs passkey assertion using the provided credential JSON, original options JSON, and optional user. + /// + /// The user associated with the passkey, if known. + /// The credentials obtained by JSON-serializing the result of the navigator.credentials.get() JavaScript function. + /// The JSON representation of the original passkey creation options provided to the browser. + /// The to retrieve user information from. + /// A task object representing the asynchronous operation containing the . + Task> PerformAssertionAsync(TUser? user, string credentialJson, string originalOptionsJson, UserManager userManager); +} diff --git a/src/Identity/Extensions.Core/src/IPasskeyOriginValidator.cs b/src/Identity/Extensions.Core/src/IPasskeyOriginValidator.cs new file mode 100644 index 000000000000..5dd2707b31b5 --- /dev/null +++ b/src/Identity/Extensions.Core/src/IPasskeyOriginValidator.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. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Validates the credential origin for passkey operations. +/// +public interface IPasskeyOriginValidator +{ + /// + /// Determines whether the specified origin is valid for passkey operations. + /// + /// Information about the passkey's origin. + /// true if the origin is valid; otherwise, false. + bool IsValidOrigin(PasskeyOriginInfo originInfo); +} diff --git a/src/Identity/Extensions.Core/src/IPasskeyRequestContextProvider.cs b/src/Identity/Extensions.Core/src/IPasskeyRequestContextProvider.cs new file mode 100644 index 000000000000..eecb48f90384 --- /dev/null +++ b/src/Identity/Extensions.Core/src/IPasskeyRequestContextProvider.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Provides access to the current . +/// +public interface IPasskeyRequestContextProvider +{ + /// + /// Gets the current . + /// + PasskeyRequestContext Context { get; } +} diff --git a/src/Identity/Extensions.Core/src/IUserPasskeyStore.cs b/src/Identity/Extensions.Core/src/IUserPasskeyStore.cs new file mode 100644 index 000000000000..0e25bb3c3753 --- /dev/null +++ b/src/Identity/Extensions.Core/src/IUserPasskeyStore.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Provides an abstraction for storing passkey credentials for a user. +/// +/// The type that represents a user. +public interface IUserPasskeyStore : IUserStore where TUser : class +{ + /// + /// Adds a new passkey credential in the store for the specified , + /// or updates an existing passkey. + /// + /// The user to create the passkey credential for. + /// The passkey to add. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken); + + /// + /// Gets the passkey credentials for the specified . + /// + /// The user whose passkeys should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing a list of the user's passkeys. + Task> GetPasskeysAsync(TUser user, CancellationToken cancellationToken); + + /// + /// Finds and returns a user, if any, associated with the specified passkey credential identifier. + /// + /// The passkey credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, containing the user, if any, associated with the specified passkey credential id. + /// + Task FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken); + + /// + /// Finds a passkey for the specified user with the specified credential id. + /// + /// The user whose passkey should be retrieved. + /// The credential id to search for. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the user's passkey information. + Task FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken); + + /// + /// Removes a passkey credential from the specified . + /// + /// The user to remove the passkey credential from. + /// The credential id of the passkey to remove. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken); +} diff --git a/src/Identity/Extensions.Core/src/IdentityJsonSerializerContext.cs b/src/Identity/Extensions.Core/src/IdentityJsonSerializerContext.cs new file mode 100644 index 000000000000..e790bd256a6c --- /dev/null +++ b/src/Identity/Extensions.Core/src/IdentityJsonSerializerContext.cs @@ -0,0 +1,15 @@ +// 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)] +internal partial class IdentityJsonSerializerContext : JsonSerializerContext; diff --git a/src/Identity/Extensions.Core/src/IdentityOptions.cs b/src/Identity/Extensions.Core/src/IdentityOptions.cs index 57ab10a6f9c1..458d46f16a96 100644 --- a/src/Identity/Extensions.Core/src/IdentityOptions.cs +++ b/src/Identity/Extensions.Core/src/IdentityOptions.cs @@ -32,6 +32,14 @@ public class IdentityOptions /// public PasswordOptions Password { get; set; } = new PasswordOptions(); + /// + /// Gets or sets the for the identity system. + /// + /// + /// The for the identity system. + /// + public PasskeyOptions Passkey { get; set; } = new PasskeyOptions(); + /// /// Gets or sets the for the identity system. /// diff --git a/src/Identity/Extensions.Core/src/IdentitySchemaVersions.cs b/src/Identity/Extensions.Core/src/IdentitySchemaVersions.cs index 76f8e892485a..973e4c9c42ab 100644 --- a/src/Identity/Extensions.Core/src/IdentitySchemaVersions.cs +++ b/src/Identity/Extensions.Core/src/IdentitySchemaVersions.cs @@ -25,4 +25,8 @@ public static class IdentitySchemaVersions /// public static readonly Version Version2 = new Version(2, 0); + /// + /// Represents the 3.0 version of the identity schema + /// + public static readonly Version Version3 = new Version(3, 0); } diff --git a/src/Identity/Extensions.Core/src/IdentityServiceCollectionExtensions.cs b/src/Identity/Extensions.Core/src/IdentityServiceCollectionExtensions.cs index 28415c47318d..861005ef1f2b 100644 --- a/src/Identity/Extensions.Core/src/IdentityServiceCollectionExtensions.cs +++ b/src/Identity/Extensions.Core/src/IdentityServiceCollectionExtensions.cs @@ -42,6 +42,9 @@ public static IdentityBuilder AddIdentityCore(this IServiceCollection ser services.TryAddScoped, PasswordHasher>(); services.TryAddScoped(); services.TryAddScoped, DefaultUserConfirmation>(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped, DefaultPasskeyHandler>(); // No interface for the error describer so we can add errors without rev'ing the interface services.TryAddScoped(); services.TryAddScoped, UserClaimsPrincipalFactory>(); diff --git a/src/Identity/Extensions.Core/src/LoggerEventIds.cs b/src/Identity/Extensions.Core/src/LoggerEventIds.cs index ffbf54de6650..8dce61906372 100644 --- a/src/Identity/Extensions.Core/src/LoggerEventIds.cs +++ b/src/Identity/Extensions.Core/src/LoggerEventIds.cs @@ -22,4 +22,6 @@ internal static class LoggerEventIds public static readonly EventId UserValidationFailed = new EventId(13, "UserValidationFailed"); public static readonly EventId PasswordValidationFailed = new EventId(14, "PasswordValidationFailed"); public static readonly EventId GetSecurityStampFailed = new EventId(15, "GetSecurityStampFailed"); + public static readonly EventId PasskeyAttestationFailed = new EventId(16, "PasskeyAttestationFailed"); + public static readonly EventId PasskeyAssertionFailed = new EventId(16, "PasskeyAssertionFailed"); } diff --git a/src/Identity/Extensions.Core/src/Microsoft.Extensions.Identity.Core.csproj b/src/Identity/Extensions.Core/src/Microsoft.Extensions.Identity.Core.csproj index 1174c460f250..4d478a30f7c1 100644 --- a/src/Identity/Extensions.Core/src/Microsoft.Extensions.Identity.Core.csproj +++ b/src/Identity/Extensions.Core/src/Microsoft.Extensions.Identity.Core.csproj @@ -5,6 +5,7 @@ $(DefaultNetFxTargetFramework);netstandard2.0;$(DefaultNetCoreTargetFramework) $(DefaultNetCoreTargetFramework) true + true true aspnetcore;identity;membership true @@ -14,16 +15,19 @@ + + + - + + diff --git a/src/Identity/Extensions.Core/src/PasskeyAssertionResult.cs b/src/Identity/Extensions.Core/src/PasskeyAssertionResult.cs new file mode 100644 index 000000000000..68ef566fdcf7 --- /dev/null +++ b/src/Identity/Extensions.Core/src/PasskeyAssertionResult.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; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Shared; + +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 + { + ArgumentNullThrowHelper.ThrowIfNull(passkey); + ArgumentNullThrowHelper.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/Extensions.Core/src/PasskeyAttestationResult.cs b/src/Identity/Extensions.Core/src/PasskeyAttestationResult.cs new file mode 100644 index 000000000000..7475d113a836 --- /dev/null +++ b/src/Identity/Extensions.Core/src/PasskeyAttestationResult.cs @@ -0,0 +1,68 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Shared; + +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) + { + ArgumentNullThrowHelper.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/Extensions.Core/src/PasskeyCreationArgs.cs b/src/Identity/Extensions.Core/src/PasskeyCreationArgs.cs new file mode 100644 index 000000000000..a7bda440e002 --- /dev/null +++ b/src/Identity/Extensions.Core/src/PasskeyCreationArgs.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; + +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/Extensions.Core/src/PasskeyCreationOptions.cs b/src/Identity/Extensions.Core/src/PasskeyCreationOptions.cs new file mode 100644 index 000000000000..b82ef7dc8b61 --- /dev/null +++ b/src/Identity/Extensions.Core/src/PasskeyCreationOptions.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents 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/Extensions.Core/src/PasskeyException.cs b/src/Identity/Extensions.Core/src/PasskeyException.cs new file mode 100644 index 000000000000..e4c003c61558 --- /dev/null +++ b/src/Identity/Extensions.Core/src/PasskeyException.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents 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/Extensions.Core/src/PasskeyExceptionExtensions.cs b/src/Identity/Extensions.Core/src/PasskeyExceptionExtensions.cs new file mode 100644 index 000000000000..48ca8c96cdfd --- /dev/null +++ b/src/Identity/Extensions.Core/src/PasskeyExceptionExtensions.cs @@ -0,0 +1,104 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +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 'type' field of client data to be '{expectedType}', but it was actually '{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 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() + => new("The attestation object was in an invalid format."); + + public static PasskeyException InvalidAuthenticatorData() + => new("The authenticator data was in an invalid format."); + + public static PasskeyException MissingCredentialId() + => new("The credential ID was missing."); + + public static PasskeyException InvalidTokenBindingStatus(string tokenBindingStatus) + => new($"Invalid token binding status '{tokenBindingStatus}'."); + } +} diff --git a/src/Identity/Extensions.Core/src/PasskeyOptions.cs b/src/Identity/Extensions.Core/src/PasskeyOptions.cs new file mode 100644 index 000000000000..e274a3c3762a --- /dev/null +++ b/src/Identity/Extensions.Core/src/PasskeyOptions.cs @@ -0,0 +1,105 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Specifies options for passkey requirements. +/// +public class PasskeyOptions +{ + /// + /// Gets or sets the time that the server is willing to wait for a passkey operation to complete. + /// + /// + /// The default value is 1 minute. + /// See + /// and . + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// The size of the challenge in bytes sent to the client during WebAuthn attestation and assertion. + /// + /// + /// The default value is 16 bytes. + /// See + /// and . + /// + public int ChallengeSize { get; set; } = 16; + + /// + /// The effective domain of the server. Should be unique and will be used as the identity for the server. + /// + /// + /// If left , the server's origin may be used instead. + /// See . + /// + public string? ServerDomain { get; set; } + + /// + /// Gets or sets the allowed origins for credential registration and assertion. + /// When specified, these origins are explicitly allowed in addition to any origins allowed by other settings. + /// + public IList AllowedOrigins { get; set; } = []; + + /// + /// Gets or sets whether the current server's origin should be allowed for credentials. + /// When true, the origin of the current request will be automatically allowed. + /// + /// + /// The default value is . + /// + public bool AllowCurrentOrigin { get; set; } = true; + + /// + /// Gets or sets whether credentials from cross-origin iframes should be allowed. + /// + /// + /// The default value is . + /// + public bool AllowCrossOriginIframes { get; set; } + + /// + /// Whether or not to accept a backup eligible credential. + /// + /// + /// The default value is . + /// + public CredentialBackupPolicy BackupEligibleCredentialPolicy { get; set; } = CredentialBackupPolicy.Allowed; + + /// + /// Whether or not to accept a backed up credential. + /// + /// + /// The default value is . + /// + public CredentialBackupPolicy BackedUpCredentialPolicy { get; set; } = CredentialBackupPolicy.Allowed; + + /// + /// Represents the policy for credential backup eligibility and backup status. + /// + public enum CredentialBackupPolicy + { + /// + /// Indicates that the credential backup eligibility or backup status is required. + /// + Required = 0, + + /// + /// Indicates that the credential backup eligibility or backup status is allowed, but not required. + /// + Allowed = 1, + + /// + /// Indicates that the credential backup eligibility or backup status is disallowed. + /// + Disallowed = 2, + } +} diff --git a/src/Identity/Extensions.Core/src/PasskeyOriginInfo.cs b/src/Identity/Extensions.Core/src/PasskeyOriginInfo.cs new file mode 100644 index 000000000000..8e23f67d3b14 --- /dev/null +++ b/src/Identity/Extensions.Core/src/PasskeyOriginInfo.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. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +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/Extensions.Core/src/PasskeyRequestArgs.cs b/src/Identity/Extensions.Core/src/PasskeyRequestArgs.cs new file mode 100644 index 000000000000..5eb3958335b2 --- /dev/null +++ b/src/Identity/Extensions.Core/src/PasskeyRequestArgs.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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +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/Extensions.Core/src/PasskeyRequestContext.cs b/src/Identity/Extensions.Core/src/PasskeyRequestContext.cs new file mode 100644 index 000000000000..b1494d756505 --- /dev/null +++ b/src/Identity/Extensions.Core/src/PasskeyRequestContext.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. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Contains passkey-relevant information about the current request. +/// +public sealed class PasskeyRequestContext +{ + /// + /// Gets or sets the server domain. + /// + public string? Domain { get; set; } + + /// + /// Gets or sets the request origin. + /// + public string? Origin { get; set; } +} diff --git a/src/Identity/Extensions.Core/src/PasskeyRequestOptions.cs b/src/Identity/Extensions.Core/src/PasskeyRequestOptions.cs new file mode 100644 index 000000000000..cb8acc86fb86 --- /dev/null +++ b/src/Identity/Extensions.Core/src/PasskeyRequestOptions.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents options for a passkey request. +/// +/// The ID of the user for whom this request is made. +/// The JSON representation of the options. +/// +/// See . +/// +public 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/Extensions.Core/src/PasskeyUserEntity.cs b/src/Identity/Extensions.Core/src/PasskeyUserEntity.cs new file mode 100644 index 000000000000..60f59a411a25 --- /dev/null +++ b/src/Identity/Extensions.Core/src/PasskeyUserEntity.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents 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/Extensions.Core/src/Passkeys/AttestationObject.cs b/src/Identity/Extensions.Core/src/Passkeys/AttestationObject.cs new file mode 100644 index 000000000000..c5d6c8718ffa --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/AttestationObject.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Formats.Cbor; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents an authenticator attestation object, which contains the attestation statement and authenticator data. +/// +/// +/// See . +/// +internal sealed class AttestationObject(string fmt, ReadOnlyMemory attStmt, ReadOnlyMemory authData) +{ + public string Fmt => fmt; + + public ReadOnlyMemory AttStmt => attStmt; + + public ReadOnlyMemory AuthData => authData; + + public static bool TryParse(ReadOnlyMemory data, [NotNullWhen(true)] out AttestationObject? result) + { + var reader = new CborReader(data); + _ = reader.ReadStartMap(); + + string? fmt = null; + ReadOnlyMemory? attStmt = default; + ReadOnlyMemory? authData = default; + + while (reader.PeekState() != CborReaderState.EndMap) + { + var key = reader.ReadTextString(); + switch (key) + { + case "fmt": + fmt = reader.ReadTextString(); + break; + case "attStmt": + attStmt = reader.ReadEncodedValue(); + break; + case "authData": + authData = reader.ReadByteString(); + break; + default: + // Unknown key - skip. + reader.SkipValue(); + break; + } + } + + if (fmt is null || !attStmt.HasValue || !authData.HasValue) + { + result = null; + return false; + } + + result = new(fmt, attStmt.Value, authData.Value); + return true; + } +} diff --git a/src/Identity/Extensions.Core/src/Passkeys/AttestedCredentialData.cs b/src/Identity/Extensions.Core/src/Passkeys/AttestedCredentialData.cs new file mode 100644 index 000000000000..82faa347b3da --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/AttestedCredentialData.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents attested credential data in an . +/// +/// +/// See . +/// +internal sealed class AttestedCredentialData +{ + /// + /// Gets the AAGUID of the authenticator that created the credential. + /// + public ReadOnlyMemory Aaguid { get; } + + /// + /// Gets the credential ID. + /// + public ReadOnlyMemory CredentialId { get; } + + /// + /// Gets the credential public key. + /// + public CredentialPublicKey CredentialPublicKey { get; } + + private AttestedCredentialData( + ReadOnlyMemory aaguid, + ReadOnlyMemory credentialId, + CredentialPublicKey credentialPublicKey) + { + Aaguid = aaguid; + CredentialId = credentialId; + CredentialPublicKey = credentialPublicKey; + } + + public static bool TryParse(ReadOnlyMemory data, out int bytesRead, [NotNullWhen(true)] out AttestedCredentialData? result) + { + const int MinLength = 18; // aaguid + credential ID length + const int MaxCredentialIdLength = 1023; + + result = null; + bytesRead = 0; + + if (data.Length < MinLength) + { + return false; + } + + var aaguid = data.Slice(0, 16); + var credentialIDLen = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(start: 16, length: 2).Span); + if (credentialIDLen > MaxCredentialIdLength) + { + return false; + } + + var offset = 18; + var credentialID = data.Slice(offset, credentialIDLen).ToArray(); + offset += credentialIDLen; + + var credentialPublicKey = CredentialPublicKey.Decode(data.Slice(offset), out int read); + offset += read; + + bytesRead = offset; + result = new AttestedCredentialData(aaguid, credentialID, credentialPublicKey); + return true; + } +} diff --git a/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorAssertionResponse.cs b/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorAssertionResponse.cs new file mode 100644 index 000000000000..ea8f87d49528 --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorAssertionResponse.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 System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +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(BufferSource authenticatorData, BufferSource signature, BufferSource? userHandle, BufferSource clientDataJSON) : AuthenticatorResponse(clientDataJSON) +{ + /// + /// Gets or sets the authenticator data. + /// + public BufferSource AuthenticatorData { get; } = authenticatorData; + + /// + /// Gets or sets the assertion signature. + /// + public BufferSource Signature { get; } = signature; + + /// + /// Gets or sets the opaque user identifier. + /// + public BufferSource? UserHandle { get; } = userHandle; +} diff --git a/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorAttestationResponse.cs b/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorAttestationResponse.cs new file mode 100644 index 000000000000..545502cc7d7d --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorAttestationResponse.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents the response returned by an authenticator during the attestation phase of a WebAuthn registration +/// process. +/// +/// +/// See . +/// +internal sealed class AuthenticatorAttestationResponse(BufferSource attestationObject, BufferSource clientDataJSON) : AuthenticatorResponse(clientDataJSON) +{ + /// + /// Gets or sets the attestation object. + /// + public BufferSource AttestationObject { get; set; } = attestationObject; + + /// + /// 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; set; } = []; +} diff --git a/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorData.cs b/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorData.cs new file mode 100644 index 000000000000..ce0411e15ac6 --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorData.cs @@ -0,0 +1,140 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Formats.Cbor; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Encodes contextual bindings made by an authenticator. +/// +/// +/// See +/// +internal sealed class AuthenticatorData( + ReadOnlyMemory rpIdHash, + AuthenticatorDataFlags flags, + uint signCount, + AttestedCredentialData? attestedCredentialData, + ReadOnlyMemory? extensions) +{ + private readonly AuthenticatorDataFlags _flags = flags; + + /// + /// Gets the SHA-256 hash of the Relying Party ID the credential is scoped to. + /// + public ReadOnlyMemory RpIdHash { get; } = rpIdHash; + + /// + /// Gets the signature counter. + /// + public uint SignCount { get; } = signCount; + + /// + /// Gets the attested credential data. + /// + public AttestedCredentialData? AttestedCredentialData { get; } = attestedCredentialData; + + /// + /// Gets the extension-defined authenticator data. + /// + public ReadOnlyMemory? Extensions { get; } = extensions; + + /// + /// Gets the flags for this authenticator data. + /// + public AuthenticatorDataFlags Flags => _flags; + + /// + /// 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. + /// + public bool HasAttestedCredentialData => _flags.HasFlag(AuthenticatorDataFlags.HasAttestedCredentialData); + + public static bool TryParse(ReadOnlyMemory bytes, [NotNullWhen(true)] out AuthenticatorData? result) + { + // Min length specified in https://www.w3.org/TR/webauthn-3/#authenticator-data + const int MinLength = 37; + + if (bytes.Length < MinLength) + { + result = null; + return false; + } + + var offset = 0; + var rpIdHash = ReadBytes(32); + var flags = (AuthenticatorDataFlags)ReadByte(); + var signCount = BinaryPrimitives.ReadUInt32BigEndian(ReadBytes(4).Span); + + AttestedCredentialData? attestedCredentialData = null; + if (flags.HasFlag(AuthenticatorDataFlags.HasAttestedCredentialData)) + { + var remaining = bytes.Slice(offset); + if (!AttestedCredentialData.TryParse(remaining, out var bytesRead, out attestedCredentialData)) + { + result = null; + return false; + } + offset += bytesRead; + } + + ReadOnlyMemory? extensions = default; + if (flags.HasFlag(AuthenticatorDataFlags.HasExtensionData)) + { + var reader = new CborReader(bytes.Slice(offset)); + extensions = reader.ReadEncodedValue(); + offset += extensions.Value.Length; + } + + if (offset != bytes.Length) + { + // Leftover bytes signifies a possible parsing error. + result = null; + return false; + } + + result = new(rpIdHash, flags, signCount, attestedCredentialData, extensions); + return true; + + byte ReadByte() => bytes.Span[offset++]; + + ReadOnlyMemory ReadBytes(int length) + { + var result = bytes.Slice(offset, length); + offset += length; + return result; + } + } +} diff --git a/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorDataFlags.cs b/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorDataFlags.cs new file mode 100644 index 000000000000..6ae73aa2a203 --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorDataFlags.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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +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/Extensions.Core/src/Passkeys/AuthenticatorResponse.cs b/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorResponse.cs new file mode 100644 index 000000000000..9914fd982448 --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorResponse.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents the base class for responses returned by an authenticator during credential creation or retrieval +/// operations. +/// +internal abstract class AuthenticatorResponse(BufferSource clientDataJSON) +{ + /// + /// Gets or sets the client data passed to + /// navigator.credentials.create() or navigator.credentials.get(). + /// + public BufferSource ClientDataJSON { get; } = clientDataJSON; +} diff --git a/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorSelectionCriteria.cs b/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorSelectionCriteria.cs new file mode 100644 index 000000000000..eb6a202c9111 --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorSelectionCriteria.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// 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/Extensions.Core/src/Passkeys/BufferSource.cs b/src/Identity/Extensions.Core/src/Passkeys/BufferSource.cs new file mode 100644 index 000000000000..978ff9e40bad --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/BufferSource.cs @@ -0,0 +1,169 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.Extensions.Internal; + +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; + private string? _base64UrlString; + + /// + /// 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) + { + return new(bytes, base64UrlString: null); + } + + /// + /// Creates a new instance of from a string. + /// + public static BufferSource FromString(string value) + { + var buffer = Encoding.UTF8.GetBytes(value); + return new(buffer, base64UrlString: null); + } + + /// + /// Creates a new instance of from a base64url-encoded string. + /// + public static BufferSource FromBase64UrlString(string base64UrlString) + { + var buffer = WebEncoders.Base64UrlDecode(base64UrlString); + return new(buffer, base64UrlString); + } + + private BufferSource(ReadOnlyMemory buffer, string? base64UrlString) + { + _bytes = buffer; + _base64UrlString = base64UrlString; + } + + /// + /// Gets the base64url-encoded string representation of the byte buffer. + /// + /// + /// If originally constructed with a base64url-encoded string, this method will directly return that string. + /// Otherwise, it will compute the base64url-encoded string from the byte buffer. + /// + public string AsBase64UrlString() + { + return _base64UrlString ??= GetBase64UrlString(_bytes); + + static string GetBase64UrlString(ReadOnlyMemory bytes) + { + var array = MemoryMarshal.TryGetArray(bytes, out var segment) + ? segment.Array! + : bytes.ToArray(); + + return WebEncoders.Base64UrlEncode(array); + } + } + + /// + /// 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 unsafe string ToString() + { + var span = _bytes.Span; + + if (span.IsEmpty) + { + return string.Empty; + } + + fixed (byte* ptr = span) + { + return Encoding.UTF8.GetString(ptr, span.Length); + } + } +} diff --git a/src/Identity/Extensions.Core/src/Passkeys/BufferSourceJsonConverter.cs b/src/Identity/Extensions.Core/src/Passkeys/BufferSourceJsonConverter.cs new file mode 100644 index 000000000000..9af52a19f0fe --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/BufferSourceJsonConverter.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 System; +using System.Buffers; +using System.Buffers.Text; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.Extensions.Internal; + +namespace Microsoft.AspNetCore.Identity; + +internal sealed class BufferSourceJsonConverter : JsonConverter +{ + public override BufferSource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + if (value is null) + { + return null; + } + + return BufferSource.FromBase64UrlString(value); + } + + public override void Write(Utf8JsonWriter writer, BufferSource value, JsonSerializerOptions options) + { + var base64UrlString = value.AsBase64UrlString(); + writer.WriteStringValue(base64UrlString); + } +} diff --git a/src/Identity/Extensions.Core/src/Passkeys/COSEAlgorithmIdentifier.cs b/src/Identity/Extensions.Core/src/Passkeys/COSEAlgorithmIdentifier.cs new file mode 100644 index 000000000000..cbf79d199da1 --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/COSEAlgorithmIdentifier.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents a number identifying a cryptographic algorithm. +/// +/// +/// See . +/// +internal enum COSEAlgorithmIdentifier : long +{ + 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/Extensions.Core/src/Passkeys/CollectedClientData.cs b/src/Identity/Extensions.Core/src/Passkeys/CollectedClientData.cs new file mode 100644 index 000000000000..95e7f7ea7c6b --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/CollectedClientData.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents the client data passed to navigator.credentials.get() or navigator.credentials.create(). +/// +/// +/// See +/// +internal sealed class CollectedClientData(string type, BufferSource challenge, string origin) +{ + /// + /// Gets the type of the operation that produced the client data. + /// + /// + /// Will be either "webauthn.create" or "webauthn.get". + /// + public string Type { get; } = type; + + /// + /// Gets the challenge provided by the relying party. + /// + public BufferSource Challenge { get; } = challenge; + + /// + /// Gets the fully qualified origin of the requester. + /// + public string Origin { get; } = origin; + + /// + /// 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; set; } + + /// + /// Gets or sets information about the state of the token binding protocol. + /// + public TokenBinding? TokenBinding { get; set; } +} diff --git a/src/Identity/Extensions.Core/src/Passkeys/CredentialPublicKey.cs b/src/Identity/Extensions.Core/src/Passkeys/CredentialPublicKey.cs new file mode 100644 index 000000000000..c7846ea9c3cc --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/CredentialPublicKey.cs @@ -0,0 +1,264 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Formats.Cbor; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +internal sealed class CredentialPublicKey +{ + private readonly CoseKeyType _type; + private readonly COSEAlgorithmIdentifier _alg; + private readonly ReadOnlyMemory _bytes; + private readonly RSA? _rsa; + +#if NETCOREAPP + private readonly ECDsa? _ecdsa; +#endif + + public COSEAlgorithmIdentifier Alg => _alg; + + public CredentialPublicKey(ReadOnlyMemory bytes) + { + var reader = Ctap2CborReader.Create(bytes); + + reader.ReadCoseKeyLabel((int)CoseKeyParameter.KeyType); + _type = (CoseKeyType)reader.ReadInt32(); + _alg = ParseCoseKeyCommonParameters(reader); + + switch (_type) + { +#if NETCOREAPP + case CoseKeyType.EC2: + case CoseKeyType.OKP: + _ecdsa = ParseECDsa(_type, reader); + break; +#endif + case CoseKeyType.RSA: + _rsa = ParseRSA(reader); + break; + default: + throw new InvalidOperationException($"Unsupported key type '{_type}'."); + } + + var keyLength = bytes.Length - reader.BytesRemaining; + _bytes = bytes.Slice(0, keyLength); + } + + public bool Verify(ReadOnlySpan data, ReadOnlySpan signature) + { + switch (_type) + { +#if NETCOREAPP + case CoseKeyType.EC2: + return _ecdsa!.VerifyData(data, signature, HashAlgFromCOSEAlg(_alg), DSASignatureFormat.Rfc3279DerSequence); +#endif + + case CoseKeyType.RSA: +#if NETCOREAPP + return _rsa!.VerifyData(data, signature, HashAlgFromCOSEAlg(_alg), Padding); +#else + return _rsa!.VerifyData(data.ToArray(), signature.ToArray(), HashAlgFromCOSEAlg(_alg), Padding); +#endif + } + 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(); + +#if NETCOREAPP + return RSA.Create(rsaParams); +#else + var rsa = RSA.Create(); + rsa.ImportParameters(rsaParams); + return rsa; +#endif + } + +#if NETCOREAPP + 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, + }; + } + } +#endif + + internal RSASignaturePadding Padding + { + get + { + if (_type != CoseKeyType.RSA) + { + throw new InvalidOperationException($"Must be a RSA key. Was {_type}"); + } + + switch (_alg) // https://www.iana.org/assignments/cose/cose.xhtml#algorithms + { + case COSEAlgorithmIdentifier.PS256: + case COSEAlgorithmIdentifier.PS384: + case COSEAlgorithmIdentifier.PS512: + return RSASignaturePadding.Pss; + + case COSEAlgorithmIdentifier.RS1: + case COSEAlgorithmIdentifier.RS256: + case COSEAlgorithmIdentifier.RS384: + case COSEAlgorithmIdentifier.RS512: + return RSASignaturePadding.Pkcs1; + default: + 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 static CredentialPublicKey Decode(ReadOnlyMemory cpk, out int bytesRead) + { + var key = new CredentialPublicKey(cpk); + bytesRead = key._bytes.Length; + return key; + } + + 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/Extensions.Core/src/Passkeys/Ctap2CborReader.cs b/src/Identity/Extensions.Core/src/Passkeys/Ctap2CborReader.cs new file mode 100644 index 000000000000..fee3fc41304e --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/Ctap2CborReader.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Formats.Cbor; +using System.Text; + +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/Extensions.Core/src/Passkeys/PublicKeyCredential.cs b/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredential.cs new file mode 100644 index 000000000000..e53b9e2ee9e8 --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredential.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents information about a public key/private key pair. +/// +/// +/// See +/// +internal sealed class PublicKeyCredential(BufferSource id, string type, JsonElement clientExtensionResults, TResponse response) + where TResponse : AuthenticatorResponse +{ + /// + /// Gets or sets the credential ID. + /// + public BufferSource Id { get; } = id; + + /// + /// Gets the type of the public key credential. + /// + /// + /// This is always expected to have the value "public-key". + /// + public string Type { get; } = type; + + /// + /// Gets the client extensions map. + /// + public JsonElement ClientExtensionResults { get; } = clientExtensionResults; + + /// + /// Gets or sets the authenticator response. + /// + public TResponse Response { get; } = response; + + /// + /// Gets or sets a string indicating the mechanism by which the WebAuthn implementation + /// is attached to the authenticator. + /// + public string? AuthenticatorAttachment { get; set; } +} diff --git a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs b/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs new file mode 100644 index 000000000000..06092a423caa --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents options for credential creation. +/// +/// +/// See . +/// +internal sealed class PublicKeyCredentialCreationOptions( + PublicKeyCredentialRpEntity rp, + PublicKeyCredentialUserEntity user, + BufferSource challenge) +{ + /// + /// Gets the name and identifier for the relying party requesting attestation. + /// + public PublicKeyCredentialRpEntity Rp { get; } = rp; + + /// + /// Gets the names and and identifier for the user account performing the registration. + /// + public PublicKeyCredentialUserEntity User { get; } = user; + + /// + /// Gets a challenge that the authenticator signs when producing an attestation object for the newly created credential. + /// + public BufferSource Challenge { get; } = challenge; + + /// + /// Gets the key types and signature algorithms the relying party supports, ordered from most preferred to least preferred. + /// + public IReadOnlyList PubKeyCredParams { get; set; } = []; + + /// + /// Gets or sets the time, in milliseconds, that the relying party is willing to wait for the call to complete. + /// + public ulong? Timeout { get; set; } + + /// + /// Gets or sets the existing credentials mapped to the user account. + /// + public IReadOnlyList ExcludeCredentials { get; set; } = []; + + /// + /// Gets or sets settings that the authenticator should satisfy when creating a new credential. + /// + public AuthenticatorSelectionCriteria? AuthenticatorSelection { get; set; } + + /// + /// Gets or sets hints that guide the user agent in interacting with the user. + /// + public IReadOnlyList Hints { get; set; } = []; + + /// + /// Gets or sets the attestation conveyance preference for the relying party. + /// + public string Attestation { get; set; } = "none"; + + /// + /// Gets or sets the attestation statement format preferences of the relying party, ordered from most preferred to least preferred. + /// + public IReadOnlyList AttestationFormats { get; set; } = []; + + /// + /// Gets or sets the client extension inputs that the relying party supports. + /// + public JsonElement? Extensions { get; set; } +} diff --git a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialDescriptor.cs b/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialDescriptor.cs new file mode 100644 index 000000000000..264e28d8f0e5 --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialDescriptor.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 System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Identifies a specific public key credential. +/// +/// +/// See +/// +internal sealed class PublicKeyCredentialDescriptor(string type, BufferSource id) +{ + /// + /// Gets the type of the public key credential. + /// + public string Type { get; } = type; + + /// + /// Gets the identifier of the public key credential. + /// + public BufferSource Id { get; } = id; + + /// + /// Gets or sets hints as to how the client might communicate with the authenticator. + /// + public IReadOnlyList Transports { get; set; } = []; +} diff --git a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialParameters.cs b/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialParameters.cs new file mode 100644 index 000000000000..d52fdcc6914c --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialParameters.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +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; } = +#if NET10_0_OR_GREATER + [ + 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), + ]; +#else + [ + new(COSEAlgorithmIdentifier.PS256), + new(COSEAlgorithmIdentifier.PS384), + new(COSEAlgorithmIdentifier.PS512), + new(COSEAlgorithmIdentifier.RS256), + new(COSEAlgorithmIdentifier.RS384), + new(COSEAlgorithmIdentifier.RS512), + ]; +#endif + + public PublicKeyCredentialParameters(COSEAlgorithmIdentifier alg) + : this(type: "public-key", alg) + { + } + + public string Type { get; } = type; + + public COSEAlgorithmIdentifier Alg { get; } = alg; +} diff --git a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs b/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs new file mode 100644 index 000000000000..967675aa891f --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents options for requesting a credential. +/// +/// +/// See +/// +internal sealed class PublicKeyCredentialRequestOptions(BufferSource challenge) +{ + /// + /// Gets the challenge that the authenticator signs when producing an assertion for the requested credential. + /// + public BufferSource Challenge { get; } = challenge; + + /// + /// Gets or sets a time in milliseconds that the server is willing to wait for the call to complete. + /// + public ulong? Timeout { get; set; } + + /// + /// Gets or sets the relying party identifier. + /// + public string? RpId { get; set; } + + /// + /// Gets or sets the credentials of the identified user account, if any. + /// + public IReadOnlyList AllowCredentials { get; set; } = []; + + /// + /// Gets or sets the user verification requirement for the request. + /// + public string UserVerification { get; set; } = "preferred"; + + /// + /// Gets or sets hints that guide the user agent in interacting with the user. + /// + public IReadOnlyList Hints { get; set; } = []; + + /// + /// Gets or sets the client extension inputs that the relying party supports. + /// + public JsonElement? Extensions { get; set; } +} diff --git a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialRpEntity.cs b/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialRpEntity.cs new file mode 100644 index 000000000000..58814ce9e283 --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialRpEntity.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. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Used to supply Relying Party attributes when creating a new credential. +/// +/// +/// See . +/// +/// +internal sealed class PublicKeyCredentialRpEntity(string name) +{ + /// + /// Gets the human-palatable name for the entity. + /// + public string Name { get; } = name; + + /// + /// Gets or sets the unique identifier for the replying party entity. + /// + public string? Id { get; set; } +} diff --git a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialUserEntity.cs b/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialUserEntity.cs new file mode 100644 index 000000000000..804e4009f70e --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialUserEntity.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 System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Used to supply additional user account attributes when creating a new credential. +/// +/// +/// See . +/// +internal sealed class PublicKeyCredentialUserEntity(BufferSource id, string name, string displayName) +{ + /// + /// Gets the user handle of the user account. + /// + public BufferSource Id { get; } = id; + + /// + /// Gets the human-palatable name for the entity. + /// + public string Name { get; } = name; + + /// + /// Gets the human-palatable name for the user account, intended only for display. + /// + public string DisplayName { get; } = displayName; +} diff --git a/src/Identity/Extensions.Core/src/Passkeys/TokenBinding.cs b/src/Identity/Extensions.Core/src/Passkeys/TokenBinding.cs new file mode 100644 index 000000000000..cd2a7e5fbec5 --- /dev/null +++ b/src/Identity/Extensions.Core/src/Passkeys/TokenBinding.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Contains information about the state of the token binding protocol. +/// +/// +/// See . +/// +internal sealed class TokenBinding(string status) +{ + /// + /// Gets the token binding status. + /// + /// + /// Supported values are "supported", "present", and "not-supported". + /// See . + /// + public string Status { get; } = status; + + /// + /// Gets or sets the token binding ID. + /// + /// + /// See . + /// + public string? Id { get; set; } +} diff --git a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt index 58c861652420..438fef8524ca 100644 --- a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt @@ -1,3 +1,146 @@ #nullable enable *REMOVED*Microsoft.AspNetCore.Identity.UserLoginInfo.UserLoginInfo(string! loginProvider, string! providerKey, string? displayName) -> void +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, Microsoft.AspNetCore.Identity.IPasskeyOriginValidator! originValidator, Microsoft.AspNetCore.Identity.IPasskeyAttestationStatementVerifier? attestationStatementVerifier = null) -> void +Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.PerformAssertionAsync(TUser? user, string! credentialJson, string! originalOptionsJson, Microsoft.AspNetCore.Identity.UserManager! userManager) -> System.Threading.Tasks.Task!>! +Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.PerformAttestationAsync(string! credentialJson, string! originalOptionsJson, Microsoft.AspNetCore.Identity.UserManager! userManager) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.DefaultPasskeyOriginValidator +Microsoft.AspNetCore.Identity.DefaultPasskeyOriginValidator.DefaultPasskeyOriginValidator(Microsoft.AspNetCore.Identity.IPasskeyRequestContextProvider! requestContextProvider, Microsoft.Extensions.Options.IOptions! options) -> void +Microsoft.AspNetCore.Identity.DefaultPasskeyOriginValidator.IsValidOrigin(Microsoft.AspNetCore.Identity.PasskeyOriginInfo originInfo) -> bool +Microsoft.AspNetCore.Identity.IdentityOptions.Passkey.get -> Microsoft.AspNetCore.Identity.PasskeyOptions! +Microsoft.AspNetCore.Identity.IdentityOptions.Passkey.set -> void +Microsoft.AspNetCore.Identity.IPasskeyAttestationStatementVerifier +Microsoft.AspNetCore.Identity.IPasskeyAttestationStatementVerifier.VerifyAsync(System.ReadOnlyMemory attestationObject, System.ReadOnlyMemory clientDataHash) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IPasskeyHandler +Microsoft.AspNetCore.Identity.IPasskeyHandler.PerformAssertionAsync(TUser? user, string! credentialJson, string! originalOptionsJson, Microsoft.AspNetCore.Identity.UserManager! userManager) -> System.Threading.Tasks.Task!>! +Microsoft.AspNetCore.Identity.IPasskeyHandler.PerformAttestationAsync(string! credentialJson, string! originalOptionsJson, Microsoft.AspNetCore.Identity.UserManager! userManager) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IPasskeyOriginValidator +Microsoft.AspNetCore.Identity.IPasskeyOriginValidator.IsValidOrigin(Microsoft.AspNetCore.Identity.PasskeyOriginInfo originInfo) -> bool +Microsoft.AspNetCore.Identity.IPasskeyRequestContextProvider +Microsoft.AspNetCore.Identity.IPasskeyRequestContextProvider.Context.get -> Microsoft.AspNetCore.Identity.PasskeyRequestContext! +Microsoft.AspNetCore.Identity.IUserPasskeyStore +Microsoft.AspNetCore.Identity.IUserPasskeyStore.FindByPasskeyIdAsync(byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IUserPasskeyStore.FindPasskeyAsync(TUser! user, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IUserPasskeyStore.GetPasskeysAsync(TUser! user, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! +Microsoft.AspNetCore.Identity.IUserPasskeyStore.RemovePasskeyAsync(TUser! user, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IUserPasskeyStore.SetPasskeyAsync(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.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.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.PasskeyOptions +Microsoft.AspNetCore.Identity.PasskeyOptions.AllowCrossOriginIframes.get -> bool +Microsoft.AspNetCore.Identity.PasskeyOptions.AllowCrossOriginIframes.set -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.AllowCurrentOrigin.get -> bool +Microsoft.AspNetCore.Identity.PasskeyOptions.AllowCurrentOrigin.set -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.AllowedOrigins.get -> System.Collections.Generic.IList! +Microsoft.AspNetCore.Identity.PasskeyOptions.AllowedOrigins.set -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.BackedUpCredentialPolicy.get -> Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy +Microsoft.AspNetCore.Identity.PasskeyOptions.BackedUpCredentialPolicy.set -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.BackupEligibleCredentialPolicy.get -> Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy +Microsoft.AspNetCore.Identity.PasskeyOptions.BackupEligibleCredentialPolicy.set -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.ChallengeSize.get -> int +Microsoft.AspNetCore.Identity.PasskeyOptions.ChallengeSize.set -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy +Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy.Allowed = 1 -> Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy +Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy.Disallowed = 2 -> Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy +Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy.Required = 0 -> Microsoft.AspNetCore.Identity.PasskeyOptions.CredentialBackupPolicy +Microsoft.AspNetCore.Identity.PasskeyOptions.PasskeyOptions() -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.ServerDomain.get -> string? +Microsoft.AspNetCore.Identity.PasskeyOptions.ServerDomain.set -> void +Microsoft.AspNetCore.Identity.PasskeyOptions.Timeout.get -> System.TimeSpan +Microsoft.AspNetCore.Identity.PasskeyOptions.Timeout.set -> void +Microsoft.AspNetCore.Identity.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.PasskeyRequestContext +Microsoft.AspNetCore.Identity.PasskeyRequestContext.Domain.get -> string? +Microsoft.AspNetCore.Identity.PasskeyRequestContext.Domain.set -> void +Microsoft.AspNetCore.Identity.PasskeyRequestContext.Origin.get -> string? +Microsoft.AspNetCore.Identity.PasskeyRequestContext.Origin.set -> void +Microsoft.AspNetCore.Identity.PasskeyRequestContext.PasskeyRequestContext() -> 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.UserLoginInfo.UserLoginInfo(string! loginProvider, string! providerKey, string? providerDisplayName) -> void +Microsoft.AspNetCore.Identity.UserPasskeyInfo +Microsoft.AspNetCore.Identity.UserPasskeyInfo.AttestationObject.get -> byte[]! +Microsoft.AspNetCore.Identity.UserPasskeyInfo.ClientDataJson.get -> byte[]! +Microsoft.AspNetCore.Identity.UserPasskeyInfo.CreatedAt.get -> System.DateTimeOffset +Microsoft.AspNetCore.Identity.UserPasskeyInfo.CredentialId.get -> byte[]! +Microsoft.AspNetCore.Identity.UserPasskeyInfo.Name.get -> string? +Microsoft.AspNetCore.Identity.UserPasskeyInfo.Name.set -> void +Microsoft.AspNetCore.Identity.UserPasskeyInfo.PublicKey.get -> byte[]! +Microsoft.AspNetCore.Identity.UserPasskeyInfo.SignCount.get -> uint +Microsoft.AspNetCore.Identity.UserPasskeyInfo.SignCount.set -> void +Microsoft.AspNetCore.Identity.UserPasskeyInfo.Transports.get -> string![]? +Microsoft.AspNetCore.Identity.UserPasskeyInfo.UserPasskeyInfo(byte[]! credentialId, byte[]! publicKey, string? name, System.DateTimeOffset createdAt, uint signCount, string![]? transports, bool isUserVerified, bool isBackupEligible, bool isBackedUp, byte[]! attestationObject, byte[]! clientDataJson) -> void +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! +static readonly Microsoft.AspNetCore.Identity.IdentitySchemaVersions.Version3 -> System.Version! +virtual Microsoft.AspNetCore.Identity.UserManager.FindByPasskeyIdAsync(byte[]! credentialId) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.UserManager.GeneratePasskeyCreationOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyCreationArgs! creationArgs) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.UserManager.GeneratePasskeyRequestOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyRequestArgs! requestArgs) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.UserManager.GetPasskeyAsync(TUser! user, byte[]! credentialId) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.UserManager.GetPasskeysAsync(TUser! user) -> System.Threading.Tasks.Task!>! +virtual Microsoft.AspNetCore.Identity.UserManager.PerformPasskeyAssertionAsync(string! credentialJson, Microsoft.AspNetCore.Identity.PasskeyRequestOptions! options) -> System.Threading.Tasks.Task!>! +virtual Microsoft.AspNetCore.Identity.UserManager.PerformPasskeyAttestationAsync(string! credentialJson, Microsoft.AspNetCore.Identity.PasskeyCreationOptions! options) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.UserManager.RemovePasskeyAsync(TUser! user, byte[]! credentialId) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.UserManager.SetPasskeyAsync(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.UserManager.SupportsUserPasskey.get -> bool +virtual Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsBackedUp.get -> bool +virtual Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsBackedUp.set -> void +virtual Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsBackupEligible.get -> bool +virtual Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsUserVerified.get -> bool +virtual Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsUserVerified.set -> void diff --git a/src/Identity/Extensions.Core/src/Resources.resx b/src/Identity/Extensions.Core/src/Resources.resx index fe35a8b7868a..f95299d9d219 100644 --- a/src/Identity/Extensions.Core/src/Resources.resx +++ b/src/Identity/Extensions.Core/src/Resources.resx @@ -1,17 +1,17 @@  - @@ -269,6 +269,10 @@ Store does not implement IUserTwoFactorStore<TUser>. Error when the store does not implement this interface + + Store does not implement IUserPasskeyStore<TUser>. + Error when the store does not implement this interface + Recovery code redemption failed. Error when a recovery code is not redeemed. diff --git a/src/Identity/Extensions.Core/src/UserManager.cs b/src/Identity/Extensions.Core/src/UserManager.cs index cca2005d10d0..66babb2ee600 100644 --- a/src/Identity/Extensions.Core/src/UserManager.cs +++ b/src/Identity/Extensions.Core/src/UserManager.cs @@ -9,6 +9,7 @@ using System.Security.Claims; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Shared; @@ -48,6 +49,8 @@ public class UserManager : IDisposable where TUser : class private static readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create(); #endif private readonly IServiceProvider _services; + private readonly IPasskeyHandler? _passkeyHandler; + private readonly IPasskeyRequestContextProvider? _passkeyRequestContextProvider; /// /// The cancellation token used to cancel operations. @@ -117,6 +120,9 @@ public UserManager(IUserStore store, RegisterTokenProvider(providerName, provider); } } + + _passkeyHandler = services.GetService>(); + _passkeyRequestContextProvider = services.GetService(); } if (Options.Stores.ProtectPersonalData) @@ -373,6 +379,21 @@ public virtual bool SupportsQueryableUsers } } + /// + /// Gets a flag indicating whether the backing user store supports passkeys. + /// + /// + /// true if the backing user store supports passkeys, otherwise false. + /// + public virtual bool SupportsUserPasskey + { + get + { + ThrowIfDisposed(); + return Store is IUserPasskeyStore; + } + } + /// /// Returns an IQueryable of users if the store is an IQueryableUserStore /// @@ -2128,6 +2149,287 @@ public virtual Task CountRecoveryCodesAsync(TUser user) return store.CountCodesAsync(user, CancellationToken); } + /// + /// 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) + { + ThrowIfDisposed(); + ThrowIfNoPasskeyHandler(); + ArgumentNullThrowHelper.ThrowIfNullOrEmpty(credentialJson); + ArgumentNullThrowHelper.ThrowIfNull(options); + + var result = await _passkeyHandler.PerformAttestationAsync(credentialJson, options.AsJson(), this).ConfigureAwait(false); + if (!result.Succeeded) + { + Logger.LogDebug(LoggerEventIds.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) + { + ThrowIfDisposed(); + ThrowIfNoPasskeyHandler(); + ArgumentNullThrowHelper.ThrowIfNullOrEmpty(credentialJson); + ArgumentNullThrowHelper.ThrowIfNull(options); + + var user = options.UserId is { Length: > 0 } userId + ? await FindByIdAsync(userId).ConfigureAwait(false) + : null; + var result = await _passkeyHandler.PerformAssertionAsync(user, credentialJson, options.AsJson(), this).ConfigureAwait(false); + if (!result.Succeeded) + { + Logger.LogDebug(LoggerEventIds.PasskeyAssertionFailed, "Passkey assertion failed: {message}", result.Failure.Message); + } + + return result; + } + + /// + /// Generates a to create a new passkey for a user. + /// + /// Args for configuring the . + /// + /// A task object representing the asynchronous operation containing the . + /// + public virtual async Task GeneratePasskeyCreationOptionsAsync(PasskeyCreationArgs creationArgs) + { + ThrowIfDisposed(); + ThrowIfNoPasskeyRequestContextProvider(); + ArgumentNullThrowHelper.ThrowIfNull(creationArgs); + + var requestContext = _passkeyRequestContextProvider.Context; + var serverDomain = requestContext.Domain + ?? throw new InvalidOperationException("A passkey server domain has not been configured and cannot be inferred from the request context."); + + var excludeCredentials = await GetExcludeCredentialsAsync().ConfigureAwait(false); + 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 = GetRandomChallenge(Options.Passkey.ChallengeSize); + var options = new PublicKeyCredentialCreationOptions(rpEntity, userEntity, BufferSource.FromBytes(challenge)) + { + Timeout = (uint)Options.Passkey.Timeout.Milliseconds, + 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 FindByIdAsync(creationArgs.UserEntity.Id).ConfigureAwait(false); + if (existingUser is null) + { + return []; + } + + var passkeyStore = GetUserPasskeyStore(); + var passkeys = await passkeyStore.GetPasskeysAsync(existingUser, CancellationToken).ConfigureAwait(false); + var excludeCredentials = passkeys + .Select(p => new PublicKeyCredentialDescriptor(type: "public-key", id: BufferSource.FromBytes(p.CredentialId)) + { + Transports = [] // TODO: Consider making this configurable. + }); + return [.. excludeCredentials]; + } + } + + /// + /// Generates a to request an existing passkey for a user. + /// + /// Args for configuring the . + /// + /// A task object representing the asynchronous operation containing the . + /// + public virtual async Task GeneratePasskeyRequestOptionsAsync(PasskeyRequestArgs requestArgs) + { + ThrowIfDisposed(); + ThrowIfNoPasskeyRequestContextProvider(); + + var requestContext = _passkeyRequestContextProvider.Context; + var serverDomain = requestContext.Domain + ?? throw new InvalidOperationException("A passkey server domain has not been configured and cannot be inferred from the request context."); + + var allowCredentials = await GetAllowCredentialsAsync().ConfigureAwait(false); + var challenge = GetRandomChallenge(Options.Passkey.ChallengeSize); + var options = new PublicKeyCredentialRequestOptions(BufferSource.FromBytes(challenge)) + { + RpId = requestContext.Domain, + Timeout = (uint)Options.Passkey.Timeout.Milliseconds, + AllowCredentials = allowCredentials, + }; + if (requestArgs is not null) + { + options.UserVerification = requestArgs.UserVerification; + options.Extensions = requestArgs.Extensions; + } + var userId = requestArgs?.User is { } user + ? await 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 passkeyStore = GetUserPasskeyStore(); + var passkeys = await passkeyStore.GetPasskeysAsync(user, CancellationToken).ConfigureAwait(false); + var allowCredentials = passkeys + .Select(p => new PublicKeyCredentialDescriptor(type: "public-key", id: BufferSource.FromBytes(p.CredentialId)) + { + Transports = [] // TODO: Consider making this configurable. + }); + return [.. allowCredentials]; + } + } + + [MemberNotNull(nameof(_passkeyHandler))] + private void ThrowIfNoPasskeyHandler() + { + if (_passkeyHandler is null) + { + throw new InvalidOperationException( + $"This operation requires an {nameof(IPasskeyHandler<>)} service to be registered."); + } + } + + [MemberNotNull(nameof(_passkeyRequestContextProvider))] + private void ThrowIfNoPasskeyRequestContextProvider() + { + if (_passkeyRequestContextProvider is null) + { + throw new InvalidOperationException( + $"This operation requires an {nameof(IPasskeyRequestContextProvider)} service to be registered."); + } + } + + private static byte[] GetRandomChallenge(int challengeSize) + { + var resultBuffer = new byte[challengeSize]; + +#if NETCOREAPP + RandomNumberGenerator.Fill(resultBuffer); +#else + _rng.GetBytes(resultBuffer); +#endif + + return resultBuffer; + } + + /// + /// Adds a new passkey for the given user or updates an existing one. + /// + /// The user for whom the passkey should be added or updated. + /// The passkey to add or update. + /// Whether the passkey was successfully set. + public virtual async Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey) + { + ThrowIfDisposed(); + var passkeyStore = GetUserPasskeyStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); + ArgumentNullThrowHelper.ThrowIfNull(passkey); + + await passkeyStore.SetPasskeyAsync(user, passkey, CancellationToken).ConfigureAwait(false); + return await UpdateUserAsync(user).ConfigureAwait(false); + } + + /// + /// Gets a user's passkeys. + /// + /// The user whose passkeys should be retrieved. + /// + /// The that represents the asynchronous operation, containing a list of the user's passkeys. + /// + public virtual Task> GetPasskeysAsync(TUser user) + { + ThrowIfDisposed(); + var passkeyStore = GetUserPasskeyStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); + + return passkeyStore.GetPasskeysAsync(user, CancellationToken); + } + + /// + /// Finds a user's passkey by its credential id. + /// + /// The user whose passkey should be retrieved. + /// The credential ID to search for. + /// + /// The that represents the asynchronous operation, containing the passkey if found; otherwise . + /// + public virtual Task GetPasskeyAsync(TUser user, byte[] credentialId) + { + ThrowIfDisposed(); + var passkeyStore = GetUserPasskeyStore(); + ArgumentNullThrowHelper.ThrowIfNull(credentialId); + + return passkeyStore.FindPasskeyAsync(user, credentialId, CancellationToken); + } + + /// + /// Finds the user associated with a passkey. + /// + /// The credential ID to search for. + /// + /// The that represents the asynchronous operation, containing the user if found, otherwise . + /// + public virtual Task FindByPasskeyIdAsync(byte[] credentialId) + { + ThrowIfDisposed(); + var passkeyStore = GetUserPasskeyStore(); + ArgumentNullThrowHelper.ThrowIfNull(credentialId); + + return passkeyStore.FindByPasskeyIdAsync(credentialId, CancellationToken); + } + + /// + /// Removes a passkey credential from a user. + /// + /// The user whose passkey should be removed. + /// The credential id of the passkey to remove. + /// + /// The that represents the asynchronous operation, containing the + /// of the operation. + /// + public virtual async Task RemovePasskeyAsync(TUser user, byte[] credentialId) + { + ThrowIfDisposed(); + var passkeyStore = GetUserPasskeyStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); + ArgumentNullThrowHelper.ThrowIfNull(credentialId); + + await passkeyStore.RemovePasskeyAsync(user, credentialId, CancellationToken).ConfigureAwait(false); + return await UpdateUserAsync(user).ConfigureAwait(false); + } + /// /// Releases the unmanaged resources used by the role manager and optionally releases the managed resources. /// @@ -2420,6 +2722,16 @@ private IUserPasswordStore GetPasswordStore() return cast; } + private IUserPasskeyStore GetUserPasskeyStore() + { + var cast = Store as IUserPasskeyStore; + if (cast == null) + { + throw new NotSupportedException(Resources.StoreNotIUserPasskeyStore); + } + return cast; + } + /// /// Throws if this class has been disposed. /// diff --git a/src/Identity/Extensions.Core/src/UserPasskeyInfo.cs b/src/Identity/Extensions.Core/src/UserPasskeyInfo.cs new file mode 100644 index 000000000000..129cabd10158 --- /dev/null +++ b/src/Identity/Extensions.Core/src/UserPasskeyInfo.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Provides information for a user's passkey credential. +/// +public class UserPasskeyInfo +{ + /// + /// Initializes a new instance of . + /// + /// The credential ID for the passkey. + /// The public key for the passkey. + /// The friendly name for the passkey. + /// The time when the passkey was created. + /// The signature counter for the passkey. + /// The passkey's attestation object. + /// The passkey's client data JSON. + /// The transports supported by this passkey. + /// Indicates if the passkey has a verified user. + /// Indicates if the passkey is eligible for backup. + /// Indicates if the passkey is currently backed up. + public UserPasskeyInfo( + byte[] credentialId, + byte[] publicKey, + string? name, + DateTimeOffset createdAt, + uint signCount, + string[]? transports, + bool isUserVerified, + bool isBackupEligible, + bool isBackedUp, + byte[] attestationObject, + byte[] clientDataJson) + { + CredentialId = credentialId; + PublicKey = publicKey; + Name = name; + CreatedAt = createdAt; + SignCount = signCount; + Transports = transports; + IsUserVerified = isUserVerified; + IsBackupEligible = isBackupEligible; + IsBackedUp = isBackedUp; + AttestationObject = attestationObject; + ClientDataJson = clientDataJson; + } + + /// + /// Gets the credential ID for this passkey. + /// + public byte[] CredentialId { get; } = []; + + /// + /// Gets the public key associated with this passkey. + /// + public byte[] PublicKey { get; } = []; + + /// + /// Gets or sets the friendly name for this passkey. + /// + public string? Name { get; set; } + + /// + /// Gets the time this passkey was created. + /// + public DateTimeOffset CreatedAt { get; } + + /// + /// Gets or sets the signature counter for this passkey. + /// + public uint SignCount { get; set; } + + /// + /// Gets the transports supported by this passkey. + /// + /// + /// See . + /// + public string[]? Transports { get; } + + /// + /// Gets or sets whether the passkey has a verified user. + /// + public virtual bool IsUserVerified { get; set; } + + /// + /// Gets whether the passkey is eligible for backup. + /// + public virtual bool IsBackupEligible { get; } + + /// + /// Gets or sets whether the passkey is currently backed up. + /// + public virtual bool IsBackedUp { get; set; } + + /// + /// Gets the attestation object associated with this passkey. + /// + /// + /// See . + /// + public byte[] AttestationObject { get; } + + /// + /// Gets the collected client data JSON associated with this passkey. + /// + /// + /// See . + /// + public byte[] ClientDataJson { get; } +} diff --git a/src/Identity/Extensions.Stores/src/IdentityUserPasskey.cs b/src/Identity/Extensions.Stores/src/IdentityUserPasskey.cs new file mode 100644 index 000000000000..4a54e38707bb --- /dev/null +++ b/src/Identity/Extensions.Stores/src/IdentityUserPasskey.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents a passkey credential for a user in the identity system. +/// +/// +/// See . +/// +/// The type used for the primary key for this passkey credential. +public class IdentityUserPasskey where TKey : IEquatable +{ + /// + /// Gets or sets the primary key of the user that owns this passkey. + /// + public virtual TKey UserId { get; set; } = default!; + + /// + /// Gets or sets the credential ID for this passkey. + /// + public virtual byte[] CredentialId { get; set; } = []; + + /// + /// Gets or sets the public key associated with this passkey. + /// + public virtual byte[] PublicKey { get; set; } = []; + + /// + /// Gets or sets the friendly name for this passkey. + /// + public virtual string? Name { get; set; } + + /// + /// Gets or sets the time this passkey was created. + /// + public virtual DateTimeOffset CreatedAt { get; set; } + + /// + /// Gets or sets the signature counter for this passkey. + /// + public virtual uint SignCount { get; set; } + + /// + /// Gets or sets the transports supported by this passkey. + /// + /// + /// See . + /// + public virtual string[]? Transports { get; set; } + + /// + /// Gets or sets whether the passkey has a verified user. + /// + public virtual bool IsUserVerified { get; set; } + + /// + /// Gets or sets whether the passkey is eligible for backup. + /// + public virtual bool IsBackupEligible { get; set; } + + /// + /// Gets or sets whether the passkey is currently backed up. + /// + public virtual bool IsBackedUp { get; set; } + + /// + /// Gets or sets the attestation object associated with this passkey. + /// + /// + /// See . + /// + public virtual byte[] AttestationObject { get; set; } = []; + + /// + /// Gets or sets the collected client data JSON associated with this passkey. + /// + /// + /// See . + /// + public virtual byte[] ClientDataJson { get; set; } = []; +} diff --git a/src/Identity/Extensions.Stores/src/PublicAPI.Unshipped.txt b/src/Identity/Extensions.Stores/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..e6dce7cbb561 100644 --- a/src/Identity/Extensions.Stores/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Stores/src/PublicAPI.Unshipped.txt @@ -1 +1,27 @@ #nullable enable +Microsoft.AspNetCore.Identity.IdentityUserPasskey +Microsoft.AspNetCore.Identity.IdentityUserPasskey.IdentityUserPasskey() -> void +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.AttestationObject.get -> byte[]! +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.AttestationObject.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.ClientDataJson.get -> byte[]! +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.ClientDataJson.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.CreatedAt.get -> System.DateTimeOffset +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.CreatedAt.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.CredentialId.get -> byte[]! +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.CredentialId.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.IsBackedUp.get -> bool +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.IsBackedUp.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.IsBackupEligible.get -> bool +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.IsBackupEligible.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.IsUserVerified.get -> bool +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.IsUserVerified.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.Name.get -> string? +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.Name.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.PublicKey.get -> byte[]! +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.PublicKey.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.SignCount.get -> uint +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.SignCount.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.Transports.get -> string![]? +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.Transports.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.UserId.get -> TKey +virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.UserId.set -> void diff --git a/src/Identity/Identity.slnf b/src/Identity/Identity.slnf index 8e6b1617cb3d..ffeba99c52f5 100644 --- a/src/Identity/Identity.slnf +++ b/src/Identity/Identity.slnf @@ -41,6 +41,7 @@ "src\\Identity\\samples\\IdentitySample.ApiEndpoints\\IdentitySample.ApiEndpoints.csproj", "src\\Identity\\samples\\IdentitySample.DefaultUI\\IdentitySample.DefaultUI.csproj", "src\\Identity\\samples\\IdentitySample.Mvc\\IdentitySample.Mvc.csproj", + "src\\Identity\\samples\\IdentitySample.PasskeyConformance\\IdentitySample.PasskeyConformance.csproj", "src\\Identity\\test\\Identity.FunctionalTests\\Microsoft.AspNetCore.Identity.FunctionalTests.csproj", "src\\Identity\\test\\Identity.Test\\Microsoft.AspNetCore.Identity.Test.csproj", "src\\Identity\\test\\InMemory.Test\\Microsoft.AspNetCore.Identity.InMemory.Test.csproj", diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Data/FailedResponse.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/FailedResponse.cs new file mode 100644 index 000000000000..f55774624040 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/FailedResponse.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace IdentitySample.PasskeyConformance.Data; + +internal sealed class FailedResponse(string errorMessage) : ServerResponse(status: "failed", errorMessage); diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Data/OkResponse.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/OkResponse.cs new file mode 100644 index 000000000000..9ba9a1d5e8b4 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/OkResponse.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace IdentitySample.PasskeyConformance.Data; + +internal class OkResponse() : ServerResponse(status: "ok"); diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialCreationOptionsRequest.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialCreationOptionsRequest.cs new file mode 100644 index 000000000000..3f87115bf849 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialCreationOptionsRequest.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.AspNetCore.Identity; + +namespace IdentitySample.PasskeyConformance.Data; + +internal sealed class ServerPublicKeyCredentialCreationOptionsRequest(string username, string displayName) +{ + public string Username { get; } = username; + public string DisplayName { get; } = displayName; + public AuthenticatorSelectionCriteria? AuthenticatorSelection { get; set; } + public JsonElement? Extensions { get; set; } + public string? Attestation { get; set; } = "none"; +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialGetOptionsRequest.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialGetOptionsRequest.cs new file mode 100644 index 000000000000..6fa8873ed441 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialGetOptionsRequest.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; + +namespace IdentitySample.PasskeyConformance.Data; + +internal sealed class ServerPublicKeyCredentialGetOptionsRequest(string username, string userVerification) +{ + public string Username { get; } = username; + public string UserVerification { get; } = userVerification; + public JsonElement? Extensions { get; set; } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialOptionsResponse.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialOptionsResponse.cs new file mode 100644 index 000000000000..c2a3e70b245b --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerPublicKeyCredentialOptionsResponse.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace IdentitySample.PasskeyConformance.Data; + +[JsonConverter(typeof(JsonConverter))] +internal sealed class ServerPublicKeyCredentialOptionsResponse(string optionsJson) : OkResponse() +{ + public string OptionsJson { get; } = optionsJson; + + public sealed class JsonConverter : JsonConverter + { + public override ServerPublicKeyCredentialOptionsResponse? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, ServerPublicKeyCredentialOptionsResponse value, JsonSerializerOptions options) + { + var optionsObject = JsonNode.Parse(value.OptionsJson)?.AsObject() + ?? throw new JsonException("Could not parse the creation options JSON."); + + writer.WriteStartObject(); + writer.WriteString("status", value.Status); + writer.WriteString("errorMessage", value.ErrorMessage); + foreach (var (propertyName, propertyValue) in optionsObject) + { + writer.WritePropertyName(propertyName); + if (propertyValue is not null) + { + propertyValue.WriteTo(writer); + } + else + { + writer.WriteNullValue(); + } + } + writer.WriteEndObject(); + } + } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerResponse.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerResponse.cs new file mode 100644 index 000000000000..2446c5a1e414 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Data/ServerResponse.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace IdentitySample.PasskeyConformance.Data; + +internal abstract class ServerResponse(string status, string errorMessage = "") +{ + public string Status { get; } = status; + public string ErrorMessage { get; } = errorMessage; +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/IdentitySample.PasskeyConformance.csproj b/src/Identity/samples/IdentitySample.PasskeyConformance/IdentitySample.PasskeyConformance.csproj new file mode 100644 index 000000000000..3e50af823bc5 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/IdentitySample.PasskeyConformance.csproj @@ -0,0 +1,26 @@ + + + + Passkey conformance testing for ASP.NET Core Identity + $(DefaultNetCoreTargetFramework) + false + enable + + + + + + + + + + + + + + + + + + + diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/InMemoryUserStore.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/InMemoryUserStore.cs new file mode 100644 index 000000000000..2e5b13750492 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/InMemoryUserStore.cs @@ -0,0 +1,140 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.Test; + +namespace IdentitySample.PasskeyConformance; + +public sealed class InMemoryUserStore : + IQueryableUserStore, + IUserPasskeyStore + where TUser : PocoUser +{ + private readonly Dictionary _users = []; + + public IQueryable Users => _users.Values.AsQueryable(); + + public Task CreateAsync(TUser user, CancellationToken cancellationToken) + { + _users[user.Id] = user; + return Task.FromResult(IdentityResult.Success); + } + + public Task DeleteAsync(TUser user, CancellationToken cancellationToken) + { + if (!_users.Remove(user.Id)) + { + throw new InvalidOperationException($"Unknown user with ID '{user.Id}'."); + } + return Task.FromResult(IdentityResult.Success); + } + + public Task FindByIdAsync(string userId, CancellationToken cancellationToken) + => Task.FromResult(_users.TryGetValue(userId, out var result) ? result : null); + + public Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken) + => Task.FromResult(_users.Values.FirstOrDefault(u => string.Equals(u.NormalizedUserName, normalizedUserName, StringComparison.Ordinal))); + + public Task FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken) + => Task.FromResult(_users.Values.FirstOrDefault(u => u.Passkeys.Any(p => p.CredentialId.SequenceEqual(credentialId)))); + + public Task FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + => Task.FromResult(ToUserPasskeyInfo(user.Passkeys.FirstOrDefault(p => p.CredentialId.SequenceEqual(credentialId)))); + + public Task GetNormalizedUserNameAsync(TUser user, CancellationToken cancellationToken) + => Task.FromResult(user.NormalizedUserName); + + public Task GetUserIdAsync(TUser user, CancellationToken cancellationToken) + => Task.FromResult(user.Id); + + public Task GetUserNameAsync(TUser user, CancellationToken cancellationToken) + => Task.FromResult(user.UserName); + + public Task SetNormalizedUserNameAsync(TUser user, string? normalizedName, CancellationToken cancellationToken) + { + user.NormalizedUserName = normalizedName; + return Task.CompletedTask; + } + + public Task SetUserNameAsync(TUser user, string? userName, CancellationToken cancellationToken) + { + user.UserName = userName; + return Task.CompletedTask; + } + + public Task UpdateAsync(TUser user, CancellationToken cancellationToken) + { + _users[user.Id] = user; + return Task.FromResult(IdentityResult.Success); + } + + public Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) + { + var passkeyEntity = user.Passkeys.FirstOrDefault(p => p.CredentialId.SequenceEqual(passkey.CredentialId)); + if (passkeyEntity is null) + { + user.Passkeys.Add(ToPocoUserPasskey(user, passkey)); + } + else + { + passkeyEntity.Name = passkey.Name; + passkeyEntity.SignCount = passkey.SignCount; + passkeyEntity.IsBackedUp = passkey.IsBackedUp; + passkeyEntity.IsUserVerified = passkey.IsUserVerified; + } + return Task.CompletedTask; + } + + public Task> GetPasskeysAsync(TUser user, CancellationToken cancellationToken) + => Task.FromResult>(user.Passkeys.Select(ToUserPasskeyInfo).ToList()!); + + public Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + { + var passkey = user.Passkeys.SingleOrDefault(p => p.CredentialId.SequenceEqual(credentialId)); + if (passkey is not null) + { + user.Passkeys.Remove(passkey); + } + + return Task.CompletedTask; + } + + [return: NotNullIfNotNull(nameof(p))] + private static UserPasskeyInfo? ToUserPasskeyInfo(PocoUserPasskey? p) + => p is null ? null : new( + p.CredentialId, + p.PublicKey, + p.Name, + p.CreatedAt, + p.SignCount, + p.Transports, + p.IsUserVerified, + p.IsBackupEligible, + p.IsBackedUp, + p.AttestationObject, + p.ClientDataJson); + + [return: NotNullIfNotNull(nameof(p))] + private static PocoUserPasskey? ToPocoUserPasskey(TUser user, UserPasskeyInfo? p) + => p is null ? null : new PocoUserPasskey + { + UserId = user.Id, + CredentialId = p.CredentialId, + PublicKey = p.PublicKey, + Name = p.Name, + CreatedAt = p.CreatedAt, + Transports = p.Transports, + SignCount = p.SignCount, + IsUserVerified = p.IsUserVerified, + IsBackupEligible = p.IsBackupEligible, + IsBackedUp = p.IsBackedUp, + AttestationObject = p.AttestationObject, + ClientDataJson = p.ClientDataJson, + }; + + public void Dispose() + { + } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs new file mode 100644 index 000000000000..a270dcdacc70 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs @@ -0,0 +1,203 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using System.Text.Json; +using IdentitySample.PasskeyConformance; +using IdentitySample.PasskeyConformance.Data; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.Test; +using Microsoft.AspNetCore.Mvc; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddAuthentication(options => + { + options.DefaultScheme = IdentityConstants.ApplicationScheme; + options.DefaultSignInScheme = IdentityConstants.ExternalScheme; + }) + .AddIdentityCookies(builder => + { + builder.TwoFactorUserIdCookie!.Configure(options => + { + options.Cookie.SameSite = SameSiteMode.None; + }); + }); + +builder.Services.AddIdentityCore(options => + { + // The origin can't be inferred from the request, since the conformance testing tool + // does not send the Origin header. Therefore, we need to explicitly set the allowed origins. + options.Passkey.AllowedOrigins = [ + "http://localhost:7020", + "https://localhost:7020" + ]; + }) + .AddSignInManager(); + +builder.Services.AddSingleton, InMemoryUserStore>(); +builder.Services.AddSingleton, InMemoryUserStore>(); + +var app = builder.Build(); + +var attestationGroup = app.MapGroup("/attestation"); + +attestationGroup.MapPost("/options", async ( + [FromServices] UserManager userManager, + [FromServices] SignInManager signInManager, + [FromBody] ServerPublicKeyCredentialCreationOptionsRequest request) => +{ + var userId = (await userManager.FindByNameAsync(request.Username) ?? new PocoUser()).Id; + var userEntity = new PasskeyUserEntity(userId, request.Username, request.DisplayName); + var creationArgs = new PasskeyCreationArgs(userEntity) + { + AuthenticatorSelection = request.AuthenticatorSelection, + Extensions = request.Extensions, + }; + + if (request.Attestation is { Length: > 0 } attestation) + { + creationArgs.Attestation = attestation; + } + + var options = await signInManager.ConfigurePasskeyCreationOptionsAsync(creationArgs); + var response = new ServerPublicKeyCredentialOptionsResponse(options.AsJson()); + return Results.Ok(response); +}); + +attestationGroup.MapPost("/result", async ( + [FromServices] IUserPasskeyStore passkeyStore, + [FromServices] UserManager userManager, + [FromServices] SignInManager signInManager, + [FromBody] JsonElement result, + CancellationToken cancellationToken) => +{ + var credentialJson = ServerPublicKeyCredentialToJson(result); + + var options = await signInManager.RetrievePasskeyCreationOptionsAsync(); + + await signInManager.SignOutAsync(); + + if (options is null) + { + return Results.BadRequest(new FailedResponse("There are no original passkey options present.")); + } + + var attestationResult = await userManager.PerformPasskeyAttestationAsync(credentialJson, options); + if (!attestationResult.Succeeded) + { + return Results.BadRequest(new FailedResponse($"Attestation failed: {attestationResult.Failure.Message}")); + } + + // Create the user if they don't exist yet. + var userEntity = options.UserEntity; + var user = await userManager.FindByIdAsync(userEntity.Id); + if (user is null) + { + user = new PocoUser(userName: userEntity.Name) + { + Id = userEntity.Id, + }; + var createUserResult = await userManager.CreateAsync(user); + if (!createUserResult.Succeeded) + { + return Results.InternalServerError(new FailedResponse("Failed to create the user.")); + } + } + + await passkeyStore.SetPasskeyAsync(user, attestationResult.Passkey, cancellationToken).ConfigureAwait(false); + var updateResult = await userManager.UpdateAsync(user).ConfigureAwait(false); + if (!updateResult.Succeeded) + { + return Results.InternalServerError(new FailedResponse("Unable to update the user.")); + } + + return Results.Ok(new OkResponse()); +}); + +var assertionGroup = app.MapGroup("/assertion"); + +assertionGroup.MapPost("/options", async ( + [FromServices] UserManager userManager, + [FromServices] SignInManager signInManager, + [FromBody] ServerPublicKeyCredentialGetOptionsRequest request) => +{ + var user = await userManager.FindByNameAsync(request.Username); + if (user is null) + { + return Results.BadRequest($"User with username {request.Username} does not exist."); + } + + var requestArgs = new PasskeyRequestArgs + { + User = user, + UserVerification = request.UserVerification, + Extensions = request.Extensions, + }; + var options = await signInManager.ConfigurePasskeyRequestOptionsAsync(requestArgs); + var response = new ServerPublicKeyCredentialOptionsResponse(options.AsJson()); + return Results.Ok(response); +}); + +assertionGroup.MapPost("/result", async ( + [FromServices] SignInManager signInManager, + [FromServices] UserManager userManager, + [FromBody] JsonElement result) => +{ + var credentialJson = ServerPublicKeyCredentialToJson(result); + + var options = await signInManager.RetrievePasskeyRequestOptionsAsync(); + await signInManager.SignOutAsync(); + + if (options is null) + { + return Results.BadRequest(new FailedResponse("There are no original passkey options present.")); + } + + var assertionResult = await userManager.PerformPasskeyAssertionAsync(credentialJson, options); + if (!assertionResult.Succeeded) + { + return Results.BadRequest(new FailedResponse($"Assertion failed: {assertionResult.Failure.Message}")); + } + + await userManager.SetPasskeyAsync(assertionResult.User, assertionResult.Passkey); + + return Results.Ok(new OkResponse()); +}); + +app.UseHttpsRedirection(); + +app.Run(); + +static string ServerPublicKeyCredentialToJson(JsonElement serverPublicKeyCredential) +{ + // The response from the conformance testing tool comes in this format: + // https://github.com/fido-alliance/conformance-test-tools-resources/blob/main/docs/FIDO2/Server/Conformance-Test-API.md#serverpublickeycredential + // ...but we want it to be in this format: + // https://www.w3.org/TR/webauthn-3/#dictdef-registrationresponsejson + // This mainly entails renaming the 'getClientExtensionResults' property + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions() + { + Indented = true, + }); + writer.WriteStartObject(); + foreach (var property in serverPublicKeyCredential.EnumerateObject()) + { + switch (property.Name) + { + case "getClientExtensionResults": + writer.WritePropertyName("clientExtensionResults"); + break; + default: + writer.WritePropertyName(property.Name); + break; + } + property.Value.WriteTo(writer); + } + writer.WriteEndObject(); + writer.Flush(); + var resultBytes = stream.ToArray(); + var resultJson = Encoding.UTF8.GetString(resultBytes); + return resultJson; +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Properties/launchSettings.json b/src/Identity/samples/IdentitySample.PasskeyConformance/Properties/launchSettings.json new file mode 100644 index 000000000000..d8a374fe0b6f --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:7020", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7020", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.Development.json b/src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.Development.json new file mode 100644 index 000000000000..0c208ae9181e --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.json b/src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.json new file mode 100644 index 000000000000..10f68b8c8b4f --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Identity/startvscode.cmd b/src/Identity/startvscode.cmd new file mode 100644 index 000000000000..d403f3028231 --- /dev/null +++ b/src/Identity/startvscode.cmd @@ -0,0 +1,3 @@ +@ECHO OFF + +%~dp0..\..\startvscode.cmd %~dp0 diff --git a/src/Identity/test/Shared/PocoModel/PocoRole.cs b/src/Identity/test/Shared/PocoModel/PocoRole.cs index 5b0b84e65ab4..b5d5c61b310b 100644 --- a/src/Identity/test/Shared/PocoModel/PocoRole.cs +++ b/src/Identity/test/Shared/PocoModel/PocoRole.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + namespace Microsoft.AspNetCore.Identity.Test; /// diff --git a/src/Identity/test/Shared/PocoModel/PocoRoleClaim.cs b/src/Identity/test/Shared/PocoModel/PocoRoleClaim.cs index 5b251c20537b..c8c6b921a924 100644 --- a/src/Identity/test/Shared/PocoModel/PocoRoleClaim.cs +++ b/src/Identity/test/Shared/PocoModel/PocoRoleClaim.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + namespace Microsoft.AspNetCore.Identity.Test; /// diff --git a/src/Identity/test/Shared/PocoModel/PocoUser.cs b/src/Identity/test/Shared/PocoModel/PocoUser.cs index 342506ae1c0a..b9a04eb52894 100644 --- a/src/Identity/test/Shared/PocoModel/PocoUser.cs +++ b/src/Identity/test/Shared/PocoModel/PocoUser.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + namespace Microsoft.AspNetCore.Identity.Test; /// @@ -144,4 +146,8 @@ public PocoUser(string userName) : this() /// Navigation property /// public virtual ICollection> Tokens { get; private set; } = new List>(); + /// + /// Navigation property + /// + public virtual ICollection> Passkeys { get; private set; } = new List>(); } diff --git a/src/Identity/test/Shared/PocoModel/PocoUserClaim.cs b/src/Identity/test/Shared/PocoModel/PocoUserClaim.cs index ebd5a24515c2..2d1343891410 100644 --- a/src/Identity/test/Shared/PocoModel/PocoUserClaim.cs +++ b/src/Identity/test/Shared/PocoModel/PocoUserClaim.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + namespace Microsoft.AspNetCore.Identity.Test; /// diff --git a/src/Identity/test/Shared/PocoModel/PocoUserLogin.cs b/src/Identity/test/Shared/PocoModel/PocoUserLogin.cs index ab7162fe230a..e62fd55aad6c 100644 --- a/src/Identity/test/Shared/PocoModel/PocoUserLogin.cs +++ b/src/Identity/test/Shared/PocoModel/PocoUserLogin.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + namespace Microsoft.AspNetCore.Identity.Test; /// diff --git a/src/Identity/test/Shared/PocoModel/PocoUserPasskey.cs b/src/Identity/test/Shared/PocoModel/PocoUserPasskey.cs new file mode 100644 index 000000000000..9a4ec0e610a5 --- /dev/null +++ b/src/Identity/test/Shared/PocoModel/PocoUserPasskey.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace Microsoft.AspNetCore.Identity.Test; + +public class PocoUserPasskey : PocoUserPasskey; + +/// +/// Represents a passkey credential for a user in the identity system. +/// +/// +/// See . +/// +/// The type used for the primary key for this passkey credential. +public class PocoUserPasskey where TKey : IEquatable +{ + /// + /// Gets or sets the primary key of the user that owns this passkey. + /// + public virtual TKey UserId { get; set; } = default!; + + /// + /// Gets or sets the credential ID for this passkey. + /// + public virtual byte[] CredentialId { get; set; } = []; + + /// + /// Gets or sets the public key associated with this passkey. + /// + public virtual byte[] PublicKey { get; set; } = []; + + /// + /// Gets or sets the friendly name for this passkey. + /// + public virtual string Name { get; set; } + + /// + /// Gets or sets the time this passkey was created. + /// + public virtual DateTimeOffset CreatedAt { get; set; } + + /// + /// Gets or sets the signature counter for this passkey. + /// + public virtual uint SignCount { get; set; } + + /// + /// Gets or sets the transports supported by this passkey. + /// + /// + /// See . + /// + public virtual string[] Transports { get; set; } + + /// + /// Gets or sets whether the passkey has a verified user. + /// + public virtual bool IsUserVerified { get; set; } + + /// + /// Gets or sets whether the passkey is eligible for backup. + /// + public virtual bool IsBackupEligible { get; set; } + + /// + /// Gets or sets whether the passkey is currently backed up. + /// + public virtual bool IsBackedUp { get; set; } + + /// + /// Gets or sets the attestation object associated with this passkey. + /// + /// + /// See . + /// + public virtual byte[] AttestationObject { get; set; } = []; + + /// + /// Gets or sets the collected client data JSON associated with this passkey. + /// + /// + /// See . + /// + public virtual byte[] ClientDataJson { get; set; } = []; +} diff --git a/src/Identity/test/Shared/PocoModel/PocoUserRole.cs b/src/Identity/test/Shared/PocoModel/PocoUserRole.cs index 19b7d1b0c56a..5637d35cff5d 100644 --- a/src/Identity/test/Shared/PocoModel/PocoUserRole.cs +++ b/src/Identity/test/Shared/PocoModel/PocoUserRole.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + namespace Microsoft.AspNetCore.Identity.Test; /// diff --git a/src/Identity/test/Shared/PocoModel/PocoUserToken.cs b/src/Identity/test/Shared/PocoModel/PocoUserToken.cs index 22af469fd298..a57721225449 100644 --- a/src/Identity/test/Shared/PocoModel/PocoUserToken.cs +++ b/src/Identity/test/Shared/PocoModel/PocoUserToken.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + namespace Microsoft.AspNetCore.Identity.Test; /// diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor index 292750b79a4c..c31a0fb701c2 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor @@ -5,6 +5,7 @@ @using Microsoft.AspNetCore.Identity @using BlazorWeb_CSharp.Data +@inject UserManager UserManager @inject SignInManager SignInManager @inject ILogger Logger @inject NavigationManager NavigationManager @@ -16,8 +17,9 @@
+ - +

Use a local account to log in.


@@ -39,8 +41,14 @@
- +
+
+
+ OR + +
+

Forgot your password? @@ -67,10 +75,13 @@ @code { private string? errorMessage; + private EditContext editContext = default!; + private string? currentPasskeyRequestOptions; + [CascadingParameter] private HttpContext HttpContext { get; set; } = default!; - [SupplyParameterFromForm] + [SupplyParameterFromForm(FormName = "login")] private InputModel Input { get; set; } = new(); [SupplyParameterFromQuery] @@ -78,6 +89,8 @@ protected override async Task OnInitializedAsync() { + editContext = new EditContext(Input); + if (HttpMethods.IsGet(HttpContext.Request.Method)) { // Clear the existing external cookie to ensure a clean login process @@ -87,6 +100,29 @@ public async Task LoginUser() { + // When performing passkey sign-in, don't perform form validation. + // If provided, we use the email as a hint to suggest which user is likely being signed in. + if (Input.UsePasskey) + { + var user = Input.Email is { Length: > 0 } email + ? await UserManager.FindByEmailAsync(email) + : null; + + var passkeyRequestArgs = new PasskeyRequestArgs + { + User = user, + }; + var options = await SignInManager.ConfigurePasskeyRequestOptionsAsync(passkeyRequestArgs); + currentPasskeyRequestOptions = options.AsJson(); + return; + } + + // If doing a password sign-in, validate the form. + if (!editContext.Validate()) + { + return; + } + // This doesn't count login failures towards account lockout // To enable password failures to trigger account lockout, set lockoutOnFailure: true var result = await SignInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false); @@ -112,6 +148,33 @@ } } + public async Task LoginUserWithPasskey(string responseJson) + { + var options = await SignInManager.RetrievePasskeyRequestOptionsAsync(); + if (options is null) + { + errorMessage = "Error: Could not complete passkey login. Please try again."; + return; + } + + var result = await SignInManager.PasskeySignInAsync(responseJson, options); + if (result.Succeeded) + { + Logger.LogInformation("User logged in."); + RedirectManager.RedirectTo(ReturnUrl); + } + else + { + errorMessage = "Error: Could not log in using the provided passkey."; + return; + } + } + + public void SetPasskeyError(string? error) + { + errorMessage = $"Error: Could not log in using the provided passkey{(string.IsNullOrEmpty(error) ? "." : $": {error}")}"; + } + private sealed class InputModel { [Required] @@ -124,5 +187,7 @@ [Display(Name = "Remember me?")] public bool RememberMe { get; set; } + + public bool UsePasskey { get; set; } } } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor new file mode 100644 index 000000000000..2f9f59b87f26 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor @@ -0,0 +1,238 @@ +@page "/Account/Manage/Passkeys" + +@using BlazorWeb_CSharp.Data +@using Microsoft.AspNetCore.Identity +@using System.ComponentModel.DataAnnotations + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityRedirectManager RedirectManager + +Manage your passkeys + +

Manage your passkeys

+ + + +@if (!string.IsNullOrEmpty(renamingPasskeyId)) +{ + + +

Enter a name for your passkey

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

No passkeys are registered.

+ } + + + +
+ + + +} + +@code { + private ApplicationUser? user; + private IList? currentPasskeys; + private string? renamingPasskeyId; + private string? currentPasskeyCreationOptions; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private string? RemovingPasskeyId { get; set; } + + [SupplyParameterFromForm(FormName = "rename-passkey")] + private RenamePasskeyInput RenameInput { get; set; } = new(); + + protected override async Task OnInitializedAsync() + { + renamingPasskeyId = RenameInput?.Id; + + user = await UserManager.GetUserAsync(HttpContext.User); + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + currentPasskeys = await UserManager.GetPasskeysAsync(user); + } + + private async Task ConfigurePasskeyCreationOptions() + { + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + var userId = await UserManager.GetUserIdAsync(user); + var userName = await UserManager.GetUserNameAsync(user) ?? "User"; + var userEntity = new PasskeyUserEntity(userId, userName, displayName: userName); + var options = await SignInManager.ConfigurePasskeyCreationOptionsAsync(new(userEntity)); + currentPasskeyCreationOptions = options.AsJson(); + } + + private async Task AddPasskeyAsync(string responseJson) + { + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + var options = await SignInManager.RetrievePasskeyCreationOptionsAsync(); + if (options is null) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: Could not retrieve passkey creation options.", HttpContext); + return; + } + + var attestationResult = await UserManager.PerformPasskeyAttestationAsync(responseJson, options); + if (!attestationResult.Succeeded) + { + RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add the passkey: {attestationResult.Failure.Message}.", HttpContext); + return; + } + + await UserManager.SetPasskeyAsync(user, attestationResult.Passkey); + + // Immediately prompt the user to enter a name for the credential + renamingPasskeyId = Convert.ToBase64String(attestationResult.Passkey.CredentialId); + } + + private void SetPasskeyError(string error) + { + RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add a passkey: {error}", HttpContext); + } + + private async Task RemovePasskeyAsync() + { + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + var passkey = GetPasskeyByBase64Id(RemovingPasskeyId); + if (passkey is null) + { + // Redirected in GetPasskeyByBase64Id + return; + } + + await UserManager.RemovePasskeyAsync(user, passkey.CredentialId); + + RedirectManager.RedirectToCurrentPageWithStatus("Passkey deleted successfully.", HttpContext); + } + + private async Task RenamePasskeyAsync() + { + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + var passkey = GetPasskeyByBase64Id(RenameInput.Id); + if (passkey is null) + { + // Redirected in GetPasskeyByBase64Id + return; + } + + passkey.Name = RenameInput.DisplayName; + var result = await UserManager.SetPasskeyAsync(user, passkey); + if (!result.Succeeded) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: The passkey could not be updated.", HttpContext); + return; + } + + RedirectManager.RedirectToCurrentPageWithStatus("Passkey updated successfully.", HttpContext); + } + + private UserPasskeyInfo? GetPasskeyByBase64Id(string? base64CredentialId) + { + if (string.IsNullOrEmpty(base64CredentialId)) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: The passkey ID was not specified.", HttpContext); + return null; + } + + var credentialId = Convert.FromBase64String(base64CredentialId); + if (credentialId is null) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: The specified passkey ID had an invalid format.", HttpContext); + return null; + } + + if (currentPasskeys is null) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: Could not fetch passkeys for the current user.", HttpContext); + return null; + } + + var credential = currentPasskeys.SingleOrDefault(c => c.CredentialId.SequenceEqual(credentialId)); + if (credential is null) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: The specified passkey does not exist for the current user.", HttpContext); + return null; + } + + return credential; + } + + private sealed class RenamePasskeyInput + { + [Required] + public string Id { get; set; } = ""; + + [Required] + public string DisplayName { get; set; } = ""; + } +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/ManageNavMenu.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/ManageNavMenu.razor index 29d1ec6e7d8a..6621996dd33f 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/ManageNavMenu.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/ManageNavMenu.razor @@ -22,6 +22,9 @@ + diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeyHandler.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeyHandler.razor new file mode 100644 index 000000000000..5b2b40a46350 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeyHandler.razor @@ -0,0 +1,76 @@ +@using BlazorWeb_CSharp.Data +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Identity + +
+ + + + + +@if (options is not null) +{ + + +} + +@code { + private string? action; + private string? options; + + [Parameter] + public string? CurrentCreationOptions { get; set; } + + [Parameter] + public string? CurrentRequestOptions { get; set; } + + [Parameter] + [EditorRequired] + public EventCallback OnResponse { get; set; } + + [Parameter] + [EditorRequired] + public EventCallback OnError { get; set; } + + [SupplyParameterFromForm] + private string? ResponseJson { get; set; } + + [SupplyParameterFromForm] + private string? Error { get; set; } + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + if (HttpMethods.IsGet(HttpContext.Request.Method)) + { + // Clear the existing two factor cookie to ensure a clean ceremony + await HttpContext.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); + } + } + + protected override void OnParametersSet() + { + (options, action) = (CurrentCreationOptions, CurrentRequestOptions) switch + { + (null, null) => (null, null), + (var createOptions, null) => (createOptions, "create"), + (null, var requestOptions) => (requestOptions, "get"), + (not null, not null) => throw new InvalidOperationException( + $"Only one of '{nameof(CurrentCreationOptions)}' and '{nameof(CurrentRequestOptions)}' should be specified."), + }; + } + + private async Task OnSubmitAsync() + { + if (ResponseJson is { Length: > 0 } responseJson) + { + await OnResponse.InvokeAsync(responseJson); + } + else + { + await OnError.InvokeAsync(Error); + } + } +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeyHandler.razor.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeyHandler.razor.js new file mode 100644 index 000000000000..726a82d6355b --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeyHandler.razor.js @@ -0,0 +1,78 @@ +async function createCredential(optionsJSON) { + // See: https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential + + // 1. Let options be a new PublicKeyCredentialCreationOptions structure configured to + // the Relying Party’s needs for the ceremony. + // See: https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-parsecreationoptionsfromjson + const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJSON); + + // 2. Call navigator.credentials.create() and pass options as the publicKey option. + // Let credential be the result of the successfully resolved promise. + // If the promise is rejected, abort the ceremony with a user-visible error, + // or otherwise guide the user experience as might be determinable from the + // context available in the rejected promise. + const credential = await navigator.credentials.create({ publicKey: options }); + + // 3. Let response be credential.response. If response is not an instance of + // AuthenticatorAttestationResponse, abort the ceremony with a user-visible error. + if (!(credential?.response instanceof AuthenticatorAttestationResponse)) { + throw new Error('The authenticator failed to provide a valid credential.'); + } + + // Continue the ceremony on the server. + // See: https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-tojson + return JSON.stringify(credential); +} + +async function getCredential(optionsJSON) { + // See: https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion + + // 1. Let options be a new PublicKeyCredentialRequestOptions structure configured to + // the Relying Party’s needs for the ceremony. + // See: https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-parserequestoptionsfromjson + const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON); + + // 2. Call navigator.credentials.get() and pass options as the publicKey option. + // Let credential be the result of the successfully resolved promise. + // If the promise is rejected, abort the ceremony with a user-visible error, + // or otherwise guide the user experience as might be determinable from the + // context available in the rejected promise. + const credential = await navigator.credentials.get({ publicKey: options }); + + // 3. Let response be credential.response. If response is not an instance of + // AuthenticatorAssertionResponse, abort the ceremony with a user - visible error. + if (!(credential?.response instanceof AuthenticatorResponse)) { + throw new Error('The authenticator failed to provide a valid credential.'); + } + + // Continue the ceremony on the server. + // See: https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-tojson + return JSON.stringify(credential); +} + +async function submitResponse(action) { + const optionsScript = document.getElementById('passkey-options'); + const form = document.getElementById('passkey-response-form'); + const responseInput = document.getElementById('passkey-response'); + const errorInput = document.getElementById('passkey-error'); + + try { + const optionsJson = optionsScript.innerHTML; + const options = JSON.parse(optionsJson); + + if (action === 'create') { + responseInput.value = await createCredential(options); + } else if (action === 'get') { + responseInput.value = await getCredential(options); + } else { + throw new Error(`Unknown passkey action '${action}'.`); + } + } catch (error) { + errorInput.value = error.message; + } + + form.submit(); +} + +const action = document.currentScript.getAttribute('data-action'); +submitResponse(action); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/00000000000000_CreateIdentitySchema.Designer.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/00000000000000_CreateIdentitySchema.Designer.cs index ba5b97c07dff..ae1764e8d38c 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/00000000000000_CreateIdentitySchema.Designer.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/00000000000000_CreateIdentitySchema.Designer.cs @@ -18,7 +18,7 @@ partial class CreateIdentitySchema protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); modelBuilder.Entity("BlazorWeb_CSharp.Data.ApplicationUser", b => { @@ -57,6 +57,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("PhoneNumber") + .HasMaxLength(256) .HasColumnType("TEXT"); b.Property("PhoneNumberConfirmed") @@ -159,9 +160,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { b.Property("LoginProvider") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("ProviderKey") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("ProviderDisplayName") @@ -178,6 +181,57 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserLogins", (string)null); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.Property("CredentialId") + .HasMaxLength(1024) + .HasColumnType("BLOB"); + + b.Property("AttestationObject") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ClientDataJson") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsBackedUp") + .HasColumnType("INTEGER"); + + b.Property("IsBackupEligible") + .HasColumnType("INTEGER"); + + b.Property("IsUserVerified") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("BLOB"); + + b.Property("SignCount") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("Transports") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("CredentialId"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserPasskeys", (string)null); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.Property("UserId") @@ -199,9 +253,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("LoginProvider") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Name") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Value") @@ -239,6 +295,15 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.HasOne("BlazorWeb_CSharp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/00000000000000_CreateIdentitySchema.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/00000000000000_CreateIdentitySchema.cs index bf83fc9a9f27..92e0a0ea2cc2 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/00000000000000_CreateIdentitySchema.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/00000000000000_CreateIdentitySchema.cs @@ -38,7 +38,7 @@ protected override void Up(MigrationBuilder migrationBuilder) PasswordHash = table.Column(type: "TEXT", nullable: true), SecurityStamp = table.Column(type: "TEXT", nullable: true), ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), - PhoneNumber = table.Column(type: "TEXT", nullable: true), + PhoneNumber = table.Column(type: "TEXT", maxLength: 256, nullable: true), PhoneNumberConfirmed = table.Column(type: "INTEGER", nullable: false), TwoFactorEnabled = table.Column(type: "INTEGER", nullable: false), LockoutEnd = table.Column(type: "TEXT", nullable: true), @@ -96,8 +96,8 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "AspNetUserLogins", columns: table => new { - LoginProvider = table.Column(type: "TEXT", nullable: false), - ProviderKey = table.Column(type: "TEXT", nullable: false), + LoginProvider = table.Column(type: "TEXT", maxLength: 128, nullable: false), + ProviderKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), ProviderDisplayName = table.Column(type: "TEXT", nullable: true), UserId = table.Column(type: "TEXT", nullable: false) }, @@ -112,6 +112,34 @@ protected override void Up(MigrationBuilder migrationBuilder) onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "AspNetUserPasskeys", + columns: table => new + { + CredentialId = table.Column(type: "BLOB", maxLength: 1024, nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + PublicKey = table.Column(type: "BLOB", maxLength: 1024, nullable: false), + Name = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + SignCount = table.Column(type: "INTEGER", nullable: false), + Transports = table.Column(type: "TEXT", nullable: true), + IsUserVerified = table.Column(type: "INTEGER", nullable: false), + IsBackupEligible = table.Column(type: "INTEGER", nullable: false), + IsBackedUp = table.Column(type: "INTEGER", nullable: false), + AttestationObject = table.Column(type: "BLOB", nullable: false), + ClientDataJson = table.Column(type: "BLOB", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserPasskeys", x => x.CredentialId); + table.ForeignKey( + name: "FK_AspNetUserPasskeys_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "AspNetUserRoles", columns: table => new @@ -141,8 +169,8 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: table => new { UserId = table.Column(type: "TEXT", nullable: false), - LoginProvider = table.Column(type: "TEXT", nullable: false), - Name = table.Column(type: "TEXT", nullable: false), + LoginProvider = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Name = table.Column(type: "TEXT", maxLength: 128, nullable: false), Value = table.Column(type: "TEXT", nullable: true) }, constraints: table => @@ -177,6 +205,11 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "AspNetUserLogins", column: "UserId"); + migrationBuilder.CreateIndex( + name: "IX_AspNetUserPasskeys_UserId", + table: "AspNetUserPasskeys", + column: "UserId"); + migrationBuilder.CreateIndex( name: "IX_AspNetUserRoles_RoleId", table: "AspNetUserRoles", @@ -206,6 +239,9 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "AspNetUserLogins"); + migrationBuilder.DropTable( + name: "AspNetUserPasskeys"); + migrationBuilder.DropTable( name: "AspNetUserRoles"); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/ApplicationDbContextModelSnapshot.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/ApplicationDbContextModelSnapshot.cs index 930cebc1857d..f6bdeaf144ec 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/ApplicationDbContextModelSnapshot.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlLite/ApplicationDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class ApplicationDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); modelBuilder.Entity("BlazorWeb_CSharp.Data.ApplicationUser", b => { @@ -54,6 +54,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("PhoneNumber") + .HasMaxLength(256) .HasColumnType("TEXT"); b.Property("PhoneNumberConfirmed") @@ -156,9 +157,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { b.Property("LoginProvider") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("ProviderKey") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("ProviderDisplayName") @@ -175,6 +178,57 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserLogins", (string)null); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.Property("CredentialId") + .HasMaxLength(1024) + .HasColumnType("BLOB"); + + b.Property("AttestationObject") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ClientDataJson") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsBackedUp") + .HasColumnType("INTEGER"); + + b.Property("IsBackupEligible") + .HasColumnType("INTEGER"); + + b.Property("IsUserVerified") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("BLOB"); + + b.Property("SignCount") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("Transports") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("CredentialId"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserPasskeys", (string)null); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.Property("UserId") @@ -196,9 +250,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("LoginProvider") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Name") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Value") @@ -236,6 +292,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.HasOne("BlazorWeb_CSharp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/00000000000000_CreateIdentitySchema.Designer.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/00000000000000_CreateIdentitySchema.Designer.cs index 34d2b6df1a30..c92d84f26d83 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/00000000000000_CreateIdentitySchema.Designer.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/00000000000000_CreateIdentitySchema.Designer.cs @@ -20,7 +20,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("ProductVersion", "10.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -62,7 +62,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(max)"); b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); b.Property("PhoneNumberConfirmed") .HasColumnType("bit"); @@ -170,10 +171,12 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); b.Property("ProviderDisplayName") .HasColumnType("nvarchar(max)"); @@ -189,6 +192,57 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserLogins", (string)null); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.Property("CredentialId") + .HasMaxLength(1024) + .HasColumnType("varbinary(1024)"); + + b.Property("AttestationObject") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("ClientDataJson") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsBackedUp") + .HasColumnType("bit"); + + b.Property("IsBackupEligible") + .HasColumnType("bit"); + + b.Property("IsUserVerified") + .HasColumnType("bit"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("varbinary(1024)"); + + b.Property("SignCount") + .HasColumnType("bigint"); + + b.PrimitiveCollection("Transports") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("CredentialId"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserPasskeys", (string)null); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.Property("UserId") @@ -210,10 +264,12 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(450)"); b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); b.Property("Name") - .HasColumnType("nvarchar(450)"); + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); b.Property("Value") .HasColumnType("nvarchar(max)"); @@ -250,6 +306,15 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.HasOne("BlazorWeb_CSharp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/00000000000000_CreateIdentitySchema.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/00000000000000_CreateIdentitySchema.cs index ec47e9f16b95..8d26035044d5 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/00000000000000_CreateIdentitySchema.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/00000000000000_CreateIdentitySchema.cs @@ -38,7 +38,7 @@ protected override void Up(MigrationBuilder migrationBuilder) PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), - PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), TwoFactorEnabled = table.Column(type: "bit", nullable: false), LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), @@ -96,8 +96,8 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "AspNetUserLogins", columns: table => new { - LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), - ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), + LoginProvider = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + ProviderKey = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), UserId = table.Column(type: "nvarchar(450)", nullable: false) }, @@ -112,6 +112,34 @@ protected override void Up(MigrationBuilder migrationBuilder) onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "AspNetUserPasskeys", + columns: table => new + { + CredentialId = table.Column(type: "varbinary(1024)", maxLength: 1024, nullable: false), + UserId = table.Column(type: "nvarchar(450)", nullable: false), + PublicKey = table.Column(type: "varbinary(1024)", maxLength: 1024, nullable: false), + Name = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + SignCount = table.Column(type: "bigint", nullable: false), + Transports = table.Column(type: "nvarchar(max)", nullable: true), + IsUserVerified = table.Column(type: "bit", nullable: false), + IsBackupEligible = table.Column(type: "bit", nullable: false), + IsBackedUp = table.Column(type: "bit", nullable: false), + AttestationObject = table.Column(type: "varbinary(max)", nullable: false), + ClientDataJson = table.Column(type: "varbinary(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserPasskeys", x => x.CredentialId); + table.ForeignKey( + name: "FK_AspNetUserPasskeys_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "AspNetUserRoles", columns: table => new @@ -141,8 +169,8 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: table => new { UserId = table.Column(type: "nvarchar(450)", nullable: false), - LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), - Name = table.Column(type: "nvarchar(450)", nullable: false), + LoginProvider = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), Value = table.Column(type: "nvarchar(max)", nullable: true) }, constraints: table => @@ -178,6 +206,11 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "AspNetUserLogins", column: "UserId"); + migrationBuilder.CreateIndex( + name: "IX_AspNetUserPasskeys_UserId", + table: "AspNetUserPasskeys", + column: "UserId"); + migrationBuilder.CreateIndex( name: "IX_AspNetUserRoles_RoleId", table: "AspNetUserRoles", diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/ApplicationDbContextModelSnapshot.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/ApplicationDbContextModelSnapshot.cs index a6b6896c65dd..3f5e8fe8be78 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/ApplicationDbContextModelSnapshot.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/SqlServer/ApplicationDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("ProductVersion", "10.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -59,7 +59,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(max)"); b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); b.Property("PhoneNumberConfirmed") .HasColumnType("bit"); @@ -167,10 +168,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); b.Property("ProviderDisplayName") .HasColumnType("nvarchar(max)"); @@ -186,6 +189,57 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserLogins", (string)null); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.Property("CredentialId") + .HasMaxLength(1024) + .HasColumnType("varbinary(1024)"); + + b.Property("AttestationObject") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("ClientDataJson") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsBackedUp") + .HasColumnType("bit"); + + b.Property("IsBackupEligible") + .HasColumnType("bit"); + + b.Property("IsUserVerified") + .HasColumnType("bit"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("varbinary(1024)"); + + b.Property("SignCount") + .HasColumnType("bigint"); + + b.PrimitiveCollection("Transports") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("CredentialId"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserPasskeys", (string)null); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.Property("UserId") @@ -207,10 +261,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(450)"); b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); b.Property("Name") - .HasColumnType("nvarchar(450)"); + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); b.Property("Value") .HasColumnType("nvarchar(max)"); @@ -247,6 +303,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.HasOne("BlazorWeb_CSharp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/app.db b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Data/app.db index 769de58a9edcf5a1759ed6f6ce8bd0b2608e727e..74d0af218938386bbd548461afe84706dcda3179 100644 GIT binary patch delta 2398 zcmeHIU2NM_6wYxR$4+89>Ds0(&G5W3C0m*~S(1)wU7Lm?vSvxk+9r)ULfV+hG)qG4 z0!3(AsCeLw=?Yl_2BnW^5)wrMX;M{*M}EW$LaKzeJ@N!^XqpgQyFb7ZctSkYwyv(v z`R={voO{l9ZfsCDHtcUd&iX8WJ{>oM@zbxpWWy5R9~$yw{6p8T><^A<*AD$H{W|r) zW~06!ddVHwh21TWI|I~+oAOSdK_79)(T1JWg$3x2dEOjg?cQ)0-cq&FVnt4<^^B~| zs&Zqhq^c|On%XMi(0#Vk|5uDC_*sX5A2HdG%W_pyN|jVu6vorpaU{?u?faXdvs}0C zDMG(noLXJ1C`*&_+8#>3TL2$=mv+$&xunVEgoeU|m${-WSCh5Xs)l}d^#x`crK(!5 zH8fQeW+w78LS{Cd4(^|vQVomd?ElxCU8r^0&l5Sol(KaiB4I}R0@fH^) zsb-!)-*a)WA{W1j$4~fYE|55zXd|VX2{2RRjX^Utip7biFDuIpGg@l8wzSeJK~COB z8Y*;;33as5OI+6zGs-n1=-288w`c|%AK2kwjdR@=3y)J)`i_?dz-q}xq)2Q~iVa4F z1Svi&g@?n@P*^$_m(E4cNYbz*8LNQbw(#3H(LdvDV_?0KKtG;6*%y{V_#Lb_>3QHz6mVBs%t9QV_|md~e0B;&G+^q15Jlw`YY^ONtBKI%s~_$(|7ZD_&Eom5yorxPK2QuP^xA5)l|ONN@zucgHz>J zMW4CP&>9 zsh^t1K6wqFy7cGgAxa!G5uN(S^Vq#^{p)$SWL-b*(F+AkyqyMNe*Rtot^zUIfvNYD zez5>Od>7^vlLgy!P13XeEUynQz-e%s%hwhF!6U>!edudD;y-G;|8d(TMi2Vtqb;=t Mo070O=5G7_0c=UhJOBUy delta 1034 zcmd5*O-K|`0G@Al{&(lSS#@=#RC0DlH@9J3buF^ij+N$?ML)0*1RcUqqGN`#ox0fe z4(||=*`Y2%w1fxoDCDuaND$dA?Al>TvEFE4i3#0$yvzIezIXV3CZ_d?Y3)Ia-6(Az zT2ANE*H=}6fUX62JzXP@!ZG`v`Oxgw7t|&FPUy9IAMZoAF(iF1mxtw%D9?IGpoWjR zi185*-EjC*4>?5nvWEc>o@_%Jlnu)9xf^Y`0#HtTc>HTS;)+u`7vX18I0$=_QBGkB z@-dr}6#5iW%2>SM3w?NQ+(!v=J{*XTE0L09vUM#DE09%A=zV~}?iwkW?1j+|oxL^E zFe)3y1wduztyXB8%qc4?tD4qu(q$`XWnUsh(I<-`_C3-Hg>4vd)?+_Ym0yHrwr1BT z>f|M`D9N!=+Qepw$!?NyRn{7cz2c8J(`mXkv}I(SX>a#DWrBwMkTb0Z~Uw;e)!L$ zQG;lV%KDJuG~3#eL`w|#YzrMvqEie)j35@^N{~y_zh@-p|J+IQ a&Lv&`eJ8uEXnQL=Rs^ep{vR!(cRvBT6!}X4 diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs index d37d24553867..3de30095f9bf 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs @@ -70,7 +70,11 @@ public static void Main(string[] args) #endif builder.Services.AddDatabaseDeveloperPageExceptionFilter(); - builder.Services.AddIdentityCore(options => options.SignIn.RequireConfirmedAccount = true) + builder.Services.AddIdentityCore(options => + { + options.SignIn.RequireConfirmedAccount = true; + options.Stores.SchemaVersion = IdentitySchemaVersion.Version3; + }) .AddEntityFrameworkStores() .AddSignInManager() .AddDefaultTokenProviders(); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.cs index 7ea8e5a50033..5d254ba4386b 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.cs @@ -21,21 +21,21 @@ builder.Services.AddRazorComponents(); #else builder.Services.AddRazorComponents() - #if (UseServer && UseWebAssembly && IndividualLocalAuth) +#if (UseServer && UseWebAssembly && IndividualLocalAuth) .AddInteractiveServerComponents() .AddInteractiveWebAssemblyComponents() .AddAuthenticationStateSerialization(); - #elif (UseServer && UseWebAssembly) +#elif (UseServer && UseWebAssembly) .AddInteractiveServerComponents() .AddInteractiveWebAssemblyComponents(); - #elif (UseServer) +#elif (UseServer) .AddInteractiveServerComponents(); - #elif (UseWebAssembly && IndividualLocalAuth) +#elif (UseWebAssembly && IndividualLocalAuth) .AddInteractiveWebAssemblyComponents() .AddAuthenticationStateSerialization(); - #elif (UseWebAssembly) +#elif (UseWebAssembly) .AddInteractiveWebAssemblyComponents(); - #endif +#endif #endif #if (IndividualLocalAuth) @@ -64,7 +64,11 @@ #endif builder.Services.AddDatabaseDeveloperPageExceptionFilter(); -builder.Services.AddIdentityCore(options => options.SignIn.RequireConfirmedAccount = true) +builder.Services.AddIdentityCore(options => + { + options.SignIn.RequireConfirmedAccount = true; + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; + }) .AddEntityFrameworkStores() .AddSignInManager() .AddDefaultTokenProviders(); diff --git a/src/Security/Authentication/Core/src/AuthenticationHandler.cs b/src/Security/Authentication/Core/src/AuthenticationHandler.cs index 242bee0c3424..2695e668cd57 100644 --- a/src/Security/Authentication/Core/src/AuthenticationHandler.cs +++ b/src/Security/Authentication/Core/src/AuthenticationHandler.cs @@ -205,7 +205,7 @@ protected string BuildRedirectUri(string targetPath) { var target = scheme ?? Options.ForwardDefaultSelector?.Invoke(Context) ?? Options.ForwardDefault; - // Prevent self targetting + // Prevent self targeting return string.Equals(target, Scheme.Name, StringComparison.Ordinal) ? null : target; From 8294e9ebeae05425dbc3be5e2db5b7a88a33315d Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 27 May 2025 10:56:00 -0700 Subject: [PATCH 02/31] Remove extra entry in Version.Details.xml --- eng/Version.Details.xml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index e7145153685a..ba525ba4439b 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -295,10 +295,6 @@ https://github.com/dotnet/dotnet a4d6fdc935d5da12efb00a0b3b693ff1439e0b41 - - https://github.com/dotnet/runtime - fa004fb5ce5ec9f99d1c3ba3adc48c9473cc8eaa - https://github.com/dotnet/dotnet From 725a30434dce625526d1b60a9497833703631336 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 27 May 2025 11:28:17 -0700 Subject: [PATCH 03/31] Fix Program.Main.cs --- .../content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs index 3de30095f9bf..5d9dc3d078aa 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Program.Main.cs @@ -73,7 +73,7 @@ public static void Main(string[] args) builder.Services.AddIdentityCore(options => { options.SignIn.RequireConfirmedAccount = true; - options.Stores.SchemaVersion = IdentitySchemaVersion.Version3; + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; }) .AddEntityFrameworkStores() .AddSignInManager() From f86515f35428a8b316af744500cc056486e8da7b Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 27 May 2025 15:31:00 -0700 Subject: [PATCH 04/31] Fix failing tests --- src/Framework/test/TestData.cs | 2 ++ src/Identity/EntityFrameworkCore/src/UserStore.cs | 4 ++-- .../EntityFrameworkCore/test/EF.Test/VersionTestDbContext.cs | 1 + src/Identity/Extensions.Core/src/PasskeyRequestOptions.cs | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Framework/test/TestData.cs b/src/Framework/test/TestData.cs index 9c052bda0e51..f11c878430c2 100644 --- a/src/Framework/test/TestData.cs +++ b/src/Framework/test/TestData.cs @@ -155,6 +155,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", @@ -306,6 +307,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/EntityFrameworkCore/src/UserStore.cs b/src/Identity/EntityFrameworkCore/src/UserStore.cs index 29c1b1493fc1..0165b2fc01e4 100644 --- a/src/Identity/EntityFrameworkCore/src/UserStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserStore.cs @@ -91,8 +91,7 @@ public UserStore(TContext context, IdentityErrorDescriber? describer = null) : b /// The type representing a user token. /// The type representing a role claim. public class UserStore : - UserStore>, - IProtectedUserStore + UserStore> where TUser : IdentityUser where TRole : IdentityRole where TContext : DbContext @@ -126,6 +125,7 @@ public UserStore(TContext context, IdentityErrorDescriber? describer = null) : b /// The type representing a user passkey. public class UserStore : UserStoreBase, + IProtectedUserStore, IUserPasskeyStore where TUser : IdentityUser where TRole : IdentityRole diff --git a/src/Identity/EntityFrameworkCore/test/EF.Test/VersionTestDbContext.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/VersionTestDbContext.cs index 0e0c29407052..959fbb142a46 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.Test/VersionTestDbContext.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/VersionTestDbContext.cs @@ -49,6 +49,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Ignore>(); + builder.Ignore>(); } else { diff --git a/src/Identity/Extensions.Core/src/PasskeyRequestOptions.cs b/src/Identity/Extensions.Core/src/PasskeyRequestOptions.cs index cb8acc86fb86..e1230f178462 100644 --- a/src/Identity/Extensions.Core/src/PasskeyRequestOptions.cs +++ b/src/Identity/Extensions.Core/src/PasskeyRequestOptions.cs @@ -17,7 +17,7 @@ namespace Microsoft.AspNetCore.Identity; /// /// See . /// -public class PasskeyRequestOptions(string? userId, string optionsJson) +public sealed class PasskeyRequestOptions(string? userId, string optionsJson) { private readonly string _optionsJson = optionsJson; From ee5e0076657c6633715534ac9a266031ae46d561 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 3 Jun 2025 14:49:44 -0700 Subject: [PATCH 05/31] Add passkey sample app + E2E tests --- AspNetCore.slnx | 1 + .../GenerateFiles/Directory.Build.targets.in | 4 +- .../Extensions.Core/src/UserManager.cs | 4 +- src/Identity/Identity.slnf | 1 + .../Components/App.razor | 20 +++ .../Components/Pages/Authenticated.razor | 18 +++ .../Components/Pages/Home.razor | 131 ++++++++++++++++ .../Components/Pages/NotFound.razor | 3 + .../Components/RedirectToHome.razor | 8 + .../Components/Routes.razor | 12 ++ .../Components/_Imports.razor | 9 ++ .../IdentitySample.PasskeyUI.csproj | 40 +++++ .../InMemoryUserStore.cs | 140 ++++++++++++++++++ .../IdentitySample.PasskeyUI/Program.cs | 116 +++++++++++++++ .../Properties/launchSettings.json | 23 +++ .../appsettings.Development.json | 8 + .../IdentitySample.PasskeyUI/appsettings.json | 9 ++ .../IdentitySample.PasskeyUI/wwwroot/app.css | 17 +++ .../IdentitySample.PasskeyUI/wwwroot/app.js | 105 +++++++++++++ .../TestInfrastructure/PrepareForTest.targets | 6 +- .../BlazorTemplateTest.cs | 101 +++++++++++-- .../BlazorWasmTemplateTest.cs | 4 +- .../BlazorWebTemplateTest.cs | 46 +++--- 23 files changed, 774 insertions(+), 52 deletions(-) create mode 100644 src/Identity/samples/IdentitySample.PasskeyUI/Components/App.razor create mode 100644 src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Authenticated.razor create mode 100644 src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor create mode 100644 src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/NotFound.razor create mode 100644 src/Identity/samples/IdentitySample.PasskeyUI/Components/RedirectToHome.razor create mode 100644 src/Identity/samples/IdentitySample.PasskeyUI/Components/Routes.razor create mode 100644 src/Identity/samples/IdentitySample.PasskeyUI/Components/_Imports.razor create mode 100644 src/Identity/samples/IdentitySample.PasskeyUI/IdentitySample.PasskeyUI.csproj create mode 100644 src/Identity/samples/IdentitySample.PasskeyUI/InMemoryUserStore.cs create mode 100644 src/Identity/samples/IdentitySample.PasskeyUI/Program.cs create mode 100644 src/Identity/samples/IdentitySample.PasskeyUI/Properties/launchSettings.json create mode 100644 src/Identity/samples/IdentitySample.PasskeyUI/appsettings.Development.json create mode 100644 src/Identity/samples/IdentitySample.PasskeyUI/appsettings.json create mode 100644 src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.css create mode 100644 src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.js diff --git a/AspNetCore.slnx b/AspNetCore.slnx index d2c15b537600..034399dd6960 100644 --- a/AspNetCore.slnx +++ b/AspNetCore.slnx @@ -393,6 +393,7 @@ + diff --git a/eng/tools/GenerateFiles/Directory.Build.targets.in b/eng/tools/GenerateFiles/Directory.Build.targets.in index c63b8b21c92d..2f5fb8ec7b39 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/Identity/Extensions.Core/src/UserManager.cs b/src/Identity/Extensions.Core/src/UserManager.cs index 66babb2ee600..18c85fb20c69 100644 --- a/src/Identity/Extensions.Core/src/UserManager.cs +++ b/src/Identity/Extensions.Core/src/UserManager.cs @@ -2229,7 +2229,7 @@ public virtual async Task GeneratePasskeyCreationOptions var challenge = GetRandomChallenge(Options.Passkey.ChallengeSize); var options = new PublicKeyCredentialCreationOptions(rpEntity, userEntity, BufferSource.FromBytes(challenge)) { - Timeout = (uint)Options.Passkey.Timeout.Milliseconds, + Timeout = (uint)Options.Passkey.Timeout.TotalMilliseconds, ExcludeCredentials = excludeCredentials, PubKeyCredParams = PublicKeyCredentialParameters.AllSupportedParameters, AuthenticatorSelection = creationArgs.AuthenticatorSelection, @@ -2279,7 +2279,7 @@ public virtual async Task GeneratePasskeyRequestOptionsAs var options = new PublicKeyCredentialRequestOptions(BufferSource.FromBytes(challenge)) { RpId = requestContext.Domain, - Timeout = (uint)Options.Passkey.Timeout.Milliseconds, + Timeout = (uint)Options.Passkey.Timeout.TotalMilliseconds, AllowCredentials = allowCredentials, }; if (requestArgs is not null) diff --git a/src/Identity/Identity.slnf b/src/Identity/Identity.slnf index ffeba99c52f5..81bcc742f00a 100644 --- a/src/Identity/Identity.slnf +++ b/src/Identity/Identity.slnf @@ -42,6 +42,7 @@ "src\\Identity\\samples\\IdentitySample.DefaultUI\\IdentitySample.DefaultUI.csproj", "src\\Identity\\samples\\IdentitySample.Mvc\\IdentitySample.Mvc.csproj", "src\\Identity\\samples\\IdentitySample.PasskeyConformance\\IdentitySample.PasskeyConformance.csproj", + "src\\Identity\\samples\\IdentitySample.PasskeyUI\\IdentitySample.PasskeyUI.csproj", "src\\Identity\\test\\Identity.FunctionalTests\\Microsoft.AspNetCore.Identity.FunctionalTests.csproj", "src\\Identity\\test\\Identity.Test\\Microsoft.AspNetCore.Identity.Test.csproj", "src\\Identity\\test\\InMemory.Test\\Microsoft.AspNetCore.Identity.InMemory.Test.csproj", diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/App.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/App.razor new file mode 100644 index 000000000000..0bd026cc6a0e --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/App.razor @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Authenticated.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Authenticated.razor new file mode 100644 index 000000000000..79d19ae4e32d --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Authenticated.razor @@ -0,0 +1,18 @@ +@page "/authenticated" + +@using Microsoft.AspNetCore.Authorization + +@attribute [Authorize] + +Authenticated + +

You are authenticated!

+ + +

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

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

Welcome!

+ +

Log in or register here

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

@statusMessage

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

Not Found

diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/RedirectToHome.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/RedirectToHome.razor new file mode 100644 index 000000000000..95d0281f7a1f --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/RedirectToHome.razor @@ -0,0 +1,8 @@ +@inject NavigationManager NavigationManager + +@code { + protected override void OnInitialized() + { + NavigationManager.NavigateTo("/"); + } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Routes.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Routes.razor new file mode 100644 index 000000000000..0499aaa02370 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Routes.razor @@ -0,0 +1,12 @@ +@inject NavigationManager NavigationManager + + + + + + + + + + + diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/_Imports.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/_Imports.razor new file mode 100644 index 000000000000..2c9c57bfc8b9 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/_Imports.razor @@ -0,0 +1,9 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/IdentitySample.PasskeyUI.csproj b/src/Identity/samples/IdentitySample.PasskeyUI/IdentitySample.PasskeyUI.csproj new file mode 100644 index 000000000000..92a0db3006a0 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/IdentitySample.PasskeyUI.csproj @@ -0,0 +1,40 @@ + + + + Passkey conformance testing for ASP.NET Core Identity + $(DefaultNetCoreTargetFramework) + false + enable + $(TS6385);$(NoWarn) + + + + + + + + + + + + + + + + + + + + + + + + + true + $(RepoRoot)src\Components\Web.JS\dist\Debug + $(RepoRoot)src\Components\Web.JS\dist\Release + + + + + diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/InMemoryUserStore.cs b/src/Identity/samples/IdentitySample.PasskeyUI/InMemoryUserStore.cs new file mode 100644 index 000000000000..cb98946b3436 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/InMemoryUserStore.cs @@ -0,0 +1,140 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.Test; + +namespace IdentitySample.PasskeyUI; + +public sealed class InMemoryUserStore : + IQueryableUserStore, + IUserPasskeyStore + where TUser : PocoUser +{ + private readonly Dictionary _users = []; + + public IQueryable Users => _users.Values.AsQueryable(); + + public Task CreateAsync(TUser user, CancellationToken cancellationToken) + { + _users[user.Id] = user; + return Task.FromResult(IdentityResult.Success); + } + + public Task DeleteAsync(TUser user, CancellationToken cancellationToken) + { + if (!_users.Remove(user.Id)) + { + throw new InvalidOperationException($"Unknown user with ID '{user.Id}'."); + } + return Task.FromResult(IdentityResult.Success); + } + + public Task FindByIdAsync(string userId, CancellationToken cancellationToken) + => Task.FromResult(_users.TryGetValue(userId, out var result) ? result : null); + + public Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken) + => Task.FromResult(_users.Values.FirstOrDefault(u => string.Equals(u.NormalizedUserName, normalizedUserName, StringComparison.Ordinal))); + + public Task FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken) + => Task.FromResult(_users.Values.FirstOrDefault(u => u.Passkeys.Any(p => p.CredentialId.SequenceEqual(credentialId)))); + + public Task FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + => Task.FromResult(ToUserPasskeyInfo(user.Passkeys.FirstOrDefault(p => p.CredentialId.SequenceEqual(credentialId)))); + + public Task GetNormalizedUserNameAsync(TUser user, CancellationToken cancellationToken) + => Task.FromResult(user.NormalizedUserName); + + public Task GetUserIdAsync(TUser user, CancellationToken cancellationToken) + => Task.FromResult(user.Id); + + public Task GetUserNameAsync(TUser user, CancellationToken cancellationToken) + => Task.FromResult(user.UserName); + + public Task SetNormalizedUserNameAsync(TUser user, string? normalizedName, CancellationToken cancellationToken) + { + user.NormalizedUserName = normalizedName; + return Task.CompletedTask; + } + + public Task SetUserNameAsync(TUser user, string? userName, CancellationToken cancellationToken) + { + user.UserName = userName; + return Task.CompletedTask; + } + + public Task UpdateAsync(TUser user, CancellationToken cancellationToken) + { + _users[user.Id] = user; + return Task.FromResult(IdentityResult.Success); + } + + public Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) + { + var passkeyEntity = user.Passkeys.FirstOrDefault(p => p.CredentialId.SequenceEqual(passkey.CredentialId)); + if (passkeyEntity is null) + { + user.Passkeys.Add(ToPocoUserPasskey(user, passkey)); + } + else + { + passkeyEntity.Name = passkey.Name; + passkeyEntity.SignCount = passkey.SignCount; + passkeyEntity.IsBackedUp = passkey.IsBackedUp; + passkeyEntity.IsUserVerified = passkey.IsUserVerified; + } + return Task.CompletedTask; + } + + public Task> GetPasskeysAsync(TUser user, CancellationToken cancellationToken) + => Task.FromResult>(user.Passkeys.Select(ToUserPasskeyInfo).ToList()!); + + public Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + { + var passkey = user.Passkeys.SingleOrDefault(p => p.CredentialId.SequenceEqual(credentialId)); + if (passkey is not null) + { + user.Passkeys.Remove(passkey); + } + + return Task.CompletedTask; + } + + [return: NotNullIfNotNull(nameof(p))] + private static UserPasskeyInfo? ToUserPasskeyInfo(PocoUserPasskey? p) + => p is null ? null : new( + p.CredentialId, + p.PublicKey, + p.Name, + p.CreatedAt, + p.SignCount, + p.Transports, + p.IsUserVerified, + p.IsBackupEligible, + p.IsBackedUp, + p.AttestationObject, + p.ClientDataJson); + + [return: NotNullIfNotNull(nameof(p))] + private static PocoUserPasskey? ToPocoUserPasskey(TUser user, UserPasskeyInfo? p) + => p is null ? null : new PocoUserPasskey + { + UserId = user.Id, + CredentialId = p.CredentialId, + PublicKey = p.PublicKey, + Name = p.Name, + CreatedAt = p.CreatedAt, + Transports = p.Transports, + SignCount = p.SignCount, + IsUserVerified = p.IsUserVerified, + IsBackupEligible = p.IsBackupEligible, + IsBackedUp = p.IsBackedUp, + AttestationObject = p.AttestationObject, + ClientDataJson = p.ClientDataJson, + }; + + public void Dispose() + { + } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Program.cs b/src/Identity/samples/IdentitySample.PasskeyUI/Program.cs new file mode 100644 index 000000000000..665f1d87f06e --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Program.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using IdentitySample.PasskeyUI; +using IdentitySample.PasskeyUI.Components; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.Test; +using Microsoft.AspNetCore.Mvc; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddRazorComponents(); +builder.Services.AddCascadingAuthenticationState(); + +builder.Services.AddAuthentication(options => + { + options.DefaultScheme = IdentityConstants.ApplicationScheme; + options.DefaultSignInScheme = IdentityConstants.ExternalScheme; + }) + .AddIdentityCookies(); + +builder.Services.ConfigureApplicationCookie(options => +{ + options.Events.OnRedirectToLogin = options => + { + options.HttpContext.Response.Redirect("/"); + return Task.CompletedTask; + }; +}); + +builder.Services.AddAuthorization(); + +builder.Services.AddIdentityCore() + .AddSignInManager() + .AddDefaultTokenProviders(); + +builder.Services.AddSingleton, InMemoryUserStore>(); +builder.Services.AddSingleton, InMemoryUserStore>(); + +var app = builder.Build(); + +app.UseHttpsRedirection(); +app.UseAntiforgery(); +app.MapStaticAssets(); +app.MapRazorComponents(); + +app.MapPost("attestation/options", async ( + [FromServices] UserManager userManager, + [FromServices] SignInManager signInManager, + [FromBody] PublicKeyCredentialCreationOptionsRequest request) => +{ + var userId = (await userManager.FindByNameAsync(request.Username) ?? new PocoUser()).Id; + var userEntity = new PasskeyUserEntity(userId, request.Username, null); + var creationArgs = new PasskeyCreationArgs(userEntity) + { + AuthenticatorSelection = request.AuthenticatorSelection, + Extensions = request.Extensions, + }; + + if (!string.IsNullOrEmpty(request.Attestation)) + { + creationArgs.Attestation = request.Attestation; + } + + var options = await signInManager.ConfigurePasskeyCreationOptionsAsync(creationArgs); + return Results.Content(options.AsJson(), contentType: "application/json"); +}); + +app.MapPost("assertion/options", async ( + [FromServices] UserManager userManager, + [FromServices] SignInManager signInManager, + [FromBody] PublicKeyCredentialGetOptionsRequest request) => +{ + var user = !string.IsNullOrEmpty(request.Username) + ? await userManager.FindByNameAsync(request.Username) + : null; + + var requestArgs = new PasskeyRequestArgs + { + User = user, + Extensions = request.Extensions, + }; + + if (!string.IsNullOrEmpty(request.UserVerification)) + { + requestArgs.UserVerification = request.UserVerification; + } + + var options = await signInManager.ConfigurePasskeyRequestOptionsAsync(requestArgs); + return Results.Content(options.AsJson(), contentType: "application/json"); +}); + +app.MapPost("account/logout", async ( + [FromServices] SignInManager signInManager) => +{ + await signInManager.SignOutAsync(); + return TypedResults.LocalRedirect($"~/"); +}); + +app.Run(); + +sealed class PublicKeyCredentialCreationOptionsRequest(string username) +{ + public string Username { get; } = username; + public AuthenticatorSelectionCriteria? AuthenticatorSelection { get; set; } + public JsonElement? Extensions { get; set; } + public string? Attestation { get; set; } = "none"; +} + +sealed class PublicKeyCredentialGetOptionsRequest +{ + public string? Username { get; set; } + public string? UserVerification { get; set; } + public JsonElement? Extensions { get; set; } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Properties/launchSettings.json b/src/Identity/samples/IdentitySample.PasskeyUI/Properties/launchSettings.json new file mode 100644 index 000000000000..2859cf29811d --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5021", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7021;http://localhost:5021", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/appsettings.Development.json b/src/Identity/samples/IdentitySample.PasskeyUI/appsettings.Development.json new file mode 100644 index 000000000000..0c208ae9181e --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/appsettings.json b/src/Identity/samples/IdentitySample.PasskeyUI/appsettings.json new file mode 100644 index 000000000000..10f68b8c8b4f --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.css b/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.css new file mode 100644 index 000000000000..0f16b65a571e --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.css @@ -0,0 +1,17 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +h1:focus { + outline: none; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.js b/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.js new file mode 100644 index 000000000000..025d9cc9a332 --- /dev/null +++ b/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.js @@ -0,0 +1,105 @@ +(function () { + // Following is a quick and dirty way to execute scripts based on the current route. + const routeScripts = {}; + + function addRouteScript(path, callback) { + routeScripts[path] = callback; + } + + function executeScript() { + const routeScript = routeScripts[location.pathname]; + routeScript?.(); + } + + function enableRouteScripts() { + Blazor.addEventListener('enhancednavigationend', executeScript); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', executeScript); + } else { + executeScript(); + } + } + + // Define home page JS functionality. + addRouteScript('/', () => { + const form = document.getElementById('auth-form'); + const usernameInput = document.getElementById('input-username'); + const credentialInput = document.getElementById('input-credential'); + const actionInput = document.getElementById('input-action'); + const registerInput = document.getElementById('input-register'); + const authenticateInput = document.getElementById('input-authenticate'); + const statusMessage = document.getElementById('status-message'); + + async function submitCredential(action, credentialCallback) { + statusMessage.textContent = 'Submitting...'; + try { + var credential = await credentialCallback(); + var credentialJson = JSON.stringify(credential); + credentialInput.value = credentialJson; + actionInput.value = action; + form.submit(); + } catch (error) { + statusMessage.textContent = 'Error: ' + error.message; + throw error; + } + } + + registerInput.addEventListener('click', async (e) => { + e.preventDefault(); + + await submitCredential('register', async () => { + const username = usernameInput.value; + if (!username) { + throw new Error('Please enter a username.'); + } + + const optionsResponse = await fetch('/attestation/options', { + method: 'POST', + body: JSON.stringify({ + username, + authenticatorSelection: { + residentKey: 'preferred', + } + // TODO: Allow configuration of other options. + }), + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }); + const optionsJson = await optionsResponse.json(); + const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson); + const credential = await navigator.credentials.create({ publicKey: options }); + return credential; + }); + }); + + authenticateInput.addEventListener('click', async (e) => { + e.preventDefault(); + + await submitCredential('authenticate', async () => { + // The username is optional for authentication, so we don't validate it here. + const username = usernameInput.value; + + const optionsResponse = await fetch('/assertion/options', { + method: 'POST', + body: JSON.stringify({ + username, + // TODO: Allow configuration of other options. + }), + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }); + const optionsJson = await optionsResponse.json(); + const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson); + const credential = await navigator.credentials.get({ publicKey: options }); + return credential; + }); + }); + }); + + enableRouteScripts(); +})(); diff --git a/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets b/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets index 4868bb529620..1d4a655b7c61 100644 --- a/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets +++ b/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets @@ -30,7 +30,7 @@
- + <_DevCertFileName>aspnetcore-https.pfx @@ -49,8 +49,8 @@ - - + + diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs index 60d372d34dee..c655e6c26fe7 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs @@ -31,6 +31,7 @@ protected async Task CreateBuildPublishAsync( { // Additional arguments are needed. See: https://github.com/dotnet/aspnetcore/issues/24278 Environment.SetEnvironmentVariable("EnableDefaultScopedCssItems", "true"); + Environment.SetEnvironmentVariable("AllowMissingPrunePackageData", "true"); var project = await ProjectFactory.CreateProject(Output); if (targetFramework != null) @@ -86,7 +87,7 @@ protected async Task TestBasicInteractionInNewPageAsync( string listeningUri, string appName, BlazorTemplatePages pagesToExclude = BlazorTemplatePages.None, - bool usesAuth = false) + AuthenticationFeatures authenticationFeatures = AuthenticationFeatures.None) { if (!BrowserManager.IsAvailable(browserKind)) { @@ -100,16 +101,17 @@ protected async Task TestBasicInteractionInNewPageAsync( Output.WriteLine($"Opening browser at {listeningUri}..."); await page.GotoAsync(listeningUri, new() { WaitUntil = WaitUntilState.NetworkIdle }); - await TestBasicInteractionAsync(page, appName, pagesToExclude, usesAuth); + await TestBasicInteractionAsync(browser, page, appName, pagesToExclude, authenticationFeatures); await page.CloseAsync(); } protected async Task TestBasicInteractionAsync( + IBrowserContext browser, IPage page, string appName, BlazorTemplatePages pagesToExclude = BlazorTemplatePages.None, - bool usesAuth = false) + AuthenticationFeatures authenticationFeatures = AuthenticationFeatures.None) { await page.WaitForSelectorAsync("nav"); @@ -134,17 +136,18 @@ await Task.WhenAll( await IncrementCounterAsync(page); } - if (usesAuth) + if (authenticationFeatures.HasFlag(AuthenticationFeatures.Basic)) { await Task.WhenAll( - page.WaitForURLAsync("**/Identity/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), - page.ClickAsync("text=Log in")); + page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Login")); await Task.WhenAll( - page.WaitForSelectorAsync("[name=\"Input.Email\"]"), - page.WaitForURLAsync("**/Identity/Account/Register**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.WaitForURLAsync("**/Account/Register**", new() { WaitUntil = WaitUntilState.NetworkIdle }), page.ClickAsync("text=Register as a new user")); + await page.WaitForSelectorAsync("text=Create a new account."); + var userName = $"{Guid.NewGuid()}@example.com"; var password = "[PLACEHOLDER]-1a"; @@ -154,12 +157,12 @@ await Task.WhenAll( // We will be redirected to the RegisterConfirmation await Task.WhenAll( - page.WaitForURLAsync("**/Identity/Account/RegisterConfirmation**", new() { WaitUntil = WaitUntilState.NetworkIdle }), - page.ClickAsync("#registerSubmit")); + page.WaitForURLAsync("**/Account/RegisterConfirmation**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("button[type=\"submit\"]")); // We will be redirected to the ConfirmEmail await Task.WhenAll( - page.WaitForURLAsync("**/Identity/Account/ConfirmEmail**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.WaitForURLAsync("**/Account/ConfirmEmail**", new() { WaitUntil = WaitUntilState.NetworkIdle }), page.ClickAsync("text=Click here to confirm your account")); // Now we can login @@ -167,11 +170,71 @@ await Task.WhenAll( await page.WaitForSelectorAsync("[name=\"Input.Email\"]"); await page.FillAsync("[name=\"Input.Email\"]", userName); await page.FillAsync("[name=\"Input.Password\"]", password); - await page.ClickAsync("#login-submit"); + await page.ClickAsync("button[type=\"submit\"]"); + + // Verify that we can visit the "Auth Required" page + await Task.WhenAll( + page.WaitForURLAsync("**/auth", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Auth Required")); + await page.WaitForSelectorAsync("text=You are authenticated"); + + if (authenticationFeatures.HasFlag(AuthenticationFeatures.Passkeys)) + { + // Start a new CDP session with WebAuthn enabled and add a virtual authenticator + await using var cdpSession = await browser.NewCDPSessionAsync(page); + await cdpSession.SendAsync("WebAuthn.enable"); + var result = await cdpSession.SendAsync("WebAuthn.addVirtualAuthenticator", new Dictionary + { + ["options"] = new + { + protocol = "ctap2", + transport = "internal", + hasResidentKey = false, + hasUserIdentification = true, + isUserVerified = true, + automaticPresenceSimulation = true, + } + }); + + Assert.True(result.HasValue); + var authenticatorId = result.Value.GetProperty("authenticatorId").GetString(); + + // Navigate to the passkey management page + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Manage**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("a[href=\"Account/Manage\"]")); + + await page.WaitForSelectorAsync("text=Manage your account"); + + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Manage/Passkeys**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("a[href=\"Account/Manage/Passkeys\"]")); + + // Register a new passkey + await page.ClickAsync("text=Add a new passkey"); - // Need to navigate to fetch page - await page.GotoAsync(new Uri(page.Url).GetLeftPart(UriPartial.Authority)); - Assert.Equal(appName.Trim(), (await page.TitleAsync()).Trim()); + await page.WaitForSelectorAsync("text=Enter a name for your passkey"); + await page.FillAsync("[name=\"RenameInput.DisplayName\"]", "My passkey"); + await page.ClickAsync("text=Continue"); + + await page.WaitForSelectorAsync("text=Passkey updated successfully"); + + // Login with the passkey + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Logout")); + + await page.WaitForSelectorAsync("[name=\"Input.Email\"]"); + await page.FillAsync("[name=\"Input.Email\"]", userName); + + await page.ClickAsync("text=Log in with a passkey"); + + // Verify that we can visit the "Auth Required" page + await Task.WhenAll( + page.WaitForURLAsync("**/auth", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Auth Required")); + await page.WaitForSelectorAsync("text=You are authenticated"); + } } if (!pagesToExclude.HasFlag(BlazorTemplatePages.Weather)) @@ -232,4 +295,12 @@ protected enum BlazorTemplatePages Weather = 4, All = ~0, } + + [Flags] + protected enum AuthenticationFeatures + { + None = 0, + Basic = 1, + Passkeys = 2, + } } diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWasmTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWasmTemplateTest.cs index 69269843c9ba..d74b67207a5c 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWasmTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWasmTemplateTest.cs @@ -78,7 +78,7 @@ public async Task BlazorWasmStandalonePwaTemplate_Works(BrowserKind browserKind) await page.GotoAsync(listeningUri, new() { WaitUntil = WaitUntilState.NetworkIdle }); using (serveProcess) { - await TestBasicInteractionAsync(page, project.ProjectName); + await TestBasicInteractionAsync(browser, page, project.ProjectName); } // The PWA template supports offline use. By now, the browser should have cached everything it needs, @@ -86,7 +86,7 @@ public async Task BlazorWasmStandalonePwaTemplate_Works(BrowserKind browserKind) await page.GotoAsync("about:blank"); await browser.SetOfflineAsync(true); await page.GotoAsync(listeningUri); - await TestBasicInteractionAsync(page, project.ProjectName, pagesToExclude: BlazorTemplatePages.Weather); + await TestBasicInteractionAsync(browser, page, project.ProjectName, pagesToExclude: BlazorTemplatePages.Weather); await page.CloseAsync(); } else diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs index dc2cf5b63982..3d81b47ce311 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs @@ -10,20 +10,21 @@ namespace BlazorTemplates.Tests; -public class BlazorWebTemplateTest(ProjectFactoryFixture projectFactory) : BlazorTemplateTest(projectFactory) +public class BlazorWebTemplateTest(ProjectFactoryFixture projectFactory) : BlazorTemplateTest(projectFactory), IClassFixture { public override string ProjectType => "blazor"; - [ConditionalTheory] - [SkipNonHelix] + [Theory] [InlineData(BrowserKind.Chromium, "None")] [InlineData(BrowserKind.Chromium, "Server")] [InlineData(BrowserKind.Chromium, "WebAssembly")] [InlineData(BrowserKind.Chromium, "Auto")] - public async Task BlazorWebTemplate_Works(BrowserKind browserKind, string interactivityOption) + [InlineData(BrowserKind.Chromium, "None", "Individual")] + [InlineData(BrowserKind.Chromium, "None", "Individual", true)] + public async Task BlazorWebTemplate_Works(BrowserKind browserKind, string interactivityOption, string authOption = "None", bool testPasskeys = false) { var project = await CreateBuildPublishAsync( - args: ["-int", interactivityOption], + args: ["-int", interactivityOption, "-au", authOption], getTargetProject: GetTargetProject); // There won't be a counter page when the 'None' interactivity option is used @@ -31,6 +32,16 @@ public async Task BlazorWebTemplate_Works(BrowserKind browserKind, string intera ? BlazorTemplatePages.Counter : BlazorTemplatePages.None; + var authenticationFeatures = AuthenticationFeatures.None; + if (authOption is not "None") + { + authenticationFeatures |= AuthenticationFeatures.Basic; + } + if (testPasskeys) + { + authenticationFeatures |= AuthenticationFeatures.Passkeys; + } + var appName = project.ProjectName; // Test the built project @@ -41,7 +52,7 @@ public async Task BlazorWebTemplate_Works(BrowserKind browserKind, string intera ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", project, aspNetProcess.Process)); await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); - await TestBasicInteractionInNewPageAsync(browserKind, aspNetProcess.ListeningUri.AbsoluteUri, appName, pagesToExclude); + await TestBasicInteractionInNewPageAsync(browserKind, aspNetProcess.ListeningUri.AbsoluteUri, appName, pagesToExclude, authenticationFeatures); } // Test the published project @@ -52,13 +63,7 @@ public async Task BlazorWebTemplate_Works(BrowserKind browserKind, string intera ErrorMessages.GetFailedProcessMessageOrEmpty("Run published project", project, aspNetProcess.Process)); await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); - - if (HasClientProject()) - { - await AssertWebAssemblyCompressionFormatAsync(aspNetProcess, "br"); - } - - await TestBasicInteractionInNewPageAsync(browserKind, aspNetProcess.ListeningUri.AbsoluteUri, appName, pagesToExclude); + await TestBasicInteractionInNewPageAsync(browserKind, aspNetProcess.ListeningUri.AbsoluteUri, appName, pagesToExclude, authenticationFeatures); } bool HasClientProject() @@ -77,19 +82,4 @@ Project GetTargetProject(Project rootProject) return rootProject; } } - - private static async Task AssertWebAssemblyCompressionFormatAsync(AspNetProcess aspNetProcess, string expectedEncoding) - { - var response = await aspNetProcess.SendRequest(() => - { - var request = new HttpRequestMessage(HttpMethod.Get, new Uri(aspNetProcess.ListeningUri, "/_framework/blazor.boot.json")); - // These are the same as chrome - request.Headers.AcceptEncoding.Clear(); - request.Headers.AcceptEncoding.Add(StringWithQualityHeaderValue.Parse("gzip")); - request.Headers.AcceptEncoding.Add(StringWithQualityHeaderValue.Parse("deflate")); - request.Headers.AcceptEncoding.Add(StringWithQualityHeaderValue.Parse("br")); - return request; - }); - Assert.Equal(expectedEncoding, response.Content.Headers.ContentEncoding.Single()); - } } From e23320cbdfd5abac4e2c686a642853c50d5b2cb7 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 3 Jun 2025 15:08:57 -0700 Subject: [PATCH 06/31] Correctly specify transports in generated credential options --- src/Identity/Extensions.Core/src/UserManager.cs | 4 ++-- .../Components/Pages/Home.razor | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Identity/Extensions.Core/src/UserManager.cs b/src/Identity/Extensions.Core/src/UserManager.cs index 18c85fb20c69..eab3072f26ae 100644 --- a/src/Identity/Extensions.Core/src/UserManager.cs +++ b/src/Identity/Extensions.Core/src/UserManager.cs @@ -2252,7 +2252,7 @@ async Task GetExcludeCredentialsAsync() var excludeCredentials = passkeys .Select(p => new PublicKeyCredentialDescriptor(type: "public-key", id: BufferSource.FromBytes(p.CredentialId)) { - Transports = [] // TODO: Consider making this configurable. + Transports = p.Transports ?? [], }); return [.. excludeCredentials]; } @@ -2305,7 +2305,7 @@ async Task GetAllowCredentialsAsync() var allowCredentials = passkeys .Select(p => new PublicKeyCredentialDescriptor(type: "public-key", id: BufferSource.FromBytes(p.CredentialId)) { - Transports = [] // TODO: Consider making this configurable. + Transports = p.Transports ?? [], }); return [.. allowCredentials]; } diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor index 08da10bf3244..a30497082f6f 100644 --- a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor @@ -11,6 +11,19 @@

Welcome!

+

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

+ +

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

+ +

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

+

Log in or register here

From 5d3b8644ee219c8736f055e145300f40f6ec23a4 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 5 Jun 2025 11:00:00 -0400 Subject: [PATCH 07/31] Undo changes to Components.slnf --- src/Components/Components.slnf | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/Components/Components.slnf b/src/Components/Components.slnf index af7dca18742d..cc79f35b9b88 100644 --- a/src/Components/Components.slnf +++ b/src/Components/Components.slnf @@ -83,10 +83,8 @@ "src\\Http\\Authentication.Core\\src\\Microsoft.AspNetCore.Authentication.Core.csproj", "src\\Http\\Headers\\src\\Microsoft.Net.Http.Headers.csproj", "src\\Http\\Http.Abstractions\\src\\Microsoft.AspNetCore.Http.Abstractions.csproj", - "src\\Http\\Http.Extensions\\gen\\Microsoft.AspNetCore.Http.RequestDelegateGenerator\\Microsoft.AspNetCore.Http.RequestDelegateGenerator.csproj", "src\\Http\\Http.Extensions\\src\\Microsoft.AspNetCore.Http.Extensions.csproj", "src\\Http\\Http.Features\\src\\Microsoft.AspNetCore.Http.Features.csproj", - "src\\Http\\Http.Results\\src\\Microsoft.AspNetCore.Http.Results.csproj", "src\\Http\\Http\\src\\Microsoft.AspNetCore.Http.csproj", "src\\Http\\Metadata\\src\\Microsoft.AspNetCore.Metadata.csproj", "src\\Http\\Routing.Abstractions\\src\\Microsoft.AspNetCore.Routing.Abstractions.csproj", @@ -102,16 +100,13 @@ "src\\Localization\\Localization\\src\\Microsoft.Extensions.Localization.csproj", "src\\Middleware\\CORS\\src\\Microsoft.AspNetCore.Cors.csproj", "src\\Middleware\\Diagnostics.Abstractions\\src\\Microsoft.AspNetCore.Diagnostics.Abstractions.csproj", - "src\\Middleware\\Diagnostics.EntityFrameworkCore\\src\\Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore.csproj", "src\\Middleware\\Diagnostics\\src\\Microsoft.AspNetCore.Diagnostics.csproj", "src\\Middleware\\HostFiltering\\src\\Microsoft.AspNetCore.HostFiltering.csproj", "src\\Middleware\\HttpOverrides\\src\\Microsoft.AspNetCore.HttpOverrides.csproj", "src\\Middleware\\HttpsPolicy\\src\\Microsoft.AspNetCore.HttpsPolicy.csproj", "src\\Middleware\\Localization\\src\\Microsoft.AspNetCore.Localization.csproj", - "src\\Middleware\\OutputCaching\\src\\Microsoft.AspNetCore.OutputCaching.csproj", "src\\Middleware\\ResponseCaching.Abstractions\\src\\Microsoft.AspNetCore.ResponseCaching.Abstractions.csproj", "src\\Middleware\\ResponseCompression\\src\\Microsoft.AspNetCore.ResponseCompression.csproj", - "src\\Middleware\\Session\\src\\Microsoft.AspNetCore.Session.csproj", "src\\Middleware\\StaticFiles\\src\\Microsoft.AspNetCore.StaticFiles.csproj", "src\\Middleware\\WebSockets\\src\\Microsoft.AspNetCore.WebSockets.csproj", "src\\Mvc\\Mvc.Abstractions\\src\\Microsoft.AspNetCore.Mvc.Abstractions.csproj", @@ -131,14 +126,12 @@ "src\\ObjectPool\\src\\Microsoft.Extensions.ObjectPool.csproj", "src\\Razor\\Razor.Runtime\\src\\Microsoft.AspNetCore.Razor.Runtime.csproj", "src\\Razor\\Razor\\src\\Microsoft.AspNetCore.Razor.csproj", - "src\\Security\\Authentication\\BearerToken\\src\\Microsoft.AspNetCore.Authentication.BearerToken.csproj", "src\\Security\\Authentication\\Cookies\\src\\Microsoft.AspNetCore.Authentication.Cookies.csproj", "src\\Security\\Authentication\\Core\\src\\Microsoft.AspNetCore.Authentication.csproj", "src\\Security\\Authorization\\Core\\src\\Microsoft.AspNetCore.Authorization.csproj", "src\\Security\\Authorization\\Policy\\src\\Microsoft.AspNetCore.Authorization.Policy.csproj", "src\\Security\\CookiePolicy\\src\\Microsoft.AspNetCore.CookiePolicy.csproj", "src\\Servers\\Connections.Abstractions\\src\\Microsoft.AspNetCore.Connections.Abstractions.csproj", - "src\\Servers\\HttpSys\\src\\Microsoft.AspNetCore.Server.HttpSys.csproj", "src\\Servers\\IIS\\IISIntegration\\src\\Microsoft.AspNetCore.Server.IISIntegration.csproj", "src\\Servers\\IIS\\IIS\\src\\Microsoft.AspNetCore.Server.IIS.csproj", "src\\Servers\\Kestrel\\Core\\src\\Microsoft.AspNetCore.Server.Kestrel.Core.csproj", From af27faf878511323471332a554fbb5df61f6f02b Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 5 Jun 2025 17:23:29 -0400 Subject: [PATCH 08/31] Move core passkey implementation to Microsoft.AspNetCore.Identity --- .../src/DefaultPasskeyHandler.cs | 48 +---- .../src/DefaultPasskeyOriginValidator.cs | 31 +-- src/Identity/Core/src/EventIds.cs | 22 +- .../src/HttpPasskeyRequestContextProvider.cs | 25 --- .../IPasskeyAttestationStatementVerifier.cs | 6 - .../src/IPasskeyHandler.cs | 6 - .../src/IPasskeyOriginValidator.cs | 6 - .../Core/src/IdentityBuilderExtensions.cs | 4 +- .../src/IdentityJsonSerializerContext.cs | 0 .../IdentityServiceCollectionExtensions.cs | 2 +- .../src/Microsoft.AspNetCore.Identity.csproj | 2 + ...NoOpPasskeyAttestationStatementVerifier.cs | 10 + .../src/PasskeyAssertionResult.cs | 10 +- .../src/PasskeyAttestationResult.cs | 8 +- .../src/PasskeyCreationArgs.cs | 6 - .../src/PasskeyCreationOptions.cs | 6 - .../src/PasskeyException.cs | 6 - .../src/PasskeyExceptionExtensions.cs | 7 - .../src/PasskeyOriginInfo.cs | 6 - .../src/PasskeyRequestArgs.cs | 5 - .../src/PasskeyRequestContext.cs | 6 - .../src/PasskeyRequestOptions.cs | 6 - .../src/PasskeyUserEntity.cs | 6 - .../src/Passkeys/AttestationObject.cs | 5 - .../src/Passkeys/AttestedCredentialData.cs | 5 - .../AuthenticatorAssertionResponse.cs | 6 - .../AuthenticatorAttestationResponse.cs | 6 - .../src/Passkeys/AuthenticatorData.cs | 5 - .../src/Passkeys/AuthenticatorDataFlags.cs | 6 - .../src/Passkeys/AuthenticatorResponse.cs | 7 - .../src/Passkeys/BufferSource.cs | 46 +--- .../src/Passkeys/BufferSourceJsonConverter.cs | 93 ++++++++ .../src/Passkeys/COSEAlgorithmIdentifier.cs | 6 - .../src/Passkeys/CollectedClientData.cs | 6 - .../src/Passkeys/CredentialPublicKey.cs | 99 ++++----- .../src/Passkeys/Ctap2CborReader.cs | 3 - .../src/Passkeys/PublicKeyCredential.cs | 6 - .../PublicKeyCredentialCreationOptions.cs | 4 - .../Passkeys/PublicKeyCredentialDescriptor.cs | 7 - .../Passkeys/PublicKeyCredentialParameters.cs | 16 -- .../PublicKeyCredentialRequestOptions.cs | 6 - .../Passkeys/PublicKeyCredentialRpEntity.cs | 6 - .../Passkeys/PublicKeyCredentialUserEntity.cs | 7 - .../src/Passkeys/TokenBinding.cs | 6 - src/Identity/Core/src/PublicAPI.Unshipped.txt | 76 +++++++ src/Identity/Core/src/SignInManager.cs | 192 ++++++++++++++++- .../AuthenticatorSelectionCriteria.cs | 4 - .../DefaultPasskeyRequestContextProvider.cs | 28 --- .../src/IPasskeyRequestContextProvider.cs | 21 -- .../IdentityServiceCollectionExtensions.cs | 3 - .../Extensions.Core/src/LoggerEventIds.cs | 2 - .../Microsoft.Extensions.Identity.Core.csproj | 4 - .../src/Passkeys/BufferSourceJsonConverter.cs | 35 --- .../src/PublicAPI.Unshipped.txt | 80 ------- .../Extensions.Core/src/UserManager.cs | 201 ------------------ .../Program.cs | 4 +- .../Components/Pages/Home.razor | 2 +- 57 files changed, 450 insertions(+), 787 deletions(-) rename src/Identity/{Extensions.Core => Core}/src/DefaultPasskeyHandler.cs (94%) rename src/Identity/{Extensions.Core => Core}/src/DefaultPasskeyOriginValidator.cs (64%) delete mode 100644 src/Identity/Core/src/HttpPasskeyRequestContextProvider.cs rename src/Identity/{Extensions.Core => Core}/src/IPasskeyAttestationStatementVerifier.cs (90%) rename src/Identity/{Extensions.Core => Core}/src/IPasskeyHandler.cs (94%) rename src/Identity/{Extensions.Core => Core}/src/IPasskeyOriginValidator.cs (84%) rename src/Identity/{Extensions.Core => Core}/src/IdentityJsonSerializerContext.cs (100%) create mode 100644 src/Identity/Core/src/NoOpPasskeyAttestationStatementVerifier.cs rename src/Identity/{Extensions.Core => Core}/src/PasskeyAssertionResult.cs (91%) rename src/Identity/{Extensions.Core => Core}/src/PasskeyAttestationResult.cs (90%) rename src/Identity/{Extensions.Core => Core}/src/PasskeyCreationArgs.cs (91%) rename src/Identity/{Extensions.Core => Core}/src/PasskeyCreationOptions.cs (93%) rename src/Identity/{Extensions.Core => Core}/src/PasskeyException.cs (86%) rename src/Identity/{Extensions.Core => Core}/src/PasskeyExceptionExtensions.cs (97%) rename src/Identity/{Extensions.Core => Core}/src/PasskeyOriginInfo.cs (88%) rename src/Identity/{Extensions.Core => Core}/src/PasskeyRequestArgs.cs (92%) rename src/Identity/{Extensions.Core => Core}/src/PasskeyRequestContext.cs (82%) rename src/Identity/{Extensions.Core => Core}/src/PasskeyRequestOptions.cs (93%) rename src/Identity/{Extensions.Core => Core}/src/PasskeyUserEntity.cs (89%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/AttestationObject.cs (94%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/AttestedCredentialData.cs (95%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/AuthenticatorAssertionResponse.cs (90%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/AuthenticatorAttestationResponse.cs (90%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/AuthenticatorData.cs (97%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/AuthenticatorDataFlags.cs (91%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/AuthenticatorResponse.cs (80%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/BufferSource.cs (69%) create mode 100644 src/Identity/Core/src/Passkeys/BufferSourceJsonConverter.cs rename src/Identity/{Extensions.Core => Core}/src/Passkeys/COSEAlgorithmIdentifier.cs (84%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/CollectedClientData.cs (92%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/CredentialPublicKey.cs (68%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/Ctap2CborReader.cs (96%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/PublicKeyCredential.cs (90%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/PublicKeyCredentialCreationOptions.cs (96%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/PublicKeyCredentialDescriptor.cs (85%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/PublicKeyCredentialParameters.cs (78%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/PublicKeyCredentialRequestOptions.cs (91%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/PublicKeyCredentialRpEntity.cs (87%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/PublicKeyCredentialUserEntity.cs (86%) rename src/Identity/{Extensions.Core => Core}/src/Passkeys/TokenBinding.cs (89%) rename src/Identity/Extensions.Core/src/{Passkeys => }/AuthenticatorSelectionCriteria.cs (94%) delete mode 100644 src/Identity/Extensions.Core/src/DefaultPasskeyRequestContextProvider.cs delete mode 100644 src/Identity/Extensions.Core/src/IPasskeyRequestContextProvider.cs delete mode 100644 src/Identity/Extensions.Core/src/Passkeys/BufferSourceJsonConverter.cs diff --git a/src/Identity/Extensions.Core/src/DefaultPasskeyHandler.cs b/src/Identity/Core/src/DefaultPasskeyHandler.cs similarity index 94% rename from src/Identity/Extensions.Core/src/DefaultPasskeyHandler.cs rename to src/Identity/Core/src/DefaultPasskeyHandler.cs index 44daa15d5b9f..c40c8eea0848 100644 --- a/src/Identity/Extensions.Core/src/DefaultPasskeyHandler.cs +++ b/src/Identity/Core/src/DefaultPasskeyHandler.cs @@ -1,16 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Buffers.Binary; -using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.Json; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Identity; @@ -22,7 +17,7 @@ public sealed partial class DefaultPasskeyHandler : IPasskeyHandler @@ -34,7 +29,7 @@ public sealed partial class DefaultPasskeyHandler : IPasskeyHandler options, IPasskeyOriginValidator originValidator, - IPasskeyAttestationStatementVerifier? attestationStatementVerifier = null) + IPasskeyAttestationStatementVerifier attestationStatementVerifier) { _originValidator = originValidator; _attestationStatementVerifier = attestationStatementVerifier; @@ -151,7 +146,7 @@ private async Task PerformAttestationCoreAsync( } // 12. Let clientDataHash be the result of computing a hash over response.clientDataJSON using SHA-256. - var clientDataHash = ComputeSHA256Hash(response.ClientDataJSON); + 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. @@ -166,7 +161,7 @@ private async Task PerformAttestationCoreAsync( } // 14. Verify that the rpIdHash in authenticatorData is the SHA-256 hash of the RP ID expected by the Relying Party. - var rpIdHash = ComputeSHA256Hash(Encoding.UTF8.GetBytes(originalOptions.Rp.Id ?? string.Empty)); + var rpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(originalOptions.Rp.Id ?? string.Empty)); if (!authenticatorData.RpIdHash.Span.SequenceEqual(rpIdHash.AsSpan())) { throw PasskeyException.InvalidRelyingPartyIDHash(); @@ -230,14 +225,11 @@ private async Task PerformAttestationCoreAsync( // 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... - if (_attestationStatementVerifier is not null) + // Handles all validation related to the attestation statement (21-24). + var isAttestationStatementValid = await _attestationStatementVerifier.VerifyAsync(attestationObjectMemory, clientDataHash).ConfigureAwait(false); + if (!isAttestationStatementValid) { - // Handles all validation related to the attestation statement (21-24). - var isAttestationStatementValid = await _attestationStatementVerifier.VerifyAsync(attestationObjectMemory, clientDataHash).ConfigureAwait(false); - if (!isAttestationStatementValid) - { - throw PasskeyException.InvalidAttestationStatement(); - } + throw PasskeyException.InvalidAttestationStatement(); } // 25. Verify that the credentialId is <= 1023 bytes. @@ -410,7 +402,7 @@ private async Task> PerformAssertionCoreAsync( } // 15. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party. - var rpIdHash = ComputeSHA256Hash(Encoding.UTF8.GetBytes(originalOptions.RpId ?? string.Empty)); + var rpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(originalOptions.RpId ?? string.Empty)); if (!authenticatorData.RpIdHash.Span.SequenceEqual(rpIdHash.AsSpan())) { throw PasskeyException.InvalidRelyingPartyIDHash(); @@ -459,7 +451,7 @@ private async Task> PerformAssertionCoreAsync( } // 20. Let clientDataHash be the result of computing a hash over the cData using SHA-256. - var clientDataHash = ComputeSHA256Hash(response.ClientDataJSON); + 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]; @@ -502,24 +494,4 @@ private async Task> PerformAssertionCoreAsync( // 25. If all the above steps are successful, continue the authentication ceremony as appropriate. return PasskeyAssertionResult.Success(storedPasskey, user); } - - private static byte[] ComputeSHA256Hash(byte[] data) - { -#if NETCOREAPP - return SHA256.HashData(data); -#else - using var sha256 = SHA256.Create(); - return sha256.ComputeHash(data); -#endif - } - - private static byte[] ComputeSHA256Hash(BufferSource data) - { -#if NETCOREAPP - return SHA256.HashData(data.AsSpan()); -#else - using var sha256 = SHA256.Create(); - return sha256.ComputeHash(data.ToArray()); -#endif - } } diff --git a/src/Identity/Extensions.Core/src/DefaultPasskeyOriginValidator.cs b/src/Identity/Core/src/DefaultPasskeyOriginValidator.cs similarity index 64% rename from src/Identity/Extensions.Core/src/DefaultPasskeyOriginValidator.cs rename to src/Identity/Core/src/DefaultPasskeyOriginValidator.cs index 54b6dbcacf14..12bc9e50b3db 100644 --- a/src/Identity/Extensions.Core/src/DefaultPasskeyOriginValidator.cs +++ b/src/Identity/Core/src/DefaultPasskeyOriginValidator.cs @@ -1,36 +1,27 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Text; -using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Identity; -/// -/// The default passkey origin validator. -/// -public sealed class DefaultPasskeyOriginValidator : IPasskeyOriginValidator +internal class DefaultPasskeyOriginValidator : IPasskeyOriginValidator { - private readonly IPasskeyRequestContextProvider _requestContextProvider; + private readonly IHttpContextAccessor _httpContextAccessor; private readonly PasskeyOptions _options; - /// - /// Constructs a new . - /// public DefaultPasskeyOriginValidator( - IPasskeyRequestContextProvider requestContextProvider, + IHttpContextAccessor httpContextAccessor, IOptions options) { - _requestContextProvider = requestContextProvider; + ArgumentNullException.ThrowIfNull(httpContextAccessor); + ArgumentNullException.ThrowIfNull(options); + + _httpContextAccessor = httpContextAccessor; _options = options.Value.Passkey; } - /// public bool IsValidOrigin(PasskeyOriginInfo originInfo) { if (string.IsNullOrEmpty(originInfo.Origin)) @@ -59,12 +50,10 @@ public bool IsValidOrigin(PasskeyOriginInfo originInfo) } } - if (_options.AllowCurrentOrigin) + if (_options.AllowCurrentOrigin && _httpContextAccessor.HttpContext?.Request.Headers.Origin is [var origin]) { - var context = _requestContextProvider.Context; - // Uri.Equals correctly handles string comparands. - if (originUri.Equals(context.Origin)) + if (originUri.Equals(origin)) { return true; } diff --git a/src/Identity/Core/src/EventIds.cs b/src/Identity/Core/src/EventIds.cs index 55766f83482f..05c7e6c25d16 100644 --- a/src/Identity/Core/src/EventIds.cs +++ b/src/Identity/Core/src/EventIds.cs @@ -7,14 +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 EventId NoPasskeyCreationOptions = new EventId(6, "NoPasskeyCreationOptions"); - public static EventId UserDoesNotMatchPasskeyCreationOptions = new EventId(7, "UserDoesNotMatchPasskeyCreationOptions"); + 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/HttpPasskeyRequestContextProvider.cs b/src/Identity/Core/src/HttpPasskeyRequestContextProvider.cs deleted file mode 100644 index 75d57e41aff0..000000000000 --- a/src/Identity/Core/src/HttpPasskeyRequestContextProvider.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; - -namespace Microsoft.AspNetCore.Identity; - -internal sealed class HttpPasskeyRequestContextProvider(IHttpContextAccessor httpContextAccessor, IOptions options) : IPasskeyRequestContextProvider -{ - private PasskeyRequestContext? _context; - - public PasskeyRequestContext Context => _context ??= GetPasskeyRequestContext(); - - private PasskeyRequestContext GetPasskeyRequestContext() - { - var passkeyOptions = options.Value.Passkey; - var httpContext = httpContextAccessor.HttpContext; - return new() - { - Domain = passkeyOptions.ServerDomain ?? httpContext?.Request.Host.Host, - Origin = httpContext?.Request.Headers.Origin, - }; - } -} diff --git a/src/Identity/Extensions.Core/src/IPasskeyAttestationStatementVerifier.cs b/src/Identity/Core/src/IPasskeyAttestationStatementVerifier.cs similarity index 90% rename from src/Identity/Extensions.Core/src/IPasskeyAttestationStatementVerifier.cs rename to src/Identity/Core/src/IPasskeyAttestationStatementVerifier.cs index d3144a1b4da2..906e8d29ec3c 100644 --- a/src/Identity/Extensions.Core/src/IPasskeyAttestationStatementVerifier.cs +++ b/src/Identity/Core/src/IPasskeyAttestationStatementVerifier.cs @@ -1,12 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Extensions.Core/src/IPasskeyHandler.cs b/src/Identity/Core/src/IPasskeyHandler.cs similarity index 94% rename from src/Identity/Extensions.Core/src/IPasskeyHandler.cs rename to src/Identity/Core/src/IPasskeyHandler.cs index 964de8d65699..1a936eeff282 100644 --- a/src/Identity/Extensions.Core/src/IPasskeyHandler.cs +++ b/src/Identity/Core/src/IPasskeyHandler.cs @@ -1,12 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Extensions.Core/src/IPasskeyOriginValidator.cs b/src/Identity/Core/src/IPasskeyOriginValidator.cs similarity index 84% rename from src/Identity/Extensions.Core/src/IPasskeyOriginValidator.cs rename to src/Identity/Core/src/IPasskeyOriginValidator.cs index 5dd2707b31b5..be626c4be8f3 100644 --- a/src/Identity/Extensions.Core/src/IPasskeyOriginValidator.cs +++ b/src/Identity/Core/src/IPasskeyOriginValidator.cs @@ -1,12 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Core/src/IdentityBuilderExtensions.cs b/src/Identity/Core/src/IdentityBuilderExtensions.cs index 2aed6b55d67e..1c16853fd1bb 100644 --- a/src/Identity/Core/src/IdentityBuilderExtensions.cs +++ b/src/Identity/Core/src/IdentityBuilderExtensions.cs @@ -41,7 +41,9 @@ public static IdentityBuilder AddDefaultTokenProviders(this IdentityBuilder buil private static void AddSignInManagerDeps(this IdentityBuilder builder) { builder.Services.AddHttpContextAccessor(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + 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/Extensions.Core/src/IdentityJsonSerializerContext.cs b/src/Identity/Core/src/IdentityJsonSerializerContext.cs similarity index 100% rename from src/Identity/Extensions.Core/src/IdentityJsonSerializerContext.cs rename to src/Identity/Core/src/IdentityJsonSerializerContext.cs diff --git a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs index d86d128f4d9d..fb85b5856969 100644 --- a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs +++ b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs @@ -102,7 +102,7 @@ public static class IdentityServiceCollectionExtensions services.TryAddScoped>(); services.TryAddScoped, UserClaimsPrincipalFactory>(); services.TryAddScoped, DefaultUserConfirmation>(); - services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped, DefaultPasskeyHandler>(); services.TryAddScoped>(); diff --git a/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj b/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj index f3faa30a2af4..26426e690674 100644 --- a/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj +++ b/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj @@ -10,6 +10,7 @@ true true $(InterceptorsPreviewNamespaces);Microsoft.AspNetCore.Http.Generated + true @@ -22,6 +23,7 @@ + diff --git a/src/Identity/Core/src/NoOpPasskeyAttestationStatementVerifier.cs b/src/Identity/Core/src/NoOpPasskeyAttestationStatementVerifier.cs new file mode 100644 index 000000000000..5416117a3cff --- /dev/null +++ b/src/Identity/Core/src/NoOpPasskeyAttestationStatementVerifier.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity; + +internal sealed class NoOpPasskeyAttestationStatementVerifier : IPasskeyAttestationStatementVerifier +{ + public Task VerifyAsync(ReadOnlyMemory attestationObject, ReadOnlyMemory clientDataHash) + => Task.FromResult(true); +} diff --git a/src/Identity/Extensions.Core/src/PasskeyAssertionResult.cs b/src/Identity/Core/src/PasskeyAssertionResult.cs similarity index 91% rename from src/Identity/Extensions.Core/src/PasskeyAssertionResult.cs rename to src/Identity/Core/src/PasskeyAssertionResult.cs index 68ef566fdcf7..088ddf4797c5 100644 --- a/src/Identity/Extensions.Core/src/PasskeyAssertionResult.cs +++ b/src/Identity/Core/src/PasskeyAssertionResult.cs @@ -1,13 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Shared; namespace Microsoft.AspNetCore.Identity; @@ -68,8 +62,8 @@ public static class PasskeyAssertionResult public static PasskeyAssertionResult Success(UserPasskeyInfo passkey, TUser user) where TUser : class { - ArgumentNullThrowHelper.ThrowIfNull(passkey); - ArgumentNullThrowHelper.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(passkey); + ArgumentNullException.ThrowIfNull(user); return new PasskeyAssertionResult(passkey, user); } diff --git a/src/Identity/Extensions.Core/src/PasskeyAttestationResult.cs b/src/Identity/Core/src/PasskeyAttestationResult.cs similarity index 90% rename from src/Identity/Extensions.Core/src/PasskeyAttestationResult.cs rename to src/Identity/Core/src/PasskeyAttestationResult.cs index 7475d113a836..3034cb3d5c45 100644 --- a/src/Identity/Extensions.Core/src/PasskeyAttestationResult.cs +++ b/src/Identity/Core/src/PasskeyAttestationResult.cs @@ -1,13 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Shared; namespace Microsoft.AspNetCore.Identity; @@ -52,7 +46,7 @@ private PasskeyAttestationResult(PasskeyException failure) /// A instance representing a successful attestation. public static PasskeyAttestationResult Success(UserPasskeyInfo passkey) { - ArgumentNullThrowHelper.ThrowIfNull(passkey); + ArgumentNullException.ThrowIfNull(passkey); return new PasskeyAttestationResult(passkey); } diff --git a/src/Identity/Extensions.Core/src/PasskeyCreationArgs.cs b/src/Identity/Core/src/PasskeyCreationArgs.cs similarity index 91% rename from src/Identity/Extensions.Core/src/PasskeyCreationArgs.cs rename to src/Identity/Core/src/PasskeyCreationArgs.cs index a7bda440e002..9db4f97ac269 100644 --- a/src/Identity/Extensions.Core/src/PasskeyCreationArgs.cs +++ b/src/Identity/Core/src/PasskeyCreationArgs.cs @@ -1,13 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Text.Json; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Identity; namespace Microsoft.AspNetCore.Identity; diff --git a/src/Identity/Extensions.Core/src/PasskeyCreationOptions.cs b/src/Identity/Core/src/PasskeyCreationOptions.cs similarity index 93% rename from src/Identity/Extensions.Core/src/PasskeyCreationOptions.cs rename to src/Identity/Core/src/PasskeyCreationOptions.cs index b82ef7dc8b61..f784b0afe461 100644 --- a/src/Identity/Extensions.Core/src/PasskeyCreationOptions.cs +++ b/src/Identity/Core/src/PasskeyCreationOptions.cs @@ -1,12 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Extensions.Core/src/PasskeyException.cs b/src/Identity/Core/src/PasskeyException.cs similarity index 86% rename from src/Identity/Extensions.Core/src/PasskeyException.cs rename to src/Identity/Core/src/PasskeyException.cs index e4c003c61558..750f00bc203e 100644 --- a/src/Identity/Extensions.Core/src/PasskeyException.cs +++ b/src/Identity/Core/src/PasskeyException.cs @@ -1,12 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Extensions.Core/src/PasskeyExceptionExtensions.cs b/src/Identity/Core/src/PasskeyExceptionExtensions.cs similarity index 97% rename from src/Identity/Extensions.Core/src/PasskeyExceptionExtensions.cs rename to src/Identity/Core/src/PasskeyExceptionExtensions.cs index 48ca8c96cdfd..25e05e85b87b 100644 --- a/src/Identity/Extensions.Core/src/PasskeyExceptionExtensions.cs +++ b/src/Identity/Core/src/PasskeyExceptionExtensions.cs @@ -1,13 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; internal static class PasskeyExceptionExtensions diff --git a/src/Identity/Extensions.Core/src/PasskeyOriginInfo.cs b/src/Identity/Core/src/PasskeyOriginInfo.cs similarity index 88% rename from src/Identity/Extensions.Core/src/PasskeyOriginInfo.cs rename to src/Identity/Core/src/PasskeyOriginInfo.cs index 8e23f67d3b14..897959708fa8 100644 --- a/src/Identity/Extensions.Core/src/PasskeyOriginInfo.cs +++ b/src/Identity/Core/src/PasskeyOriginInfo.cs @@ -1,12 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Extensions.Core/src/PasskeyRequestArgs.cs b/src/Identity/Core/src/PasskeyRequestArgs.cs similarity index 92% rename from src/Identity/Extensions.Core/src/PasskeyRequestArgs.cs rename to src/Identity/Core/src/PasskeyRequestArgs.cs index 5eb3958335b2..25df25909e49 100644 --- a/src/Identity/Extensions.Core/src/PasskeyRequestArgs.cs +++ b/src/Identity/Core/src/PasskeyRequestArgs.cs @@ -1,12 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Text.Json; -using System.Threading.Tasks; namespace Microsoft.AspNetCore.Identity; diff --git a/src/Identity/Extensions.Core/src/PasskeyRequestContext.cs b/src/Identity/Core/src/PasskeyRequestContext.cs similarity index 82% rename from src/Identity/Extensions.Core/src/PasskeyRequestContext.cs rename to src/Identity/Core/src/PasskeyRequestContext.cs index b1494d756505..001cfa62e1d9 100644 --- a/src/Identity/Extensions.Core/src/PasskeyRequestContext.cs +++ b/src/Identity/Core/src/PasskeyRequestContext.cs @@ -1,12 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Extensions.Core/src/PasskeyRequestOptions.cs b/src/Identity/Core/src/PasskeyRequestOptions.cs similarity index 93% rename from src/Identity/Extensions.Core/src/PasskeyRequestOptions.cs rename to src/Identity/Core/src/PasskeyRequestOptions.cs index e1230f178462..ac034c8711e7 100644 --- a/src/Identity/Extensions.Core/src/PasskeyRequestOptions.cs +++ b/src/Identity/Core/src/PasskeyRequestOptions.cs @@ -1,12 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Extensions.Core/src/PasskeyUserEntity.cs b/src/Identity/Core/src/PasskeyUserEntity.cs similarity index 89% rename from src/Identity/Extensions.Core/src/PasskeyUserEntity.cs rename to src/Identity/Core/src/PasskeyUserEntity.cs index 60f59a411a25..91e8de5ea09c 100644 --- a/src/Identity/Extensions.Core/src/PasskeyUserEntity.cs +++ b/src/Identity/Core/src/PasskeyUserEntity.cs @@ -1,12 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Extensions.Core/src/Passkeys/AttestationObject.cs b/src/Identity/Core/src/Passkeys/AttestationObject.cs similarity index 94% rename from src/Identity/Extensions.Core/src/Passkeys/AttestationObject.cs rename to src/Identity/Core/src/Passkeys/AttestationObject.cs index c5d6c8718ffa..5693c74ec232 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/AttestationObject.cs +++ b/src/Identity/Core/src/Passkeys/AttestationObject.cs @@ -1,13 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Formats.Cbor; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Microsoft.AspNetCore.Identity; diff --git a/src/Identity/Extensions.Core/src/Passkeys/AttestedCredentialData.cs b/src/Identity/Core/src/Passkeys/AttestedCredentialData.cs similarity index 95% rename from src/Identity/Extensions.Core/src/Passkeys/AttestedCredentialData.cs rename to src/Identity/Core/src/Passkeys/AttestedCredentialData.cs index 82faa347b3da..27e7de7d144a 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/AttestedCredentialData.cs +++ b/src/Identity/Core/src/Passkeys/AttestedCredentialData.cs @@ -1,13 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Buffers.Binary; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Microsoft.AspNetCore.Identity; diff --git a/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorAssertionResponse.cs b/src/Identity/Core/src/Passkeys/AuthenticatorAssertionResponse.cs similarity index 90% rename from src/Identity/Extensions.Core/src/Passkeys/AuthenticatorAssertionResponse.cs rename to src/Identity/Core/src/Passkeys/AuthenticatorAssertionResponse.cs index ea8f87d49528..adf1996f80a2 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorAssertionResponse.cs +++ b/src/Identity/Core/src/Passkeys/AuthenticatorAssertionResponse.cs @@ -1,12 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorAttestationResponse.cs b/src/Identity/Core/src/Passkeys/AuthenticatorAttestationResponse.cs similarity index 90% rename from src/Identity/Extensions.Core/src/Passkeys/AuthenticatorAttestationResponse.cs rename to src/Identity/Core/src/Passkeys/AuthenticatorAttestationResponse.cs index 545502cc7d7d..5f120cf85475 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorAttestationResponse.cs +++ b/src/Identity/Core/src/Passkeys/AuthenticatorAttestationResponse.cs @@ -1,12 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorData.cs b/src/Identity/Core/src/Passkeys/AuthenticatorData.cs similarity index 97% rename from src/Identity/Extensions.Core/src/Passkeys/AuthenticatorData.cs rename to src/Identity/Core/src/Passkeys/AuthenticatorData.cs index ce0411e15ac6..dc52050dace3 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorData.cs +++ b/src/Identity/Core/src/Passkeys/AuthenticatorData.cs @@ -1,14 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Buffers.Binary; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Formats.Cbor; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Microsoft.AspNetCore.Identity; diff --git a/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorDataFlags.cs b/src/Identity/Core/src/Passkeys/AuthenticatorDataFlags.cs similarity index 91% rename from src/Identity/Extensions.Core/src/Passkeys/AuthenticatorDataFlags.cs rename to src/Identity/Core/src/Passkeys/AuthenticatorDataFlags.cs index 6ae73aa2a203..ad9ff726855e 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorDataFlags.cs +++ b/src/Identity/Core/src/Passkeys/AuthenticatorDataFlags.cs @@ -1,12 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorResponse.cs b/src/Identity/Core/src/Passkeys/AuthenticatorResponse.cs similarity index 80% rename from src/Identity/Extensions.Core/src/Passkeys/AuthenticatorResponse.cs rename to src/Identity/Core/src/Passkeys/AuthenticatorResponse.cs index 9914fd982448..9bf31e64d0ac 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorResponse.cs +++ b/src/Identity/Core/src/Passkeys/AuthenticatorResponse.cs @@ -1,13 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.Json.Serialization; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Extensions.Core/src/Passkeys/BufferSource.cs b/src/Identity/Core/src/Passkeys/BufferSource.cs similarity index 69% rename from src/Identity/Extensions.Core/src/Passkeys/BufferSource.cs rename to src/Identity/Core/src/Passkeys/BufferSource.cs index 978ff9e40bad..0f14cd1f56ca 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/BufferSource.cs +++ b/src/Identity/Core/src/Passkeys/BufferSource.cs @@ -1,15 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; using System.Linq; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; using System.Text; using System.Text.Json.Serialization; -using System.Threading.Tasks; -using Microsoft.Extensions.Internal; namespace Microsoft.AspNetCore.Identity; @@ -26,7 +20,6 @@ namespace Microsoft.AspNetCore.Identity; internal sealed class BufferSource : IEquatable { private readonly ReadOnlyMemory _bytes; - private string? _base64UrlString; /// /// Gets the length of the byte buffer. @@ -37,9 +30,7 @@ internal sealed class BufferSource : IEquatable /// Creates a new instance of from a byte array. /// public static BufferSource FromBytes(ReadOnlyMemory bytes) - { - return new(bytes, base64UrlString: null); - } + => new(bytes); /// /// Creates a new instance of from a string. @@ -47,43 +38,12 @@ public static BufferSource FromBytes(ReadOnlyMemory bytes) public static BufferSource FromString(string value) { var buffer = Encoding.UTF8.GetBytes(value); - return new(buffer, base64UrlString: null); - } - - /// - /// Creates a new instance of from a base64url-encoded string. - /// - public static BufferSource FromBase64UrlString(string base64UrlString) - { - var buffer = WebEncoders.Base64UrlDecode(base64UrlString); - return new(buffer, base64UrlString); + return new(buffer); } - private BufferSource(ReadOnlyMemory buffer, string? base64UrlString) + private BufferSource(ReadOnlyMemory buffer) { _bytes = buffer; - _base64UrlString = base64UrlString; - } - - /// - /// Gets the base64url-encoded string representation of the byte buffer. - /// - /// - /// If originally constructed with a base64url-encoded string, this method will directly return that string. - /// Otherwise, it will compute the base64url-encoded string from the byte buffer. - /// - public string AsBase64UrlString() - { - return _base64UrlString ??= GetBase64UrlString(_bytes); - - static string GetBase64UrlString(ReadOnlyMemory bytes) - { - var array = MemoryMarshal.TryGetArray(bytes, out var segment) - ? segment.Array! - : bytes.ToArray(); - - return WebEncoders.Base64UrlEncode(array); - } } /// diff --git a/src/Identity/Core/src/Passkeys/BufferSourceJsonConverter.cs b/src/Identity/Core/src/Passkeys/BufferSourceJsonConverter.cs new file mode 100644 index 000000000000..601b789236d5 --- /dev/null +++ b/src/Identity/Core/src/Passkeys/BufferSourceJsonConverter.cs @@ -0,0 +1,93 @@ +// 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) + { + byteSpan.Clear(); + ArrayPool.Shared.Return(pooledArray); + } + + return false; + } + Debug.Assert(bytesConsumed == utf8Unescaped.Length); + + bytes = byteSpan[..bytesWritten].ToArray(); + + if (pooledArray != null) + { + byteSpan.Clear(); + 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); + } +} diff --git a/src/Identity/Extensions.Core/src/Passkeys/COSEAlgorithmIdentifier.cs b/src/Identity/Core/src/Passkeys/COSEAlgorithmIdentifier.cs similarity index 84% rename from src/Identity/Extensions.Core/src/Passkeys/COSEAlgorithmIdentifier.cs rename to src/Identity/Core/src/Passkeys/COSEAlgorithmIdentifier.cs index cbf79d199da1..e6d989708849 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/COSEAlgorithmIdentifier.cs +++ b/src/Identity/Core/src/Passkeys/COSEAlgorithmIdentifier.cs @@ -1,12 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Extensions.Core/src/Passkeys/CollectedClientData.cs b/src/Identity/Core/src/Passkeys/CollectedClientData.cs similarity index 92% rename from src/Identity/Extensions.Core/src/Passkeys/CollectedClientData.cs rename to src/Identity/Core/src/Passkeys/CollectedClientData.cs index 95e7f7ea7c6b..29ea6a91230c 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/CollectedClientData.cs +++ b/src/Identity/Core/src/Passkeys/CollectedClientData.cs @@ -1,12 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Extensions.Core/src/Passkeys/CredentialPublicKey.cs b/src/Identity/Core/src/Passkeys/CredentialPublicKey.cs similarity index 68% rename from src/Identity/Extensions.Core/src/Passkeys/CredentialPublicKey.cs rename to src/Identity/Core/src/Passkeys/CredentialPublicKey.cs index c7846ea9c3cc..1a78daa2e0eb 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/CredentialPublicKey.cs +++ b/src/Identity/Core/src/Passkeys/CredentialPublicKey.cs @@ -1,26 +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; -using System.Collections.Generic; using System.Formats.Cbor; -using System.Linq; using System.Security.Cryptography; -using System.Text; -using System.Threading.Tasks; namespace Microsoft.AspNetCore.Identity; internal sealed class CredentialPublicKey { - private readonly CoseKeyType _type; + private readonly COSEKeyType _type; private readonly COSEAlgorithmIdentifier _alg; private readonly ReadOnlyMemory _bytes; private readonly RSA? _rsa; - -#if NETCOREAPP private readonly ECDsa? _ecdsa; -#endif public COSEAlgorithmIdentifier Alg => _alg; @@ -28,19 +20,17 @@ public CredentialPublicKey(ReadOnlyMemory bytes) { var reader = Ctap2CborReader.Create(bytes); - reader.ReadCoseKeyLabel((int)CoseKeyParameter.KeyType); - _type = (CoseKeyType)reader.ReadInt32(); + reader.ReadCoseKeyLabel((int)COSEKeyParameter.KeyType); + _type = (COSEKeyType)reader.ReadInt32(); _alg = ParseCoseKeyCommonParameters(reader); switch (_type) { -#if NETCOREAPP - case CoseKeyType.EC2: - case CoseKeyType.OKP: + case COSEKeyType.EC2: + case COSEKeyType.OKP: _ecdsa = ParseECDsa(_type, reader); break; -#endif - case CoseKeyType.RSA: + case COSEKeyType.RSA: _rsa = ParseRSA(reader); break; default: @@ -53,29 +43,20 @@ public CredentialPublicKey(ReadOnlyMemory bytes) public bool Verify(ReadOnlySpan data, ReadOnlySpan signature) { - switch (_type) + return _type switch { -#if NETCOREAPP - case CoseKeyType.EC2: - return _ecdsa!.VerifyData(data, signature, HashAlgFromCOSEAlg(_alg), DSASignatureFormat.Rfc3279DerSequence); -#endif - - case CoseKeyType.RSA: -#if NETCOREAPP - return _rsa!.VerifyData(data, signature, HashAlgFromCOSEAlg(_alg), Padding); -#else - return _rsa!.VerifyData(data.ToArray(), signature.ToArray(), HashAlgFromCOSEAlg(_alg), Padding); -#endif - } - throw new InvalidOperationException($"Missing or unknown kty {_type}"); + COSEKeyType.EC2 => _ecdsa!.VerifyData(data, signature, HashAlgFromCOSEAlg(_alg), DSASignatureFormat.Rfc3279DerSequence), + COSEKeyType.RSA => _rsa!.VerifyData(data, signature, HashAlgFromCOSEAlg(_alg), Padding), + _ => throw new InvalidOperationException($"Missing or unknown kty {_type}"), + }; } private static COSEAlgorithmIdentifier ParseCoseKeyCommonParameters(Ctap2CborReader reader) { - reader.ReadCoseKeyLabel((int)CoseKeyParameter.Alg); + reader.ReadCoseKeyLabel((int)COSEKeyParameter.Alg); var alg = (COSEAlgorithmIdentifier)reader.ReadInt32(); - if (reader.TryReadCoseKeyLabel((int)CoseKeyParameter.KeyOps)) + if (reader.TryReadCoseKeyLabel((int)COSEKeyParameter.KeyOps)) { // No-op, simply tolerate potential key_ops labels reader.SkipValue(); @@ -88,10 +69,10 @@ private static RSA ParseRSA(Ctap2CborReader reader) { var rsaParams = new RSAParameters(); - reader.ReadCoseKeyLabel((int)CoseKeyParameter.N); + reader.ReadCoseKeyLabel((int)COSEKeyParameter.N); rsaParams.Modulus = reader.ReadByteString(); - if (!reader.TryReadCoseKeyLabel((int)CoseKeyParameter.E)) + if (!reader.TryReadCoseKeyLabel((int)COSEKeyParameter.E)) { throw new CborContentException("The COSE key encodes a private key."); } @@ -99,35 +80,28 @@ private static RSA ParseRSA(Ctap2CborReader reader) reader.ReadEndMap(); -#if NETCOREAPP return RSA.Create(rsaParams); -#else - var rsa = RSA.Create(); - rsa.ImportParameters(rsaParams); - return rsa; -#endif } -#if NETCOREAPP - private static ECDsa ParseECDsa(CoseKeyType kty, Ctap2CborReader reader) + private static ECDsa ParseECDsa(COSEKeyType kty, Ctap2CborReader reader) { var ecParams = new ECParameters(); - reader.ReadCoseKeyLabel((int)CoseKeyParameter.Crv); - var crv = (CoseEllipticCurve)reader.ReadInt32(); + reader.ReadCoseKeyLabel((int)COSEKeyParameter.Crv); + var crv = (COSEEllipticCurve)reader.ReadInt32(); if (IsValidKtyCrvCombination(kty, crv)) { ecParams.Curve = MapCoseCrvToECCurve(crv); } - reader.ReadCoseKeyLabel((int)CoseKeyParameter.X); + reader.ReadCoseKeyLabel((int)COSEKeyParameter.X); ecParams.Q.X = reader.ReadByteString(); - reader.ReadCoseKeyLabel((int)CoseKeyParameter.Y); + reader.ReadCoseKeyLabel((int)COSEKeyParameter.Y); ecParams.Q.Y = reader.ReadByteString(); - if (reader.TryReadCoseKeyLabel((int)CoseKeyParameter.D)) + if (reader.TryReadCoseKeyLabel((int)COSEKeyParameter.D)) { throw new CborContentException("The COSE key encodes a private key."); } @@ -136,38 +110,37 @@ private static ECDsa ParseECDsa(CoseKeyType kty, Ctap2CborReader reader) return ECDsa.Create(ecParams); - static ECCurve MapCoseCrvToECCurve(CoseEllipticCurve crv) + 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."), + 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) + 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, + (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, }; } } -#endif internal RSASignaturePadding Padding { get { - if (_type != CoseKeyType.RSA) + if (_type != COSEKeyType.RSA) { throw new InvalidOperationException($"Must be a RSA key. Was {_type}"); } @@ -225,7 +198,7 @@ public static CredentialPublicKey Decode(ReadOnlyMemory cpk, out int bytes public byte[] ToArray() => _bytes.ToArray(); - private enum CoseKeyType + private enum COSEKeyType { OKP = 1, EC2 = 2, @@ -233,7 +206,7 @@ private enum CoseKeyType Symmetric = 4 } - private enum CoseKeyParameter + private enum COSEKeyParameter { Crv = -1, K = -1, @@ -249,7 +222,7 @@ private enum CoseKeyParameter BaseIV = 5 } - private enum CoseEllipticCurve + private enum COSEEllipticCurve { Reserved = 0, P256 = 1, diff --git a/src/Identity/Extensions.Core/src/Passkeys/Ctap2CborReader.cs b/src/Identity/Core/src/Passkeys/Ctap2CborReader.cs similarity index 96% rename from src/Identity/Extensions.Core/src/Passkeys/Ctap2CborReader.cs rename to src/Identity/Core/src/Passkeys/Ctap2CborReader.cs index fee3fc41304e..f8c916c6cc7f 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/Ctap2CborReader.cs +++ b/src/Identity/Core/src/Passkeys/Ctap2CborReader.cs @@ -1,10 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; using System.Formats.Cbor; -using System.Text; namespace Microsoft.AspNetCore.Identity; diff --git a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredential.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredential.cs similarity index 90% rename from src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredential.cs rename to src/Identity/Core/src/Passkeys/PublicKeyCredential.cs index e53b9e2ee9e8..4a272ffe190f 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredential.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredential.cs @@ -1,13 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; namespace Microsoft.AspNetCore.Identity; diff --git a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs similarity index 96% rename from src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs rename to src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs index 06092a423caa..33288d487df3 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs @@ -1,11 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; namespace Microsoft.AspNetCore.Identity; diff --git a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialDescriptor.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialDescriptor.cs similarity index 85% rename from src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialDescriptor.cs rename to src/Identity/Core/src/Passkeys/PublicKeyCredentialDescriptor.cs index 264e28d8f0e5..acb235b939c0 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialDescriptor.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialDescriptor.cs @@ -1,13 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.Json.Serialization; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialParameters.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialParameters.cs similarity index 78% rename from src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialParameters.cs rename to src/Identity/Core/src/Passkeys/PublicKeyCredentialParameters.cs index d52fdcc6914c..d9e0cf834f67 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialParameters.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialParameters.cs @@ -1,12 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Text.Json.Serialization; -using System.Threading.Tasks; namespace Microsoft.AspNetCore.Identity; @@ -27,7 +22,6 @@ internal readonly struct PublicKeyCredentialParameters(string type, COSEAlgorith /// This list is sorted in the order of preference, with the most preferred algorithm first. /// internal static IReadOnlyList AllSupportedParameters { get; } = -#if NET10_0_OR_GREATER [ new(COSEAlgorithmIdentifier.ES256), new(COSEAlgorithmIdentifier.PS256), @@ -39,16 +33,6 @@ internal readonly struct PublicKeyCredentialParameters(string type, COSEAlgorith new(COSEAlgorithmIdentifier.RS384), new(COSEAlgorithmIdentifier.RS512), ]; -#else - [ - new(COSEAlgorithmIdentifier.PS256), - new(COSEAlgorithmIdentifier.PS384), - new(COSEAlgorithmIdentifier.PS512), - new(COSEAlgorithmIdentifier.RS256), - new(COSEAlgorithmIdentifier.RS384), - new(COSEAlgorithmIdentifier.RS512), - ]; -#endif public PublicKeyCredentialParameters(COSEAlgorithmIdentifier alg) : this(type: "public-key", alg) diff --git a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs similarity index 91% rename from src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs rename to src/Identity/Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs index 967675aa891f..b007c8e7c2fa 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs @@ -1,13 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; namespace Microsoft.AspNetCore.Identity; diff --git a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialRpEntity.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialRpEntity.cs similarity index 87% rename from src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialRpEntity.cs rename to src/Identity/Core/src/Passkeys/PublicKeyCredentialRpEntity.cs index 58814ce9e283..7540420796b7 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialRpEntity.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialRpEntity.cs @@ -1,12 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialUserEntity.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialUserEntity.cs similarity index 86% rename from src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialUserEntity.cs rename to src/Identity/Core/src/Passkeys/PublicKeyCredentialUserEntity.cs index 804e4009f70e..192f275dc667 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/PublicKeyCredentialUserEntity.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialUserEntity.cs @@ -1,13 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.Json.Serialization; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Extensions.Core/src/Passkeys/TokenBinding.cs b/src/Identity/Core/src/Passkeys/TokenBinding.cs similarity index 89% rename from src/Identity/Extensions.Core/src/Passkeys/TokenBinding.cs rename to src/Identity/Core/src/Passkeys/TokenBinding.cs index cd2a7e5fbec5..1136eafb2f10 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/TokenBinding.cs +++ b/src/Identity/Core/src/Passkeys/TokenBinding.cs @@ -1,12 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 9a6a595e8c45..06f45692c9d0 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1,6 +1,82 @@ #nullable enable +Microsoft.AspNetCore.Identity.DefaultPasskeyHandler +Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.DefaultPasskeyHandler(Microsoft.Extensions.Options.IOptions! options, Microsoft.AspNetCore.Identity.IPasskeyOriginValidator! originValidator, Microsoft.AspNetCore.Identity.IPasskeyAttestationStatementVerifier! attestationStatementVerifier) -> void +Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.PerformAssertionAsync(TUser? user, string! credentialJson, string! originalOptionsJson, Microsoft.AspNetCore.Identity.UserManager! userManager) -> System.Threading.Tasks.Task!>! +Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.PerformAttestationAsync(string! credentialJson, string! originalOptionsJson, Microsoft.AspNetCore.Identity.UserManager! userManager) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IPasskeyAttestationStatementVerifier +Microsoft.AspNetCore.Identity.IPasskeyAttestationStatementVerifier.VerifyAsync(System.ReadOnlyMemory attestationObject, System.ReadOnlyMemory clientDataHash) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IPasskeyHandler +Microsoft.AspNetCore.Identity.IPasskeyHandler.PerformAssertionAsync(TUser? user, string! credentialJson, string! originalOptionsJson, Microsoft.AspNetCore.Identity.UserManager! userManager) -> System.Threading.Tasks.Task!>! +Microsoft.AspNetCore.Identity.IPasskeyHandler.PerformAttestationAsync(string! credentialJson, string! originalOptionsJson, Microsoft.AspNetCore.Identity.UserManager! userManager) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IPasskeyOriginValidator +Microsoft.AspNetCore.Identity.IPasskeyOriginValidator.IsValidOrigin(Microsoft.AspNetCore.Identity.PasskeyOriginInfo originInfo) -> bool +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.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.PasskeyRequestContext +Microsoft.AspNetCore.Identity.PasskeyRequestContext.Domain.get -> string? +Microsoft.AspNetCore.Identity.PasskeyRequestContext.Domain.set -> void +Microsoft.AspNetCore.Identity.PasskeyRequestContext.Origin.get -> string? +Microsoft.AspNetCore.Identity.PasskeyRequestContext.Origin.set -> void +Microsoft.AspNetCore.Identity.PasskeyRequestContext.PasskeyRequestContext() -> 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.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 d9e7354eb02e..e02fd3e98480 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; @@ -26,6 +28,7 @@ public class SignInManager where TUser : class 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; @@ -62,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. /// @@ -435,6 +464,63 @@ 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 result = await _passkeyHandler.PerformAttestationAsync(credentialJson, options.AsJson(), UserManager).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 result = await _passkeyHandler.PerformAssertionAsync(user, credentialJson, options.AsJson(), UserManager); + 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."); + } + } + /// /// Attempts to sign in the user with a passkey, as an asynchronous operation. /// @@ -448,13 +534,13 @@ public virtual async Task PasskeySignInAsync(string credentialJson { ArgumentException.ThrowIfNullOrEmpty(credentialJson); - var assertionResult = await UserManager.PerformPasskeyAssertionAsync(credentialJson, options); + var assertionResult = await PerformPasskeyAssertionAsync(credentialJson, options); if (!assertionResult.Succeeded) { return SignInResult.Failed; } - var setPasskeyResult = await UserManager.SetPasskeyAsync(assertionResult.User, assertionResult.Passkey).ConfigureAwait(false); + var setPasskeyResult = await UserManager.SetPasskeyAsync(assertionResult.User, assertionResult.Passkey); if (!setPasskeyResult.Succeeded) { return SignInResult.Failed; @@ -474,7 +560,7 @@ public virtual async Task ConfigurePasskeyCreationOption { ArgumentNullException.ThrowIfNull(creationArgs); - var options = await UserManager.GeneratePasskeyCreationOptionsAsync(creationArgs); + var options = await GeneratePasskeyCreationOptionsAsync(creationArgs); var props = new AuthenticationProperties(); props.Items[PasskeyCreationOptionsKey] = options.AsJson(); @@ -488,6 +574,58 @@ public virtual async Task ConfigurePasskeyCreationOption return options; } + /// + /// Generates a to create a new passkey for a user. + /// + /// Args for configuring 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(rpEntity, userEntity, 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. /// @@ -499,7 +637,7 @@ public virtual async Task ConfigurePasskeyRequestOptionsA { ArgumentNullException.ThrowIfNull(requestArgs); - var options = await UserManager.GeneratePasskeyRequestOptionsAsync(requestArgs); + var options = await GeneratePasskeyRequestOptionsAsync(requestArgs); var props = new AuthenticationProperties(); props.Items[PasskeyRequestOptionsKey] = options.AsJson(); @@ -515,6 +653,52 @@ public virtual async Task ConfigurePasskeyRequestOptionsA return options; } + /// + /// Generates a to request an existing passkey for a user. + /// + /// Args for configuring the . + /// + /// A task object representing the asynchronous operation containing the . + /// + public virtual async Task GeneratePasskeyRequestOptionsAsync(PasskeyRequestArgs? 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(BufferSource.FromBytes(challenge)) + { + RpId = serverDomain, + Timeout = (uint)Options.Passkey.Timeout.TotalMilliseconds, + AllowCredentials = allowCredentials, + }; + if (requestArgs is not null) + { + options.UserVerification = requestArgs.UserVerification; + options.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 . /// diff --git a/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorSelectionCriteria.cs b/src/Identity/Extensions.Core/src/AuthenticatorSelectionCriteria.cs similarity index 94% rename from src/Identity/Extensions.Core/src/Passkeys/AuthenticatorSelectionCriteria.cs rename to src/Identity/Extensions.Core/src/AuthenticatorSelectionCriteria.cs index eb6a202c9111..026ef734e8a4 100644 --- a/src/Identity/Extensions.Core/src/Passkeys/AuthenticatorSelectionCriteria.cs +++ b/src/Identity/Extensions.Core/src/AuthenticatorSelectionCriteria.cs @@ -2,10 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Microsoft.AspNetCore.Identity; diff --git a/src/Identity/Extensions.Core/src/DefaultPasskeyRequestContextProvider.cs b/src/Identity/Extensions.Core/src/DefaultPasskeyRequestContextProvider.cs deleted file mode 100644 index 5b7e0fcbaf0f..000000000000 --- a/src/Identity/Extensions.Core/src/DefaultPasskeyRequestContextProvider.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.Extensions.Options; - -namespace Microsoft.AspNetCore.Identity; - -internal sealed class DefaultPasskeyRequestContextProvider(IOptions options) : IPasskeyRequestContextProvider -{ - private PasskeyRequestContext? _context; - - public PasskeyRequestContext Context => _context ??= GetPasskeyRequestContext(); - - private PasskeyRequestContext GetPasskeyRequestContext() - { - var passkeyOptions = options.Value.Passkey; - return new() - { - Domain = passkeyOptions.ServerDomain, - Origin = null, - }; - } -} diff --git a/src/Identity/Extensions.Core/src/IPasskeyRequestContextProvider.cs b/src/Identity/Extensions.Core/src/IPasskeyRequestContextProvider.cs deleted file mode 100644 index eecb48f90384..000000000000 --- a/src/Identity/Extensions.Core/src/IPasskeyRequestContextProvider.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Microsoft.AspNetCore.Identity; - -/// -/// Provides access to the current . -/// -public interface IPasskeyRequestContextProvider -{ - /// - /// Gets the current . - /// - PasskeyRequestContext Context { get; } -} diff --git a/src/Identity/Extensions.Core/src/IdentityServiceCollectionExtensions.cs b/src/Identity/Extensions.Core/src/IdentityServiceCollectionExtensions.cs index 861005ef1f2b..28415c47318d 100644 --- a/src/Identity/Extensions.Core/src/IdentityServiceCollectionExtensions.cs +++ b/src/Identity/Extensions.Core/src/IdentityServiceCollectionExtensions.cs @@ -42,9 +42,6 @@ public static IdentityBuilder AddIdentityCore(this IServiceCollection ser services.TryAddScoped, PasswordHasher>(); services.TryAddScoped(); services.TryAddScoped, DefaultUserConfirmation>(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped, DefaultPasskeyHandler>(); // No interface for the error describer so we can add errors without rev'ing the interface services.TryAddScoped(); services.TryAddScoped, UserClaimsPrincipalFactory>(); diff --git a/src/Identity/Extensions.Core/src/LoggerEventIds.cs b/src/Identity/Extensions.Core/src/LoggerEventIds.cs index 8dce61906372..ffbf54de6650 100644 --- a/src/Identity/Extensions.Core/src/LoggerEventIds.cs +++ b/src/Identity/Extensions.Core/src/LoggerEventIds.cs @@ -22,6 +22,4 @@ internal static class LoggerEventIds public static readonly EventId UserValidationFailed = new EventId(13, "UserValidationFailed"); public static readonly EventId PasswordValidationFailed = new EventId(14, "PasswordValidationFailed"); public static readonly EventId GetSecurityStampFailed = new EventId(15, "GetSecurityStampFailed"); - public static readonly EventId PasskeyAttestationFailed = new EventId(16, "PasskeyAttestationFailed"); - public static readonly EventId PasskeyAssertionFailed = new EventId(16, "PasskeyAssertionFailed"); } diff --git a/src/Identity/Extensions.Core/src/Microsoft.Extensions.Identity.Core.csproj b/src/Identity/Extensions.Core/src/Microsoft.Extensions.Identity.Core.csproj index 4d478a30f7c1..a1315dd79254 100644 --- a/src/Identity/Extensions.Core/src/Microsoft.Extensions.Identity.Core.csproj +++ b/src/Identity/Extensions.Core/src/Microsoft.Extensions.Identity.Core.csproj @@ -5,7 +5,6 @@ $(DefaultNetFxTargetFramework);netstandard2.0;$(DefaultNetCoreTargetFramework) $(DefaultNetCoreTargetFramework) true - true true aspnetcore;identity;membership true @@ -15,9 +14,7 @@ - - @@ -27,7 +24,6 @@ - diff --git a/src/Identity/Extensions.Core/src/Passkeys/BufferSourceJsonConverter.cs b/src/Identity/Extensions.Core/src/Passkeys/BufferSourceJsonConverter.cs deleted file mode 100644 index 9af52a19f0fe..000000000000 --- a/src/Identity/Extensions.Core/src/Passkeys/BufferSourceJsonConverter.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Buffers; -using System.Buffers.Text; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; -using Microsoft.Extensions.Internal; - -namespace Microsoft.AspNetCore.Identity; - -internal sealed class BufferSourceJsonConverter : JsonConverter -{ - public override BufferSource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var value = reader.GetString(); - if (value is null) - { - return null; - } - - return BufferSource.FromBase64UrlString(value); - } - - public override void Write(Utf8JsonWriter writer, BufferSource value, JsonSerializerOptions options) - { - var base64UrlString = value.AsBase64UrlString(); - writer.WriteStringValue(base64UrlString); - } -} diff --git a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt index 438fef8524ca..5629c19fc226 100644 --- a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt @@ -9,56 +9,14 @@ Microsoft.AspNetCore.Identity.AuthenticatorSelectionCriteria.ResidentKey.get -> 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, Microsoft.AspNetCore.Identity.IPasskeyOriginValidator! originValidator, Microsoft.AspNetCore.Identity.IPasskeyAttestationStatementVerifier? attestationStatementVerifier = null) -> void -Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.PerformAssertionAsync(TUser? user, string! credentialJson, string! originalOptionsJson, Microsoft.AspNetCore.Identity.UserManager! userManager) -> System.Threading.Tasks.Task!>! -Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.PerformAttestationAsync(string! credentialJson, string! originalOptionsJson, Microsoft.AspNetCore.Identity.UserManager! userManager) -> System.Threading.Tasks.Task! -Microsoft.AspNetCore.Identity.DefaultPasskeyOriginValidator -Microsoft.AspNetCore.Identity.DefaultPasskeyOriginValidator.DefaultPasskeyOriginValidator(Microsoft.AspNetCore.Identity.IPasskeyRequestContextProvider! requestContextProvider, Microsoft.Extensions.Options.IOptions! options) -> void -Microsoft.AspNetCore.Identity.DefaultPasskeyOriginValidator.IsValidOrigin(Microsoft.AspNetCore.Identity.PasskeyOriginInfo originInfo) -> bool Microsoft.AspNetCore.Identity.IdentityOptions.Passkey.get -> Microsoft.AspNetCore.Identity.PasskeyOptions! Microsoft.AspNetCore.Identity.IdentityOptions.Passkey.set -> void -Microsoft.AspNetCore.Identity.IPasskeyAttestationStatementVerifier -Microsoft.AspNetCore.Identity.IPasskeyAttestationStatementVerifier.VerifyAsync(System.ReadOnlyMemory attestationObject, System.ReadOnlyMemory clientDataHash) -> System.Threading.Tasks.Task! -Microsoft.AspNetCore.Identity.IPasskeyHandler -Microsoft.AspNetCore.Identity.IPasskeyHandler.PerformAssertionAsync(TUser? user, string! credentialJson, string! originalOptionsJson, Microsoft.AspNetCore.Identity.UserManager! userManager) -> System.Threading.Tasks.Task!>! -Microsoft.AspNetCore.Identity.IPasskeyHandler.PerformAttestationAsync(string! credentialJson, string! originalOptionsJson, Microsoft.AspNetCore.Identity.UserManager! userManager) -> System.Threading.Tasks.Task! -Microsoft.AspNetCore.Identity.IPasskeyOriginValidator -Microsoft.AspNetCore.Identity.IPasskeyOriginValidator.IsValidOrigin(Microsoft.AspNetCore.Identity.PasskeyOriginInfo originInfo) -> bool -Microsoft.AspNetCore.Identity.IPasskeyRequestContextProvider -Microsoft.AspNetCore.Identity.IPasskeyRequestContextProvider.Context.get -> Microsoft.AspNetCore.Identity.PasskeyRequestContext! Microsoft.AspNetCore.Identity.IUserPasskeyStore Microsoft.AspNetCore.Identity.IUserPasskeyStore.FindByPasskeyIdAsync(byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Identity.IUserPasskeyStore.FindPasskeyAsync(TUser! user, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Identity.IUserPasskeyStore.GetPasskeysAsync(TUser! user, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! Microsoft.AspNetCore.Identity.IUserPasskeyStore.RemovePasskeyAsync(TUser! user, byte[]! credentialId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Identity.IUserPasskeyStore.SetPasskeyAsync(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -Microsoft.AspNetCore.Identity.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.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.PasskeyOptions Microsoft.AspNetCore.Identity.PasskeyOptions.AllowCrossOriginIframes.get -> bool Microsoft.AspNetCore.Identity.PasskeyOptions.AllowCrossOriginIframes.set -> void @@ -81,34 +39,6 @@ Microsoft.AspNetCore.Identity.PasskeyOptions.ServerDomain.get -> string? Microsoft.AspNetCore.Identity.PasskeyOptions.ServerDomain.set -> void Microsoft.AspNetCore.Identity.PasskeyOptions.Timeout.get -> System.TimeSpan Microsoft.AspNetCore.Identity.PasskeyOptions.Timeout.set -> void -Microsoft.AspNetCore.Identity.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.PasskeyRequestContext -Microsoft.AspNetCore.Identity.PasskeyRequestContext.Domain.get -> string? -Microsoft.AspNetCore.Identity.PasskeyRequestContext.Domain.set -> void -Microsoft.AspNetCore.Identity.PasskeyRequestContext.Origin.get -> string? -Microsoft.AspNetCore.Identity.PasskeyRequestContext.Origin.set -> void -Microsoft.AspNetCore.Identity.PasskeyRequestContext.PasskeyRequestContext() -> 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.UserLoginInfo.UserLoginInfo(string! loginProvider, string! providerKey, string? providerDisplayName) -> void Microsoft.AspNetCore.Identity.UserPasskeyInfo Microsoft.AspNetCore.Identity.UserPasskeyInfo.AttestationObject.get -> byte[]! @@ -122,20 +52,10 @@ Microsoft.AspNetCore.Identity.UserPasskeyInfo.SignCount.get -> uint Microsoft.AspNetCore.Identity.UserPasskeyInfo.SignCount.set -> void Microsoft.AspNetCore.Identity.UserPasskeyInfo.Transports.get -> string![]? Microsoft.AspNetCore.Identity.UserPasskeyInfo.UserPasskeyInfo(byte[]! credentialId, byte[]! publicKey, string? name, System.DateTimeOffset createdAt, uint signCount, string![]? transports, bool isUserVerified, bool isBackupEligible, bool isBackedUp, byte[]! attestationObject, byte[]! clientDataJson) -> void -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! static readonly Microsoft.AspNetCore.Identity.IdentitySchemaVersions.Version3 -> System.Version! virtual Microsoft.AspNetCore.Identity.UserManager.FindByPasskeyIdAsync(byte[]! credentialId) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.UserManager.GeneratePasskeyCreationOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyCreationArgs! creationArgs) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.UserManager.GeneratePasskeyRequestOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyRequestArgs! requestArgs) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserManager.GetPasskeyAsync(TUser! user, byte[]! credentialId) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserManager.GetPasskeysAsync(TUser! user) -> System.Threading.Tasks.Task!>! -virtual Microsoft.AspNetCore.Identity.UserManager.PerformPasskeyAssertionAsync(string! credentialJson, Microsoft.AspNetCore.Identity.PasskeyRequestOptions! options) -> System.Threading.Tasks.Task!>! -virtual Microsoft.AspNetCore.Identity.UserManager.PerformPasskeyAttestationAsync(string! credentialJson, Microsoft.AspNetCore.Identity.PasskeyCreationOptions! options) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserManager.RemovePasskeyAsync(TUser! user, byte[]! credentialId) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserManager.SetPasskeyAsync(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserManager.SupportsUserPasskey.get -> bool diff --git a/src/Identity/Extensions.Core/src/UserManager.cs b/src/Identity/Extensions.Core/src/UserManager.cs index eab3072f26ae..f7bcbf38368d 100644 --- a/src/Identity/Extensions.Core/src/UserManager.cs +++ b/src/Identity/Extensions.Core/src/UserManager.cs @@ -9,7 +9,6 @@ using System.Security.Claims; using System.Security.Cryptography; using System.Text; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Shared; @@ -49,8 +48,6 @@ public class UserManager : IDisposable where TUser : class private static readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create(); #endif private readonly IServiceProvider _services; - private readonly IPasskeyHandler? _passkeyHandler; - private readonly IPasskeyRequestContextProvider? _passkeyRequestContextProvider; /// /// The cancellation token used to cancel operations. @@ -120,9 +117,6 @@ public UserManager(IUserStore store, RegisterTokenProvider(providerName, provider); } } - - _passkeyHandler = services.GetService>(); - _passkeyRequestContextProvider = services.GetService(); } if (Options.Stores.ProtectPersonalData) @@ -2149,201 +2143,6 @@ public virtual Task CountRecoveryCodesAsync(TUser user) return store.CountCodesAsync(user, CancellationToken); } - /// - /// 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) - { - ThrowIfDisposed(); - ThrowIfNoPasskeyHandler(); - ArgumentNullThrowHelper.ThrowIfNullOrEmpty(credentialJson); - ArgumentNullThrowHelper.ThrowIfNull(options); - - var result = await _passkeyHandler.PerformAttestationAsync(credentialJson, options.AsJson(), this).ConfigureAwait(false); - if (!result.Succeeded) - { - Logger.LogDebug(LoggerEventIds.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) - { - ThrowIfDisposed(); - ThrowIfNoPasskeyHandler(); - ArgumentNullThrowHelper.ThrowIfNullOrEmpty(credentialJson); - ArgumentNullThrowHelper.ThrowIfNull(options); - - var user = options.UserId is { Length: > 0 } userId - ? await FindByIdAsync(userId).ConfigureAwait(false) - : null; - var result = await _passkeyHandler.PerformAssertionAsync(user, credentialJson, options.AsJson(), this).ConfigureAwait(false); - if (!result.Succeeded) - { - Logger.LogDebug(LoggerEventIds.PasskeyAssertionFailed, "Passkey assertion failed: {message}", result.Failure.Message); - } - - return result; - } - - /// - /// Generates a to create a new passkey for a user. - /// - /// Args for configuring the . - /// - /// A task object representing the asynchronous operation containing the . - /// - public virtual async Task GeneratePasskeyCreationOptionsAsync(PasskeyCreationArgs creationArgs) - { - ThrowIfDisposed(); - ThrowIfNoPasskeyRequestContextProvider(); - ArgumentNullThrowHelper.ThrowIfNull(creationArgs); - - var requestContext = _passkeyRequestContextProvider.Context; - var serverDomain = requestContext.Domain - ?? throw new InvalidOperationException("A passkey server domain has not been configured and cannot be inferred from the request context."); - - var excludeCredentials = await GetExcludeCredentialsAsync().ConfigureAwait(false); - 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 = GetRandomChallenge(Options.Passkey.ChallengeSize); - var options = new PublicKeyCredentialCreationOptions(rpEntity, userEntity, 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 FindByIdAsync(creationArgs.UserEntity.Id).ConfigureAwait(false); - if (existingUser is null) - { - return []; - } - - var passkeyStore = GetUserPasskeyStore(); - var passkeys = await passkeyStore.GetPasskeysAsync(existingUser, CancellationToken).ConfigureAwait(false); - var excludeCredentials = passkeys - .Select(p => new PublicKeyCredentialDescriptor(type: "public-key", id: BufferSource.FromBytes(p.CredentialId)) - { - Transports = p.Transports ?? [], - }); - return [.. excludeCredentials]; - } - } - - /// - /// Generates a to request an existing passkey for a user. - /// - /// Args for configuring the . - /// - /// A task object representing the asynchronous operation containing the . - /// - public virtual async Task GeneratePasskeyRequestOptionsAsync(PasskeyRequestArgs requestArgs) - { - ThrowIfDisposed(); - ThrowIfNoPasskeyRequestContextProvider(); - - var requestContext = _passkeyRequestContextProvider.Context; - var serverDomain = requestContext.Domain - ?? throw new InvalidOperationException("A passkey server domain has not been configured and cannot be inferred from the request context."); - - var allowCredentials = await GetAllowCredentialsAsync().ConfigureAwait(false); - var challenge = GetRandomChallenge(Options.Passkey.ChallengeSize); - var options = new PublicKeyCredentialRequestOptions(BufferSource.FromBytes(challenge)) - { - RpId = requestContext.Domain, - Timeout = (uint)Options.Passkey.Timeout.TotalMilliseconds, - AllowCredentials = allowCredentials, - }; - if (requestArgs is not null) - { - options.UserVerification = requestArgs.UserVerification; - options.Extensions = requestArgs.Extensions; - } - var userId = requestArgs?.User is { } user - ? await 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 passkeyStore = GetUserPasskeyStore(); - var passkeys = await passkeyStore.GetPasskeysAsync(user, CancellationToken).ConfigureAwait(false); - var allowCredentials = passkeys - .Select(p => new PublicKeyCredentialDescriptor(type: "public-key", id: BufferSource.FromBytes(p.CredentialId)) - { - Transports = p.Transports ?? [], - }); - return [.. allowCredentials]; - } - } - - [MemberNotNull(nameof(_passkeyHandler))] - private void ThrowIfNoPasskeyHandler() - { - if (_passkeyHandler is null) - { - throw new InvalidOperationException( - $"This operation requires an {nameof(IPasskeyHandler<>)} service to be registered."); - } - } - - [MemberNotNull(nameof(_passkeyRequestContextProvider))] - private void ThrowIfNoPasskeyRequestContextProvider() - { - if (_passkeyRequestContextProvider is null) - { - throw new InvalidOperationException( - $"This operation requires an {nameof(IPasskeyRequestContextProvider)} service to be registered."); - } - } - - private static byte[] GetRandomChallenge(int challengeSize) - { - var resultBuffer = new byte[challengeSize]; - -#if NETCOREAPP - RandomNumberGenerator.Fill(resultBuffer); -#else - _rng.GetBytes(resultBuffer); -#endif - - return resultBuffer; - } - /// /// Adds a new passkey for the given user or updates an existing one. /// diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs b/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs index a270dcdacc70..45a5dcc163f8 100644 --- a/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/Program.cs @@ -83,7 +83,7 @@ return Results.BadRequest(new FailedResponse("There are no original passkey options present.")); } - var attestationResult = await userManager.PerformPasskeyAttestationAsync(credentialJson, options); + var attestationResult = await signInManager.PerformPasskeyAttestationAsync(credentialJson, options); if (!attestationResult.Succeeded) { return Results.BadRequest(new FailedResponse($"Attestation failed: {attestationResult.Failure.Message}")); @@ -154,7 +154,7 @@ return Results.BadRequest(new FailedResponse("There are no original passkey options present.")); } - var assertionResult = await userManager.PerformPasskeyAssertionAsync(credentialJson, options); + var assertionResult = await signInManager.PerformPasskeyAssertionAsync(credentialJson, options); if (!assertionResult.Succeeded) { return Results.BadRequest(new FailedResponse($"Assertion failed: {assertionResult.Failure.Message}")); diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor index a30497082f6f..986e5e86c2ea 100644 --- a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor @@ -82,7 +82,7 @@ return; } - var attestationResult = await UserManager.PerformPasskeyAttestationAsync(CredentialJson, options); + var attestationResult = await SignInManager.PerformPasskeyAttestationAsync(CredentialJson, options); if (!attestationResult.Succeeded) { statusMessage = $"Error: Could not validate credential: {attestationResult.Failure.Message}"; From 65e6a370916946cdaf4264bb7d844cc67b3af180 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 5 Jun 2025 20:25:44 -0400 Subject: [PATCH 09/31] Update Passkeys.razor --- .../Components/Account/Pages/Manage/Passkeys.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor index 2f9f59b87f26..64246ce6597c 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor @@ -132,7 +132,7 @@ else return; } - var attestationResult = await UserManager.PerformPasskeyAttestationAsync(responseJson, options); + var attestationResult = await SignInManager.PerformPasskeyAttestationAsync(responseJson, options); if (!attestationResult.Succeeded) { RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add the passkey: {attestationResult.Failure.Message}.", HttpContext); From bb187c7d2b986b09544a84b5b63cdfee4cfe2153 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 6 Jun 2025 10:57:27 -0400 Subject: [PATCH 10/31] PR feedback --- src/Identity/Core/src/DefaultPasskeyHandler.cs | 8 ++++---- src/Identity/Core/src/Passkeys/BufferSource.cs | 10 ++++++++++ src/Identity/Core/src/SignInManager.cs | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Identity/Core/src/DefaultPasskeyHandler.cs b/src/Identity/Core/src/DefaultPasskeyHandler.cs index c40c8eea0848..eca5c8ec481d 100644 --- a/src/Identity/Core/src/DefaultPasskeyHandler.cs +++ b/src/Identity/Core/src/DefaultPasskeyHandler.cs @@ -117,7 +117,7 @@ private async Task PerformAttestationCoreAsync( } // 8. Verify that the value of clientData.challenge equals the base64url encoding of pkOptions.challenge. - if (!clientData.Challenge.Equals(originalOptions.Challenge)) + if (!clientData.Challenge.FixedTimeEquals(originalOptions.Challenge)) { throw PasskeyException.InvalidChallenge(); } @@ -162,7 +162,7 @@ private async Task PerformAttestationCoreAsync( // 14. Verify that the rpIdHash in authenticatorData is the SHA-256 hash of the RP ID expected by the Relying Party. var rpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(originalOptions.Rp.Id ?? string.Empty)); - if (!authenticatorData.RpIdHash.Span.SequenceEqual(rpIdHash.AsSpan())) + if (!CryptographicOperations.FixedTimeEquals(authenticatorData.RpIdHash.Span, rpIdHash.AsSpan())) { throw PasskeyException.InvalidRelyingPartyIDHash(); } @@ -373,7 +373,7 @@ private async Task> PerformAssertionCoreAsync( } // 11. Verify that the value of C.challenge equals the base64url encoding of originalOptions.challenge. - if (!clientData.Challenge.Equals(originalOptions.Challenge)) + if (!clientData.Challenge.FixedTimeEquals(originalOptions.Challenge)) { throw PasskeyException.InvalidChallenge(); } @@ -403,7 +403,7 @@ private async Task> PerformAssertionCoreAsync( // 15. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party. var rpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(originalOptions.RpId ?? string.Empty)); - if (!authenticatorData.RpIdHash.Span.SequenceEqual(rpIdHash.AsSpan())) + if (!CryptographicOperations.FixedTimeEquals(authenticatorData.RpIdHash.Span, rpIdHash.AsSpan())) { throw PasskeyException.InvalidRelyingPartyIDHash(); } diff --git a/src/Identity/Core/src/Passkeys/BufferSource.cs b/src/Identity/Core/src/Passkeys/BufferSource.cs index 0f14cd1f56ca..c09282b2b8be 100644 --- a/src/Identity/Core/src/Passkeys/BufferSource.cs +++ b/src/Identity/Core/src/Passkeys/BufferSource.cs @@ -2,6 +2,7 @@ // 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.Serialization; @@ -77,6 +78,15 @@ public bool Equals(BufferSource? other) return other is not null && _bytes.Span.SequenceEqual(other._bytes.Span); } + /// + /// Performs a fixed-time value-based equality comparison with another instance + /// using . + /// + public bool FixedTimeEquals(BufferSource? other) + { + return other is not null && CryptographicOperations.FixedTimeEquals(_bytes.Span, other._bytes.Span); + } + /// public override bool Equals(object? obj) => obj is BufferSource other && Equals(other); diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index e02fd3e98480..b68d550faed6 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -522,7 +522,7 @@ private void ThrowIfNoPasskeyHandler() } /// - /// Attempts to sign in the user with a passkey, as an asynchronous operation. + /// Attempts to sign in the user with a passkey. /// /// The credentials obtained by JSON-serializing the result of the navigator.credentials.get() JavaScript function. /// The original passkey request options provided to the browser. From d1780682cbb7a145c3805aa060617081b8cde74a Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 6 Jun 2025 18:21:27 -0400 Subject: [PATCH 11/31] Update template passkey functionality --- .../.template.config/template.json | 1 + ...omponentsEndpointRouteBuilderExtensions.cs | 33 +++ .../Components/Account/Pages/Login.razor | 82 +++---- .../Account/Pages/Manage/Passkeys.razor | 219 ++++++------------ .../Account/Pages/Manage/RenamePasskey.razor | 92 ++++++++ .../Components/Account/PasskeyInputModel.cs | 7 + .../Components/Account/PasskeyOperation.cs | 7 + .../Account/Shared/PasskeyHandler.razor | 76 ------ .../Account/Shared/PasskeyHandler.razor.js | 78 ------- .../Account/Shared/PasskeySubmit.razor | 37 +++ .../wwwroot/BlazorWeb-CSharp.lib.module.js | 77 ++++++ 11 files changed, 359 insertions(+), 350 deletions(-) create mode 100644 src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor create mode 100644 src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/PasskeyInputModel.cs create mode 100644 src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/PasskeyOperation.cs delete mode 100644 src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeyHandler.razor delete mode 100644 src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeyHandler.razor.js create mode 100644 src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor create mode 100644 src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/wwwroot/BlazorWeb-CSharp.lib.module.js diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/.template.config/template.json b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/.template.config/template.json index 09f28a39ffee..e31baa1bbc45 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/.template.config/template.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/.template.config/template.json @@ -135,6 +135,7 @@ "exclude": [ "BlazorWeb-CSharp/Components/Account/**", "BlazorWeb-CSharp/Data/**", + "BlazorWeb-CSharp/wwwroot/BlazorWeb-CSharp.lib.module.js", "BlazorWeb-CSharp.Client/UserInfo.cs", "BlazorWeb-CSharp.Client/Pages/Auth.razor" ] diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs index fd9fe31ab31a..db27d9076403 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs @@ -49,6 +49,39 @@ public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEn return TypedResults.LocalRedirect($"~/{returnUrl}"); }); + accountGroup.MapPost("/PasskeyCreationOptions", async ( + HttpContext context, + [FromServices] UserManager userManager, + [FromServices] SignInManager signInManager) => + { + var user = await userManager.GetUserAsync(context.User); + if (user is null) + { + return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'."); + } + + var userId = await userManager.GetUserIdAsync(user); + var userName = await userManager.GetUserNameAsync(user) ?? "User"; + var userEntity = new PasskeyUserEntity(userId, userName, displayName: userName); + var passkeyCreationArgs = new PasskeyCreationArgs(userEntity); + var options = await signInManager.ConfigurePasskeyCreationOptionsAsync(passkeyCreationArgs); + return TypedResults.Content(options.AsJson(), contentType: "application/json"); + }); + + accountGroup.MapPost("/PasskeyRequestOptions", async ( + [FromServices] UserManager userManager, + [FromServices] SignInManager signInManager, + [FromQuery] string? email) => + { + var user = string.IsNullOrEmpty(email) ? null : await userManager.FindByEmailAsync(email); + var passkeyRequestArgs = new PasskeyRequestArgs + { + User = user, + }; + var options = await signInManager.ConfigurePasskeyRequestOptionsAsync(passkeyRequestArgs); + return TypedResults.Content(options.AsJson(), contentType: "application/json"); + }); + var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization(); manageGroup.MapPost("/LinkExternalLogin", async ( diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor index c31a0fb701c2..79be1bfac680 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor @@ -17,7 +17,6 @@
- @@ -41,12 +40,12 @@
- +

OR - + Log in with a passkey

@@ -74,14 +73,12 @@ @code { private string? errorMessage; - private EditContext editContext = default!; - private string? currentPasskeyRequestOptions; [CascadingParameter] private HttpContext HttpContext { get; set; } = default!; - [SupplyParameterFromForm(FormName = "login")] + [SupplyParameterFromForm] private InputModel Input { get; set; } = new(); [SupplyParameterFromQuery] @@ -100,32 +97,38 @@ public async Task LoginUser() { - // When performing passkey sign-in, don't perform form validation. - // If provided, we use the email as a hint to suggest which user is likely being signed in. - if (Input.UsePasskey) + if (!string.IsNullOrEmpty(Input.Passkey?.Error)) { - var user = Input.Email is { Length: > 0 } email - ? await UserManager.FindByEmailAsync(email) - : null; - - var passkeyRequestArgs = new PasskeyRequestArgs - { - User = user, - }; - var options = await SignInManager.ConfigurePasskeyRequestOptionsAsync(passkeyRequestArgs); - currentPasskeyRequestOptions = options.AsJson(); + errorMessage = $"Error: Could not log in using the provided passkey: {Input.Passkey.Error}"; return; } - // If doing a password sign-in, validate the form. - if (!editContext.Validate()) + SignInResult result; + if (!string.IsNullOrEmpty(Input.Passkey?.CredentialJson)) { - return; + // When performing passkey sign-in, don't perform form validation. + var options = await SignInManager.RetrievePasskeyRequestOptionsAsync(); + if (options is null) + { + errorMessage = "Error: Could not complete passkey login. Please try again."; + return; + } + + result = await SignInManager.PasskeySignInAsync(Input.Passkey.CredentialJson, options); + } + else + { + // If doing a password sign-in, validate the form. + if (!editContext.Validate()) + { + return; + } + + // This doesn't count login failures towards account lockout + // To enable password failures to trigger account lockout, set lockoutOnFailure: true + result = await SignInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false); } - // This doesn't count login failures towards account lockout - // To enable password failures to trigger account lockout, set lockoutOnFailure: true - var result = await SignInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false); if (result.Succeeded) { Logger.LogInformation("User logged in."); @@ -148,33 +151,6 @@ } } - public async Task LoginUserWithPasskey(string responseJson) - { - var options = await SignInManager.RetrievePasskeyRequestOptionsAsync(); - if (options is null) - { - errorMessage = "Error: Could not complete passkey login. Please try again."; - return; - } - - var result = await SignInManager.PasskeySignInAsync(responseJson, options); - if (result.Succeeded) - { - Logger.LogInformation("User logged in."); - RedirectManager.RedirectTo(ReturnUrl); - } - else - { - errorMessage = "Error: Could not log in using the provided passkey."; - return; - } - } - - public void SetPasskeyError(string? error) - { - errorMessage = $"Error: Could not log in using the provided passkey{(string.IsNullOrEmpty(error) ? "." : $": {error}")}"; - } - private sealed class InputModel { [Required] @@ -188,6 +164,6 @@ [Display(Name = "Remember me?")] public bool RememberMe { get; set; } - public bool UsePasskey { get; set; } + public PasskeyInputModel? Passkey { get; set; } } } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor index 64246ce6597c..23f6aadef9b1 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor @@ -3,6 +3,7 @@ @using BlazorWeb_CSharp.Data @using Microsoft.AspNetCore.Identity @using System.ComponentModel.DataAnnotations +@using System.Buffers.Text @inject UserManager UserManager @inject SignInManager SignInManager @@ -14,85 +15,60 @@ -@if (!string.IsNullOrEmpty(renamingPasskeyId)) +@if (currentPasskeys is { Count: > 0 }) { - - -

Enter a name for your passkey

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

No passkeys are registered.

- } - - - -
- - - +

No passkeys are registered.

} +
+ + Add a new passkey + + @code { private ApplicationUser? user; private IList? currentPasskeys; - private string? renamingPasskeyId; - private string? currentPasskeyCreationOptions; [CascadingParameter] private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private string? RemovingPasskeyId { get; set; } + private string? Action { get; set; } - [SupplyParameterFromForm(FormName = "rename-passkey")] - private RenamePasskeyInput RenameInput { get; set; } = new(); + [SupplyParameterFromForm] + private string? CredentialId { get; set; } + + [SupplyParameterFromForm(FormName = "add-passkey")] + private PasskeyInputModel AddPasskeyInput { get; set; } = new(); protected override async Task OnInitializedAsync() { - renamingPasskeyId = RenameInput?.Id; - user = await UserManager.GetUserAsync(HttpContext.User); if (user is null) { @@ -102,7 +78,7 @@ else currentPasskeys = await UserManager.GetPasskeysAsync(user); } - private async Task ConfigurePasskeyCreationOptions() + private async Task AddPasskey() { if (user is null) { @@ -110,18 +86,15 @@ else return; } - var userId = await UserManager.GetUserIdAsync(user); - var userName = await UserManager.GetUserNameAsync(user) ?? "User"; - var userEntity = new PasskeyUserEntity(userId, userName, displayName: userName); - var options = await SignInManager.ConfigurePasskeyCreationOptionsAsync(new(userEntity)); - currentPasskeyCreationOptions = options.AsJson(); - } + if (!string.IsNullOrEmpty(AddPasskeyInput.Error)) + { + RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add a passkey: {AddPasskeyInput.Error}", HttpContext); + return; + } - private async Task AddPasskeyAsync(string responseJson) - { - if (user is null) + if (string.IsNullOrEmpty(AddPasskeyInput.CredentialJson)) { - RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + RedirectManager.RedirectToCurrentPageWithStatus("Error: The browser did not provide a passkey.", HttpContext); return; } @@ -132,45 +105,42 @@ else return; } - var attestationResult = await SignInManager.PerformPasskeyAttestationAsync(responseJson, options); + var attestationResult = await SignInManager.PerformPasskeyAttestationAsync(AddPasskeyInput.CredentialJson, options); if (!attestationResult.Succeeded) { RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add the passkey: {attestationResult.Failure.Message}.", HttpContext); return; } - await UserManager.SetPasskeyAsync(user, attestationResult.Passkey); + var setPasskeyResult = await UserManager.SetPasskeyAsync(user, attestationResult.Passkey); + if (!setPasskeyResult.Succeeded) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: The passkey could not be added to your account.", HttpContext); + return; + } // Immediately prompt the user to enter a name for the credential - renamingPasskeyId = Convert.ToBase64String(attestationResult.Passkey.CredentialId); - } - - private void SetPasskeyError(string error) - { - RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add a passkey: {error}", HttpContext); + var credentialIdBase64Url = Base64Url.EncodeToString(attestationResult.Passkey.CredentialId); + RedirectManager.RedirectTo($"Account/Manage/RenamePasskey/{credentialIdBase64Url}"); } - private async Task RemovePasskeyAsync() + private async Task UpdatePasskey() { - if (user is null) + switch (Action) { - RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); - return; + case "rename": + RedirectManager.RedirectTo($"Account/Manage/RenamePasskey/{CredentialId}"); + break; + case "delete": + await DeletePasskey(); + break; + default: + RedirectManager.RedirectToCurrentPageWithStatus($"Error: Unknown action '{Action}'.", HttpContext); + break; } - - var passkey = GetPasskeyByBase64Id(RemovingPasskeyId); - if (passkey is null) - { - // Redirected in GetPasskeyByBase64Id - return; - } - - await UserManager.RemovePasskeyAsync(user, passkey.CredentialId); - - RedirectManager.RedirectToCurrentPageWithStatus("Passkey deleted successfully.", HttpContext); } - private async Task RenamePasskeyAsync() + private async Task DeletePasskey() { if (user is null) { @@ -178,61 +148,24 @@ else return; } - var passkey = GetPasskeyByBase64Id(RenameInput.Id); - if (passkey is null) - { - // Redirected in GetPasskeyByBase64Id - return; - } - - passkey.Name = RenameInput.DisplayName; - var result = await UserManager.SetPasskeyAsync(user, passkey); - if (!result.Succeeded) - { - RedirectManager.RedirectToCurrentPageWithStatus("Error: The passkey could not be updated.", HttpContext); - return; - } - - RedirectManager.RedirectToCurrentPageWithStatus("Passkey updated successfully.", HttpContext); - } - - private UserPasskeyInfo? GetPasskeyByBase64Id(string? base64CredentialId) - { - if (string.IsNullOrEmpty(base64CredentialId)) + byte[] credentialId; + try { - RedirectManager.RedirectToCurrentPageWithStatus("Error: The passkey ID was not specified.", HttpContext); - return null; + credentialId = Base64Url.DecodeFromChars(CredentialId); } - - var credentialId = Convert.FromBase64String(base64CredentialId); - if (credentialId is null) + catch (FormatException) { RedirectManager.RedirectToCurrentPageWithStatus("Error: The specified passkey ID had an invalid format.", HttpContext); - return null; - } - - if (currentPasskeys is null) - { - RedirectManager.RedirectToCurrentPageWithStatus("Error: Could not fetch passkeys for the current user.", HttpContext); - return null; + return; } - var credential = currentPasskeys.SingleOrDefault(c => c.CredentialId.SequenceEqual(credentialId)); - if (credential is null) + var result = await UserManager.RemovePasskeyAsync(user, credentialId); + if (!result.Succeeded) { - RedirectManager.RedirectToCurrentPageWithStatus("Error: The specified passkey does not exist for the current user.", HttpContext); - return null; + RedirectManager.RedirectToCurrentPageWithStatus("Error: The passkey could not be deleted.", HttpContext); + return; } - return credential; - } - - private sealed class RenamePasskeyInput - { - [Required] - public string Id { get; set; } = ""; - - [Required] - public string DisplayName { get; set; } = ""; + RedirectManager.RedirectToCurrentPageWithStatus("Passkey deleted successfully.", HttpContext); } } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor new file mode 100644 index 000000000000..c951b353bf98 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor @@ -0,0 +1,92 @@ +@page "/Account/Manage/RenamePasskey/{Id}" + +@using BlazorWeb_CSharp.Data +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Identity +@using System.Buffers.Text + +@inject UserManager UserManager +@inject IdentityRedirectManager RedirectManager + + + + @if (passkey?.Name is { } name) + { +

Enter a new name for your "@name" passkey

+ } + else + { +

Enter a name for your passkey

+ } +
+ +
+ + + +
+
+ +
+
+ +@code { + private ApplicationUser? user; + private UserPasskeyInfo? passkey; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [Parameter] + public string? Id { get; set; } + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = new(); + + protected override async Task OnInitializedAsync() + { + user = (await UserManager.GetUserAsync(HttpContext.User))!; + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + byte[] credentialId; + try + { + credentialId = Base64Url.DecodeFromChars(Id); + } + catch (FormatException) + { + RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Error: The specified passkey ID had an invalid format.", HttpContext); + return; + } + + passkey = await UserManager.GetPasskeyAsync(user, credentialId); + if (passkey is null) + { + RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Error: The specified passkey could not be found.", HttpContext); + return; + } + } + + private async Task Rename() + { + passkey!.Name = Input.Name; + var result = await UserManager.SetPasskeyAsync(user!, passkey); + if (!result.Succeeded) + { + RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Error: The passkey could not be updated.", HttpContext); + return; + } + + RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Passkey updated successfully.", HttpContext); + } + + private sealed class InputModel + { + [Required] + public string Name { get; set; } = ""; + } +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/PasskeyInputModel.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/PasskeyInputModel.cs new file mode 100644 index 000000000000..6a5e0eb99320 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/PasskeyInputModel.cs @@ -0,0 +1,7 @@ +namespace BlazorWeb_CSharp.Components.Account; + +public class PasskeyInputModel +{ + public string? CredentialJson { get; set; } + public string? Error { get; set; } +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/PasskeyOperation.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/PasskeyOperation.cs new file mode 100644 index 000000000000..3b4b291aacb8 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/PasskeyOperation.cs @@ -0,0 +1,7 @@ +namespace BlazorWeb_CSharp.Components.Account; + +public enum PasskeyOperation +{ + Create = 0, + Request = 1, +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeyHandler.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeyHandler.razor deleted file mode 100644 index 5b2b40a46350..000000000000 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeyHandler.razor +++ /dev/null @@ -1,76 +0,0 @@ -@using BlazorWeb_CSharp.Data -@using Microsoft.AspNetCore.Authentication -@using Microsoft.AspNetCore.Identity - -
- - - - - -@if (options is not null) -{ - - -} - -@code { - private string? action; - private string? options; - - [Parameter] - public string? CurrentCreationOptions { get; set; } - - [Parameter] - public string? CurrentRequestOptions { get; set; } - - [Parameter] - [EditorRequired] - public EventCallback OnResponse { get; set; } - - [Parameter] - [EditorRequired] - public EventCallback OnError { get; set; } - - [SupplyParameterFromForm] - private string? ResponseJson { get; set; } - - [SupplyParameterFromForm] - private string? Error { get; set; } - - [CascadingParameter] - private HttpContext HttpContext { get; set; } = default!; - - protected override async Task OnInitializedAsync() - { - if (HttpMethods.IsGet(HttpContext.Request.Method)) - { - // Clear the existing two factor cookie to ensure a clean ceremony - await HttpContext.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); - } - } - - protected override void OnParametersSet() - { - (options, action) = (CurrentCreationOptions, CurrentRequestOptions) switch - { - (null, null) => (null, null), - (var createOptions, null) => (createOptions, "create"), - (null, var requestOptions) => (requestOptions, "get"), - (not null, not null) => throw new InvalidOperationException( - $"Only one of '{nameof(CurrentCreationOptions)}' and '{nameof(CurrentRequestOptions)}' should be specified."), - }; - } - - private async Task OnSubmitAsync() - { - if (ResponseJson is { Length: > 0 } responseJson) - { - await OnResponse.InvokeAsync(responseJson); - } - else - { - await OnError.InvokeAsync(Error); - } - } -} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeyHandler.razor.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeyHandler.razor.js deleted file mode 100644 index 726a82d6355b..000000000000 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeyHandler.razor.js +++ /dev/null @@ -1,78 +0,0 @@ -async function createCredential(optionsJSON) { - // See: https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential - - // 1. Let options be a new PublicKeyCredentialCreationOptions structure configured to - // the Relying Party’s needs for the ceremony. - // See: https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-parsecreationoptionsfromjson - const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJSON); - - // 2. Call navigator.credentials.create() and pass options as the publicKey option. - // Let credential be the result of the successfully resolved promise. - // If the promise is rejected, abort the ceremony with a user-visible error, - // or otherwise guide the user experience as might be determinable from the - // context available in the rejected promise. - const credential = await navigator.credentials.create({ publicKey: options }); - - // 3. Let response be credential.response. If response is not an instance of - // AuthenticatorAttestationResponse, abort the ceremony with a user-visible error. - if (!(credential?.response instanceof AuthenticatorAttestationResponse)) { - throw new Error('The authenticator failed to provide a valid credential.'); - } - - // Continue the ceremony on the server. - // See: https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-tojson - return JSON.stringify(credential); -} - -async function getCredential(optionsJSON) { - // See: https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion - - // 1. Let options be a new PublicKeyCredentialRequestOptions structure configured to - // the Relying Party’s needs for the ceremony. - // See: https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-parserequestoptionsfromjson - const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON); - - // 2. Call navigator.credentials.get() and pass options as the publicKey option. - // Let credential be the result of the successfully resolved promise. - // If the promise is rejected, abort the ceremony with a user-visible error, - // or otherwise guide the user experience as might be determinable from the - // context available in the rejected promise. - const credential = await navigator.credentials.get({ publicKey: options }); - - // 3. Let response be credential.response. If response is not an instance of - // AuthenticatorAssertionResponse, abort the ceremony with a user - visible error. - if (!(credential?.response instanceof AuthenticatorResponse)) { - throw new Error('The authenticator failed to provide a valid credential.'); - } - - // Continue the ceremony on the server. - // See: https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-tojson - return JSON.stringify(credential); -} - -async function submitResponse(action) { - const optionsScript = document.getElementById('passkey-options'); - const form = document.getElementById('passkey-response-form'); - const responseInput = document.getElementById('passkey-response'); - const errorInput = document.getElementById('passkey-error'); - - try { - const optionsJson = optionsScript.innerHTML; - const options = JSON.parse(optionsJson); - - if (action === 'create') { - responseInput.value = await createCredential(options); - } else if (action === 'get') { - responseInput.value = await getCredential(options); - } else { - throw new Error(`Unknown passkey action '${action}'.`); - } - } catch (error) { - errorInput.value = error.message; - } - - form.submit(); -} - -const action = document.currentScript.getAttribute('data-action'); -submitResponse(action); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor new file mode 100644 index 000000000000..a506d449df61 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor @@ -0,0 +1,37 @@ + + + +@code { + private string? operation; + private string? credentialJsonName; + private string? errorName; + + [Parameter] + [EditorRequired] + public PasskeyOperation Operation { get; set; } + + [Parameter] + [EditorRequired] + public string Name { get; set; } = default!; + + [Parameter] + public string? EmailName { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter(CaptureUnmatchedValues = true)] + public IDictionary? AdditionalAttributes { get; set; } + + protected override void OnParametersSet() + { + operation = Operation switch + { + PasskeyOperation.Create => "create", + PasskeyOperation.Request => "request", + _ => throw new InvalidOperationException($"Unsupported passkey operation '{Operation}'.") + }; + credentialJsonName = $"{Name}.{nameof(PasskeyInputModel.CredentialJson)}"; + errorName = $"{Name}.{nameof(PasskeyInputModel.Error)}"; + } +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/wwwroot/BlazorWeb-CSharp.lib.module.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/wwwroot/BlazorWeb-CSharp.lib.module.js new file mode 100644 index 000000000000..d163b1610a39 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/wwwroot/BlazorWeb-CSharp.lib.module.js @@ -0,0 +1,77 @@ +async function fetchWithErrorHandling(url, options = {}) { + const response = await fetch(url, { + credentials: 'include', + ...options + }); + if (!response.ok) { + const text = await response.text(); + console.error(text); + throw new Error(`The server responded with status ${response.status}.`); + } + return response; +} + +async function createCredential() { + const optionsResponse = await fetchWithErrorHandling('/Account/PasskeyCreationOptions', { + method: 'POST', + }); + const optionsJson = await optionsResponse.json(); + const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson); + return await navigator.credentials.create({ publicKey: options }); +} + +async function requestCredential(email) { + const optionsResponse = await fetchWithErrorHandling(`/Account/PasskeyRequestOptions?email=${email}`, { + method: 'POST', + }); + const optionsJson = await optionsResponse.json(); + const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson); + return await navigator.credentials.get({ publicKey: options }); +} + +customElements.define('passkey-submit', class extends HTMLElement { + connectedCallback() { + this.form = this.closest('form'); + this.attrs = { + operation: this.getAttribute('operation'), + credentialJsonName: this.getAttribute('credential-json-name'), + errorName: this.getAttribute('error-name'), + emailName: this.getAttribute('email-name'), + }; + + this.form.addEventListener('submit', (event) => { + if (event.submitter?.name === '__passkey') { + event.preventDefault(); + this.obtainCredentialAndReSubmit(); + } + }); + } + + addFormValue(name, value) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = name; + input.value = value; + this.form.appendChild(input); + } + + async obtainCredentialAndReSubmit() { + try { + let credential; + if (this.attrs.operation === 'create') { + credential = await createCredential(); + } else if (this.attrs.operation === 'request') { + const email = new FormData(this.form).get(this.attrs.emailName); + credential = await requestCredential(email); + } else { + throw new Error(`Unknown passkey operation '${operation}'`); + } + const credentialJson = JSON.stringify(credential); + this.addFormValue(this.attrs.credentialJsonName, credentialJson); + } catch (error) { + this.addFormValue(this.attrs.errorName, error.message); + console.error(error); + } + this.form.submit(); + } +}); From 331b5f6b4e6bdf6b4240d4448af2b14eabae4288 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 9 Jun 2025 10:40:20 -0400 Subject: [PATCH 12/31] PR feedback --- .../src/Microsoft.AspNetCore.Identity.csproj | 1 - .../Core/src/Passkeys/BufferSource.cs | 7 +-- ...omponentsEndpointRouteBuilderExtensions.cs | 4 +- .../wwwroot/BlazorWeb-CSharp.lib.module.js | 2 +- .../BlazorTemplateTest.cs | 6 +- .../BlazorWebTemplateTest.cs | 62 ++++++++++--------- 6 files changed, 42 insertions(+), 40 deletions(-) diff --git a/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj b/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj index 26426e690674..6a77e34972cf 100644 --- a/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj +++ b/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj @@ -10,7 +10,6 @@ true true $(InterceptorsPreviewNamespaces);Microsoft.AspNetCore.Http.Generated - true diff --git a/src/Identity/Core/src/Passkeys/BufferSource.cs b/src/Identity/Core/src/Passkeys/BufferSource.cs index c09282b2b8be..4dd5a3c78239 100644 --- a/src/Identity/Core/src/Passkeys/BufferSource.cs +++ b/src/Identity/Core/src/Passkeys/BufferSource.cs @@ -122,7 +122,7 @@ public override int GetHashCode() /// /// Gets the UTF-8 string representation of the byte buffer. /// - public override unsafe string ToString() + public override string ToString() { var span = _bytes.Span; @@ -131,9 +131,6 @@ public override unsafe string ToString() return string.Empty; } - fixed (byte* ptr = span) - { - return Encoding.UTF8.GetString(ptr, span.Length); - } + return Encoding.UTF8.GetString(span); } } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs index db27d9076403..4a791f890e13 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs @@ -71,9 +71,9 @@ public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEn accountGroup.MapPost("/PasskeyRequestOptions", async ( [FromServices] UserManager userManager, [FromServices] SignInManager signInManager, - [FromQuery] string? email) => + [FromQuery] string? username) => { - var user = string.IsNullOrEmpty(email) ? null : await userManager.FindByEmailAsync(email); + var user = string.IsNullOrEmpty(username) ? null : await userManager.FindByNameAsync(username); var passkeyRequestArgs = new PasskeyRequestArgs { User = user, diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/wwwroot/BlazorWeb-CSharp.lib.module.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/wwwroot/BlazorWeb-CSharp.lib.module.js index d163b1610a39..e51fc531bf92 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/wwwroot/BlazorWeb-CSharp.lib.module.js +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/wwwroot/BlazorWeb-CSharp.lib.module.js @@ -21,7 +21,7 @@ async function createCredential() { } async function requestCredential(email) { - const optionsResponse = await fetchWithErrorHandling(`/Account/PasskeyRequestOptions?email=${email}`, { + const optionsResponse = await fetchWithErrorHandling(`/Account/PasskeyRequestOptions?username=${email}`, { method: 'POST', }); const optionsJson = await optionsResponse.json(); diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs index c655e6c26fe7..7651f0125b5b 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs @@ -136,7 +136,7 @@ await Task.WhenAll( await IncrementCounterAsync(page); } - if (authenticationFeatures.HasFlag(AuthenticationFeatures.Basic)) + if (authenticationFeatures.HasFlag(AuthenticationFeatures.RegisterAndLogIn)) { await Task.WhenAll( page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), @@ -214,7 +214,7 @@ await Task.WhenAll( await page.ClickAsync("text=Add a new passkey"); await page.WaitForSelectorAsync("text=Enter a name for your passkey"); - await page.FillAsync("[name=\"RenameInput.DisplayName\"]", "My passkey"); + await page.FillAsync("[name=\"Input.Name\"]", "My passkey"); await page.ClickAsync("text=Continue"); await page.WaitForSelectorAsync("text=Passkey updated successfully"); @@ -300,7 +300,7 @@ protected enum BlazorTemplatePages protected enum AuthenticationFeatures { None = 0, - Basic = 1, + RegisterAndLogIn = 1, Passkeys = 2, } } diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs index 3d81b47ce311..cff83ab7b3fb 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs @@ -2,10 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; using Microsoft.AspNetCore.BrowserTesting; -using Microsoft.AspNetCore.InternalTesting; using Templates.Test.Helpers; namespace BlazorTemplates.Tests; @@ -20,8 +17,7 @@ public class BlazorWebTemplateTest(ProjectFactoryFixture projectFactory) : Blazo [InlineData(BrowserKind.Chromium, "WebAssembly")] [InlineData(BrowserKind.Chromium, "Auto")] [InlineData(BrowserKind.Chromium, "None", "Individual")] - [InlineData(BrowserKind.Chromium, "None", "Individual", true)] - public async Task BlazorWebTemplate_Works(BrowserKind browserKind, string interactivityOption, string authOption = "None", bool testPasskeys = false) + public async Task BlazorWebTemplate_Works(BrowserKind browserKind, string interactivityOption, string authOption = "None") { var project = await CreateBuildPublishAsync( args: ["-int", interactivityOption, "-au", authOption], @@ -32,16 +28,42 @@ public async Task BlazorWebTemplate_Works(BrowserKind browserKind, string intera ? BlazorTemplatePages.Counter : BlazorTemplatePages.None; - var authenticationFeatures = AuthenticationFeatures.None; - if (authOption is not "None") - { - authenticationFeatures |= AuthenticationFeatures.Basic; - } - if (testPasskeys) + var authenticationFeatures = authOption is "None" + ? AuthenticationFeatures.None + : AuthenticationFeatures.RegisterAndLogIn; + + await TestProjectCoreAsync(project, browserKind, pagesToExclude, authenticationFeatures); + + bool HasClientProject() + => interactivityOption is "WebAssembly" or "Auto"; + + Project GetTargetProject(Project rootProject) { - authenticationFeatures |= AuthenticationFeatures.Passkeys; + if (HasClientProject()) + { + // Multiple projects were created, so we need to specifically select the server + // project to be used + return GetSubProject(rootProject, rootProject.ProjectName, rootProject.ProjectName); + } + + // In other cases, just use the root project + return rootProject; } + } + + [Theory] + [InlineData(BrowserKind.Chromium)] + public async Task BlazorWebTemplate_CanUsePasskeys(BrowserKind browserKind) + { + var project = await CreateBuildPublishAsync(args: ["-int", "None", "-au", "Individual"]); + var pagesToExclude = BlazorTemplatePages.Counter; + var authenticationFeatures = AuthenticationFeatures.RegisterAndLogIn | AuthenticationFeatures.Passkeys; + + await TestProjectCoreAsync(project, browserKind, pagesToExclude, authenticationFeatures); + } + private async Task TestProjectCoreAsync(Project project, BrowserKind browserKind, BlazorTemplatePages pagesToExclude, AuthenticationFeatures authenticationFeatures) + { var appName = project.ProjectName; // Test the built project @@ -65,21 +87,5 @@ public async Task BlazorWebTemplate_Works(BrowserKind browserKind, string intera await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); await TestBasicInteractionInNewPageAsync(browserKind, aspNetProcess.ListeningUri.AbsoluteUri, appName, pagesToExclude, authenticationFeatures); } - - bool HasClientProject() - => interactivityOption is "WebAssembly" or "Auto"; - - Project GetTargetProject(Project rootProject) - { - if (HasClientProject()) - { - // Multiple projects were created, so we need to specifically select the server - // project to be used - return GetSubProject(rootProject, rootProject.ProjectName, rootProject.ProjectName); - } - - // In other cases, just use the root project - return rootProject; - } } } From beecc323fb4abf29fc40ab167cf329f1131720ab Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 9 Jun 2025 14:05:43 -0400 Subject: [PATCH 13/31] PR feedback --- src/Identity/Core/src/Passkeys/BufferSourceJsonConverter.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Identity/Core/src/Passkeys/BufferSourceJsonConverter.cs b/src/Identity/Core/src/Passkeys/BufferSourceJsonConverter.cs index 601b789236d5..2997fae71830 100644 --- a/src/Identity/Core/src/Passkeys/BufferSourceJsonConverter.cs +++ b/src/Identity/Core/src/Passkeys/BufferSourceJsonConverter.cs @@ -89,5 +89,11 @@ private static void WriteBase64UrlStringValue(Utf8JsonWriter writer, ReadOnlySpa var base64UrlUtf8 = byteSpan[..bytesWritten]; writer.WriteStringValue(base64UrlUtf8); + + if (pooledArray != null) + { + byteSpan.Clear(); + ArrayPool.Shared.Return(pooledArray); + } } } From 2636c5e393ec81a06a53cd680c66ab65316867a5 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 9 Jun 2025 14:51:29 -0400 Subject: [PATCH 14/31] Remove PasskeyRequestContext --- .../Core/src/PasskeyRequestContext.cs | 20 ------------------- src/Identity/Core/src/PublicAPI.Unshipped.txt | 6 ------ 2 files changed, 26 deletions(-) delete mode 100644 src/Identity/Core/src/PasskeyRequestContext.cs diff --git a/src/Identity/Core/src/PasskeyRequestContext.cs b/src/Identity/Core/src/PasskeyRequestContext.cs deleted file mode 100644 index 001cfa62e1d9..000000000000 --- a/src/Identity/Core/src/PasskeyRequestContext.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.AspNetCore.Identity; - -/// -/// Contains passkey-relevant information about the current request. -/// -public sealed class PasskeyRequestContext -{ - /// - /// Gets or sets the server domain. - /// - public string? Domain { get; set; } - - /// - /// Gets or sets the request origin. - /// - public string? Origin { get; set; } -} diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 06f45692c9d0..759b348c8909 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -49,12 +49,6 @@ 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.PasskeyRequestContext -Microsoft.AspNetCore.Identity.PasskeyRequestContext.Domain.get -> string? -Microsoft.AspNetCore.Identity.PasskeyRequestContext.Domain.set -> void -Microsoft.AspNetCore.Identity.PasskeyRequestContext.Origin.get -> string? -Microsoft.AspNetCore.Identity.PasskeyRequestContext.Origin.set -> void -Microsoft.AspNetCore.Identity.PasskeyRequestContext.PasskeyRequestContext() -> void Microsoft.AspNetCore.Identity.PasskeyRequestOptions Microsoft.AspNetCore.Identity.PasskeyRequestOptions.AsJson() -> string! Microsoft.AspNetCore.Identity.PasskeyRequestOptions.PasskeyRequestOptions(string? userId, string! optionsJson) -> void From ce0f0b1639a77954504f4a84728924b7c13a84fa Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 10 Jun 2025 09:32:47 -0400 Subject: [PATCH 15/31] API cleanups --- src/Identity/Core/src/SignInManager.cs | 2 +- .../Extensions.Core/src/PublicAPI.Unshipped.txt | 10 +++++----- src/Identity/Extensions.Core/src/UserPasskeyInfo.cs | 10 +++++----- .../Extensions.Stores/src/IdentityUserPasskey.cs | 8 ++++---- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index b68d550faed6..fa48334f65bc 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -654,7 +654,7 @@ public virtual async Task ConfigurePasskeyRequestOptionsA } /// - /// Generates a to request an existing passkey for a user. + /// Generates a to request an existing passkey for a user. /// /// Args for configuring the . /// diff --git a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt index 5629c19fc226..7ae4d69dd3bc 100644 --- a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt @@ -45,6 +45,11 @@ Microsoft.AspNetCore.Identity.UserPasskeyInfo.AttestationObject.get -> byte[]! Microsoft.AspNetCore.Identity.UserPasskeyInfo.ClientDataJson.get -> byte[]! Microsoft.AspNetCore.Identity.UserPasskeyInfo.CreatedAt.get -> System.DateTimeOffset Microsoft.AspNetCore.Identity.UserPasskeyInfo.CredentialId.get -> byte[]! +Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsBackedUp.get -> bool +Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsBackedUp.set -> void +Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsBackupEligible.get -> bool +Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsUserVerified.get -> bool +Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsUserVerified.set -> void Microsoft.AspNetCore.Identity.UserPasskeyInfo.Name.get -> string? Microsoft.AspNetCore.Identity.UserPasskeyInfo.Name.set -> void Microsoft.AspNetCore.Identity.UserPasskeyInfo.PublicKey.get -> byte[]! @@ -59,8 +64,3 @@ virtual Microsoft.AspNetCore.Identity.UserManager.GetPasskeysAsync(TUser! virtual Microsoft.AspNetCore.Identity.UserManager.RemovePasskeyAsync(TUser! user, byte[]! credentialId) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserManager.SetPasskeyAsync(TUser! user, Microsoft.AspNetCore.Identity.UserPasskeyInfo! passkey) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserManager.SupportsUserPasskey.get -> bool -virtual Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsBackedUp.get -> bool -virtual Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsBackedUp.set -> void -virtual Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsBackupEligible.get -> bool -virtual Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsUserVerified.get -> bool -virtual Microsoft.AspNetCore.Identity.UserPasskeyInfo.IsUserVerified.set -> void diff --git a/src/Identity/Extensions.Core/src/UserPasskeyInfo.cs b/src/Identity/Extensions.Core/src/UserPasskeyInfo.cs index 129cabd10158..c96335cafba1 100644 --- a/src/Identity/Extensions.Core/src/UserPasskeyInfo.cs +++ b/src/Identity/Extensions.Core/src/UserPasskeyInfo.cs @@ -53,12 +53,12 @@ public UserPasskeyInfo( /// /// Gets the credential ID for this passkey. /// - public byte[] CredentialId { get; } = []; + public byte[] CredentialId { get; } /// /// Gets the public key associated with this passkey. /// - public byte[] PublicKey { get; } = []; + public byte[] PublicKey { get; } /// /// Gets or sets the friendly name for this passkey. @@ -86,17 +86,17 @@ public UserPasskeyInfo( /// /// Gets or sets whether the passkey has a verified user. /// - public virtual bool IsUserVerified { get; set; } + public bool IsUserVerified { get; set; } /// /// Gets whether the passkey is eligible for backup. /// - public virtual bool IsBackupEligible { get; } + public bool IsBackupEligible { get; } /// /// Gets or sets whether the passkey is currently backed up. /// - public virtual bool IsBackedUp { get; set; } + public bool IsBackedUp { get; set; } /// /// Gets the attestation object associated with this passkey. diff --git a/src/Identity/Extensions.Stores/src/IdentityUserPasskey.cs b/src/Identity/Extensions.Stores/src/IdentityUserPasskey.cs index 4a54e38707bb..d05f940bc79b 100644 --- a/src/Identity/Extensions.Stores/src/IdentityUserPasskey.cs +++ b/src/Identity/Extensions.Stores/src/IdentityUserPasskey.cs @@ -22,12 +22,12 @@ public class IdentityUserPasskey where TKey : IEquatable /// /// Gets or sets the credential ID for this passkey. /// - public virtual byte[] CredentialId { get; set; } = []; + public virtual byte[] CredentialId { get; set; } = default!; /// /// Gets or sets the public key associated with this passkey. /// - public virtual byte[] PublicKey { get; set; } = []; + public virtual byte[] PublicKey { get; set; } = default!; /// /// Gets or sets the friendly name for this passkey. @@ -73,7 +73,7 @@ public class IdentityUserPasskey where TKey : IEquatable /// /// See . /// - public virtual byte[] AttestationObject { get; set; } = []; + public virtual byte[] AttestationObject { get; set; } = default!; /// /// Gets or sets the collected client data JSON associated with this passkey. @@ -81,5 +81,5 @@ public class IdentityUserPasskey where TKey : IEquatable /// /// See . /// - public virtual byte[] ClientDataJson { get; set; } = []; + public virtual byte[] ClientDataJson { get; set; } = default!; } From 7f631025fe747c82327656aee01d306348e3e019 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 10 Jun 2025 11:36:24 -0400 Subject: [PATCH 16/31] Try to fix CI --- eng/tools/GenerateFiles/Directory.Build.targets.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/tools/GenerateFiles/Directory.Build.targets.in b/eng/tools/GenerateFiles/Directory.Build.targets.in index 2f5fb8ec7b39..7af7010bb2ec 100644 --- a/eng/tools/GenerateFiles/Directory.Build.targets.in +++ b/eng/tools/GenerateFiles/Directory.Build.targets.in @@ -140,7 +140,7 @@ false - + From 8ee6b194f77955b6c665d3916c18562277e28783 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 10 Jun 2025 11:52:07 -0400 Subject: [PATCH 17/31] Update Versions.props --- eng/Versions.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/Versions.props b/eng/Versions.props index dcdf87264d4a..88cc6aff0012 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -115,7 +115,7 @@ 10.0.0-preview.6.25304.106 10.0.0-preview.6.25304.106 10.0.0-preview.6.25304.106 - 10.0.0-preview.6.25303.102 + 10.0.0-preview.6.25304.106 10.0.0-preview.6.25304.106 10.0.0-preview.6.25304.106 10.0.0-preview.6.25304.106 From e82ddcb88859b06f5f624cab586f584b4e06a75d Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 10 Jun 2025 13:01:06 -0400 Subject: [PATCH 18/31] Update Directory.Build.targets.in --- eng/tools/GenerateFiles/Directory.Build.targets.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/tools/GenerateFiles/Directory.Build.targets.in b/eng/tools/GenerateFiles/Directory.Build.targets.in index 7af7010bb2ec..a0e1f3e17908 100644 --- a/eng/tools/GenerateFiles/Directory.Build.targets.in +++ b/eng/tools/GenerateFiles/Directory.Build.targets.in @@ -140,7 +140,7 @@ false - + From b114f44fe15f5233ed27b63ca2db5cf4b275d726 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 10 Jun 2025 15:28:36 -0400 Subject: [PATCH 19/31] Better exceptions + clean up JSON representation --- .../Core/src/DefaultPasskeyHandler.cs | 116 ++++++++++++------ src/Identity/Core/src/PasskeyException.cs | 2 +- .../Core/src/PasskeyExceptionExtensions.cs | 66 +++++++++- .../Core/src/Passkeys/AttestationObject.cs | 84 ++++++++++--- .../src/Passkeys/AttestedCredentialData.cs | 71 ++++++----- .../AuthenticatorAssertionResponse.cs | 8 +- .../AuthenticatorAttestationResponse.cs | 6 +- .../Core/src/Passkeys/AuthenticatorData.cs | 114 +++++++++-------- .../src/Passkeys/AuthenticatorResponse.cs | 4 +- .../Core/src/Passkeys/CollectedClientData.cs | 18 +-- .../Core/src/Passkeys/PublicKeyCredential.cs | 14 +-- .../PublicKeyCredentialCreationOptions.cs | 35 +++--- .../Passkeys/PublicKeyCredentialDescriptor.cs | 12 +- .../Passkeys/PublicKeyCredentialParameters.cs | 12 ++ .../PublicKeyCredentialRequestOptions.cs | 18 +-- .../Passkeys/PublicKeyCredentialRpEntity.cs | 9 +- .../Passkeys/PublicKeyCredentialUserEntity.cs | 14 +-- .../Core/src/Passkeys/TokenBinding.cs | 8 +- src/Identity/Core/src/PublicAPI.Unshipped.txt | 4 +- src/Identity/Core/src/SignInManager.cs | 40 +++--- .../appsettings.Development.json | 3 +- 21 files changed, 423 insertions(+), 235 deletions(-) diff --git a/src/Identity/Core/src/DefaultPasskeyHandler.cs b/src/Identity/Core/src/DefaultPasskeyHandler.cs index eca5c8ec481d..e3efd6b91b8a 100644 --- a/src/Identity/Core/src/DefaultPasskeyHandler.cs +++ b/src/Identity/Core/src/DefaultPasskeyHandler.cs @@ -89,10 +89,28 @@ private async Task PerformAttestationCoreAsync( // 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. - var credential = JsonSerializer.Deserialize(credentialJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialAuthenticatorAttestationResponse) - ?? throw new InvalidOperationException("The attestation JSON was unexpectedly null."); - var originalOptions = JsonSerializer.Deserialize(originalOptionsJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialCreationOptions) - ?? throw new InvalidOperationException("The original passkey creation options were unexpectedly null."); + PublicKeyCredential credential; + PublicKeyCredentialCreationOptions originalOptions; + + try + { + credential = JsonSerializer.Deserialize(credentialJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialAuthenticatorAttestationResponse) + ?? throw PasskeyException.NullAttestationCredentialJson(); + } + catch (JsonException ex) + { + throw PasskeyException.InvalidAttestationCredentialJsonFormat(ex); + } + + try + { + originalOptions = JsonSerializer.Deserialize(originalOptionsJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialCreationOptions) + ?? throw PasskeyException.NullOriginalCreationOptionsJson(); + } + catch (JsonException ex) + { + throw PasskeyException.InvalidOriginalCreationOptionsJsonFormat(ex); + } if (!string.Equals("public-key", credential.Type, StringComparison.Ordinal)) { @@ -107,8 +125,16 @@ private async Task PerformAttestationCoreAsync( // 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. - var clientData = JsonSerializer.Deserialize(response.ClientDataJSON.AsSpan(), IdentityJsonSerializerContext.Default.CollectedClientData) - ?? throw new InvalidOperationException("The client data JSON was unexpectedly null."); + CollectedClientData clientData; + try + { + clientData = JsonSerializer.Deserialize(response.ClientDataJSON.AsSpan(), IdentityJsonSerializerContext.Default.CollectedClientData) + ?? throw PasskeyException.NullClientDataJson(); + } + catch (JsonException ex) + { + throw PasskeyException.InvalidClientDataJsonFormat(ex); + } // 7. Verify that the value of clientData.type is webauthn.create. if (!string.Equals("webauthn.create", clientData.Type, StringComparison.Ordinal)) @@ -151,14 +177,8 @@ private async Task PerformAttestationCoreAsync( // 13. Perform CBOR decoding on the attestationObject field of the AuthenticatorAttestationResponse structure and obtain the // the authenticator data authenticatorData. var attestationObjectMemory = response.AttestationObject.AsMemory(); - if (!AttestationObject.TryParse(attestationObjectMemory, out var attestationObject)) - { - throw PasskeyException.InvalidAttestationObject(); - } - if (!AuthenticatorData.TryParse(attestationObject.AuthData, out var authenticatorData)) - { - throw PasskeyException.InvalidAuthenticatorData(); - } + 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. var rpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(originalOptions.Rp.Id ?? string.Empty)); @@ -216,9 +236,10 @@ private async Task PerformAttestationCoreAsync( } // The attested credential data should always be non-null if the 'HasAttestedCredentialData' flag is set. - Debug.Assert(authenticatorData.AttestedCredentialData is not null); + var attestedCredentialData = authenticatorData.AttestedCredentialData; + Debug.Assert(attestedCredentialData is not null); - if (!originalOptions.PubKeyCredParams.Any(a => authenticatorData.AttestedCredentialData.CredentialPublicKey.Alg == a.Alg)) + if (!originalOptions.PubKeyCredParams.Any(a => attestedCredentialData.CredentialPublicKey.Alg == a.Alg)) { throw PasskeyException.UnsupportedCredentialPublicKeyAlgorithm(); } @@ -233,17 +254,15 @@ private async Task PerformAttestationCoreAsync( } // 25. Verify that the credentialId is <= 1023 bytes. - if (credential.Id is not { } credentialIdBufferSource) - { - throw PasskeyException.MissingCredentialId(); - } - if (credentialIdBufferSource.Length is not > 0 and <= 1023) + // NOTE: Handled while parsing the attested credential data. + if (!credential.Id.AsSpan().SequenceEqual(attestedCredentialData.CredentialId.Span)) { - throw PasskeyException.InvalidCredentialIdLength(credentialIdBufferSource.Length); + throw PasskeyException.CredentialIdMismatch(); } + var credentialId = attestedCredentialData.CredentialId.ToArray(); + // 26. Verify that the credentialId is not yet registered for any user. - var credentialId = credentialIdBufferSource.ToArray(); var existingUser = await userManager.FindByPasskeyIdAsync(credentialId).ConfigureAwait(false); if (existingUser is not null) { @@ -251,12 +270,11 @@ private async Task PerformAttestationCoreAsync( } // 27. Let credentialRecord be a new credential record with the following contents: - var attestedCredentialData = authenticatorData.AttestedCredentialData; var credentialRecord = new UserPasskeyInfo( - credentialId: credentialId, + credentialId, publicKey: attestedCredentialData.CredentialPublicKey.ToArray(), name: null, - createdAt: DateTime.Now, + createdAt: DateTime.UtcNow, signCount: authenticatorData.SignCount, transports: response.Transports, isUserVerified: authenticatorData.IsUserVerified, @@ -280,14 +298,32 @@ private async Task> PerformAssertionCoreAsync( string originalOptionsJson, UserManager userManager) { - // See: https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential + // 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. - var credential = JsonSerializer.Deserialize(credentialJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialAuthenticatorAssertionResponse) - ?? throw new InvalidOperationException("The assertion JSON was unexpectedly null."); - var originalOptions = JsonSerializer.Deserialize(originalOptionsJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialRequestOptions) - ?? throw new InvalidOperationException("The original passkey request options were unexpectedly null."); + PublicKeyCredential credential; + PublicKeyCredentialRequestOptions originalOptions; + + try + { + credential = JsonSerializer.Deserialize(credentialJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialAuthenticatorAssertionResponse) + ?? throw PasskeyException.NullAssertionCredentialJson(); + } + catch (JsonException ex) + { + throw PasskeyException.InvalidAssertionCredentialJsonFormat(ex); + } + + try + { + originalOptions = JsonSerializer.Deserialize(originalOptionsJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialRequestOptions) + ?? throw PasskeyException.NullOriginalRequestOptionsJson(); + } + catch (JsonException ex) + { + throw PasskeyException.InvalidOriginalRequestOptionsJsonFormat(ex); + } if (!string.Equals("public-key", credential.Type, StringComparison.Ordinal)) { @@ -357,14 +393,20 @@ private async Task> PerformAssertionCoreAsync( } // 7. Let cData, authData and sig denote the value of response’s clientDataJSON, authenticatorData, and signature respectively. - if (!AuthenticatorData.TryParse(response.AuthenticatorData.AsMemory(), out var authenticatorData)) - { - throw PasskeyException.InvalidAuthenticatorData(); - } + 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. - var clientData = JsonSerializer.Deserialize(response.ClientDataJSON.AsSpan(), IdentityJsonSerializerContext.Default.CollectedClientData) - ?? throw new InvalidOperationException("The client data JSON was unexpectedly null."); + CollectedClientData clientData; + try + { + clientData = JsonSerializer.Deserialize(response.ClientDataJSON.AsSpan(), IdentityJsonSerializerContext.Default.CollectedClientData) + ?? throw PasskeyException.NullClientDataJson(); + } + catch (JsonException ex) + { + throw PasskeyException.InvalidClientDataJsonFormat(ex); + } // 10. Verify that the value of C.type is the string webauthn.get. if (!string.Equals("webauthn.get", clientData.Type, StringComparison.Ordinal)) diff --git a/src/Identity/Core/src/PasskeyException.cs b/src/Identity/Core/src/PasskeyException.cs index 750f00bc203e..f10a926fd333 100644 --- a/src/Identity/Core/src/PasskeyException.cs +++ b/src/Identity/Core/src/PasskeyException.cs @@ -19,7 +19,7 @@ public PasskeyException(string message) /// /// Constructs a new instance. /// - public PasskeyException(string message, Exception innerException) + 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 index 25e05e85b87b..e379a2ab96b8 100644 --- a/src/Identity/Core/src/PasskeyExceptionExtensions.cs +++ b/src/Identity/Core/src/PasskeyExceptionExtensions.cs @@ -1,6 +1,9 @@ // 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.Text.Json; + namespace Microsoft.AspNetCore.Identity; internal static class PasskeyExceptionExtensions @@ -55,6 +58,9 @@ public static PasskeyException InvalidAttestationStatement() 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."); @@ -82,16 +88,64 @@ public static PasskeyException InvalidAssertionSignature() 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() - => new("The attestation object was in an invalid format."); + public static PasskeyException InvalidAttestationObject(Exception ex) + => new($"An exception occurred while parsing the attestation object: {ex.Message}", ex); + + public static PasskeyException InvalidAttestationObjectFormat(CborContentException 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 InvalidAuthenticatorData() - => new("The authenticator data was in an invalid format."); + public static PasskeyException MissingAuthenticatorData() + => new("The attestation object did not include authenticator data."); - public static PasskeyException MissingCredentialId() - => new("The credential ID was missing."); + public static PasskeyException InvalidAuthenticatorDataLength(int length) + => new($"The authenticator data had an invalid length of {length} bytes."); + + 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 length of {length} bytes."); + + 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); } } diff --git a/src/Identity/Core/src/Passkeys/AttestationObject.cs b/src/Identity/Core/src/Passkeys/AttestationObject.cs index 5693c74ec232..2d2433f7fff0 100644 --- a/src/Identity/Core/src/Passkeys/AttestationObject.cs +++ b/src/Identity/Core/src/Passkeys/AttestationObject.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics.CodeAnalysis; using System.Formats.Cbor; namespace Microsoft.AspNetCore.Identity; @@ -12,22 +11,60 @@ namespace Microsoft.AspNetCore.Identity; /// /// See . /// -internal sealed class AttestationObject(string fmt, ReadOnlyMemory attStmt, ReadOnlyMemory authData) +internal sealed class AttestationObject { - public string Fmt => fmt; + /// + /// Gets or sets the attestation statement format. + /// + /// + /// See . + /// + public required string Format { get; init; } - public ReadOnlyMemory AttStmt => attStmt; + /// + /// Gets or sets the attestation statement. + /// + /// + /// See . + /// + public required ReadOnlyMemory AttestationStatement { get; init; } - public ReadOnlyMemory AuthData => authData; + /// + /// Gets or sets the authenticator data. + /// + /// + /// See . + /// + public required ReadOnlyMemory AuthenticatorData { get; init; } - public static bool TryParse(ReadOnlyMemory data, [NotNullWhen(true)] out AttestationObject? result) + public static AttestationObject Parse(ReadOnlyMemory data) + { + try + { + return ParseCore(data); + } + catch (PasskeyException) + { + throw; + } + catch (CborContentException 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? fmt = null; - ReadOnlyMemory? attStmt = default; - ReadOnlyMemory? authData = default; + string? format = null; + ReadOnlyMemory? attestationStatement = default; + ReadOnlyMemory? authenticatorData = default; while (reader.PeekState() != CborReaderState.EndMap) { @@ -35,13 +72,13 @@ public static bool TryParse(ReadOnlyMemory data, [NotNullWhen(true)] out A switch (key) { case "fmt": - fmt = reader.ReadTextString(); + format = reader.ReadTextString(); break; case "attStmt": - attStmt = reader.ReadEncodedValue(); + attestationStatement = reader.ReadEncodedValue(); break; case "authData": - authData = reader.ReadByteString(); + authenticatorData = reader.ReadByteString(); break; default: // Unknown key - skip. @@ -50,13 +87,26 @@ public static bool TryParse(ReadOnlyMemory data, [NotNullWhen(true)] out A } } - if (fmt is null || !attStmt.HasValue || !authData.HasValue) + if (format is null) + { + throw PasskeyException.MissingAttestationStatementFormat(); + } + + if (!attestationStatement.HasValue) { - result = null; - return false; + throw PasskeyException.MissingAttestationStatement(); } - result = new(fmt, attStmt.Value, authData.Value); - return true; + 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 index 27e7de7d144a..7c09c3ba5e9c 100644 --- a/src/Identity/Core/src/Passkeys/AttestedCredentialData.cs +++ b/src/Identity/Core/src/Passkeys/AttestedCredentialData.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Buffers.Binary; -using System.Diagnostics.CodeAnalysis; namespace Microsoft.AspNetCore.Identity; @@ -15,59 +14,73 @@ namespace Microsoft.AspNetCore.Identity; internal sealed class AttestedCredentialData { /// - /// Gets the AAGUID of the authenticator that created the credential. + /// Gets or sets the AAGUID of the authenticator that created the credential. /// - public ReadOnlyMemory Aaguid { get; } + public required ReadOnlyMemory Aaguid { get; init; } /// - /// Gets the credential ID. + /// Gets or sets the credential ID. /// - public ReadOnlyMemory CredentialId { get; } + public required ReadOnlyMemory CredentialId { get; init; } /// - /// Gets the credential public key. + /// Gets or sets the credential public key. /// - public CredentialPublicKey CredentialPublicKey { get; } + public required CredentialPublicKey CredentialPublicKey { get; init; } - private AttestedCredentialData( - ReadOnlyMemory aaguid, - ReadOnlyMemory credentialId, - CredentialPublicKey credentialPublicKey) + public static AttestedCredentialData Parse(ReadOnlyMemory data, out int bytesRead) { - Aaguid = aaguid; - CredentialId = credentialId; - CredentialPublicKey = credentialPublicKey; + try + { + return ParseCore(data, out bytesRead); + } + catch (PasskeyException) + { + throw; + } + catch (Exception ex) + { + throw PasskeyException.InvalidAttestedCredentialDataFormat(ex); + } } - public static bool TryParse(ReadOnlyMemory data, out int bytesRead, [NotNullWhen(true)] out AttestedCredentialData? result) + private static AttestedCredentialData ParseCore(ReadOnlyMemory data, out int bytesRead) { - const int MinLength = 18; // aaguid + credential ID length + const int AaguidLength = 16; + const int CredentialIdLengthLength = 2; + const int MinLength = AaguidLength + CredentialIdLengthLength; const int MaxCredentialIdLength = 1023; - result = null; - bytesRead = 0; + var offset = 0; if (data.Length < MinLength) { - return false; + throw PasskeyException.InvalidAttestedCredentialDataLength(data.Length); } - var aaguid = data.Slice(0, 16); - var credentialIDLen = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(start: 16, length: 2).Span); - if (credentialIDLen > MaxCredentialIdLength) + var aaguid = data.Slice(offset, AaguidLength); + offset += AaguidLength; + + var credentialIdLength = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, CredentialIdLengthLength).Span); + offset += CredentialIdLengthLength; + + if (credentialIdLength > MaxCredentialIdLength) { - return false; + throw PasskeyException.InvalidCredentialIdLength(credentialIdLength); } - var offset = 18; - var credentialID = data.Slice(offset, credentialIDLen).ToArray(); - offset += credentialIDLen; + var credentialId = data.Slice(offset, credentialIdLength).ToArray(); + offset += credentialIdLength; - var credentialPublicKey = CredentialPublicKey.Decode(data.Slice(offset), out int read); + var credentialPublicKey = CredentialPublicKey.Decode(data[offset..], out var read); offset += read; bytesRead = offset; - result = new AttestedCredentialData(aaguid, credentialID, credentialPublicKey); - return true; + 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 index adf1996f80a2..3468ac46bffd 100644 --- a/src/Identity/Core/src/Passkeys/AuthenticatorAssertionResponse.cs +++ b/src/Identity/Core/src/Passkeys/AuthenticatorAssertionResponse.cs @@ -10,20 +10,20 @@ namespace Microsoft.AspNetCore.Identity; /// /// See . /// -internal sealed class AuthenticatorAssertionResponse(BufferSource authenticatorData, BufferSource signature, BufferSource? userHandle, BufferSource clientDataJSON) : AuthenticatorResponse(clientDataJSON) +internal sealed class AuthenticatorAssertionResponse : AuthenticatorResponse { /// /// Gets or sets the authenticator data. /// - public BufferSource AuthenticatorData { get; } = authenticatorData; + public required BufferSource AuthenticatorData { get; init; } /// /// Gets or sets the assertion signature. /// - public BufferSource Signature { get; } = signature; + public required BufferSource Signature { get; init; } /// /// Gets or sets the opaque user identifier. /// - public BufferSource? UserHandle { get; } = userHandle; + public BufferSource? UserHandle { get; init; } } diff --git a/src/Identity/Core/src/Passkeys/AuthenticatorAttestationResponse.cs b/src/Identity/Core/src/Passkeys/AuthenticatorAttestationResponse.cs index 5f120cf85475..b7dad16c0f25 100644 --- a/src/Identity/Core/src/Passkeys/AuthenticatorAttestationResponse.cs +++ b/src/Identity/Core/src/Passkeys/AuthenticatorAttestationResponse.cs @@ -10,12 +10,12 @@ namespace Microsoft.AspNetCore.Identity; /// /// See . /// -internal sealed class AuthenticatorAttestationResponse(BufferSource attestationObject, BufferSource clientDataJSON) : AuthenticatorResponse(clientDataJSON) +internal sealed class AuthenticatorAttestationResponse : AuthenticatorResponse { /// /// Gets or sets the attestation object. /// - public BufferSource AttestationObject { get; set; } = attestationObject; + public required BufferSource AttestationObject { get; init; } /// /// Gets or sets the strings describing which transport methods (e.g., usb, nfc) are believed @@ -24,5 +24,5 @@ internal sealed class AuthenticatorAttestationResponse(BufferSource attestationO /// /// May be empty or null if the information is not available. /// - public string[]? Transports { get; set; } = []; + public string[]? Transports { get; init; } = []; } diff --git a/src/Identity/Core/src/Passkeys/AuthenticatorData.cs b/src/Identity/Core/src/Passkeys/AuthenticatorData.cs index dc52050dace3..469e2cc52f43 100644 --- a/src/Identity/Core/src/Passkeys/AuthenticatorData.cs +++ b/src/Identity/Core/src/Passkeys/AuthenticatorData.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Buffers.Binary; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics; using System.Formats.Cbor; namespace Microsoft.AspNetCore.Identity; @@ -13,102 +13,116 @@ namespace Microsoft.AspNetCore.Identity; /// /// See /// -internal sealed class AuthenticatorData( - ReadOnlyMemory rpIdHash, - AuthenticatorDataFlags flags, - uint signCount, - AttestedCredentialData? attestedCredentialData, - ReadOnlyMemory? extensions) +internal sealed class AuthenticatorData { - private readonly AuthenticatorDataFlags _flags = flags; - /// - /// Gets the SHA-256 hash of the Relying Party ID the credential is scoped to. + /// Gets or sets the SHA-256 hash of the Relying Party ID the credential is scoped to. /// - public ReadOnlyMemory RpIdHash { get; } = rpIdHash; + public required ReadOnlyMemory RpIdHash { get; init; } /// - /// Gets the signature counter. + /// Gets or sets the flags for this authenticator data. /// - public uint SignCount { get; } = signCount; + public required AuthenticatorDataFlags Flags { get; init; } /// - /// Gets the attested credential data. + /// Gets or sets the signature counter. /// - public AttestedCredentialData? AttestedCredentialData { get; } = attestedCredentialData; + public required uint SignCount { get; init; } /// - /// Gets the extension-defined authenticator data. + /// Gets or sets the attested credential data. /// - public ReadOnlyMemory? Extensions { get; } = extensions; + public AttestedCredentialData? AttestedCredentialData { get; init; } /// - /// Gets the flags for this authenticator data. + /// Gets or sets the extension-defined authenticator data. /// - public AuthenticatorDataFlags Flags => _flags; + public ReadOnlyMemory? Extensions { get; init; } /// /// Gets whether the user is present. /// - public bool IsUserPresent => _flags.HasFlag(AuthenticatorDataFlags.UserPresent); + public bool IsUserPresent => Flags.HasFlag(AuthenticatorDataFlags.UserPresent); /// /// Gets whether the user is verified. /// - public bool IsUserVerified => _flags.HasFlag(AuthenticatorDataFlags.UserVerified); + public bool IsUserVerified => Flags.HasFlag(AuthenticatorDataFlags.UserVerified); /// /// Gets whether the public key credential source is backup eligible. /// - public bool IsBackupEligible => _flags.HasFlag(AuthenticatorDataFlags.BackupEligible); + 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); + public bool IsBackedUp => Flags.HasFlag(AuthenticatorDataFlags.BackedUp); /// /// Gets whether the authenticator data has extensions. /// - public bool HasExtensionsData => _flags.HasFlag(AuthenticatorDataFlags.HasExtensionData); + public bool HasExtensionsData => Flags.HasFlag(AuthenticatorDataFlags.HasExtensionData); /// /// Gets whether the authenticator added attested credential data. /// - public bool HasAttestedCredentialData => _flags.HasFlag(AuthenticatorDataFlags.HasAttestedCredentialData); + public bool HasAttestedCredentialData => Flags.HasFlag(AuthenticatorDataFlags.HasAttestedCredentialData); - public static bool TryParse(ReadOnlyMemory bytes, [NotNullWhen(true)] out AuthenticatorData? result) + public static AuthenticatorData Parse(ReadOnlyMemory bytes) { - // Min length specified in https://www.w3.org/TR/webauthn-3/#authenticator-data - const int MinLength = 37; + 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) { - result = null; - return false; + throw PasskeyException.InvalidAuthenticatorDataLength(bytes.Length); } var offset = 0; - var rpIdHash = ReadBytes(32); - var flags = (AuthenticatorDataFlags)ReadByte(); - var signCount = BinaryPrimitives.ReadUInt32BigEndian(ReadBytes(4).Span); + + 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.Slice(offset); - if (!AttestedCredentialData.TryParse(remaining, out var bytesRead, out attestedCredentialData)) - { - result = null; - return false; - } + 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.Slice(offset)); + var reader = new CborReader(bytes[offset..]); extensions = reader.ReadEncodedValue(); offset += extensions.Value.Length; } @@ -116,20 +130,16 @@ public static bool TryParse(ReadOnlyMemory bytes, [NotNullWhen(true)] out if (offset != bytes.Length) { // Leftover bytes signifies a possible parsing error. - result = null; - return false; + throw PasskeyException.InvalidAuthenticatorDataFormat(); } - result = new(rpIdHash, flags, signCount, attestedCredentialData, extensions); - return true; - - byte ReadByte() => bytes.Span[offset++]; - - ReadOnlyMemory ReadBytes(int length) + return new() { - var result = bytes.Slice(offset, length); - offset += length; - return result; - } + RpIdHash = rpIdHash, + Flags = flags, + SignCount = signCount, + AttestedCredentialData = attestedCredentialData, + Extensions = extensions, + }; } } diff --git a/src/Identity/Core/src/Passkeys/AuthenticatorResponse.cs b/src/Identity/Core/src/Passkeys/AuthenticatorResponse.cs index 9bf31e64d0ac..d2760169faf2 100644 --- a/src/Identity/Core/src/Passkeys/AuthenticatorResponse.cs +++ b/src/Identity/Core/src/Passkeys/AuthenticatorResponse.cs @@ -7,11 +7,11 @@ namespace Microsoft.AspNetCore.Identity; /// Represents the base class for responses returned by an authenticator during credential creation or retrieval /// operations. /// -internal abstract class AuthenticatorResponse(BufferSource clientDataJSON) +internal abstract class AuthenticatorResponse { /// /// Gets or sets the client data passed to /// navigator.credentials.create() or navigator.credentials.get(). /// - public BufferSource ClientDataJSON { get; } = clientDataJSON; + public required BufferSource ClientDataJSON { get; init; } } diff --git a/src/Identity/Core/src/Passkeys/CollectedClientData.cs b/src/Identity/Core/src/Passkeys/CollectedClientData.cs index 29ea6a91230c..8e2e747283da 100644 --- a/src/Identity/Core/src/Passkeys/CollectedClientData.cs +++ b/src/Identity/Core/src/Passkeys/CollectedClientData.cs @@ -9,34 +9,34 @@ namespace Microsoft.AspNetCore.Identity; /// /// See /// -internal sealed class CollectedClientData(string type, BufferSource challenge, string origin) +internal sealed class CollectedClientData { /// - /// Gets the type of the operation that produced the client data. + /// Gets or sets the type of the operation that produced the client data. /// /// /// Will be either "webauthn.create" or "webauthn.get". /// - public string Type { get; } = type; + public required string Type { get; init; } /// - /// Gets the challenge provided by the relying party. + /// Gets or sets the challenge provided by the relying party. /// - public BufferSource Challenge { get; } = challenge; + public required BufferSource Challenge { get; init; } /// - /// Gets the fully qualified origin of the requester. + /// Gets or sets the fully qualified origin of the requester. /// - public string Origin { get; } = origin; + 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; set; } + public bool? CrossOrigin { get; init; } /// /// Gets or sets information about the state of the token binding protocol. /// - public TokenBinding? TokenBinding { get; set; } + public TokenBinding? TokenBinding { get; init; } } diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredential.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredential.cs index 4a272ffe190f..64b4cc1b6e42 100644 --- a/src/Identity/Core/src/Passkeys/PublicKeyCredential.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredential.cs @@ -9,15 +9,15 @@ namespace Microsoft.AspNetCore.Identity; /// Represents information about a public key/private key pair. /// /// -/// See +/// See /// -internal sealed class PublicKeyCredential(BufferSource id, string type, JsonElement clientExtensionResults, TResponse response) +internal sealed class PublicKeyCredential where TResponse : AuthenticatorResponse { /// /// Gets or sets the credential ID. /// - public BufferSource Id { get; } = id; + public required BufferSource Id { get; init; } /// /// Gets the type of the public key credential. @@ -25,21 +25,21 @@ internal sealed class PublicKeyCredential(BufferSource id, string typ /// /// This is always expected to have the value "public-key". /// - public string Type { get; } = type; + public required string Type { get; init; } /// /// Gets the client extensions map. /// - public JsonElement ClientExtensionResults { get; } = clientExtensionResults; + public required JsonElement ClientExtensionResults { get; init; } /// /// Gets or sets the authenticator response. /// - public TResponse Response { get; } = 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; set; } + public string? AuthenticatorAttachment { get; init; } } diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs index 33288d487df3..2f07198a61db 100644 --- a/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialCreationOptions.cs @@ -11,63 +11,60 @@ namespace Microsoft.AspNetCore.Identity; /// /// See . /// -internal sealed class PublicKeyCredentialCreationOptions( - PublicKeyCredentialRpEntity rp, - PublicKeyCredentialUserEntity user, - BufferSource challenge) +internal sealed class PublicKeyCredentialCreationOptions { /// - /// Gets the name and identifier for the relying party requesting attestation. + /// Gets or sets the name and identifier for the relying party requesting attestation. /// - public PublicKeyCredentialRpEntity Rp { get; } = rp; + public required PublicKeyCredentialRpEntity Rp { get; init; } /// - /// Gets the names and and identifier for the user account performing the registration. + /// Gets or sets the names and and identifier for the user account performing the registration. /// - public PublicKeyCredentialUserEntity User { get; } = user; + public required PublicKeyCredentialUserEntity User { get; init; } /// - /// Gets a challenge that the authenticator signs when producing an attestation object for the newly created credential. + /// Gets or sets a challenge that the authenticator signs when producing an attestation object for the newly created credential. /// - public BufferSource Challenge { get; } = challenge; + public required BufferSource Challenge { get; init; } /// - /// Gets the key types and signature algorithms the relying party supports, ordered from most preferred to least preferred. + /// Gets or sets the key types and signature algorithms the relying party supports, ordered from most preferred to least preferred. /// - public IReadOnlyList PubKeyCredParams { get; set; } = []; + 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; set; } + public ulong? Timeout { get; init; } /// /// Gets or sets the existing credentials mapped to the user account. /// - public IReadOnlyList ExcludeCredentials { get; set; } = []; + public IReadOnlyList ExcludeCredentials { get; init; } = []; /// /// Gets or sets settings that the authenticator should satisfy when creating a new credential. /// - public AuthenticatorSelectionCriteria? AuthenticatorSelection { get; set; } + public AuthenticatorSelectionCriteria? AuthenticatorSelection { get; init; } /// /// Gets or sets hints that guide the user agent in interacting with the user. /// - public IReadOnlyList Hints { get; set; } = []; + public IReadOnlyList Hints { get; init; } = []; /// /// Gets or sets the attestation conveyance preference for the relying party. /// - public string Attestation { get; set; } = "none"; + 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; set; } = []; + public IReadOnlyList AttestationFormats { get; init; } = []; /// /// Gets or sets the client extension inputs that the relying party supports. /// - public JsonElement? Extensions { get; set; } + public JsonElement? Extensions { get; init; } } diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredentialDescriptor.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialDescriptor.cs index acb235b939c0..2cf52c82b72f 100644 --- a/src/Identity/Core/src/Passkeys/PublicKeyCredentialDescriptor.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialDescriptor.cs @@ -9,20 +9,20 @@ namespace Microsoft.AspNetCore.Identity; /// /// See /// -internal sealed class PublicKeyCredentialDescriptor(string type, BufferSource id) +internal sealed class PublicKeyCredentialDescriptor { /// - /// Gets the type of the public key credential. + /// Gets or sets the type of the public key credential. /// - public string Type { get; } = type; + public required string Type { get; init; } /// - /// Gets the identifier of the public key credential. + /// Gets or sets the identifier of the public key credential. /// - public BufferSource Id { get; } = id; + public required BufferSource Id { get; init; } /// /// Gets or sets hints as to how the client might communicate with the authenticator. /// - public IReadOnlyList Transports { get; set; } = []; + public IReadOnlyList Transports { get; init; } = []; } diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredentialParameters.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialParameters.cs index d9e0cf834f67..d6abed1c1d6a 100644 --- a/src/Identity/Core/src/Passkeys/PublicKeyCredentialParameters.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialParameters.cs @@ -39,7 +39,19 @@ public PublicKeyCredentialParameters(COSEAlgorithmIdentifier 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 index b007c8e7c2fa..3978cd795693 100644 --- a/src/Identity/Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialRequestOptions.cs @@ -11,40 +11,40 @@ namespace Microsoft.AspNetCore.Identity; /// /// See /// -internal sealed class PublicKeyCredentialRequestOptions(BufferSource challenge) +internal sealed class PublicKeyCredentialRequestOptions { /// - /// Gets the challenge that the authenticator signs when producing an assertion for the requested credential. + /// Gets or sets the challenge that the authenticator signs when producing an assertion for the requested credential. /// - public BufferSource Challenge { get; } = challenge; + 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; set; } + public ulong? Timeout { get; init; } /// /// Gets or sets the relying party identifier. /// - public string? RpId { get; set; } + public string? RpId { get; init; } /// /// Gets or sets the credentials of the identified user account, if any. /// - public IReadOnlyList AllowCredentials { get; set; } = []; + public IReadOnlyList AllowCredentials { get; init; } = []; /// /// Gets or sets the user verification requirement for the request. /// - public string UserVerification { get; set; } = "preferred"; + public string UserVerification { get; init; } = "preferred"; /// /// Gets or sets hints that guide the user agent in interacting with the user. /// - public IReadOnlyList Hints { get; set; } = []; + public IReadOnlyList Hints { get; init; } = []; /// /// Gets or sets the client extension inputs that the relying party supports. /// - public JsonElement? Extensions { get; set; } + public JsonElement? Extensions { get; init; } } diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredentialRpEntity.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialRpEntity.cs index 7540420796b7..3b4fa13af2b0 100644 --- a/src/Identity/Core/src/Passkeys/PublicKeyCredentialRpEntity.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialRpEntity.cs @@ -9,16 +9,15 @@ namespace Microsoft.AspNetCore.Identity; /// /// See . /// -/// -internal sealed class PublicKeyCredentialRpEntity(string name) +internal sealed class PublicKeyCredentialRpEntity { /// - /// Gets the human-palatable name for the entity. + /// Gets or sets the human-palatable name for the entity. /// - public string Name { get; } = name; + public required string Name { get; init; } /// /// Gets or sets the unique identifier for the replying party entity. /// - public string? Id { get; set; } + public string? Id { get; init; } } diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredentialUserEntity.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialUserEntity.cs index 192f275dc667..ca1d2613a4cc 100644 --- a/src/Identity/Core/src/Passkeys/PublicKeyCredentialUserEntity.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialUserEntity.cs @@ -9,20 +9,20 @@ namespace Microsoft.AspNetCore.Identity; /// /// See . /// -internal sealed class PublicKeyCredentialUserEntity(BufferSource id, string name, string displayName) +internal sealed class PublicKeyCredentialUserEntity { /// - /// Gets the user handle of the user account. + /// Gets or sets the user handle of the user account. /// - public BufferSource Id { get; } = id; + public required BufferSource Id { get; init; } /// - /// Gets the human-palatable name for the entity. + /// Gets or sets the human-palatable name for the entity. /// - public string Name { get; } = name; + public required string Name { get; init; } /// - /// Gets the human-palatable name for the user account, intended only for display. + /// Gets or sets the human-palatable name for the user account, intended only for display. /// - public string DisplayName { get; } = displayName; + public required string DisplayName { get; init; } } diff --git a/src/Identity/Core/src/Passkeys/TokenBinding.cs b/src/Identity/Core/src/Passkeys/TokenBinding.cs index 1136eafb2f10..2a46b2a8656d 100644 --- a/src/Identity/Core/src/Passkeys/TokenBinding.cs +++ b/src/Identity/Core/src/Passkeys/TokenBinding.cs @@ -9,16 +9,16 @@ namespace Microsoft.AspNetCore.Identity; /// /// See . /// -internal sealed class TokenBinding(string status) +internal sealed class TokenBinding { /// - /// Gets the token binding status. + /// Gets or sets the token binding status. /// /// /// Supported values are "supported", "present", and "not-supported". /// See . /// - public string Status { get; } = status; + public required string Status { get; init; } /// /// Gets or sets the token binding ID. @@ -26,5 +26,5 @@ internal sealed class TokenBinding(string status) /// /// See . /// - public string? Id { get; set; } + public string? Id { get; init; } } diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 759b348c8909..d52980d7acfb 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -35,7 +35,7 @@ Microsoft.AspNetCore.Identity.PasskeyCreationOptions.PasskeyCreationOptions(Micr 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.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! @@ -68,7 +68,7 @@ static Microsoft.AspNetCore.Identity.PasskeyAttestationResult.Success(Microsoft. 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.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! diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index fa48334f65bc..36eff4d26606 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -587,17 +587,23 @@ public virtual async Task GeneratePasskeyCreationOptions var excludeCredentials = await GetExcludeCredentialsAsync(); var serverDomain = Options.Passkey.ServerDomain ?? Context.Request.Host.Host; - var rpEntity = new PublicKeyCredentialRpEntity(name: serverDomain) + 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 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(rpEntity, userEntity, BufferSource.FromBytes(challenge)) + var options = new PublicKeyCredentialCreationOptions { + Rp = rpEntity, + User = userEntity, + Challenge = BufferSource.FromBytes(challenge), Timeout = (uint)Options.Passkey.Timeout.TotalMilliseconds, ExcludeCredentials = excludeCredentials, PubKeyCredParams = PublicKeyCredentialParameters.AllSupportedParameters, @@ -618,8 +624,10 @@ async Task GetExcludeCredentialsAsync() var passkeys = await UserManager.GetPasskeysAsync(existingUser); var excludeCredentials = passkeys - .Select(p => new PublicKeyCredentialDescriptor(type: "public-key", id: BufferSource.FromBytes(p.CredentialId)) + .Select(p => new PublicKeyCredentialDescriptor { + Type = "public-key", + Id = BufferSource.FromBytes(p.CredentialId), Transports = p.Transports ?? [], }); return [.. excludeCredentials]; @@ -660,22 +668,22 @@ public virtual async Task ConfigurePasskeyRequestOptionsA /// /// A task object representing the asynchronous operation containing the . /// - public virtual async Task GeneratePasskeyRequestOptionsAsync(PasskeyRequestArgs? requestArgs) + 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(BufferSource.FromBytes(challenge)) + var options = new PublicKeyCredentialRequestOptions { + Challenge = BufferSource.FromBytes(challenge), RpId = serverDomain, Timeout = (uint)Options.Passkey.Timeout.TotalMilliseconds, AllowCredentials = allowCredentials, + UserVerification = requestArgs.UserVerification, + Extensions = requestArgs.Extensions, }; - if (requestArgs is not null) - { - options.UserVerification = requestArgs.UserVerification; - options.Extensions = requestArgs.Extensions; - } var userId = requestArgs?.User is { } user ? await UserManager.GetUserIdAsync(user).ConfigureAwait(false) : null; @@ -691,8 +699,10 @@ async Task GetAllowCredentialsAsync() var passkeys = await UserManager.GetPasskeysAsync(user); var allowCredentials = passkeys - .Select(p => new PublicKeyCredentialDescriptor(type: "public-key", id: BufferSource.FromBytes(p.CredentialId)) + .Select(p => new PublicKeyCredentialDescriptor { + Type = "public-key", + Id = BufferSource.FromBytes(p.CredentialId), Transports = p.Transports ?? [], }); return [.. allowCredentials]; diff --git a/src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.Development.json b/src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.Development.json index 0c208ae9181e..00520a6ab864 100644 --- a/src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.Development.json +++ b/src/Identity/samples/IdentitySample.PasskeyConformance/appsettings.Development.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.Identity": "Debug" } } } From 03d8a62778613b14d589817f8cf4df3767e61999 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 11 Jun 2025 11:24:12 -0400 Subject: [PATCH 20/31] PR feedback --- .../Core/src/DefaultPasskeyHandler.cs | 137 +++++++++++++----- .../Core/src/DefaultPasskeyOriginValidator.cs | 69 --------- .../IPasskeyAttestationStatementVerifier.cs | 21 --- src/Identity/Core/src/IPasskeyHandler.cs | 13 +- .../Core/src/IPasskeyOriginValidator.cs | 17 --- .../Core/src/IdentityBuilderExtensions.cs | 2 - .../IdentityServiceCollectionExtensions.cs | 2 - ...NoOpPasskeyAttestationStatementVerifier.cs | 10 -- .../Core/src/PasskeyAssertionContext.cs | 40 +++++ .../Core/src/PasskeyAttestationContext.cs | 35 +++++ src/Identity/Core/src/PublicAPI.Unshipped.txt | 40 +++-- src/Identity/Core/src/SignInManager.cs | 19 ++- 12 files changed, 227 insertions(+), 178 deletions(-) delete mode 100644 src/Identity/Core/src/DefaultPasskeyOriginValidator.cs delete mode 100644 src/Identity/Core/src/IPasskeyAttestationStatementVerifier.cs delete mode 100644 src/Identity/Core/src/IPasskeyOriginValidator.cs delete mode 100644 src/Identity/Core/src/NoOpPasskeyAttestationStatementVerifier.cs create mode 100644 src/Identity/Core/src/PasskeyAssertionContext.cs create mode 100644 src/Identity/Core/src/PasskeyAttestationContext.cs diff --git a/src/Identity/Core/src/DefaultPasskeyHandler.cs b/src/Identity/Core/src/DefaultPasskeyHandler.cs index e3efd6b91b8a..feb1246c44a4 100644 --- a/src/Identity/Core/src/DefaultPasskeyHandler.cs +++ b/src/Identity/Core/src/DefaultPasskeyHandler.cs @@ -6,6 +6,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Identity; @@ -13,35 +14,26 @@ namespace Microsoft.AspNetCore.Identity; /// /// The default passkey handler. /// -public sealed partial class DefaultPasskeyHandler : IPasskeyHandler +public partial class DefaultPasskeyHandler : IPasskeyHandler where TUser : class { - private readonly IPasskeyOriginValidator _originValidator; - private readonly IPasskeyAttestationStatementVerifier _attestationStatementVerifier; private readonly PasskeyOptions _passkeyOptions; /// /// Constructs a new instance. /// /// The . - /// The for validating origins. - /// An optional for verifying attestation statements. - public DefaultPasskeyHandler( - IOptions options, - IPasskeyOriginValidator originValidator, - IPasskeyAttestationStatementVerifier attestationStatementVerifier) + public DefaultPasskeyHandler(IOptions options) { - _originValidator = originValidator; - _attestationStatementVerifier = attestationStatementVerifier; _passkeyOptions = options.Value.Passkey; } /// - public async Task PerformAttestationAsync(string credentialJson, string originalOptionsJson, UserManager userManager) + public async Task PerformAttestationAsync(PasskeyAttestationContext context) { try { - return await PerformAttestationCoreAsync(credentialJson, originalOptionsJson, userManager).ConfigureAwait(false); + return await PerformAttestationCoreAsync(context).ConfigureAwait(false); } catch (PasskeyException ex) { @@ -59,11 +51,11 @@ public async Task PerformAttestationAsync(string crede } /// - public async Task> PerformAssertionAsync(TUser? user, string credentialJson, string originalOptionsJson, UserManager userManager) + public async Task> PerformAssertionAsync(PasskeyAssertionContext context) { try { - return await PerformAssertionCoreAsync(user, credentialJson, originalOptionsJson, userManager).ConfigureAwait(false); + return await PerformAssertionCoreAsync(context).ConfigureAwait(false); } catch (PasskeyException ex) { @@ -80,10 +72,78 @@ public async Task> PerformAssertionAsync(TUser? us } } - private async Task PerformAttestationCoreAsync( - string credentialJson, - string originalOptionsJson, - UserManager userManager) + /// + /// 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 == true && !_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. @@ -94,7 +154,7 @@ private async Task PerformAttestationCoreAsync( try { - credential = JsonSerializer.Deserialize(credentialJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialAuthenticatorAttestationResponse) + credential = JsonSerializer.Deserialize(context.CredentialJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialAuthenticatorAttestationResponse) ?? throw PasskeyException.NullAttestationCredentialJson(); } catch (JsonException ex) @@ -104,7 +164,7 @@ private async Task PerformAttestationCoreAsync( try { - originalOptions = JsonSerializer.Deserialize(originalOptionsJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialCreationOptions) + originalOptions = JsonSerializer.Deserialize(context.OriginalOptionsJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialCreationOptions) ?? throw PasskeyException.NullOriginalCreationOptionsJson(); } catch (JsonException ex) @@ -153,7 +213,8 @@ private async Task PerformAttestationCoreAsync( // 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); - if (!_originValidator.IsValidOrigin(originInfo)) + var isOriginValid = await IsValidOriginAsync(originInfo, context.HttpContext).ConfigureAwait(false); + if (!isOriginValid) { throw PasskeyException.InvalidOrigin(clientData.Origin); } @@ -247,7 +308,7 @@ private async Task PerformAttestationCoreAsync( // 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 _attestationStatementVerifier.VerifyAsync(attestationObjectMemory, clientDataHash).ConfigureAwait(false); + var isAttestationStatementValid = await VerifyAttestationStatementAsync(attestationObjectMemory, clientDataHash, context.HttpContext).ConfigureAwait(false); if (!isAttestationStatementValid) { throw PasskeyException.InvalidAttestationStatement(); @@ -263,7 +324,7 @@ private async Task PerformAttestationCoreAsync( var credentialId = attestedCredentialData.CredentialId.ToArray(); // 26. Verify that the credentialId is not yet registered for any user. - var existingUser = await userManager.FindByPasskeyIdAsync(credentialId).ConfigureAwait(false); + var existingUser = await context.UserManager.FindByPasskeyIdAsync(credentialId).ConfigureAwait(false); if (existingUser is not null) { throw PasskeyException.CredentialAlreadyRegistered(); @@ -292,11 +353,12 @@ private async Task PerformAttestationCoreAsync( return PasskeyAttestationResult.Success(credentialRecord); } - private async Task> PerformAssertionCoreAsync( - TUser? user, - string credentialJson, - string originalOptionsJson, - UserManager userManager) + /// + /// 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-registering-a-new-credential // NOTE: Quotes from the spec may have been modified. @@ -307,7 +369,7 @@ private async Task> PerformAssertionCoreAsync( try { - credential = JsonSerializer.Deserialize(credentialJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialAuthenticatorAssertionResponse) + credential = JsonSerializer.Deserialize(context.CredentialJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialAuthenticatorAssertionResponse) ?? throw PasskeyException.NullAssertionCredentialJson(); } catch (JsonException ex) @@ -317,7 +379,7 @@ private async Task> PerformAssertionCoreAsync( try { - originalOptions = JsonSerializer.Deserialize(originalOptionsJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialRequestOptions) + originalOptions = JsonSerializer.Deserialize(context.OriginalOptionsJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialRequestOptions) ?? throw PasskeyException.NullOriginalRequestOptionsJson(); } catch (JsonException ex) @@ -349,20 +411,20 @@ private async Task> PerformAssertionCoreAsync( UserPasskeyInfo? storedPasskey; // 6. Identify the user being authenticated and let credentialRecord be the credential record for the credential: - if (user is not null) + 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 userManager.GetPasskeyAsync(user, credentialId).ConfigureAwait(false); + storedPasskey = await context.UserManager.GetPasskeyAsync(user, credentialId).ConfigureAwait(false); if (storedPasskey is null) { throw PasskeyException.CredentialDoesNotBelongToUser(); } if (userHandle is not null) { - var userId = await userManager.GetUserIdAsync(user).ConfigureAwait(false); + var userId = await context.UserManager.GetUserIdAsync(user).ConfigureAwait(false); if (!string.Equals(userHandle, userId, StringComparison.Ordinal)) { throw PasskeyException.UserHandleMismatch(userId, userHandle); @@ -380,12 +442,12 @@ private async Task> PerformAssertionCoreAsync( throw PasskeyException.MissingUserHandle(); } - user = await userManager.FindByIdAsync(userHandle).ConfigureAwait(false); + user = await context.UserManager.FindByIdAsync(userHandle).ConfigureAwait(false); if (user is null) { throw PasskeyException.CredentialDoesNotBelongToUser(); } - storedPasskey = await userManager.GetPasskeyAsync(user, credentialId).ConfigureAwait(false); + storedPasskey = await context.UserManager.GetPasskeyAsync(user, credentialId).ConfigureAwait(false); if (storedPasskey is null) { throw PasskeyException.CredentialDoesNotBelongToUser(); @@ -425,7 +487,8 @@ private async Task> PerformAssertionCoreAsync( // 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); - if (!_originValidator.IsValidOrigin(originInfo)) + var isOriginValid = await IsValidOriginAsync(originInfo, context.HttpContext).ConfigureAwait(false); + if (!isOriginValid) { throw PasskeyException.InvalidOrigin(clientData.Origin); } diff --git a/src/Identity/Core/src/DefaultPasskeyOriginValidator.cs b/src/Identity/Core/src/DefaultPasskeyOriginValidator.cs deleted file mode 100644 index 12bc9e50b3db..000000000000 --- a/src/Identity/Core/src/DefaultPasskeyOriginValidator.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; - -namespace Microsoft.AspNetCore.Identity; - -internal class DefaultPasskeyOriginValidator : IPasskeyOriginValidator -{ - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly PasskeyOptions _options; - - public DefaultPasskeyOriginValidator( - IHttpContextAccessor httpContextAccessor, - IOptions options) - { - ArgumentNullException.ThrowIfNull(httpContextAccessor); - ArgumentNullException.ThrowIfNull(options); - - _httpContextAccessor = httpContextAccessor; - _options = options.Value.Passkey; - } - - public bool IsValidOrigin(PasskeyOriginInfo originInfo) - { - if (string.IsNullOrEmpty(originInfo.Origin)) - { - return false; - } - - if (originInfo.CrossOrigin == true && !_options.AllowCrossOriginIframes) - { - return false; - } - - try - { - var originUri = new Uri(originInfo.Origin); - - if (_options.AllowedOrigins.Count > 0) - { - foreach (var allowedOrigin in _options.AllowedOrigins) - { - // Uri.Equals correctly handles string comparands. - if (originUri.Equals(allowedOrigin)) - { - return true; - } - } - } - - if (_options.AllowCurrentOrigin && _httpContextAccessor.HttpContext?.Request.Headers.Origin is [var origin]) - { - // Uri.Equals correctly handles string comparands. - if (originUri.Equals(origin)) - { - return true; - } - } - - return false; - } - catch (UriFormatException) - { - return false; - } - } -} diff --git a/src/Identity/Core/src/IPasskeyAttestationStatementVerifier.cs b/src/Identity/Core/src/IPasskeyAttestationStatementVerifier.cs deleted file mode 100644 index 906e8d29ec3c..000000000000 --- a/src/Identity/Core/src/IPasskeyAttestationStatementVerifier.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.AspNetCore.Identity; - -/// -/// Interface for verifying passkey attestation statements. -/// -public interface IPasskeyAttestationStatementVerifier -{ - /// - /// Verifies the attestation statement of a passkey. - /// - /// - /// See . - /// - /// The attestation object to verify. See . - /// The hash of the client data used during registration. - /// A task that represents the asynchronous operation. The task result contains true if the verification is successful; otherwise, false. - Task VerifyAsync(ReadOnlyMemory attestationObject, ReadOnlyMemory clientDataHash); -} diff --git a/src/Identity/Core/src/IPasskeyHandler.cs b/src/Identity/Core/src/IPasskeyHandler.cs index 1a936eeff282..be2a68a48d68 100644 --- a/src/Identity/Core/src/IPasskeyHandler.cs +++ b/src/Identity/Core/src/IPasskeyHandler.cs @@ -12,19 +12,14 @@ public interface IPasskeyHandler /// /// Performs passkey attestation using the provided credential JSON and original options JSON. /// - /// The credentials obtained by JSON-serializing the result of the navigator.credentials.create() JavaScript function. - /// The JSON representation of the original passkey creation options provided to the browser. - /// The to retrieve user information from. + /// The context containing necessary information for passkey attestation. /// A task object representing the asynchronous operation containing the . - Task PerformAttestationAsync(string credentialJson, string originalOptionsJson, UserManager userManager); + Task PerformAttestationAsync(PasskeyAttestationContext context); /// /// Performs passkey assertion using the provided credential JSON, original options JSON, and optional user. /// - /// The user associated with the passkey, if known. - /// The credentials obtained by JSON-serializing the result of the navigator.credentials.get() JavaScript function. - /// The JSON representation of the original passkey creation options provided to the browser. - /// The to retrieve user information from. + /// The context containing necessary information for passkey assertion. /// A task object representing the asynchronous operation containing the . - Task> PerformAssertionAsync(TUser? user, string credentialJson, string originalOptionsJson, UserManager userManager); + Task> PerformAssertionAsync(PasskeyAssertionContext context); } diff --git a/src/Identity/Core/src/IPasskeyOriginValidator.cs b/src/Identity/Core/src/IPasskeyOriginValidator.cs deleted file mode 100644 index be626c4be8f3..000000000000 --- a/src/Identity/Core/src/IPasskeyOriginValidator.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.AspNetCore.Identity; - -/// -/// Validates the credential origin for passkey operations. -/// -public interface IPasskeyOriginValidator -{ - /// - /// Determines whether the specified origin is valid for passkey operations. - /// - /// Information about the passkey's origin. - /// true if the origin is valid; otherwise, false. - bool IsValidOrigin(PasskeyOriginInfo originInfo); -} diff --git a/src/Identity/Core/src/IdentityBuilderExtensions.cs b/src/Identity/Core/src/IdentityBuilderExtensions.cs index 1c16853fd1bb..afa44b51e528 100644 --- a/src/Identity/Core/src/IdentityBuilderExtensions.cs +++ b/src/Identity/Core/src/IdentityBuilderExtensions.cs @@ -41,8 +41,6 @@ public static IdentityBuilder AddDefaultTokenProviders(this IdentityBuilder buil private static void AddSignInManagerDeps(this IdentityBuilder builder) { builder.Services.AddHttpContextAccessor(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); 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)); diff --git a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs index fb85b5856969..43f81cccfbbb 100644 --- a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs +++ b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs @@ -102,8 +102,6 @@ public static class IdentityServiceCollectionExtensions services.TryAddScoped>(); services.TryAddScoped, UserClaimsPrincipalFactory>(); services.TryAddScoped, DefaultUserConfirmation>(); - services.TryAddScoped(); - services.TryAddScoped(); services.TryAddScoped, DefaultPasskeyHandler>(); services.TryAddScoped>(); services.TryAddScoped>(); diff --git a/src/Identity/Core/src/NoOpPasskeyAttestationStatementVerifier.cs b/src/Identity/Core/src/NoOpPasskeyAttestationStatementVerifier.cs deleted file mode 100644 index 5416117a3cff..000000000000 --- a/src/Identity/Core/src/NoOpPasskeyAttestationStatementVerifier.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.AspNetCore.Identity; - -internal sealed class NoOpPasskeyAttestationStatementVerifier : IPasskeyAttestationStatementVerifier -{ - public Task VerifyAsync(ReadOnlyMemory attestationObject, ReadOnlyMemory clientDataHash) - => Task.FromResult(true); -} 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/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/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index d52980d7acfb..09f0ace1c9c1 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1,21 +1,39 @@ #nullable enable Microsoft.AspNetCore.Identity.DefaultPasskeyHandler -Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.DefaultPasskeyHandler(Microsoft.Extensions.Options.IOptions! options, Microsoft.AspNetCore.Identity.IPasskeyOriginValidator! originValidator, Microsoft.AspNetCore.Identity.IPasskeyAttestationStatementVerifier! attestationStatementVerifier) -> void -Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.PerformAssertionAsync(TUser? user, string! credentialJson, string! originalOptionsJson, Microsoft.AspNetCore.Identity.UserManager! userManager) -> System.Threading.Tasks.Task!>! -Microsoft.AspNetCore.Identity.DefaultPasskeyHandler.PerformAttestationAsync(string! credentialJson, string! originalOptionsJson, Microsoft.AspNetCore.Identity.UserManager! userManager) -> System.Threading.Tasks.Task! -Microsoft.AspNetCore.Identity.IPasskeyAttestationStatementVerifier -Microsoft.AspNetCore.Identity.IPasskeyAttestationStatementVerifier.VerifyAsync(System.ReadOnlyMemory attestationObject, System.ReadOnlyMemory clientDataHash) -> System.Threading.Tasks.Task! +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(TUser? user, string! credentialJson, string! originalOptionsJson, Microsoft.AspNetCore.Identity.UserManager! userManager) -> System.Threading.Tasks.Task!>! -Microsoft.AspNetCore.Identity.IPasskeyHandler.PerformAttestationAsync(string! credentialJson, string! originalOptionsJson, Microsoft.AspNetCore.Identity.UserManager! userManager) -> System.Threading.Tasks.Task! -Microsoft.AspNetCore.Identity.IPasskeyOriginValidator -Microsoft.AspNetCore.Identity.IPasskeyOriginValidator.IsValidOrigin(Microsoft.AspNetCore.Identity.PasskeyOriginInfo originInfo) -> bool +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? @@ -65,6 +83,10 @@ static Microsoft.AspNetCore.Identity.PasskeyAssertionResult.Fail(Microsof 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! diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index 36eff4d26606..0da1f0698231 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -478,7 +478,14 @@ public virtual async Task PerformPasskeyAttestationAsy ArgumentException.ThrowIfNullOrEmpty(credentialJson); ArgumentNullException.ThrowIfNull(options); - var result = await _passkeyHandler.PerformAttestationAsync(credentialJson, options.AsJson(), UserManager).ConfigureAwait(false); + 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); @@ -502,7 +509,15 @@ public virtual async Task> PerformPasskeyAssertion ArgumentNullException.ThrowIfNull(options); var user = options.UserId is { Length: > 0 } userId ? await UserManager.FindByIdAsync(userId) : null; - var result = await _passkeyHandler.PerformAssertionAsync(user, credentialJson, options.AsJson(), UserManager); + 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); From cabe3ee7a8195a47988d5237d3fe5b1226ff34da Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 11 Jun 2025 11:58:26 -0400 Subject: [PATCH 21/31] PR feedback --- .../Components/Account/Pages/ExternalLogin.razor | 4 +++- .../Components/Account/Pages/ForgotPassword.razor | 7 ++++++- .../Components/Account/Pages/Login.razor | 4 +++- .../Components/Account/Pages/LoginWith2fa.razor | 4 +++- .../Account/Pages/LoginWithRecoveryCode.razor | 4 +++- .../Account/Pages/Manage/ChangePassword.razor | 4 +++- .../Account/Pages/Manage/DeletePersonalData.razor | 3 ++- .../Components/Account/Pages/Manage/Email.razor | 4 +++- .../Account/Pages/Manage/EnableAuthenticator.razor | 4 +++- .../Components/Account/Pages/Manage/Index.razor | 4 +++- .../Components/Account/Pages/Manage/Passkeys.razor | 14 ++++++++------ .../Account/Pages/Manage/RenamePasskey.razor | 4 +++- .../Account/Pages/Manage/SetPassword.razor | 4 +++- .../Components/Account/Pages/Register.razor | 7 ++++++- .../Account/Pages/ResendEmailConfirmation.razor | 7 ++++++- .../Components/Account/Pages/ResetPassword.razor | 4 +++- 16 files changed, 61 insertions(+), 21 deletions(-) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ExternalLogin.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ExternalLogin.razor index 3dbb3c412a41..2fd7f7d99ec3 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ExternalLogin.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ExternalLogin.razor @@ -54,7 +54,7 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } [SupplyParameterFromQuery] private string? RemoteError { get; set; } @@ -69,6 +69,8 @@ protected override async Task OnInitializedAsync() { + Input ??= new(); + if (RemoteError is not null) { RedirectManager.RedirectToWithStatus("Account/Login", $"Error from external provider: {RemoteError}", HttpContext); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ForgotPassword.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ForgotPassword.razor index 1ab1bfa5fcb0..5fd2ca14e755 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ForgotPassword.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ForgotPassword.razor @@ -35,7 +35,12 @@ @code { [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } + + protected override void OnInitialized() + { + Input ??= new(); + } private async Task OnValidSubmitAsync() { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor index 79be1bfac680..c733e0e8ff51 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor @@ -79,13 +79,15 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } protected override async Task OnInitializedAsync() { + Input ??= new(); + editContext = new EditContext(Input); if (HttpMethods.IsGet(HttpContext.Request.Method)) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWith2fa.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWith2fa.razor index 20cebaaef191..e9d9106f46a3 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWith2fa.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWith2fa.razor @@ -49,7 +49,7 @@ private ApplicationUser user = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } @@ -59,6 +59,8 @@ protected override async Task OnInitializedAsync() { + Input ??= new(); + // Ensure the user has gone through the username & password screen first user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? throw new InvalidOperationException("Unable to load two-factor authentication user."); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWithRecoveryCode.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWithRecoveryCode.razor index b984545c18d2..807223641dd4 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWithRecoveryCode.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWithRecoveryCode.razor @@ -38,13 +38,15 @@ private ApplicationUser user = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } protected override async Task OnInitializedAsync() { + Input ??= new(); + // Ensure the user has gone through the username & password screen first user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? throw new InvalidOperationException("Unable to load two-factor authentication user."); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/ChangePassword.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/ChangePassword.razor index 1764bde9919f..56259e41e2e3 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/ChangePassword.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/ChangePassword.razor @@ -48,10 +48,12 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } protected override async Task OnInitializedAsync() { + Input ??= new(); + user = await UserManager.GetUserAsync(HttpContext.User); if (user is null) { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/DeletePersonalData.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/DeletePersonalData.razor index 12dcd63bd78a..abde8bb24593 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/DeletePersonalData.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/DeletePersonalData.razor @@ -46,11 +46,12 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } protected override async Task OnInitializedAsync() { Input ??= new(); + user = await UserManager.GetUserAsync(HttpContext.User); if (user is null) { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Email.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Email.razor index 5cbfdcb75024..3d3ffb3cde85 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Email.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Email.razor @@ -63,10 +63,12 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm(FormName = "change-email")] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } protected override async Task OnInitializedAsync() { + Input ??= new(); + user = await UserManager.GetUserAsync(HttpContext.User); if (user is null) { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/EnableAuthenticator.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/EnableAuthenticator.razor index b1e7f7ca41a9..33b2fc6c3504 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/EnableAuthenticator.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/EnableAuthenticator.razor @@ -78,10 +78,12 @@ else private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } protected override async Task OnInitializedAsync() { + Input ??= new(); + user = await UserManager.GetUserAsync(HttpContext.User); if (user is null) { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Index.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Index.razor index 0663ca6ebed6..2ae59c358de1 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Index.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Index.razor @@ -41,10 +41,12 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } protected override async Task OnInitializedAsync() { + Input ??= new(); + user = await UserManager.GetUserAsync(HttpContext.User); if (user is null) { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor index 23f6aadef9b1..01ba32d46c21 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor @@ -48,7 +48,7 @@ else
- Add a new passkey + Add a new passkey @code { @@ -65,10 +65,12 @@ else private string? CredentialId { get; set; } [SupplyParameterFromForm(FormName = "add-passkey")] - private PasskeyInputModel AddPasskeyInput { get; set; } = new(); + private PasskeyInputModel Input { get; set; } protected override async Task OnInitializedAsync() { + Input ??= new(); + user = await UserManager.GetUserAsync(HttpContext.User); if (user is null) { @@ -86,13 +88,13 @@ else return; } - if (!string.IsNullOrEmpty(AddPasskeyInput.Error)) + if (!string.IsNullOrEmpty(Input.Error)) { - RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add a passkey: {AddPasskeyInput.Error}", HttpContext); + RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add a passkey: {Input.Error}", HttpContext); return; } - if (string.IsNullOrEmpty(AddPasskeyInput.CredentialJson)) + if (string.IsNullOrEmpty(Input.CredentialJson)) { RedirectManager.RedirectToCurrentPageWithStatus("Error: The browser did not provide a passkey.", HttpContext); return; @@ -105,7 +107,7 @@ else return; } - var attestationResult = await SignInManager.PerformPasskeyAttestationAsync(AddPasskeyInput.CredentialJson, options); + var attestationResult = await SignInManager.PerformPasskeyAttestationAsync(Input.CredentialJson, options); if (!attestationResult.Succeeded) { RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add the passkey: {attestationResult.Failure.Message}.", HttpContext); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor index c951b353bf98..42b6ef77319c 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor @@ -41,10 +41,12 @@ public string? Id { get; set; } [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } protected override async Task OnInitializedAsync() { + Input ??= new(); + user = (await UserManager.GetUserAsync(HttpContext.User))!; if (user is null) { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/SetPassword.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/SetPassword.razor index c9c7b26317cf..09f4d76330d9 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/SetPassword.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/SetPassword.razor @@ -44,10 +44,12 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } protected override async Task OnInitializedAsync() { + Input ??= new(); + user = await UserManager.GetUserAsync(HttpContext.User); if (user is null) { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Register.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Register.razor index ce5f49387419..7d79bb1a7bc1 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Register.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Register.razor @@ -58,13 +58,18 @@ private IEnumerable? identityErrors; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } private string? Message => identityErrors is null ? null : $"Error: {string.Join(", ", identityErrors.Select(error => error.Description))}"; + protected override void OnInitialized() + { + Input ??= new(); + } + public async Task RegisterUser(EditContext editContext) { var user = CreateUser(); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResendEmailConfirmation.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResendEmailConfirmation.razor index 8024beaeeee2..b06c2cb44615 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResendEmailConfirmation.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResendEmailConfirmation.razor @@ -37,7 +37,12 @@ private string? message; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } + + protected override void OnInitialized() + { + Input ??= new(); + } private async Task OnValidSubmitAsync() { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResetPassword.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResetPassword.razor index fd0945f58a04..ebc290380933 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResetPassword.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResetPassword.razor @@ -46,7 +46,7 @@ private IEnumerable? identityErrors; [SupplyParameterFromForm] - private InputModel Input { get; set; } = new(); + private InputModel Input { get; set; } [SupplyParameterFromQuery] private string? Code { get; set; } @@ -55,6 +55,8 @@ protected override void OnInitialized() { + Input ??= new(); + if (Code is null) { RedirectManager.RedirectTo("Account/InvalidPasswordReset"); From 927596bb0534824ca8a1960b1726afacfe46a2c5 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 11 Jun 2025 16:09:18 -0400 Subject: [PATCH 22/31] Update template --- .../.template.config/template.json | 1 - .../Account/Pages/ExternalLogin.razor | 2 +- .../Account/Pages/ForgotPassword.razor | 2 +- .../Components/Account/Pages/Login.razor | 2 +- .../Account/Pages/LoginWith2fa.razor | 2 +- .../Account/Pages/LoginWithRecoveryCode.razor | 2 +- .../Account/Pages/Manage/ChangePassword.razor | 2 +- .../Pages/Manage/DeletePersonalData.razor | 2 +- .../Account/Pages/Manage/Email.razor | 2 +- .../Pages/Manage/EnableAuthenticator.razor | 2 +- .../Account/Pages/Manage/Index.razor | 2 +- .../Account/Pages/Manage/Passkeys.razor | 2 +- .../Account/Pages/Manage/RenamePasskey.razor | 2 +- .../Account/Pages/Manage/SetPassword.razor | 2 +- .../Components/Account/Pages/Register.razor | 2 +- .../Pages/ResendEmailConfirmation.razor | 2 +- .../Account/Pages/ResetPassword.razor | 2 +- .../Account/Shared/PasskeySubmit.razor | 20 +-------- .../Account/Shared/PasskeySubmit.razor.js} | 43 ++++++++++--------- .../BlazorWeb-CSharp/Components/App.razor | 3 ++ 20 files changed, 44 insertions(+), 55 deletions(-) rename src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/{wwwroot/BlazorWeb-CSharp.lib.module.js => Components/Account/Shared/PasskeySubmit.razor.js} (66%) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/.template.config/template.json b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/.template.config/template.json index e31baa1bbc45..09f28a39ffee 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/.template.config/template.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/.template.config/template.json @@ -135,7 +135,6 @@ "exclude": [ "BlazorWeb-CSharp/Components/Account/**", "BlazorWeb-CSharp/Data/**", - "BlazorWeb-CSharp/wwwroot/BlazorWeb-CSharp.lib.module.js", "BlazorWeb-CSharp.Client/UserInfo.cs", "BlazorWeb-CSharp.Client/Pages/Auth.razor" ] diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ExternalLogin.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ExternalLogin.razor index 2fd7f7d99ec3..0bd33362fe9c 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ExternalLogin.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ExternalLogin.razor @@ -54,7 +54,7 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } + private InputModel Input { get; set; } = default!; [SupplyParameterFromQuery] private string? RemoteError { get; set; } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ForgotPassword.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ForgotPassword.razor index 5fd2ca14e755..2a8a903a0300 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ForgotPassword.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ForgotPassword.razor @@ -35,7 +35,7 @@ @code { [SupplyParameterFromForm] - private InputModel Input { get; set; } + private InputModel Input { get; set; } = default!; protected override void OnInitialized() { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor index c733e0e8ff51..173cc9db35c6 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor @@ -79,7 +79,7 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } + private InputModel Input { get; set; } = default!; [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWith2fa.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWith2fa.razor index e9d9106f46a3..8019df6775e4 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWith2fa.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWith2fa.razor @@ -49,7 +49,7 @@ private ApplicationUser user = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } + private InputModel Input { get; set; } = default!; [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWithRecoveryCode.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWithRecoveryCode.razor index 807223641dd4..d0f289c8653e 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWithRecoveryCode.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/LoginWithRecoveryCode.razor @@ -38,7 +38,7 @@ private ApplicationUser user = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } + private InputModel Input { get; set; } = default!; [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/ChangePassword.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/ChangePassword.razor index 56259e41e2e3..28d5673a7fab 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/ChangePassword.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/ChangePassword.razor @@ -48,7 +48,7 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } + private InputModel Input { get; set; } = default!; protected override async Task OnInitializedAsync() { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/DeletePersonalData.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/DeletePersonalData.razor index abde8bb24593..4ae6422d02b5 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/DeletePersonalData.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/DeletePersonalData.razor @@ -46,7 +46,7 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } + private InputModel Input { get; set; } = default!; protected override async Task OnInitializedAsync() { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Email.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Email.razor index 3d3ffb3cde85..a619c85987d5 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Email.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Email.razor @@ -63,7 +63,7 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm(FormName = "change-email")] - private InputModel Input { get; set; } + private InputModel Input { get; set; } = default!; protected override async Task OnInitializedAsync() { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/EnableAuthenticator.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/EnableAuthenticator.razor index 33b2fc6c3504..7a85dbd303db 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/EnableAuthenticator.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/EnableAuthenticator.razor @@ -78,7 +78,7 @@ else private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } + private InputModel Input { get; set; } = default!; protected override async Task OnInitializedAsync() { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Index.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Index.razor index 2ae59c358de1..a3e4a0b3e997 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Index.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Index.razor @@ -41,7 +41,7 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } + private InputModel Input { get; set; } = default!; protected override async Task OnInitializedAsync() { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor index 01ba32d46c21..948711d5a201 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/Passkeys.razor @@ -65,7 +65,7 @@ else private string? CredentialId { get; set; } [SupplyParameterFromForm(FormName = "add-passkey")] - private PasskeyInputModel Input { get; set; } + private PasskeyInputModel Input { get; set; } = default!; protected override async Task OnInitializedAsync() { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor index 42b6ef77319c..4d67ca04e360 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor @@ -41,7 +41,7 @@ public string? Id { get; set; } [SupplyParameterFromForm] - private InputModel Input { get; set; } + private InputModel Input { get; set; } = default!; protected override async Task OnInitializedAsync() { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/SetPassword.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/SetPassword.razor index 09f4d76330d9..62ace2f15001 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/SetPassword.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/SetPassword.razor @@ -44,7 +44,7 @@ private HttpContext HttpContext { get; set; } = default!; [SupplyParameterFromForm] - private InputModel Input { get; set; } + private InputModel Input { get; set; } = default!; protected override async Task OnInitializedAsync() { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Register.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Register.razor index 7d79bb1a7bc1..0a22f43cf4e0 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Register.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Register.razor @@ -58,7 +58,7 @@ private IEnumerable? identityErrors; [SupplyParameterFromForm] - private InputModel Input { get; set; } + private InputModel Input { get; set; } = default!; [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResendEmailConfirmation.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResendEmailConfirmation.razor index b06c2cb44615..964de23cbc67 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResendEmailConfirmation.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResendEmailConfirmation.razor @@ -37,7 +37,7 @@ private string? message; [SupplyParameterFromForm] - private InputModel Input { get; set; } + private InputModel Input { get; set; } = default!; protected override void OnInitialized() { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResetPassword.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResetPassword.razor index ebc290380933..ab835031c871 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResetPassword.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/ResetPassword.razor @@ -46,7 +46,7 @@ private IEnumerable? identityErrors; [SupplyParameterFromForm] - private InputModel Input { get; set; } + private InputModel Input { get; set; } = default!; [SupplyParameterFromQuery] private string? Code { get; set; } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor index a506d449df61..8e373571e654 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor @@ -1,11 +1,7 @@ - - + + @code { - private string? operation; - private string? credentialJsonName; - private string? errorName; - [Parameter] [EditorRequired] public PasskeyOperation Operation { get; set; } @@ -22,16 +18,4 @@ [Parameter(CaptureUnmatchedValues = true)] public IDictionary? AdditionalAttributes { get; set; } - - protected override void OnParametersSet() - { - operation = Operation switch - { - PasskeyOperation.Create => "create", - PasskeyOperation.Request => "request", - _ => throw new InvalidOperationException($"Unsupported passkey operation '{Operation}'.") - }; - credentialJsonName = $"{Name}.{nameof(PasskeyInputModel.CredentialJson)}"; - errorName = $"{Name}.{nameof(PasskeyInputModel.Error)}"; - } } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/wwwroot/BlazorWeb-CSharp.lib.module.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js similarity index 66% rename from src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/wwwroot/BlazorWeb-CSharp.lib.module.js rename to src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js index e51fc531bf92..93ad15836692 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/wwwroot/BlazorWeb-CSharp.lib.module.js +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js @@ -30,48 +30,51 @@ async function requestCredential(email) { } customElements.define('passkey-submit', class extends HTMLElement { + static formAssociated = true; + connectedCallback() { - this.form = this.closest('form'); + this.internals = this.attachInternals(); + this.isObtainingCredentials = false; this.attrs = { operation: this.getAttribute('operation'), - credentialJsonName: this.getAttribute('credential-json-name'), - errorName: this.getAttribute('error-name'), + name: this.getAttribute('name'), emailName: this.getAttribute('email-name'), }; - this.form.addEventListener('submit', (event) => { - if (event.submitter?.name === '__passkey') { + this.internals.form.addEventListener('submit', (event) => { + if (event.submitter?.name === '__passkeySubmit') { event.preventDefault(); this.obtainCredentialAndReSubmit(); } }); } - addFormValue(name, value) { - const input = document.createElement('input'); - input.type = 'hidden'; - input.name = name; - input.value = value; - this.form.appendChild(input); - } - async obtainCredentialAndReSubmit() { + if (this.isObtainingCredentials) { + return; + } + + this.isObtainingCredentials = true; + const formData = new FormData(); try { let credential; - if (this.attrs.operation === 'create') { + if (this.attrs.operation === 'Create') { credential = await createCredential(); - } else if (this.attrs.operation === 'request') { - const email = new FormData(this.form).get(this.attrs.emailName); + } else if (this.attrs.operation === 'Request') { + const email = new FormData(this.internals.form).get(this.attrs.emailName); credential = await requestCredential(email); } else { - throw new Error(`Unknown passkey operation '${operation}'`); + throw new Error(`Unknown passkey operation '${operation}'.`); } const credentialJson = JSON.stringify(credential); - this.addFormValue(this.attrs.credentialJsonName, credentialJson); + formData.append(`${this.attrs.name}.CredentialJson`, credentialJson); } catch (error) { - this.addFormValue(this.attrs.errorName, error.message); + formData.append(`${this.attrs.name}.Error`, error.message); console.error(error); + } finally { + this.isObtainingCredentials = false; } - this.form.submit(); + this.internals.setFormValue(formData); + this.internals.form.submit(); } }); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/App.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/App.razor index 3f3531f29052..425efc8c8d99 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/App.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/App.razor @@ -43,6 +43,9 @@ ##endif*@ + @*#if (IndividualLocalAuth) + + ##endif*@ From c2326732643be771106b3aef06a9b8eef9fd0b66 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 11 Jun 2025 16:44:56 -0400 Subject: [PATCH 23/31] PR feedback --- src/Identity/Core/src/DefaultPasskeyHandler.cs | 6 +++--- src/Identity/Core/src/PasskeyOriginInfo.cs | 4 ++-- src/Identity/Core/src/Passkeys/BufferSourceJsonConverter.cs | 3 --- src/Identity/Core/src/Passkeys/COSEAlgorithmIdentifier.cs | 2 +- src/Identity/Core/src/PublicAPI.Unshipped.txt | 4 ++-- 5 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/Identity/Core/src/DefaultPasskeyHandler.cs b/src/Identity/Core/src/DefaultPasskeyHandler.cs index feb1246c44a4..58722dff535d 100644 --- a/src/Identity/Core/src/DefaultPasskeyHandler.cs +++ b/src/Identity/Core/src/DefaultPasskeyHandler.cs @@ -90,7 +90,7 @@ bool IsValidOrigin() return false; } - if (originInfo.CrossOrigin == true && !_passkeyOptions.AllowCrossOriginIframes) + if (originInfo.CrossOrigin && !_passkeyOptions.AllowCrossOriginIframes) { return false; } @@ -212,7 +212,7 @@ protected virtual async Task PerformAttestationCoreAsy // 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); + var originInfo = new PasskeyOriginInfo(clientData.Origin, clientData.CrossOrigin == true); var isOriginValid = await IsValidOriginAsync(originInfo, context.HttpContext).ConfigureAwait(false); if (!isOriginValid) { @@ -486,7 +486,7 @@ protected virtual async Task> PerformAssertionCore // 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); + var originInfo = new PasskeyOriginInfo(clientData.Origin, clientData.CrossOrigin == true); var isOriginValid = await IsValidOriginAsync(originInfo, context.HttpContext).ConfigureAwait(false); if (!isOriginValid) { diff --git a/src/Identity/Core/src/PasskeyOriginInfo.cs b/src/Identity/Core/src/PasskeyOriginInfo.cs index 897959708fa8..30576f1609fc 100644 --- a/src/Identity/Core/src/PasskeyOriginInfo.cs +++ b/src/Identity/Core/src/PasskeyOriginInfo.cs @@ -8,7 +8,7 @@ namespace Microsoft.AspNetCore.Identity; ///
/// The fully-qualified origin of the requester. /// Whether the request came from a cross-origin <iframe> -public readonly struct PasskeyOriginInfo(string origin, bool? crossOrigin) +public readonly struct PasskeyOriginInfo(string origin, bool crossOrigin) { /// /// Gets the fully-qualified origin of the requester. @@ -18,5 +18,5 @@ public readonly struct PasskeyOriginInfo(string origin, bool? crossOrigin) /// /// Gets whether the request came from a cross-origin <iframe>. /// - public bool? CrossOrigin { get; } = crossOrigin; + public bool CrossOrigin { get; } = crossOrigin; } diff --git a/src/Identity/Core/src/Passkeys/BufferSourceJsonConverter.cs b/src/Identity/Core/src/Passkeys/BufferSourceJsonConverter.cs index 2997fae71830..64d1618ac33b 100644 --- a/src/Identity/Core/src/Passkeys/BufferSourceJsonConverter.cs +++ b/src/Identity/Core/src/Passkeys/BufferSourceJsonConverter.cs @@ -55,7 +55,6 @@ private static bool TryDecodeBase64Url(ReadOnlySpan utf8Unescaped, [NotNul if (pooledArray != null) { - byteSpan.Clear(); ArrayPool.Shared.Return(pooledArray); } @@ -67,7 +66,6 @@ private static bool TryDecodeBase64Url(ReadOnlySpan utf8Unescaped, [NotNul if (pooledArray != null) { - byteSpan.Clear(); ArrayPool.Shared.Return(pooledArray); } @@ -92,7 +90,6 @@ private static void WriteBase64UrlStringValue(Utf8JsonWriter writer, ReadOnlySpa if (pooledArray != null) { - byteSpan.Clear(); ArrayPool.Shared.Return(pooledArray); } } diff --git a/src/Identity/Core/src/Passkeys/COSEAlgorithmIdentifier.cs b/src/Identity/Core/src/Passkeys/COSEAlgorithmIdentifier.cs index e6d989708849..c90dba23a77a 100644 --- a/src/Identity/Core/src/Passkeys/COSEAlgorithmIdentifier.cs +++ b/src/Identity/Core/src/Passkeys/COSEAlgorithmIdentifier.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.Identity; /// /// See . /// -internal enum COSEAlgorithmIdentifier : long +internal enum COSEAlgorithmIdentifier : int { RS1 = -65535, RS512 = -259, diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 09f0ace1c9c1..91313c5a385f 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -55,10 +55,10 @@ 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.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.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 From f5d78e274a7b0d9b6a7abe7514ffd903564f0c7c Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 11 Jun 2025 16:49:58 -0400 Subject: [PATCH 24/31] PR feedback --- src/Identity/Core/src/DefaultPasskeyHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Identity/Core/src/DefaultPasskeyHandler.cs b/src/Identity/Core/src/DefaultPasskeyHandler.cs index 58722dff535d..af014752c5ad 100644 --- a/src/Identity/Core/src/DefaultPasskeyHandler.cs +++ b/src/Identity/Core/src/DefaultPasskeyHandler.cs @@ -360,7 +360,7 @@ protected virtual async Task PerformAttestationCoreAsy /// 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-registering-a-new-credential + // 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. From e7ccd11afc491fd5ac26c44e5d8a9055761ff072 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 12 Jun 2025 09:26:59 -0400 Subject: [PATCH 25/31] Update baselines --- .../Templates.Tests/template-baselines.json | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/ProjectTemplates/test/Templates.Tests/template-baselines.json b/src/ProjectTemplates/test/Templates.Tests/template-baselines.json index a4420fbb65c7..61b2ffddf049 100644 --- a/src/ProjectTemplates/test/Templates.Tests/template-baselines.json +++ b/src/ProjectTemplates/test/Templates.Tests/template-baselines.json @@ -620,7 +620,9 @@ "Components/Account/Pages/Manage/ExternalLogins.razor", "Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "Components/Account/Pages/Manage/Index.razor", + "Components/Account/Pages/Manage/Passkeys.razor", "Components/Account/Pages/Manage/PersonalData.razor", + "Components/Account/Pages/Manage/RenamePasskey.razor", "Components/Account/Pages/Manage/ResetAuthenticator.razor", "Components/Account/Pages/Manage/SetPassword.razor", "Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -631,9 +633,13 @@ "Components/Account/Pages/ResetPassword.razor", "Components/Account/Pages/ResetPasswordConfirmation.razor", "Components/Account/Pages/_Imports.razor", + "Components/Account/PasskeyInputModel.cs", + "Components/Account/PasskeyOperation.cs", "Components/Account/Shared/ExternalLoginPicker.razor", "Components/Account/Shared/ManageLayout.razor", "Components/Account/Shared/ManageNavMenu.razor", + "Components/Account/Shared/PasskeySubmit.razor", + "Components/Account/Shared/PasskeySubmit.razor.js", "Components/Account/Shared/RedirectToLogin.razor", "Components/Account/Shared/ShowRecoveryCodes.razor", "Components/Account/Shared/StatusMessage.razor", @@ -810,7 +816,9 @@ "Components/Account/Pages/Manage/ExternalLogins.razor", "Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "Components/Account/Pages/Manage/Index.razor", + "Components/Account/Pages/Manage/Passkeys.razor", "Components/Account/Pages/Manage/PersonalData.razor", + "Components/Account/Pages/Manage/RenamePasskey.razor", "Components/Account/Pages/Manage/ResetAuthenticator.razor", "Components/Account/Pages/Manage/SetPassword.razor", "Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -821,9 +829,13 @@ "Components/Account/Pages/ResetPassword.razor", "Components/Account/Pages/ResetPasswordConfirmation.razor", "Components/Account/Pages/_Imports.razor", + "Components/Account/PasskeyInputModel.cs", + "Components/Account/PasskeyOperation.cs", "Components/Account/Shared/ExternalLoginPicker.razor", "Components/Account/Shared/ManageLayout.razor", "Components/Account/Shared/ManageNavMenu.razor", + "Components/Account/Shared/PasskeySubmit.razor", + "Components/Account/Shared/PasskeySubmit.razor.js", "Components/Account/Shared/RedirectToLogin.razor", "Components/Account/Shared/ShowRecoveryCodes.razor", "Components/Account/Shared/StatusMessage.razor", @@ -931,7 +943,9 @@ "Components/Account/Pages/Manage/ExternalLogins.razor", "Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "Components/Account/Pages/Manage/Index.razor", + "Components/Account/Pages/Manage/Passkeys.razor", "Components/Account/Pages/Manage/PersonalData.razor", + "Components/Account/Pages/Manage/RenamePasskey.razor", "Components/Account/Pages/Manage/ResetAuthenticator.razor", "Components/Account/Pages/Manage/SetPassword.razor", "Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -942,9 +956,13 @@ "Components/Account/Pages/ResetPassword.razor", "Components/Account/Pages/ResetPasswordConfirmation.razor", "Components/Account/Pages/_Imports.razor", + "Components/Account/PasskeyInputModel.cs", + "Components/Account/PasskeyOperation.cs", "Components/Account/Shared/ExternalLoginPicker.razor", "Components/Account/Shared/ManageLayout.razor", "Components/Account/Shared/ManageNavMenu.razor", + "Components/Account/Shared/PasskeySubmit.razor", + "Components/Account/Shared/PasskeySubmit.razor.js", "Components/Account/Shared/RedirectToLogin.razor", "Components/Account/Shared/ShowRecoveryCodes.razor", "Components/Account/Shared/StatusMessage.razor", @@ -1135,7 +1153,9 @@ "{ProjectName}/Components/Account/Pages/Manage/ExternalLogins.razor", "{ProjectName}/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "{ProjectName}/Components/Account/Pages/Manage/Index.razor", + "{ProjectName}/Components/Account/Pages/Manage/Passkeys.razor", "{ProjectName}/Components/Account/Pages/Manage/PersonalData.razor", + "{ProjectName}/Components/Account/Pages/Manage/RenamePasskey.razor", "{ProjectName}/Components/Account/Pages/Manage/ResetAuthenticator.razor", "{ProjectName}/Components/Account/Pages/Manage/SetPassword.razor", "{ProjectName}/Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -1146,9 +1166,13 @@ "{ProjectName}/Components/Account/Pages/ResetPassword.razor", "{ProjectName}/Components/Account/Pages/ResetPasswordConfirmation.razor", "{ProjectName}/Components/Account/Pages/_Imports.razor", + "{ProjectName}/Components/Account/PasskeyInputModel.cs", + "{ProjectName}/Components/Account/PasskeyOperation.cs", "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", "{ProjectName}/Components/Account/Shared/StatusMessage.razor", "{ProjectName}/Components/App.razor", @@ -1338,7 +1362,9 @@ "{ProjectName}/Components/Account/Pages/Manage/ExternalLogins.razor", "{ProjectName}/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "{ProjectName}/Components/Account/Pages/Manage/Index.razor", + "{ProjectName}/Components/Account/Pages/Manage/Passkeys.razor", "{ProjectName}/Components/Account/Pages/Manage/PersonalData.razor", + "{ProjectName}/Components/Account/Pages/Manage/RenamePasskey.razor", "{ProjectName}/Components/Account/Pages/Manage/ResetAuthenticator.razor", "{ProjectName}/Components/Account/Pages/Manage/SetPassword.razor", "{ProjectName}/Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -1349,9 +1375,13 @@ "{ProjectName}/Components/Account/Pages/ResetPassword.razor", "{ProjectName}/Components/Account/Pages/ResetPasswordConfirmation.razor", "{ProjectName}/Components/Account/Pages/_Imports.razor", + "{ProjectName}/Components/Account/PasskeyInputModel.cs", + "{ProjectName}/Components/Account/PasskeyOperation.cs", "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", "{ProjectName}/Components/Account/Shared/StatusMessage.razor", "{ProjectName}/Components/App.razor", @@ -1787,7 +1817,9 @@ "{ProjectName}/Components/Account/Pages/Manage/ExternalLogins.razor", "{ProjectName}/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "{ProjectName}/Components/Account/Pages/Manage/Index.razor", + "{ProjectName}/Components/Account/Pages/Manage/Passkeys.razor", "{ProjectName}/Components/Account/Pages/Manage/PersonalData.razor", + "{ProjectName}/Components/Account/Pages/Manage/RenamePasskey.razor", "{ProjectName}/Components/Account/Pages/Manage/ResetAuthenticator.razor", "{ProjectName}/Components/Account/Pages/Manage/SetPassword.razor", "{ProjectName}/Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -1798,9 +1830,13 @@ "{ProjectName}/Components/Account/Pages/ResetPassword.razor", "{ProjectName}/Components/Account/Pages/ResetPasswordConfirmation.razor", "{ProjectName}/Components/Account/Pages/_Imports.razor", + "{ProjectName}/Components/Account/PasskeyInputModel.cs", + "{ProjectName}/Components/Account/PasskeyOperation.cs", "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", "{ProjectName}/Components/Account/Shared/StatusMessage.razor", "{ProjectName}/Components/App.razor", @@ -1857,7 +1893,9 @@ "Components/Account/Pages/Manage/ExternalLogins.razor", "Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "Components/Account/Pages/Manage/Index.razor", + "Components/Account/Pages/Manage/Passkeys.razor", "Components/Account/Pages/Manage/PersonalData.razor", + "Components/Account/Pages/Manage/RenamePasskey.razor", "Components/Account/Pages/Manage/ResetAuthenticator.razor", "Components/Account/Pages/Manage/SetPassword.razor", "Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -1868,9 +1906,13 @@ "Components/Account/Pages/ResetPassword.razor", "Components/Account/Pages/ResetPasswordConfirmation.razor", "Components/Account/Pages/_Imports.razor", + "Components/Account/PasskeyInputModel.cs", + "Components/Account/PasskeyOperation.cs", "Components/Account/Shared/ExternalLoginPicker.razor", "Components/Account/Shared/ManageLayout.razor", "Components/Account/Shared/ManageNavMenu.razor", + "Components/Account/Shared/PasskeySubmit.razor", + "Components/Account/Shared/PasskeySubmit.razor.js", "Components/Account/Shared/RedirectToLogin.razor", "Components/Account/Shared/ShowRecoveryCodes.razor", "Components/Account/Shared/StatusMessage.razor", @@ -1994,7 +2036,9 @@ "{ProjectName}/Components/Account/Pages/Manage/ExternalLogins.razor", "{ProjectName}/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "{ProjectName}/Components/Account/Pages/Manage/Index.razor", + "{ProjectName}/Components/Account/Pages/Manage/Passkeys.razor", "{ProjectName}/Components/Account/Pages/Manage/PersonalData.razor", + "{ProjectName}/Components/Account/Pages/Manage/RenamePasskey.razor", "{ProjectName}/Components/Account/Pages/Manage/ResetAuthenticator.razor", "{ProjectName}/Components/Account/Pages/Manage/SetPassword.razor", "{ProjectName}/Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -2005,9 +2049,13 @@ "{ProjectName}/Components/Account/Pages/ResetPassword.razor", "{ProjectName}/Components/Account/Pages/ResetPasswordConfirmation.razor", "{ProjectName}/Components/Account/Pages/_Imports.razor", + "{ProjectName}/Components/Account/PasskeyInputModel.cs", + "{ProjectName}/Components/Account/PasskeyOperation.cs", "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", "{ProjectName}/Components/Account/Shared/StatusMessage.razor", "{ProjectName}/Components/App.razor", @@ -2122,7 +2170,9 @@ "{ProjectName}/Components/Account/Pages/Manage/ExternalLogins.razor", "{ProjectName}/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor", "{ProjectName}/Components/Account/Pages/Manage/Index.razor", + "{ProjectName}/Components/Account/Pages/Manage/Passkeys.razor", "{ProjectName}/Components/Account/Pages/Manage/PersonalData.razor", + "{ProjectName}/Components/Account/Pages/Manage/RenamePasskey.razor", "{ProjectName}/Components/Account/Pages/Manage/ResetAuthenticator.razor", "{ProjectName}/Components/Account/Pages/Manage/SetPassword.razor", "{ProjectName}/Components/Account/Pages/Manage/TwoFactorAuthentication.razor", @@ -2133,9 +2183,13 @@ "{ProjectName}/Components/Account/Pages/ResetPassword.razor", "{ProjectName}/Components/Account/Pages/ResetPasswordConfirmation.razor", "{ProjectName}/Components/Account/Pages/_Imports.razor", + "{ProjectName}/Components/Account/PasskeyInputModel.cs", + "{ProjectName}/Components/Account/PasskeyOperation.cs", "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", + "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", "{ProjectName}/Components/Account/Shared/StatusMessage.razor", "{ProjectName}/Components/App.razor", From ac1bd583cab2b065ae3c7e50ffa9e0f9c981b5f5 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 12 Jun 2025 13:19:13 -0400 Subject: [PATCH 26/31] PR feedback + a few tests --- .../Core/src/DefaultPasskeyHandler.cs | 311 ++++++++---------- .../Core/src/PasskeyExceptionExtensions.cs | 3 + .../Core/src/Passkeys/AuthenticatorData.cs | 2 + .../Core/src/Passkeys/BufferSource.cs | 10 - .../Core/src/Passkeys/CredentialPublicKey.cs | 34 +- src/Identity/Core/src/SignInManager.cs | 21 +- .../test/Identity.Test/IdentityOptionsTest.cs | 7 +- .../test/Identity.Test/SignInManagerTest.cs | 51 ++- 8 files changed, 250 insertions(+), 189 deletions(-) diff --git a/src/Identity/Core/src/DefaultPasskeyHandler.cs b/src/Identity/Core/src/DefaultPasskeyHandler.cs index af014752c5ad..dc9aa9c03509 100644 --- a/src/Identity/Core/src/DefaultPasskeyHandler.cs +++ b/src/Identity/Core/src/DefaultPasskeyHandler.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics; using System.Linq; using System.Security.Cryptography; using System.Text; @@ -172,10 +171,7 @@ protected virtual async Task PerformAttestationCoreAsy throw PasskeyException.InvalidOriginalCreationOptionsJsonFormat(ex); } - if (!string.Equals("public-key", credential.Type, StringComparison.Ordinal)) - { - throw PasskeyException.InvalidCredentialType("public-key", credential.Type); - } + VerifyCredentialType(credential); // 3. Let response be credential.response. var response = credential.Response; @@ -185,52 +181,15 @@ protected virtual async Task PerformAttestationCoreAsy // 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. - CollectedClientData clientData; - try - { - clientData = JsonSerializer.Deserialize(response.ClientDataJSON.AsSpan(), IdentityJsonSerializerContext.Default.CollectedClientData) - ?? throw PasskeyException.NullClientDataJson(); - } - catch (JsonException ex) - { - throw PasskeyException.InvalidClientDataJsonFormat(ex); - } - // 7. Verify that the value of clientData.type is webauthn.create. - if (!string.Equals("webauthn.create", clientData.Type, StringComparison.Ordinal)) - { - throw PasskeyException.InvalidClientDataType("webauthn.create", clientData.Type); - } - // 8. Verify that the value of clientData.challenge equals the base64url encoding of pkOptions.challenge. - if (!clientData.Challenge.FixedTimeEquals(originalOptions.Challenge)) - { - throw PasskeyException.InvalidChallenge(); - } - // 9-11. Verify that the value of C.origin matches the Relying Party's origin. - // 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, context.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); - } - } + 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()); @@ -242,64 +201,25 @@ protected virtual async Task PerformAttestationCoreAsy 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. - var rpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(originalOptions.Rp.Id ?? string.Empty)); - if (!CryptographicOperations.FixedTimeEquals(authenticatorData.RpIdHash.Span, rpIdHash.AsSpan())) - { - throw PasskeyException.InvalidRelyingPartyIDHash(); - } - // 15. 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(); - } - // 16. 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", originalOptions.AuthenticatorSelection?.UserVerification, StringComparison.Ordinal) && !authenticatorData.IsUserVerified) - { - throw PasskeyException.UserNotVerified(); - } - // 17. 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(); - } - // 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. - if (authenticatorData.IsBackupEligible && _passkeyOptions.BackupEligibleCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Disallowed) - { - throw PasskeyException.BackupEligibilityDisallowedYetBackupEligible(); - } - if (!authenticatorData.IsBackupEligible && _passkeyOptions.BackupEligibleCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Required) - { - throw PasskeyException.BackupEligibilityRequiredYetNotBackupEligible(); - } - // 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. - if (authenticatorData.IsBackedUp && _passkeyOptions.BackedUpCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Disallowed) - { - throw PasskeyException.BackupDisallowedYetBackedUp(); - } - if (!authenticatorData.IsBackedUp && _passkeyOptions.BackedUpCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Required) - { - throw PasskeyException.BackupRequiredYetNotBackedUp(); - } + VerifyAuthenticatorData( + authenticatorData, + originalRpId: originalOptions.Rp.Id, + originalUserVerificationRequirement: originalOptions.AuthenticatorSelection?.UserVerification); - // 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. if (!authenticatorData.HasAttestedCredentialData) { throw PasskeyException.MissingAttestedCredentialData(); } - // The attested credential data should always be non-null if the 'HasAttestedCredentialData' flag is set. + // 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; - Debug.Assert(attestedCredentialData is not null); - if (!originalOptions.PubKeyCredParams.Any(a => attestedCredentialData.CredentialPublicKey.Alg == a.Alg)) { throw PasskeyException.UnsupportedCredentialPublicKeyAlgorithm(); @@ -387,10 +307,7 @@ protected virtual async Task> PerformAssertionCore throw PasskeyException.InvalidOriginalRequestOptionsJsonFormat(ex); } - if (!string.Equals("public-key", credential.Type, StringComparison.Ordinal)) - { - throw PasskeyException.InvalidCredentialType("public-key", credential.Type); - } + VerifyCredentialType(credential); // 3. Let response be credential.response. var response = credential.Response; @@ -459,10 +376,109 @@ protected virtual async Task> PerformAssertionCore // 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(response.ClientDataJSON.AsSpan(), IdentityJsonSerializerContext.Default.CollectedClientData) + clientData = JsonSerializer.Deserialize(utf8Json.Span, IdentityJsonSerializerContext.Default.CollectedClientData) ?? throw PasskeyException.NullClientDataJson(); } catch (JsonException ex) @@ -470,24 +486,25 @@ protected virtual async Task> PerformAssertionCore throw PasskeyException.InvalidClientDataJsonFormat(ex); } - // 10. Verify that the value of C.type is the string webauthn.get. - if (!string.Equals("webauthn.get", clientData.Type, StringComparison.Ordinal)) + // 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("webauthn.get", clientData.Type); + throw PasskeyException.InvalidClientDataType(expectedType, clientData.Type); } - // 11. Verify that the value of C.challenge equals the base64url encoding of originalOptions.challenge. - if (!clientData.Challenge.FixedTimeEquals(originalOptions.Challenge)) + // 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(); } - // 12-14. Verify that the value of C.origin is an origin expected by the Relying Party. + // 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, context.HttpContext).ConfigureAwait(false); + var isOriginValid = await IsValidOriginAsync(originInfo, httpContext).ConfigureAwait(false); if (!isOriginValid) { throw PasskeyException.InvalidOrigin(clientData.Origin); @@ -505,47 +522,53 @@ protected virtual async Task> PerformAssertionCore throw PasskeyException.InvalidTokenBindingStatus(status); } } + } - // 15. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party. - var rpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(originalOptions.RpId ?? string.Empty)); - if (!CryptographicOperations.FixedTimeEquals(authenticatorData.RpIdHash.Span, rpIdHash.AsSpan())) + 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(); } - // 16. Verify that the UP bit of the flags in authData is set. + // 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(); } - // 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. - if (string.Equals("required", originalOptions.UserVerification, StringComparison.Ordinal) && !authenticatorData.IsUserVerified) + // 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(); } - // 18. If the BE bit of the flags in authData is not set, verify that the BS bit is not set. + // 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(); } - // 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. - if (storedPasskey.IsBackupEligible && !authenticatorData.IsBackupEligible) + // 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.ExpectedBackupEligibleCredential(); + throw PasskeyException.BackupEligibilityDisallowedYetBackupEligible(); } - if (!storedPasskey.IsBackupEligible && authenticatorData.IsBackupEligible) + if (!authenticatorData.IsBackupEligible && _passkeyOptions.BackupEligibleCredentialPolicy is PasskeyOptions.CredentialBackupPolicy.Required) { - throw PasskeyException.ExpectedBackupIneligibleCredential(); + 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(); @@ -554,49 +577,5 @@ protected virtual async Task> PerformAssertionCore { throw PasskeyException.BackupRequiredYetNotBackedUp(); } - - // 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 = new CredentialPublicKey(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); } } diff --git a/src/Identity/Core/src/PasskeyExceptionExtensions.cs b/src/Identity/Core/src/PasskeyExceptionExtensions.cs index e379a2ab96b8..ff50e11f59b1 100644 --- a/src/Identity/Core/src/PasskeyExceptionExtensions.cs +++ b/src/Identity/Core/src/PasskeyExceptionExtensions.cs @@ -147,5 +147,8 @@ public static PasskeyException NullClientDataJson() 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/Passkeys/AuthenticatorData.cs b/src/Identity/Core/src/Passkeys/AuthenticatorData.cs index 469e2cc52f43..bff5c1f5c246 100644 --- a/src/Identity/Core/src/Passkeys/AuthenticatorData.cs +++ b/src/Identity/Core/src/Passkeys/AuthenticatorData.cs @@ -3,6 +3,7 @@ using System.Buffers.Binary; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Formats.Cbor; namespace Microsoft.AspNetCore.Identity; @@ -68,6 +69,7 @@ internal sealed class AuthenticatorData /// /// 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) diff --git a/src/Identity/Core/src/Passkeys/BufferSource.cs b/src/Identity/Core/src/Passkeys/BufferSource.cs index 4dd5a3c78239..0db74ebac1f2 100644 --- a/src/Identity/Core/src/Passkeys/BufferSource.cs +++ b/src/Identity/Core/src/Passkeys/BufferSource.cs @@ -2,7 +2,6 @@ // 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.Serialization; @@ -78,15 +77,6 @@ public bool Equals(BufferSource? other) return other is not null && _bytes.Span.SequenceEqual(other._bytes.Span); } - /// - /// Performs a fixed-time value-based equality comparison with another instance - /// using . - /// - public bool FixedTimeEquals(BufferSource? other) - { - return other is not null && CryptographicOperations.FixedTimeEquals(_bytes.Span, other._bytes.Span); - } - /// public override bool Equals(object? obj) => obj is BufferSource other && Equals(other); diff --git a/src/Identity/Core/src/Passkeys/CredentialPublicKey.cs b/src/Identity/Core/src/Passkeys/CredentialPublicKey.cs index 1a78daa2e0eb..edcb8bae3c2a 100644 --- a/src/Identity/Core/src/Passkeys/CredentialPublicKey.cs +++ b/src/Identity/Core/src/Passkeys/CredentialPublicKey.cs @@ -16,7 +16,7 @@ internal sealed class CredentialPublicKey public COSEAlgorithmIdentifier Alg => _alg; - public CredentialPublicKey(ReadOnlyMemory bytes) + private CredentialPublicKey(ReadOnlyMemory bytes) { var reader = Ctap2CborReader.Create(bytes); @@ -38,7 +38,30 @@ public CredentialPublicKey(ReadOnlyMemory bytes) } var keyLength = bytes.Length - reader.BytesRemaining; - _bytes = bytes.Slice(0, keyLength); + _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) @@ -187,13 +210,6 @@ private static HashAlgorithmName HashAlgFromCOSEAlg(COSEAlgorithmIdentifier alg) }; } - public static CredentialPublicKey Decode(ReadOnlyMemory cpk, out int bytesRead) - { - var key = new CredentialPublicKey(cpk); - bytesRead = key._bytes.Length; - return key; - } - public ReadOnlyMemory AsMemory() => _bytes; public byte[] ToArray() => _bytes.ToArray(); diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index 0da1f0698231..d23c94628312 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -537,7 +537,7 @@ private void ThrowIfNoPasskeyHandler() } /// - /// Attempts to sign in the user with a passkey. + /// 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. @@ -555,6 +555,8 @@ public virtual async Task PasskeySignInAsync(string credentialJson 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) { @@ -568,6 +570,10 @@ public virtual async Task PasskeySignInAsync(string credentialJson /// 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 . /// @@ -593,6 +599,10 @@ public virtual async Task ConfigurePasskeyCreationOption /// 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 . /// @@ -653,6 +663,11 @@ async Task GetExcludeCredentialsAsync() /// 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 . /// @@ -680,6 +695,10 @@ public virtual async Task ConfigurePasskeyRequestOptionsA /// 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 . /// diff --git a/src/Identity/test/Identity.Test/IdentityOptionsTest.cs b/src/Identity/test/Identity.Test/IdentityOptionsTest.cs index 0b6e7d67343d..9f53948d4e57 100644 --- a/src/Identity/test/Identity.Test/IdentityOptionsTest.cs +++ b/src/Identity/test/Identity.Test/IdentityOptionsTest.cs @@ -32,6 +32,12 @@ public void VerifyDefaultOptions() Assert.Equal(ClaimTypes.Name, options.ClaimsIdentity.UserNameClaimType); Assert.Equal(ClaimTypes.NameIdentifier, options.ClaimsIdentity.UserIdClaimType); Assert.Equal("AspNet.Identity.SecurityStamp", options.ClaimsIdentity.SecurityStampClaimType); + + Assert.Equal(TimeSpan.FromMinutes(1), options.Passkey.Timeout); + Assert.Equal(16, options.Passkey.ChallengeSize); + Assert.True(options.Passkey.AllowCurrentOrigin); + Assert.Equal(PasskeyOptions.CredentialBackupPolicy.Allowed, options.Passkey.BackupEligibleCredentialPolicy); + Assert.Equal(PasskeyOptions.CredentialBackupPolicy.Allowed, options.Passkey.BackedUpCredentialPolicy); } [Fact] @@ -89,5 +95,4 @@ public void CanConfigureCookieOptions() Assert.Equal("c", options.Get(IdentityConstants.TwoFactorRememberMeScheme).Cookie.Name); Assert.Equal("d", options.Get(IdentityConstants.TwoFactorUserIdScheme).Cookie.Name); } - } diff --git a/src/Identity/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index 73fe6d6be218..99e525cb275e 100644 --- a/src/Identity/test/Identity.Test/SignInManagerTest.cs +++ b/src/Identity/test/Identity.Test/SignInManagerTest.cs @@ -97,7 +97,13 @@ private static Mock> SetupUserManager(PocoUser user) return manager; } - private static SignInManager SetupSignInManager(UserManager manager, HttpContext context, ILogger logger = null, IdentityOptions identityOptions = null, IAuthenticationSchemeProvider schemeProvider = null) + private static SignInManager SetupSignInManager( + UserManager manager, + HttpContext context, + ILogger logger = null, + IdentityOptions identityOptions = null, + IAuthenticationSchemeProvider schemeProvider = null, + IPasskeyHandler passkeyHandler = null) { var contextAccessor = new Mock(); contextAccessor.Setup(a => a.HttpContext).Returns(context); @@ -107,7 +113,16 @@ private static SignInManager SetupSignInManager(UserManager options.Setup(a => a.Value).Returns(identityOptions); var claimsFactory = new UserClaimsPrincipalFactory(manager, roleManager.Object, options.Object); schemeProvider = schemeProvider ?? new MockSchemeProvider(); - var sm = new SignInManager(manager, contextAccessor.Object, claimsFactory, options.Object, null, schemeProvider, new DefaultUserConfirmation()); + passkeyHandler = passkeyHandler ?? Mock.Of>(); + var sm = new SignInManager( + manager, + contextAccessor.Object, + claimsFactory, + options.Object, + null, + schemeProvider, + new DefaultUserConfirmation(), + passkeyHandler); sm.Logger = logger ?? NullLogger>.Instance; return sm; } @@ -339,6 +354,38 @@ public async Task ExternalSignInRequiresVerificationIfNotBypassed(bool bypass) auth.Verify(); } + [Fact] + public async Task CanPasskeySignIn() + { + // Setup + var user = new PocoUser { UserName = "Foo" }; + var passkey = new UserPasskeyInfo(null, null, null, default, 0, null, false, false, false, null, null); + var assertionResult = PasskeyAssertionResult.Success(passkey, user); + var passkeyHandler = new Mock>(); + passkeyHandler + .Setup(h => h.PerformAssertionAsync(It.IsAny>())) + .Returns(Task.FromResult(assertionResult)); + var manager = SetupUserManager(user); + manager + .Setup(m => m.SetPasskeyAsync(user, passkey)) + .Returns(Task.FromResult(IdentityResult.Success)) + .Verifiable(); + var context = new DefaultHttpContext(); + var auth = MockAuth(context); + SetupSignIn(context, auth, user.Id, isPersistent: false, loginProvider: null); + var helper = SetupSignInManager(manager.Object, context, passkeyHandler: passkeyHandler.Object); + + // Act + var passkeyRequestOptions = new PasskeyRequestOptions(userId: user.Id, ""); + var signInResult = await helper.PasskeySignInAsync(credentialJson: "", passkeyRequestOptions); + + // Assert + Assert.True(assertionResult.Succeeded); + Assert.Same(SignInResult.Success, signInResult); + manager.Verify(); + auth.Verify(); + } + private class GoodTokenProvider : AuthenticatorTokenProvider { public override Task ValidateAsync(string purpose, string token, UserManager manager, PocoUser user) From 231cc6fe6e62e251f93191e7d4bd049d630a392f Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 12 Jun 2025 16:03:20 -0400 Subject: [PATCH 27/31] Add BOM to template files --- .../IdentitySample.PasskeyUI/Components/Pages/Home.razor | 6 ++---- .../Components/Account/Pages/Manage/RenamePasskey.razor | 2 +- .../Components/Account/Shared/PasskeySubmit.razor | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor index 986e5e86c2ea..241ab23ed9e2 100644 --- a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor @@ -7,7 +7,6 @@ @inject NavigationManager NavigationManager @inject SignInManager SignInManager @inject UserManager UserManager -@inject IUserPasskeyStore PasskeyStore

Welcome!

@@ -106,9 +105,8 @@ } } - await PasskeyStore.SetPasskeyAsync(user, attestationResult.Passkey, CancellationToken.None); - var updateResult = await UserManager.UpdateAsync(user); - if (!updateResult.Succeeded) + var setPasskeyResult = await UserManager.SetPasskeyAsync(user, attestationResult.Passkey); + if (!setPasskeyResult.Succeeded) { statusMessage = "Error: Could not update the user with the new passkey."; return; diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor index 4d67ca04e360..e89253cc3a38 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Manage/RenamePasskey.razor @@ -1,4 +1,4 @@ -@page "/Account/Manage/RenamePasskey/{Id}" +@page "/Account/Manage/RenamePasskey/{Id}" @using BlazorWeb_CSharp.Data @using System.ComponentModel.DataAnnotations diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor index 8e373571e654..9cc8e57fe8ec 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor @@ -1,4 +1,4 @@ - + @code { From 29c008d8d0a32fcb7dcf1ccbd8962561172b6c85 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 13 Jun 2025 14:26:54 -0400 Subject: [PATCH 28/31] Add passkey autofill --- .../Components/Pages/Home.razor | 21 +-- .../Properties/launchSettings.json | 2 + .../IdentitySample.PasskeyUI/wwwroot/app.js | 147 ++++++++++-------- .../Components/Account/Pages/Login.razor | 2 +- .../Account/Shared/PasskeySubmit.razor.js | 46 ++++-- 5 files changed, 126 insertions(+), 92 deletions(-) diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor index 241ab23ed9e2..04c98b9ce58e 100644 --- a/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Components/Pages/Home.razor @@ -10,6 +10,8 @@

Welcome!

+Passkey sample +

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

@@ -27,12 +29,13 @@
- -
- - - - +
+ +
+
+ + +

@statusMessage

@@ -43,13 +46,13 @@ [CascadingParameter] private HttpContext HttpContext { get; set; } = default!; - [SupplyParameterFromForm] + [SupplyParameterFromForm(Name = "username")] private string? Username { get; set; } - [SupplyParameterFromForm] + [SupplyParameterFromForm(Name = "credential")] private string? CredentialJson { get; set; } - [SupplyParameterFromForm] + [SupplyParameterFromForm(Name = "action")] private string? Action { get; set; } private Task OnSubmitAsync() diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/Properties/launchSettings.json b/src/Identity/samples/IdentitySample.PasskeyUI/Properties/launchSettings.json index 2859cf29811d..d8362d8fcaf9 100644 --- a/src/Identity/samples/IdentitySample.PasskeyUI/Properties/launchSettings.json +++ b/src/Identity/samples/IdentitySample.PasskeyUI/Properties/launchSettings.json @@ -6,6 +6,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "http://localhost:5021", + "hotReloadEnabled": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -15,6 +16,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "https://localhost:7021;http://localhost:5021", + "hotReloadEnabled": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.js b/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.js index 025d9cc9a332..9f344b4413cd 100644 --- a/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.js +++ b/src/Identity/samples/IdentitySample.PasskeyUI/wwwroot/app.js @@ -22,83 +22,100 @@ } // Define home page JS functionality. - addRouteScript('/', () => { + addRouteScript('/', async () => { + let abortController; const form = document.getElementById('auth-form'); - const usernameInput = document.getElementById('input-username'); - const credentialInput = document.getElementById('input-credential'); - const actionInput = document.getElementById('input-action'); - const registerInput = document.getElementById('input-register'); - const authenticateInput = document.getElementById('input-authenticate'); const statusMessage = document.getElementById('status-message'); - async function submitCredential(action, credentialCallback) { - statusMessage.textContent = 'Submitting...'; + async function fetchNewCredential(username) { + if (!username) { + throw new Error('Please enter a username.'); + } + + const optionsResponse = await fetch('/attestation/options', { + method: 'POST', + body: JSON.stringify({ + username, + authenticatorSelection: { + residentKey: 'preferred', + } + // TODO: Allow configuration of other options. + }), + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }); + const optionsJson = await optionsResponse.json(); + const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson); + abortController?.abort(); + abortController = new AbortController(); + return await navigator.credentials.create({ + publicKey: options, + signal: abortController.signal, + }); + } + + async function fetchExistingCredential(username, useConditionalMediation) { + // The username is optional for authentication, so we don't validate it here. + const optionsResponse = await fetch('/assertion/options', { + method: 'POST', + body: JSON.stringify({ + username, + // TODO: Allow configuration of other options. + }), + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }); + const optionsJson = await optionsResponse.json(); + const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson); + abortController?.abort(); + abortController = new AbortController(); + return await navigator.credentials.get({ + publicKey: options, + mediation: useConditionalMediation ? 'conditional' : undefined, + signal: abortController.signal, + }); + } + + async function fetchAndSubmitCredential(action, useConditionalMediation = false) { try { - var credential = await credentialCallback(); + const username = new FormData(form).get('username'); + let credential; + if (action === 'register') { + credential = await fetchNewCredential(username); + } else if (action === 'authenticate') { + credential = await fetchExistingCredential(username, useConditionalMediation); + } else { + throw new Error('Unknown action: ' + action); + } var credentialJson = JSON.stringify(credential); - credentialInput.value = credentialJson; - actionInput.value = action; + form.addEventListener('formdata', (e) => { + e.formData.append('action', action); + e.formData.append('credential', credentialJson); + }, { once: true }); form.submit(); } catch (error) { - statusMessage.textContent = 'Error: ' + error.message; - throw error; + // Ignore abort errors, they are expected when the user cancels the operation. + if (error.name !== 'AbortError') { + statusMessage.textContent = 'Error: ' + error.message; + throw error; + } } } - registerInput.addEventListener('click', async (e) => { - e.preventDefault(); - - await submitCredential('register', async () => { - const username = usernameInput.value; - if (!username) { - throw new Error('Please enter a username.'); - } - - const optionsResponse = await fetch('/attestation/options', { - method: 'POST', - body: JSON.stringify({ - username, - authenticatorSelection: { - residentKey: 'preferred', - } - // TODO: Allow configuration of other options. - }), - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - }); - const optionsJson = await optionsResponse.json(); - const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson); - const credential = await navigator.credentials.create({ publicKey: options }); - return credential; - }); + form.addEventListener('submit', (e) => { + if (e.submitter?.name == 'action') { + e.preventDefault(); + fetchAndSubmitCredential(e.submitter.value); + } }); - authenticateInput.addEventListener('click', async (e) => { - e.preventDefault(); - - await submitCredential('authenticate', async () => { - // The username is optional for authentication, so we don't validate it here. - const username = usernameInput.value; - - const optionsResponse = await fetch('/assertion/options', { - method: 'POST', - body: JSON.stringify({ - username, - // TODO: Allow configuration of other options. - }), - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - }); - const optionsJson = await optionsResponse.json(); - const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson); - const credential = await navigator.credentials.get({ publicKey: options }); - return credential; - }); - }); + if (await PublicKeyCredential.isConditionalMediationAvailable()) { + await fetchAndSubmitCredential('authenticate', /* useConditionalMediation */ true); + } }); enableRouteScripts(); diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor index 173cc9db35c6..ce7cd0dc1d68 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Pages/Login.razor @@ -24,7 +24,7 @@
- +
diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js index 93ad15836692..6fb5ec86e236 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js @@ -1,4 +1,4 @@ -async function fetchWithErrorHandling(url, options = {}) { +async function fetchWithErrorHandling(url, options = {}) { const response = await fetch(url, { credentials: 'include', ...options @@ -11,30 +11,33 @@ async function fetchWithErrorHandling(url, options = {}) { return response; } -async function createCredential() { +async function createCredential(signal) { const optionsResponse = await fetchWithErrorHandling('/Account/PasskeyCreationOptions', { method: 'POST', + signal, }); const optionsJson = await optionsResponse.json(); const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson); - return await navigator.credentials.create({ publicKey: options }); + return await navigator.credentials.create({ publicKey: options, signal }); } -async function requestCredential(email) { +async function requestCredential(email, mediation, signal) { const optionsResponse = await fetchWithErrorHandling(`/Account/PasskeyRequestOptions?username=${email}`, { method: 'POST', + signal, }); const optionsJson = await optionsResponse.json(); const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson); - return await navigator.credentials.get({ publicKey: options }); + return await navigator.credentials.get({ publicKey: options, mediation, signal }); } +let passkeySubmitAbortController; + customElements.define('passkey-submit', class extends HTMLElement { static formAssociated = true; connectedCallback() { this.internals = this.attachInternals(); - this.isObtainingCredentials = false; this.attrs = { operation: this.getAttribute('operation'), name: this.getAttribute('name'), @@ -44,37 +47,46 @@ customElements.define('passkey-submit', class extends HTMLElement { this.internals.form.addEventListener('submit', (event) => { if (event.submitter?.name === '__passkeySubmit') { event.preventDefault(); - this.obtainCredentialAndReSubmit(); + this.obtainCredentialAndSubmit(); } }); - } - async obtainCredentialAndReSubmit() { - if (this.isObtainingCredentials) { - return; - } + this.tryAutofillPasskey(); + } - this.isObtainingCredentials = true; + async obtainCredentialAndSubmit(useConditionalMediation = false) { + passkeySubmitAbortController?.abort(); + passkeySubmitAbortController = new AbortController(); + const signal = passkeySubmitAbortController.signal; const formData = new FormData(); try { let credential; if (this.attrs.operation === 'Create') { - credential = await createCredential(); + credential = await createCredential(signal); } else if (this.attrs.operation === 'Request') { const email = new FormData(this.internals.form).get(this.attrs.emailName); - credential = await requestCredential(email); + const mediation = useConditionalMediation ? 'conditional' : undefined; + credential = await requestCredential(email, mediation, signal); } else { throw new Error(`Unknown passkey operation '${operation}'.`); } const credentialJson = JSON.stringify(credential); formData.append(`${this.attrs.name}.CredentialJson`, credentialJson); } catch (error) { + if (error.name === 'AbortError') { + // Canceled by user action, do not submit the form + return; + } formData.append(`${this.attrs.name}.Error`, error.message); console.error(error); - } finally { - this.isObtainingCredentials = false; } this.internals.setFormValue(formData); this.internals.form.submit(); } + + async tryAutofillPasskey() { + if (this.attrs.operation === 'Request' && await PublicKeyCredential.isConditionalMediationAvailable()) { + await this.obtainCredentialAndSubmit(/* useConditionalMediation */ true); + } + } }); From 8f16a1a09b31c69df9fbfd223a4d066e4789a3fd Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 13 Jun 2025 17:41:53 -0400 Subject: [PATCH 29/31] Fix E2E tests --- .../Account/Shared/PasskeySubmit.razor.js | 12 +++--- .../BlazorTemplateTest.cs | 40 ++++++++++--------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js index 6fb5ec86e236..f234215ef2d8 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Account/Shared/PasskeySubmit.razor.js @@ -31,8 +31,6 @@ async function requestCredential(email, mediation, signal) { return await navigator.credentials.get({ publicKey: options, mediation, signal }); } -let passkeySubmitAbortController; - customElements.define('passkey-submit', class extends HTMLElement { static formAssociated = true; @@ -54,10 +52,14 @@ customElements.define('passkey-submit', class extends HTMLElement { this.tryAutofillPasskey(); } + disconnectedCallback() { + this.abortController?.abort(); + } + async obtainCredentialAndSubmit(useConditionalMediation = false) { - passkeySubmitAbortController?.abort(); - passkeySubmitAbortController = new AbortController(); - const signal = passkeySubmitAbortController.signal; + this.abortController?.abort(); + this.abortController = new AbortController(); + const signal = this.abortController.signal; const formData = new FormData(); try { let credential; diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs index 7651f0125b5b..50a93deeebe9 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs @@ -138,6 +138,27 @@ await Task.WhenAll( if (authenticationFeatures.HasFlag(AuthenticationFeatures.RegisterAndLogIn)) { + // Start a new CDP session with WebAuthn enabled and add a virtual authenticator. + // We do this regardless of whether we're testing passkeys, because passkey + // gets attempted unconditionally on the login page, and this utilizes the WebAuthn API. + await using var cdpSession = await browser.NewCDPSessionAsync(page); + await cdpSession.SendAsync("WebAuthn.enable"); + var result = await cdpSession.SendAsync("WebAuthn.addVirtualAuthenticator", new Dictionary + { + ["options"] = new + { + protocol = "ctap2", + transport = "internal", + hasResidentKey = false, + hasUserIdentification = true, + isUserVerified = true, + automaticPresenceSimulation = true, + } + }); + + Assert.True(result.HasValue); + var authenticatorId = result.Value.GetProperty("authenticatorId").GetString(); + await Task.WhenAll( page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), page.ClickAsync("text=Login")); @@ -180,25 +201,6 @@ await Task.WhenAll( if (authenticationFeatures.HasFlag(AuthenticationFeatures.Passkeys)) { - // Start a new CDP session with WebAuthn enabled and add a virtual authenticator - await using var cdpSession = await browser.NewCDPSessionAsync(page); - await cdpSession.SendAsync("WebAuthn.enable"); - var result = await cdpSession.SendAsync("WebAuthn.addVirtualAuthenticator", new Dictionary - { - ["options"] = new - { - protocol = "ctap2", - transport = "internal", - hasResidentKey = false, - hasUserIdentification = true, - isUserVerified = true, - automaticPresenceSimulation = true, - } - }); - - Assert.True(result.HasValue); - var authenticatorId = result.Value.GetProperty("authenticatorId").GetString(); - // Navigate to the passkey management page await Task.WhenAll( page.WaitForURLAsync("**/Account/Manage**", new() { WaitUntil = WaitUntilState.NetworkIdle }), From 4964087b6b1aced611f86e811b0669169d6fb75e Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 18 Jun 2025 14:58:24 -0400 Subject: [PATCH 30/31] Cleanups --- .../src/AuthenticatorSelectionCriteria.cs | 2 - .../Core/src/IdentityJsonSerializerContext.cs | 5 ++- .../Core/src/PasskeyExceptionExtensions.cs | 9 ++-- .../Core/src/Passkeys/AttestationObject.cs | 4 ++ .../Core/src/Passkeys/CredentialPublicKey.cs | 44 +++++++++---------- .../Core/src/Passkeys/PublicKeyCredential.cs | 2 +- src/Identity/Core/src/PublicAPI.Unshipped.txt | 9 ++++ src/Identity/Core/src/SignInManager.cs | 4 +- .../src/PublicAPI.Unshipped.txt | 9 ---- 9 files changed, 45 insertions(+), 43 deletions(-) rename src/Identity/{Extensions.Core => Core}/src/AuthenticatorSelectionCriteria.cs (99%) diff --git a/src/Identity/Extensions.Core/src/AuthenticatorSelectionCriteria.cs b/src/Identity/Core/src/AuthenticatorSelectionCriteria.cs similarity index 99% rename from src/Identity/Extensions.Core/src/AuthenticatorSelectionCriteria.cs rename to src/Identity/Core/src/AuthenticatorSelectionCriteria.cs index 026ef734e8a4..fd834ad3e516 100644 --- a/src/Identity/Extensions.Core/src/AuthenticatorSelectionCriteria.cs +++ b/src/Identity/Core/src/AuthenticatorSelectionCriteria.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; - namespace Microsoft.AspNetCore.Identity; /// diff --git a/src/Identity/Core/src/IdentityJsonSerializerContext.cs b/src/Identity/Core/src/IdentityJsonSerializerContext.cs index e790bd256a6c..81ddf44b6acc 100644 --- a/src/Identity/Core/src/IdentityJsonSerializerContext.cs +++ b/src/Identity/Core/src/IdentityJsonSerializerContext.cs @@ -11,5 +11,8 @@ namespace Microsoft.AspNetCore.Identity; [JsonSerializable(typeof(PublicKeyCredentialRequestOptions))] [JsonSerializable(typeof(PublicKeyCredential))] [JsonSerializable(typeof(PublicKeyCredential))] -[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSourceGenerationOptions( + JsonSerializerDefaults.Web, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + RespectNullableAnnotations = true)] internal partial class IdentityJsonSerializerContext : JsonSerializerContext; diff --git a/src/Identity/Core/src/PasskeyExceptionExtensions.cs b/src/Identity/Core/src/PasskeyExceptionExtensions.cs index ff50e11f59b1..9c640cb4edc0 100644 --- a/src/Identity/Core/src/PasskeyExceptionExtensions.cs +++ b/src/Identity/Core/src/PasskeyExceptionExtensions.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Formats.Cbor; using System.Text.Json; namespace Microsoft.AspNetCore.Identity; @@ -14,7 +13,7 @@ public static PasskeyException InvalidCredentialType(string expectedType, string => new($"Expected credential type '{expectedType}', got '{actualType}'."); public static PasskeyException InvalidClientDataType(string expectedType, string actualType) - => new($"Expected the 'type' field of client data to be '{expectedType}', but it was actually '{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."); @@ -91,7 +90,7 @@ public static PasskeyException SignCountLessThanStoredSignCount() public static PasskeyException InvalidAttestationObject(Exception ex) => new($"An exception occurred while parsing the attestation object: {ex.Message}", ex); - public static PasskeyException InvalidAttestationObjectFormat(CborContentException ex) + public static PasskeyException InvalidAttestationObjectFormat(Exception ex) => new("The attestation object had an invalid format.", ex); public static PasskeyException MissingAttestationStatementFormat() @@ -104,13 +103,13 @@ 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 length of {length} bytes."); + => 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 length of {length} bytes."); + => 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); diff --git a/src/Identity/Core/src/Passkeys/AttestationObject.cs b/src/Identity/Core/src/Passkeys/AttestationObject.cs index 2d2433f7fff0..649df7249a6e 100644 --- a/src/Identity/Core/src/Passkeys/AttestationObject.cs +++ b/src/Identity/Core/src/Passkeys/AttestationObject.cs @@ -51,6 +51,10 @@ public static AttestationObject Parse(ReadOnlyMemory data) { throw PasskeyException.InvalidAttestationObjectFormat(ex); } + catch (InvalidOperationException ex) + { + throw PasskeyException.InvalidAttestationObjectFormat(ex); + } catch (Exception ex) { throw PasskeyException.InvalidAttestationObject(ex); diff --git a/src/Identity/Core/src/Passkeys/CredentialPublicKey.cs b/src/Identity/Core/src/Passkeys/CredentialPublicKey.cs index edcb8bae3c2a..27a322cf6741 100644 --- a/src/Identity/Core/src/Passkeys/CredentialPublicKey.cs +++ b/src/Identity/Core/src/Passkeys/CredentialPublicKey.cs @@ -69,7 +69,7 @@ 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), Padding), + COSEKeyType.RSA => _rsa!.VerifyData(data, signature, HashAlgFromCOSEAlg(_alg), GetRSASignaturePadding()), _ => throw new InvalidOperationException($"Missing or unknown kty {_type}"), }; } @@ -159,31 +159,29 @@ static bool IsValidKtyCrvCombination(COSEKeyType kty, COSEEllipticCurve crv) } } - internal RSASignaturePadding Padding + private RSASignaturePadding GetRSASignaturePadding() { - get + if (_type != COSEKeyType.RSA) { - if (_type != COSEKeyType.RSA) - { - throw new InvalidOperationException($"Must be a RSA key. Was {_type}"); - } - - switch (_alg) // https://www.iana.org/assignments/cose/cose.xhtml#algorithms - { - case COSEAlgorithmIdentifier.PS256: - case COSEAlgorithmIdentifier.PS384: - case COSEAlgorithmIdentifier.PS512: - return RSASignaturePadding.Pss; - - case COSEAlgorithmIdentifier.RS1: - case COSEAlgorithmIdentifier.RS256: - case COSEAlgorithmIdentifier.RS384: - case COSEAlgorithmIdentifier.RS512: - return RSASignaturePadding.Pkcs1; - default: - throw new InvalidOperationException($"Missing or unknown alg {_alg}"); - } + 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) diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredential.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredential.cs index 64b4cc1b6e42..fd702da5e272 100644 --- a/src/Identity/Core/src/Passkeys/PublicKeyCredential.cs +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredential.cs @@ -12,7 +12,7 @@ namespace Microsoft.AspNetCore.Identity; /// See /// internal sealed class PublicKeyCredential - where TResponse : AuthenticatorResponse + where TResponse : notnull, AuthenticatorResponse { /// /// Gets or sets the credential ID. diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 91313c5a385f..9594235ec62f 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1,4 +1,13 @@ #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!>! diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index d23c94628312..0604d2b799c4 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -585,7 +585,7 @@ public virtual async Task ConfigurePasskeyCreationOption var props = new AuthenticationProperties(); props.Items[PasskeyCreationOptionsKey] = options.AsJson(); - var claimsIdentity = new ClaimsIdentity(new ClaimsIdentity(IdentityConstants.TwoFactorUserIdScheme)); + 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)); @@ -679,7 +679,7 @@ public virtual async Task ConfigurePasskeyRequestOptionsA var props = new AuthenticationProperties(); props.Items[PasskeyRequestOptionsKey] = options.AsJson(); - var claimsIdentity = new ClaimsIdentity(new ClaimsIdentity(IdentityConstants.TwoFactorUserIdScheme)); + var claimsIdentity = new ClaimsIdentity(IdentityConstants.TwoFactorUserIdScheme); if (options.UserId is { } userId) { diff --git a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt index 7ae4d69dd3bc..52862f56815d 100644 --- a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt @@ -1,14 +1,5 @@ #nullable enable *REMOVED*Microsoft.AspNetCore.Identity.UserLoginInfo.UserLoginInfo(string! loginProvider, string! providerKey, string? displayName) -> void -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.IdentityOptions.Passkey.get -> Microsoft.AspNetCore.Identity.PasskeyOptions! Microsoft.AspNetCore.Identity.IdentityOptions.Passkey.set -> void Microsoft.AspNetCore.Identity.IUserPasskeyStore From 130fa9b06ce027e50b52fb65f51e3c598f276e30 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 19 Jun 2025 10:34:31 -0400 Subject: [PATCH 31/31] Update SignInManager.cs --- src/Identity/Core/src/SignInManager.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index 0604d2b799c4..a41cc20d01f8 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -759,13 +759,12 @@ async Task GetAllowCredentialsAsync() var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); - if (result?.Principal == null) + if (result?.Principal == null || result.Properties is not { } properties) { return null; } - var optionsJson = result.Properties?.Items[PasskeyCreationOptionsKey]; - if (optionsJson == null) + if (!properties.Items.TryGetValue(PasskeyCreationOptionsKey, out var optionsJson) || optionsJson is null) { return null; } @@ -798,13 +797,12 @@ async Task GetAllowCredentialsAsync() var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); - if (result?.Principal == null) + if (result?.Principal == null || result.Properties is not { } properties) { return null; } - var optionsJson = result.Properties?.Items[PasskeyRequestOptionsKey]; - if (optionsJson == null) + if (!properties.Items.TryGetValue(PasskeyRequestOptionsKey, out var optionsJson) || optionsJson is null) { return null; }