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); + } +}