Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,12 @@ public async Task<OrganizationUser> AcceptOrgUserByEmailTokenAsync(Guid organiza
throw new BadRequestException("User invalid.");
}

var tokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
_orgUserInviteTokenDataFactory, emailToken, orgUser);
var tokenValidationError = OrgUserInviteTokenable.ValidateOrgUserInvite(
_orgUserInviteTokenDataFactory, emailToken, orgUser.Id, orgUser.Email);

if (!tokenValid)
if (tokenValidationError != null)
{
throw new BadRequestException("Invalid token.");
throw new BadRequestException(tokenValidationError.ErrorMessage);
}

var existingOrgUserCount = await _organizationUserRepository.GetCountByOrganizationAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -63,22 +54,27 @@ public bool TokenIsValid(Guid orgUserId, string orgUserEmail)
protected override bool TokenIsValid() =>
Identifier == TokenIdentifier && OrgUserId != default && !string.IsNullOrWhiteSpace(OrgUserEmail);

public static TokenableValidationError? ValidateOrgUserInvite(
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
string orgUserInviteToken,
Guid orgUserId,
string? orgUserEmail) =>
orgUserInviteTokenDataFactory.TryUnprotect(orgUserInviteToken, out var decryptedToken) switch
{
true when decryptedToken.IsExpired => TokenableValidationError.ExpiringTokenables.Expired,
true when !(decryptedToken.Valid && decryptedToken.TokenIsValid(orgUserId, orgUserEmail)) =>
TokenableValidationError.InvalidToken,
false => TokenableValidationError.InvalidToken,
_ => null
};

public static bool ValidateOrgUserInviteStringToken(
IDataProtectorTokenFactory<OrgUserInviteTokenable> 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<OrgUserInviteTokenable> 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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
ο»Ώnamespace Bit.Core.Auth.Models.Business.Tokenables;

public record TokenableValidationError
{
public static readonly TokenableValidationError InvalidToken = new("Invalid token.");

public string ErrorMessage { get; }

private TokenableValidationError(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 readonly TokenableValidationError Expired = new("Expired token.");
}
}
Comment thread
JaredSnider-Bitwarden marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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.ValidateOrgUserInvite(
_orgUserInviteTokenDataFactory, orgInviteToken, orgUserId.Value, user.Email);

if (tokenValidationError == null)
{
return;
}
Expand All @@ -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.ErrorMessage);
}

// no token data or missing token data
Expand Down
4 changes: 3 additions & 1 deletion src/Core/Tokens/ExpiringTokenable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// <para>For data validation, this property relies on the <see cref="TokenIsValid"/> method.</para>
/// </summary>
public override bool Valid => ExpirationDate > DateTime.UtcNow && TokenIsValid();
public override bool Valid => !IsExpired && TokenIsValid();

public bool IsExpired => ExpirationDate < DateTime.UtcNow;

/// <summary>
/// Validates that the token data properties are correct.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -451,8 +451,39 @@ public async Task AcceptOrgUserByToken_ExpiredNewToken_ThrowsBadRequest(
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.AcceptOrgUserByEmailTokenAsync(orgUser.Id, user, newToken, _userService));

Assert.Equal("Invalid token.", exception.Message);
Assert.Equal("Expired token.", exception.Message);
}

[Theory]
[BitAutoData]
public async Task AcceptOrgUserByToken_InvalidNewToken_ThrowsBadRequest(
SutProvider<AcceptOrgUserCommand> 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<IOrganizationUserRepository>()
.GetByIdAsync(orgUser.Id)
.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),
});

var newToken = CreateToken(orgUser);

// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.AcceptOrgUserByEmailTokenAsync(orgUser.Id, user, newToken, _userService));

Assert.Equal("Invalid token.", exception.Message);
}

[Theory]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Entities;
using Bit.Core.Tokens;
using NSubstitute;
using Xunit;


Expand Down Expand Up @@ -257,6 +258,61 @@ public void FromToken_SerializedToken_PreservesOrgUserEmail(OrganizationUser org
Assert.Equal(orgUser.Email, result.OrgUserEmail);
}

// ValidateOrgUserInvite 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 ValidateOrgUserInvite_ReturnsExpectedErrors(
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<IDataProtectorTokenFactory<OrgUserInviteTokenable>>();
factory.TryUnprotect(Arg.Any<string>(), out Arg.Any<OrgUserInviteTokenable>())
.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.ValidateOrgUserInvite(
factory, "test-token", inputOrgUserId, inputEmail);

// Assert
Assert.Equal(expectedError, result?.ErrorMessage);
}

private bool TimesAreCloseEnough(DateTime time1, DateTime time2, TimeSpan tolerance)
{
return (time1 - time2).Duration() < tolerance;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading