From 552757c15e5ccd1ebc6fe5727f84dae4080ea48b Mon Sep 17 00:00:00 2001 From: Sven Date: Mon, 9 Feb 2026 23:29:23 -0600 Subject: [PATCH 01/12] Send better error message when token is expired --- .../OrganizationUsers/AcceptOrgUserCommand.cs | 24 +++++++++++++------ .../Tokenables/OrgUserInviteTokenable.cs | 1 - src/Core/Tokens/ExpiringTokenable.cs | 4 +++- .../AcceptOrgUserCommandTests.cs | 3 +-- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index 50f194b57838..3bd87852e1cb 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -81,16 +81,26 @@ public async Task AcceptOrgUserByEmailTokenAsync(Guid organiza // For backwards compatibility, must check validity of both types of tokens and accept if either is valid // TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete - var newTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken( - _orgUserInviteTokenDataFactory, emailToken, orgUser); + var tokenValidationError = _orgUserInviteTokenDataFactory.TryUnprotect(emailToken, out var decryptedToken) switch + { + true when decryptedToken.IsExpired => "Expired token.", + true when !(decryptedToken.Valid && decryptedToken.TokenIsValid(orgUser)) => "Invalid token.", + false => "Invalid token.", + _ => null + }; - var tokenValid = newTokenValid || - CoreHelpers.UserInviteTokenIsValid(_dataProtector, emailToken, user.Email, orgUser.Id, - _globalSettings); + if (tokenValidationError != null) + { + tokenValidationError = CoreHelpers.UserInviteTokenIsValid(_dataProtector, emailToken, user.Email, + orgUser.Id, + _globalSettings) + ? null + : tokenValidationError; + } - if (!tokenValid) + if (tokenValidationError != null) { - throw new BadRequestException("Invalid token."); + throw new BadRequestException(tokenValidationError); } var existingOrgUserCount = await _organizationUserRepository.GetCountByOrganizationAsync( diff --git a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs index 5be7ed481f2a..1a763f91f0a2 100644 --- a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs @@ -63,7 +63,6 @@ public bool TokenIsValid(Guid orgUserId, string orgUserEmail) protected override bool TokenIsValid() => Identifier == TokenIdentifier && OrgUserId != default && !string.IsNullOrWhiteSpace(OrgUserEmail); - public static bool ValidateOrgUserInviteStringToken( IDataProtectorTokenFactory orgUserInviteTokenDataFactory, string orgUserInviteToken, OrganizationUser orgUser) diff --git a/src/Core/Tokens/ExpiringTokenable.cs b/src/Core/Tokens/ExpiringTokenable.cs index 5e90a2406606..5aac7080fc41 100644 --- a/src/Core/Tokens/ExpiringTokenable.cs +++ b/src/Core/Tokens/ExpiringTokenable.cs @@ -12,7 +12,9 @@ public abstract class ExpiringTokenable : Tokenable /// Checks if the token is still within its valid duration and if its data is valid. /// For data validation, this property relies on the method. /// - public override bool Valid => ExpirationDate > DateTime.UtcNow && TokenIsValid(); + public override bool Valid => !IsExpired && TokenIsValid(); + + public bool IsExpired => ExpirationDate < DateTime.UtcNow; /// /// Validates that the token data properties are correct. diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs index 82d4eceaed72..a017d264edc8 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs @@ -464,8 +464,7 @@ public async Task AcceptOrgUserByToken_ExpiredNewToken_ThrowsBadRequest( var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.AcceptOrgUserByEmailTokenAsync(orgUser.Id, user, newToken, _userService)); - Assert.Equal("Invalid token.", exception.Message); - + Assert.Equal("Expired token.", exception.Message); } [Theory] From bbcdfc6017f47cc9940a29283795586464bfe825 Mon Sep 17 00:00:00 2001 From: Sven Date: Tue, 10 Feb 2026 08:00:40 -0600 Subject: [PATCH 02/12] Add comment indicating frontend usage --- .../OrganizationUsers/AcceptOrgUserCommand.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index 3bd87852e1cb..458f162bb9ed 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -83,6 +83,7 @@ public async Task AcceptOrgUserByEmailTokenAsync(Guid organiza // TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete var tokenValidationError = _orgUserInviteTokenDataFactory.TryUnprotect(emailToken, out var decryptedToken) switch { + // Used by clients to show better error message on token expiration, adjust both as-needed true when decryptedToken.IsExpired => "Expired token.", true when !(decryptedToken.Valid && decryptedToken.TokenIsValid(orgUser)) => "Invalid token.", false => "Invalid token.", From fd710d31977de415917efc911702614d2c9ba4d4 Mon Sep 17 00:00:00 2001 From: Sven Date: Wed, 11 Feb 2026 12:03:15 -0600 Subject: [PATCH 03/12] Add testcase for Invalid Token scenario --- .../AcceptOrgUserCommandTests.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs index a017d264edc8..20bc6345aa7b 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs @@ -467,6 +467,37 @@ public async Task AcceptOrgUserByToken_ExpiredNewToken_ThrowsBadRequest( Assert.Equal("Expired token.", exception.Message); } + [Theory] + [BitAutoData] + public async Task AcceptOrgUserByToken_InvalidNewToken_ThrowsBadRequest( + SutProvider sutProvider, + User user, OrganizationUser orgUser) + { + // Arrange + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order + // to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(orgUser.Id) + .Returns(Task.FromResult(orgUser)); + + // Must come after common mocks as they mutate the org user. + _orgUserInviteTokenableFactory.CreateToken(orgUser).Returns(new OrgUserInviteTokenable(null!) + { + ExpirationDate = DateTime.UtcNow.AddDays(1), + }); + + var newToken = CreateNewToken(orgUser); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AcceptOrgUserByEmailTokenAsync(orgUser.Id, user, newToken, _userService)); + + Assert.Equal("Invalid token.", exception.Message); + } + [Theory] [BitAutoData(OrganizationUserStatusType.Accepted, "Invitation already accepted. You will receive an email when your organization membership is confirmed.")] From 489f70ef6b1782fc283e321d3a75fdffecc55e7f Mon Sep 17 00:00:00 2001 From: Sven Date: Wed, 11 Feb 2026 12:04:15 -0600 Subject: [PATCH 04/12] Update comment in test-case --- .../OrganizationUsers/AcceptOrgUserCommandTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs index 20bc6345aa7b..87376ebd7f64 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs @@ -484,6 +484,7 @@ public async Task AcceptOrgUserByToken_InvalidNewToken_ThrowsBadRequest( .Returns(Task.FromResult(orgUser)); // Must come after common mocks as they mutate the org user. + // Send a null org-user to force an invalid token result _orgUserInviteTokenableFactory.CreateToken(orgUser).Returns(new OrgUserInviteTokenable(null!) { ExpirationDate = DateTime.UtcNow.AddDays(1), From 0d3fc392d322357a23e04aa3b2c0e18a4583408d Mon Sep 17 00:00:00 2001 From: Sven Date: Mon, 2 Mar 2026 16:35:37 -0600 Subject: [PATCH 05/12] Fix merge issue --- .../OrganizationUsers/AcceptOrgUserCommand.cs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index d8b66383890d..e7d0895e775e 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -69,12 +69,18 @@ public async Task AcceptOrgUserByEmailTokenAsync(Guid organiza throw new BadRequestException("User invalid."); } - var tokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken( - _orgUserInviteTokenDataFactory, emailToken, orgUser); - - if (!tokenValid) + var tokenValidationError = _orgUserInviteTokenDataFactory.TryUnprotect(emailToken, out var decryptedToken) switch + { + // Used by clients to show better error message on token expiration, adjust both as-needed + true when decryptedToken.IsExpired => "Expired token.", + true when !(decryptedToken.Valid && decryptedToken.TokenIsValid(orgUser)) => "Invalid token.", + false => "Invalid token.", + _ => null + }; + + if (tokenValidationError != null) { - throw new BadRequestException("Invalid token."); + throw new BadRequestException(tokenValidationError); } var existingOrgUserCount = await _organizationUserRepository.GetCountByOrganizationAsync( From 9322e67f81a9c0776e2e6cdd09bca6fbfd522901 Mon Sep 17 00:00:00 2001 From: Sven Date: Mon, 2 Mar 2026 16:37:40 -0600 Subject: [PATCH 06/12] Fix method name --- .../OrganizationUsers/AcceptOrgUserCommandTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs index 8fe929dd0d72..0bb64c71b688 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs @@ -439,7 +439,7 @@ public async Task AcceptOrgUserByToken_InvalidNewToken_ThrowsBadRequest( ExpirationDate = DateTime.UtcNow.AddDays(1), }); - var newToken = CreateNewToken(orgUser); + var newToken = CreateToken(orgUser); // Act & Assert var exception = await Assert.ThrowsAsync( From 93350af462f0f2c9fc432835c782daab21c06c6d Mon Sep 17 00:00:00 2001 From: Sven Date: Mon, 16 Mar 2026 15:29:16 -0500 Subject: [PATCH 07/12] Consolidate token validation error calculation, apply to new area --- .../OrganizationUsers/AcceptOrgUserCommand.cs | 10 +--- .../Tokenables/OrgUserInviteTokenable.cs | 17 ++++++ .../Implementations/RegisterUserCommand.cs | 8 ++- .../Tokenables/OrgUserInviteTokenableTests.cs | 56 +++++++++++++++++++ 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index ec62520b4098..944ab8928bd8 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -74,14 +74,8 @@ public async Task AcceptOrgUserByEmailTokenAsync(Guid organiza throw new BadRequestException("User invalid."); } - var tokenValidationError = _orgUserInviteTokenDataFactory.TryUnprotect(emailToken, out var decryptedToken) switch - { - // Used by clients to show better error message on token expiration, adjust both as-needed - true when decryptedToken.IsExpired => "Expired token.", - true when !(decryptedToken.Valid && decryptedToken.TokenIsValid(orgUser)) => "Invalid token.", - false => "Invalid token.", - _ => null - }; + var tokenValidationError = OrgUserInviteTokenable.GetOrgUserInviteValidationError( + _orgUserInviteTokenDataFactory, emailToken, orgUser.Id, user.Email); if (tokenValidationError != null) { diff --git a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs index 1a763f91f0a2..9fb6e694cc17 100644 --- a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs @@ -63,6 +63,23 @@ public bool TokenIsValid(Guid orgUserId, string orgUserEmail) protected override bool TokenIsValid() => Identifier == TokenIdentifier && OrgUserId != default && !string.IsNullOrWhiteSpace(OrgUserEmail); + public static string? GetOrgUserInviteValidationError( + IDataProtectorTokenFactory orgUserInviteTokenDataFactory, + string orgUserInviteToken, + Guid orgUserId, + string orgUserEmail) + { + return orgUserInviteTokenDataFactory.TryUnprotect(orgUserInviteToken, out var decryptedToken) switch + { + // Used by clients to show better error message on token expiration, adjust both as-needed + true when decryptedToken.IsExpired => "Expired token.", + true when !(decryptedToken.Valid && decryptedToken.TokenIsValid(orgUserId, orgUserEmail)) => + "Invalid token.", + false => "Invalid token.", + _ => null + }; + } + public static bool ValidateOrgUserInviteStringToken( IDataProtectorTokenFactory orgUserInviteTokenDataFactory, string orgUserInviteToken, OrganizationUser orgUser) diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index 823ec585ba0d..6b99e40d9d39 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -169,8 +169,10 @@ private void TryValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, U if (orgInviteTokenProvided && orgUserId.HasValue) { // We have token data so validate it - if (OrgUserInviteTokenable.ValidateOrgUserInviteStringToken( - _orgUserInviteTokenDataFactory, orgInviteToken, orgUserId.Value, user.Email)) + var tokenValidationError = OrgUserInviteTokenable.GetOrgUserInviteValidationError( + _orgUserInviteTokenDataFactory, orgInviteToken, orgUserId.Value, user.Email); + + if (tokenValidationError == null) { return; } @@ -181,7 +183,7 @@ private void TryValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, U throw new BadRequestException(_disabledUserRegistrationExceptionMsg); } - throw new BadRequestException("Organization invite token is invalid."); + throw new BadRequestException(tokenValidationError); } // no token data or missing token data diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/OrgUserInviteTokenableTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/OrgUserInviteTokenableTests.cs index 866cdbfe2920..4ac616f2daaa 100644 --- a/test/Core.Test/Auth/Models/Business/Tokenables/OrgUserInviteTokenableTests.cs +++ b/test/Core.Test/Auth/Models/Business/Tokenables/OrgUserInviteTokenableTests.cs @@ -2,6 +2,7 @@ using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Entities; using Bit.Core.Tokens; +using NSubstitute; using Xunit; @@ -257,6 +258,61 @@ public void FromToken_SerializedToken_PreservesOrgUserEmail(OrganizationUser org Assert.Equal(orgUser.Email, result.OrgUserEmail); } + // GetOrgUserInviteValidationError Theory parameters: + // bool tryUnprotectResult, bool useMatchingOrgUserId, bool useMatchingEmail, bool isExpired, string? expectedError + [Theory] + // TryUnprotect fails → "Invalid token." + [InlineData(false, true, true, false, "Invalid token.")] + // TryUnprotect succeeds, token is expired → "Expired token." + [InlineData(true, true, true, true, "Expired token.")] + // TryUnprotect succeeds, not expired, mismatched org user ID → "Invalid token." + [InlineData(true, false, true, false, "Invalid token.")] + // TryUnprotect succeeds, not expired, mismatched email → "Invalid token." + [InlineData(true, true, false, false, "Invalid token.")] + // TryUnprotect succeeds, not expired, both mismatched → "Invalid token." + [InlineData(true, false, false, false, "Invalid token.")] + // TryUnprotect succeeds, not expired, matching ID and email → null (valid) + [InlineData(true, true, true, false, null)] + public void GetOrgUserInviteValidationError_ReturnsExpectedError( + bool tryUnprotectResult, + bool useMatchingOrgUserId, + bool useMatchingEmail, + bool isExpired, + string? expectedError) + { + // Arrange + var orgUserId = Guid.NewGuid(); + var orgUserEmail = "test@example.com"; + + var tokenable = new OrgUserInviteTokenable + { + Identifier = OrgUserInviteTokenable.TokenIdentifier, + OrgUserId = orgUserId, + OrgUserEmail = orgUserEmail, + ExpirationDate = isExpired + ? DateTime.UtcNow.AddDays(-1) + : DateTime.UtcNow.AddDays(1) + }; + + var factory = Substitute.For>(); + factory.TryUnprotect(Arg.Any(), out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = tryUnprotectResult ? tokenable : null; + return tryUnprotectResult; + }); + + var inputOrgUserId = useMatchingOrgUserId ? orgUserId : Guid.NewGuid(); + var inputEmail = useMatchingEmail ? orgUserEmail : "wrong@example.com"; + + // Act + var result = OrgUserInviteTokenable.GetOrgUserInviteValidationError( + factory, "test-token", inputOrgUserId, inputEmail); + + // Assert + Assert.Equal(expectedError, result); + } + private bool TimesAreCloseEnough(DateTime time1, DateTime time2, TimeSpan tolerance) { return (time1 - time2).Duration() < tolerance; From 7b82811ad931105dce9e6c8c789965e14c3b18e1 Mon Sep 17 00:00:00 2001 From: Sven Date: Mon, 16 Mar 2026 16:31:20 -0500 Subject: [PATCH 08/12] Move away from magic strings, fix tests --- .../OrganizationUsers/AcceptOrgUserCommand.cs | 6 +-- .../Tokenables/OrgUserInviteTokenable.cs | 44 +++++-------------- .../Tokenables/TokenableValidationErrors.cs | 19 ++++++++ .../Implementations/RegisterUserCommand.cs | 4 +- .../Tokenables/OrgUserInviteTokenableTests.cs | 4 +- .../Registration/RegisterUserCommandTests.cs | 2 +- 6 files changed, 39 insertions(+), 40 deletions(-) create mode 100644 src/Core/Auth/Models/Business/Tokenables/TokenableValidationErrors.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index 944ab8928bd8..fa7f69f0fc81 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -74,12 +74,12 @@ public async Task AcceptOrgUserByEmailTokenAsync(Guid organiza throw new BadRequestException("User invalid."); } - var tokenValidationError = OrgUserInviteTokenable.GetOrgUserInviteValidationError( - _orgUserInviteTokenDataFactory, emailToken, orgUser.Id, user.Email); + var tokenValidationError = OrgUserInviteTokenable.ValidateOrgUserInvite( + _orgUserInviteTokenDataFactory, emailToken, orgUser.Id, orgUser.Email); if (tokenValidationError != null) { - throw new BadRequestException(tokenValidationError); + throw new BadRequestException(tokenValidationError.ErrorMessage); } var existingOrgUserCount = await _organizationUserRepository.GetCountByOrganizationAsync( diff --git a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs index 9fb6e694cc17..2ac36713d21a 100644 --- a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs @@ -37,18 +37,9 @@ public OrgUserInviteTokenable(OrganizationUser orgUser) : this() OrgUserEmail = orgUser?.Email; } - public bool TokenIsValid(OrganizationUser orgUser) - { - if (OrgUserId == default || OrgUserEmail == default || orgUser == null) - { - return false; - } - - return OrgUserId == orgUser.Id && - OrgUserEmail.Equals(orgUser.Email, StringComparison.InvariantCultureIgnoreCase); - } + public bool TokenIsValid(OrganizationUser? orgUser) => TokenIsValid(orgUser?.Id ?? default, orgUser?.Email); - public bool TokenIsValid(Guid orgUserId, string orgUserEmail) + public bool TokenIsValid(Guid orgUserId, string? orgUserEmail) { if (OrgUserId == default || OrgUserEmail == default || orgUserId == default || orgUserEmail == default) { @@ -63,38 +54,27 @@ public bool TokenIsValid(Guid orgUserId, string orgUserEmail) protected override bool TokenIsValid() => Identifier == TokenIdentifier && OrgUserId != default && !string.IsNullOrWhiteSpace(OrgUserEmail); - public static string? GetOrgUserInviteValidationError( + public static TokenableValidationErrors? ValidateOrgUserInvite( IDataProtectorTokenFactory orgUserInviteTokenDataFactory, string orgUserInviteToken, Guid orgUserId, - string orgUserEmail) - { - return orgUserInviteTokenDataFactory.TryUnprotect(orgUserInviteToken, out var decryptedToken) switch + string? orgUserEmail) => + orgUserInviteTokenDataFactory.TryUnprotect(orgUserInviteToken, out var decryptedToken) switch { - // Used by clients to show better error message on token expiration, adjust both as-needed - true when decryptedToken.IsExpired => "Expired token.", + true when decryptedToken.IsExpired => TokenableValidationErrors.ExpiringTokenables.Expired, true when !(decryptedToken.Valid && decryptedToken.TokenIsValid(orgUserId, orgUserEmail)) => - "Invalid token.", - false => "Invalid token.", + TokenableValidationErrors.InvalidToken, + false => TokenableValidationErrors.InvalidToken, _ => null }; - } public static bool ValidateOrgUserInviteStringToken( IDataProtectorTokenFactory orgUserInviteTokenDataFactory, - string orgUserInviteToken, OrganizationUser orgUser) - { - return orgUserInviteTokenDataFactory.TryUnprotect(orgUserInviteToken, out var decryptedToken) - && decryptedToken.Valid - && decryptedToken.TokenIsValid(orgUser); - } + string orgUserInviteToken, OrganizationUser orgUser) => + ValidateOrgUserInvite(orgUserInviteTokenDataFactory, orgUserInviteToken, orgUser.Id, orgUser.Email) is null; public static bool ValidateOrgUserInviteStringToken( IDataProtectorTokenFactory orgUserInviteTokenDataFactory, - string orgUserInviteToken, Guid orgUserId, string orgUserEmail) - { - return orgUserInviteTokenDataFactory.TryUnprotect(orgUserInviteToken, out var decryptedToken) - && decryptedToken.Valid - && decryptedToken.TokenIsValid(orgUserId, orgUserEmail); - } + string orgUserInviteToken, Guid orgUserId, string orgUserEmail) => + ValidateOrgUserInvite(orgUserInviteTokenDataFactory, orgUserInviteToken, orgUserId, orgUserEmail) is null; } diff --git a/src/Core/Auth/Models/Business/Tokenables/TokenableValidationErrors.cs b/src/Core/Auth/Models/Business/Tokenables/TokenableValidationErrors.cs new file mode 100644 index 000000000000..8115db92c92d --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/TokenableValidationErrors.cs @@ -0,0 +1,19 @@ +namespace Bit.Core.Auth.Models.Business.Tokenables; + +public record TokenableValidationErrors +{ + public static TokenableValidationErrors InvalidToken => new("Invalid token."); + + public string ErrorMessage { get; } + + private TokenableValidationErrors(string errorMessage) + { + ErrorMessage = errorMessage; + } + + public static class ExpiringTokenables + { + // Used by clients to show better error message on token expiration, adjust both strings as-needed + public static TokenableValidationErrors Expired => new("Expired token."); + } +} diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index 6b99e40d9d39..9170fb90e297 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -169,7 +169,7 @@ private void TryValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, U if (orgInviteTokenProvided && orgUserId.HasValue) { // We have token data so validate it - var tokenValidationError = OrgUserInviteTokenable.GetOrgUserInviteValidationError( + var tokenValidationError = OrgUserInviteTokenable.ValidateOrgUserInvite( _orgUserInviteTokenDataFactory, orgInviteToken, orgUserId.Value, user.Email); if (tokenValidationError == null) @@ -183,7 +183,7 @@ private void TryValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, U throw new BadRequestException(_disabledUserRegistrationExceptionMsg); } - throw new BadRequestException(tokenValidationError); + throw new BadRequestException(tokenValidationError.ErrorMessage); } // no token data or missing token data diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/OrgUserInviteTokenableTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/OrgUserInviteTokenableTests.cs index 4ac616f2daaa..e7b1dc653e39 100644 --- a/test/Core.Test/Auth/Models/Business/Tokenables/OrgUserInviteTokenableTests.cs +++ b/test/Core.Test/Auth/Models/Business/Tokenables/OrgUserInviteTokenableTests.cs @@ -306,11 +306,11 @@ public void GetOrgUserInviteValidationError_ReturnsExpectedError( var inputEmail = useMatchingEmail ? orgUserEmail : "wrong@example.com"; // Act - var result = OrgUserInviteTokenable.GetOrgUserInviteValidationError( + var result = OrgUserInviteTokenable.ValidateOrgUserInvite( factory, "test-token", inputOrgUserId, inputEmail); // Assert - Assert.Equal(expectedError, result); + Assert.Equal(expectedError, result?.ErrorMessage); } private bool TimesAreCloseEnough(DateTime time1, DateTime time2, TimeSpan tolerance) diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index 5631fd7f5442..2881575769d1 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -451,7 +451,7 @@ public async Task RegisterUserViaOrganizationInviteToken_MissingOrInvalidOrgInvi return true; }); - expectedErrorMessage = "Organization invite token is invalid."; + expectedErrorMessage = "Invalid token."; break; case "nullOrgInviteToken": orgInviteToken = null; From 7f8a0afdb5c3a314e6012034de3b0fb15707ef72 Mon Sep 17 00:00:00 2001 From: Sven Date: Mon, 16 Mar 2026 16:36:41 -0500 Subject: [PATCH 09/12] Adjust class name --- .../Models/Business/Tokenables/OrgUserInviteTokenable.cs | 8 ++++---- ...bleValidationErrors.cs => TokenableValidationError.cs} | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) rename src/Core/Auth/Models/Business/Tokenables/{TokenableValidationErrors.cs => TokenableValidationError.cs} (54%) diff --git a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs index 2ac36713d21a..5f0b7a15ab6a 100644 --- a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs @@ -54,17 +54,17 @@ public bool TokenIsValid(Guid orgUserId, string? orgUserEmail) protected override bool TokenIsValid() => Identifier == TokenIdentifier && OrgUserId != default && !string.IsNullOrWhiteSpace(OrgUserEmail); - public static TokenableValidationErrors? ValidateOrgUserInvite( + public static TokenableValidationError? ValidateOrgUserInvite( IDataProtectorTokenFactory orgUserInviteTokenDataFactory, string orgUserInviteToken, Guid orgUserId, string? orgUserEmail) => orgUserInviteTokenDataFactory.TryUnprotect(orgUserInviteToken, out var decryptedToken) switch { - true when decryptedToken.IsExpired => TokenableValidationErrors.ExpiringTokenables.Expired, + true when decryptedToken.IsExpired => TokenableValidationError.ExpiringTokenables.Expired, true when !(decryptedToken.Valid && decryptedToken.TokenIsValid(orgUserId, orgUserEmail)) => - TokenableValidationErrors.InvalidToken, - false => TokenableValidationErrors.InvalidToken, + TokenableValidationError.InvalidToken, + false => TokenableValidationError.InvalidToken, _ => null }; diff --git a/src/Core/Auth/Models/Business/Tokenables/TokenableValidationErrors.cs b/src/Core/Auth/Models/Business/Tokenables/TokenableValidationError.cs similarity index 54% rename from src/Core/Auth/Models/Business/Tokenables/TokenableValidationErrors.cs rename to src/Core/Auth/Models/Business/Tokenables/TokenableValidationError.cs index 8115db92c92d..61d0e159c977 100644 --- a/src/Core/Auth/Models/Business/Tokenables/TokenableValidationErrors.cs +++ b/src/Core/Auth/Models/Business/Tokenables/TokenableValidationError.cs @@ -1,12 +1,12 @@ namespace Bit.Core.Auth.Models.Business.Tokenables; -public record TokenableValidationErrors +public record TokenableValidationError { - public static TokenableValidationErrors InvalidToken => new("Invalid token."); + public static TokenableValidationError InvalidToken => new("Invalid token."); public string ErrorMessage { get; } - private TokenableValidationErrors(string errorMessage) + private TokenableValidationError(string errorMessage) { ErrorMessage = errorMessage; } @@ -14,6 +14,6 @@ private TokenableValidationErrors(string errorMessage) public static class ExpiringTokenables { // Used by clients to show better error message on token expiration, adjust both strings as-needed - public static TokenableValidationErrors Expired => new("Expired token."); + public static TokenableValidationError Expired => new("Expired token."); } } From f6f4832812b956a3ad4ee04828020961e39a18f7 Mon Sep 17 00:00:00 2001 From: Sven Date: Mon, 16 Mar 2026 16:48:57 -0500 Subject: [PATCH 10/12] Clean up old method name references --- .../Models/Business/Tokenables/OrgUserInviteTokenableTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/OrgUserInviteTokenableTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/OrgUserInviteTokenableTests.cs index e7b1dc653e39..619bc8d8f136 100644 --- a/test/Core.Test/Auth/Models/Business/Tokenables/OrgUserInviteTokenableTests.cs +++ b/test/Core.Test/Auth/Models/Business/Tokenables/OrgUserInviteTokenableTests.cs @@ -258,7 +258,7 @@ public void FromToken_SerializedToken_PreservesOrgUserEmail(OrganizationUser org Assert.Equal(orgUser.Email, result.OrgUserEmail); } - // GetOrgUserInviteValidationError Theory parameters: + // ValidateOrgUserInvite Parameters: // bool tryUnprotectResult, bool useMatchingOrgUserId, bool useMatchingEmail, bool isExpired, string? expectedError [Theory] // TryUnprotect fails → "Invalid token." @@ -273,7 +273,7 @@ public void FromToken_SerializedToken_PreservesOrgUserEmail(OrganizationUser org [InlineData(true, false, false, false, "Invalid token.")] // TryUnprotect succeeds, not expired, matching ID and email → null (valid) [InlineData(true, true, true, false, null)] - public void GetOrgUserInviteValidationError_ReturnsExpectedError( + public void ValidateOrgUserInvite_ReturnsExpectedErrors( bool tryUnprotectResult, bool useMatchingOrgUserId, bool useMatchingEmail, From 62be2ce71041d7d0d91e3321dbd16fa797ba5d91 Mon Sep 17 00:00:00 2001 From: Sven Date: Mon, 16 Mar 2026 16:49:10 -0500 Subject: [PATCH 11/12] Change errors to fields for singleton behavior --- .../Models/Business/Tokenables/TokenableValidationError.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Auth/Models/Business/Tokenables/TokenableValidationError.cs b/src/Core/Auth/Models/Business/Tokenables/TokenableValidationError.cs index 61d0e159c977..b6b0a2aaa4e6 100644 --- a/src/Core/Auth/Models/Business/Tokenables/TokenableValidationError.cs +++ b/src/Core/Auth/Models/Business/Tokenables/TokenableValidationError.cs @@ -2,7 +2,7 @@ namespace Bit.Core.Auth.Models.Business.Tokenables; public record TokenableValidationError { - public static TokenableValidationError InvalidToken => new("Invalid token."); + public static readonly TokenableValidationError InvalidToken = new("Invalid token."); public string ErrorMessage { get; } @@ -14,6 +14,6 @@ private TokenableValidationError(string errorMessage) public static class ExpiringTokenables { // Used by clients to show better error message on token expiration, adjust both strings as-needed - public static TokenableValidationError Expired => new("Expired token."); + public static readonly TokenableValidationError Expired = new("Expired token."); } } From 28009218209755124cf135766f7865c8173f9ebe Mon Sep 17 00:00:00 2001 From: Sven Date: Mon, 16 Mar 2026 16:50:27 -0500 Subject: [PATCH 12/12] Formatting --- .../Auth/Models/Business/Tokenables/TokenableValidationError.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Auth/Models/Business/Tokenables/TokenableValidationError.cs b/src/Core/Auth/Models/Business/Tokenables/TokenableValidationError.cs index b6b0a2aaa4e6..961180dd4a6a 100644 --- a/src/Core/Auth/Models/Business/Tokenables/TokenableValidationError.cs +++ b/src/Core/Auth/Models/Business/Tokenables/TokenableValidationError.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Auth.Models.Business.Tokenables; +namespace Bit.Core.Auth.Models.Business.Tokenables; public record TokenableValidationError {