From a447c740d590120b366665df83420f5d8a44c675 Mon Sep 17 00:00:00 2001
From: rr-bw <102181210+rr-bw@users.noreply.github.com>
Date: Tue, 22 Sep 2026 14:58:44 -0500
Subject: [PATCH 01/27] [PM-40673 Part 1] add TwoFactorRememberToken.cs and
ITwoFactorRememberTokenRepository.cs
---
.../Auth/Entities/TwoFactorRememberToken.cs | 62 +++++++++++++++++++
.../ITwoFactorRememberTokenRepository.cs | 42 +++++++++++++
2 files changed, 104 insertions(+)
create mode 100644 src/Core/Auth/Entities/TwoFactorRememberToken.cs
create mode 100644 src/Core/Auth/Repositories/ITwoFactorRememberTokenRepository.cs
diff --git a/src/Core/Auth/Entities/TwoFactorRememberToken.cs b/src/Core/Auth/Entities/TwoFactorRememberToken.cs
new file mode 100644
index 000000000000..c611cbbbe662
--- /dev/null
+++ b/src/Core/Auth/Entities/TwoFactorRememberToken.cs
@@ -0,0 +1,62 @@
+using System.ComponentModel.DataAnnotations;
+using Bit.Core.Entities;
+using Bit.Core.Utilities;
+
+namespace Bit.Core.Auth.Entities;
+
+///
+/// Server-side state backing a single remembered device's two-factor "remember me" token.
+/// One row per (, ).
+///
+///
+/// A token issued to a device carries a copy of that device's , and is honored only
+/// while the two match. Writing a new therefore invalidates every token previously
+/// issued for the device, leaving the user's other devices and their access and refresh tokens untouched.
+///
+public class TwoFactorRememberToken : ITableObject
+{
+ public Guid Id { get; set; }
+
+ ///
+ /// The owning user. Derivable through , but stored directly so that
+ /// revoking every remembered device for one user is an index seek.
+ ///
+ public Guid UserId { get; set; }
+
+ ///
+ /// The remembered device. Note this is Device.Id, not the client-supplied device identifier.
+ ///
+ public Guid DeviceId { get; set; }
+
+ ///
+ /// The value a presented token's stamp is compared against. Replaced to revoke.
+ ///
+ [MaxLength(50)]
+ public string Stamp { get; set; } = null!;
+
+ ///
+ /// When this device was first remembered. Preserved when the row is re-issued.
+ ///
+ public DateTime CreationDate { get; set; } = DateTime.UtcNow;
+
+ ///
+ /// When was last written, whether by re-issuance or by a revocation.
+ ///
+ public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
+
+ ///
+ /// When this row stops being honored, regardless of .
+ ///
+ public DateTime ExpirationDate { get; set; }
+
+ ///
+ /// Initializes to a new COMB GUID.
+ ///
+ public void SetNewId()
+ {
+ if (Id == default)
+ {
+ Id = CoreHelpers.GenerateComb();
+ }
+ }
+}
diff --git a/src/Core/Auth/Repositories/ITwoFactorRememberTokenRepository.cs b/src/Core/Auth/Repositories/ITwoFactorRememberTokenRepository.cs
new file mode 100644
index 000000000000..ebf93c7a6df0
--- /dev/null
+++ b/src/Core/Auth/Repositories/ITwoFactorRememberTokenRepository.cs
@@ -0,0 +1,42 @@
+using Bit.Core.Auth.Entities;
+
+namespace Bit.Core.Auth.Repositories;
+
+///
+/// Point lookups and scoped writes only, deliberately: no ad-hoc queries, so that every operation here
+/// is expressible against a partitioned document store if this ever needs a second implementation.
+///
+public interface ITwoFactorRememberTokenRepository
+{
+ ///
+ /// Reads the row for one remembered device, or null if the device has never been remembered.
+ ///
+ Task GetByUserIdDeviceIdAsync(Guid userId, Guid deviceId);
+
+ ///
+ /// Creates the row for this user and device, or updates the existing one in place,
+ /// preserving its CreationDate. The caller supplies a fresh Stamp, so any
+ /// token previously issued for this device stops validating.
+ ///
+ Task UpsertAsync(TwoFactorRememberToken token);
+
+ ///
+ /// Rotates the Stamp on every row for this user, invalidating all of their remember
+ /// tokens at once. Rows are kept.
+ ///
+ ///
+ /// The new stamp is generated here rather than by the caller: nothing above this layer needs to
+ /// know the value, and generating it in one place keeps every provider writing the same thing.
+ ///
+ Task RotateStampsByUserIdAsync(Guid userId);
+
+ ///
+ /// Deletes every row that expired before . Hygiene only — an expired row is
+ /// already refused at validation time.
+ ///
+ ///
+ /// The instant to compare against, supplied by the caller so that every provider and the stored
+ /// procedure agree on one value.
+ ///
+ Task DeleteExpiredAsync(DateTime now);
+}
From 272ccb5ce7a56e5d063245250e9dbd1efa2d1b8c Mon Sep 17 00:00:00 2001
From: rr-bw <102181210+rr-bw@users.noreply.github.com>
Date: Tue, 22 Sep 2026 19:55:33 -0500
Subject: [PATCH 02/27] [PM-40673 Part 1] add TwoFactorRememberToken.sql,
migrator script, stored procedures, Dapper infrastructure
---
.../TwoFactorRememberTokenRepository.cs | 89 +++++
.../DapperServiceCollectionExtensions.cs | 1 +
.../TwoFactorRememberToken_DeleteExpired.sql | 22 ++
...ctorRememberToken_ReadByUserIdDeviceId.sql | 15 +
.../TwoFactorRememberToken_Save.sql | 57 ++++
...RememberToken_UpdateManyStampsByUserId.sql | 24 ++
.../Auth/Tables/TwoFactorRememberToken.sql | 26 ++
.../Auth/Views/TwoFactorRememberTokenView.sql | 6 +
.../TwoFactorRememberTokenRepositoryTests.cs | 316 ++++++++++++++++++
...-09-22_00_CreateTwoFactorRememberToken.sql | 163 +++++++++
10 files changed, 719 insertions(+)
create mode 100644 src/Infrastructure.Dapper/Auth/Repositories/TwoFactorRememberTokenRepository.cs
create mode 100644 src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_DeleteExpired.sql
create mode 100644 src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_ReadByUserIdDeviceId.sql
create mode 100644 src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_Save.sql
create mode 100644 src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_UpdateManyStampsByUserId.sql
create mode 100644 src/Sql/dbo/Auth/Tables/TwoFactorRememberToken.sql
create mode 100644 src/Sql/dbo/Auth/Views/TwoFactorRememberTokenView.sql
create mode 100644 test/Infrastructure.IntegrationTest/Auth/Repositories/TwoFactorRememberTokenRepositoryTests.cs
create mode 100644 util/Migrator/DbScripts/2026-09-22_00_CreateTwoFactorRememberToken.sql
diff --git a/src/Infrastructure.Dapper/Auth/Repositories/TwoFactorRememberTokenRepository.cs b/src/Infrastructure.Dapper/Auth/Repositories/TwoFactorRememberTokenRepository.cs
new file mode 100644
index 000000000000..0f9031bbdbac
--- /dev/null
+++ b/src/Infrastructure.Dapper/Auth/Repositories/TwoFactorRememberTokenRepository.cs
@@ -0,0 +1,89 @@
+using System.Data;
+using Bit.Core.Auth.Entities;
+using Bit.Core.Auth.Repositories;
+using Bit.Core.Settings;
+using Bit.Infrastructure.Dapper.Repositories;
+using Dapper;
+using Microsoft.Data.SqlClient;
+
+namespace Bit.Infrastructure.Dapper.Auth.Repositories;
+
+///
+/// Derives from rather than the generic repository because every
+/// operation is a named procedure; none of them map onto generic CRUD.
+///
+public class TwoFactorRememberTokenRepository : BaseRepository, ITwoFactorRememberTokenRepository
+{
+ public TwoFactorRememberTokenRepository(GlobalSettings globalSettings)
+ : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
+ { }
+
+ public TwoFactorRememberTokenRepository(string connectionString, string readOnlyConnectionString)
+ : base(connectionString, readOnlyConnectionString)
+ { }
+
+ public async Task GetByUserIdDeviceIdAsync(Guid userId, Guid deviceId)
+ {
+ await using var connection = new SqlConnection(ConnectionString);
+
+ var results = await connection.QueryAsync(
+ "[dbo].[TwoFactorRememberToken_ReadByUserIdDeviceId]",
+ new { UserId = userId, DeviceId = deviceId },
+ commandType: CommandType.StoredProcedure);
+
+ return results.SingleOrDefault();
+ }
+
+ public async Task UpsertAsync(TwoFactorRememberToken token)
+ {
+ // BaseRepository does not assign ids the way the generic repository does, and the procedure
+ // needs one for the insert branch. SetNewId only fills an unset id, so an update is unaffected.
+ token.SetNewId();
+
+ await using var connection = new SqlConnection(ConnectionString);
+
+ await connection.ExecuteAsync(
+ "[dbo].[TwoFactorRememberToken_Save]",
+ new
+ {
+ token.Id,
+ token.UserId,
+ token.DeviceId,
+ token.Stamp,
+ token.CreationDate,
+ token.RevisionDate,
+ token.ExpirationDate,
+ },
+ commandType: CommandType.StoredProcedure);
+
+ return token;
+ }
+
+ public async Task RotateStampsByUserIdAsync(Guid userId)
+ {
+ await using var connection = new SqlConnection(ConnectionString);
+
+ await connection.ExecuteAsync(
+ "[dbo].[TwoFactorRememberToken_UpdateManyStampsByUserId]",
+ new
+ {
+ UserId = userId,
+ // Generated here rather than with NEWID() so that this and the Entity Framework
+ // implementations write the same shape of value. One stamp covers every row for the
+ // user; see the procedure for why that is safe.
+ Stamp = Guid.NewGuid().ToString(),
+ RevisionDate = DateTime.UtcNow,
+ },
+ commandType: CommandType.StoredProcedure);
+ }
+
+ public async Task DeleteExpiredAsync(DateTime now)
+ {
+ await using var connection = new SqlConnection(ConnectionString);
+
+ await connection.ExecuteAsync(
+ "[dbo].[TwoFactorRememberToken_DeleteExpired]",
+ new { Now = now },
+ commandType: CommandType.StoredProcedure);
+ }
+}
diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs
index 8b4061620bd2..1670e394b67a 100644
--- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs
+++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs
@@ -72,6 +72,7 @@ public static void AddDapperRepositories(this IServiceCollection services, bool
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_DeleteExpired.sql b/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_DeleteExpired.sql
new file mode 100644
index 000000000000..6437ec29291f
--- /dev/null
+++ b/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_DeleteExpired.sql
@@ -0,0 +1,22 @@
+CREATE PROCEDURE [dbo].[TwoFactorRememberToken_DeleteExpired]
+ @Now DATETIME2(7)
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ -- Deleted in batches so each statement holds few enough locks to avoid escalating to a table
+ -- lock, which would block logins while the sweep runs. The loop ends when a batch deletes
+ -- nothing.
+ DECLARE @BatchSize INT = 100
+
+ WHILE @BatchSize > 0
+ BEGIN
+ DELETE TOP(@BatchSize)
+ FROM
+ [dbo].[TwoFactorRememberToken]
+ WHERE
+ [ExpirationDate] < @Now
+
+ SET @BatchSize = @@ROWCOUNT
+ END
+END
diff --git a/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_ReadByUserIdDeviceId.sql b/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_ReadByUserIdDeviceId.sql
new file mode 100644
index 000000000000..f3b714f38cfa
--- /dev/null
+++ b/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_ReadByUserIdDeviceId.sql
@@ -0,0 +1,15 @@
+CREATE PROCEDURE [dbo].[TwoFactorRememberToken_ReadByUserIdDeviceId]
+ @UserId UNIQUEIDENTIFIER,
+ @DeviceId UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT
+ *
+ FROM
+ [dbo].[TwoFactorRememberTokenView]
+ WHERE
+ [UserId] = @UserId
+ AND [DeviceId] = @DeviceId
+END
diff --git a/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_Save.sql b/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_Save.sql
new file mode 100644
index 000000000000..ae28d19ad847
--- /dev/null
+++ b/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_Save.sql
@@ -0,0 +1,57 @@
+CREATE PROCEDURE [dbo].[TwoFactorRememberToken_Save]
+ @Id UNIQUEIDENTIFIER,
+ @UserId UNIQUEIDENTIFIER,
+ @DeviceId UNIQUEIDENTIFIER,
+ @Stamp NVARCHAR(50),
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7),
+ @ExpirationDate DATETIME2(7)
+AS
+BEGIN
+ SET NOCOUNT ON
+ -- An explicit transaction is required so the range lock below is still held when the INSERT
+ -- runs; XACT_ABORT leaves the pooled connection clean if the unique-index backstop trips.
+ SET XACT_ABORT ON
+
+ BEGIN TRANSACTION
+
+ -- One row per (UserId, DeviceId). The UPDLOCK, HOLDLOCK range lock serializes a concurrent
+ -- remember-login on the same device: it blocks here until this transaction commits, then
+ -- finds the row and takes the UPDATE branch instead of inserting a duplicate. The lock is
+ -- cheap because [IX_TwoFactorRememberToken_UserId_DeviceId] makes it a key-range lock
+ -- rather than a scan. CreationDate is deliberately not updated.
+ UPDATE [dbo].[TwoFactorRememberToken] WITH (UPDLOCK, HOLDLOCK)
+ SET
+ [Stamp] = @Stamp,
+ [RevisionDate] = @RevisionDate,
+ [ExpirationDate] = @ExpirationDate
+ WHERE
+ [UserId] = @UserId
+ AND [DeviceId] = @DeviceId
+
+ IF @@ROWCOUNT = 0
+ BEGIN
+ INSERT INTO [dbo].[TwoFactorRememberToken]
+ (
+ [Id],
+ [UserId],
+ [DeviceId],
+ [Stamp],
+ [CreationDate],
+ [RevisionDate],
+ [ExpirationDate]
+ )
+ VALUES
+ (
+ @Id,
+ @UserId,
+ @DeviceId,
+ @Stamp,
+ @CreationDate,
+ @RevisionDate,
+ @ExpirationDate
+ )
+ END
+
+ COMMIT TRANSACTION
+END
diff --git a/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_UpdateManyStampsByUserId.sql b/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_UpdateManyStampsByUserId.sql
new file mode 100644
index 000000000000..2708880469b3
--- /dev/null
+++ b/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_UpdateManyStampsByUserId.sql
@@ -0,0 +1,24 @@
+CREATE PROCEDURE [dbo].[TwoFactorRememberToken_UpdateManyStampsByUserId]
+ @UserId UNIQUEIDENTIFIER,
+ @Stamp NVARCHAR(50),
+ @RevisionDate DATETIME2(7)
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ -- Revokes every remember token this user holds by replacing the value each of their tokens is
+ -- compared against. The rows are kept, so CreationDate still records when each device was first
+ -- remembered and RevisionDate now records when it was cut off.
+ --
+ -- One stamp is shared across the user's rows, and the caller supplies it. Rows are located by
+ -- (UserId, DeviceId), so the stamp never selects a row and a token naming one device can never
+ -- match another's. Generating it in application code also keeps this procedure and the Entity
+ -- Framework implementations writing identical values.
+ UPDATE
+ [dbo].[TwoFactorRememberToken]
+ SET
+ [Stamp] = @Stamp,
+ [RevisionDate] = @RevisionDate
+ WHERE
+ [UserId] = @UserId
+END
diff --git a/src/Sql/dbo/Auth/Tables/TwoFactorRememberToken.sql b/src/Sql/dbo/Auth/Tables/TwoFactorRememberToken.sql
new file mode 100644
index 000000000000..74044176041c
--- /dev/null
+++ b/src/Sql/dbo/Auth/Tables/TwoFactorRememberToken.sql
@@ -0,0 +1,26 @@
+CREATE TABLE [dbo].[TwoFactorRememberToken] (
+ [Id] UNIQUEIDENTIFIER NOT NULL,
+ [UserId] UNIQUEIDENTIFIER NOT NULL,
+ [DeviceId] UNIQUEIDENTIFIER NOT NULL,
+ [Stamp] NVARCHAR(50) NOT NULL,
+ [CreationDate] DATETIME2(7) NOT NULL,
+ [RevisionDate] DATETIME2(7) NOT NULL,
+ [ExpirationDate] DATETIME2(7) NOT NULL,
+ CONSTRAINT [PK_TwoFactorRememberToken] PRIMARY KEY CLUSTERED ([Id] ASC),
+ CONSTRAINT [FK_TwoFactorRememberToken_User] FOREIGN KEY ([UserId])
+ REFERENCES [dbo].[User] ([Id]),
+ CONSTRAINT [FK_TwoFactorRememberToken_Device] FOREIGN KEY ([DeviceId])
+ REFERENCES [dbo].[Device] ([Id]) ON DELETE CASCADE
+);
+
+GO
+CREATE UNIQUE NONCLUSTERED INDEX [IX_TwoFactorRememberToken_UserId_DeviceId]
+ ON [dbo].[TwoFactorRememberToken]([UserId] ASC, [DeviceId] ASC);
+
+GO
+CREATE NONCLUSTERED INDEX [IX_TwoFactorRememberToken_DeviceId]
+ ON [dbo].[TwoFactorRememberToken]([DeviceId] ASC);
+
+GO
+CREATE NONCLUSTERED INDEX [IX_TwoFactorRememberToken_ExpirationDate]
+ ON [dbo].[TwoFactorRememberToken]([ExpirationDate] ASC);
diff --git a/src/Sql/dbo/Auth/Views/TwoFactorRememberTokenView.sql b/src/Sql/dbo/Auth/Views/TwoFactorRememberTokenView.sql
new file mode 100644
index 000000000000..d0f04a1e905a
--- /dev/null
+++ b/src/Sql/dbo/Auth/Views/TwoFactorRememberTokenView.sql
@@ -0,0 +1,6 @@
+CREATE VIEW [dbo].[TwoFactorRememberTokenView]
+AS
+SELECT
+ *
+FROM
+ [dbo].[TwoFactorRememberToken]
diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/TwoFactorRememberTokenRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/TwoFactorRememberTokenRepositoryTests.cs
new file mode 100644
index 000000000000..d4a3f838ea9b
--- /dev/null
+++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/TwoFactorRememberTokenRepositoryTests.cs
@@ -0,0 +1,316 @@
+using Bit.Core.Auth.Entities;
+using Bit.Core.Auth.Repositories;
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Repositories;
+using Bit.Infrastructure.EntityFramework.Repositories;
+using Bit.Infrastructure.IntegrationTest.AdminConsole;
+using Microsoft.Data.SqlClient;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Bit.Infrastructure.IntegrationTest.Auth.Repositories;
+
+public class TwoFactorRememberTokenRepositoryTests
+{
+ // -------------------------------------------------------------------------------------------
+ // Test helpers
+ // -------------------------------------------------------------------------------------------
+
+ private static Task CreateTestDeviceAsync(IDeviceRepository deviceRepository, Guid userId) =>
+ deviceRepository.CreateAsync(new Device
+ {
+ UserId = userId,
+ Name = "chrome-test",
+ Type = DeviceType.ChromeBrowser,
+ Identifier = Guid.NewGuid().ToString(),
+ Active = true,
+ LastActivityDate = DateTime.UtcNow,
+ });
+
+ private static TwoFactorRememberToken NewToken(
+ Guid userId,
+ Guid deviceId,
+ string stamp,
+ DateTime? expirationDate = null) =>
+ new()
+ {
+ UserId = userId,
+ DeviceId = deviceId,
+ Stamp = stamp,
+ ExpirationDate = expirationDate ?? DateTime.UtcNow.AddDays(30),
+ };
+
+ ///
+ /// Deletes a device row at the database level, bypassing repository code entirely.
+ ///
+ ///
+ /// There is no Device_DeleteById stored procedure, so the Dapper DeviceRepository
+ /// cannot delete. More importantly, the claim under test is about a database-level cascade, so
+ /// the delete has to reach the database without EF resolving the cascade in memory first —
+ /// hence ExecuteDeleteAsync rather than Remove plus SaveChanges.
+ ///
+ private static async Task DeleteDeviceAtDatabaseLevelAsync(
+ IServiceProvider services, Database database, Guid deviceId)
+ {
+ if (database.Type == SupportedDatabaseProviders.SqlServer && !database.UseEf)
+ {
+ await using var connection = new SqlConnection(database.ConnectionString);
+ await connection.OpenAsync();
+ await using var command = connection.CreateCommand();
+ command.CommandText = "DELETE FROM [dbo].[Device] WHERE [Id] = @Id";
+ command.Parameters.Add(new SqlParameter("@Id", deviceId));
+ await command.ExecuteNonQueryAsync();
+ return;
+ }
+
+ using var scope = services.CreateScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+ await dbContext.Devices.Where(d => d.Id == deviceId).ExecuteDeleteAsync();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // R1 — upsert semantics
+ // -------------------------------------------------------------------------------------------
+
+ [Theory, DatabaseData]
+ public async Task UpsertAsync_SameUserAndDevice_UpdatesInPlaceAndPreservesCreationDate(
+ ITwoFactorRememberTokenRepository sut,
+ IUserRepository userRepository,
+ IDeviceRepository deviceRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var device = await CreateTestDeviceAsync(deviceRepository, user.Id);
+
+ await sut.UpsertAsync(NewToken(user.Id, device.Id, "stamp-one"));
+ var inserted = await sut.GetByUserIdDeviceIdAsync(user.Id, device.Id);
+
+ Assert.NotNull(inserted);
+ Assert.NotEqual(Guid.Empty, inserted.Id);
+ Assert.Equal("stamp-one", inserted.Stamp);
+
+ await sut.UpsertAsync(NewToken(user.Id, device.Id, "stamp-two"));
+ var updated = await sut.GetByUserIdDeviceIdAsync(user.Id, device.Id);
+
+ Assert.NotNull(updated);
+ Assert.Equal("stamp-two", updated.Stamp);
+ // Same row rather than a second one, and the row remembers when the device was first trusted.
+ Assert.Equal(inserted.Id, updated.Id);
+ Assert.Equal(inserted.CreationDate, updated.CreationDate);
+ Assert.True(updated.RevisionDate >= inserted.RevisionDate);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // R2 — concurrent upsert
+ // -------------------------------------------------------------------------------------------
+
+ ///
+ /// Two logins requesting "remember me" for the same device at the same instant must not produce a
+ /// unique-key violation, which would surface to the user as a failed login.
+ ///
+ ///
+ /// On SQL Server this exercises the UPDLOCK, HOLDLOCK range lock inside an explicit
+ /// transaction. That hint is MSSQL-only, so on the EF providers this is a regression guard on the
+ /// common path rather than proof the lock works.
+ ///
+ [Theory, DatabaseData]
+ public async Task UpsertAsync_Concurrent_BothCompleteAndOneRowSurvives(
+ ITwoFactorRememberTokenRepository sut,
+ IUserRepository userRepository,
+ IDeviceRepository deviceRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var device = await CreateTestDeviceAsync(deviceRepository, user.Id);
+
+ await Task.WhenAll(
+ sut.UpsertAsync(NewToken(user.Id, device.Id, "stamp-a")),
+ sut.UpsertAsync(NewToken(user.Id, device.Id, "stamp-b")));
+
+ var row = await sut.GetByUserIdDeviceIdAsync(user.Id, device.Id);
+
+ Assert.NotNull(row);
+ Assert.Contains(row.Stamp, new[] { "stamp-a", "stamp-b" });
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // R3, R4 — rotation
+ // -------------------------------------------------------------------------------------------
+
+ [Theory, DatabaseData]
+ public async Task RotateStampsByUserIdAsync_RotatesEveryRowForThatUser(
+ ITwoFactorRememberTokenRepository sut,
+ IUserRepository userRepository,
+ IDeviceRepository deviceRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var deviceOne = await CreateTestDeviceAsync(deviceRepository, user.Id);
+ var deviceTwo = await CreateTestDeviceAsync(deviceRepository, user.Id);
+
+ await sut.UpsertAsync(NewToken(user.Id, deviceOne.Id, "stamp-one"));
+ await sut.UpsertAsync(NewToken(user.Id, deviceTwo.Id, "stamp-two"));
+
+ var beforeOne = await sut.GetByUserIdDeviceIdAsync(user.Id, deviceOne.Id);
+ var beforeTwo = await sut.GetByUserIdDeviceIdAsync(user.Id, deviceTwo.Id);
+ Assert.NotNull(beforeOne);
+ Assert.NotNull(beforeTwo);
+
+ await sut.RotateStampsByUserIdAsync(user.Id);
+
+ var afterOne = await sut.GetByUserIdDeviceIdAsync(user.Id, deviceOne.Id);
+ var afterTwo = await sut.GetByUserIdDeviceIdAsync(user.Id, deviceTwo.Id);
+
+ Assert.NotNull(afterOne);
+ Assert.NotNull(afterTwo);
+
+ // Every device the user remembered is cut off, and the rows are kept rather than deleted.
+ Assert.NotEqual(beforeOne.Stamp, afterOne.Stamp);
+ Assert.NotEqual(beforeTwo.Stamp, afterTwo.Stamp);
+ Assert.Equal(beforeOne.Id, afterOne.Id);
+ Assert.Equal(beforeOne.CreationDate, afterOne.CreationDate);
+ Assert.True(afterOne.RevisionDate >= beforeOne.RevisionDate);
+
+ // Stamps are generated in C#, never by NEWID(), which would emit upper-case hex.
+ Assert.Equal(afterOne.Stamp.ToLowerInvariant(), afterOne.Stamp);
+ Assert.Equal(afterTwo.Stamp.ToLowerInvariant(), afterTwo.Stamp);
+ }
+
+ [Theory, DatabaseData]
+ public async Task RotateStampsByUserIdAsync_LeavesOtherUsersAlone(
+ ITwoFactorRememberTokenRepository sut,
+ IUserRepository userRepository,
+ IDeviceRepository deviceRepository)
+ {
+ var revokedUser = await userRepository.CreateTestUserAsync("revoked");
+ var untouchedUser = await userRepository.CreateTestUserAsync("untouched");
+ var revokedDevice = await CreateTestDeviceAsync(deviceRepository, revokedUser.Id);
+ var untouchedDevice = await CreateTestDeviceAsync(deviceRepository, untouchedUser.Id);
+
+ await sut.UpsertAsync(NewToken(revokedUser.Id, revokedDevice.Id, "stamp-revoked"));
+ await sut.UpsertAsync(NewToken(untouchedUser.Id, untouchedDevice.Id, "stamp-untouched"));
+
+ await sut.RotateStampsByUserIdAsync(revokedUser.Id);
+
+ var untouched = await sut.GetByUserIdDeviceIdAsync(untouchedUser.Id, untouchedDevice.Id);
+
+ Assert.NotNull(untouched);
+ Assert.Equal("stamp-untouched", untouched.Stamp);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // R5, R6 — cascade behavior
+ // -------------------------------------------------------------------------------------------
+
+ [Theory, DatabaseData]
+ public async Task DeletingDevice_CascadesToTokenRow(
+ ITwoFactorRememberTokenRepository sut,
+ IUserRepository userRepository,
+ IDeviceRepository deviceRepository,
+ Database database,
+ IServiceProvider services)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var device = await CreateTestDeviceAsync(deviceRepository, user.Id);
+ await sut.UpsertAsync(NewToken(user.Id, device.Id, "stamp"));
+
+ Assert.NotNull(await sut.GetByUserIdDeviceIdAsync(user.Id, device.Id));
+
+ await DeleteDeviceAtDatabaseLevelAsync(services, database, device.Id);
+
+ Assert.Null(await sut.GetByUserIdDeviceIdAsync(user.Id, device.Id));
+ }
+
+ ///
+ /// Nothing deletes these rows by UserId. They survive a user deletion only because both
+ /// user-delete paths delete the user's Device rows first and the cascade follows.
+ ///
+ [Theory, DatabaseData]
+ public async Task DeleteAsync_User_RemovesTokenRows(
+ ITwoFactorRememberTokenRepository sut,
+ IUserRepository userRepository,
+ IDeviceRepository deviceRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var device = await CreateTestDeviceAsync(deviceRepository, user.Id);
+ await sut.UpsertAsync(NewToken(user.Id, device.Id, "stamp"));
+
+ await userRepository.DeleteAsync(user);
+
+ Assert.Null(await sut.GetByUserIdDeviceIdAsync(user.Id, device.Id));
+ }
+
+ ///
+ /// The bulk path takes different code than on EF —
+ /// ExecuteDeleteAsync bypasses change tracking, so it depends on the database-level cascade
+ /// actually existing rather than on EF resolving it.
+ ///
+ [Theory, DatabaseData]
+ public async Task DeleteManyAsync_Users_RemovesTokenRows(
+ ITwoFactorRememberTokenRepository sut,
+ IUserRepository userRepository,
+ IDeviceRepository deviceRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var device = await CreateTestDeviceAsync(deviceRepository, user.Id);
+ await sut.UpsertAsync(NewToken(user.Id, device.Id, "stamp"));
+
+ await userRepository.DeleteManyAsync(new[] { user });
+
+ Assert.Null(await sut.GetByUserIdDeviceIdAsync(user.Id, device.Id));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // R7 — expiry sweep
+ // -------------------------------------------------------------------------------------------
+
+ [Theory, DatabaseData]
+ public async Task DeleteExpiredAsync_RemovesOnlyExpiredRows(
+ ITwoFactorRememberTokenRepository sut,
+ IUserRepository userRepository,
+ IDeviceRepository deviceRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var expiredDevice = await CreateTestDeviceAsync(deviceRepository, user.Id);
+ var liveDevice = await CreateTestDeviceAsync(deviceRepository, user.Id);
+
+ // A fixed instant the two rows straddle, so the sweep is deterministic rather than a race
+ // against the wall clock.
+ var now = DateTime.UtcNow;
+
+ await sut.UpsertAsync(NewToken(user.Id, expiredDevice.Id, "expired", now.AddMinutes(-1)));
+ await sut.UpsertAsync(NewToken(user.Id, liveDevice.Id, "live", now.AddMinutes(1)));
+
+ await sut.DeleteExpiredAsync(now);
+
+ Assert.Null(await sut.GetByUserIdDeviceIdAsync(user.Id, expiredDevice.Id));
+ Assert.NotNull(await sut.GetByUserIdDeviceIdAsync(user.Id, liveDevice.Id));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // R8 — storage fidelity
+ // -------------------------------------------------------------------------------------------
+
+ ///
+ /// Cheap guard against a column width or collation mistake on any one provider: the stamp is
+ /// compared with ordinal case sensitivity at validation time, so it has to come back byte-identical.
+ ///
+ [Theory, DatabaseData]
+ public async Task UpsertAsync_StampRoundTripsUnchanged(
+ ITwoFactorRememberTokenRepository sut,
+ IUserRepository userRepository,
+ IDeviceRepository deviceRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var device = await CreateTestDeviceAsync(deviceRepository, user.Id);
+ var stamp = Guid.NewGuid().ToString();
+
+ await sut.UpsertAsync(NewToken(user.Id, device.Id, stamp));
+
+ var row = await sut.GetByUserIdDeviceIdAsync(user.Id, device.Id);
+
+ Assert.NotNull(row);
+ Assert.Equal(stamp, row.Stamp, StringComparer.Ordinal);
+ Assert.Equal(user.Id, row.UserId);
+ Assert.Equal(device.Id, row.DeviceId);
+ }
+}
diff --git a/util/Migrator/DbScripts/2026-09-22_00_CreateTwoFactorRememberToken.sql b/util/Migrator/DbScripts/2026-09-22_00_CreateTwoFactorRememberToken.sql
new file mode 100644
index 000000000000..47c10e9e928b
--- /dev/null
+++ b/util/Migrator/DbScripts/2026-09-22_00_CreateTwoFactorRememberToken.sql
@@ -0,0 +1,163 @@
+-- Table
+IF OBJECT_ID('[dbo].[TwoFactorRememberToken]') IS NULL
+BEGIN
+ CREATE TABLE [dbo].[TwoFactorRememberToken]
+ (
+ [Id] UNIQUEIDENTIFIER NOT NULL,
+ [UserId] UNIQUEIDENTIFIER NOT NULL,
+ [DeviceId] UNIQUEIDENTIFIER NOT NULL,
+ [Stamp] NVARCHAR(50) NOT NULL,
+ [CreationDate] DATETIME2(7) NOT NULL,
+ [RevisionDate] DATETIME2(7) NOT NULL,
+ [ExpirationDate] DATETIME2(7) NOT NULL,
+ CONSTRAINT [PK_TwoFactorRememberToken] PRIMARY KEY CLUSTERED ([Id] ASC),
+ CONSTRAINT [FK_TwoFactorRememberToken_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]),
+ CONSTRAINT [FK_TwoFactorRememberToken_Device] FOREIGN KEY ([DeviceId]) REFERENCES [dbo].[Device] ([Id]) ON DELETE CASCADE
+ );
+
+ CREATE UNIQUE NONCLUSTERED INDEX [IX_TwoFactorRememberToken_UserId_DeviceId]
+ ON [dbo].[TwoFactorRememberToken]([UserId] ASC, [DeviceId] ASC);
+
+ CREATE NONCLUSTERED INDEX [IX_TwoFactorRememberToken_DeviceId]
+ ON [dbo].[TwoFactorRememberToken]([DeviceId] ASC);
+
+ CREATE NONCLUSTERED INDEX [IX_TwoFactorRememberToken_ExpirationDate]
+ ON [dbo].[TwoFactorRememberToken]([ExpirationDate] ASC);
+END
+GO
+
+-- View
+CREATE OR ALTER VIEW [dbo].[TwoFactorRememberTokenView]
+AS
+SELECT
+ *
+FROM
+ [dbo].[TwoFactorRememberToken]
+GO
+
+-- Stored Procedures
+CREATE OR ALTER PROCEDURE [dbo].[TwoFactorRememberToken_Save]
+ @Id UNIQUEIDENTIFIER,
+ @UserId UNIQUEIDENTIFIER,
+ @DeviceId UNIQUEIDENTIFIER,
+ @Stamp NVARCHAR(50),
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7),
+ @ExpirationDate DATETIME2(7)
+AS
+BEGIN
+ SET NOCOUNT ON
+ -- An explicit transaction is required so the range lock below is still held when the INSERT
+ -- runs; XACT_ABORT leaves the pooled connection clean if the unique-index backstop trips.
+ SET XACT_ABORT ON
+
+ BEGIN TRANSACTION
+
+ -- One row per (UserId, DeviceId). The UPDLOCK, HOLDLOCK range lock serializes a concurrent
+ -- remember-login on the same device: it blocks here until this transaction commits, then
+ -- finds the row and takes the UPDATE branch instead of inserting a duplicate. The lock is
+ -- cheap because [IX_TwoFactorRememberToken_UserId_DeviceId] makes it a key-range lock
+ -- rather than a scan. CreationDate is deliberately not updated.
+ UPDATE [dbo].[TwoFactorRememberToken] WITH (UPDLOCK, HOLDLOCK)
+ SET
+ [Stamp] = @Stamp,
+ [RevisionDate] = @RevisionDate,
+ [ExpirationDate] = @ExpirationDate
+ WHERE
+ [UserId] = @UserId
+ AND [DeviceId] = @DeviceId
+
+ IF @@ROWCOUNT = 0
+ BEGIN
+ INSERT INTO [dbo].[TwoFactorRememberToken]
+ (
+ [Id],
+ [UserId],
+ [DeviceId],
+ [Stamp],
+ [CreationDate],
+ [RevisionDate],
+ [ExpirationDate]
+ )
+ VALUES
+ (
+ @Id,
+ @UserId,
+ @DeviceId,
+ @Stamp,
+ @CreationDate,
+ @RevisionDate,
+ @ExpirationDate
+ )
+ END
+
+ COMMIT TRANSACTION
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[TwoFactorRememberToken_ReadByUserIdDeviceId]
+ @UserId UNIQUEIDENTIFIER,
+ @DeviceId UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT
+ *
+ FROM
+ [dbo].[TwoFactorRememberTokenView]
+ WHERE
+ [UserId] = @UserId
+ AND [DeviceId] = @DeviceId
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[TwoFactorRememberToken_UpdateManyStampsByUserId]
+ @UserId UNIQUEIDENTIFIER,
+ @Stamp NVARCHAR(50),
+ @RevisionDate DATETIME2(7)
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ -- Revokes every remember token this user holds by replacing the value each of their tokens is
+ -- compared against. The rows are kept, so CreationDate still records when each device was first
+ -- remembered and RevisionDate now records when it was cut off.
+ --
+ -- One stamp is shared across the user's rows, and the caller supplies it. Rows are located by
+ -- (UserId, DeviceId), so the stamp never selects a row and a token naming one device can never
+ -- match another's. Generating it in application code also keeps this procedure and the Entity
+ -- Framework implementations writing identical values.
+ UPDATE
+ [dbo].[TwoFactorRememberToken]
+ SET
+ [Stamp] = @Stamp,
+ [RevisionDate] = @RevisionDate
+ WHERE
+ [UserId] = @UserId
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[TwoFactorRememberToken_DeleteExpired]
+ @Now DATETIME2(7)
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ -- Deleted in batches so each statement holds few enough locks to avoid escalating to a table
+ -- lock, which would block logins while the sweep runs. The loop ends when a batch deletes
+ -- nothing.
+ DECLARE @BatchSize INT = 100
+
+ WHILE @BatchSize > 0
+ BEGIN
+ DELETE TOP(@BatchSize)
+ FROM
+ [dbo].[TwoFactorRememberToken]
+ WHERE
+ [ExpirationDate] < @Now
+
+ SET @BatchSize = @@ROWCOUNT
+ END
+END
+GO
From 18a82a306a3d98c94d3a277a09c170df81e7087e Mon Sep 17 00:00:00 2001
From: rr-bw <102181210+rr-bw@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:09:51 -0500
Subject: [PATCH 03/27] [PM-40673 Part 1] add Entity Framework infrastructure
---
...torRememberTokenEntityTypeConfiguration.cs | 48 +
.../Auth/Models/TwoFactorRememberToken.cs | 18 +
.../TwoFactorRememberTokenRepository.cs | 83 +
...ityFrameworkServiceCollectionExtensions.cs | 1 +
.../Repositories/DatabaseContext.cs | 1 +
.../TwoFactorRememberTokenRepositoryTests.cs | 65 +
...1_CreateTwoFactorRememberToken.Designer.cs | 4179 ++++++++++++++++
...0923010021_CreateTwoFactorRememberToken.cs | 68 +
.../DatabaseContextModelSnapshot.cs | 59 +
...1_CreateTwoFactorRememberToken.Designer.cs | 4185 +++++++++++++++++
...0923010041_CreateTwoFactorRememberToken.cs | 66 +
.../DatabaseContextModelSnapshot.cs | 59 +
...5_CreateTwoFactorRememberToken.Designer.cs | 4168 ++++++++++++++++
...0923010055_CreateTwoFactorRememberToken.cs | 66 +
.../DatabaseContextModelSnapshot.cs | 59 +
15 files changed, 13125 insertions(+)
create mode 100644 src/Infrastructure.EntityFramework/Auth/Configurations/TwoFactorRememberTokenEntityTypeConfiguration.cs
create mode 100644 src/Infrastructure.EntityFramework/Auth/Models/TwoFactorRememberToken.cs
create mode 100644 src/Infrastructure.EntityFramework/Auth/Repositories/TwoFactorRememberTokenRepository.cs
create mode 100644 util/MySqlMigrations/Migrations/20260923010021_CreateTwoFactorRememberToken.Designer.cs
create mode 100644 util/MySqlMigrations/Migrations/20260923010021_CreateTwoFactorRememberToken.cs
create mode 100644 util/PostgresMigrations/Migrations/20260923010041_CreateTwoFactorRememberToken.Designer.cs
create mode 100644 util/PostgresMigrations/Migrations/20260923010041_CreateTwoFactorRememberToken.cs
create mode 100644 util/SqliteMigrations/Migrations/20260923010055_CreateTwoFactorRememberToken.Designer.cs
create mode 100644 util/SqliteMigrations/Migrations/20260923010055_CreateTwoFactorRememberToken.cs
diff --git a/src/Infrastructure.EntityFramework/Auth/Configurations/TwoFactorRememberTokenEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/Auth/Configurations/TwoFactorRememberTokenEntityTypeConfiguration.cs
new file mode 100644
index 000000000000..7a42eff15df7
--- /dev/null
+++ b/src/Infrastructure.EntityFramework/Auth/Configurations/TwoFactorRememberTokenEntityTypeConfiguration.cs
@@ -0,0 +1,48 @@
+using Bit.Infrastructure.EntityFramework.Auth.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Bit.Infrastructure.EntityFramework.Auth.Configurations;
+
+public class TwoFactorRememberTokenEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder
+ .Property(e => e.Id)
+ .ValueGeneratedNever();
+
+ // Enforces one row per remembered device, serves the point lookup on validation, and — because
+ // UserId leads — also serves the revoke-everything-for-this-user write.
+ builder
+ .HasIndex(e => new { e.UserId, e.DeviceId })
+ .IsUnique()
+ .IsClustered(false);
+
+ // Foreign keys are not indexed automatically, and the cascade below needs a seek on DeviceId.
+ // The composite index above cannot serve it because DeviceId is not its leading column.
+ builder
+ .HasIndex(e => e.DeviceId)
+ .IsClustered(false);
+
+ builder
+ .HasIndex(e => e.ExpirationDate)
+ .IsClustered(false);
+
+ // Must match the MSSQL schema. The bulk user-delete path uses ExecuteDeleteAsync on Device,
+ // which bypasses change tracking, so it relies on this cascade existing in the database.
+ builder
+ .HasOne(e => e.Device)
+ .WithMany()
+ .HasForeignKey(e => e.DeviceId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder
+ .HasOne(e => e.User)
+ .WithMany()
+ .HasForeignKey(e => e.UserId)
+ .OnDelete(DeleteBehavior.NoAction);
+
+ builder.ToTable(nameof(TwoFactorRememberToken));
+ }
+}
diff --git a/src/Infrastructure.EntityFramework/Auth/Models/TwoFactorRememberToken.cs b/src/Infrastructure.EntityFramework/Auth/Models/TwoFactorRememberToken.cs
new file mode 100644
index 000000000000..635c20dcbe44
--- /dev/null
+++ b/src/Infrastructure.EntityFramework/Auth/Models/TwoFactorRememberToken.cs
@@ -0,0 +1,18 @@
+using AutoMapper;
+
+namespace Bit.Infrastructure.EntityFramework.Auth.Models;
+
+public class TwoFactorRememberToken : Core.Auth.Entities.TwoFactorRememberToken
+{
+ public virtual Bit.Infrastructure.EntityFramework.Models.User User { get; set; } = null!;
+ public virtual Bit.Infrastructure.EntityFramework.Models.Device Device { get; set; } = null!;
+}
+
+public class TwoFactorRememberTokenMapperProfile : Profile
+{
+ public TwoFactorRememberTokenMapperProfile()
+ {
+ CreateMap()
+ .ReverseMap();
+ }
+}
diff --git a/src/Infrastructure.EntityFramework/Auth/Repositories/TwoFactorRememberTokenRepository.cs b/src/Infrastructure.EntityFramework/Auth/Repositories/TwoFactorRememberTokenRepository.cs
new file mode 100644
index 000000000000..5ddf8bca145f
--- /dev/null
+++ b/src/Infrastructure.EntityFramework/Auth/Repositories/TwoFactorRememberTokenRepository.cs
@@ -0,0 +1,83 @@
+using AutoMapper;
+using Bit.Core.Auth.Repositories;
+using Bit.Infrastructure.EntityFramework.Repositories;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Bit.Infrastructure.EntityFramework.Auth.Repositories;
+
+public class TwoFactorRememberTokenRepository : BaseEntityFrameworkRepository, ITwoFactorRememberTokenRepository
+{
+ public TwoFactorRememberTokenRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
+ : base(serviceScopeFactory, mapper)
+ { }
+
+ public async Task GetByUserIdDeviceIdAsync(Guid userId, Guid deviceId)
+ {
+ using var scope = ServiceScopeFactory.CreateScope();
+ var dbContext = GetDatabaseContext(scope);
+
+ return await dbContext.TwoFactorRememberTokens
+ .AsNoTracking()
+ .FirstOrDefaultAsync(t => t.UserId == userId && t.DeviceId == deviceId);
+ }
+
+ public async Task UpsertAsync(
+ Core.Auth.Entities.TwoFactorRememberToken token)
+ {
+ using var scope = ServiceScopeFactory.CreateScope();
+ var dbContext = GetDatabaseContext(scope);
+
+ var existing = await dbContext.TwoFactorRememberTokens
+ .FirstOrDefaultAsync(t => t.UserId == token.UserId && t.DeviceId == token.DeviceId);
+
+ if (existing != null)
+ {
+ // CreationDate is deliberately left alone so the row still records when the device was
+ // first remembered, matching the MSSQL procedure.
+ existing.Stamp = token.Stamp;
+ existing.RevisionDate = token.RevisionDate;
+ existing.ExpirationDate = token.ExpirationDate;
+ await dbContext.SaveChangesAsync();
+
+ token.Id = existing.Id;
+ token.CreationDate = existing.CreationDate;
+ return token;
+ }
+
+ token.SetNewId();
+ var entity = Mapper.Map(token);
+ await dbContext.AddAsync(entity);
+ await dbContext.SaveChangesAsync();
+
+ return token;
+ }
+
+ public async Task RotateStampsByUserIdAsync(Guid userId)
+ {
+ using var scope = ServiceScopeFactory.CreateScope();
+ var dbContext = GetDatabaseContext(scope);
+
+ // One stamp shared across the user's rows, generated here rather than per row, so this and
+ // the MSSQL procedure write identical values. Rows are located by (UserId, DeviceId), so the
+ // stamp never selects a row and a token naming one device cannot match another's.
+ var stamp = Guid.NewGuid().ToString();
+ var revisionDate = DateTime.UtcNow;
+
+ await dbContext.TwoFactorRememberTokens
+ .Where(t => t.UserId == userId)
+ .ExecuteUpdateAsync(s => s
+ .SetProperty(t => t.Stamp, stamp)
+ .SetProperty(t => t.RevisionDate, revisionDate));
+ }
+
+ public async Task DeleteExpiredAsync(DateTime now)
+ {
+ using var scope = ServiceScopeFactory.CreateScope();
+ var dbContext = GetDatabaseContext(scope);
+
+ await dbContext.TwoFactorRememberTokens
+ .Where(t => t.ExpirationDate < now)
+ .ExecuteDeleteAsync();
+ }
+}
diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs
index ce81db621a3a..2dbb19c50bbc 100644
--- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs
+++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs
@@ -101,6 +101,7 @@ public static void AddPasswordManagerEFRepositories(this IServiceCollection serv
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
index ca0229e02f03..9112a32b4f3a 100644
--- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
+++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
@@ -63,6 +63,7 @@ public DatabaseContext(DbContextOptions options)
public DbSet Events { get; set; }
public DbSet Folders { get; set; }
public DbSet Grants { get; set; }
+ public DbSet TwoFactorRememberTokens { get; set; }
public DbSet Groups { get; set; }
public DbSet GroupUsers { get; set; }
public DbSet Installations { get; set; }
diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/TwoFactorRememberTokenRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/TwoFactorRememberTokenRepositoryTests.cs
index d4a3f838ea9b..c196cd6f3239 100644
--- a/test/Infrastructure.IntegrationTest/Auth/Repositories/TwoFactorRememberTokenRepositoryTests.cs
+++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/TwoFactorRememberTokenRepositoryTests.cs
@@ -3,8 +3,10 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
+using Bit.Core.Utilities;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Infrastructure.IntegrationTest.AdminConsole;
+using EfTwoFactorRememberToken = Bit.Infrastructure.EntityFramework.Auth.Models.TwoFactorRememberToken;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@@ -286,6 +288,69 @@ public async Task DeleteExpiredAsync_RemovesOnlyExpiredRows(
Assert.NotNull(await sut.GetByUserIdDeviceIdAsync(user.Id, liveDevice.Id));
}
+ // -------------------------------------------------------------------------------------------
+ // R9 — the unique index exists on every provider
+ // -------------------------------------------------------------------------------------------
+
+ ///
+ /// Proves the one-row-per-device constraint is enforced by the database rather than only by the
+ /// upsert's own logic. Inserts directly, since going through UpsertAsync would update.
+ ///
+ [Theory, DatabaseData]
+ public async Task DuplicateUserIdDeviceId_IsRejectedByUniqueIndex(
+ ITwoFactorRememberTokenRepository sut,
+ IUserRepository userRepository,
+ IDeviceRepository deviceRepository,
+ Database database,
+ IServiceProvider services)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var device = await CreateTestDeviceAsync(deviceRepository, user.Id);
+ await sut.UpsertAsync(NewToken(user.Id, device.Id, "stamp-one"));
+
+ await Assert.ThrowsAnyAsync(
+ () => InsertRowDirectlyAsync(services, database, user.Id, device.Id));
+ }
+
+ private static async Task InsertRowDirectlyAsync(
+ IServiceProvider services, Database database, Guid userId, Guid deviceId)
+ {
+ if (database.Type == SupportedDatabaseProviders.SqlServer && !database.UseEf)
+ {
+ await using var connection = new SqlConnection(database.ConnectionString);
+ await connection.OpenAsync();
+ await using var command = connection.CreateCommand();
+ command.CommandText = """
+ INSERT INTO [dbo].[TwoFactorRememberToken]
+ ([Id], [UserId], [DeviceId], [Stamp], [CreationDate], [RevisionDate], [ExpirationDate])
+ VALUES
+ (@Id, @UserId, @DeviceId, @Stamp, @Now, @Now, @Expiration)
+ """;
+ command.Parameters.Add(new SqlParameter("@Id", CoreHelpers.GenerateComb()));
+ command.Parameters.Add(new SqlParameter("@UserId", userId));
+ command.Parameters.Add(new SqlParameter("@DeviceId", deviceId));
+ command.Parameters.Add(new SqlParameter("@Stamp", "stamp-two"));
+ command.Parameters.Add(new SqlParameter("@Now", DateTime.UtcNow));
+ command.Parameters.Add(new SqlParameter("@Expiration", DateTime.UtcNow.AddDays(30)));
+ await command.ExecuteNonQueryAsync();
+ return;
+ }
+
+ using var scope = services.CreateScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+ dbContext.TwoFactorRememberTokens.Add(new EfTwoFactorRememberToken
+ {
+ Id = CoreHelpers.GenerateComb(),
+ UserId = userId,
+ DeviceId = deviceId,
+ Stamp = "stamp-two",
+ CreationDate = DateTime.UtcNow,
+ RevisionDate = DateTime.UtcNow,
+ ExpirationDate = DateTime.UtcNow.AddDays(30),
+ });
+ await dbContext.SaveChangesAsync();
+ }
+
// -------------------------------------------------------------------------------------------
// R8 — storage fidelity
// -------------------------------------------------------------------------------------------
diff --git a/util/MySqlMigrations/Migrations/20260923010021_CreateTwoFactorRememberToken.Designer.cs b/util/MySqlMigrations/Migrations/20260923010021_CreateTwoFactorRememberToken.Designer.cs
new file mode 100644
index 000000000000..f95c632d7809
--- /dev/null
+++ b/util/MySqlMigrations/Migrations/20260923010021_CreateTwoFactorRememberToken.Designer.cs
@@ -0,0 +1,4179 @@
+//
+using System;
+using Bit.Infrastructure.EntityFramework.Repositories;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Bit.MySqlMigrations.Migrations
+{
+ [DbContext(typeof(DatabaseContext))]
+ [Migration("20260923010021_CreateTwoFactorRememberToken")]
+ partial class CreateTwoFactorRememberToken
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b =>
+ {
+ b.Property("CipherId")
+ .HasColumnType("char(36)");
+
+ b.Property("CollectionId")
+ .HasColumnType("char(36)");
+
+ b.Property("CollectionName")
+ .HasColumnType("longtext");
+
+ b.Property("Email")
+ .HasColumnType("longtext");
+
+ b.Property("GroupId")
+ .HasColumnType("char(36)");
+
+ b.Property("GroupName")
+ .HasColumnType("longtext");
+
+ b.Property("HidePasswords")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Manage")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ReadOnly")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ResetPasswordKey")
+ .HasColumnType("longtext");
+
+ b.Property("TwoFactorProviders")
+ .HasColumnType("longtext");
+
+ b.Property("UserGuid")
+ .HasColumnType("char(36)");
+
+ b.Property("UserName")
+ .HasColumnType("longtext");
+
+ b.Property("UsesKeyConnector")
+ .HasColumnType("tinyint(1)");
+
+ b.ToTable("OrganizationMemberBaseDetails");
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("AccessRuleId")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DefaultUserCollectionEmail")
+ .HasColumnType("longtext");
+
+ b.Property("ExternalId")
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Type")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccessRuleId");
+
+ b.HasIndex("OrganizationId");
+
+ b.ToTable("Collection", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b =>
+ {
+ b.Property("CollectionId")
+ .HasColumnType("char(36)");
+
+ b.Property("GroupId")
+ .HasColumnType("char(36)");
+
+ b.Property("HidePasswords")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Manage")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ReadOnly")
+ .HasColumnType("tinyint(1)");
+
+ b.HasKey("CollectionId", "GroupId");
+
+ b.HasIndex("GroupId");
+
+ b.ToTable("CollectionGroups");
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b =>
+ {
+ b.Property("CollectionId")
+ .HasColumnType("char(36)");
+
+ b.Property("OrganizationUserId")
+ .HasColumnType("char(36)");
+
+ b.Property("HidePasswords")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Manage")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ReadOnly")
+ .HasColumnType("tinyint(1)");
+
+ b.HasKey("CollectionId", "OrganizationUserId");
+
+ b.HasIndex("OrganizationUserId");
+
+ b.ToTable("CollectionUsers");
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("AllowAdminAccessToAllCollectionItems")
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ b.Property("BillingEmail")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("BusinessAddress1")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BusinessAddress2")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BusinessAddress3")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BusinessCountry")
+ .HasMaxLength(2)
+ .HasColumnType("varchar(2)");
+
+ b.Property("BusinessName")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BusinessTaxNumber")
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ExemptFromBillingAutomation")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ExpirationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Gateway")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("GatewayCustomerId")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("GatewaySubscriptionId")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Identifier")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("LicenseKey")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("LimitCollectionCreation")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("LimitCollectionDeletion")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("LimitItemDeletion")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("MaxAutoscaleSeats")
+ .HasColumnType("int");
+
+ b.Property("MaxAutoscaleSmSeats")
+ .HasColumnType("int");
+
+ b.Property("MaxAutoscaleSmServiceAccounts")
+ .HasColumnType("int");
+
+ b.Property("MaxCollections")
+ .HasColumnType("smallint");
+
+ b.Property("MaxStorageGb")
+ .HasColumnType("smallint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("OwnersNotifiedOfAutoscaling")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Plan")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("PlanType")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("PrivateKey")
+ .HasColumnType("longtext");
+
+ b.Property("PublicKey")
+ .HasColumnType("longtext");
+
+ b.Property("ReferenceData")
+ .HasColumnType("longtext");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Seats")
+ .HasColumnType("int");
+
+ b.Property("SelfHost")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("SmSeats")
+ .HasColumnType("int");
+
+ b.Property("SmServiceAccounts")
+ .HasColumnType("int");
+
+ b.Property("Status")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("Storage")
+ .HasColumnType("bigint");
+
+ b.Property("SyncSeats")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("TwoFactorProviders")
+ .HasColumnType("longtext");
+
+ b.Property("Use2fa")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseAdminSponsoredFamilies")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseApi")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseAutomaticUserConfirmation")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseCustomPermissions")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseDirectory")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseDisableSmAdsForUsers")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseEvents")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseGroups")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseInviteLinks")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseKeyConnector")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseMyItems")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseOrganizationDomains")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsePam")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsePasswordManager")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsePhishingBlocker")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsePolicies")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseResetPassword")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseRiskInsights")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseScim")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseSecretsManager")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseSso")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseTotp")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsersGetPremium")
+ .HasColumnType("tinyint(1)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GatewayCustomerId");
+
+ b.HasIndex("GatewaySubscriptionId");
+
+ b.HasIndex("Id", "Enabled")
+ .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" });
+
+ b.ToTable("Organization", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("AllowedDomains")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Invite")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("SupportsConfirmation")
+ .HasColumnType("tinyint(1)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId")
+ .IsUnique()
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.ToTable("OrganizationInviteLink", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Data")
+ .HasColumnType("longtext");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Type")
+ .HasColumnType("tinyint unsigned");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId")
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("OrganizationId", "Type")
+ .IsUnique()
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.ToTable("Policy", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("BillingEmail")
+ .HasColumnType("longtext");
+
+ b.Property("BillingPhone")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessAddress1")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessAddress2")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessAddress3")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessCountry")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessName")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessTaxNumber")
+ .HasColumnType("longtext");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DiscountId")
+ .HasColumnType("longtext");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Gateway")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("GatewayCustomerId")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("GatewaySubscriptionId")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Name")
+ .HasColumnType("longtext");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Status")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("Type")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("UseEvents")
+ .HasColumnType("tinyint(1)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GatewayCustomerId");
+
+ b.HasIndex("GatewaySubscriptionId");
+
+ b.ToTable("Provider", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Key")
+ .HasColumnType("longtext");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("ProviderId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Settings")
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId");
+
+ b.HasIndex("ProviderId");
+
+ b.ToTable("ProviderOrganization", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Email")
+ .HasColumnType("longtext");
+
+ b.Property("Key")
+ .HasColumnType("longtext");
+
+ b.Property("Permissions")
+ .HasColumnType("longtext");
+
+ b.Property("ProviderId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Status")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("Type")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProviderId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ProviderUser", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("AccessCode")
+ .HasMaxLength(25)
+ .HasColumnType("varchar(25)");
+
+ b.Property("Approved")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("AuthenticationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Key")
+ .HasColumnType("longtext");
+
+ b.Property("MasterPasswordHash")
+ .HasColumnType("longtext");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("PublicKey")
+ .HasColumnType("longtext");
+
+ b.Property("RequestCountryName")
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("RequestDeviceIdentifier")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("RequestDeviceType")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("RequestIpAddress")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("ResponseDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ResponseDeviceId")
+ .HasColumnType("char(36)");
+
+ b.Property("Type")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId");
+
+ b.HasIndex("ResponseDeviceId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AuthRequest", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("GranteeId")
+ .HasColumnType("char(36)");
+
+ b.Property("GrantorId")
+ .HasColumnType("char(36)");
+
+ b.Property("KeyEncrypted")
+ .HasColumnType("longtext");
+
+ b.Property("LastNotificationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("RecoveryInitiatedDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Status")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("Type")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("WaitTimeDays")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GranteeId");
+
+ b.HasIndex("GrantorId");
+
+ b.ToTable("EmergencyAccess", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("ConsumedDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Data")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Description")
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("ExpirationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("SessionId")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("SubjectId")
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.HasKey("Id")
+ .HasName("PK_Grant")
+ .HasAnnotation("SqlServer:Clustered", true);
+
+ b.HasIndex("ExpirationDate")
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("Key")
+ .IsUnique();
+
+ b.ToTable("Grant", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Data")
+ .HasColumnType("longtext");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId");
+
+ b.ToTable("SsoConfig", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ExternalId")
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId")
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("OrganizationId", "ExternalId")
+ .IsUnique()
+ .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" })
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("OrganizationId", "UserId")
+ .IsUnique()
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.ToTable("SsoUser", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.TwoFactorRememberToken", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DeviceId")
+ .HasColumnType("char(36)");
+
+ b.Property("ExpirationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Stamp")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId")
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("ExpirationDate")
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("UserId", "DeviceId")
+ .IsUnique()
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.ToTable("TwoFactorRememberToken", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("AaGuid")
+ .HasColumnType("char(36)");
+
+ b.Property("Counter")
+ .HasColumnType("int");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property