diff --git a/src/Admin/Auth/Jobs/DeleteExpiredTwoFactorRememberTokensJob.cs b/src/Admin/Auth/Jobs/DeleteExpiredTwoFactorRememberTokensJob.cs
new file mode 100644
index 000000000000..5427368491ed
--- /dev/null
+++ b/src/Admin/Auth/Jobs/DeleteExpiredTwoFactorRememberTokensJob.cs
@@ -0,0 +1,36 @@
+using Bit.Core;
+using Bit.Core.Auth.Repositories;
+using Bit.Core.Jobs;
+using Quartz;
+
+namespace Bit.Admin.Auth.Jobs;
+
+///
+/// Clears out remember-token rows whose expiry has passed. Hygiene rather than correctness — an
+/// expired row is refused at validation time regardless, the row count is bounded by the number of
+/// devices, and the expiry index makes the sweep cheap. Weekly is sufficient.
+///
+public class DeleteExpiredTwoFactorRememberTokensJob : BaseJob
+{
+ private readonly ITwoFactorRememberTokenRepository _twoFactorRememberTokenRepository;
+ private readonly TimeProvider _timeProvider;
+
+ public DeleteExpiredTwoFactorRememberTokensJob(
+ ITwoFactorRememberTokenRepository twoFactorRememberTokenRepository,
+ TimeProvider timeProvider,
+ ILogger logger)
+ : base(logger)
+ {
+ _twoFactorRememberTokenRepository = twoFactorRememberTokenRepository;
+ _timeProvider = timeProvider;
+ }
+
+ protected async override Task ExecuteJobAsync(IJobExecutionContext context)
+ {
+ _logger.LogInformation(
+ Constants.BypassFiltersEventId, "Execute job task: DeleteExpiredTwoFactorRememberTokensAsync");
+ await _twoFactorRememberTokenRepository.DeleteExpiredAsync(_timeProvider.GetUtcNow().UtcDateTime);
+ _logger.LogInformation(
+ Constants.BypassFiltersEventId, "Finished job task: DeleteExpiredTwoFactorRememberTokensAsync");
+ }
+}
diff --git a/src/Admin/Jobs/JobsHostedService.cs b/src/Admin/Jobs/JobsHostedService.cs
index 23c8e6d4ab77..984f656b7fd1 100644
--- a/src/Admin/Jobs/JobsHostedService.cs
+++ b/src/Admin/Jobs/JobsHostedService.cs
@@ -44,6 +44,11 @@ public override async Task StartAsync(CancellationToken cancellationToken)
.StartNow()
.WithCronSchedule("0 0 22 ? * FRI", x => x.InTimeZone(timeZone))
.Build();
+ var everySaturdayAt430amTrigger = TriggerBuilder.Create()
+ .WithIdentity("EverySaturdayAt430amTrigger")
+ .StartNow()
+ .WithCronSchedule("0 30 4 ? * SAT", x => x.InTimeZone(timeZone))
+ .Build();
var everySaturdayAtMidnightTrigger = TriggerBuilder.Create()
.WithIdentity("EverySaturdayAtMidnightTrigger")
.StartNow()
@@ -88,6 +93,7 @@ public override async Task StartAsync(CancellationToken cancellationToken)
{
new Tuple(typeof(DeleteSendsJob), everyFiveMinutesTrigger),
new Tuple(typeof(DatabaseExpiredGrantsJob), everyFridayAt10pmTrigger),
+ new Tuple(typeof(DeleteExpiredTwoFactorRememberTokensJob), everySaturdayAt430amTrigger),
new Tuple(typeof(DeleteCiphersJob), everyDayAtMidnightUtc),
new Tuple(typeof(DatabaseExpiredSponsorshipsJob), everyMondayAtMidnightTrigger),
new Tuple(typeof(DeleteAuthRequestsJob), everyFifteenMinutesTrigger),
@@ -122,6 +128,7 @@ public static void AddJobsServices(IServiceCollection services, bool selfHosted)
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
services.AddTransient();
services.AddTransient();
services.AddTransient();
diff --git a/src/Core/Auth/Entities/TwoFactorRememberToken.cs b/src/Core/Auth/Entities/TwoFactorRememberToken.cs
new file mode 100644
index 000000000000..d0274fe6f811
--- /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 = CombGuid.Generate();
+ }
+ }
+}
diff --git a/src/Core/Auth/Models/Business/Tokenables/ITwoFactorRememberTokenableFactory.cs b/src/Core/Auth/Models/Business/Tokenables/ITwoFactorRememberTokenableFactory.cs
new file mode 100644
index 000000000000..236df8899493
--- /dev/null
+++ b/src/Core/Auth/Models/Business/Tokenables/ITwoFactorRememberTokenableFactory.cs
@@ -0,0 +1,13 @@
+using Bit.Core.Entities;
+
+namespace Bit.Core.Auth.Models.Business.Tokenables;
+
+/// Mints instances with the operator-configured lifetime.
+public interface ITwoFactorRememberTokenableFactory
+{
+ ///
+ /// Creates a token bound to the given user and device, carrying , and expiring
+ /// after the configured lifetime.
+ ///
+ TwoFactorRememberTokenable CreateToken(User user, Device device, string stamp);
+}
diff --git a/src/Core/Auth/Models/Business/Tokenables/TwoFactorRememberTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/TwoFactorRememberTokenable.cs
new file mode 100644
index 000000000000..43fb86ccf063
--- /dev/null
+++ b/src/Core/Auth/Models/Business/Tokenables/TwoFactorRememberTokenable.cs
@@ -0,0 +1,140 @@
+using System.Text.Json.Serialization;
+using Bit.Core.Tokens;
+
+namespace Bit.Core.Auth.Models.Business.Tokenables;
+
+///
+/// The "remember this device" token a client presents to satisfy two-factor authentication without
+/// being challenged again.
+///
+///
+/// The token is encrypted and signed, so a client can neither read nor alter its contents. It names
+/// the device it was issued to and carries two stamps that are compared against server-side state on
+/// every use: against the user, and against that
+/// device's TwoFactorRememberToken row. Minted only through
+/// , which applies the configured lifetime.
+///
+public class TwoFactorRememberTokenable : ExpiringTokenable
+{
+ public const string ClearTextPrefix = "BwTwoFactorRemember_";
+ public const string DataProtectorPurpose = "TwoFactorRememberTokenDataProtector";
+ public const string TokenIdentifier = "TwoFactorRememberToken";
+
+ // Binding properties use [JsonInclude] internal set so only the factory (in Bit.Core) can mint a
+ // token. [JsonInclude] is required: JsonSerializer with default options ignores non-public
+ // setters, and deserialized tokens would silently come back with default values.
+ [JsonInclude]
+ public string Identifier { get; internal set; } = TokenIdentifier;
+
+ [JsonInclude]
+ public Guid UserId { get; internal set; }
+
+ ///
+ /// The primary key of the device this token was issued to, used to locate the row to compare
+ /// against.
+ ///
+ [JsonInclude]
+ public Guid DeviceId { get; internal set; }
+
+ ///
+ /// The client-generated device identifier from the request that minted this token. Compared
+ /// against the presenting request's identifier, which binds the token to one device without
+ /// needing a database read.
+ ///
+ [JsonInclude]
+ public string DeviceIdentifier { get; internal set; } = null!;
+
+ ///
+ /// A copy of the issuing device's row stamp. Device-scoped.
+ ///
+ [JsonInclude]
+ public string Stamp { get; internal set; } = null!;
+
+ // TODO: PM-44171 - Consider removing SecurityStamp from this token. The row's Stamp already
+ // covers the same ground more precisely, and carrying the user stamp ties remember-me lifetime
+ // to every user stamp rotation. Any removal has to stay backwards compatible: tokens already
+ // issued carry this field, so treat it as optional on read before dropping it.
+ ///
+ /// A copy of User.SecurityStamp as it stood at issuance. Account-scoped.
+ ///
+ [JsonInclude]
+ public string SecurityStamp { get; internal set; } = null!;
+
+ // Leaves ExpirationDate at its default, so a token constructed without going through the factory
+ // is expired and therefore never valid.
+ [JsonConstructor]
+ public TwoFactorRememberTokenable()
+ {
+ }
+
+ internal TwoFactorRememberTokenable(
+ Guid userId, Guid deviceId, string deviceIdentifier, string stamp, string securityStamp)
+ {
+ if (userId == default)
+ {
+ throw new ArgumentException("User id cannot be empty.", nameof(userId));
+ }
+
+ if (deviceId == default)
+ {
+ throw new ArgumentException("Device id cannot be empty.", nameof(deviceId));
+ }
+
+ if (string.IsNullOrWhiteSpace(deviceIdentifier))
+ {
+ throw new ArgumentException("Device identifier cannot be null or whitespace.", nameof(deviceIdentifier));
+ }
+
+ if (string.IsNullOrWhiteSpace(stamp))
+ {
+ throw new ArgumentException("Stamp cannot be null or whitespace.", nameof(stamp));
+ }
+
+ if (string.IsNullOrWhiteSpace(securityStamp))
+ {
+ throw new ArgumentException("Security stamp cannot be null or whitespace.", nameof(securityStamp));
+ }
+
+ Identifier = TokenIdentifier;
+ UserId = userId;
+ DeviceId = deviceId;
+ DeviceIdentifier = deviceIdentifier;
+ Stamp = stamp;
+ SecurityStamp = securityStamp;
+ }
+
+ ///
+ /// Checks only that the deserialized fields are present and well-formed. Comparing them against
+ /// the user and the device's row requires state this type does not have, and happens in
+ /// ValidateTwoFactorRememberTokenQuery.
+ ///
+ protected override bool TokenIsValid() =>
+ Identifier == TokenIdentifier && UserId != default && DeviceId != default
+ && !string.IsNullOrWhiteSpace(DeviceIdentifier)
+ && !string.IsNullOrWhiteSpace(Stamp)
+ && !string.IsNullOrWhiteSpace(SecurityStamp);
+
+ ///
+ /// Unprotects a presented token and checks everything that can be checked without server-side
+ /// state: tampering, a wrong data-protection purpose, the token's own expiry, and the presence of
+ /// its fields. The caller still compares the result against the user and the device's row.
+ ///
+ /// The unprotected token, or null when it could not be unprotected.
+ public static TokenableValidationError? ValidateTwoFactorRememberToken(
+ IDataProtectorTokenFactory tokenFactory,
+ string token,
+ out TwoFactorRememberTokenable? tokenable)
+ {
+ if (!tokenFactory.TryUnprotect(token, out tokenable) || tokenable is null)
+ {
+ return TokenableValidationError.InvalidToken;
+ }
+
+ if (tokenable.IsExpired)
+ {
+ return TokenableValidationError.ExpiringTokenables.Expired;
+ }
+
+ return tokenable.Valid ? null : TokenableValidationError.InvalidToken;
+ }
+}
diff --git a/src/Core/Auth/Models/Business/Tokenables/TwoFactorRememberTokenableFactory.cs b/src/Core/Auth/Models/Business/Tokenables/TwoFactorRememberTokenableFactory.cs
new file mode 100644
index 000000000000..dedab6bb5a85
--- /dev/null
+++ b/src/Core/Auth/Models/Business/Tokenables/TwoFactorRememberTokenableFactory.cs
@@ -0,0 +1,18 @@
+using Bit.Core.Entities;
+using Bit.Core.Settings;
+
+namespace Bit.Core.Auth.Models.Business.Tokenables;
+
+///
+public class TwoFactorRememberTokenableFactory(
+ IGlobalSettings globalSettings,
+ TimeProvider timeProvider) : ITwoFactorRememberTokenableFactory
+{
+ ///
+ public TwoFactorRememberTokenable CreateToken(User user, Device device, string stamp) =>
+ new(user.Id, device.Id, device.Identifier, stamp, user.SecurityStamp)
+ {
+ ExpirationDate = timeProvider.GetUtcNow().UtcDateTime
+ .AddDays(globalSettings.TwoFactorRememberTokenLifetimeInDays),
+ };
+}
diff --git a/src/Core/Auth/Repositories/ITwoFactorRememberTokenRepository.cs b/src/Core/Auth/Repositories/ITwoFactorRememberTokenRepository.cs
new file mode 100644
index 000000000000..b29eb5f67d75
--- /dev/null
+++ b/src/Core/Auth/Repositories/ITwoFactorRememberTokenRepository.cs
@@ -0,0 +1,46 @@
+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.
+ ///
+ ///
+ /// The value written to RevisionDate on every rotated row, supplied by the caller so that
+ /// every provider writes one instant.
+ ///
+ Task RotateStampsByUserIdAsync(Guid userId, DateTime revisionDate);
+
+ ///
+ /// 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);
+}
diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/IIssueTwoFactorRememberTokenCommand.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/IIssueTwoFactorRememberTokenCommand.cs
new file mode 100644
index 000000000000..afe7961d66a7
--- /dev/null
+++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/IIssueTwoFactorRememberTokenCommand.cs
@@ -0,0 +1,17 @@
+using Bit.Core.Entities;
+
+namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth;
+
+public interface IIssueTwoFactorRememberTokenCommand
+{
+ ///
+ /// Records that this device is remembered and mints the token the client presents to skip the
+ /// two-factor challenge on its next login.
+ ///
+ ///
+ /// Writing the row assigns it a fresh stamp, so any token previously issued for this device
+ /// stops being honored — one live remember token per device, newest wins.
+ ///
+ /// The protected token string, to be returned to the client verbatim.
+ Task IssueAsync(User user, Device device);
+}
diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/IRevokeTwoFactorRememberTokensCommand.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/IRevokeTwoFactorRememberTokensCommand.cs
new file mode 100644
index 000000000000..14976b2b18a6
--- /dev/null
+++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/IRevokeTwoFactorRememberTokensCommand.cs
@@ -0,0 +1,14 @@
+namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth;
+
+public interface IRevokeTwoFactorRememberTokensCommand
+{
+ ///
+ /// Stops every remember token this user holds from being honored, on all of their devices.
+ ///
+ ///
+ /// Scoped to remember-me only: access tokens, refresh tokens, and live sessions are unaffected,
+ /// so this does not log the user out anywhere. Devices stay remembered as rows — they simply
+ /// require a two-factor challenge again on next login.
+ ///
+ Task RevokeAllForUserAsync(Guid userId);
+}
diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/IValidateTwoFactorRememberTokenQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/IValidateTwoFactorRememberTokenQuery.cs
new file mode 100644
index 000000000000..8ce9bbc64301
--- /dev/null
+++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/IValidateTwoFactorRememberTokenQuery.cs
@@ -0,0 +1,26 @@
+using Bit.Core.Entities;
+
+namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth;
+
+public interface IValidateTwoFactorRememberTokenQuery
+{
+ ///
+ /// Decides whether a presented remember token entitles this request to skip the two-factor
+ /// challenge.
+ ///
+ ///
+ /// A pure read: nothing here writes. If a last-used timestamp is ever wanted, it belongs in its
+ /// own command rather than folded in here.
+ ///
+ /// This validates the token only. Whether the user is required to complete two-factor at all is
+ /// the caller's decision, made before a remember token is considered.
+ ///
+ ///
+ /// The authenticating user.
+ ///
+ /// The client-generated device identifier from the current request. The device row itself is not
+ /// available at this point in the login pipeline, which is why the identifier is passed in.
+ ///
+ /// The protected token string as presented by the client.
+ Task ValidateAsync(User user, string deviceIdentifier, string token);
+}
diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/IssueTwoFactorRememberTokenCommand.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/IssueTwoFactorRememberTokenCommand.cs
new file mode 100644
index 000000000000..d98e4769e8a1
--- /dev/null
+++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/IssueTwoFactorRememberTokenCommand.cs
@@ -0,0 +1,39 @@
+using Bit.Core.Auth.Entities;
+using Bit.Core.Auth.Models.Business.Tokenables;
+using Bit.Core.Auth.Repositories;
+using Bit.Core.Entities;
+using Bit.Core.Tokens;
+
+namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations;
+
+public class IssueTwoFactorRememberTokenCommand(
+ ITwoFactorRememberTokenRepository twoFactorRememberTokenRepository,
+ ITwoFactorRememberTokenableFactory tokenableFactory,
+ IDataProtectorTokenFactory tokenFactory,
+ TimeProvider timeProvider) : IIssueTwoFactorRememberTokenCommand
+{
+ public async Task IssueAsync(User user, Device device)
+ {
+ var now = timeProvider.GetUtcNow().UtcDateTime;
+
+ // Generated here rather than by the repository because the value has to go into the token
+ // as well as the row. Guid.NewGuid rather than a comb: this is an opaque comparand, not a
+ // table identifier, and it should carry no embedded timestamp.
+ var stamp = Guid.NewGuid().ToString();
+
+ // The row takes its expiry from the token, so the two always lapse together.
+ var tokenable = tokenableFactory.CreateToken(user, device, stamp);
+
+ await twoFactorRememberTokenRepository.UpsertAsync(new TwoFactorRememberToken
+ {
+ UserId = user.Id,
+ DeviceId = device.Id,
+ Stamp = stamp,
+ CreationDate = now,
+ RevisionDate = now,
+ ExpirationDate = tokenable.ExpirationDate,
+ });
+
+ return tokenFactory.Protect(tokenable);
+ }
+}
diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/RevokeTwoFactorRememberTokensCommand.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/RevokeTwoFactorRememberTokensCommand.cs
new file mode 100644
index 000000000000..3731fc5d413e
--- /dev/null
+++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/RevokeTwoFactorRememberTokensCommand.cs
@@ -0,0 +1,12 @@
+using Bit.Core.Auth.Repositories;
+
+namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations;
+
+public class RevokeTwoFactorRememberTokensCommand(
+ ITwoFactorRememberTokenRepository twoFactorRememberTokenRepository,
+ TimeProvider timeProvider)
+ : IRevokeTwoFactorRememberTokensCommand
+{
+ public Task RevokeAllForUserAsync(Guid userId) =>
+ twoFactorRememberTokenRepository.RotateStampsByUserIdAsync(userId, timeProvider.GetUtcNow().UtcDateTime);
+}
diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/ValidateTwoFactorRememberTokenQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/ValidateTwoFactorRememberTokenQuery.cs
new file mode 100644
index 000000000000..9f7061c1885b
--- /dev/null
+++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/ValidateTwoFactorRememberTokenQuery.cs
@@ -0,0 +1,57 @@
+using Bit.Core.Auth.Models.Business.Tokenables;
+using Bit.Core.Auth.Repositories;
+using Bit.Core.Entities;
+using Bit.Core.Tokens;
+
+namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations;
+
+public class ValidateTwoFactorRememberTokenQuery(
+ ITwoFactorRememberTokenRepository twoFactorRememberTokenRepository,
+ IDataProtectorTokenFactory tokenFactory,
+ TimeProvider timeProvider) : IValidateTwoFactorRememberTokenQuery
+{
+ public async Task ValidateAsync(User user, string deviceIdentifier, string token)
+ {
+ if (TwoFactorRememberTokenable.ValidateTwoFactorRememberToken(tokenFactory, token, out var tokenable) != null
+ || tokenable is null)
+ {
+ return false;
+ }
+
+ if (tokenable.UserId != user.Id)
+ {
+ return false;
+ }
+
+ // Account-scoped: a security stamp rotation invalidates every token carrying the old value.
+ if (!string.Equals(tokenable.SecurityStamp, user.SecurityStamp, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ // Device binding. Case-insensitive because Device_ReadByIdentifierUserId matches the same
+ // value through a case-insensitive collation, and a stricter comparison here would reject a
+ // token whose identifier still resolves to the same device row.
+ if (!string.Equals(tokenable.DeviceIdentifier, deviceIdentifier, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ // Safe to locate the row by the token's own DeviceId only because the identifier comparison
+ // above already proved the presenter is on the device the token names. Do not reorder.
+ var row = await twoFactorRememberTokenRepository.GetByUserIdDeviceIdAsync(user.Id, tokenable.DeviceId);
+ if (row is null)
+ {
+ return false;
+ }
+
+ // Device-scoped: rotating the row's stamp cuts off this device without affecting the user's
+ // other devices or any of their sessions.
+ if (!string.Equals(tokenable.Stamp, row.Stamp, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ return row.ExpirationDate >= timeProvider.GetUtcNow().UtcDateTime;
+ }
+}
diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs
index dbe56bc0b46c..5e04efeb3fcd 100644
--- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs
+++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs
@@ -105,6 +105,9 @@ private static void AddTwoFactorCommandsQueries(this IServiceCollection services
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
}
}
diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs
index 1bc8c3dbe6b1..9b62a5b5c913 100644
--- a/src/Core/Settings/GlobalSettings.cs
+++ b/src/Core/Settings/GlobalSettings.cs
@@ -51,6 +51,7 @@ public virtual string MailTemplateDirectory
public virtual bool EnableCloudCommunication { get; set; } = false;
public virtual int OrganizationInviteExpirationHours { get; set; } = 120; // 5 days
public virtual int TwoFactorUserVerificationTokenLifetimeInMinutes { get; set; } = 30;
+ public virtual int TwoFactorRememberTokenLifetimeInDays { get; set; } = 30;
public virtual int DeviceLastActivityCacheTtlHours { get; set; } = 120; // 5 days
public virtual string EventGridKey { get; set; }
public virtual bool TestPlayIdTrackingEnabled { get; set; } = false;
diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs
index 7ea8a45ec6ce..42ee4bfa955c 100644
--- a/src/Core/Settings/IGlobalSettings.cs
+++ b/src/Core/Settings/IGlobalSettings.cs
@@ -16,6 +16,7 @@ public interface IGlobalSettings
string LicenseCertificatePath { get; set; }
int OrganizationInviteExpirationHours { get; set; }
int TwoFactorUserVerificationTokenLifetimeInMinutes { get; set; }
+ int TwoFactorRememberTokenLifetimeInDays { get; set; }
int DeviceLastActivityCacheTtlHours { get; set; }
bool DisableUserRegistration { get; set; }
bool SuppressOnboardingInterstitials { get; set; }
diff --git a/src/Infrastructure.Dapper/Auth/Repositories/TwoFactorRememberTokenRepository.cs b/src/Infrastructure.Dapper/Auth/Repositories/TwoFactorRememberTokenRepository.cs
new file mode 100644
index 000000000000..41d3ed6ef29f
--- /dev/null
+++ b/src/Infrastructure.Dapper/Auth/Repositories/TwoFactorRememberTokenRepository.cs
@@ -0,0 +1,143 @@
+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)
+ { }
+
+ // Virtual so tests can simulate a stale read, which is what the fallbacks in UpsertAsync handle.
+ public virtual 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)
+ {
+ var existing = await GetByUserIdDeviceIdAsync(token.UserId, token.DeviceId);
+ if (existing != null && await TryUpdateAsync(token, existing))
+ {
+ return token;
+ }
+
+ // Either there is no row, or it was removed between the read and the update (the expiry
+ // sweep can do that). BaseRepository does not assign ids the way the generic repository does,
+ // and the procedure needs one for the insert.
+ token.SetNewId();
+
+ try
+ {
+ await CreateAsync(token);
+ }
+ catch (SqlException e) when (e.Number is 2601 or 2627)
+ {
+ // Unique-index backstop ([IX_TwoFactorRememberToken_UserId_DeviceId]): a concurrent
+ // remember-login on the same device inserted the row between our read and our insert.
+ // Update the winner's row instead of failing the login.
+ existing = await GetByUserIdDeviceIdAsync(token.UserId, token.DeviceId);
+ if (existing == null || !await TryUpdateAsync(token, existing))
+ {
+ throw;
+ }
+ }
+
+ return token;
+ }
+
+ private async Task TryUpdateAsync(TwoFactorRememberToken token, TwoFactorRememberToken existing)
+ {
+ await using var connection = new SqlConnection(ConnectionString);
+
+ var rowsUpdated = await connection.ExecuteScalarAsync(
+ "[dbo].[TwoFactorRememberToken_Update]",
+ new
+ {
+ existing.Id,
+ token.Stamp,
+ token.RevisionDate,
+ token.ExpirationDate,
+ },
+ commandType: CommandType.StoredProcedure);
+
+ if (rowsUpdated == 0)
+ {
+ return false;
+ }
+
+ // CreationDate stays as first written, so the row still records when the device was first
+ // remembered; the update procedure does not write it.
+ token.Id = existing.Id;
+ token.CreationDate = existing.CreationDate;
+ return true;
+ }
+
+ private async Task CreateAsync(TwoFactorRememberToken token)
+ {
+ await using var connection = new SqlConnection(ConnectionString);
+
+ await connection.ExecuteAsync(
+ "[dbo].[TwoFactorRememberToken_Create]",
+ new
+ {
+ token.Id,
+ token.UserId,
+ token.DeviceId,
+ token.Stamp,
+ token.CreationDate,
+ token.RevisionDate,
+ token.ExpirationDate,
+ },
+ commandType: CommandType.StoredProcedure);
+ }
+
+ public async Task RotateStampsByUserIdAsync(Guid userId, DateTime revisionDate)
+ {
+ 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 = revisionDate,
+ },
+ commandType: CommandType.StoredProcedure);
+ }
+
+ public async Task DeleteExpiredAsync(DateTime now)
+ {
+ await using var connection = new SqlConnection(ConnectionString);
+
+ await connection.ExecuteAsync(
+ "[dbo].[TwoFactorRememberToken_DeleteManyExpired]",
+ 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/Infrastructure.EntityFramework/Auth/Configurations/TwoFactorRememberTokenEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/Auth/Configurations/TwoFactorRememberTokenEntityTypeConfiguration.cs
new file mode 100644
index 000000000000..85e71eef82aa
--- /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..26c4e839ea4f
--- /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..8e7f1f403b5b
--- /dev/null
+++ b/src/Infrastructure.EntityFramework/Auth/Repositories/TwoFactorRememberTokenRepository.cs
@@ -0,0 +1,135 @@
+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)
+ { }
+
+ // Virtual so tests can simulate a stale read, which is what the fallbacks in UpsertAsync handle.
+ public virtual 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)
+ {
+ var existing = await GetByUserIdDeviceIdAsync(token.UserId, token.DeviceId);
+ if (existing != null && await TryUpdateAsync(token, existing))
+ {
+ return token;
+ }
+
+ // Either there is no row, or it was removed between the read and the update (the expiry
+ // sweep can do that).
+ token.SetNewId();
+
+ try
+ {
+ await CreateAsync(token);
+ }
+ catch (DbUpdateException e) when (IsDuplicateKeyException(e))
+ {
+ // Unique-index backstop: a concurrent remember-login on the same device inserted the row
+ // between our read and our insert. Update the winner's row instead of failing the login.
+ existing = await GetByUserIdDeviceIdAsync(token.UserId, token.DeviceId);
+ if (existing == null || !await TryUpdateAsync(token, existing))
+ {
+ throw;
+ }
+ }
+
+ return token;
+ }
+
+ private async Task TryUpdateAsync(
+ Core.Auth.Entities.TwoFactorRememberToken token, Core.Auth.Entities.TwoFactorRememberToken existing)
+ {
+ using var scope = ServiceScopeFactory.CreateScope();
+ var dbContext = GetDatabaseContext(scope);
+
+ var rowsUpdated = await dbContext.TwoFactorRememberTokens
+ .Where(t => t.Id == existing.Id)
+ .ExecuteUpdateAsync(s => s
+ .SetProperty(t => t.Stamp, token.Stamp)
+ .SetProperty(t => t.RevisionDate, token.RevisionDate)
+ .SetProperty(t => t.ExpirationDate, token.ExpirationDate));
+
+ if (rowsUpdated == 0)
+ {
+ return false;
+ }
+
+ // CreationDate is left alone so the row still records when the device was first remembered,
+ // matching the MSSQL procedure.
+ token.Id = existing.Id;
+ token.CreationDate = existing.CreationDate;
+ return true;
+ }
+
+ private async Task CreateAsync(Core.Auth.Entities.TwoFactorRememberToken token)
+ {
+ using var scope = ServiceScopeFactory.CreateScope();
+ var dbContext = GetDatabaseContext(scope);
+
+ var entity = Mapper.Map(token);
+ await dbContext.AddAsync(entity);
+ await dbContext.SaveChangesAsync();
+ }
+
+ ///
+ /// Recognises unique-index violations, which report different codes from primary-key ones on
+ /// SQL Server (2601 rather than 2627) and SQLite (2067 rather than 1555).
+ ///
+ private static bool IsDuplicateKeyException(DbUpdateException e) => e.InnerException switch
+ {
+ MySqlConnector.MySqlException my => my.ErrorCode == MySqlConnector.MySqlErrorCode.DuplicateKeyEntry,
+ Microsoft.Data.SqlClient.SqlException ms => ms.Errors
+ .Cast()
+ .Any(error => error.Number is 2601 or 2627),
+ Npgsql.PostgresException pg => pg.SqlState == "23505",
+ Microsoft.Data.Sqlite.SqliteException lite => lite.SqliteErrorCode == 19
+ && lite.SqliteExtendedErrorCode is 1555 or 2067,
+ _ => false,
+ };
+
+ public async Task RotateStampsByUserIdAsync(Guid userId, DateTime revisionDate)
+ {
+ 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();
+
+ 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/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
index 04d61d530ec5..892450fb713b 100644
--- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
+++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
@@ -240,6 +240,12 @@ public static void AddTokenizers(this IServiceCollection services)
DuoUserStateTokenable.DataProtectorPurpose,
serviceProvider.GetDataProtectionProvider(),
serviceProvider.GetRequiredService>>()));
+ services.AddSingleton>(serviceProvider =>
+ new DataProtectorTokenFactory(
+ TwoFactorRememberTokenable.ClearTextPrefix,
+ TwoFactorRememberTokenable.DataProtectorPurpose,
+ serviceProvider.GetDataProtectionProvider(),
+ serviceProvider.GetRequiredService>>()));
services.AddSingleton>(serviceProvider =>
new DataProtectorTokenFactory(
@@ -261,6 +267,7 @@ public static void AddTokenizers(this IServiceCollection services)
serviceProvider.GetDataProtectionProvider(),
serviceProvider.GetRequiredService>>()));
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton>(
serviceProvider => new DataProtectorTokenFactory(
TwoFactorUserVerificationTokenable.ClearTextPrefix,
diff --git a/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_Create.sql b/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_Create.sql
new file mode 100644
index 000000000000..b947b06453d4
--- /dev/null
+++ b/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_Create.sql
@@ -0,0 +1,33 @@
+CREATE PROCEDURE [dbo].[TwoFactorRememberToken_Create]
+ @Id UNIQUEIDENTIFIER,
+ @UserId UNIQUEIDENTIFIER,
+ @DeviceId UNIQUEIDENTIFIER,
+ @Stamp NVARCHAR(50),
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7),
+ @ExpirationDate DATETIME2(7)
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ INSERT INTO [dbo].[TwoFactorRememberToken]
+ (
+ [Id],
+ [UserId],
+ [DeviceId],
+ [Stamp],
+ [CreationDate],
+ [RevisionDate],
+ [ExpirationDate]
+ )
+ VALUES
+ (
+ @Id,
+ @UserId,
+ @DeviceId,
+ @Stamp,
+ @CreationDate,
+ @RevisionDate,
+ @ExpirationDate
+ )
+END
diff --git a/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_DeleteManyExpired.sql b/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_DeleteManyExpired.sql
new file mode 100644
index 000000000000..2a0091552cb9
--- /dev/null
+++ b/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_DeleteManyExpired.sql
@@ -0,0 +1,22 @@
+CREATE PROCEDURE [dbo].[TwoFactorRememberToken_DeleteManyExpired]
+ @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 = 1000
+
+ 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_Update.sql b/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_Update.sql
new file mode 100644
index 000000000000..03a6d1e7f01d
--- /dev/null
+++ b/src/Sql/dbo/Auth/Stored Procedures/TwoFactorRememberToken_Update.sql
@@ -0,0 +1,22 @@
+CREATE PROCEDURE [dbo].[TwoFactorRememberToken_Update]
+ @Id UNIQUEIDENTIFIER,
+ @Stamp NVARCHAR(50),
+ @RevisionDate DATETIME2(7),
+ @ExpirationDate DATETIME2(7)
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ -- CreationDate is deliberately not updated: it records when the device was first remembered.
+ UPDATE
+ [dbo].[TwoFactorRememberToken]
+ SET
+ [Stamp] = @Stamp,
+ [RevisionDate] = @RevisionDate,
+ [ExpirationDate] = @ExpirationDate
+ WHERE
+ [Id] = @Id
+
+ -- Zero when the row no longer exists, e.g. the expiry sweep removed it after the caller read it.
+ SELECT @@ROWCOUNT
+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/Admin.Test/Jobs/DeleteExpiredTwoFactorRememberTokensJobTests.cs b/test/Admin.Test/Jobs/DeleteExpiredTwoFactorRememberTokensJobTests.cs
new file mode 100644
index 000000000000..be6358430b69
--- /dev/null
+++ b/test/Admin.Test/Jobs/DeleteExpiredTwoFactorRememberTokensJobTests.cs
@@ -0,0 +1,52 @@
+using Bit.Admin.Auth.Jobs;
+using Bit.Core.Auth.Repositories;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Time.Testing;
+using NSubstitute;
+using Quartz;
+
+namespace Admin.Test.Jobs;
+
+public class DeleteExpiredTwoFactorRememberTokensJobTests
+{
+ private readonly ITwoFactorRememberTokenRepository _repository;
+ private readonly FakeTimeProvider _timeProvider;
+ private readonly ILogger _logger;
+ private readonly DeleteExpiredTwoFactorRememberTokensJob _sut;
+
+ public DeleteExpiredTwoFactorRememberTokensJobTests()
+ {
+ _repository = Substitute.For();
+ _timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 9, 22, 22, 0, 0, TimeSpan.Zero));
+ _logger = Substitute.For>();
+ _sut = new DeleteExpiredTwoFactorRememberTokensJob(_repository, _timeProvider, _logger);
+ }
+
+ [Fact]
+ public async Task Execute_Always_SweepsExpiredRows()
+ {
+ await _sut.Execute(CreateContext());
+
+ await _repository.Received(1).DeleteExpiredAsync(Arg.Any());
+ }
+
+ ///
+ /// The job owns the clock reading and passes it down, so the procedure and every Entity
+ /// Framework implementation compare against the same instant.
+ ///
+ [Fact]
+ public async Task Execute_Always_PassesItsOwnClockReading()
+ {
+ await _sut.Execute(CreateContext());
+
+ await _repository.Received(1)
+ .DeleteExpiredAsync(_timeProvider.GetUtcNow().UtcDateTime);
+ }
+
+ private static IJobExecutionContext CreateContext(CancellationToken cancellationToken = default)
+ {
+ var context = Substitute.For();
+ context.CancellationToken.Returns(cancellationToken);
+ return context;
+ }
+}
diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorRememberTokenableFactoryTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorRememberTokenableFactoryTests.cs
new file mode 100644
index 000000000000..5d1b2052e687
--- /dev/null
+++ b/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorRememberTokenableFactoryTests.cs
@@ -0,0 +1,44 @@
+using AutoFixture.Xunit2;
+using Bit.Core.Auth.Models.Business.Tokenables;
+using Bit.Core.Entities;
+using Bit.Core.Settings;
+using Microsoft.Extensions.Time.Testing;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.Auth.Models.Business.Tokenables;
+
+public class TwoFactorRememberTokenableFactoryTests
+{
+ private static readonly DateTime _now = new(2026, 10, 2, 12, 0, 0, DateTimeKind.Utc);
+
+ private static TwoFactorRememberTokenableFactory GetSut(int lifetimeInDays)
+ {
+ var globalSettings = Substitute.For();
+ globalSettings.TwoFactorRememberTokenLifetimeInDays.Returns(lifetimeInDays);
+ var timeProvider = new FakeTimeProvider();
+ timeProvider.SetUtcNow(_now);
+ return new TwoFactorRememberTokenableFactory(globalSettings, timeProvider);
+ }
+
+ [Theory, AutoData]
+ public void CreateToken_BindsUserDeviceAndStamps(User user, Device device)
+ {
+ var token = GetSut(30).CreateToken(user, device, "row-stamp");
+
+ Assert.Equal(user.Id, token.UserId);
+ Assert.Equal(device.Id, token.DeviceId);
+ Assert.Equal(device.Identifier, token.DeviceIdentifier);
+ Assert.Equal("row-stamp", token.Stamp);
+ Assert.Equal(user.SecurityStamp, token.SecurityStamp);
+ Assert.True(token.Valid);
+ }
+
+ [Theory, AutoData]
+ public void CreateToken_HonorsConfiguredLifetime(User user, Device device)
+ {
+ var token = GetSut(7).CreateToken(user, device, "row-stamp");
+
+ Assert.Equal(_now.AddDays(7), token.ExpirationDate);
+ }
+}
diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorRememberTokenableTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorRememberTokenableTests.cs
new file mode 100644
index 000000000000..83248dca323f
--- /dev/null
+++ b/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorRememberTokenableTests.cs
@@ -0,0 +1,248 @@
+using AutoFixture;
+using AutoFixture.Xunit2;
+using Bit.Core.Auth.Models.Business.Tokenables;
+using Bit.Core.Tokens;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.Auth.Models.Business.Tokenables;
+
+// Note: test names follow MethodName_StateUnderTest_ExpectedBehavior pattern.
+public class TwoFactorRememberTokenableTests
+{
+ private static DataProtectorTokenFactory GetSigningFactory()
+ {
+ var fixture = new Fixture();
+ return new DataProtectorTokenFactory(
+ TwoFactorRememberTokenable.ClearTextPrefix,
+ TwoFactorRememberTokenable.DataProtectorPurpose,
+ fixture.Create(),
+ Substitute.For>>());
+ }
+
+ private static TwoFactorRememberTokenable NewTokenable(
+ Guid? userId = null,
+ Guid? deviceId = null,
+ string deviceIdentifier = "device-identifier",
+ string stamp = "row-stamp",
+ string securityStamp = "user-security-stamp") =>
+ new()
+ {
+ UserId = userId ?? Guid.NewGuid(),
+ DeviceId = deviceId ?? Guid.NewGuid(),
+ DeviceIdentifier = deviceIdentifier,
+ Stamp = stamp,
+ SecurityStamp = securityStamp,
+ ExpirationDate = DateTime.UtcNow.AddDays(30),
+ };
+
+ ///
+ /// A token constructed without going through the factory has no expiry, so it is expired and never
+ /// valid. Callers that bypass the factory fail closed instead of getting an ad-hoc lifetime.
+ ///
+ [Fact]
+ public void Constructor_WithoutFactory_IsExpiredAndInvalid()
+ {
+ var token = new TwoFactorRememberTokenable();
+
+ Assert.Equal(default, token.ExpirationDate);
+ Assert.True(token.IsExpired);
+ Assert.False(token.Valid);
+ }
+
+ [Fact]
+ public void InternalConstructor_ValidArguments_BindsEveryField()
+ {
+ var userId = Guid.NewGuid();
+ var deviceId = Guid.NewGuid();
+
+ var token = new TwoFactorRememberTokenable(userId, deviceId, "device-identifier", "row-stamp", "user-stamp");
+
+ Assert.Equal(userId, token.UserId);
+ Assert.Equal(deviceId, token.DeviceId);
+ Assert.Equal("device-identifier", token.DeviceIdentifier);
+ Assert.Equal("row-stamp", token.Stamp);
+ Assert.Equal("user-stamp", token.SecurityStamp);
+ Assert.Equal(TwoFactorRememberTokenable.TokenIdentifier, token.Identifier);
+ }
+
+ [Fact]
+ public void InternalConstructor_DefaultUserId_Throws()
+ {
+ Assert.Throws(() =>
+ new TwoFactorRememberTokenable(default, Guid.NewGuid(), "device-identifier", "row-stamp", "user-stamp"));
+ }
+
+ [Fact]
+ public void InternalConstructor_DefaultDeviceId_Throws()
+ {
+ Assert.Throws(() =>
+ new TwoFactorRememberTokenable(Guid.NewGuid(), default, "device-identifier", "row-stamp", "user-stamp"));
+ }
+
+ [Theory]
+ [InlineData("", "row-stamp", "user-stamp")]
+ [InlineData(" ", "row-stamp", "user-stamp")]
+ [InlineData("device-identifier", "", "user-stamp")]
+ [InlineData("device-identifier", " ", "user-stamp")]
+ [InlineData("device-identifier", "row-stamp", "")]
+ [InlineData("device-identifier", "row-stamp", " ")]
+ public void InternalConstructor_BlankStringArgument_Throws(
+ string deviceIdentifier, string stamp, string securityStamp)
+ {
+ Assert.Throws(() =>
+ new TwoFactorRememberTokenable(Guid.NewGuid(), Guid.NewGuid(), deviceIdentifier, stamp, securityStamp));
+ }
+
+ [Fact]
+ public void ValidateTwoFactorRememberToken_ValidToken_ReturnsNullAndTheToken()
+ {
+ var factory = GetSigningFactory();
+ var token = NewTokenable();
+
+ var error = TwoFactorRememberTokenable.ValidateTwoFactorRememberToken(
+ factory, factory.Protect(token), out var recovered);
+
+ Assert.Null(error);
+ Assert.NotNull(recovered);
+ Assert.Equal(token.UserId, recovered.UserId);
+ }
+
+ [Fact]
+ public void ValidateTwoFactorRememberToken_UnprotectFails_ReturnsInvalidToken()
+ {
+ var error = TwoFactorRememberTokenable.ValidateTwoFactorRememberToken(
+ GetSigningFactory(), "not-a-token", out var recovered);
+
+ Assert.Equal(TokenableValidationError.InvalidToken, error);
+ Assert.Null(recovered);
+ }
+
+ [Fact]
+ public void ValidateTwoFactorRememberToken_ExpiredToken_ReturnsExpired()
+ {
+ var factory = GetSigningFactory();
+ var token = NewTokenable();
+ token.ExpirationDate = DateTime.UtcNow.AddMinutes(-1);
+
+ var error = TwoFactorRememberTokenable.ValidateTwoFactorRememberToken(
+ factory, factory.Protect(token), out _);
+
+ Assert.Equal(TokenableValidationError.ExpiringTokenables.Expired, error);
+ }
+
+ [Fact]
+ public void ValidateTwoFactorRememberToken_MissingField_ReturnsInvalidToken()
+ {
+ var factory = GetSigningFactory();
+ var token = NewTokenable(stamp: " ");
+
+ var error = TwoFactorRememberTokenable.ValidateTwoFactorRememberToken(
+ factory, factory.Protect(token), out _);
+
+ Assert.Equal(TokenableValidationError.InvalidToken, error);
+ }
+
+ ///
+ /// The binding properties have internal setters, so this round trip is also what proves
+ /// [JsonInclude] is doing its job: without it every field would come back as its default.
+ ///
+ [Fact]
+ public void ProtectUnprotect_ValidToken_PreservesDataAndExpiration()
+ {
+ var factory = GetSigningFactory();
+ var token = NewTokenable();
+ var originalExpiration = token.ExpirationDate;
+
+ var recovered = factory.Unprotect(factory.Protect(token));
+
+ Assert.Equal(token.UserId, recovered.UserId);
+ Assert.Equal(token.DeviceId, recovered.DeviceId);
+ Assert.Equal(token.DeviceIdentifier, recovered.DeviceIdentifier);
+ Assert.Equal(token.Stamp, recovered.Stamp);
+ Assert.Equal(token.SecurityStamp, recovered.SecurityStamp);
+ Assert.Equal(TwoFactorRememberTokenable.TokenIdentifier, recovered.Identifier);
+ Assert.Equal(originalExpiration, recovered.ExpirationDate, TimeSpan.FromSeconds(1));
+ Assert.True(recovered.Valid);
+ }
+
+ [Fact]
+ public void Protect_Always_AppliesClearTextPrefix()
+ {
+ var factory = GetSigningFactory();
+
+ var protectedToken = factory.Protect(NewTokenable());
+
+ Assert.StartsWith(TwoFactorRememberTokenable.ClearTextPrefix, protectedToken);
+ }
+
+ [Fact]
+ public void Valid_Expired_ReturnsFalse()
+ {
+ var token = NewTokenable();
+ token.ExpirationDate = DateTime.UtcNow.AddMinutes(-1);
+
+ Assert.True(token.IsExpired);
+ Assert.False(token.Valid);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public void Valid_MissingDeviceIdentifier_ReturnsFalse(string? deviceIdentifier)
+ {
+ var token = NewTokenable(deviceIdentifier: deviceIdentifier!);
+
+ Assert.False(token.Valid);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public void Valid_MissingStamp_ReturnsFalse(string? stamp)
+ {
+ var token = NewTokenable(stamp: stamp!);
+
+ Assert.False(token.Valid);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public void Valid_MissingSecurityStamp_ReturnsFalse(string? securityStamp)
+ {
+ var token = NewTokenable(securityStamp: securityStamp!);
+
+ Assert.False(token.Valid);
+ }
+
+ [Fact]
+ public void Valid_DefaultUserId_ReturnsFalse()
+ {
+ var token = NewTokenable(userId: default(Guid));
+
+ Assert.False(token.Valid);
+ }
+
+ [Fact]
+ public void Valid_DefaultDeviceId_ReturnsFalse()
+ {
+ var token = NewTokenable(deviceId: default(Guid));
+
+ Assert.False(token.Valid);
+ }
+
+ [Theory, AutoData]
+ public void Valid_WrongIdentifier_ReturnsFalse(string identifier)
+ {
+ var token = NewTokenable();
+ token.Identifier = identifier;
+
+ Assert.False(token.Valid);
+ }
+}
diff --git a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/IssueTwoFactorRememberTokenCommandTests.cs b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/IssueTwoFactorRememberTokenCommandTests.cs
new file mode 100644
index 000000000000..682789feadb4
--- /dev/null
+++ b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/IssueTwoFactorRememberTokenCommandTests.cs
@@ -0,0 +1,147 @@
+using Bit.Core.Auth.Entities;
+using Bit.Core.Auth.Models.Business.Tokenables;
+using Bit.Core.Auth.Repositories;
+using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations;
+using Bit.Core.Entities;
+using Bit.Core.Tokens;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.Auth.UserFeatures.TwoFactorAuth;
+
+[SutProviderCustomize]
+public class IssueTwoFactorRememberTokenCommandTests
+{
+ private static readonly DateTime _expiration = new(2026, 11, 1, 12, 0, 0, DateTimeKind.Utc);
+
+ ///
+ /// Stands in for the real factory: binds the user and device it is given, carries whatever stamp the
+ /// command generated, and expires at a fixed instant.
+ ///
+ private static void ArrangeFactory(
+ SutProvider sutProvider, User user, Device device)
+ {
+ sutProvider.GetDependency()
+ .CreateToken(user, device, Arg.Any())
+ .Returns(c => new TwoFactorRememberTokenable(
+ user.Id, device.Id, device.Identifier, c.ArgAt(2), user.SecurityStamp)
+ {
+ ExpirationDate = _expiration,
+ });
+ }
+
+ ///
+ /// The stamp written to the row is the stamp embedded in the token, or validation could
+ /// never succeed.
+ ///
+ [Theory, BitAutoData]
+ public async Task IssueAsync_UpsertsRowWithStampMatchingToken(
+ SutProvider sutProvider,
+ User user,
+ Device device)
+ {
+ device.UserId = user.Id;
+ ArrangeFactory(sutProvider, user, device);
+ TwoFactorRememberToken? saved = null;
+ sutProvider.GetDependency()
+ .UpsertAsync(Arg.Do(t => saved = t))
+ .Returns(c => c.Arg());
+
+ TwoFactorRememberTokenable? minted = null;
+ sutProvider.GetDependency>()
+ .Protect(Arg.Do(t => minted = t))
+ .Returns("protected-token");
+
+ var result = await sutProvider.Sut.IssueAsync(user, device);
+
+ Assert.Equal("protected-token", result);
+ Assert.NotNull(saved);
+ Assert.NotNull(minted);
+ Assert.Equal(user.Id, saved.UserId);
+ Assert.Equal(device.Id, saved.DeviceId);
+ Assert.Equal(saved.Stamp, minted.Stamp);
+ Assert.Equal(user.Id, minted.UserId);
+ Assert.Equal(device.Id, minted.DeviceId);
+ Assert.Equal(device.Identifier, minted.DeviceIdentifier);
+ Assert.Equal(user.SecurityStamp, minted.SecurityStamp);
+ }
+
+ ///
+ /// Re-remembering a device writes a different stamp, which is what orphans the token that
+ /// device was holding.
+ ///
+ [Theory, BitAutoData]
+ public async Task IssueAsync_CalledTwice_WritesDifferentStampEachTime(
+ SutProvider sutProvider,
+ User user,
+ Device device)
+ {
+ device.UserId = user.Id;
+ ArrangeFactory(sutProvider, user, device);
+ var stamps = new List();
+ sutProvider.GetDependency()
+ .UpsertAsync(Arg.Do(t => stamps.Add(t.Stamp)))
+ .Returns(c => c.Arg());
+
+ await sutProvider.Sut.IssueAsync(user, device);
+ await sutProvider.Sut.IssueAsync(user, device);
+
+ Assert.Equal(2, stamps.Count);
+ Assert.NotEqual(stamps[0], stamps[1]);
+ }
+
+ ///
+ /// The row takes its expiry from the minted token, so the two lapse at the same instant.
+ ///
+ [Theory, BitAutoData]
+ public async Task IssueAsync_RowAndTokenShareExpiration(
+ SutProvider sutProvider,
+ User user,
+ Device device)
+ {
+ device.UserId = user.Id;
+ ArrangeFactory(sutProvider, user, device);
+ TwoFactorRememberToken? saved = null;
+ sutProvider.GetDependency()
+ .UpsertAsync(Arg.Do(t => saved = t))
+ .Returns(c => c.Arg());
+
+ TwoFactorRememberTokenable? minted = null;
+ sutProvider.GetDependency>()
+ .Protect(Arg.Do(t => minted = t))
+ .Returns("protected-token");
+
+ await sutProvider.Sut.IssueAsync(user, device);
+
+ Assert.NotNull(saved);
+ Assert.NotNull(minted);
+ Assert.Equal(_expiration, saved.ExpirationDate);
+ Assert.Equal(_expiration, minted.ExpirationDate);
+ }
+
+ ///
+ /// Stamps are lower-case GUID text on every write path, which is what makes the ordinal,
+ /// case-sensitive comparison at validation time safe.
+ ///
+ [Theory, BitAutoData]
+ public async Task IssueAsync_StampIsLowerCaseGuidText(
+ SutProvider sutProvider,
+ User user,
+ Device device)
+ {
+ device.UserId = user.Id;
+ ArrangeFactory(sutProvider, user, device);
+ TwoFactorRememberToken? saved = null;
+ sutProvider.GetDependency()
+ .UpsertAsync(Arg.Do(t => saved = t))
+ .Returns(c => c.Arg());
+
+ await sutProvider.Sut.IssueAsync(user, device);
+
+ Assert.NotNull(saved);
+ Assert.True(Guid.TryParse(saved.Stamp, out _));
+ Assert.Equal(saved.Stamp.ToLowerInvariant(), saved.Stamp);
+ }
+}
diff --git a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/RevokeTwoFactorRememberTokensCommandTests.cs b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/RevokeTwoFactorRememberTokensCommandTests.cs
new file mode 100644
index 000000000000..7df8efa7765b
--- /dev/null
+++ b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/RevokeTwoFactorRememberTokensCommandTests.cs
@@ -0,0 +1,68 @@
+using Bit.Core.Auth.Repositories;
+using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Microsoft.Extensions.Time.Testing;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.Auth.UserFeatures.TwoFactorAuth;
+
+[SutProviderCustomize]
+public class RevokeTwoFactorRememberTokensCommandTests
+{
+ private static readonly DateTime _now = new(2026, 10, 2, 12, 0, 0, DateTimeKind.Utc);
+
+ private static SutProvider GetSutProvider()
+ {
+ var sutProvider = new SutProvider()
+ .WithFakeTimeProvider()
+ .Create();
+ sutProvider.GetDependency().SetUtcNow(_now);
+ return sutProvider;
+ }
+
+ [Theory, BitAutoData]
+ public async Task RevokeAllForUserAsync_RotatesStampsForThatUser(Guid userId)
+ {
+ var sutProvider = GetSutProvider();
+
+ await sutProvider.Sut.RevokeAllForUserAsync(userId);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .RotateStampsByUserIdAsync(userId, _now);
+ }
+
+ ///
+ /// Revocation is scoped to remember-me. It must not delete rows, which would destroy the record
+ /// of when each device was first remembered.
+ ///
+ [Theory, BitAutoData]
+ public async Task RevokeAllForUserAsync_DoesNotDeleteRows(Guid userId)
+ {
+ var sutProvider = GetSutProvider();
+
+ await sutProvider.Sut.RevokeAllForUserAsync(userId);
+
+ await sutProvider.GetDependency()
+ .DidNotReceiveWithAnyArgs()
+ .DeleteExpiredAsync(default);
+ }
+
+ ///
+ /// Failures propagate. Call sites rely on this: they revoke before the write that commits a
+ /// teardown so that a failure leaves a state the caller can retry.
+ ///
+ [Theory, BitAutoData]
+ public async Task RevokeAllForUserAsync_RepositoryThrows_Propagates(Guid userId)
+ {
+ var sutProvider = GetSutProvider();
+ sutProvider.GetDependency()
+ .RotateStampsByUserIdAsync(userId, _now)
+ .Returns(Task.FromException(new InvalidOperationException("database unavailable")));
+
+ await Assert.ThrowsAsync(
+ () => sutProvider.Sut.RevokeAllForUserAsync(userId));
+ }
+}
diff --git a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/ValidateTwoFactorRememberTokenQueryTests.cs b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/ValidateTwoFactorRememberTokenQueryTests.cs
new file mode 100644
index 000000000000..17ac6c5cdd34
--- /dev/null
+++ b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/ValidateTwoFactorRememberTokenQueryTests.cs
@@ -0,0 +1,242 @@
+using Bit.Core.Auth.Entities;
+using Bit.Core.Auth.Models.Business.Tokenables;
+using Bit.Core.Auth.Repositories;
+using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations;
+using Bit.Core.Entities;
+using Bit.Core.Tokens;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Microsoft.Extensions.Time.Testing;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.Auth.UserFeatures.TwoFactorAuth;
+
+///
+/// One test per rejection path. Each is asserted in isolation — a test that broke two checks at once
+/// would still pass if one of them were dropped from the implementation.
+///
+[SutProviderCustomize]
+public class ValidateTwoFactorRememberTokenQueryTests
+{
+ private const string _deviceIdentifier = "device-identifier";
+ private const string _token = "protected-token";
+
+ private static readonly DateTime _now = new(2026, 10, 2, 12, 0, 0, DateTimeKind.Utc);
+
+ private static SutProvider GetSutProvider()
+ {
+ var sutProvider = new SutProvider()
+ .WithFakeTimeProvider()
+ .Create();
+ sutProvider.GetDependency().SetUtcNow(_now);
+ return sutProvider;
+ }
+
+ private static TwoFactorRememberTokenable Tokenable(User user, Guid deviceId, string stamp) =>
+ new()
+ {
+ UserId = user.Id,
+ DeviceId = deviceId,
+ DeviceIdentifier = _deviceIdentifier,
+ Stamp = stamp,
+ SecurityStamp = user.SecurityStamp,
+ // Real clock: ExpiringTokenable reads DateTime.UtcNow directly.
+ ExpirationDate = DateTime.UtcNow.AddDays(30),
+ };
+
+ private static TwoFactorRememberToken Row(User user, Guid deviceId, string stamp) =>
+ new()
+ {
+ Id = Guid.NewGuid(),
+ UserId = user.Id,
+ DeviceId = deviceId,
+ Stamp = stamp,
+ ExpirationDate = _now.AddDays(1),
+ };
+
+ ///
+ /// Wires the happy path: token unprotects and the row exists with a matching stamp.
+ /// Individual tests then break exactly one of those.
+ ///
+ private static (TwoFactorRememberTokenable tokenable, TwoFactorRememberToken row) ArrangeValid(
+ SutProvider sutProvider, User user, Guid deviceId)
+ {
+ const string stamp = "row-stamp";
+ var tokenable = Tokenable(user, deviceId, stamp);
+ var row = Row(user, deviceId, stamp);
+
+ sutProvider.GetDependency>()
+ .TryUnprotect(_token, out Arg.Any())
+ .Returns(c => { c[1] = tokenable; return true; });
+
+ sutProvider.GetDependency()
+ .GetByUserIdDeviceIdAsync(user.Id, deviceId)
+ .Returns(row);
+
+ return (tokenable, row);
+ }
+
+ /// The happy path.
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_EverythingMatches_ReturnsTrue(
+ User user, Guid deviceId)
+ {
+ var sutProvider = GetSutProvider();
+ ArrangeValid(sutProvider, user, deviceId);
+
+ Assert.True(await sutProvider.Sut.ValidateAsync(user, _deviceIdentifier, _token));
+ }
+
+ /// The targeted revocation: the device's row stamp has been rotated.
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_RowStampRotated_ReturnsFalse(
+ User user, Guid deviceId)
+ {
+ var sutProvider = GetSutProvider();
+ var (_, row) = ArrangeValid(sutProvider, user, deviceId);
+ row.Stamp = "rotated-stamp";
+
+ Assert.False(await sutProvider.Sut.ValidateAsync(user, _deviceIdentifier, _token));
+ }
+
+ /// The account-wide check inherited from the previous design.
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_UserSecurityStampRotated_ReturnsFalse(
+ User user, Guid deviceId)
+ {
+ var sutProvider = GetSutProvider();
+ var (tokenable, _) = ArrangeValid(sutProvider, user, deviceId);
+ tokenable.SecurityStamp = "a-different-security-stamp";
+
+ Assert.False(await sutProvider.Sut.ValidateAsync(user, _deviceIdentifier, _token));
+ }
+
+ /// A token issued to a different user.
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_UserIdMismatch_ReturnsFalse(
+ User user, Guid deviceId)
+ {
+ var sutProvider = GetSutProvider();
+ var (tokenable, _) = ArrangeValid(sutProvider, user, deviceId);
+ tokenable.UserId = Guid.NewGuid();
+
+ Assert.False(await sutProvider.Sut.ValidateAsync(user, _deviceIdentifier, _token));
+ }
+
+ /// The device binding.
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_DeviceIdentifierMismatch_ReturnsFalse(
+ User user, Guid deviceId)
+ {
+ var sutProvider = GetSutProvider();
+ ArrangeValid(sutProvider, user, deviceId);
+
+ Assert.False(await sutProvider.Sut.ValidateAsync(user, "a-different-device", _token));
+ }
+
+ ///
+ /// Identifiers are matched the way SQL collation matches them, so a case difference must
+ /// not cause a spurious challenge.
+ ///
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_DeviceIdentifierDiffersOnlyByCase_ReturnsTrue(
+ User user, Guid deviceId)
+ {
+ var sutProvider = GetSutProvider();
+ ArrangeValid(sutProvider, user, deviceId);
+
+ Assert.True(await sutProvider.Sut.ValidateAsync(user, _deviceIdentifier.ToUpperInvariant(), _token));
+ }
+
+ /// No row for this device.
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_RowMissing_ReturnsFalse(
+ User user, Guid deviceId)
+ {
+ var sutProvider = GetSutProvider();
+ ArrangeValid(sutProvider, user, deviceId);
+ sutProvider.GetDependency()
+ .GetByUserIdDeviceIdAsync(user.Id, deviceId)
+ .Returns((TwoFactorRememberToken?)null);
+
+ Assert.False(await sutProvider.Sut.ValidateAsync(user, _deviceIdentifier, _token));
+ }
+
+ /// The row outlived its expiry but the sweep has not reached it.
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_RowExpired_ReturnsFalse(
+ User user, Guid deviceId)
+ {
+ var sutProvider = GetSutProvider();
+ var (_, row) = ArrangeValid(sutProvider, user, deviceId);
+ row.ExpirationDate = _now.AddMinutes(-1);
+
+ Assert.False(await sutProvider.Sut.ValidateAsync(user, _deviceIdentifier, _token));
+ }
+
+ ///
+ /// Expiry is exclusive of the expiry instant, matching ExpiringTokenable.IsExpired: a row is
+ /// still honored at exactly its ExpirationDate and refused one tick later.
+ ///
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_RowExpiresExactlyNow_ReturnsTrue(User user, Guid deviceId)
+ {
+ var sutProvider = GetSutProvider();
+ var (_, row) = ArrangeValid(sutProvider, user, deviceId);
+ row.ExpirationDate = _now;
+
+ Assert.True(await sutProvider.Sut.ValidateAsync(user, _deviceIdentifier, _token));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_RowExpiredOneTickAgo_ReturnsFalse(User user, Guid deviceId)
+ {
+ var sutProvider = GetSutProvider();
+ var (_, row) = ArrangeValid(sutProvider, user, deviceId);
+ row.ExpirationDate = _now.AddTicks(-1);
+
+ Assert.False(await sutProvider.Sut.ValidateAsync(user, _deviceIdentifier, _token));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_TokenCannotBeUnprotected_ReturnsFalse(
+ User user)
+ {
+ var sutProvider = GetSutProvider();
+ sutProvider.GetDependency>()
+ .TryUnprotect(_token, out Arg.Any())
+ .Returns(c => { c[1] = null!; return false; });
+
+ Assert.False(await sutProvider.Sut.ValidateAsync(user, _deviceIdentifier, _token));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_TokenItselfExpired_ReturnsFalse(
+ User user, Guid deviceId)
+ {
+ var sutProvider = GetSutProvider();
+ var (tokenable, _) = ArrangeValid(sutProvider, user, deviceId);
+ // Real clock on purpose: ExpiringTokenable.IsExpired reads DateTime.UtcNow directly, so the
+ // injected TimeProvider cannot reach it.
+ tokenable.ExpirationDate = DateTime.UtcNow.AddMinutes(-1);
+
+ Assert.False(await sutProvider.Sut.ValidateAsync(user, _deviceIdentifier, _token));
+ }
+
+ /// Validation reads; it must never write.
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_Always_DoesNotWrite(
+ User user, Guid deviceId)
+ {
+ var sutProvider = GetSutProvider();
+ ArrangeValid(sutProvider, user, deviceId);
+
+ await sutProvider.Sut.ValidateAsync(user, _deviceIdentifier, _token);
+
+ var repository = sutProvider.GetDependency();
+ await repository.DidNotReceiveWithAnyArgs().UpsertAsync(default!);
+ await repository.DidNotReceiveWithAnyArgs().RotateStampsByUserIdAsync(default, default);
+ await repository.DidNotReceiveWithAnyArgs().DeleteExpiredAsync(default);
+ }
+}
diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/TwoFactorRememberTokenRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/TwoFactorRememberTokenRepositoryTests.cs
new file mode 100644
index 000000000000..0ccf0ce482c8
--- /dev/null
+++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/TwoFactorRememberTokenRepositoryTests.cs
@@ -0,0 +1,512 @@
+using AutoMapper;
+using Bit.Core.Auth.Entities;
+using Bit.Core.Auth.Repositories;
+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 Microsoft.Data.SqlClient;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+using DapperTwoFactorRememberTokenRepository = Bit.Infrastructure.Dapper.Auth.Repositories.TwoFactorRememberTokenRepository;
+using EfTwoFactorRememberToken = Bit.Infrastructure.EntityFramework.Auth.Models.TwoFactorRememberToken;
+using EfTwoFactorRememberTokenRepository = Bit.Infrastructure.EntityFramework.Auth.Repositories.TwoFactorRememberTokenRepository;
+
+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();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 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);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 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.
+ ///
+ ///
+ /// Whether the two calls actually collide is timing-dependent, so this is a regression guard on the
+ /// common path. The two tests that follow force each repository's fallbacks deterministically.
+ ///
+ [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" });
+ }
+
+ ///
+ /// A repository whose first read returns a canned value, standing in for a read that was stale by
+ /// the time the write happened.
+ ///
+ private sealed class StaleFirstReadDapperRepository(string connectionString, TwoFactorRememberToken? staleRow)
+ : DapperTwoFactorRememberTokenRepository(connectionString, connectionString)
+ {
+ private bool _served;
+
+ public override Task GetByUserIdDeviceIdAsync(Guid userId, Guid deviceId)
+ {
+ if (_served)
+ {
+ return base.GetByUserIdDeviceIdAsync(userId, deviceId);
+ }
+
+ _served = true;
+ return Task.FromResult(staleRow);
+ }
+ }
+
+ ///
+ private sealed class StaleFirstReadEfRepository(
+ IServiceScopeFactory serviceScopeFactory, IMapper mapper, TwoFactorRememberToken? staleRow)
+ : EfTwoFactorRememberTokenRepository(serviceScopeFactory, mapper)
+ {
+ private bool _served;
+
+ public override Task GetByUserIdDeviceIdAsync(Guid userId, Guid deviceId)
+ {
+ if (_served)
+ {
+ return base.GetByUserIdDeviceIdAsync(userId, deviceId);
+ }
+
+ _served = true;
+ return Task.FromResult(staleRow);
+ }
+ }
+
+ private static ITwoFactorRememberTokenRepository CreateStaleFirstReadRepository(
+ Database database, IServiceProvider services, TwoFactorRememberToken? staleRow) =>
+ database.Type == SupportedDatabaseProviders.SqlServer && !database.UseEf
+ ? new StaleFirstReadDapperRepository(database.ConnectionString, staleRow)
+ : new StaleFirstReadEfRepository(
+ services.GetRequiredService(),
+ services.GetRequiredService(),
+ staleRow);
+
+ ///
+ /// Deletes a token row at the database level, standing in for the expiry sweep.
+ ///
+ private static async Task DeleteTokenRowAtDatabaseLevelAsync(
+ IServiceProvider services, Database database, Guid id)
+ {
+ if (database.Type == SupportedDatabaseProviders.SqlServer && !database.UseEf)
+ {
+ await using var connection = new SqlConnection(database.ConnectionString);
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
+ await using var command = connection.CreateCommand();
+ command.CommandText = "DELETE FROM [dbo].[TwoFactorRememberToken] WHERE [Id] = @Id";
+ command.Parameters.Add(new SqlParameter("@Id", id));
+ await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
+ return;
+ }
+
+ using var scope = services.CreateScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+ await dbContext.TwoFactorRememberTokens.Where(r => r.Id == id).ExecuteDeleteAsync(TestContext.Current.CancellationToken);
+ }
+
+ ///
+ /// The read finds no row, but a concurrent login inserts one before this call's insert runs. The
+ /// unique index rejects the insert, and the repository must update the winner's row rather than
+ /// fail the login.
+ ///
+ [Theory, DatabaseData]
+ public async Task UpsertAsync_RowAppearsAfterRead_UpdatesTheWinnersRow(
+ Database database,
+ IServiceProvider services,
+ 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, "winner"));
+ var winner = await sut.GetByUserIdDeviceIdAsync(user.Id, device.Id);
+ Assert.NotNull(winner);
+
+ var loser = CreateStaleFirstReadRepository(database, services, staleRow: null);
+ await loser.UpsertAsync(NewToken(user.Id, device.Id, "loser"));
+
+ var row = await sut.GetByUserIdDeviceIdAsync(user.Id, device.Id);
+ Assert.NotNull(row);
+ Assert.Equal("loser", row.Stamp);
+ Assert.Equal(winner.Id, row.Id);
+ Assert.Equal(winner.CreationDate, row.CreationDate);
+ }
+
+ ///
+ /// The read finds a row, which the expiry sweep then deletes before the update runs. The update
+ /// matches nothing, so the repository must create the row instead of returning a token with no
+ /// row behind it.
+ ///
+ [Theory, DatabaseData]
+ public async Task UpsertAsync_RowDeletedAfterRead_CreatesTheRow(
+ Database database,
+ IServiceProvider services,
+ 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, "original"));
+ var staleRow = await sut.GetByUserIdDeviceIdAsync(user.Id, device.Id);
+ Assert.NotNull(staleRow);
+
+ await DeleteTokenRowAtDatabaseLevelAsync(services, database, staleRow.Id);
+
+ var repository = CreateStaleFirstReadRepository(database, services, staleRow);
+ await repository.UpsertAsync(NewToken(user.Id, device.Id, "replacement"));
+
+ var row = await sut.GetByUserIdDeviceIdAsync(user.Id, device.Id);
+ Assert.NotNull(row);
+ Assert.Equal("replacement", row.Stamp);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 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, DateTime.UtcNow);
+
+ 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, DateTime.UtcNow);
+
+ var untouched = await sut.GetByUserIdDeviceIdAsync(untouchedUser.Id, untouchedDevice.Id);
+
+ Assert.NotNull(untouched);
+ Assert.Equal("stamp-untouched", untouched.Stamp);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 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));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 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));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 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", CombGuid.Generate()));
+ 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 = CombGuid.Generate(),
+ UserId = userId,
+ DeviceId = deviceId,
+ Stamp = "stamp-two",
+ CreationDate = DateTime.UtcNow,
+ RevisionDate = DateTime.UtcNow,
+ ExpirationDate = DateTime.UtcNow.AddDays(30),
+ });
+ await dbContext.SaveChangesAsync();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 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-10-17_00_CreateTwoFactorRememberToken.sql b/util/Migrator/DbScripts/2026-10-17_00_CreateTwoFactorRememberToken.sql
new file mode 100644
index 000000000000..a0ef9ee01dda
--- /dev/null
+++ b/util/Migrator/DbScripts/2026-10-17_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_Create]
+ @Id UNIQUEIDENTIFIER,
+ @UserId UNIQUEIDENTIFIER,
+ @DeviceId UNIQUEIDENTIFIER,
+ @Stamp NVARCHAR(50),
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7),
+ @ExpirationDate DATETIME2(7)
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ INSERT INTO [dbo].[TwoFactorRememberToken]
+ (
+ [Id],
+ [UserId],
+ [DeviceId],
+ [Stamp],
+ [CreationDate],
+ [RevisionDate],
+ [ExpirationDate]
+ )
+ VALUES
+ (
+ @Id,
+ @UserId,
+ @DeviceId,
+ @Stamp,
+ @CreationDate,
+ @RevisionDate,
+ @ExpirationDate
+ )
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[TwoFactorRememberToken_Update]
+ @Id UNIQUEIDENTIFIER,
+ @Stamp NVARCHAR(50),
+ @RevisionDate DATETIME2(7),
+ @ExpirationDate DATETIME2(7)
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ -- CreationDate is deliberately not updated: it records when the device was first remembered.
+ UPDATE
+ [dbo].[TwoFactorRememberToken]
+ SET
+ [Stamp] = @Stamp,
+ [RevisionDate] = @RevisionDate,
+ [ExpirationDate] = @ExpirationDate
+ WHERE
+ [Id] = @Id
+
+ -- Zero when the row no longer exists, e.g. the expiry sweep removed it after the caller read it.
+ SELECT @@ROWCOUNT
+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_DeleteManyExpired]
+ @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 = 1000
+
+ WHILE @BatchSize > 0
+ BEGIN
+ DELETE TOP(@BatchSize)
+ FROM
+ [dbo].[TwoFactorRememberToken]
+ WHERE
+ [ExpirationDate] < @Now
+
+ SET @BatchSize = @@ROWCOUNT
+ END
+END
+GO
diff --git a/util/MySqlMigrations/Migrations/20261017122324_CreateTwoFactorRememberToken.Designer.cs b/util/MySqlMigrations/Migrations/20261017122324_CreateTwoFactorRememberToken.Designer.cs
new file mode 100644
index 000000000000..865a4524dd06
--- /dev/null
+++ b/util/MySqlMigrations/Migrations/20261017122324_CreateTwoFactorRememberToken.Designer.cs
@@ -0,0 +1,4681 @@
+//
+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("20261017122324_CreateTwoFactorRememberToken")]
+ partial class CreateTwoFactorRememberToken
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.11")
+ .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("MaxAutoscalePamSeats")
+ .HasColumnType("int");
+
+ 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("PamSeatMinimum")
+ .HasColumnType("int");
+
+ b.Property("PamSeats")
+ .HasColumnType("int");
+
+ 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