1
0
mirror of https://github.com/bitwarden/server synced 2026-01-03 00:53:37 +00:00

[PM-22678] Send email otp authentication method (#6255)

feat(auth): email OTP validation, and generalize authentication interface

- Generalized send authentication method interface
- Made validate method async
- Added email mail support for Handlebars
- Modified email templates to match future implementation

fix(auth): update constants, naming conventions, and error handling

- Renamed constants for clarity
- Updated claims naming convention
- Fixed error message generation
- Added customResponse for Rust consumption

test(auth): add and fix tests for validators and email

- Added tests for SendEmailOtpRequestValidator
- Updated tests for SendAccessGrantValidator

chore: apply dotnet formatting
This commit is contained in:
Ike
2025-09-02 16:48:57 -04:00
committed by GitHub
parent a5bed5dcaa
commit d2d3e0f11b
24 changed files with 1213 additions and 90 deletions

View File

@@ -0,0 +1,332 @@
using System.Collections.Specialized;
using Bit.Core;
using Bit.Core.Enums;
using Bit.Core.Identity;
using Bit.Core.IdentityServer;
using Bit.Core.Services;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
using Bit.Core.Utilities;
using Bit.Identity.IdentityServer.Enums;
using Bit.Identity.IdentityServer.RequestValidators.SendAccess;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Duende.IdentityModel;
using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Validation;
using NSubstitute;
using Xunit;
namespace Bit.Identity.Test.IdentityServer.SendAccess;
[SutProviderCustomize]
public class SendAccessGrantValidatorTests
{
[Theory, BitAutoData]
public async Task ValidateAsync_FeatureFlagDisabled_ReturnsUnsupportedGrantType(
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
SutProvider<SendAccessGrantValidator> sutProvider)
{
// Arrange
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.SendAccess)
.Returns(false);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
// Act
await sutProvider.Sut.ValidateAsync(context);
// Assert
Assert.True(context.Result.IsError);
Assert.Equal(OidcConstants.TokenErrors.UnsupportedGrantType, context.Result.Error);
}
[Theory, BitAutoData]
public async Task ValidateAsync_MissingSendId_ReturnsInvalidRequest(
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
SutProvider<SendAccessGrantValidator> sutProvider)
{
// Arrange
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.SendAccess)
.Returns(true);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
// Act
await sutProvider.Sut.ValidateAsync(context);
// Assert
Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, context.Result.Error);
Assert.Equal($"{SendAccessConstants.TokenRequest.SendId} is required.", context.Result.ErrorDescription);
}
[Theory, BitAutoData]
public async Task ValidateAsync_InvalidSendId_ReturnsInvalidGrant(
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
SutProvider<SendAccessGrantValidator> sutProvider)
{
// Arrange
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.SendAccess)
.Returns(true);
var context = new ExtensionGrantValidationContext();
tokenRequest.GrantType = CustomGrantTypes.SendAccess;
tokenRequest.Raw = CreateTokenRequestBody(Guid.Empty);
// To preserve the CreateTokenRequestBody method for more general usage we over write the sendId
tokenRequest.Raw.Set(SendAccessConstants.TokenRequest.SendId, "invalid-guid-format");
context.Request = tokenRequest;
// Act
await sutProvider.Sut.ValidateAsync(context);
// Assert
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.Result.Error);
Assert.Equal($"{SendAccessConstants.TokenRequest.SendId} is invalid.", context.Result.ErrorDescription);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EmptyGuidSendId_ReturnsInvalidGrant(
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
SutProvider<SendAccessGrantValidator> sutProvider)
{
// Arrange
var context = SetupTokenRequest(
sutProvider,
Guid.Empty, // Empty Guid as sendId
tokenRequest);
// Act
await sutProvider.Sut.ValidateAsync(context);
// Assert
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.Result.Error);
Assert.Equal($"{SendAccessConstants.TokenRequest.SendId} is invalid.", context.Result.ErrorDescription);
}
[Theory, BitAutoData]
public async Task ValidateAsync_NeverAuthenticateMethod_ReturnsInvalidGrant(
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
SutProvider<SendAccessGrantValidator> sutProvider,
Guid sendId)
{
// Arrange
var context = SetupTokenRequest(
sutProvider,
sendId,
tokenRequest);
sutProvider.GetDependency<ISendAuthenticationQuery>()
.GetAuthenticationMethod(sendId)
.Returns(new NeverAuthenticate());
// Act
await sutProvider.Sut.ValidateAsync(context);
// Assert
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.Result.Error);
Assert.Equal($"{SendAccessConstants.TokenRequest.SendId} is invalid.", context.Result.ErrorDescription);
}
[Theory, BitAutoData]
public async Task ValidateAsync_NotAuthenticatedMethod_ReturnsSuccess(
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
SutProvider<SendAccessGrantValidator> sutProvider,
Guid sendId)
{
// Arrange
var context = SetupTokenRequest(
sutProvider,
sendId,
tokenRequest);
sutProvider.GetDependency<ISendAuthenticationQuery>()
.GetAuthenticationMethod(sendId)
.Returns(new NotAuthenticated());
// Act
await sutProvider.Sut.ValidateAsync(context);
// Assert
Assert.False(context.Result.IsError);
// get the claims principal from the result
var subject = context.Result.Subject;
Assert.NotNull(subject);
Assert.Equal(sendId.ToString(), subject.GetSubjectId());
Assert.Equal(CustomGrantTypes.SendAccess, subject.GetAuthenticationMethod());
// get the claims from the subject
var claims = subject.Claims.ToList();
Assert.NotEmpty(claims);
Assert.Contains(claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString());
Assert.Contains(claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString());
}
[Theory, BitAutoData]
public async Task ValidateAsync_ResourcePasswordMethod_CallsPasswordValidator(
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
SutProvider<SendAccessGrantValidator> sutProvider,
Guid sendId,
ResourcePassword resourcePassword,
GrantValidationResult expectedResult)
{
// Arrange
var context = SetupTokenRequest(
sutProvider,
sendId,
tokenRequest);
sutProvider.GetDependency<ISendAuthenticationQuery>()
.GetAuthenticationMethod(sendId)
.Returns(resourcePassword);
sutProvider.GetDependency<ISendAuthenticationMethodValidator<ResourcePassword>>()
.ValidateRequestAsync(context, resourcePassword, sendId)
.Returns(expectedResult);
// Act
await sutProvider.Sut.ValidateAsync(context);
// Assert
Assert.Equal(expectedResult, context.Result);
await sutProvider.GetDependency<ISendAuthenticationMethodValidator<ResourcePassword>>()
.Received(1)
.ValidateRequestAsync(context, resourcePassword, sendId);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EmailOtpMethod_CallsEmailOtp(
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
SutProvider<SendAccessGrantValidator> sutProvider,
GrantValidationResult expectedResult,
Guid sendId,
EmailOtp emailOtp)
{
// Arrange
var context = SetupTokenRequest(
sutProvider,
sendId,
tokenRequest);
sutProvider.GetDependency<ISendAuthenticationQuery>()
.GetAuthenticationMethod(sendId)
.Returns(emailOtp);
sutProvider.GetDependency<ISendAuthenticationMethodValidator<EmailOtp>>()
.ValidateRequestAsync(context, emailOtp, sendId)
.Returns(expectedResult);
// Act
await sutProvider.Sut.ValidateAsync(context);
// Assert
Assert.Equal(expectedResult, context.Result);
await sutProvider.GetDependency<ISendAuthenticationMethodValidator<EmailOtp>>()
.Received(1)
.ValidateRequestAsync(context, emailOtp, sendId);
}
[Theory, BitAutoData]
public async Task ValidateAsync_UnknownAuthMethod_ThrowsInvalidOperationException(
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
SutProvider<SendAccessGrantValidator> sutProvider,
Guid sendId)
{
// Arrange
var context = SetupTokenRequest(
sutProvider,
sendId,
tokenRequest);
// Create a mock authentication method that's not handled
var unknownMethod = Substitute.For<SendAuthenticationMethod>();
sutProvider.GetDependency<ISendAuthenticationQuery>()
.GetAuthenticationMethod(sendId)
.Returns(unknownMethod);
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => sutProvider.Sut.ValidateAsync(context));
Assert.StartsWith("Unknown auth method:", exception.Message);
}
[Fact]
public void GrantType_ReturnsCorrectType()
{
// Arrange & Act
var validator = new SendAccessGrantValidator(null!, null!, null!, null!);
// Assert
Assert.Equal(CustomGrantTypes.SendAccess, ((IExtensionGrantValidator)validator).GrantType);
}
/// <summary>
/// Mutator method fo the SutProvider and the Context to set up a valid request
/// </summary>
/// <param name="sutProvider">current sut provider</param>
/// <param name="context">test context</param>
/// <param name="sendId">the send id</param>
/// <param name="request">the token request</param>
private static ExtensionGrantValidationContext SetupTokenRequest(
SutProvider<SendAccessGrantValidator> sutProvider,
Guid sendId,
ValidatedTokenRequest request)
{
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.SendAccess)
.Returns(true);
var context = new ExtensionGrantValidationContext();
request.GrantType = CustomGrantTypes.SendAccess;
request.Raw = CreateTokenRequestBody(sendId);
context.Request = request;
return context;
}
private static NameValueCollection CreateTokenRequestBody(
Guid sendId,
string passwordHash = null,
string sendEmail = null,
string otpCode = null)
{
var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray());
var rawRequestParameters = new NameValueCollection
{
{ OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess },
{ OidcConstants.TokenRequest.ClientId, BitwardenClient.Send },
{ OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess },
{ "deviceType", ((int)DeviceType.FirefoxBrowser).ToString() },
{ SendAccessConstants.TokenRequest.SendId, sendIdBase64 }
};
if (passwordHash != null)
{
rawRequestParameters.Add(SendAccessConstants.TokenRequest.ClientB64HashedPassword, passwordHash);
}
if (sendEmail != null)
{
rawRequestParameters.Add(SendAccessConstants.TokenRequest.Email, sendEmail);
}
if (otpCode != null && sendEmail != null)
{
rawRequestParameters.Add(SendAccessConstants.TokenRequest.Otp, otpCode);
}
return rawRequestParameters;
}
}

View File

@@ -0,0 +1,310 @@
using System.Collections.Specialized;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Enums;
using Bit.Core.Identity;
using Bit.Core.IdentityServer;
using Bit.Core.Services;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Utilities;
using Bit.Identity.IdentityServer.Enums;
using Bit.Identity.IdentityServer.RequestValidators.SendAccess;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Duende.IdentityModel;
using Duende.IdentityServer.Validation;
using NSubstitute;
using Xunit;
namespace Bit.Identity.Test.IdentityServer.SendAccess;
[SutProviderCustomize]
public class SendEmailOtpRequestValidatorTests
{
[Theory, BitAutoData]
public async Task ValidateRequestAsync_MissingEmail_ReturnsInvalidRequest(
SutProvider<SendEmailOtpRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
EmailOtp emailOtp,
Guid sendId)
{
// Arrange
tokenRequest.Raw = CreateValidatedTokenRequest(sendId);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
// Assert
Assert.True(result.IsError);
Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error);
Assert.Equal("email is required.", result.ErrorDescription);
// Verify no OTP generation or email sending occurred
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.DidNotReceive()
.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendSendEmailOtpEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task ValidateRequestAsync_EmailNotInList_ReturnsInvalidRequest(
SutProvider<SendEmailOtpRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
EmailOtp emailOtp,
string email,
Guid sendId)
{
// Arrange
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email);
var emailOTP = new EmailOtp(["user@test.dev"]);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
// Assert
Assert.True(result.IsError);
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error);
Assert.Equal("email is invalid.", result.ErrorDescription);
// Verify no OTP generation or email sending occurred
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.DidNotReceive()
.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendSendEmailOtpEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task ValidateRequestAsync_EmailWithoutOtp_GeneratesAndSendsOtp(
SutProvider<SendEmailOtpRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
EmailOtp emailOtp,
Guid sendId,
string email,
string generatedToken)
{
// Arrange
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email);
sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.GenerateTokenAsync(
SendAccessConstants.OtpToken.TokenProviderName,
SendAccessConstants.OtpToken.Purpose,
expectedUniqueId)
.Returns(generatedToken);
emailOtp = emailOtp with { Emails = [email] };
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
// Assert
Assert.True(result.IsError);
Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error);
Assert.Equal("email otp sent.", result.ErrorDescription);
// Verify OTP generation
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.Received(1)
.GenerateTokenAsync(
SendAccessConstants.OtpToken.TokenProviderName,
SendAccessConstants.OtpToken.Purpose,
expectedUniqueId);
// Verify email sending
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendSendEmailOtpEmailAsync(email, generatedToken, Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task ValidateRequestAsync_OtpGenerationFails_ReturnsGenerationFailedError(
SutProvider<SendEmailOtpRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
EmailOtp emailOtp,
Guid sendId,
string email)
{
// Arrange
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
emailOtp = emailOtp with { Emails = [email] };
sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns((string)null); // Generation fails
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
// Assert
Assert.True(result.IsError);
Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error);
// Verify no email was sent
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendSendEmailOtpEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task ValidateRequestAsync_ValidOtp_ReturnsSuccess(
SutProvider<SendEmailOtpRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
EmailOtp emailOtp,
Guid sendId,
string email,
string otp)
{
// Arrange
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, otp);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
emailOtp = emailOtp with { Emails = [email] };
var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email);
sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.ValidateTokenAsync(
otp,
SendAccessConstants.OtpToken.TokenProviderName,
SendAccessConstants.OtpToken.Purpose,
expectedUniqueId)
.Returns(true);
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
// Assert
Assert.False(result.IsError);
var sub = result.Subject;
Assert.Equal(sendId.ToString(), sub.Claims.First(c => c.Type == Claims.SendAccessClaims.SendId).Value);
// Verify claims
Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString());
Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.Email && c.Value == email);
Assert.Contains(sub.Claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString());
// Verify OTP validation was called
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.Received(1)
.ValidateTokenAsync(otp, SendAccessConstants.OtpToken.TokenProviderName, SendAccessConstants.OtpToken.Purpose, expectedUniqueId);
// Verify no email was sent (validation only)
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendSendEmailOtpEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task ValidateRequestAsync_InvalidOtp_ReturnsInvalidGrant(
SutProvider<SendEmailOtpRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
EmailOtp emailOtp,
Guid sendId,
string email,
string invalidOtp)
{
// Arrange
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, invalidOtp);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
emailOtp = emailOtp with { Emails = [email] };
var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email);
sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.ValidateTokenAsync(invalidOtp,
SendAccessConstants.OtpToken.TokenProviderName,
SendAccessConstants.OtpToken.Purpose,
expectedUniqueId)
.Returns(false);
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
// Assert
Assert.True(result.IsError);
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error);
Assert.Equal("email otp is invalid.", result.ErrorDescription);
// Verify OTP validation was attempted
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.Received(1)
.ValidateTokenAsync(invalidOtp,
SendAccessConstants.OtpToken.TokenProviderName,
SendAccessConstants.OtpToken.Purpose,
expectedUniqueId);
}
[Fact]
public void Constructor_WithValidParameters_CreatesInstance()
{
// Arrange
var otpTokenProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
var mailService = Substitute.For<IMailService>();
// Act
var validator = new SendEmailOtpRequestValidator(otpTokenProvider, mailService);
// Assert
Assert.NotNull(validator);
}
private static NameValueCollection CreateValidatedTokenRequest(
Guid sendId,
string sendEmail = null,
string otpCode = null)
{
var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray());
var rawRequestParameters = new NameValueCollection
{
{ OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess },
{ OidcConstants.TokenRequest.ClientId, BitwardenClient.Send },
{ OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess },
{ "device_type", ((int)DeviceType.FirefoxBrowser).ToString() },
{ SendAccessConstants.TokenRequest.SendId, sendIdBase64 }
};
if (sendEmail != null)
{
rawRequestParameters.Add(SendAccessConstants.TokenRequest.Email, sendEmail);
}
if (otpCode != null && sendEmail != null)
{
rawRequestParameters.Add(SendAccessConstants.TokenRequest.Otp, otpCode);
}
return rawRequestParameters;
}
}

View File

@@ -0,0 +1,297 @@
using System.Collections.Specialized;
using Bit.Core.Auth.UserFeatures.SendAccess;
using Bit.Core.Enums;
using Bit.Core.Identity;
using Bit.Core.IdentityServer;
using Bit.Core.KeyManagement.Sends;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Utilities;
using Bit.Identity.IdentityServer.Enums;
using Bit.Identity.IdentityServer.RequestValidators.SendAccess;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Duende.IdentityModel;
using Duende.IdentityServer.Validation;
using NSubstitute;
using Xunit;
namespace Bit.Identity.Test.IdentityServer.SendAccess;
[SutProviderCustomize]
public class SendPasswordRequestValidatorTests
{
[Theory, BitAutoData]
public async Task ValidateSendPassword_MissingPasswordHash_ReturnsInvalidRequest(
SutProvider<SendPasswordRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword,
Guid sendId)
{
// Arrange
tokenRequest.Raw = CreateValidatedTokenRequest(sendId);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert
Assert.True(result.IsError);
Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error);
Assert.Equal($"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is required.", result.ErrorDescription);
// Verify password hasher was not called
sutProvider.GetDependency<ISendPasswordHasher>()
.DidNotReceive()
.PasswordHashMatches(Arg.Any<string>(), Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant(
SutProvider<SendPasswordRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword,
Guid sendId,
string clientPasswordHash)
{
// Arrange
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
sutProvider.GetDependency<ISendPasswordHasher>()
.PasswordHashMatches(resourcePassword.Hash, clientPasswordHash)
.Returns(false);
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert
Assert.True(result.IsError);
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error);
Assert.Equal($"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is invalid.", result.ErrorDescription);
// Verify password hasher was called with correct parameters
sutProvider.GetDependency<ISendPasswordHasher>()
.Received(1)
.PasswordHashMatches(resourcePassword.Hash, clientPasswordHash);
}
[Theory, BitAutoData]
public async Task ValidateSendPassword_PasswordHashMatches_ReturnsSuccess(
SutProvider<SendPasswordRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword,
Guid sendId,
string clientPasswordHash)
{
// Arrange
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
sutProvider.GetDependency<ISendPasswordHasher>()
.PasswordHashMatches(resourcePassword.Hash, clientPasswordHash)
.Returns(true);
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert
Assert.False(result.IsError);
var sub = result.Subject;
Assert.Equal(sendId, sub.GetSendId());
// Verify claims
Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString());
Assert.Contains(sub.Claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString());
// Verify password hasher was called
sutProvider.GetDependency<ISendPasswordHasher>()
.Received(1)
.PasswordHashMatches(resourcePassword.Hash, clientPasswordHash);
}
[Theory, BitAutoData]
public async Task ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher(
SutProvider<SendPasswordRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword,
Guid sendId)
{
// Arrange
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, string.Empty);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
sutProvider.GetDependency<ISendPasswordHasher>()
.PasswordHashMatches(resourcePassword.Hash, string.Empty)
.Returns(false);
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert
Assert.True(result.IsError);
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error);
// Verify password hasher was called with empty string
sutProvider.GetDependency<ISendPasswordHasher>()
.Received(1)
.PasswordHashMatches(resourcePassword.Hash, string.Empty);
}
[Theory, BitAutoData]
public async Task ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher(
SutProvider<SendPasswordRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword,
Guid sendId)
{
// Arrange
var whitespacePassword = " ";
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, whitespacePassword);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
sutProvider.GetDependency<ISendPasswordHasher>()
.PasswordHashMatches(resourcePassword.Hash, whitespacePassword)
.Returns(false);
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert
Assert.True(result.IsError);
// Verify password hasher was called with whitespace string
sutProvider.GetDependency<ISendPasswordHasher>()
.Received(1)
.PasswordHashMatches(resourcePassword.Hash, whitespacePassword);
}
[Theory, BitAutoData]
public async Task ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant(
SutProvider<SendPasswordRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword,
Guid sendId)
{
// Arrange
var firstPassword = "first-password";
var secondPassword = "second-password";
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, firstPassword, secondPassword);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
sutProvider.GetDependency<ISendPasswordHasher>()
.PasswordHashMatches(resourcePassword.Hash, firstPassword)
.Returns(true);
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert
Assert.True(result.IsError);
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error);
// Verify password hasher was called with first value
sutProvider.GetDependency<ISendPasswordHasher>()
.Received(1)
.PasswordHashMatches(resourcePassword.Hash, $"{firstPassword},{secondPassword}");
}
[Theory, BitAutoData]
public async Task ValidateSendPassword_SuccessResult_ContainsCorrectClaims(
SutProvider<SendPasswordRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword,
Guid sendId,
string clientPasswordHash)
{
// Arrange
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
sutProvider.GetDependency<ISendPasswordHasher>()
.PasswordHashMatches(Arg.Any<string>(), Arg.Any<string>())
.Returns(true);
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert
Assert.False(result.IsError);
var sub = result.Subject;
var sendIdClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.SendAccessClaims.SendId);
Assert.NotNull(sendIdClaim);
Assert.Equal(sendId.ToString(), sendIdClaim.Value);
var typeClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.Type);
Assert.NotNull(typeClaim);
Assert.Equal(IdentityClientType.Send.ToString(), typeClaim.Value);
}
[Fact]
public void Constructor_WithValidParameters_CreatesInstance()
{
// Arrange
var sendPasswordHasher = Substitute.For<ISendPasswordHasher>();
// Act
var validator = new SendPasswordRequestValidator(sendPasswordHasher);
// Assert
Assert.NotNull(validator);
}
private static NameValueCollection CreateValidatedTokenRequest(
Guid sendId,
params string[] passwordHash)
{
var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray());
var rawRequestParameters = new NameValueCollection
{
{ OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess },
{ OidcConstants.TokenRequest.ClientId, BitwardenClient.Send },
{ OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess },
{ "device_type", ((int)DeviceType.FirefoxBrowser).ToString() },
{ SendAccessConstants.TokenRequest.SendId, sendIdBase64 }
};
if (passwordHash != null && passwordHash.Length > 0)
{
foreach (var hash in passwordHash)
{
rawRequestParameters.Add(SendAccessConstants.TokenRequest.ClientB64HashedPassword, hash);
}
}
return rawRequestParameters;
}
}