From 8cee4d846d4a3a40f1a2feacbc3d6c220f54768c Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 9 Oct 2025 18:34:00 -0700 Subject: [PATCH 1/3] Fix `UserStore` passkey name update --- .../EntityFrameworkCore/src/UserOnlyStore.cs | 1 + .../EntityFrameworkCore/src/UserStore.cs | 2 + .../test/EF.Test/SqlStoreTestBase.cs | 1 + .../UserStoreEncryptPersonalDataTest.cs | 1 + .../test/EF.Test/UserStoreTest.cs | 8 +- .../test/EF.Test/UserStoreWithGenericsTest.cs | 7 +- .../src/IdentitySpecificationTestBase.cs | 355 +++++++++++++++++- .../src/UserManagerSpecificationTests.cs | 1 + 8 files changed, 370 insertions(+), 6 deletions(-) diff --git a/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs b/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs index 5cde3fb05b39..580d637ec810 100644 --- a/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs @@ -708,6 +708,7 @@ 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) diff --git a/src/Identity/EntityFrameworkCore/src/UserStore.cs b/src/Identity/EntityFrameworkCore/src/UserStore.cs index 85c272b0b1e6..d9a3d241a732 100644 --- a/src/Identity/EntityFrameworkCore/src/UserStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserStore.cs @@ -770,6 +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.Name = passkey.Name; userPasskey.Data.SignCount = passkey.SignCount; userPasskey.Data.IsBackedUp = passkey.IsBackedUp; userPasskey.Data.IsUserVerified = passkey.IsUserVerified; @@ -851,6 +852,7 @@ 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); 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..ef32b27308bf 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreTest.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreTest.cs @@ -40,7 +40,12 @@ public void CanCreateUserUsingEF() private IdentityDbContext CreateContext() { - var db = DbUtil.Create(_fixture.Connection); + var services = new ServiceCollection(); + services.Configure(options => + { + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; + }); + var db = DbUtil.Create(_fixture.Connection, services); db.Database.EnsureCreated(); return db; } @@ -195,7 +200,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/UserStoreWithGenericsTest.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreWithGenericsTest.cs index 0889dd2ba1c2..bffce5acdb1e 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreWithGenericsTest.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreWithGenericsTest.cs @@ -21,7 +21,12 @@ public UserStoreWithGenericsTest(ScratchDatabaseFixture fixture) private ContextWithGenerics CreateContext() { - var db = DbUtil.Create(_fixture.Connection); + var services = new ServiceCollection(); + services.Configure(options => + { + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; + }); + var db = DbUtil.Create(_fixture.Connection, services); db.Database.EnsureCreated(); return db; } diff --git a/src/Identity/Specification.Tests/src/IdentitySpecificationTestBase.cs b/src/Identity/Specification.Tests/src/IdentitySpecificationTestBase.cs index 2d0b0ccec438..828558552467 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,352 @@ 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 fetched = await manager.GetPasskeyAsync(user, credentialId); + Assert.NotNull(fetched); + Assert.Equal("InitialName", fetched.Name); + Assert.Equal(0u, fetched.SignCount); + + var list = await manager.GetPasskeysAsync(user); + Assert.Single(list); + Assert.Equal("InitialName", list[0].Name); + } + + /// + /// Test. + /// + /// Task + [Fact] + public async Task AddOrUpdatePasskeyUpdatesMutableFields() + { + 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: [10, 11, 12], + DateTimeOffset.UtcNow, + signCount: 1, + transports: ["nfc"], + isUserVerified: false, + isBackupEligible: true, + isBackedUp: false, + attestationObject: [13], + clientDataJson: [14]) + { + Name = "Original" + }; + + IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, passkey)); + + passkey.Name = "Updated"; + passkey.SignCount = 2; + passkey.IsBackedUp = true; + passkey.IsUserVerified = true; + + IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, passkey)); + + var updated = await manager.GetPasskeyAsync(user, credentialId); + Assert.NotNull(updated); + Assert.Equal("Updated", updated.Name); + Assert.Equal(2u, updated.SignCount); + Assert.True(updated.IsBackedUp); + Assert.True(updated.IsUserVerified); + } + + /// + /// 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)); + } + + /// + /// 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 pk2 = 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, pk2)); + + 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"); + Assert.NotNull(await manager.GetPasskeyAsync(user, passkey1.CredentialId)); + Assert.NotNull(await manager.GetPasskeyAsync(user, pk2.CredentialId)); + } + + /// + /// Test. + /// + /// Task + [Fact] + public async Task CannotAccessAnotherUsersPasskey() + { + var context = CreateTestContext(); + var manager = CreateManager(context); + var user1 = CreateTestUser(); + var user2 = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user1)); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user2)); + + var passkey = 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" + }; + IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user1, passkey)); + + // user2 should not see user1's passkey via GetPasskeyAsync + Assert.Null(await manager.GetPasskeyAsync(user2, passkey.CredentialId)); + var owner = await manager.FindByPasskeyIdAsync(passkey.CredentialId); + Assert.Equal(await manager.GetUserIdAsync(user1), await manager.GetUserIdAsync(owner)); + } + + /// + /// Test. + /// + /// Task + [Fact] + public async Task FindByPasskeyIdReturnsCorrectUser() + { + var context = CreateTestContext(); + var manager = CreateManager(context); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + + var passkey = 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" + }; + IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, passkey)); + + var found = await manager.FindByPasskeyIdAsync(passkey.CredentialId); + Assert.Equal(await manager.GetUserIdAsync(user), await manager.GetUserIdAsync(found)); + } + + /// + /// Test. + /// + /// Task + [Fact] + public async Task UpdatingPasskeyDoesNotChangeImmutableFields() + { + 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 immutable fields + var update = 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" + }; + + IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, update)); + + var stored = await manager.GetPasskeyAsync(user, original.CredentialId); + Assert.NotNull(stored); + + // Mutable + Assert.Equal("Changed", stored.Name); + Assert.Equal((uint)3, stored.SignCount); + Assert.True(stored.IsBackedUp); + Assert.True(stored.IsUserVerified); + + // Immutable + Assert.Equal(original.PublicKey, stored.PublicKey); + Assert.Equal(original.CreatedAt, stored.CreatedAt); + Assert.Equal(original.IsBackupEligible, stored.IsBackupEligible); + Assert.Equal(original.AttestationObject, stored.AttestationObject); + Assert.Equal(original.ClientDataJson, stored.ClientDataJson); + Assert.Equal(original.Transports, stored.Transports); + } + + /// + /// Test. + /// + /// Task + [Fact] + public async Task RemovePasskeyIsIdempotent() + { + var context = CreateTestContext(); + var manager = CreateManager(context); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + + var pk = new UserPasskeyInfo(Guid.NewGuid().ToByteArray(), [1], DateTimeOffset.UtcNow, 0, null, false, false, false, [2], [3]); + IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, pk)); + + IdentityResultAssert.IsSuccess(await manager.RemovePasskeyAsync(user, pk.CredentialId)); + Assert.Empty(await manager.GetPasskeysAsync(user)); + + // Second removal should not throw or change anything + IdentityResultAssert.IsSuccess(await manager.RemovePasskeyAsync(user, pk.CredentialId)); + Assert.Empty(await manager.GetPasskeysAsync(user)); + } + + /// + /// Test. + /// + /// Task + [Fact] + public async Task FindPasskeyReturnsNullForUnknownCredential() + { + var context = CreateTestContext(); + var manager = CreateManager(context); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + + var pk = new UserPasskeyInfo(Guid.NewGuid().ToByteArray(), [7], DateTimeOffset.UtcNow, 0, null, false, false, false, [8], [9]); + IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, pk)); + + var randomId = Guid.NewGuid().ToByteArray(); + Assert.Null(await manager.GetPasskeyAsync(user, randomId)); + } } 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(); From 14ac9f2ebe0bf75e521af9dc17ca03a9c8508135 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 10 Oct 2025 10:54:38 -0700 Subject: [PATCH 2/3] Simplifications --- .../src/IdentityUserPasskeyExtensions.cs | 35 +++ .../EntityFrameworkCore/src/UserOnlyStore.cs | 39 +-- .../EntityFrameworkCore/src/UserStore.cs | 39 +-- .../test/EF.InMemory.Test/InMemoryContext.cs | 52 +++- .../InMemoryStoreWithGenericsTest.cs | 4 + .../test/EF.Test/DbUtil.cs | 5 + .../test/EF.Test/UserStoreTest.cs | 7 +- .../test/EF.Test/UserStoreWithGenericsTest.cs | 7 +- .../src/IdentitySpecificationTestBase.cs | 229 ++++-------------- .../test/InMemory.Test/InMemoryStore.cs | 77 ++++++ 10 files changed, 218 insertions(+), 276 deletions(-) create mode 100644 src/Identity/EntityFrameworkCore/src/IdentityUserPasskeyExtensions.cs 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 580d637ec810..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); @@ -711,24 +695,7 @@ public virtual async Task> GetPasskeysAsync(TUser user, C 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 d9a3d241a732..6ee3419cbb0d 100644 --- a/src/Identity/EntityFrameworkCore/src/UserStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserStore.cs @@ -770,10 +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.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 @@ -800,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); @@ -856,24 +840,7 @@ public virtual async Task> GetPasskeysAsync(TUser user, C 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/UserStoreTest.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreTest.cs index ef32b27308bf..bf575c15a282 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreTest.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreTest.cs @@ -40,12 +40,7 @@ public void CanCreateUserUsingEF() private IdentityDbContext CreateContext() { - var services = new ServiceCollection(); - services.Configure(options => - { - options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; - }); - var db = DbUtil.Create(_fixture.Connection, services); + var db = DbUtil.Create(_fixture.Connection); db.Database.EnsureCreated(); return db; } diff --git a/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreWithGenericsTest.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreWithGenericsTest.cs index bffce5acdb1e..0889dd2ba1c2 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreWithGenericsTest.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreWithGenericsTest.cs @@ -21,12 +21,7 @@ public UserStoreWithGenericsTest(ScratchDatabaseFixture fixture) private ContextWithGenerics CreateContext() { - var services = new ServiceCollection(); - services.Configure(options => - { - options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; - }); - var db = DbUtil.Create(_fixture.Connection, services); + var db = DbUtil.Create(_fixture.Connection); db.Database.EnsureCreated(); return db; } diff --git a/src/Identity/Specification.Tests/src/IdentitySpecificationTestBase.cs b/src/Identity/Specification.Tests/src/IdentitySpecificationTestBase.cs index 828558552467..7f587d5f87ee 100644 --- a/src/Identity/Specification.Tests/src/IdentitySpecificationTestBase.cs +++ b/src/Identity/Specification.Tests/src/IdentitySpecificationTestBase.cs @@ -637,60 +637,12 @@ public async Task CanAddAndRetrievePasskey() IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, passkey)); - var fetched = await manager.GetPasskeyAsync(user, credentialId); - Assert.NotNull(fetched); - Assert.Equal("InitialName", fetched.Name); - Assert.Equal(0u, fetched.SignCount); - - var list = await manager.GetPasskeysAsync(user); - Assert.Single(list); - Assert.Equal("InitialName", list[0].Name); - } - - /// - /// Test. - /// - /// Task - [Fact] - public async Task AddOrUpdatePasskeyUpdatesMutableFields() - { - 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: [10, 11, 12], - DateTimeOffset.UtcNow, - signCount: 1, - transports: ["nfc"], - isUserVerified: false, - isBackupEligible: true, - isBackedUp: false, - attestationObject: [13], - clientDataJson: [14]) - { - Name = "Original" - }; - - IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, passkey)); - - passkey.Name = "Updated"; - passkey.SignCount = 2; - passkey.IsBackedUp = true; - passkey.IsUserVerified = true; - - IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, passkey)); + var fetchedPasskey = await manager.GetPasskeyAsync(user, credentialId); + AssertPasskeysEqual(passkey, fetchedPasskey); - var updated = await manager.GetPasskeyAsync(user, credentialId); - Assert.NotNull(updated); - Assert.Equal("Updated", updated.Name); - Assert.Equal(2u, updated.SignCount); - Assert.True(updated.IsBackedUp); - Assert.True(updated.IsUserVerified); + var fetchedPasskeys = await manager.GetPasskeysAsync(user); + Assert.Single(fetchedPasskeys); + AssertPasskeysEqual(passkey, fetchedPasskeys[0]); } /// @@ -725,6 +677,10 @@ public async Task CanRemovePasskey() 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)); } /// @@ -754,7 +710,7 @@ public async Task CanAddMultiplePasskeys() { Name = "One" }; - var pk2 = new UserPasskeyInfo( + var passkey2 = new UserPasskeyInfo( credentialId: Guid.NewGuid().ToByteArray(), publicKey: [2], DateTimeOffset.UtcNow, @@ -770,82 +726,17 @@ public async Task CanAddMultiplePasskeys() }; IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, passkey1)); - IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, pk2)); + 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"); - Assert.NotNull(await manager.GetPasskeyAsync(user, passkey1.CredentialId)); - Assert.NotNull(await manager.GetPasskeyAsync(user, pk2.CredentialId)); - } - - /// - /// Test. - /// - /// Task - [Fact] - public async Task CannotAccessAnotherUsersPasskey() - { - var context = CreateTestContext(); - var manager = CreateManager(context); - var user1 = CreateTestUser(); - var user2 = CreateTestUser(); - IdentityResultAssert.IsSuccess(await manager.CreateAsync(user1)); - IdentityResultAssert.IsSuccess(await manager.CreateAsync(user2)); - - var passkey = 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" - }; - IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user1, passkey)); - - // user2 should not see user1's passkey via GetPasskeyAsync - Assert.Null(await manager.GetPasskeyAsync(user2, passkey.CredentialId)); - var owner = await manager.FindByPasskeyIdAsync(passkey.CredentialId); - Assert.Equal(await manager.GetUserIdAsync(user1), await manager.GetUserIdAsync(owner)); - } - - /// - /// Test. - /// - /// Task - [Fact] - public async Task FindByPasskeyIdReturnsCorrectUser() - { - var context = CreateTestContext(); - var manager = CreateManager(context); - var user = CreateTestUser(); - IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); - - var passkey = 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" - }; - IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, passkey)); - var found = await manager.FindByPasskeyIdAsync(passkey.CredentialId); - Assert.Equal(await manager.GetUserIdAsync(user), await manager.GetUserIdAsync(found)); + var fetchedPasskey1 = await manager.GetPasskeyAsync(user, passkey1.CredentialId); + var fetchedPasskey2 = await manager.GetPasskeyAsync(user, passkey2.CredentialId); + AssertPasskeysEqual(passkey1, fetchedPasskey1); + AssertPasskeysEqual(passkey2, fetchedPasskey2); } /// @@ -853,7 +744,7 @@ public async Task FindByPasskeyIdReturnsCorrectUser() /// /// Task [Fact] - public async Task UpdatingPasskeyDoesNotChangeImmutableFields() + public async Task UpdatingPasskeyChangesOnlyMutableFields() { var context = CreateTestContext(); var manager = CreateManager(context); @@ -876,8 +767,8 @@ public async Task UpdatingPasskeyDoesNotChangeImmutableFields() }; IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, original)); - // Attempt to modify immutable fields - var update = new UserPasskeyInfo( + // Attempt to modify both mutable and immutable fields + var updated = new UserPasskeyInfo( credentialId: original.CredentialId, publicKey: [0xFF, 0xFF], createdAt: original.CreatedAt.AddMinutes(5), @@ -892,65 +783,41 @@ public async Task UpdatingPasskeyDoesNotChangeImmutableFields() Name = "Changed" }; - IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, update)); - - var stored = await manager.GetPasskeyAsync(user, original.CredentialId); - Assert.NotNull(stored); - - // Mutable - Assert.Equal("Changed", stored.Name); - Assert.Equal((uint)3, stored.SignCount); - Assert.True(stored.IsBackedUp); - Assert.True(stored.IsUserVerified); - - // Immutable - Assert.Equal(original.PublicKey, stored.PublicKey); - Assert.Equal(original.CreatedAt, stored.CreatedAt); - Assert.Equal(original.IsBackupEligible, stored.IsBackupEligible); - Assert.Equal(original.AttestationObject, stored.AttestationObject); - Assert.Equal(original.ClientDataJson, stored.ClientDataJson); - Assert.Equal(original.Transports, stored.Transports); - } - - /// - /// Test. - /// - /// Task - [Fact] - public async Task RemovePasskeyIsIdempotent() - { - var context = CreateTestContext(); - var manager = CreateManager(context); - var user = CreateTestUser(); - IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); - - var pk = new UserPasskeyInfo(Guid.NewGuid().ToByteArray(), [1], DateTimeOffset.UtcNow, 0, null, false, false, false, [2], [3]); - IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, pk)); + 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.RemovePasskeyAsync(user, pk.CredentialId)); - Assert.Empty(await manager.GetPasskeysAsync(user)); + IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, updated)); - // Second removal should not throw or change anything - IdentityResultAssert.IsSuccess(await manager.RemovePasskeyAsync(user, pk.CredentialId)); - Assert.Empty(await manager.GetPasskeysAsync(user)); + var stored = await manager.GetPasskeyAsync(user, original.CredentialId); + AssertPasskeysEqual(expected, stored); } - /// - /// Test. - /// - /// Task - [Fact] - public async Task FindPasskeyReturnsNullForUnknownCredential() + private static void AssertPasskeysEqual(UserPasskeyInfo expected, UserPasskeyInfo actual) { - var context = CreateTestContext(); - var manager = CreateManager(context); - var user = CreateTestUser(); - IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); - - var pk = new UserPasskeyInfo(Guid.NewGuid().ToByteArray(), [7], DateTimeOffset.UtcNow, 0, null, false, false, false, [8], [9]); - IdentityResultAssert.IsSuccess(await manager.AddOrUpdatePasskeyAsync(user, pk)); + Assert.NotNull(expected); + Assert.NotNull(actual); - var randomId = Guid.NewGuid().ToByteArray(); - Assert.Null(await manager.GetPasskeyAsync(user, randomId)); + 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/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(); } From 4ab87622216e607b63cd861c6b894c85bbc34897 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 10 Oct 2025 12:11:12 -0700 Subject: [PATCH 3/3] Fix test --- .../test/EF.Test/Utilities/ScratchDatabaseFixture.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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;