diff --git a/src/Identity/EntityFrameworkCore/src/IdentityUserPasskeyExtensions.cs b/src/Identity/EntityFrameworkCore/src/IdentityUserPasskeyExtensions.cs new file mode 100644 index 000000000000..4e2f4a040a58 --- /dev/null +++ b/src/Identity/EntityFrameworkCore/src/IdentityUserPasskeyExtensions.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore; + +internal static class IdentityUserPasskeyExtensions +{ + extension(IdentityUserPasskey passkey) + where TKey : IEquatable + { + public void UpdateFromUserPasskeyInfo(UserPasskeyInfo passkeyInfo) + { + passkey.Data.Name = passkeyInfo.Name; + passkey.Data.SignCount = passkeyInfo.SignCount; + passkey.Data.IsBackedUp = passkeyInfo.IsBackedUp; + passkey.Data.IsUserVerified = passkeyInfo.IsUserVerified; + } + + public UserPasskeyInfo ToUserPasskeyInfo() + => new( + passkey.CredentialId, + passkey.Data.PublicKey, + passkey.Data.CreatedAt, + passkey.Data.SignCount, + passkey.Data.Transports, + passkey.Data.IsUserVerified, + passkey.Data.IsBackupEligible, + passkey.Data.IsBackedUp, + passkey.Data.AttestationObject, + passkey.Data.ClientDataJson) + { + Name = passkey.Data.Name + }; + } +} diff --git a/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs b/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs index 5cde3fb05b39..2db6172b8f35 100644 --- a/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs @@ -625,10 +625,7 @@ public virtual async Task AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo pa var userPasskey = await FindUserPasskeyByIdAsync(passkey.CredentialId, cancellationToken).ConfigureAwait(false); if (userPasskey != null) { - userPasskey.Data.Name = passkey.Name; - userPasskey.Data.SignCount = passkey.SignCount; - userPasskey.Data.IsBackedUp = passkey.IsBackedUp; - userPasskey.Data.IsUserVerified = passkey.IsUserVerified; + userPasskey.UpdateFromUserPasskeyInfo(passkey); UserPasskeys.Update(userPasskey); } else @@ -655,20 +652,7 @@ public virtual async Task> GetPasskeysAsync(TUser user, C var userId = user.Id; var passkeys = await UserPasskeys .Where(p => p.UserId.Equals(userId)) - .Select(p => new UserPasskeyInfo( - p.CredentialId, - p.Data.PublicKey, - p.Data.CreatedAt, - p.Data.SignCount, - p.Data.Transports, - p.Data.IsUserVerified, - p.Data.IsBackupEligible, - p.Data.IsBackedUp, - p.Data.AttestationObject, - p.Data.ClientDataJson) - { - Name = p.Data.Name, - }) + .Select(p => p.ToUserPasskeyInfo()) .ToListAsync(cancellationToken) .ConfigureAwait(false); @@ -708,26 +692,10 @@ public virtual async Task> GetPasskeysAsync(TUser user, C cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(credentialId); var passkey = await FindUserPasskeyAsync(user.Id, credentialId, cancellationToken).ConfigureAwait(false); - if (passkey != null) - { - return new UserPasskeyInfo( - passkey.CredentialId, - passkey.Data.PublicKey, - passkey.Data.CreatedAt, - passkey.Data.SignCount, - passkey.Data.Transports, - passkey.Data.IsUserVerified, - passkey.Data.IsBackupEligible, - passkey.Data.IsBackedUp, - passkey.Data.AttestationObject, - passkey.Data.ClientDataJson) - { - Name = passkey.Data.Name, - }; - } - return null; + return passkey?.ToUserPasskeyInfo(); } /// diff --git a/src/Identity/EntityFrameworkCore/src/UserStore.cs b/src/Identity/EntityFrameworkCore/src/UserStore.cs index 85c272b0b1e6..6ee3419cbb0d 100644 --- a/src/Identity/EntityFrameworkCore/src/UserStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserStore.cs @@ -770,9 +770,7 @@ public virtual async Task AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo pa var userPasskey = await FindUserPasskeyByIdAsync(passkey.CredentialId, cancellationToken).ConfigureAwait(false); if (userPasskey != null) { - userPasskey.Data.SignCount = passkey.SignCount; - userPasskey.Data.IsBackedUp = passkey.IsBackedUp; - userPasskey.Data.IsUserVerified = passkey.IsUserVerified; + userPasskey.UpdateFromUserPasskeyInfo(passkey); UserPasskeys.Update(userPasskey); } else @@ -799,20 +797,7 @@ public virtual async Task> GetPasskeysAsync(TUser user, C var userId = user.Id; var passkeys = await UserPasskeys .Where(p => p.UserId.Equals(userId)) - .Select(p => new UserPasskeyInfo( - p.CredentialId, - p.Data.PublicKey, - p.Data.CreatedAt, - p.Data.SignCount, - p.Data.Transports, - p.Data.IsUserVerified, - p.Data.IsBackupEligible, - p.Data.IsBackedUp, - p.Data.AttestationObject, - p.Data.ClientDataJson) - { - Name = p.Data.Name - }) + .Select(p => p.ToUserPasskeyInfo()) .ToListAsync(cancellationToken) .ConfigureAwait(false); @@ -851,27 +836,11 @@ public virtual async Task> GetPasskeysAsync(TUser user, C { cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); ArgumentNullException.ThrowIfNull(credentialId); var passkey = await FindUserPasskeyAsync(user.Id, credentialId, cancellationToken).ConfigureAwait(false); - if (passkey != null) - { - return new UserPasskeyInfo( - passkey.CredentialId, - passkey.Data.PublicKey, - passkey.Data.CreatedAt, - passkey.Data.SignCount, - passkey.Data.Transports, - passkey.Data.IsUserVerified, - passkey.Data.IsBackupEligible, - passkey.Data.IsBackedUp, - passkey.Data.AttestationObject, - passkey.Data.ClientDataJson) - { - Name = passkey.Data.Name - }; - } - return null; + return passkey?.ToUserPasskeyInfo(); } /// diff --git a/src/Identity/EntityFrameworkCore/test/EF.InMemory.Test/InMemoryContext.cs b/src/Identity/EntityFrameworkCore/test/EF.InMemory.Test/InMemoryContext.cs index d5e292d7404f..37cb9ba7785f 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.InMemory.Test/InMemoryContext.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.InMemory.Test/InMemoryContext.cs @@ -3,17 +3,31 @@ using System.Data.Common; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore.InMemory.Test; public class InMemoryContext : InMemoryContext { - private InMemoryContext(DbConnection connection) : base(connection) + private InMemoryContext(DbConnection connection, IServiceProvider serviceProvider) : base(connection, serviceProvider) { } - public static new InMemoryContext Create(DbConnection connection) - => Initialize(new InMemoryContext(connection)); + public static new InMemoryContext Create(DbConnection connection, IServiceCollection services = null) + { + services = ConfigureDbServices(services); + return Initialize(new InMemoryContext(connection, services.BuildServiceProvider())); + } + + public static IServiceCollection ConfigureDbServices(IServiceCollection services = null) + { + services ??= new ServiceCollection(); + services.Configure(options => + { + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; + }); + return services; + } public static TContext Initialize(TContext context) where TContext : DbContext { @@ -28,17 +42,25 @@ public class InMemoryContext : where TUser : IdentityUser { private readonly DbConnection _connection; + private readonly IServiceProvider _serviceProvider; - private InMemoryContext(DbConnection connection) + private InMemoryContext(DbConnection connection, IServiceProvider serviceProvider) { _connection = connection; + _serviceProvider = serviceProvider; } - public static InMemoryContext Create(DbConnection connection) - => InMemoryContext.Initialize(new InMemoryContext(connection)); + public static InMemoryContext Create(DbConnection connection, IServiceCollection services = null) + { + services = InMemoryContext.ConfigureDbServices(services); + return InMemoryContext.Initialize(new InMemoryContext(connection, services.BuildServiceProvider())); + } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder.UseSqlite(_connection); + { + optionsBuilder.UseSqlite(_connection); + optionsBuilder.UseApplicationServiceProvider(_serviceProvider); + } } public class InMemoryContext : IdentityDbContext @@ -47,17 +69,25 @@ public class InMemoryContext : IdentityDbContext { private readonly DbConnection _connection; + private readonly IServiceProvider _serviceProvider; - protected InMemoryContext(DbConnection connection) + protected InMemoryContext(DbConnection connection, IServiceProvider serviceProvider) { _connection = connection; + _serviceProvider = serviceProvider; } - public static InMemoryContext Create(DbConnection connection) - => InMemoryContext.Initialize(new InMemoryContext(connection)); + public static InMemoryContext Create(DbConnection connection, IServiceCollection services = null) + { + services = InMemoryContext.ConfigureDbServices(services); + return InMemoryContext.Initialize(new InMemoryContext(connection, services.BuildServiceProvider())); + } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder.UseSqlite(_connection); + { + optionsBuilder.UseSqlite(_connection); + optionsBuilder.UseApplicationServiceProvider(_serviceProvider); + } } public abstract class InMemoryContext : diff --git a/src/Identity/EntityFrameworkCore/test/EF.InMemory.Test/InMemoryStoreWithGenericsTest.cs b/src/Identity/EntityFrameworkCore/test/EF.InMemory.Test/InMemoryStoreWithGenericsTest.cs index f85e2ea66e33..d4516b0b45b0 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.InMemory.Test/InMemoryStoreWithGenericsTest.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.InMemory.Test/InMemoryStoreWithGenericsTest.cs @@ -24,6 +24,10 @@ public InMemoryEFUserStoreTestWithGenerics(InMemoryDatabaseFixture fixture) var services = new ServiceCollection(); services.AddHttpContextAccessor(); + services.Configure(options => + { + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; + }); services.AddDbContext( options => options .UseSqlite(_fixture.Connection) diff --git a/src/Identity/EntityFrameworkCore/test/EF.Test/DbUtil.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/DbUtil.cs index 4bfb6bb171e7..a17c48bff2a0 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.Test/DbUtil.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/DbUtil.cs @@ -30,6 +30,11 @@ public static IServiceCollection ConfigureDbServices( .UseSqlite(connection); }); + services.Configure(options => + { + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; + }); + return services; } diff --git a/src/Identity/EntityFrameworkCore/test/EF.Test/SqlStoreTestBase.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/SqlStoreTestBase.cs index 3d30b28e6f99..6d1718c79ae2 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.Test/SqlStoreTestBase.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/SqlStoreTestBase.cs @@ -37,6 +37,7 @@ protected virtual void SetupAddIdentity(IServiceCollection services) options.Password.RequireNonAlphanumeric = false; options.Password.RequireUppercase = false; options.User.AllowedUserNameCharacters = null; + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; }) .AddRoles() .AddDefaultTokenProviders() diff --git a/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreEncryptPersonalDataTest.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreEncryptPersonalDataTest.cs index 9aedbeaf192a..5d143464c239 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreEncryptPersonalDataTest.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreEncryptPersonalDataTest.cs @@ -25,6 +25,7 @@ protected override void SetupAddIdentity(IServiceCollection services) options.Password.RequireNonAlphanumeric = false; options.Password.RequireUppercase = false; options.User.AllowedUserNameCharacters = null; + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; }) .AddDefaultTokenProviders() .AddEntityFrameworkStores() diff --git a/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreTest.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreTest.cs index fe7eb5ee9003..bf575c15a282 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreTest.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreTest.cs @@ -195,7 +195,6 @@ public async Task FindByEmailThrowsWithTwoUsersWithSameEmail() userB.Email = "dupe@dupe.com"; IdentityResultAssert.IsSuccess(await manager.CreateAsync(userB, "password")); await Assert.ThrowsAsync(async () => await manager.FindByEmailAsync("dupe@dupe.com")); - } [ConditionalFact] diff --git a/src/Identity/EntityFrameworkCore/test/EF.Test/Utilities/ScratchDatabaseFixture.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/Utilities/ScratchDatabaseFixture.cs index b29a046cff3f..80b88e97387b 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.Test/Utilities/ScratchDatabaseFixture.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/Utilities/ScratchDatabaseFixture.cs @@ -4,6 +4,7 @@ using System.Data.Common; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test; @@ -23,7 +24,14 @@ public ScratchDatabaseFixture() } private DbContext CreateEmptyContext() - => new DbContext(new DbContextOptionsBuilder().UseSqlite(_connection).Options); + { + var services = new ServiceCollection(); + services.Configure(options => options.Stores.SchemaVersion = IdentitySchemaVersions.Version3); + return new DbContext(new DbContextOptionsBuilder() + .UseSqlite(_connection) + .UseApplicationServiceProvider(services.BuildServiceProvider()) + .Options); + } public DbConnection Connection => _connection; diff --git a/src/Identity/Specification.Tests/src/IdentitySpecificationTestBase.cs b/src/Identity/Specification.Tests/src/IdentitySpecificationTestBase.cs index 2d0b0ccec438..7f587d5f87ee 100644 --- a/src/Identity/Specification.Tests/src/IdentitySpecificationTestBase.cs +++ b/src/Identity/Specification.Tests/src/IdentitySpecificationTestBase.cs @@ -48,6 +48,7 @@ protected override void SetupIdentityServices(IServiceCollection services, objec options.Password.RequireNonAlphanumeric = false; options.Password.RequireUppercase = false; options.User.AllowedUserNameCharacters = null; + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; }).AddDefaultTokenProviders(); AddUserStore(services, context); AddRoleStore(services, context); @@ -236,7 +237,7 @@ public async Task CanAddRemoveRoleClaim() var roleSafe = CreateTestRole("ClaimsAdd"); IdentityResultAssert.IsSuccess(await manager.CreateAsync(role)); IdentityResultAssert.IsSuccess(await manager.CreateAsync(roleSafe)); - Claim[] claims = { new Claim("c", "v"), new Claim("c2", "v2"), new Claim("c2", "v3") }; + Claim[] claims = [new Claim("c", "v"), new Claim("c2", "v2"), new Claim("c2", "v3")]; foreach (Claim c in claims) { IdentityResultAssert.IsSuccess(await manager.AddClaimAsync(role, c)); @@ -366,9 +367,9 @@ public async Task CanAddUsersToRole() var role = CreateTestRole(roleName, useRoleNamePrefixAsRoleName: true); IdentityResultAssert.IsSuccess(await roleManager.CreateAsync(role)); TUser[] users = - { + [ CreateTestUser("1"),CreateTestUser("2"),CreateTestUser("3"),CreateTestUser("4"), - }; + ]; foreach (var u in users) { IdentityResultAssert.IsSuccess(await manager.CreateAsync(u)); @@ -604,4 +605,219 @@ private List GenerateRoles(string namePrefix, int count) } return roles; } + + /// + /// Test. + /// + /// Task + [Fact] + public async Task CanAddAndRetrievePasskey() + { + var context = CreateTestContext(); + var manager = CreateManager(context); + Assert.True(manager.SupportsUserPasskey); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + + var credentialId = Guid.NewGuid().ToByteArray(); + var passkey = new UserPasskeyInfo( + credentialId, + publicKey: [1, 2, 3, 4], + DateTimeOffset.UtcNow, + signCount: 0, + transports: ["usb"], + isUserVerified: false, + isBackupEligible: true, + isBackedUp: false, + attestationObject: [5, 6, 7], + clientDataJson: [8, 9]) + { + Name = "InitialName" + }; + + IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, passkey)); + + var fetchedPasskey = await manager.GetPasskeyAsync(user, credentialId); + AssertPasskeysEqual(passkey, fetchedPasskey); + + var fetchedPasskeys = await manager.GetPasskeysAsync(user); + Assert.Single(fetchedPasskeys); + AssertPasskeysEqual(passkey, fetchedPasskeys[0]); + } + + /// + /// Test. + /// + /// Task + [Fact] + public async Task CanRemovePasskey() + { + var context = CreateTestContext(); + var manager = CreateManager(context); + Assert.True(manager.SupportsUserPasskey); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + + var passkey = new UserPasskeyInfo( + credentialId: Guid.NewGuid().ToByteArray(), + publicKey: [1], + DateTimeOffset.UtcNow, + signCount: 0, + transports: null, + isUserVerified: false, + isBackupEligible: false, + isBackedUp: false, + attestationObject: [2], + clientDataJson: [3]) + { + Name = "ToRemove" + }; + + IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, passkey)); + Assert.Single(await manager.GetPasskeysAsync(user)); + IdentityResultAssert.IsSuccess(await manager.RemovePasskeyAsync(user, passkey.CredentialId)); + Assert.Empty(await manager.GetPasskeysAsync(user)); + + // Second removal should not throw or change anything + IdentityResultAssert.IsSuccess(await manager.RemovePasskeyAsync(user, passkey.CredentialId)); + Assert.Empty(await manager.GetPasskeysAsync(user)); + } + + /// + /// Test. + /// + /// Task + [Fact] + public async Task CanAddMultiplePasskeys() + { + var context = CreateTestContext(); + var manager = CreateManager(context); + Assert.True(manager.SupportsUserPasskey); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + + var passkey1 = new UserPasskeyInfo( + credentialId: Guid.NewGuid().ToByteArray(), + publicKey: [1], + DateTimeOffset.UtcNow, + signCount: 0, + transports: ["usb"], + isUserVerified: false, + isBackupEligible: false, + isBackedUp: false, + attestationObject: [10], + clientDataJson: [11]) + { + Name = "One" + }; + var passkey2 = new UserPasskeyInfo( + credentialId: Guid.NewGuid().ToByteArray(), + publicKey: [2], + DateTimeOffset.UtcNow, + signCount: 5, + transports: ["nfc"], + isUserVerified: true, + isBackupEligible: false, + isBackedUp: false, + attestationObject: [12], + clientDataJson: [13]) + { + Name = "Two" + }; + + IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, passkey1)); + IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, passkey2)); + + var all = await manager.GetPasskeysAsync(user); + Assert.Equal(2, all.Count); + Assert.Contains(all, p => p.Name == "One"); + Assert.Contains(all, p => p.Name == "Two"); + + var fetchedPasskey1 = await manager.GetPasskeyAsync(user, passkey1.CredentialId); + var fetchedPasskey2 = await manager.GetPasskeyAsync(user, passkey2.CredentialId); + AssertPasskeysEqual(passkey1, fetchedPasskey1); + AssertPasskeysEqual(passkey2, fetchedPasskey2); + } + + /// + /// Test. + /// + /// Task + [Fact] + public async Task UpdatingPasskeyChangesOnlyMutableFields() + { + var context = CreateTestContext(); + var manager = CreateManager(context); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + + var original = new UserPasskeyInfo( + credentialId: Guid.NewGuid().ToByteArray(), + publicKey: [9, 9], + createdAt: DateTimeOffset.UtcNow, + signCount: 1, + transports: ["usb", "nfc"], + isUserVerified: false, + isBackupEligible: true, + isBackedUp: false, + attestationObject: [5], + clientDataJson: [6]) + { + Name = "ImmutableTest" + }; + IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, original)); + + // Attempt to modify both mutable and immutable fields + var updated = new UserPasskeyInfo( + credentialId: original.CredentialId, + publicKey: [0xFF, 0xFF], + createdAt: original.CreatedAt.AddMinutes(5), + signCount: 3, + transports: ["ble"], + isUserVerified: true, + isBackupEligible: false, + isBackedUp: true, + attestationObject: [7], + clientDataJson: [8]) + { + Name = "Changed" + }; + + var expected = new UserPasskeyInfo( + credentialId: original.CredentialId, + publicKey: original.PublicKey, + createdAt: original.CreatedAt, + signCount: updated.SignCount, + transports: original.Transports, + isUserVerified: updated.IsUserVerified, + isBackupEligible: original.IsBackupEligible, + isBackedUp: updated.IsBackedUp, + attestationObject: original.AttestationObject, + clientDataJson: original.ClientDataJson) + { + Name = updated.Name, + }; + + IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, updated)); + + var stored = await manager.GetPasskeyAsync(user, original.CredentialId); + AssertPasskeysEqual(expected, stored); + } + + private static void AssertPasskeysEqual(UserPasskeyInfo expected, UserPasskeyInfo actual) + { + Assert.NotNull(expected); + Assert.NotNull(actual); + + Assert.Equal(expected.Name, actual.Name); + Assert.Equal(expected.SignCount, actual.SignCount); + Assert.Equal(expected.IsBackedUp, actual.IsBackedUp); + Assert.Equal(expected.IsUserVerified, actual.IsUserVerified); + Assert.Equal(expected.PublicKey, actual.PublicKey); + Assert.Equal(expected.CreatedAt, actual.CreatedAt); + Assert.Equal(expected.IsBackupEligible, actual.IsBackupEligible); + Assert.Equal(expected.AttestationObject, actual.AttestationObject); + Assert.Equal(expected.ClientDataJson, actual.ClientDataJson); + Assert.Equal(expected.Transports, actual.Transports); + } } diff --git a/src/Identity/Specification.Tests/src/UserManagerSpecificationTests.cs b/src/Identity/Specification.Tests/src/UserManagerSpecificationTests.cs index 933dad56f92c..5d58387bdfa7 100644 --- a/src/Identity/Specification.Tests/src/UserManagerSpecificationTests.cs +++ b/src/Identity/Specification.Tests/src/UserManagerSpecificationTests.cs @@ -61,6 +61,7 @@ protected virtual IdentityBuilder SetupBuilder(IServiceCollection services, obje options.Password.RequireNonAlphanumeric = false; options.Password.RequireUppercase = false; options.User.AllowedUserNameCharacters = null; + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; }).AddDefaultTokenProviders(); AddUserStore(services, context); services.AddLogging(); diff --git a/src/Identity/test/InMemory.Test/InMemoryStore.cs b/src/Identity/test/InMemory.Test/InMemoryStore.cs index 23598c8b8ce8..c5860db84589 100644 --- a/src/Identity/test/InMemory.Test/InMemoryStore.cs +++ b/src/Identity/test/InMemory.Test/InMemoryStore.cs @@ -9,6 +9,7 @@ namespace Microsoft.AspNetCore.Identity.InMemory; public class InMemoryStore : InMemoryUserStore, IUserRoleStore, + IUserPasskeyStore, IQueryableRoleStore, IRoleClaimStore where TRole : PocoRole @@ -158,6 +159,82 @@ Task IRoleStore.FindByNameAsync(string roleName, CancellationToken return Task.FromResult(0); } + public Task AddOrUpdatePasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) + { + var passkeyEntity = user.Passkeys.FirstOrDefault(p => p.CredentialId.SequenceEqual(passkey.CredentialId)); + if (passkeyEntity is null) + { + user.Passkeys.Add(ToPocoUserPasskey(user, passkey)); + } + else + { + passkeyEntity.Name = passkey.Name; + passkeyEntity.SignCount = passkey.SignCount; + passkeyEntity.IsBackedUp = passkey.IsBackedUp; + passkeyEntity.IsUserVerified = passkey.IsUserVerified; + } + return Task.CompletedTask; + } + + public Task> GetPasskeysAsync(TUser user, CancellationToken cancellationToken) + { + return Task.FromResult>(user.Passkeys.Select(ToUserPasskeyInfo).ToList()!); + } + + public Task FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken) + { + return Task.FromResult(Users.FirstOrDefault(u => u.Passkeys.Any(p => p.CredentialId.SequenceEqual(credentialId)))); + } + + public Task FindPasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + { + return Task.FromResult(ToUserPasskeyInfo(user.Passkeys.FirstOrDefault(p => p.CredentialId.SequenceEqual(credentialId)))); + } + + public Task RemovePasskeyAsync(TUser user, byte[] credentialId, CancellationToken cancellationToken) + { + var passkey = user.Passkeys.SingleOrDefault(p => p.CredentialId.SequenceEqual(credentialId)); + if (passkey is not null) + { + user.Passkeys.Remove(passkey); + } + + return Task.CompletedTask; + } + + private static UserPasskeyInfo ToUserPasskeyInfo(PocoUserPasskey p) + => p is null ? null : new( + p.CredentialId, + p.PublicKey, + p.CreatedAt, + p.SignCount, + p.Transports, + p.IsUserVerified, + p.IsBackupEligible, + p.IsBackedUp, + p.AttestationObject, + p.ClientDataJson) + { + Name = p.Name + }; + + private static PocoUserPasskey ToPocoUserPasskey(TUser user, UserPasskeyInfo p) + => new() + { + UserId = user.Id, + CredentialId = p.CredentialId, + PublicKey = p.PublicKey, + Name = p.Name, + CreatedAt = p.CreatedAt, + Transports = p.Transports, + SignCount = p.SignCount, + IsUserVerified = p.IsUserVerified, + IsBackupEligible = p.IsBackupEligible, + IsBackedUp = p.IsBackedUp, + AttestationObject = p.AttestationObject, + ClientDataJson = p.ClientDataJson, + }; + public IQueryable Roles { get { return _roles.Values.AsQueryable(); }