diff --git a/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs
index e8c8a33f56..ea8347653b 100644
--- a/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs
+++ b/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs
@@ -6,11 +6,12 @@ namespace Bit.Core.Auth.Models.Business.Tokenables;
// This token just provides a verifiable authN mechanism for the API service
// TwoFactorController.cs SendEmailLogin anonymous endpoint so it cannot be
-// used maliciously.
+// used maliciously.
public class SsoEmail2faSessionTokenable : ExpiringTokenable
{
// Just over 2 min expiration (client expires session after 2 min)
- private static readonly TimeSpan _tokenLifetime = TimeSpan.FromMinutes(2.05);
+ public static TimeSpan GetTokenLifetime() => TimeSpan.FromMinutes(2.05);
+
public const string ClearTextPrefix = "BwSsoEmail2FaSessionToken_";
public const string DataProtectorPurpose = "SsoEmail2faSessionTokenDataProtector";
@@ -24,7 +25,7 @@ public class SsoEmail2faSessionTokenable : ExpiringTokenable
[JsonConstructor]
public SsoEmail2faSessionTokenable()
{
- ExpirationDate = DateTime.UtcNow.Add(_tokenLifetime);
+ ExpirationDate = DateTime.UtcNow.Add(GetTokenLifetime());
}
public SsoEmail2faSessionTokenable(User user) : this()
@@ -44,7 +45,7 @@ public class SsoEmail2faSessionTokenable : ExpiringTokenable
Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase);
}
- // Validates deserialized
+ // Validates deserialized
protected override bool TokenIsValid() =>
Identifier == TokenIdentifier && Id != default && !string.IsNullOrWhiteSpace(Email);
}
diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenableTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenableTests.cs
new file mode 100644
index 0000000000..8d989710fc
--- /dev/null
+++ b/test/Core.Test/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenableTests.cs
@@ -0,0 +1,173 @@
+using AutoFixture.Xunit2;
+using Bit.Core.Auth.Models.Business.Tokenables;
+using Bit.Core.Entities;
+using Bit.Core.Tokens;
+using Xunit;
+namespace Bit.Core.Test.Auth.Models.Business.Tokenables;
+
+// Note: these test names follow MethodName_StateUnderTest_ExpectedBehavior pattern.
+public class SsoEmail2faSessionTokenableTests
+{
+ // Allow a small tolerance for possible execution delays or clock precision to avoid flaky tests.
+ private static readonly TimeSpan _timeTolerance = TimeSpan.FromMilliseconds(10);
+
+ ///
+ /// Tests the default constructor behavior when passed a null user.
+ ///
+ [Fact]
+ public void Constructor_NullUser_PropertiesSetToDefault()
+ {
+ var token = new SsoEmail2faSessionTokenable(null);
+
+ Assert.Equal(default, token.Id);
+ Assert.Equal(default, token.Email);
+ }
+
+ ///
+ /// Tests that when a valid user is provided to the constructor, the resulting token properties match the user.
+ ///
+ [Theory, AutoData]
+ public void Constructor_ValidUser_PropertiesSetFromUser(User user)
+ {
+ var token = new SsoEmail2faSessionTokenable(user);
+
+ Assert.Equal(user.Id, token.Id);
+ Assert.Equal(user.Email, token.Email);
+ }
+
+ ///
+ /// Tests the default expiration behavior immediately after initialization.
+ ///
+ [Fact]
+ public void Constructor_AfterInitialization_ExpirationSetToExpectedDuration()
+ {
+ var token = new SsoEmail2faSessionTokenable();
+ var expectedExpiration = DateTime.UtcNow + SsoEmail2faSessionTokenable.GetTokenLifetime();
+
+ Assert.True(expectedExpiration - token.ExpirationDate < _timeTolerance);
+ }
+
+ ///
+ /// Tests that a custom expiration date is preserved after token initialization.
+ ///
+ [Fact]
+ public void Constructor_CustomExpirationDate_ExpirationMatchesProvidedValue()
+ {
+ var customExpiration = DateTime.UtcNow.AddHours(3);
+ var token = new SsoEmail2faSessionTokenable
+ {
+ ExpirationDate = customExpiration
+ };
+
+ Assert.True((customExpiration - token.ExpirationDate).Duration() < _timeTolerance);
+ }
+
+ ///
+ /// Tests the validity of a token initialized with a null user.
+ ///
+ [Fact]
+ public void Valid_NullUser_ReturnsFalse()
+ {
+ var token = new SsoEmail2faSessionTokenable(null);
+
+ Assert.False(token.Valid);
+ }
+
+ ///
+ /// Tests the validity of a token with a non-matching identifier.
+ ///
+ [Theory, AutoData]
+ public void Valid_WrongIdentifier_ReturnsFalse(User user)
+ {
+ var token = new SsoEmail2faSessionTokenable(user)
+ {
+ Identifier = "not correct"
+ };
+
+ Assert.False(token.Valid);
+ }
+
+ ///
+ /// Tests the token validity when user ID is null.
+ ///
+ [Theory, AutoData]
+ public void TokenIsValid_NullUserId_ReturnsFalse(User user)
+ {
+ user.Id = default; // Guid.Empty
+ var token = new SsoEmail2faSessionTokenable(user);
+
+ Assert.False(token.TokenIsValid(user));
+ }
+
+ ///
+ /// Tests the token validity when user's email is null.
+ ///
+ [Theory, AutoData]
+ public void TokenIsValid_NullEmail_ReturnsFalse(User user)
+ {
+ user.Email = null;
+ var token = new SsoEmail2faSessionTokenable(user);
+
+ Assert.False(token.TokenIsValid(user));
+ }
+
+ ///
+ /// Tests the token validity when user ID and email match the token properties.
+ ///
+ [Theory, AutoData]
+ public void TokenIsValid_MatchingUserIdAndEmail_ReturnsTrue(User user)
+ {
+ var token = new SsoEmail2faSessionTokenable(user);
+
+ Assert.True(token.TokenIsValid(user));
+ }
+
+ ///
+ /// Ensures that the token is invalid when the provided user's ID doesn't match the token's ID.
+ ///
+ [Theory, AutoData]
+ public void TokenIsValid_WrongUserId_ReturnsFalse(User user)
+ {
+ // Given a token initialized with a user's details
+ var token = new SsoEmail2faSessionTokenable(user);
+
+ // modify the user's ID
+ user.Id = Guid.NewGuid();
+
+ // Then the token should be considered invalid
+ Assert.False(token.TokenIsValid(user));
+ }
+
+ ///
+ /// Ensures that the token is invalid when the provided user's email doesn't match the token's email.
+ ///
+ [Theory, AutoData]
+ public void TokenIsValid_WrongEmail_ReturnsFalse(User user)
+ {
+ // Given a token initialized with a user's details
+ var token = new SsoEmail2faSessionTokenable(user);
+
+ // modify the user's email
+ user.Email = "nonMatchingEmail@example.com";
+
+ // Then the token should be considered invalid
+ Assert.False(token.TokenIsValid(user));
+ }
+
+ ///
+ /// Tests the deserialization of a token to ensure that the expiration date is preserved.
+ ///
+ [Theory, AutoData]
+ public void FromToken_SerializedToken_PreservesExpirationDate(User user)
+ {
+ var expectedDateTime = DateTime.UtcNow.AddHours(-5);
+ var token = new SsoEmail2faSessionTokenable(user)
+ {
+ ExpirationDate = expectedDateTime
+ };
+
+ var result = Tokenable.FromToken(token.ToToken());
+
+ Assert.Equal(expectedDateTime, result.ExpirationDate, precision: _timeTolerance);
+ }
+}