diff --git a/src/Identity/Core/src/IPasskeyHandler.cs b/src/Identity/Core/src/IPasskeyHandler.cs index 50898659edf0..5d87af66d92f 100644 --- a/src/Identity/Core/src/IPasskeyHandler.cs +++ b/src/Identity/Core/src/IPasskeyHandler.cs @@ -12,6 +12,15 @@ namespace Microsoft.AspNetCore.Identity; public interface IPasskeyHandler where TUser : class { + /// + /// Gets a value indicating whether this handler supports conditionally mediated passkey creation. + /// + /// + /// Returns unless the handler implements + /// . + /// + bool SupportsConditionalCreation => false; + /// /// Generates passkey creation options for the specified user entity and HTTP context. /// @@ -20,6 +29,39 @@ public interface IPasskeyHandler /// A representing the result. Task MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext); + /// + /// Generates passkey creation options for the specified user entity and HTTP context. + /// + /// The passkey user entity for which to generate creation options. + /// + /// if the passkey will be created with conditional mediation; otherwise, . + /// + /// The HTTP context associated with the request. + /// A representing the result. + /// + /// Conditional mediation lets a passkey be created without a user gesture, typically immediately + /// after the user signs in with a password. The corresponding navigator.credentials.create() + /// call must specify mediation: "conditional". + /// The caller must only request conditional mediation after a recent successful password authentication. + /// An existing authenticated session by itself is not sufficient authorization to add a new passkey. + /// The protected attestation state prevents the client from changing the mediation mode after options + /// are issued, but it does not authorize issuing conditional options. + /// + /// + /// Thrown when is and the handler + /// does not support conditionally mediated passkey creation. + /// + Task MakeCreationOptionsAsync(PasskeyUserEntity userEntity, bool isConditionallyMediated, HttpContext httpContext) + { + if (isConditionallyMediated) + { + throw new NotSupportedException( + $"The passkey handler '{GetType()}' does not support conditionally mediated passkey creation."); + } + + return MakeCreationOptionsAsync(userEntity, httpContext); + } + /// /// Generates passkey request options for the specified user and HTTP context. /// diff --git a/src/Identity/Core/src/PasskeyHandler.cs b/src/Identity/Core/src/PasskeyHandler.cs index 0081d672fa00..b0bf432c8a4b 100644 --- a/src/Identity/Core/src/PasskeyHandler.cs +++ b/src/Identity/Core/src/PasskeyHandler.cs @@ -35,7 +35,14 @@ public PasskeyHandler(UserManager userManager, IOptions - public async Task MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext) + public bool SupportsConditionalCreation => true; + + /// + public Task MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext) + => MakeCreationOptionsAsync(userEntity, isConditionallyMediated: false, httpContext); + + /// + public async Task MakeCreationOptionsAsync(PasskeyUserEntity userEntity, bool isConditionallyMediated, HttpContext httpContext) { ArgumentNullException.ThrowIfNull(userEntity); ArgumentNullException.ThrowIfNull(httpContext); @@ -67,7 +74,7 @@ public async Task MakeCreationOptionsAsync(Passkey { AuthenticatorAttachment = _options.AuthenticatorAttachment, ResidentKey = _options.ResidentKeyRequirement, - UserVerification = _options.UserVerificationRequirement, + UserVerification = GetUserVerificationRequirement(), }, Attestation = _options.AttestationConveyancePreference, }; @@ -75,6 +82,7 @@ public async Task MakeCreationOptionsAsync(Passkey { Challenge = challenge, UserEntity = userEntity, + IsConditionallyMediated = isConditionallyMediated, }; var optionsJson = JsonSerializer.Serialize(options, IdentityJsonSerializerContext.Default.PublicKeyCredentialCreationOptions); var attestationStateJson = JsonSerializer.Serialize(attestationState, IdentityJsonSerializerContext.Default.PasskeyAttestationState); @@ -104,6 +112,18 @@ async Task GetExcludeCredentialsAsync() }); return [.. excludeCredentials]; } + + string? GetUserVerificationRequirement() + { + // A conditionally mediated creation cannot collect user verification, and the browser rejects + // the ceremony outright if we ask for it, so the requirement is reduced to "preferred". + if (isConditionallyMediated && string.Equals("required", _options.UserVerificationRequirement, StringComparison.Ordinal)) + { + return "preferred"; + } + + return _options.UserVerificationRequirement; + } } /// @@ -283,7 +303,10 @@ await VerifyClientDataAsync( // bit of the flags in authData. // NOTE: It's up to application code to evaluate BE and BS flags on the returned passkey and determine // whether any action should be taken based on them. - VerifyAuthenticatorData(authenticatorData, context.HttpContext); + VerifyAuthenticatorData( + authenticatorData, + context.HttpContext, + isConditionallyMediated: attestationState.IsConditionallyMediated); if (!authenticatorData.HasAttestedCredentialData) { @@ -491,7 +514,7 @@ await VerifyClientDataAsync( // 17. If user verification was determined to be required, verify that the UV bit of the flags in authData is set. // Otherwise, ignore the value of the UV flag. // 18. If the BE bit of the flags in authData is not set, verify that the BS bit is not set. - VerifyAuthenticatorData(authenticatorData, context.HttpContext); + VerifyAuthenticatorData(authenticatorData, context.HttpContext, isConditionallyMediated: false); // 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 @@ -619,7 +642,8 @@ private async Task VerifyClientDataAsync( private void VerifyAuthenticatorData( AuthenticatorData authenticatorData, - HttpContext httpContext) + HttpContext httpContext, + bool isConditionallyMediated) { // Verify that the rpIdHash in authenticatorData is the SHA-256 hash of the RP ID expected by the Relying Party. var originalRpId = GetServerDomain(httpContext); @@ -630,16 +654,17 @@ private void VerifyAuthenticatorData( } // 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) + if (!isConditionallyMediated && !authenticatorData.IsUserPresent) { throw PasskeyException.UserNotPresent(); } // If user verification is required for this registration, verify that the User Verified bit of the flags in authData is set. + // NOTE: A conditionally mediated creation cannot collect user verification, so the requirement doesn't apply. var originalUserVerificationRequirement = _options.UserVerificationRequirement; - if (string.Equals("required", originalUserVerificationRequirement, StringComparison.Ordinal) && !authenticatorData.IsUserVerified) + if (!isConditionallyMediated && + string.Equals("required", originalUserVerificationRequirement, StringComparison.Ordinal) && + !authenticatorData.IsUserVerified) { throw PasskeyException.UserNotVerified(); } diff --git a/src/Identity/Core/src/Passkeys/PasskeyAttestationState.cs b/src/Identity/Core/src/Passkeys/PasskeyAttestationState.cs index 95c91639c71e..1660c5b99ff9 100644 --- a/src/Identity/Core/src/Passkeys/PasskeyAttestationState.cs +++ b/src/Identity/Core/src/Passkeys/PasskeyAttestationState.cs @@ -10,4 +10,6 @@ internal sealed class PasskeyAttestationState public required ReadOnlyMemory Challenge { get; init; } public required PasskeyUserEntity UserEntity { get; init; } + + public bool IsConditionallyMediated { get; init; } } diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..b90fe3dd8b0a 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1 +1,7 @@ #nullable enable +Microsoft.AspNetCore.Identity.IPasskeyHandler.MakeCreationOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, bool isConditionallyMediated, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IPasskeyHandler.SupportsConditionalCreation.get -> bool +Microsoft.AspNetCore.Identity.PasskeyHandler.MakeCreationOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, bool isConditionallyMediated, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.PasskeyHandler.SupportsConditionalCreation.get -> bool +virtual Microsoft.AspNetCore.Identity.SignInManager.MakePasskeyCreationOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, bool isConditionallyMediated) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.SignInManager.SupportsPasskeyConditionalCreation.get -> bool diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index e303012f9849..b8b2d93eab88 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -120,6 +120,11 @@ public HttpContext Context } } + /// + /// Gets a value indicating whether the configured passkey handler supports conditionally mediated passkey creation. + /// + public virtual bool SupportsPasskeyConditionalCreation => _passkeyHandler?.SupportsConditionalCreation ?? false; + /// /// Creates a for the specified , as an asynchronous operation. /// @@ -526,6 +531,40 @@ public virtual async Task MakePasskeyCreationOptionsAsync(PasskeyUserEnt var result = await _passkeyHandler.MakeCreationOptionsAsync(userEntity, Context); await StorePasskeyAuthenticationInfoAsync(PasskeyOperations.Attestation, result.AttestationState); + + return result.CreationOptionsJson; + } + + /// + /// Generates passkey creation options for the specified . + /// + /// The user entity for which to create passkey options. + /// + /// if the passkey will be created with conditional mediation; otherwise, . + /// + /// A JSON string representing the created passkey options. + /// + /// Conditional mediation lets a passkey be created without a user gesture, typically immediately + /// after the user signs in with a password. The corresponding navigator.credentials.create() + /// call must specify mediation: "conditional". + /// The caller must only request conditional mediation after a recent successful password authentication. + /// An existing authenticated session by itself is not sufficient authorization to add a new passkey. + /// The protected attestation state prevents the client from changing the mediation mode after options + /// are issued, but it does not authorize issuing conditional options. + /// + public virtual async Task MakePasskeyCreationOptionsAsync(PasskeyUserEntity userEntity, bool isConditionallyMediated) + { + if (!isConditionallyMediated) + { + return await MakePasskeyCreationOptionsAsync(userEntity); + } + + ThrowIfNoPasskeyHandler(); + ArgumentNullException.ThrowIfNull(userEntity); + + var result = await _passkeyHandler.MakeCreationOptionsAsync(userEntity, isConditionallyMediated, Context); + await StorePasskeyAuthenticationInfoAsync(PasskeyOperations.Attestation, result.AttestationState); + return result.CreationOptionsJson; } diff --git a/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerAttestationTest.cs b/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerAttestationTest.cs index 32f066e877d1..37df84894403 100644 --- a/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerAttestationTest.cs +++ b/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerAttestationTest.cs @@ -20,6 +20,17 @@ namespace Microsoft.AspNetCore.Identity.Test; public class PasskeyHandlerAttestationTest { + [Fact] + public void SupportsConditionalCreation() + { + var userManager = MockHelpers.MockUserManager(); + var handler = new PasskeyHandler( + userManager.Object, + Options.Create(new IdentityPasskeyOptions())); + + Assert.True(handler.SupportsConditionalCreation); + } + [Fact] public async Task CanSucceed() { @@ -966,6 +977,94 @@ public async Task Fails_WhenCredentialIdAlreadyExistsForAnotherUser() Assert.StartsWith("The credential is already registered for a user", result.Failure.Message); } + [Fact] + public async Task Fails_WhenUserIsNotPresent() + { + var test = new AttestationTest(); + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags & ~AuthenticatorDataFlags.UserPresent, + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The authenticator data flags did not include the 'UserPresent' flag", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenUserIsNotVerifiedAndUserVerificationIsRequired() + { + var test = new AttestationTest(); + test.PasskeyOptions.UserVerificationRequirement = "required"; + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("User verification is required", result.Failure.Message); + } + + [Fact] + public async Task CanSucceed_WhenConditionallyMediatedAndUserIsNotPresentOrVerified() + { + var test = new AttestationTest + { + IsConditionallyMediated = true, + }; + test.PasskeyOptions.UserVerificationRequirement = "required"; + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags & ~(AuthenticatorDataFlags.UserPresent | AuthenticatorDataFlags.UserVerified), + }); + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + Assert.False(result.Passkey.IsUserVerified); + } + + [Theory] + [InlineData("required", false, "required")] + [InlineData("required", true, "preferred")] + [InlineData("preferred", true, "preferred")] + [InlineData("discouraged", true, "discouraged")] + public async Task ReducesRequiredUserVerification_WhenConditionallyMediated( + string configuredRequirement, + bool isConditionallyMediated, + string expectedRequirement) + { + var test = new AttestationTest + { + IsConditionallyMediated = isConditionallyMediated, + }; + test.PasskeyOptions.UserVerificationRequirement = configuredRequirement; + + await test.RunAsync(); + + var creationOptions = test.CreationOptionsJson.GetValueAsJsonElement(); + var userVerification = creationOptions + .GetProperty("authenticatorSelection") + .GetProperty("userVerification") + .GetString(); + Assert.Equal(expectedRequirement, userVerification); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ConditionalMediationRoundTripsThroughAttestationState(bool isConditionallyMediated) + { + var test = new AttestationTest + { + IsConditionallyMediated = isConditionallyMediated, + }; + + await test.RunAsync(); + + var attestationState = test.AttestationStateJson.GetValueAsJsonElement(); + Assert.Equal(isConditionallyMediated, attestationState.GetProperty("isConditionallyMediated").GetBoolean()); + } + private static string GetInvalidBase64UrlValue(string base64UrlValue) { var rawValue = Base64Url.DecodeFromChars(base64UrlValue); @@ -986,6 +1085,7 @@ private sealed class AttestationTest : PasskeyScenarioTest CredentialId { get; set; } = _defaultCredentialId; @@ -996,6 +1096,7 @@ private sealed class AttestationTest : PasskeyScenarioTest> AuthenticatorData { get; } = new(); public ComputedValue> AttestationObject { get; } = new(); public ComputedJsonObject AttestationStateJson { get; } = new(); + public ComputedJsonObject CreationOptionsJson { get; } = new(); public ComputedJsonObject ClientDataJson { get; } = new(); public ComputedJsonObject CredentialJson { get; } = new(); @@ -1031,9 +1132,13 @@ protected override async Task RunCoreAsync() DisplayName = UserDisplayName!, }; - var creationOptionsResult = await handler.MakeCreationOptionsAsync(userEntity, httpContext.Object); + var creationOptionsResult = await handler.MakeCreationOptionsAsync( + userEntity, + IsConditionallyMediated, + httpContext.Object); + var creationOptionsJson = CreationOptionsJson.Compute(creationOptionsResult.CreationOptionsJson); var creationOptions = JsonSerializer.Deserialize( - creationOptionsResult.CreationOptionsJson, + creationOptionsJson!, IdentityJsonSerializerContext.Default.PublicKeyCredentialCreationOptions) ?? throw new InvalidOperationException("Failed to deserialize creation options JSON."); diff --git a/src/Identity/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index 2dcba35b6fd0..365cd775e8bf 100644 --- a/src/Identity/test/Identity.Test/SignInManagerTest.cs +++ b/src/Identity/test/Identity.Test/SignInManagerTest.cs @@ -648,6 +648,166 @@ public async Task PasskeySignInReturnsLockedOutWhenLockedOut() auth.Verify(); } + [Fact] + public async Task MakePasskeyCreationOptionsAsyncPassesConditionalMediationToHandler() + { + // Setup + var user = new PocoUser { UserName = "Foo" }; + var userEntity = new PasskeyUserEntity { Id = user.Id, Name = "Foo", DisplayName = "Foo" }; + var expectedOptionsJson = ""; + PasskeyUserEntity receivedUserEntity = null; + bool? receivedIsConditionallyMediated = null; + var passkeyHandler = new Mock>(); + passkeyHandler + .Setup(h => h.MakeCreationOptionsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((PasskeyUserEntity userEntity, bool isConditionallyMediated, HttpContext _) => + { + receivedUserEntity = userEntity; + receivedIsConditionallyMediated = isConditionallyMediated; + }) + .Returns(Task.FromResult(new PasskeyCreationOptionsResult + { + AttestationState = "", + CreationOptionsJson = expectedOptionsJson, + })); + var manager = SetupUserManager(user, passkeyHandler: passkeyHandler.Object); + var context = new DefaultHttpContext(); + var auth = MockAuth(context); + SetupPasskeyAuth(context, auth); + var helper = SetupSignInManager(manager.Object, context); + + // Act + var optionsJson = await helper.MakePasskeyCreationOptionsAsync(userEntity, isConditionallyMediated: true); + + // Assert + Assert.Equal(expectedOptionsJson, optionsJson); + Assert.Same(userEntity, receivedUserEntity); + Assert.True(receivedIsConditionallyMediated); + auth.Verify( + a => a.SignInAsync(context, IdentityConstants.TwoFactorUserIdScheme, It.IsAny(), It.IsAny()), + Times.Once()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SupportsPasskeyConditionalCreationMatchesHandler(bool supportsConditionalCreation) + { + // Setup + var user = new PocoUser { UserName = "Foo" }; + var passkeyHandler = new Mock>(); + passkeyHandler + .Setup(h => h.SupportsConditionalCreation) + .Returns(supportsConditionalCreation); + var manager = SetupUserManager(user, passkeyHandler: passkeyHandler.Object); + var helper = SetupSignInManager(manager.Object, new DefaultHttpContext()); + + // Act & Assert + Assert.Equal(supportsConditionalCreation, helper.SupportsPasskeyConditionalCreation); + } + + [Fact] + public void SupportsPasskeyConditionalCreationIsFalseWithoutHandler() + { + // Setup + var user = new PocoUser { UserName = "Foo" }; + var manager = SetupUserManager(user); + var helper = SetupSignInManager(manager.Object, new DefaultHttpContext()); + + // Act & Assert + Assert.False(helper.SupportsPasskeyConditionalCreation); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task MakePasskeyCreationOptionsAsyncUsesOriginalHandlerOverload(bool useConditionalOverload) + { + // Setup + var user = new PocoUser { UserName = "Foo" }; + var userEntity = new PasskeyUserEntity { Id = user.Id, Name = "Foo", DisplayName = "Foo" }; + var expectedOptionsJson = ""; + var passkeyHandler = new Mock>(); + passkeyHandler + .Setup(h => h.MakeCreationOptionsAsync(userEntity, It.IsAny())) + .Returns(Task.FromResult(new PasskeyCreationOptionsResult + { + AttestationState = "", + CreationOptionsJson = expectedOptionsJson, + })); + var manager = SetupUserManager(user, passkeyHandler: passkeyHandler.Object); + var context = new DefaultHttpContext(); + var auth = MockAuth(context); + SetupPasskeyAuth(context, auth); + var helper = SetupSignInManager(manager.Object, context); + + // Act + var optionsJson = useConditionalOverload + ? await helper.MakePasskeyCreationOptionsAsync(userEntity, isConditionallyMediated: false) + : await helper.MakePasskeyCreationOptionsAsync(userEntity); + + // Assert + Assert.Equal(expectedOptionsJson, optionsJson); + passkeyHandler.Verify( + h => h.MakeCreationOptionsAsync(userEntity, context), + Times.Once()); + passkeyHandler.Verify( + h => h.MakeCreationOptionsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never()); + auth.Verify( + a => a.SignInAsync(context, IdentityConstants.TwoFactorUserIdScheme, It.IsAny(), It.IsAny()), + Times.Once()); + } + + [Fact] + public async Task MakePasskeyCreationOptionsAsyncThrowsForConditionalMediationOnUnsupportedHandler() + { + // Setup + var user = new PocoUser { UserName = "Foo" }; + var userEntity = new PasskeyUserEntity { Id = user.Id, Name = "Foo", DisplayName = "Foo" }; + var manager = SetupUserManager(user, passkeyHandler: new NonConditionalPasskeyHandler()); + var context = new DefaultHttpContext(); + var auth = MockAuth(context); + SetupPasskeyAuth(context, auth); + var helper = SetupSignInManager(manager.Object, context); + + // Act & Assert + Assert.False(helper.SupportsPasskeyConditionalCreation); + + var optionsJson = await helper.MakePasskeyCreationOptionsAsync(userEntity, isConditionallyMediated: false); + Assert.Equal(NonConditionalPasskeyHandler.CreationOptionsJson, optionsJson); + + var exception = await Assert.ThrowsAsync( + () => helper.MakePasskeyCreationOptionsAsync(userEntity, isConditionallyMediated: true)); + Assert.Contains("does not support conditionally mediated passkey creation", exception.Message); + } + + // Represents a handler written before conditional mediation was supported, + // which therefore relies on the default interface implementation. + private sealed class NonConditionalPasskeyHandler : IPasskeyHandler + { + public const string CreationOptionsJson = ""; + + public Task MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext) + => Task.FromResult(new PasskeyCreationOptionsResult + { + AttestationState = "", + CreationOptionsJson = CreationOptionsJson, + }); + + public Task MakeRequestOptionsAsync(PocoUser user, HttpContext httpContext) + => throw new NotImplementedException(); + + public Task PerformAttestationAsync(PasskeyAttestationContext context) + => throw new NotImplementedException(); + + public Task> PerformAssertionAsync(PasskeyAssertionContext context) + => throw new NotImplementedException(); + } + private static void SetupPasskeyAuth(HttpContext context, Mock auth) { // Calling AuthenticateAsync will return a failure result diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Login.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Login.razor index 9582d32b04c2..41fb03258348 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Login.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Login.razor @@ -85,6 +85,9 @@ [SupplyParameterFromTempData(Name = IdentityRedirectManager.StatusMessageKey)] private string? IdentityStatusMessage { get; set; } + [SupplyParameterFromTempData(Name = PasskeyUpgradeManager.CreationOptionsKey)] + private string? PasskeyUpgradeCreationOptions { get; set; } + [SupplyParameterFromForm] private InputModel Input { get; set; } = default!; @@ -115,6 +118,7 @@ } SignInResult result; + var isPasswordSignIn = false; if (!string.IsNullOrEmpty(Input.Passkey?.CredentialJson)) { // When performing passkey sign-in, don't perform form validation. @@ -131,11 +135,34 @@ // 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); + isPasswordSignIn = true; } if (result.Succeeded) { Logger.LogInformation("User logged in."); + if (isPasswordSignIn) + { + var user = await UserManager.FindByNameAsync(Input.Email); + if (user is not null) + { + PasskeyUpgradeCreationOptions = await PasskeyUpgradeManager.TryMakeCreationOptionsAsync( + UserManager, + SignInManager, + user); + } + else + { + Logger.LogWarning("Unable to load the user after a successful password sign-in."); + } + + if (PasskeyUpgradeCreationOptions is not null) + { + RedirectManager.RedirectTo("Account/PasskeyUpgrade", new() { ["returnUrl"] = ReturnUrl }); + return; + } + } + RedirectManager.RedirectTo(ReturnUrl); } else if (result.RequiresTwoFactor) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/LoginWith2fa.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/LoginWith2fa.razor index 3d7243c9b775..4ea703d96b13 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/LoginWith2fa.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/LoginWith2fa.razor @@ -57,6 +57,9 @@ [SupplyParameterFromQuery] private bool RememberMe { get; set; } + [SupplyParameterFromTempData(Name = PasskeyUpgradeManager.CreationOptionsKey)] + private string? PasskeyUpgradeCreationOptions { get; set; } + protected override async Task OnInitializedAsync() { Input ??= new(); @@ -75,6 +78,16 @@ if (result.Succeeded) { Logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId); + PasskeyUpgradeCreationOptions = await PasskeyUpgradeManager.TryMakeCreationOptionsAsync( + UserManager, + SignInManager, + user); + if (PasskeyUpgradeCreationOptions is not null) + { + RedirectManager.RedirectTo("Account/PasskeyUpgrade", new() { ["returnUrl"] = ReturnUrl }); + return; + } + RedirectManager.RedirectTo(ReturnUrl); } else if (result.IsLockedOut) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/LoginWithRecoveryCode.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/LoginWithRecoveryCode.razor index f99d02900b12..aaa5a318d96b 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/LoginWithRecoveryCode.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/LoginWithRecoveryCode.razor @@ -43,6 +43,9 @@ [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } + [SupplyParameterFromTempData(Name = PasskeyUpgradeManager.CreationOptionsKey)] + private string? PasskeyUpgradeCreationOptions { get; set; } + protected override async Task OnInitializedAsync() { Input ??= new(); @@ -63,6 +66,16 @@ if (result.Succeeded) { Logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", userId); + PasskeyUpgradeCreationOptions = await PasskeyUpgradeManager.TryMakeCreationOptionsAsync( + UserManager, + SignInManager, + user); + if (PasskeyUpgradeCreationOptions is not null) + { + RedirectManager.RedirectTo("Account/PasskeyUpgrade", new() { ["returnUrl"] = ReturnUrl }); + return; + } + RedirectManager.RedirectTo(ReturnUrl); } else if (result.IsLockedOut) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/PasskeyUpgrade.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/PasskeyUpgrade.razor new file mode 100644 index 000000000000..1d458b193bef --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/PasskeyUpgrade.razor @@ -0,0 +1,97 @@ +@page "/Account/PasskeyUpgrade" + +@using BlazorWebCSharp._1.Data +@using Microsoft.AspNetCore.Identity + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject ILogger Logger +@inject IdentityRedirectManager RedirectManager + +Signing in + +

Signing you in...

+

One moment while we finish signing you in.

+ +
+ Continue +
+ +@code { + private string? creationOptionsJson; + private ApplicationUser? user; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromTempData(Name = PasskeyUpgradeManager.CreationOptionsKey)] + private string? SuppliedCreationOptionsJson { get; set; } + + [SupplyParameterFromForm(FormName = "passkey-upgrade")] + private PasskeyInputModel Input { get; set; } = default!; + + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } + + protected override async Task OnInitializedAsync() + { + creationOptionsJson = SuppliedCreationOptionsJson; + SuppliedCreationOptionsJson = null; + Input ??= new(); + + user = await UserManager.GetUserAsync(HttpContext.User); + if (user is null) + { + // There's nothing to upgrade if nobody is signed in, so just continue. + RedirectManager.RedirectTo(ReturnUrl); + return; + } + + if (!HttpMethods.IsGet(HttpContext.Request.Method)) + { + return; + } + + if (string.IsNullOrEmpty(creationOptionsJson)) + { + RedirectManager.RedirectTo(ReturnUrl); + } + } + + private async Task UpgradeToPasskey() + { + // The user did not ask for a passkey to be created, so a failure here is never + // reported to them. Whatever happens, they continue to where they were going. + if (user is not null && !string.IsNullOrEmpty(Input.CredentialJson)) + { + var attestationResult = await SignInManager.PerformPasskeyAttestationAsync(Input.CredentialJson); + if (attestationResult.Succeeded) + { + if (PasskeyAuthenticators.TryGetDefaultDisplayName(attestationResult.Passkey, out var defaultName)) + { + attestationResult.Passkey.Name = defaultName; + } + + var addPasskeyResult = await UserManager.AddOrUpdatePasskeyAsync(user, attestationResult.Passkey); + if (addPasskeyResult.Succeeded) + { + Logger.LogInformation("User upgraded to a passkey."); + } + else + { + Logger.LogWarning("The upgraded passkey could not be added to the user's account."); + } + } + else + { + Logger.LogWarning("Passkey attestation failed during upgrade: {Message}", attestationResult.Failure.Message); + } + } + + RedirectManager.RedirectTo(ReturnUrl); + } +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/PasskeyOperation.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/PasskeyOperation.cs index ab2f0a29ef24..59af5da0457d 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/PasskeyOperation.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/PasskeyOperation.cs @@ -4,4 +4,5 @@ public enum PasskeyOperation { Create = 0, Request = 1, + Upgrade = 2, } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/PasskeyUpgradeManager.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/PasskeyUpgradeManager.cs new file mode 100644 index 000000000000..b448dfd030ff --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/PasskeyUpgradeManager.cs @@ -0,0 +1,29 @@ +using BlazorWebCSharp._1.Data; +using Microsoft.AspNetCore.Identity; + +namespace BlazorWebCSharp._1.Components.Account; + +internal static class PasskeyUpgradeManager +{ + public const string CreationOptionsKey = "Identity.PasskeyUpgradeCreationOptions"; + + public static async Task TryMakeCreationOptionsAsync( + UserManager userManager, + SignInManager signInManager, + ApplicationUser user) + { + if (!signInManager.SupportsPasskeyConditionalCreation) + { + return null; + } + + var userId = await userManager.GetUserIdAsync(user); + var userName = await userManager.GetUserNameAsync(user) ?? "User"; + return await signInManager.MakePasskeyCreationOptionsAsync(new() + { + Id = userId, + Name = userName, + DisplayName = userName + }, isConditionallyMediated: true); + } +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor index 3fad13f9dad0..1d2ef985028f 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor @@ -2,7 +2,8 @@ + email-name="@EmailName" + creation-options="@CreationOptionsJson"> @code { @@ -17,6 +18,9 @@ [Parameter] public string? EmailName { get; set; } + [Parameter] + public string? CreationOptionsJson { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor.js index 13004de3563d..2be62d299e64 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor.js +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySubmit.razor.js @@ -4,6 +4,10 @@ typeof window.PublicKeyCredential.parseCreationOptionsFromJSON === 'function' && typeof window.PublicKeyCredential.parseRequestOptionsFromJSON === 'function'; +// A conditional creation needs no user interaction, so it should be quick. Giving up early +// costs at most a passkey the user can still add manually, but waiting stalls the sign-in. +const upgradeTimeoutMs = 5000; + async function fetchWithErrorHandling(url, options = {}) { const response = await fetch(url, { credentials: 'include', @@ -17,14 +21,28 @@ async function fetchWithErrorHandling(url, options = {}) { return response; } -async function createCredential(signal) { - const optionsResponse = await fetchWithErrorHandling('/Account/PasskeyCreationOptions', { - method: 'POST', - signal, - }); - const optionsJson = await optionsResponse.json(); - const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson); - return await navigator.credentials.create({ publicKey: options, signal }); +async function createCredential(optionsJson, mediation, signal) { + let options; + if (!optionsJson) { + const optionsResponse = await fetchWithErrorHandling('/Account/PasskeyCreationOptions', { + method: 'POST', + signal, + }); + options = await optionsResponse.json(); + } else { + options = JSON.parse(optionsJson); + } + options = PublicKeyCredential.parseCreationOptionsFromJSON(options); + return await navigator.credentials.create({ publicKey: options, mediation, signal }); +} + +async function browserSupportsConditionalCreate() { + try { + const capabilities = await PublicKeyCredential.getClientCapabilities?.(); + return capabilities?.conditionalCreate === true; + } catch { + return false; + } } async function requestCredential(email, mediation, signal) { @@ -46,6 +64,7 @@ customElements.define('passkey-submit', class extends HTMLElement { operation: this.getAttribute('operation'), name: this.getAttribute('name'), emailName: this.getAttribute('email-name'), + creationOptions: this.getAttribute('creation-options'), }; this.internals.form.addEventListener('submit', (event) => { @@ -56,6 +75,7 @@ customElements.define('passkey-submit', class extends HTMLElement { }); this.tryAutofillPasskey(); + this.tryUpgradeToPasskey(); } disconnectedCallback() { @@ -68,7 +88,12 @@ customElements.define('passkey-submit', class extends HTMLElement { } if (this.attrs.operation === 'Create') { - return await createCredential(signal); + return await createCredential(/* optionsJson */ undefined, /* mediation */ undefined, signal); + } else if (this.attrs.operation === 'Upgrade') { + if (!this.attrs.creationOptions) { + throw new Error('Passkey creation options were not provided.'); + } + return await createCredential(this.attrs.creationOptions, 'conditional', signal); } else if (this.attrs.operation === 'Request') { const email = new FormData(this.internals.form).get(this.attrs.emailName); const mediation = useConditionalMediation ? 'conditional' : undefined; @@ -79,29 +104,38 @@ customElements.define('passkey-submit', class extends HTMLElement { } async obtainAndSubmitCredential(useConditionalMediation = false) { + const isUpgrade = this.attrs.operation === 'Upgrade'; this.abortController?.abort(); this.abortController = new AbortController(); - const signal = this.abortController.signal; + // An upgrade happens while the user waits to be signed in, so it must not hang. + const signal = isUpgrade + ? AbortSignal.any([this.abortController.signal, AbortSignal.timeout(upgradeTimeoutMs)]) + : this.abortController.signal; const formData = new FormData(); try { const credential = await this.obtainCredential(useConditionalMediation, signal); const credentialJson = JSON.stringify(credential); formData.append(`${this.attrs.name}.CredentialJson`, credentialJson); } catch (error) { - if (error.name === 'AbortError') { + if (isUpgrade) { + // Upgrading is a best-effort operation that the user did not ask for, so failures + // are logged but never shown, and the form is still submitted to continue sign-in. + console.debug(error); + } else if (error.name === 'AbortError') { // The user explicitly canceled the operation - return without error. return; + } else { + console.error(error); + if (useConditionalMediation) { + // An error occurred during conditional mediation, which is not user-initiated. + // We log the error in the console but do not relay it to the user. + return; + } + const errorMessage = error.name === 'NotAllowedError' + ? 'No passkey was provided by the authenticator.' + : error.message; + formData.append(`${this.attrs.name}.Error`, errorMessage); } - console.error(error); - if (useConditionalMediation) { - // An error occurred during conditional mediation, which is not user-initiated. - // We log the error in the console but do not relay it to the user. - return; - } - const errorMessage = error.name === 'NotAllowedError' - ? 'No passkey was provided by the authenticator.' - : error.message; - formData.append(`${this.attrs.name}.Error`, errorMessage); } this.internals.setFormValue(formData); this.internals.form.submit(); @@ -112,4 +146,20 @@ customElements.define('passkey-submit', class extends HTMLElement { await this.obtainAndSubmitCredential(/* useConditionalMediation */ true); } } + + async tryUpgradeToPasskey() { + if (this.attrs.operation !== 'Upgrade') { + return; + } + + // The form is only a fallback for browsers without scripting, which can't upgrade anyway. + this.internals.form.hidden = true; + + if (!browserSupportsPasskeys || !await browserSupportsConditionalCreate()) { + this.internals.form.submit(); + return; + } + + await this.obtainAndSubmitCredential(); + } }); diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs index f6334c1cca6a..2f6e15cbc622 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs @@ -1,7 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers.Binary; +using System.Globalization; using System.Runtime.InteropServices; +using System.Security.Cryptography; using Microsoft.AspNetCore.BrowserTesting; using Microsoft.Playwright; using Templates.Test.Helpers; @@ -11,6 +14,7 @@ namespace BlazorTemplates.Tests; public abstract class BlazorTemplateTest : BrowserTestBase { public const int BUILDCREATEPUBLISH_PRIORITY = -1000; + private const string ConditionalCreateRecordKey = "conditional-create-record"; public BlazorTemplateTest(ProjectFactoryFixture projectFactory) { @@ -96,6 +100,25 @@ protected async Task TestBasicInteractionInNewPageAsync( } await using var browser = await BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); + if (authenticationFeatures.HasFlag(AuthenticationFeatures.Passkeys)) + { + await browser.AddInitScriptAsync(script: $$""" + PublicKeyCredential.getClientCapabilities = async () => ({ conditionalCreate: true }); + + const originalCreate = navigator.credentials.create.bind(navigator.credentials); + navigator.credentials.create = async options => { + if (options?.mediation === 'conditional') { + sessionStorage.setItem( + '{{ConditionalCreateRecordKey}}', + `${options.mediation}:${options.publicKey !== undefined}`); + throw new DOMException('Conditional creation intercepted by the test.', 'NotAllowedError'); + } + + return await originalCreate(options); + }; + """); + } + var page = await browser.NewPageAsync(); Output.WriteLine($"Opening browser at {listeningUri}..."); @@ -203,6 +226,16 @@ await Task.WhenAll( if (authenticationFeatures.HasFlag(AuthenticationFeatures.Passkeys)) { + await AssertConditionalCreateAsync(page); + await ClearConditionalCreateRecordAsync(page); + + var passkeyUpgradeUrl = new Uri( + new Uri(page.Url), + "/Account/PasskeyUpgrade?returnUrl=/auth"); + await page.GotoAsync(passkeyUpgradeUrl.AbsoluteUri, new() { WaitUntil = WaitUntilState.NetworkIdle }); + await page.WaitForSelectorAsync("text=You are authenticated"); + Assert.Null(await GetConditionalCreateRecordAsync(page)); + // Navigate to the passkey management page await Task.WhenAll( page.WaitForURLAsync("**/Account/Manage**", new() { WaitUntil = WaitUntilState.NetworkIdle }), @@ -286,6 +319,62 @@ await page.EvaluateAsync(""" // Verify that we can visit the "Auth Required" page again await page.ClickAsync("text=Auth Required"); await page.WaitForSelectorAsync("text=You are authenticated"); + + // Enable 2FA so that both completed password sign-in paths exercise + // conditional creation after the partial 2FA cookie has been consumed. + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Manage**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("a[href=\"Account/Manage\"]")); + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Manage/TwoFactorAuthentication**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("a[href=\"Account/Manage/TwoFactorAuthentication\"]")); + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Manage/EnableAuthenticator**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Add authenticator app")); + + var sharedKey = (await page.Locator("kbd").InnerTextAsync()).Replace(" ", string.Empty); + await page.FillAsync("[name=\"Input.Code\"]", ComputeAuthenticatorCode(sharedKey)); + await page.ClickAsync("text=Verify"); + await page.WaitForSelectorAsync("text=Recovery codes"); + var recoveryCode = await page.Locator(".recovery-code").First.InnerTextAsync(); + + await page.ClickAsync("text=Auth Required"); + await page.WaitForSelectorAsync("text=You are authenticated"); + await ClearConditionalCreateRecordAsync(page); + + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Logout")); + await page.FillAsync("[name=\"Input.Email\"]", userName); + await page.FillAsync("[name=\"Input.Password\"]", password); + await Task.WhenAll( + page.WaitForURLAsync("**/Account/LoginWith2fa**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("button[type=\"submit\"]")); + Assert.Null(await GetConditionalCreateRecordAsync(page)); + + await page.FillAsync("[name=\"Input.TwoFactorCode\"]", ComputeAuthenticatorCode(sharedKey)); + await page.ClickAsync("button[type=\"submit\"]"); + await page.WaitForSelectorAsync("text=You are authenticated"); + await AssertConditionalCreateAsync(page); + await ClearConditionalCreateRecordAsync(page); + + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Logout")); + await page.FillAsync("[name=\"Input.Email\"]", userName); + await page.FillAsync("[name=\"Input.Password\"]", password); + await Task.WhenAll( + page.WaitForURLAsync("**/Account/LoginWith2fa**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("button[type=\"submit\"]")); + Assert.Null(await GetConditionalCreateRecordAsync(page)); + + await Task.WhenAll( + page.WaitForURLAsync("**/Account/LoginWithRecoveryCode**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=log in with a recovery code")); + await page.FillAsync("[name=\"Input.RecoveryCode\"]", recoveryCode); + await page.ClickAsync("button[type=\"submit\"]"); + await page.WaitForSelectorAsync("text=You are authenticated"); + await AssertConditionalCreateAsync(page); } } @@ -328,6 +417,59 @@ static async Task IncrementCounterAsync(IPage page) } } + private static async Task AssertConditionalCreateAsync(IPage page) + { + Assert.Equal("conditional:true", await GetConditionalCreateRecordAsync(page)); + } + + private static Task ClearConditionalCreateRecordAsync(IPage page) + => page.EvaluateAsync($"sessionStorage.removeItem('{ConditionalCreateRecordKey}')"); + + private static Task GetConditionalCreateRecordAsync(IPage page) + => page.EvaluateAsync($"sessionStorage.getItem('{ConditionalCreateRecordKey}')"); + + private static string ComputeAuthenticatorCode(string key) + { + var keyBytes = DecodeBase32(key); + var timestep = (ulong)(DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 30); + Span timestepBytes = stackalloc byte[sizeof(ulong)]; + BinaryPrimitives.WriteUInt64BigEndian(timestepBytes, timestep); + Span hash = stackalloc byte[HMACSHA1.HashSizeInBytes]; + Assert.True(HMACSHA1.TryHashData(keyBytes, timestepBytes, hash, out var written)); + Assert.Equal(hash.Length, written); + + var offset = hash[^1] & 0xf; + var binaryCode = (hash[offset] & 0x7f) << 24 + | (hash[offset + 1] & 0xff) << 16 + | (hash[offset + 2] & 0xff) << 8 + | hash[offset + 3] & 0xff; + return (binaryCode % 1_000_000).ToString("D6", CultureInfo.InvariantCulture); + } + + private static byte[] DecodeBase32(string value) + { + const string Base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + var bytes = new List(); + var buffer = 0; + var bitsLeft = 0; + + foreach (var character in value.ToUpperInvariant()) + { + var index = Base32Chars.IndexOf(character); + Assert.True(index >= 0, $"Unexpected base32 character '{character}'."); + buffer = buffer << 5 | index; + bitsLeft += 5; + if (bitsLeft >= 8) + { + bitsLeft -= 8; + bytes.Add((byte)(buffer >> bitsLeft)); + buffer &= (1 << bitsLeft) - 1; + } + } + + return bytes.ToArray(); + } + protected void EnsureBrowserAvailable(BrowserKind browserKind) { Assert.False( diff --git a/src/ProjectTemplates/test/Templates.Tests/template-baselines.json b/src/ProjectTemplates/test/Templates.Tests/template-baselines.json index c908aad66194..6f583af92f5a 100644 --- a/src/ProjectTemplates/test/Templates.Tests/template-baselines.json +++ b/src/ProjectTemplates/test/Templates.Tests/template-baselines.json @@ -628,6 +628,7 @@ "Components/Account/Pages/Manage/SetPassword.razor", "Components/Account/Pages/Manage/TwoFactorAuthentication.razor", "Components/Account/Pages/Manage/_Imports.razor", + "Components/Account/Pages/PasskeyUpgrade.razor", "Components/Account/Pages/Register.razor", "Components/Account/Pages/RegisterConfirmation.razor", "Components/Account/Pages/ResendEmailConfirmation.razor", @@ -635,6 +636,7 @@ "Components/Account/Pages/ResetPasswordConfirmation.razor", "Components/Account/Pages/_Imports.razor", "Components/Account/PasskeyAuthenticators.cs", + "Components/Account/PasskeyUpgradeManager.cs", "Components/Account/PasskeyInputModel.cs", "Components/Account/PasskeyOperation.cs", "Components/Account/Shared/ExternalLoginPicker.razor", @@ -827,6 +829,7 @@ "Components/Account/Pages/Manage/SetPassword.razor", "Components/Account/Pages/Manage/TwoFactorAuthentication.razor", "Components/Account/Pages/Manage/_Imports.razor", + "Components/Account/Pages/PasskeyUpgrade.razor", "Components/Account/Pages/Register.razor", "Components/Account/Pages/RegisterConfirmation.razor", "Components/Account/Pages/ResendEmailConfirmation.razor", @@ -834,6 +837,7 @@ "Components/Account/Pages/ResetPasswordConfirmation.razor", "Components/Account/Pages/_Imports.razor", "Components/Account/PasskeyAuthenticators.cs", + "Components/Account/PasskeyUpgradeManager.cs", "Components/Account/PasskeyInputModel.cs", "Components/Account/PasskeyOperation.cs", "Components/Account/Shared/ExternalLoginPicker.razor", @@ -956,6 +960,7 @@ "Components/Account/Pages/Manage/SetPassword.razor", "Components/Account/Pages/Manage/TwoFactorAuthentication.razor", "Components/Account/Pages/Manage/_Imports.razor", + "Components/Account/Pages/PasskeyUpgrade.razor", "Components/Account/Pages/Register.razor", "Components/Account/Pages/RegisterConfirmation.razor", "Components/Account/Pages/ResendEmailConfirmation.razor", @@ -963,6 +968,7 @@ "Components/Account/Pages/ResetPasswordConfirmation.razor", "Components/Account/Pages/_Imports.razor", "Components/Account/PasskeyAuthenticators.cs", + "Components/Account/PasskeyUpgradeManager.cs", "Components/Account/PasskeyInputModel.cs", "Components/Account/PasskeyOperation.cs", "Components/Account/Shared/ExternalLoginPicker.razor", @@ -1169,6 +1175,7 @@ "{ProjectName}/Components/Account/Pages/Manage/SetPassword.razor", "{ProjectName}/Components/Account/Pages/Manage/TwoFactorAuthentication.razor", "{ProjectName}/Components/Account/Pages/Manage/_Imports.razor", + "{ProjectName}/Components/Account/Pages/PasskeyUpgrade.razor", "{ProjectName}/Components/Account/Pages/Register.razor", "{ProjectName}/Components/Account/Pages/RegisterConfirmation.razor", "{ProjectName}/Components/Account/Pages/ResendEmailConfirmation.razor", @@ -1176,6 +1183,7 @@ "{ProjectName}/Components/Account/Pages/ResetPasswordConfirmation.razor", "{ProjectName}/Components/Account/Pages/_Imports.razor", "{ProjectName}/Components/Account/PasskeyAuthenticators.cs", + "{ProjectName}/Components/Account/PasskeyUpgradeManager.cs", "{ProjectName}/Components/Account/PasskeyInputModel.cs", "{ProjectName}/Components/Account/PasskeyOperation.cs", "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", @@ -1381,6 +1389,7 @@ "{ProjectName}/Components/Account/Pages/Manage/SetPassword.razor", "{ProjectName}/Components/Account/Pages/Manage/TwoFactorAuthentication.razor", "{ProjectName}/Components/Account/Pages/Manage/_Imports.razor", + "{ProjectName}/Components/Account/Pages/PasskeyUpgrade.razor", "{ProjectName}/Components/Account/Pages/Register.razor", "{ProjectName}/Components/Account/Pages/RegisterConfirmation.razor", "{ProjectName}/Components/Account/Pages/ResendEmailConfirmation.razor", @@ -1388,6 +1397,7 @@ "{ProjectName}/Components/Account/Pages/ResetPasswordConfirmation.razor", "{ProjectName}/Components/Account/Pages/_Imports.razor", "{ProjectName}/Components/Account/PasskeyAuthenticators.cs", + "{ProjectName}/Components/Account/PasskeyUpgradeManager.cs", "{ProjectName}/Components/Account/PasskeyInputModel.cs", "{ProjectName}/Components/Account/PasskeyOperation.cs", "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", @@ -1841,6 +1851,7 @@ "{ProjectName}/Components/Account/Pages/Manage/SetPassword.razor", "{ProjectName}/Components/Account/Pages/Manage/TwoFactorAuthentication.razor", "{ProjectName}/Components/Account/Pages/Manage/_Imports.razor", + "{ProjectName}/Components/Account/Pages/PasskeyUpgrade.razor", "{ProjectName}/Components/Account/Pages/Register.razor", "{ProjectName}/Components/Account/Pages/RegisterConfirmation.razor", "{ProjectName}/Components/Account/Pages/ResendEmailConfirmation.razor", @@ -1848,6 +1859,7 @@ "{ProjectName}/Components/Account/Pages/ResetPasswordConfirmation.razor", "{ProjectName}/Components/Account/Pages/_Imports.razor", "{ProjectName}/Components/Account/PasskeyAuthenticators.cs", + "{ProjectName}/Components/Account/PasskeyUpgradeManager.cs", "{ProjectName}/Components/Account/PasskeyInputModel.cs", "{ProjectName}/Components/Account/PasskeyOperation.cs", "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", @@ -1918,6 +1930,7 @@ "Components/Account/Pages/Manage/SetPassword.razor", "Components/Account/Pages/Manage/TwoFactorAuthentication.razor", "Components/Account/Pages/Manage/_Imports.razor", + "Components/Account/Pages/PasskeyUpgrade.razor", "Components/Account/Pages/Register.razor", "Components/Account/Pages/RegisterConfirmation.razor", "Components/Account/Pages/ResendEmailConfirmation.razor", @@ -1925,6 +1938,7 @@ "Components/Account/Pages/ResetPasswordConfirmation.razor", "Components/Account/Pages/_Imports.razor", "Components/Account/PasskeyAuthenticators.cs", + "Components/Account/PasskeyUpgradeManager.cs", "Components/Account/PasskeyInputModel.cs", "Components/Account/PasskeyOperation.cs", "Components/Account/Shared/ExternalLoginPicker.razor", @@ -2064,6 +2078,7 @@ "{ProjectName}/Components/Account/Pages/Manage/SetPassword.razor", "{ProjectName}/Components/Account/Pages/Manage/TwoFactorAuthentication.razor", "{ProjectName}/Components/Account/Pages/Manage/_Imports.razor", + "{ProjectName}/Components/Account/Pages/PasskeyUpgrade.razor", "{ProjectName}/Components/Account/Pages/Register.razor", "{ProjectName}/Components/Account/Pages/RegisterConfirmation.razor", "{ProjectName}/Components/Account/Pages/ResendEmailConfirmation.razor", @@ -2071,6 +2086,7 @@ "{ProjectName}/Components/Account/Pages/ResetPasswordConfirmation.razor", "{ProjectName}/Components/Account/Pages/_Imports.razor", "{ProjectName}/Components/Account/PasskeyAuthenticators.cs", + "{ProjectName}/Components/Account/PasskeyUpgradeManager.cs", "{ProjectName}/Components/Account/PasskeyInputModel.cs", "{ProjectName}/Components/Account/PasskeyOperation.cs", "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", @@ -2200,6 +2216,7 @@ "{ProjectName}/Components/Account/Pages/Manage/SetPassword.razor", "{ProjectName}/Components/Account/Pages/Manage/TwoFactorAuthentication.razor", "{ProjectName}/Components/Account/Pages/Manage/_Imports.razor", + "{ProjectName}/Components/Account/Pages/PasskeyUpgrade.razor", "{ProjectName}/Components/Account/Pages/Register.razor", "{ProjectName}/Components/Account/Pages/RegisterConfirmation.razor", "{ProjectName}/Components/Account/Pages/ResendEmailConfirmation.razor", @@ -2207,6 +2224,7 @@ "{ProjectName}/Components/Account/Pages/ResetPasswordConfirmation.razor", "{ProjectName}/Components/Account/Pages/_Imports.razor", "{ProjectName}/Components/Account/PasskeyAuthenticators.cs", + "{ProjectName}/Components/Account/PasskeyUpgradeManager.cs", "{ProjectName}/Components/Account/PasskeyInputModel.cs", "{ProjectName}/Components/Account/PasskeyOperation.cs", "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor",