diff --git a/src/Identity/Core/src/IPasskeyHandler.cs b/src/Identity/Core/src/IPasskeyHandler.cs index 50898659edf0..6503fdca6783 100644 --- a/src/Identity/Core/src/IPasskeyHandler.cs +++ b/src/Identity/Core/src/IPasskeyHandler.cs @@ -12,6 +12,16 @@ namespace Microsoft.AspNetCore.Identity; public interface IPasskeyHandler where TUser : class { + /// + /// Gets a value indicating whether this handler supports generating known passkeys signal options. + /// + /// + /// Returns unless the handler implements + /// and can retrieve + /// the user's passkeys. + /// + bool SupportsKnownPasskeysSignalOptions => false; + /// /// Generates passkey creation options for the specified user entity and HTTP context. /// @@ -28,6 +38,38 @@ public interface IPasskeyHandler /// A representing the result. Task MakeRequestOptionsAsync(TUser? user, HttpContext httpContext); + /// + /// Generates the options used to signal the current state of a user's known passkeys to authenticators. + /// + /// + /// Handlers that implement this method should also return from + /// . See . + /// + /// The user whose passkeys should be signaled. + /// The passkey user entity associated with the user's passkeys. + /// The HTTP context associated with the request. + /// A representing the result. + /// Thrown when the handler does not support generating known passkeys signal options. + Task MakeKnownPasskeysSignalOptionsAsync(TUser user, PasskeyUserEntity userEntity, HttpContext httpContext) + => throw new NotSupportedException($"'{GetType()}' does not support generating known passkeys signal options."); + + /// + /// Generates options used to signal that a passkey credential is unknown to the server. + /// + /// + /// The signal permanently deletes the passkey from the browser's passkey provider. A handler must only return + /// options when the credential is not registered to any user on the server. + /// See . + /// + /// The JSON representation of the passkey credential. + /// The HTTP context associated with the request. + /// + /// An when the credential is unknown to the server, + /// otherwise . + /// + Task MakeUnknownPasskeySignalOptionsAsync(string credentialJson, HttpContext httpContext) + => Task.FromResult(null); + /// /// Performs passkey attestation using the provided . /// diff --git a/src/Identity/Core/src/IdentityJsonSerializerContext.cs b/src/Identity/Core/src/IdentityJsonSerializerContext.cs index 34b5843b8f89..e67ddaf32718 100644 --- a/src/Identity/Core/src/IdentityJsonSerializerContext.cs +++ b/src/Identity/Core/src/IdentityJsonSerializerContext.cs @@ -13,6 +13,9 @@ namespace Microsoft.AspNetCore.Identity; [JsonSerializable(typeof(PublicKeyCredential))] [JsonSerializable(typeof(PasskeyAttestationState))] [JsonSerializable(typeof(PasskeyAssertionState))] +[JsonSerializable(typeof(KnownPasskeysSignalOptions))] +[JsonSerializable(typeof(PublicKeyCredentialId))] +[JsonSerializable(typeof(UnknownPasskeySignalOptions))] [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, diff --git a/src/Identity/Core/src/KnownPasskeysSignalOptionsResult.cs b/src/Identity/Core/src/KnownPasskeysSignalOptionsResult.cs new file mode 100644 index 000000000000..cf97ebd2d89f --- /dev/null +++ b/src/Identity/Core/src/KnownPasskeysSignalOptionsResult.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. + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents the result of a known passkeys signal options generation. +/// +public sealed class KnownPasskeysSignalOptionsResult +{ + /// + /// Gets or sets the JSON representation of the known passkeys signal options. + /// + /// + /// The structure of this JSON is a superset of the options accepted by the + /// PublicKeyCredential.signalAllAcceptedCredentials() and + /// PublicKeyCredential.signalCurrentUserDetails() JavaScript APIs. + /// See . + /// + public required string SignalOptionsJson { get; init; } +} diff --git a/src/Identity/Core/src/PasskeyHandler.cs b/src/Identity/Core/src/PasskeyHandler.cs index 0081d672fa00..3e526d326436 100644 --- a/src/Identity/Core/src/PasskeyHandler.cs +++ b/src/Identity/Core/src/PasskeyHandler.cs @@ -34,6 +34,9 @@ public PasskeyHandler(UserManager userManager, IOptions + public bool SupportsKnownPasskeysSignalOptions => _userManager.SupportsUserPasskey; + /// public async Task MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext) { @@ -157,6 +160,79 @@ async Task GetAllowCredentialsAsync() } } + /// + public async Task MakeKnownPasskeysSignalOptionsAsync(TUser user, PasskeyUserEntity userEntity, HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(userEntity); + ArgumentNullException.ThrowIfNull(httpContext); + + var userId = await _userManager.GetUserIdAsync(user).ConfigureAwait(false); + if (!string.Equals(userId, userEntity.Id, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"The user entity ID '{userEntity.Id}' does not match the ID '{userId}' of the specified user."); + } + + var passkeys = await _userManager.GetPasskeysAsync(user).ConfigureAwait(false); + var options = new KnownPasskeysSignalOptions + { + RpId = GetServerDomain(httpContext), + UserId = BufferSource.FromString(userEntity.Id), + AllAcceptedCredentialIds = [.. passkeys.Select(p => BufferSource.FromBytes(p.CredentialId))], + Name = userEntity.Name, + DisplayName = userEntity.DisplayName, + }; + var optionsJson = JsonSerializer.Serialize(options, IdentityJsonSerializerContext.Default.KnownPasskeysSignalOptions); + + return new KnownPasskeysSignalOptionsResult + { + SignalOptionsJson = optionsJson, + }; + } + + /// + public async Task MakeUnknownPasskeySignalOptionsAsync(string credentialJson, HttpContext httpContext) + { + if (!_userManager.SupportsUserPasskey) + { + return null; + } + + PublicKeyCredentialId? credential; + try + { + credential = JsonSerializer.Deserialize(credentialJson, IdentityJsonSerializerContext.Default.PublicKeyCredentialId); + } + catch (JsonException) + { + return null; + } + + if (credential?.Id is not { Length: > 0 }) + { + return null; + } + + var user = await _userManager.FindByPasskeyIdAsync(credential.Id.ToArray()).ConfigureAwait(false); + if (user is not null) + { + return null; + } + + var options = new UnknownPasskeySignalOptions + { + RpId = GetServerDomain(httpContext), + CredentialId = credential.Id, + }; + var optionsJson = JsonSerializer.Serialize(options, IdentityJsonSerializerContext.Default.UnknownPasskeySignalOptions); + + return new UnknownPasskeySignalOptionsResult + { + SignalOptionsJson = optionsJson, + }; + } + /// public async Task PerformAttestationAsync(PasskeyAttestationContext context) { diff --git a/src/Identity/Core/src/Passkeys/KnownPasskeysSignalOptions.cs b/src/Identity/Core/src/Passkeys/KnownPasskeysSignalOptions.cs new file mode 100644 index 000000000000..5b2b9d25bd17 --- /dev/null +++ b/src/Identity/Core/src/Passkeys/KnownPasskeysSignalOptions.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. + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents the information needed to signal the current state of a user's known passkeys to authenticators. +/// +/// +/// This is a superset of the options accepted by the WebAuthn signalAllAcceptedCredentials +/// and signalCurrentUserDetails methods. +/// See . +/// +internal sealed class KnownPasskeysSignalOptions +{ + /// + /// Gets the relying party identifier. + /// + public required string RpId { get; init; } + + /// + /// Gets the user handle of the user that owns the credentials. + /// + public required BufferSource UserId { get; init; } + + /// + /// Gets the credential IDs that are currently registered for the user. + /// + public required IReadOnlyList AllAcceptedCredentialIds { get; init; } + + /// + /// Gets the name of the user. + /// + public required string Name { get; init; } + + /// + /// Gets the display name of the user. + /// + public required string DisplayName { get; init; } +} diff --git a/src/Identity/Core/src/Passkeys/PublicKeyCredentialId.cs b/src/Identity/Core/src/Passkeys/PublicKeyCredentialId.cs new file mode 100644 index 000000000000..149bb8d7a78c --- /dev/null +++ b/src/Identity/Core/src/Passkeys/PublicKeyCredentialId.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. + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents the credential ID from a public key credential. +/// +internal sealed class PublicKeyCredentialId +{ + /// + /// Gets the credential ID. + /// + public required BufferSource Id { get; init; } +} diff --git a/src/Identity/Core/src/Passkeys/UnknownPasskeySignalOptions.cs b/src/Identity/Core/src/Passkeys/UnknownPasskeySignalOptions.cs new file mode 100644 index 000000000000..fb1a5d9ae004 --- /dev/null +++ b/src/Identity/Core/src/Passkeys/UnknownPasskeySignalOptions.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. + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents the information needed to signal that a passkey is unknown to the server. +/// +/// +/// These options are accepted by the WebAuthn signalUnknownCredential method. +/// See . +/// +internal sealed class UnknownPasskeySignalOptions +{ + /// + /// Gets the relying party identifier. + /// + public required string RpId { get; init; } + + /// + /// Gets the credential ID that is unknown to the server. + /// + public required BufferSource CredentialId { get; init; } +} diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 2f40db550e3c..ede05896ae38 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1,4 +1,14 @@ #nullable enable +Microsoft.AspNetCore.Identity.IPasskeyHandler.MakeKnownPasskeysSignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IPasskeyHandler.MakeUnknownPasskeySignalOptionsAsync(string! credentialJson, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IPasskeyHandler.SupportsKnownPasskeysSignalOptions.get -> bool +Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult +Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult.KnownPasskeysSignalOptionsResult() -> void +Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult.SignalOptionsJson.get -> string! +Microsoft.AspNetCore.Identity.KnownPasskeysSignalOptionsResult.SignalOptionsJson.init -> void +Microsoft.AspNetCore.Identity.PasskeyHandler.MakeKnownPasskeysSignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.PasskeyHandler.MakeUnknownPasskeySignalOptionsAsync(string! credentialJson, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.PasskeyHandler.SupportsKnownPasskeysSignalOptions.get -> bool Microsoft.AspNetCore.Identity.PasskeyEndpointsOptions Microsoft.AspNetCore.Identity.PasskeyEndpointsOptions.Enroll.get -> string? Microsoft.AspNetCore.Identity.PasskeyEndpointsOptions.Enroll.set -> void @@ -9,5 +19,12 @@ Microsoft.AspNetCore.Identity.PasskeyEndpointsOptions.PrfUsageDetails.get -> str Microsoft.AspNetCore.Identity.PasskeyEndpointsOptions.PrfUsageDetails.set -> void Microsoft.AspNetCore.Routing.PasskeyEndpointsEndpointRouteBuilderExtensions Microsoft.Extensions.DependencyInjection.PasskeyEndpointsServiceCollectionExtensions +Microsoft.AspNetCore.Identity.UnknownPasskeySignalOptionsResult +Microsoft.AspNetCore.Identity.UnknownPasskeySignalOptionsResult.UnknownPasskeySignalOptionsResult() -> void +Microsoft.AspNetCore.Identity.UnknownPasskeySignalOptionsResult.SignalOptionsJson.get -> string! +Microsoft.AspNetCore.Identity.UnknownPasskeySignalOptionsResult.SignalOptionsJson.init -> void +virtual Microsoft.AspNetCore.Identity.SignInManager.MakeKnownPasskeysSignalOptionsAsync(TUser! user, Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.SignInManager.MakeUnknownPasskeySignalOptionsAsync(string! credentialJson) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.SignInManager.SupportsKnownPasskeysSignalOptions.get -> bool static Microsoft.AspNetCore.Routing.PasskeyEndpointsEndpointRouteBuilderExtensions.MapWellKnownPasskeyEndpoints(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! static Microsoft.Extensions.DependencyInjection.PasskeyEndpointsServiceCollectionExtensions.AddPasskeyEndpoints(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index e303012f9849..14790253b99d 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -543,6 +543,95 @@ public virtual async Task MakePasskeyRequestOptionsAsync(TUser? user) return result.RequestOptionsJson; } + /// + /// Gets a value indicating whether the registered supports + /// generating known passkeys signal options. + /// + /// + /// Check this before calling , + /// which throws when the handler does not support known passkeys signal options. + /// + public virtual bool SupportsKnownPasskeysSignalOptions => _passkeyHandler?.SupportsKnownPasskeysSignalOptions ?? false; + + /// + /// Generates the options used to signal the current state of a user's known passkeys to authenticators. + /// + /// + /// + /// The returned JSON contains the arguments for both the PublicKeyCredential.signalAllAcceptedCredentials() + /// and PublicKeyCredential.signalCurrentUserDetails() JavaScript APIs, which let an authenticator + /// stop offering passkeys that were removed from the server and keep the user's details up to date. + /// + /// + /// Because these APIs reveal how many passkeys a user has, only call them when the user is authenticated. + /// The must have the same that was passed to + /// when the passkeys were created, + /// otherwise the authenticator will not recognize the user and the signal will have no effect. + /// + /// + /// See . + /// + /// + /// The user whose passkeys should be signaled. + /// The user entity associated with the user's passkeys. + /// A JSON string representing the known passkeys signal options. + /// + /// Thrown when no is registered. + /// + /// + /// Thrown when the registered does not support known passkeys signal options. + /// See . + /// + /// + /// The following example shows how the result is used from JavaScript. + /// + /// const { rpId, userId, allAcceptedCredentialIds, name, displayName } = JSON.parse(signalOptionsJson); + /// await PublicKeyCredential.signalAllAcceptedCredentials?.({ rpId, userId, allAcceptedCredentialIds }); + /// await PublicKeyCredential.signalCurrentUserDetails?.({ rpId, userId, name, displayName }); + /// + /// + public virtual async Task MakeKnownPasskeysSignalOptionsAsync(TUser user, PasskeyUserEntity userEntity) + { + ThrowIfNoPasskeyHandler(); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(userEntity); + + var result = await _passkeyHandler.MakeKnownPasskeysSignalOptionsAsync(user, userEntity, Context); + return result.SignalOptionsJson; + } + + /// + /// Generates options used to signal that a passkey credential is unknown to the server. + /// + /// + /// The returned JSON is accepted by the PublicKeyCredential.signalUnknownCredential() JavaScript API. + /// Calling that API permanently deletes the passkey from the browser's passkey provider. This method only + /// returns options when no user on the server has the credential. + /// + /// The JSON representation of the passkey credential. + /// + /// A JSON string representing the unknown passkey signal options when the credential is unknown to the server, + /// otherwise . + /// + /// + /// Thrown when no is registered. + /// + /// Thrown when is or empty. + /// + /// The following example shows how the result is used from JavaScript. + /// + /// await PublicKeyCredential.signalUnknownCredential?.(signalOptions); + /// + /// + public virtual async Task MakeUnknownPasskeySignalOptionsAsync(string credentialJson) + { + ThrowIfNoPasskeyHandler(); + ArgumentException.ThrowIfNullOrEmpty(credentialJson); + + var result = await _passkeyHandler.MakeUnknownPasskeySignalOptionsAsync(credentialJson, Context); + return result?.SignalOptionsJson; + } + /// /// Performs passkey attestation for the given . /// diff --git a/src/Identity/Core/src/UnknownPasskeySignalOptionsResult.cs b/src/Identity/Core/src/UnknownPasskeySignalOptionsResult.cs new file mode 100644 index 000000000000..4b4746da0a97 --- /dev/null +++ b/src/Identity/Core/src/UnknownPasskeySignalOptionsResult.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity; + +/// +/// Represents the result of an unknown passkey signal options generation. +/// +public sealed class UnknownPasskeySignalOptionsResult +{ + /// + /// Gets or sets the JSON representation of the unknown passkey signal options. + /// + /// + /// The JSON is accepted by the PublicKeyCredential.signalUnknownCredential() JavaScript API. + /// Calling this API permanently deletes the passkey from the browser's passkey provider. + /// + /// + /// + /// const signalOptions = JSON.parse(signalOptionsJson); + /// await PublicKeyCredential.signalUnknownCredential?.(signalOptions); + /// + /// + public required string SignalOptionsJson { get; init; } +} diff --git a/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerSignalTest.cs b/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerSignalTest.cs new file mode 100644 index 000000000000..2d1882be8cb6 --- /dev/null +++ b/src/Identity/test/Identity.Test/Passkeys/PasskeyHandlerSignalTest.cs @@ -0,0 +1,277 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Buffers.Text; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Moq; + +namespace Microsoft.AspNetCore.Identity.Test; + +public class PasskeyHandlerSignalTest +{ + [Fact] + public async Task CanMakeKnownPasskeysSignalOptions() + { + var user = new PocoUser { UserName = "Foo" }; + var userManager = SetupUserManager(user, CreatePasskey([1, 2, 3]), CreatePasskey([4, 5, 6])); + var handler = CreateHandler(userManager); + var httpContext = CreateHttpContext("contoso.com", port: 5001); + + var result = await handler.MakeKnownPasskeysSignalOptionsAsync(user, CreateUserEntity(user, "Foo", "Foo Bar"), httpContext); + + var options = JsonSerializer.Deserialize(result.SignalOptionsJson); + Assert.Equal("contoso.com", options.GetProperty("rpId").GetString()); + Assert.Equal(Base64Url.EncodeToString(Encoding.UTF8.GetBytes(user.Id)), options.GetProperty("userId").GetString()); + Assert.Equal("Foo", options.GetProperty("name").GetString()); + Assert.Equal("Foo Bar", options.GetProperty("displayName").GetString()); + Assert.Collection(options.GetProperty("allAcceptedCredentialIds").EnumerateArray(), + id => Assert.Equal(Base64Url.EncodeToString([1, 2, 3]), id.GetString()), + id => Assert.Equal(Base64Url.EncodeToString([4, 5, 6]), id.GetString())); + } + + [Fact] + public void SupportsKnownPasskeysSignalOptionsIsTrue() + { + var user = new PocoUser { UserName = "Foo" }; + var handler = CreateHandler(SetupUserManager(user)); + + Assert.True(handler.SupportsKnownPasskeysSignalOptions); + } + + [Fact] + public void SupportsKnownPasskeysSignalOptionsIsFalseWhenStoreDoesNotSupportPasskeys() + { + var user = new PocoUser { UserName = "Foo" }; + var userManager = MockHelpers.MockUserManager(); + userManager.Setup(m => m.SupportsUserPasskey).Returns(false); + var handler = CreateHandler(userManager.Object); + + Assert.False(handler.SupportsKnownPasskeysSignalOptions); + } + + [Fact] + public async Task MakeKnownPasskeysSignalOptionsUsesConfiguredServerDomain() + { + var user = new PocoUser { UserName = "Foo" }; + var userManager = SetupUserManager(user); + var handler = CreateHandler(userManager, new() { ServerDomain = "fabrikam.com" }); + var httpContext = CreateHttpContext("contoso.com"); + + var result = await handler.MakeKnownPasskeysSignalOptionsAsync(user, CreateUserEntity(user), httpContext); + + var options = JsonSerializer.Deserialize(result.SignalOptionsJson); + Assert.Equal("fabrikam.com", options.GetProperty("rpId").GetString()); + } + + [Fact] + public async Task MakeKnownPasskeysSignalOptionsWithoutPasskeysReturnsEmptyCredentialList() + { + var user = new PocoUser { UserName = "Foo" }; + var handler = CreateHandler(SetupUserManager(user)); + + var result = await handler.MakeKnownPasskeysSignalOptionsAsync(user, CreateUserEntity(user), CreateHttpContext()); + + var options = JsonSerializer.Deserialize(result.SignalOptionsJson); + Assert.Empty(options.GetProperty("allAcceptedCredentialIds").EnumerateArray()); + } + + [Fact] + public async Task MakeKnownPasskeysSignalOptionsThrowsWhenUserEntityIdDoesNotMatchUser() + { + var user = new PocoUser { UserName = "Foo" }; + var handler = CreateHandler(SetupUserManager(user)); + var userEntity = new PasskeyUserEntity + { + Id = "some-other-id", + Name = "Foo", + DisplayName = "Foo", + }; + + var ex = await Assert.ThrowsAsync( + () => handler.MakeKnownPasskeysSignalOptionsAsync(user, userEntity, CreateHttpContext())); + + Assert.Equal($"The user entity ID 'some-other-id' does not match the ID '{user.Id}' of the specified user.", ex.Message); + } + + [Fact] + public async Task CanMakeUnknownPasskeySignalOptions() + { + var user = new PocoUser { UserName = "Foo" }; + var credentialId = (byte[])[1, 2, 3]; + var userManager = SetupUserManager(user); + var handler = CreateHandler(userManager); + + var result = await handler.MakeUnknownPasskeySignalOptionsAsync( + CreateAssertionCredentialJson(credentialId), + CreateHttpContext()); + + Assert.NotNull(result); + var options = JsonSerializer.Deserialize(result.SignalOptionsJson); + Assert.Equal("contoso.com", options.GetProperty("rpId").GetString()); + Assert.Equal(Base64Url.EncodeToString(credentialId), options.GetProperty("credentialId").GetString()); + Mock.Get(userManager).Verify( + m => m.FindByPasskeyIdAsync(It.Is(id => id.SequenceEqual(credentialId))), + Times.Once); + } + + [Fact] + public async Task MakeUnknownPasskeySignalOptionsReturnsNullWhenCredentialBelongsToAUser() + { + var user = new PocoUser { UserName = "Foo" }; + var credentialId = (byte[])[1, 2, 3]; + var userManager = SetupUserManagerMock(user); + userManager + .Setup(m => m.FindByPasskeyIdAsync(It.Is(id => id.SequenceEqual(credentialId)))) + .ReturnsAsync(user); + var handler = CreateHandler(userManager.Object); + + var result = await handler.MakeUnknownPasskeySignalOptionsAsync( + CreateAssertionCredentialJson(credentialId), + CreateHttpContext()); + + Assert.Null(result); + } + + [Fact] + public async Task MakeUnknownPasskeySignalOptionsReturnsNullForMalformedJson() + { + var user = new PocoUser { UserName = "Foo" }; + var handler = CreateHandler(SetupUserManager(user)); + + var result = await handler.MakeUnknownPasskeySignalOptionsAsync("{", CreateHttpContext()); + + Assert.Null(result); + } + + [Fact] + public async Task MakeUnknownPasskeySignalOptionsReturnsNullWhenStoreDoesNotSupportPasskeys() + { + var handler = CreateHandler(MockHelpers.TestUserManager()); + + var result = await handler.MakeUnknownPasskeySignalOptionsAsync( + CreateAssertionCredentialJson([1, 2, 3]), + CreateHttpContext()); + + Assert.Null(result); + } + + [Fact] + public async Task CanMakeUnknownPasskeySignalOptionsFromAttestationCredential() + { + var user = new PocoUser { UserName = "Foo" }; + var credentialId = (byte[])[1, 2, 3]; + var handler = CreateHandler(SetupUserManager(user)); + + var result = await handler.MakeUnknownPasskeySignalOptionsAsync( + CreateAttestationCredentialJson(credentialId), + CreateHttpContext()); + + Assert.NotNull(result); + var options = JsonSerializer.Deserialize(result.SignalOptionsJson); + Assert.Equal(Base64Url.EncodeToString(credentialId), options.GetProperty("credentialId").GetString()); + } + + [Fact] + public async Task MakeUnknownPasskeySignalOptionsUsesConfiguredServerDomain() + { + var user = new PocoUser { UserName = "Foo" }; + var handler = CreateHandler(SetupUserManager(user), new() { ServerDomain = "fabrikam.com" }); + + var result = await handler.MakeUnknownPasskeySignalOptionsAsync( + CreateAssertionCredentialJson([1, 2, 3]), + CreateHttpContext("contoso.com")); + + Assert.NotNull(result); + var options = JsonSerializer.Deserialize(result.SignalOptionsJson); + Assert.Equal("fabrikam.com", options.GetProperty("rpId").GetString()); + } + + [Fact] + public async Task MakeUnknownPasskeySignalOptionsUsesUnpaddedBase64UrlCredentialId() + { + var user = new PocoUser { UserName = "Foo" }; + var credentialId = (byte[])[251, 255]; + var handler = CreateHandler(SetupUserManager(user)); + + var result = await handler.MakeUnknownPasskeySignalOptionsAsync( + CreateAssertionCredentialJson(credentialId), + CreateHttpContext()); + + Assert.NotNull(result); + var options = JsonSerializer.Deserialize(result.SignalOptionsJson); + var encodedCredentialId = options.GetProperty("credentialId").GetString(); + Assert.Equal("-_8", encodedCredentialId); + Assert.NotNull(encodedCredentialId); + Assert.DoesNotContain('=', encodedCredentialId); + } + + private static PasskeyHandler CreateHandler(UserManager userManager, IdentityPasskeyOptions? options = null) + => new(userManager, Options.Create(options ?? new IdentityPasskeyOptions())); + + private static UserManager SetupUserManager(PocoUser user, params UserPasskeyInfo[] passkeys) + => SetupUserManagerMock(user, passkeys).Object; + + private static Mock> SetupUserManagerMock(PocoUser user, params UserPasskeyInfo[] passkeys) + { + var manager = MockHelpers.MockUserManager(); + manager.Setup(m => m.SupportsUserPasskey).Returns(true); + manager.Setup(m => m.GetUserIdAsync(user)).ReturnsAsync(user.Id); + manager.Setup(m => m.GetPasskeysAsync(user)).ReturnsAsync(passkeys); + manager.Setup(m => m.FindByPasskeyIdAsync(It.IsAny())).ReturnsAsync((PocoUser?)null); + return manager; + } + + private static HttpContext CreateHttpContext(string host = "contoso.com", int? port = null) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Host = port is { } portValue ? new HostString(host, portValue) : new HostString(host); + return httpContext; + } + + private static PasskeyUserEntity CreateUserEntity(PocoUser user, string name = "Foo", string displayName = "Foo") + => new() + { + Id = user.Id, + Name = name, + DisplayName = displayName, + }; + + private static UserPasskeyInfo CreatePasskey(byte[] credentialId) + => new(credentialId, [], default, 0, null, false, false, false, [], []); + + private static string CreateAssertionCredentialJson(byte[] credentialId) + => CreateCredentialJson(credentialId, new + { + clientDataJSON = "", + authenticatorData = "", + signature = "", + userHandle = (string?)null, + }); + + private static string CreateAttestationCredentialJson(byte[] credentialId) + => CreateCredentialJson(credentialId, new + { + clientDataJSON = "", + attestationObject = "", + transports = Array.Empty(), + }); + + private static string CreateCredentialJson(byte[] credentialId, object response) + { + var encodedCredentialId = Base64Url.EncodeToString(credentialId); + return JsonSerializer.Serialize(new + { + id = encodedCredentialId, + rawId = encodedCredentialId, + response, + type = "public-key", + clientExtensionResults = new { }, + authenticatorAttachment = "platform", + }); + } +} diff --git a/src/Identity/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index 2dcba35b6fd0..4870bc8258c7 100644 --- a/src/Identity/test/Identity.Test/SignInManagerTest.cs +++ b/src/Identity/test/Identity.Test/SignInManagerTest.cs @@ -648,6 +648,131 @@ public async Task PasskeySignInReturnsLockedOutWhenLockedOut() auth.Verify(); } + [Fact] + public async Task CanMakeKnownPasskeysSignalOptions() + { + var user = new PocoUser { UserName = "Foo" }; + var userEntity = new PasskeyUserEntity { Id = user.Id, Name = "Foo", DisplayName = "Foo Bar" }; + var expectedOptionsJson = ""; + var passkeyHandler = new Mock>(); + passkeyHandler + .Setup(h => h.MakeKnownPasskeysSignalOptionsAsync(user, userEntity, It.IsAny())) + .Returns(Task.FromResult(new KnownPasskeysSignalOptionsResult + { + SignalOptionsJson = expectedOptionsJson, + })) + .Verifiable(); + var manager = SetupUserManager(user, passkeyHandler: passkeyHandler.Object); + var context = new DefaultHttpContext(); + var helper = SetupSignInManager(manager.Object, context); + + var optionsJson = await helper.MakeKnownPasskeysSignalOptionsAsync(user, userEntity); + + Assert.Equal(expectedOptionsJson, optionsJson); + passkeyHandler.Verify(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SupportsKnownPasskeysSignalOptionsMatchesPasskeyHandler(bool supportsKnownPasskeysSignalOptions) + { + var user = new PocoUser { UserName = "Foo" }; + var passkeyHandler = new Mock>(); + passkeyHandler.Setup(h => h.SupportsKnownPasskeysSignalOptions).Returns(supportsKnownPasskeysSignalOptions); + var manager = SetupUserManager(user, passkeyHandler: passkeyHandler.Object); + var context = new DefaultHttpContext(); + var helper = SetupSignInManager(manager.Object, context); + + Assert.Equal(supportsKnownPasskeysSignalOptions, helper.SupportsKnownPasskeysSignalOptions); + } + + [Fact] + public void SupportsKnownPasskeysSignalOptionsIsFalseWithoutPasskeyHandler() + { + var user = new PocoUser { UserName = "Foo" }; + var manager = SetupUserManager(user); + var context = new DefaultHttpContext(); + var helper = SetupSignInManager(manager.Object, context); + + Assert.False(helper.SupportsKnownPasskeysSignalOptions); + } + + [Fact] + public async Task MakeKnownPasskeysSignalOptionsThrowsWithoutPasskeyHandler() + { + var user = new PocoUser { UserName = "Foo" }; + var manager = SetupUserManager(user); + var context = new DefaultHttpContext(); + var helper = SetupSignInManager(manager.Object, context); + + var ex = await Assert.ThrowsAsync( + () => helper.MakeKnownPasskeysSignalOptionsAsync(user, new() + { + Id = user.Id, + Name = "Foo", + DisplayName = "Foo", + })); + + Assert.Equal("This operation requires an IPasskeyHandler service to be registered.", ex.Message); + } + + [Fact] + public async Task CanMakeUnknownPasskeySignalOptions() + { + var user = new PocoUser { UserName = "Foo" }; + var expectedOptionsJson = ""; + var passkeyHandler = new Mock>(); + passkeyHandler + .Setup(h => h.MakeUnknownPasskeySignalOptionsAsync("", It.IsAny())) + .Returns(Task.FromResult(new UnknownPasskeySignalOptionsResult + { + SignalOptionsJson = expectedOptionsJson, + })) + .Verifiable(); + var manager = SetupUserManager(user, passkeyHandler: passkeyHandler.Object); + var context = new DefaultHttpContext(); + var helper = SetupSignInManager(manager.Object, context); + + var optionsJson = await helper.MakeUnknownPasskeySignalOptionsAsync(""); + + Assert.Equal(expectedOptionsJson, optionsJson); + passkeyHandler.Verify(); + } + + [Fact] + public async Task MakeUnknownPasskeySignalOptionsReturnsNullWhenHandlerReturnsNull() + { + var user = new PocoUser { UserName = "Foo" }; + var passkeyHandler = new Mock>(); + passkeyHandler + .Setup(h => h.MakeUnknownPasskeySignalOptionsAsync("", It.IsAny())) + .Returns(Task.FromResult(null)) + .Verifiable(); + var manager = SetupUserManager(user, passkeyHandler: passkeyHandler.Object); + var context = new DefaultHttpContext(); + var helper = SetupSignInManager(manager.Object, context); + + var optionsJson = await helper.MakeUnknownPasskeySignalOptionsAsync(""); + + Assert.Null(optionsJson); + passkeyHandler.Verify(); + } + + [Fact] + public async Task MakeUnknownPasskeySignalOptionsThrowsWithoutPasskeyHandler() + { + var user = new PocoUser { UserName = "Foo" }; + var manager = SetupUserManager(user); + var context = new DefaultHttpContext(); + var helper = SetupSignInManager(manager.Object, context); + + var ex = await Assert.ThrowsAsync( + () => helper.MakeUnknownPasskeySignalOptionsAsync("")); + + Assert.Equal("This operation requires an IPasskeyHandler service to be registered.", ex.Message); + } + 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/ConfirmEmailChange.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/ConfirmEmailChange.razor index 6b9052849d68..37633d31f7e1 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/ConfirmEmailChange.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/ConfirmEmailChange.razor @@ -15,8 +15,14 @@ +@if (updatedUser is not null) +{ + +} + @code { private string? message; + private ApplicationUser? updatedUser; [CascadingParameter] private HttpContext HttpContext { get; set; } = default!; @@ -70,6 +76,7 @@ } await SignInManager.RefreshSignInAsync(user); + updatedUser = user; message = "Thank you for confirming your email change."; } } 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..0957d6a10867 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 @@ -49,7 +49,9 @@
OR - Log in with a passkey + Log in with a passkey

@@ -77,6 +79,7 @@ @code { private string? errorMessage; + private string? unknownPasskeySignalOptionsJson; private EditContext editContext = default!; [CascadingParameter] @@ -151,6 +154,11 @@ } else { + if (!string.IsNullOrEmpty(Input.Passkey?.CredentialJson)) + { + unknownPasskeySignalOptionsJson = await SignInManager.MakeUnknownPasskeySignalOptionsAsync(Input.Passkey.CredentialJson); + } + errorMessage = "Error: Invalid login attempt."; } } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Manage/Passkeys.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Manage/Passkeys.razor index f2d98bb53d98..2044ea90ab72 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Manage/Passkeys.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Manage/Passkeys.razor @@ -48,6 +48,11 @@ else

No passkeys are registered.

} +@if (user is not null) +{ + +} +
@if (currentPasskeys is { Count: >= MaxPasskeyCount }) { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor new file mode 100644 index 000000000000..1c585b07b47b --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor @@ -0,0 +1,38 @@ +@using Microsoft.AspNetCore.Identity +@using BlazorWebCSharp._1.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager + +@if (signalOptionsJson is not null) +{ + +} + +@code { + [Parameter] + [EditorRequired] + public ApplicationUser User { get; set; } = default!; + + private string? signalOptionsJson; + + protected override async Task OnInitializedAsync() + { + if (!SignInManager.SupportsKnownPasskeysSignalOptions) + { + return; + } + + // These must match the user entity passed to MakePasskeyCreationOptionsAsync when the + // passkey was created, otherwise the authenticator won't recognize the user. + var userId = await UserManager.GetUserIdAsync(User); + var userName = await UserManager.GetUserNameAsync(User) ?? "User"; + + signalOptionsJson = await SignInManager.MakeKnownPasskeysSignalOptionsAsync(User, new() + { + Id = userId, + Name = userName, + DisplayName = userName, + }); + } +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor.js new file mode 100644 index 000000000000..5e1a87a2468e --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Shared/PasskeySignals.razor.js @@ -0,0 +1,14 @@ +customElements.define('passkey-signals', class extends HTMLElement { + async connectedCallback() { + const { rpId, userId, allAcceptedCredentialIds, name, displayName } = JSON.parse(this.getAttribute('options')); + try { + // Tells the authenticator which passkeys are still valid so that deleted ones are no + // longer offered at sign-in, and keeps the displayed user details up to date. + // Not all browsers support these, and they are best-effort, so failures are not surfaced. + await window.PublicKeyCredential?.signalAllAcceptedCredentials?.({ rpId, userId, allAcceptedCredentialIds }); + await window.PublicKeyCredential?.signalCurrentUserDetails?.({ rpId, userId, name, displayName }); + } catch (error) { + console.error(error); + } + } +}); 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..363e83254845 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" + unknown-credential-signal-options="@UnknownCredentialSignalOptionsJson"> @code { @@ -17,6 +18,9 @@ [Parameter] public string? EmailName { get; set; } + [Parameter] + public string? UnknownCredentialSignalOptionsJson { 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..8f0e0940c323 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 @@ -40,12 +40,13 @@ async function requestCredential(email, mediation, signal) { customElements.define('passkey-submit', class extends HTMLElement { static formAssociated = true; - connectedCallback() { + async connectedCallback() { this.internals = this.attachInternals(); this.attrs = { operation: this.getAttribute('operation'), name: this.getAttribute('name'), emailName: this.getAttribute('email-name'), + unknownCredentialSignalOptions: this.getAttribute('unknown-credential-signal-options'), }; this.internals.form.addEventListener('submit', (event) => { @@ -55,7 +56,14 @@ customElements.define('passkey-submit', class extends HTMLElement { } }); - this.tryAutofillPasskey(); + try { + if (this.attrs.unknownCredentialSignalOptions) { + const options = JSON.parse(this.attrs.unknownCredentialSignalOptions); + await PublicKeyCredential.signalUnknownCredential?.(options); + } + } finally { + this.tryAutofillPasskey(); + } } disconnectedCallback() { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/App.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/App.razor index c7e72eb2f460..65b3302e8d92 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/App.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/App.razor @@ -46,6 +46,7 @@ @*#if (IndividualLocalAuth) + ##endif*@ diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs index f6334c1cca6a..3177c17ac461 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs @@ -1,7 +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.Buffers.Text; using System.Runtime.InteropServices; +using System.Text.Json; using Microsoft.AspNetCore.BrowserTesting; using Microsoft.Playwright; using Templates.Test.Helpers; @@ -159,6 +161,43 @@ await Task.WhenAll( Assert.True(result.HasValue); var authenticatorId = result.Value.GetProperty("authenticatorId").GetString(); + // Record the WebAuthn signal calls made by each page so that we can assert on them later. + // We define the signal methods if they're missing so that the assertions don't depend on + // the browser version bundled with Playwright. + await page.AddInitScriptAsync(""" + window.__passkeySignals = []; + window.__passkeyAutofillStarted = false; + window.__resolveUnknownCredentialSignal = null; + if (navigator.credentials) { + const originalGet = navigator.credentials.get.bind(navigator.credentials); + navigator.credentials.get = async function (options) { + const credential = await originalGet(options); + sessionStorage.setItem('__passkeyCredentialJson', JSON.stringify(credential)); + return credential; + }; + } + if (window.PublicKeyCredential) { + if (sessionStorage.getItem('__forcePasskeyAutofillOnce')) { + sessionStorage.removeItem('__forcePasskeyAutofillOnce'); + window.PublicKeyCredential.isConditionalMediationAvailable = () => { + window.__passkeyAutofillStarted = true; + return new Promise(() => {}); + }; + } else if (sessionStorage.getItem('__skipPasskeyAutofillOnce')) { + sessionStorage.removeItem('__skipPasskeyAutofillOnce'); + window.PublicKeyCredential.isConditionalMediationAvailable = () => Promise.resolve(false); + } + for (const name of ['signalAllAcceptedCredentials', 'signalCurrentUserDetails', 'signalUnknownCredential']) { + window.PublicKeyCredential[name] = function (options) { + window.__passkeySignals.push({ name, options }); + return name === 'signalUnknownCredential' + ? new Promise(resolve => window.__resolveUnknownCredentialSignal = resolve) + : Promise.resolve(); + }; + } + } + """); + await Task.WhenAll( page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), page.ClickAsync("text=Login")); @@ -246,6 +285,19 @@ await page.EvaluateAsync(""" await page.WaitForSelectorAsync("text=Passkey updated successfully"); + // The page signals the browser's passkey provider with the passkeys that are + // still valid, so that deleted ones stop being offered at sign-in. + var acceptedCredentials = await GetSignalledCredentialIdsAsync(page); + var storedCredentials = await GetAuthenticatorCredentialsAsync(cdpSession, authenticatorId); + Assert.Single(storedCredentials); + Assert.Equal(storedCredentials, acceptedCredentials); + var passkeyCredentialId = storedCredentials[0]; + + var userDetails = await GetPasskeySignalAsync(page, "signalCurrentUserDetails"); + Assert.Equal(new Uri(page.Url).Host, userDetails.GetProperty("rpId").GetString()); + Assert.Equal(userName, userDetails.GetProperty("name").GetString()); + Assert.Equal(userName, userDetails.GetProperty("displayName").GetString()); + // Logout so that we can test the passkey login flow await Task.WhenAll( page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), @@ -286,6 +338,50 @@ 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"); + + // Deleting the passkey signals the provider with an empty credential list, + // which is what removes the passkey from the sign-in options + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Manage**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("a[href=\"Account/Manage\"]")); + + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Manage/Passkeys**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("a[href=\"Account/Manage/Passkeys\"]")); + + await page.ClickAsync("button[value=\"delete\"]"); + await page.WaitForSelectorAsync("text=Passkey deleted successfully"); + + Assert.Empty(await GetSignalledCredentialIdsAsync(page)); + + // Submit the revoked credential again. The unknown credential signal remains pending, + // so a conditional autofill request can only start if the template gets the ordering wrong. + await page.EvaluateAsync("() => sessionStorage.setItem('__skipPasskeyAutofillOnce', 'true')"); + await Task.WhenAll( + page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Logout")); + + await page.EvaluateAsync(""" + () => { + const credentialJson = sessionStorage.getItem('__passkeyCredentialJson'); + if (!credentialJson) { + throw new Error('The revoked passkey credential was not captured.'); + } + sessionStorage.setItem('__forcePasskeyAutofillOnce', 'true'); + navigator.credentials.get = () => Promise.resolve(JSON.parse(credentialJson)); + } + """); + + await page.FillAsync("[name=\"Input.Email\"]", userName); + await page.ClickAsync("text=Log in with a passkey"); + await page.WaitForSelectorAsync("text=Error: Invalid login attempt."); + var unknownCredential = await GetPasskeySignalAsync(page, "signalUnknownCredential"); + Assert.Equal(new Uri(page.Url).Host, unknownCredential.GetProperty("rpId").GetString()); + Assert.Equal(passkeyCredentialId, unknownCredential.GetProperty("credentialId").GetString()); + Assert.False(await page.EvaluateAsync("() => window.__passkeyAutofillStarted")); + + await page.EvaluateAsync("() => window.__resolveUnknownCredentialSignal()"); + await page.WaitForFunctionAsync("() => window.__passkeyAutofillStarted"); } } @@ -326,6 +422,29 @@ static async Task IncrementCounterAsync(IPage page) Assert.Fail($"The counter did not increment after {MaxIncrementAttempts} attempts"); } + + static async Task GetPasskeySignalAsync(IPage page, string name) + { + await page.WaitForFunctionAsync($"() => window.__passkeySignals.some(s => s.name === '{name}')"); + return await page.EvaluateAsync($"() => window.__passkeySignals.find(s => s.name === '{name}').options"); + } + + static async Task GetSignalledCredentialIdsAsync(IPage page) + { + var options = await GetPasskeySignalAsync(page, "signalAllAcceptedCredentials"); + return [.. options.GetProperty("allAcceptedCredentialIds").EnumerateArray().Select(id => id.GetString())]; + } + + static async Task GetAuthenticatorCredentialsAsync(ICDPSession cdpSession, string authenticatorId) + { + var result = await cdpSession.SendAsync("WebAuthn.getCredentials", new Dictionary + { + ["authenticatorId"] = authenticatorId, + }); + var credentials = result.Value.GetProperty("credentials").EnumerateArray(); + // The signal API uses base64url while CDP uses base64. + return [.. credentials.Select(c => Base64Url.EncodeToString(Convert.FromBase64String(c.GetProperty("credentialId").GetString())))]; + } } protected void EnsureBrowserAvailable(BrowserKind browserKind) diff --git a/src/ProjectTemplates/test/Templates.Tests/template-baselines.json b/src/ProjectTemplates/test/Templates.Tests/template-baselines.json index c908aad66194..7d115e2d1fd5 100644 --- a/src/ProjectTemplates/test/Templates.Tests/template-baselines.json +++ b/src/ProjectTemplates/test/Templates.Tests/template-baselines.json @@ -640,6 +640,8 @@ "Components/Account/Shared/ExternalLoginPicker.razor", "Components/Account/Shared/ManageLayout.razor", "Components/Account/Shared/ManageNavMenu.razor", + "Components/Account/Shared/PasskeySignals.razor", + "Components/Account/Shared/PasskeySignals.razor.js", "Components/Account/Shared/PasskeySubmit.razor", "Components/Account/Shared/PasskeySubmit.razor.js", "Components/Account/Shared/RedirectToLogin.razor", @@ -839,6 +841,8 @@ "Components/Account/Shared/ExternalLoginPicker.razor", "Components/Account/Shared/ManageLayout.razor", "Components/Account/Shared/ManageNavMenu.razor", + "Components/Account/Shared/PasskeySignals.razor", + "Components/Account/Shared/PasskeySignals.razor.js", "Components/Account/Shared/PasskeySubmit.razor", "Components/Account/Shared/PasskeySubmit.razor.js", "Components/Account/Shared/RedirectToLogin.razor", @@ -968,6 +972,8 @@ "Components/Account/Shared/ExternalLoginPicker.razor", "Components/Account/Shared/ManageLayout.razor", "Components/Account/Shared/ManageNavMenu.razor", + "Components/Account/Shared/PasskeySignals.razor", + "Components/Account/Shared/PasskeySignals.razor.js", "Components/Account/Shared/PasskeySubmit.razor", "Components/Account/Shared/PasskeySubmit.razor.js", "Components/Account/Shared/RedirectToLogin.razor", @@ -1181,6 +1187,8 @@ "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor.js", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", @@ -1393,6 +1401,8 @@ "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor.js", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", @@ -1853,6 +1863,8 @@ "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor.js", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", @@ -1930,6 +1942,8 @@ "Components/Account/Shared/ExternalLoginPicker.razor", "Components/Account/Shared/ManageLayout.razor", "Components/Account/Shared/ManageNavMenu.razor", + "Components/Account/Shared/PasskeySignals.razor", + "Components/Account/Shared/PasskeySignals.razor.js", "Components/Account/Shared/PasskeySubmit.razor", "Components/Account/Shared/PasskeySubmit.razor.js", "Components/Account/Shared/RedirectToLogin.razor", @@ -2076,6 +2090,8 @@ "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor.js", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor", @@ -2212,6 +2228,8 @@ "{ProjectName}/Components/Account/Shared/ExternalLoginPicker.razor", "{ProjectName}/Components/Account/Shared/ManageLayout.razor", "{ProjectName}/Components/Account/Shared/ManageNavMenu.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor", + "{ProjectName}/Components/Account/Shared/PasskeySignals.razor.js", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor", "{ProjectName}/Components/Account/Shared/PasskeySubmit.razor.js", "{ProjectName}/Components/Account/Shared/ShowRecoveryCodes.razor",