diff --git a/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs b/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs
index 1feadaf081..7ae7355ba4 100644
--- a/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs
+++ b/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs
@@ -9,12 +9,12 @@ public static class SendAccessClaimsPrincipalExtensions
{
ArgumentNullException.ThrowIfNull(user);
- var sendIdClaim = user.FindFirst(Claims.SendId)
- ?? throw new InvalidOperationException("Send ID claim not found.");
+ var sendIdClaim = user.FindFirst(Claims.SendAccessClaims.SendId)
+ ?? throw new InvalidOperationException("send_id claim not found.");
if (!Guid.TryParse(sendIdClaim.Value, out var sendGuid))
{
- throw new InvalidOperationException("Invalid Send ID claim value.");
+ throw new InvalidOperationException("Invalid send_id claim value.");
}
return sendGuid;
diff --git a/src/Core/Identity/Claims.cs b/src/Core/Identity/Claims.cs
index ef3d5e450c..39a036f3f9 100644
--- a/src/Core/Identity/Claims.cs
+++ b/src/Core/Identity/Claims.cs
@@ -39,6 +39,9 @@ public static class Claims
public const string ManageResetPassword = "manageresetpassword";
public const string ManageScim = "managescim";
}
-
- public const string SendId = "send_id";
+ public static class SendAccessClaims
+ {
+ public const string SendId = "send_id";
+ public const string Email = "send_email";
+ }
}
diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs
new file mode 100644
index 0000000000..5bf1f24218
--- /dev/null
+++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs
@@ -0,0 +1,28 @@
+{{#>FullHtmlLayout}}
+
+
+ |
+ Verify your email to access this Bitwarden Send.
+ |
+
+
+
+
+ Your verification code is: {{Token}}
+ |
+
+
+
+
+ This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again.
+ |
+
+
+
+
+
+ {{TheDate}} at {{TheTime}} {{TimeZone}}
+ |
+
+
+{{/FullHtmlLayout}}
\ No newline at end of file
diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs
new file mode 100644
index 0000000000..f83008c30b
--- /dev/null
+++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs
@@ -0,0 +1,9 @@
+{{#>BasicTextLayout}}
+Verify your email to access this Bitwarden Send.
+
+Your verification code is: {{Token}}
+
+This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again.
+
+Date : {{TheDate}} at {{TheTime}} {{TimeZone}}
+{{/BasicTextLayout}}
\ No newline at end of file
diff --git a/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs b/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs
new file mode 100644
index 0000000000..5faf550e60
--- /dev/null
+++ b/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs
@@ -0,0 +1,12 @@
+namespace Bit.Core.Models.Mail.Auth;
+
+///
+/// Send email OTP view model
+///
+public class DefaultEmailOtpViewModel : BaseMailModel
+{
+ public string? Token { get; set; }
+ public string? TheDate { get; set; }
+ public string? TheTime { get; set; }
+ public string? TimeZone { get; set; }
+}
diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs
index 32aaac84b7..a38328dc9d 100644
--- a/src/Core/Services/IMailService.cs
+++ b/src/Core/Services/IMailService.cs
@@ -30,6 +30,7 @@ public interface IMailService
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose);
+ Task SendSendEmailOtpEmailAsync(string email, string token, string subject);
Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType type, DateTime utcNow, string ip);
Task SendNoMasterPasswordHintEmailAsync(string email);
Task SendMasterPasswordHintEmailAsync(string email, string hint);
diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs
index f06a37fa3b..394b5c5125 100644
--- a/src/Core/Services/Implementations/HandlebarsMailService.cs
+++ b/src/Core/Services/Implementations/HandlebarsMailService.cs
@@ -15,6 +15,7 @@ using Bit.Core.Billing.Models.Mail;
using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Mail;
+using Bit.Core.Models.Mail.Auth;
using Bit.Core.Models.Mail.Billing;
using Bit.Core.Models.Mail.FamiliesForEnterprise;
using Bit.Core.Models.Mail.Provider;
@@ -199,6 +200,26 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message);
}
+ public async Task SendSendEmailOtpEmailAsync(string email, string token, string subject)
+ {
+ var message = CreateDefaultMessage(subject, email);
+ var requestDateTime = DateTime.UtcNow;
+ var model = new DefaultEmailOtpViewModel
+ {
+ Token = token,
+ TheDate = requestDateTime.ToLongDateString(),
+ TheTime = requestDateTime.ToShortTimeString(),
+ TimeZone = _utcTimeZoneDisplay,
+ WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
+ SiteName = _globalSettings.SiteName,
+ };
+ await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmail", model);
+ message.MetaData.Add("SendGridBypassListManagement", true);
+ // TODO - PM-25380 change to string constant
+ message.Category = "SendEmailOtp";
+ await _mailDeliveryService.SendEmailAsync(message);
+ }
+
public async Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
{
// Check if we've sent this email within the last hour
diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs
index 5847aaf929..bc73fb5398 100644
--- a/src/Core/Services/NoopImplementations/NoopMailService.cs
+++ b/src/Core/Services/NoopImplementations/NoopMailService.cs
@@ -93,6 +93,11 @@ public class NoopMailService : IMailService
return Task.FromResult(0);
}
+ public Task SendSendEmailOtpEmailAsync(string email, string token, string subject)
+ {
+ return Task.FromResult(0);
+ }
+
public Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
{
return Task.FromResult(0);
diff --git a/src/Identity/IdentityServer/ApiResources.cs b/src/Identity/IdentityServer/ApiResources.cs
index eea53734cb..61f3dd10ba 100644
--- a/src/Identity/IdentityServer/ApiResources.cs
+++ b/src/Identity/IdentityServer/ApiResources.cs
@@ -29,7 +29,7 @@ public class ApiResources
}),
new(ApiScopes.ApiSendAccess, [
JwtClaimTypes.Subject,
- Claims.SendId
+ Claims.SendAccessClaims.SendId
]),
new(ApiScopes.Internal, new[] { JwtClaimTypes.Subject }),
new(ApiScopes.ApiPush, new[] { JwtClaimTypes.Subject }),
diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendAuthenticationMethodValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendAuthenticationMethodValidator.cs
new file mode 100644
index 0000000000..1ffb68ceca
--- /dev/null
+++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendAuthenticationMethodValidator.cs
@@ -0,0 +1,15 @@
+using Bit.Core.Tools.Models.Data;
+using Duende.IdentityServer.Validation;
+
+namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
+
+public interface ISendAuthenticationMethodValidator where T : SendAuthenticationMethod
+{
+ ///
+ ///
+ /// request context
+ /// SendAuthenticationRecord that contains the information to be compared against the context
+ /// the sendId being accessed
+ /// returns the result of the validation; A failed result will be an error a successful will contain the claims and a success
+ Task ValidateRequestAsync(ExtensionGrantValidationContext context, T authMethod, Guid sendId);
+}
diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendPasswordRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendPasswordRequestValidator.cs
deleted file mode 100644
index a6f33175bd..0000000000
--- a/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendPasswordRequestValidator.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using Bit.Core.Tools.Models.Data;
-using Duende.IdentityServer.Validation;
-
-namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
-
-public interface ISendPasswordRequestValidator
-{
- ///
- /// Validates the send password hash against the client hashed password.
- /// If this method fails then it will automatically set the context.Result to an invalid grant result.
- ///
- /// request context
- /// resource password authentication method containing the hash of the Send being retrieved
- /// returns the result of the validation; A failed result will be an error a successful will contain the claims and a success
- GrantValidationResult ValidateSendPassword(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId);
-}
diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs
index 952f4146ed..fae7ba4215 100644
--- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs
+++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs
@@ -1,4 +1,5 @@
-using Duende.IdentityServer.Validation;
+using Bit.Core.Auth.Identity.TokenProviders;
+using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
@@ -34,7 +35,7 @@ public static class SendAccessConstants
public static class GrantValidatorResults
{
///
- /// The sendId is valid and the request is well formed.
+ /// The sendId is valid and the request is well formed. Not returned in any response.
///
public const string ValidSendGuid = "valid_send_guid";
///
@@ -66,8 +67,40 @@ public static class SendAccessConstants
///
public const string EmailRequired = "email_required";
///
+ /// Represents the error code indicating that an email address is invalid.
+ ///
+ public const string EmailInvalid = "email_invalid";
+ ///
/// Represents the status indicating that both email and OTP are required, and the OTP has been sent.
///
public const string EmailOtpSent = "email_and_otp_required_otp_sent";
+ ///
+ /// Represents the status indicating that both email and OTP are required, and the OTP is invalid.
+ ///
+ public const string EmailOtpInvalid = "otp_invalid";
+ ///
+ /// For what ever reason the OTP was not able to be generated
+ ///
+ public const string OtpGenerationFailed = "otp_generation_failed";
+ }
+
+ ///
+ /// These are the constants for the OTP token that is generated during the email otp authentication process.
+ /// These items are required by to aid in the creation of a unique lookup key.
+ /// Look up key format is: {TokenProviderName}_{Purpose}_{TokenUniqueIdentifier}
+ ///
+ public static class OtpToken
+ {
+ public const string TokenProviderName = "send_access";
+ public const string Purpose = "email_otp";
+ ///
+ /// This will be send_id {0} and email {1}
+ ///
+ public const string TokenUniqueIdentifier = "{0}_{1}";
+ }
+
+ public static class OtpEmail
+ {
+ public const string Subject = "Your Bitwarden Send verification code is {0}";
}
}
diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs
index 7cfa2acd2a..5fe0b7b724 100644
--- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs
+++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs
@@ -13,7 +13,8 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
public class SendAccessGrantValidator(
ISendAuthenticationQuery _sendAuthenticationQuery,
- ISendPasswordRequestValidator _sendPasswordRequestValidator,
+ ISendAuthenticationMethodValidator _sendPasswordRequestValidator,
+ ISendAuthenticationMethodValidator _sendEmailOtpRequestValidator,
IFeatureService _featureService)
: IExtensionGrantValidator
{
@@ -61,16 +62,14 @@ public class SendAccessGrantValidator(
// automatically issue access token
context.Result = BuildBaseSuccessResult(sendIdGuid);
return;
-
case ResourcePassword rp:
- // Validate if the password is correct, or if we need to respond with a 400 stating a password has is required
- context.Result = _sendPasswordRequestValidator.ValidateSendPassword(context, rp, sendIdGuid);
+ // Validate if the password is correct, or if we need to respond with a 400 stating a password is invalid or required.
+ context.Result = await _sendPasswordRequestValidator.ValidateRequestAsync(context, rp, sendIdGuid);
return;
case EmailOtp eo:
- // TODO PM-22678: We will either send the OTP here or validate it based on if otp exists in the request.
- // SendOtpToEmail(eo.Emails) or ValidateOtp(eo.Emails);
- // break;
-
+ // Validate if the request has the correct email and OTP. If not, respond with a 400 and information about the failure.
+ context.Result = await _sendEmailOtpRequestValidator.ValidateRequestAsync(context, eo, sendIdGuid);
+ return;
default:
// shouldn’t ever hit this
throw new InvalidOperationException($"Unknown auth method: {method.GetType()}");
@@ -114,28 +113,27 @@ public class SendAccessGrantValidator(
///
/// Builds an error result for the specified error type.
///
- /// The error type.
+ /// This error is a constant string from
/// The error result.
private static GrantValidationResult BuildErrorResult(string error)
{
+ var customResponse = new Dictionary
+ {
+ { SendAccessConstants.SendAccessError, error }
+ };
+
return error switch
{
// Request is the wrong shape
SendAccessConstants.GrantValidatorResults.MissingSendId => new GrantValidationResult(
TokenRequestErrors.InvalidRequest,
- errorDescription: _sendGrantValidatorErrorDescriptions[SendAccessConstants.GrantValidatorResults.MissingSendId],
- new Dictionary
- {
- { SendAccessConstants.SendAccessError, SendAccessConstants.GrantValidatorResults.MissingSendId}
- }),
+ errorDescription: _sendGrantValidatorErrorDescriptions[error],
+ customResponse),
// Request is correct shape but data is bad
SendAccessConstants.GrantValidatorResults.InvalidSendId => new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
- errorDescription: _sendGrantValidatorErrorDescriptions[SendAccessConstants.GrantValidatorResults.InvalidSendId],
- new Dictionary
- {
- { SendAccessConstants.SendAccessError, SendAccessConstants.GrantValidatorResults.InvalidSendId }
- }),
+ errorDescription: _sendGrantValidatorErrorDescriptions[error],
+ customResponse),
// should never get here
_ => new GrantValidationResult(TokenRequestErrors.InvalidRequest)
};
@@ -145,7 +143,7 @@ public class SendAccessGrantValidator(
{
var claims = new List
{
- new(Claims.SendId, sendId.ToString()),
+ new(Claims.SendAccessClaims.SendId, sendId.ToString()),
new(Claims.Type, IdentityClientType.Send.ToString())
};
diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs
new file mode 100644
index 0000000000..e26556eb80
--- /dev/null
+++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs
@@ -0,0 +1,134 @@
+using System.Security.Claims;
+using Bit.Core.Auth.Identity.TokenProviders;
+using Bit.Core.Identity;
+using Bit.Core.Services;
+using Bit.Core.Tools.Models.Data;
+using Bit.Identity.IdentityServer.Enums;
+using Duende.IdentityServer.Models;
+using Duende.IdentityServer.Validation;
+
+namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
+
+public class SendEmailOtpRequestValidator(
+ IOtpTokenProvider otpTokenProvider,
+ IMailService mailService) : ISendAuthenticationMethodValidator
+{
+
+ ///
+ /// static object that contains the error messages for the SendEmailOtpRequestValidator.
+ ///
+ private static readonly Dictionary _sendEmailOtpValidatorErrorDescriptions = new()
+ {
+ { SendAccessConstants.EmailOtpValidatorResults.EmailRequired, $"{SendAccessConstants.TokenRequest.Email} is required." },
+ { SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent, "email otp sent." },
+ { SendAccessConstants.EmailOtpValidatorResults.EmailInvalid, $"{SendAccessConstants.TokenRequest.Email} is invalid." },
+ { SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid, $"{SendAccessConstants.TokenRequest.Email} otp is invalid." },
+ };
+
+ public async Task ValidateRequestAsync(ExtensionGrantValidationContext context, EmailOtp authMethod, Guid sendId)
+ {
+ var request = context.Request.Raw;
+ // get email
+ var email = request.Get(SendAccessConstants.TokenRequest.Email);
+
+ // It is an invalid request if the email is missing which indicated bad shape.
+ if (string.IsNullOrEmpty(email))
+ {
+ // Request is the wrong shape and doesn't contain an email field.
+ return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailRequired);
+ }
+
+ // email must be in the list of emails in the EmailOtp array
+ if (!authMethod.Emails.Contains(email))
+ {
+ return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid);
+ }
+
+ // get otp from request
+ var requestOtp = request.Get(SendAccessConstants.TokenRequest.Otp);
+ var uniqueIdentifierForTokenCache = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email);
+ if (string.IsNullOrEmpty(requestOtp))
+ {
+ // Since the request doesn't have an OTP, generate one
+ var token = await otpTokenProvider.GenerateTokenAsync(
+ SendAccessConstants.OtpToken.TokenProviderName,
+ SendAccessConstants.OtpToken.Purpose,
+ uniqueIdentifierForTokenCache);
+
+ // Verify that the OTP is generated
+ if (string.IsNullOrEmpty(token))
+ {
+ return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.OtpGenerationFailed);
+ }
+
+ await mailService.SendSendEmailOtpEmailAsync(
+ email,
+ token,
+ string.Format(SendAccessConstants.OtpEmail.Subject, token));
+ return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent);
+ }
+
+ // validate request otp
+ var otpResult = await otpTokenProvider.ValidateTokenAsync(
+ requestOtp,
+ SendAccessConstants.OtpToken.TokenProviderName,
+ SendAccessConstants.OtpToken.Purpose,
+ uniqueIdentifierForTokenCache);
+
+ // If OTP is invalid return error result
+ if (!otpResult)
+ {
+ return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid);
+ }
+
+ return BuildSuccessResult(sendId, email!);
+ }
+
+ private static GrantValidationResult BuildErrorResult(string error)
+ {
+ switch (error)
+ {
+ case SendAccessConstants.EmailOtpValidatorResults.EmailRequired:
+ case SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent:
+ return new GrantValidationResult(TokenRequestErrors.InvalidRequest,
+ errorDescription: _sendEmailOtpValidatorErrorDescriptions[error],
+ new Dictionary
+ {
+ { SendAccessConstants.SendAccessError, error }
+ });
+ case SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid:
+ case SendAccessConstants.EmailOtpValidatorResults.EmailInvalid:
+ return new GrantValidationResult(
+ TokenRequestErrors.InvalidGrant,
+ errorDescription: _sendEmailOtpValidatorErrorDescriptions[error],
+ new Dictionary
+ {
+ { SendAccessConstants.SendAccessError, error }
+ });
+ default:
+ return new GrantValidationResult(
+ TokenRequestErrors.InvalidRequest,
+ errorDescription: error);
+ }
+ }
+
+ ///
+ /// Builds a successful validation result for the Send password send_access grant.
+ ///
+ /// Guid of the send being accessed.
+ /// successful grant validation result
+ private static GrantValidationResult BuildSuccessResult(Guid sendId, string email)
+ {
+ var claims = new List
+ {
+ new(Claims.SendAccessClaims.SendId, sendId.ToString()),
+ new(Claims.SendAccessClaims.Email, email),
+ new(Claims.Type, IdentityClientType.Send.ToString())
+ };
+
+ return new GrantValidationResult(
+ subject: sendId.ToString(),
+ authenticationMethod: CustomGrantTypes.SendAccess,
+ claims: claims);
+ }
+}
diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs
index 3449b4cb56..4eade01a49 100644
--- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs
+++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs
@@ -8,7 +8,7 @@ using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
-public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher) : ISendPasswordRequestValidator
+public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher) : ISendAuthenticationMethodValidator
{
private readonly ISendPasswordHasher _sendPasswordHasher = sendPasswordHasher;
@@ -21,7 +21,7 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
{ SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired, $"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is required." }
};
- public GrantValidationResult ValidateSendPassword(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId)
+ public Task ValidateRequestAsync(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId)
{
var request = context.Request.Raw;
var clientHashedPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword);
@@ -30,13 +30,13 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
if (clientHashedPassword == null)
{
// Request is the wrong shape and doesn't contain a passwordHashB64 field.
- return new GrantValidationResult(
+ return Task.FromResult(new GrantValidationResult(
TokenRequestErrors.InvalidRequest,
errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired],
new Dictionary
{
{ SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired }
- });
+ }));
}
// _sendPasswordHasher.PasswordHashMatches checks for an empty string so no need to do it before we make the call.
@@ -46,16 +46,16 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
if (!hashMatches)
{
// Request is the correct shape but the passwordHashB64 doesn't match, hash could be empty.
- return new GrantValidationResult(
+ return Task.FromResult(new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch],
new Dictionary
{
{ SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch }
- });
+ }));
}
- return BuildSendPasswordSuccessResult(sendId);
+ return Task.FromResult(BuildSendPasswordSuccessResult(sendId));
}
///
@@ -67,7 +67,7 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
{
var claims = new List
{
- new(Claims.SendId, sendId.ToString()),
+ new(Claims.SendAccessClaims.SendId, sendId.ToString()),
new(Claims.Type, IdentityClientType.Send.ToString())
};
diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs
index d4f2ad8045..95c067d884 100644
--- a/src/Identity/Utilities/ServiceCollectionExtensions.cs
+++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs
@@ -1,6 +1,7 @@
using Bit.Core.Auth.Repositories;
using Bit.Core.IdentityServer;
using Bit.Core.Settings;
+using Bit.Core.Tools.Models.Data;
using Bit.Core.Utilities;
using Bit.Identity.IdentityServer;
using Bit.Identity.IdentityServer.ClientProviders;
@@ -26,7 +27,8 @@ public static class ServiceCollectionExtensions
services.AddTransient();
services.AddTransient();
services.AddTransient();
- services.AddTransient();
+ services.AddTransient, SendPasswordRequestValidator>();
+ services.AddTransient, SendEmailOtpRequestValidator>();
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
var identityServerBuilder = services
diff --git a/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs b/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs
index 27a0bc1bbc..bf5322d916 100644
--- a/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs
+++ b/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs
@@ -12,7 +12,7 @@ public class SendAccessClaimsPrincipalExtensionsTests
{
// Arrange
var guid = Guid.NewGuid();
- var claims = new[] { new Claim(Claims.SendId, guid.ToString()) };
+ var claims = new[] { new Claim(Claims.SendAccessClaims.SendId, guid.ToString()) };
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));
// Act
@@ -30,19 +30,19 @@ public class SendAccessClaimsPrincipalExtensionsTests
// Act & Assert
var ex = Assert.Throws(() => principal.GetSendId());
- Assert.Equal("Send ID claim not found.", ex.Message);
+ Assert.Equal("send_id claim not found.", ex.Message);
}
[Fact]
public void GetSendId_ThrowsInvalidOperationException_WhenClaimValueIsInvalid()
{
// Arrange
- var claims = new[] { new Claim(Claims.SendId, "not-a-guid") };
+ var claims = new[] { new Claim(Claims.SendAccessClaims.SendId, "not-a-guid") };
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));
// Act & Assert
var ex = Assert.Throws(() => principal.GetSendId());
- Assert.Equal("Invalid Send ID claim value.", ex.Message);
+ Assert.Equal("Invalid send_id claim value.", ex.Message);
}
[Fact]
diff --git a/test/Core.Test/Services/HandlebarsMailServiceTests.cs b/test/Core.Test/Services/HandlebarsMailServiceTests.cs
index 849a5130a3..242bcc60f3 100644
--- a/test/Core.Test/Services/HandlebarsMailServiceTests.cs
+++ b/test/Core.Test/Services/HandlebarsMailServiceTests.cs
@@ -247,11 +247,18 @@ public class HandlebarsMailServiceTests
}
}
- // Remove this test when we add actual tests. It only proves that
- // we've properly constructed the system under test.
[Fact]
- public void ServiceExists()
+ public async Task SendSendEmailOtpEmailAsync_SendsEmail()
{
- Assert.NotNull(_sut);
+ // Arrange
+ var email = "test@example.com";
+ var token = "aToken";
+ var subject = string.Format("Your Bitwarden Send verification code is {0}", token);
+
+ // Act
+ await _sut.SendSendEmailOtpEmailAsync(email, token, subject);
+
+ // Assert
+ await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Any());
}
}
diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs
index 4b8c267861..3b0cf2c282 100644
--- a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs
+++ b/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs
@@ -213,8 +213,8 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory
services.AddSingleton(sendAuthQuery);
// Mock password validator to return success
- var passwordValidator = Substitute.For();
- passwordValidator.ValidateSendPassword(
+ var passwordValidator = Substitute.For>();
+ passwordValidator.ValidateRequestAsync(
Arg.Any(),
Arg.Any(),
Arg.Any())
diff --git a/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs
new file mode 100644
index 0000000000..9d9bc03ef5
--- /dev/null
+++ b/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs
@@ -0,0 +1,256 @@
+using Bit.Core.Auth.Identity.TokenProviders;
+using Bit.Core.Enums;
+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.IntegrationTestCommon.Factories;
+using Duende.IdentityModel;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Identity.IntegrationTest.RequestValidation;
+
+public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture
+{
+ private readonly IdentityApplicationFactory _factory;
+
+ public SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ [Fact]
+ public async Task SendAccess_EmailOtpProtectedSend_MissingEmail_ReturnsInvalidRequest()
+ {
+ // Arrange
+ var sendId = Guid.NewGuid();
+ var client = _factory.WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureServices(services =>
+ {
+ var featureService = Substitute.For();
+ featureService.IsEnabled(Arg.Any()).Returns(true);
+ services.AddSingleton(featureService);
+
+ var sendAuthQuery = Substitute.For();
+ sendAuthQuery.GetAuthenticationMethod(sendId)
+ .Returns(new EmailOtp(["test@example.com"]));
+ services.AddSingleton(sendAuthQuery);
+ });
+ }).CreateClient();
+
+ var requestBody = CreateTokenRequestBody(sendId); // No email
+
+ // Act
+ var response = await client.PostAsync("/connect/token", requestBody);
+
+ // Assert
+ var content = await response.Content.ReadAsStringAsync();
+ Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content);
+ Assert.Contains("email is required", content);
+ }
+
+ [Fact]
+ public async Task SendAccess_EmailOtpProtectedSend_EmailWithoutOtp_SendsOtpEmail()
+ {
+ // Arrange
+ var sendId = Guid.NewGuid();
+ var email = "test@example.com";
+ var generatedToken = "123456";
+
+ var client = _factory.WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureServices(services =>
+ {
+ var featureService = Substitute.For();
+ featureService.IsEnabled(Arg.Any()).Returns(true);
+ services.AddSingleton(featureService);
+
+ var sendAuthQuery = Substitute.For();
+ sendAuthQuery.GetAuthenticationMethod(sendId)
+ .Returns(new EmailOtp([email]));
+ services.AddSingleton(sendAuthQuery);
+
+ // Mock OTP token provider
+ var otpProvider = Substitute.For>();
+ otpProvider.GenerateTokenAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(generatedToken);
+ services.AddSingleton(otpProvider);
+
+ // Mock mail service
+ var mailService = Substitute.For();
+ services.AddSingleton(mailService);
+ });
+ }).CreateClient();
+
+ var requestBody = CreateTokenRequestBody(sendId, sendEmail: email); // Email but no OTP
+
+ // Act
+ var response = await client.PostAsync("/connect/token", requestBody);
+
+ // Assert
+ var content = await response.Content.ReadAsStringAsync();
+ Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content);
+ Assert.Contains("email otp sent", content);
+ }
+
+ [Fact]
+ public async Task SendAccess_EmailOtpProtectedSend_ValidOtp_ReturnsAccessToken()
+ {
+ // Arrange
+ var sendId = Guid.NewGuid();
+ var email = "test@example.com";
+ var otp = "123456";
+
+ var client = _factory.WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureServices(services =>
+ {
+ var featureService = Substitute.For();
+ featureService.IsEnabled(Arg.Any()).Returns(true);
+ services.AddSingleton(featureService);
+
+ var sendAuthQuery = Substitute.For();
+ sendAuthQuery.GetAuthenticationMethod(sendId)
+ .Returns(new EmailOtp(new[] { email }));
+ services.AddSingleton(sendAuthQuery);
+
+ // Mock OTP token provider to validate successfully
+ var otpProvider = Substitute.For>();
+ otpProvider.ValidateTokenAsync(otp, Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(true);
+ services.AddSingleton(otpProvider);
+
+ var mailService = Substitute.For();
+ services.AddSingleton(mailService);
+ });
+ }).CreateClient();
+
+ var requestBody = CreateTokenRequestBody(sendId, sendEmail: email, emailOtp: otp);
+
+ // Act
+ var response = await client.PostAsync("/connect/token", requestBody);
+
+ // Assert
+ Assert.True(response.IsSuccessStatusCode);
+ var content = await response.Content.ReadAsStringAsync();
+ Assert.Contains(OidcConstants.TokenResponse.AccessToken, content);
+ Assert.Contains(OidcConstants.TokenResponse.BearerTokenType, content);
+ }
+
+ [Fact]
+ public async Task SendAccess_EmailOtpProtectedSend_InvalidOtp_ReturnsInvalidGrant()
+ {
+ // Arrange
+ var sendId = Guid.NewGuid();
+ var email = "test@example.com";
+ var invalidOtp = "wrong123";
+
+ var client = _factory.WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureServices(services =>
+ {
+ var featureService = Substitute.For();
+ featureService.IsEnabled(Arg.Any()).Returns(true);
+ services.AddSingleton(featureService);
+
+ var sendAuthQuery = Substitute.For();
+ sendAuthQuery.GetAuthenticationMethod(sendId)
+ .Returns(new EmailOtp(new[] { email }));
+ services.AddSingleton(sendAuthQuery);
+
+ // Mock OTP token provider to validate as false
+ var otpProvider = Substitute.For>();
+ otpProvider.ValidateTokenAsync(invalidOtp, Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(false);
+ services.AddSingleton(otpProvider);
+
+ var mailService = Substitute.For();
+ services.AddSingleton(mailService);
+ });
+ }).CreateClient();
+
+ var requestBody = CreateTokenRequestBody(sendId, sendEmail: email, emailOtp: invalidOtp);
+
+ // Act
+ var response = await client.PostAsync("/connect/token", requestBody);
+
+ // Assert
+ var content = await response.Content.ReadAsStringAsync();
+ Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content);
+ Assert.Contains("email otp is invalid", content);
+ }
+
+ [Fact]
+ public async Task SendAccess_EmailOtpProtectedSend_OtpGenerationFails_ReturnsInvalidRequest()
+ {
+ // Arrange
+ var sendId = Guid.NewGuid();
+ var email = "test@example.com";
+
+ var client = _factory.WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureServices(services =>
+ {
+ var featureService = Substitute.For();
+ featureService.IsEnabled(Arg.Any()).Returns(true);
+ services.AddSingleton(featureService);
+
+ var sendAuthQuery = Substitute.For();
+ sendAuthQuery.GetAuthenticationMethod(sendId)
+ .Returns(new EmailOtp(new[] { email }));
+ services.AddSingleton(sendAuthQuery);
+
+ // Mock OTP token provider to fail generation
+ var otpProvider = Substitute.For>();
+ otpProvider.GenerateTokenAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns((string)null);
+ services.AddSingleton(otpProvider);
+
+ var mailService = Substitute.For();
+ services.AddSingleton(mailService);
+ });
+ }).CreateClient();
+
+ var requestBody = CreateTokenRequestBody(sendId, sendEmail: email); // Email but no OTP
+
+ // Act
+ var response = await client.PostAsync("/connect/token", requestBody);
+
+ // Assert
+ var content = await response.Content.ReadAsStringAsync();
+ Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content);
+ }
+
+ private static FormUrlEncodedContent CreateTokenRequestBody(Guid sendId,
+ string sendEmail = null, string emailOtp = null)
+ {
+ var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray());
+ var parameters = new List>
+ {
+ new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess),
+ new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send ),
+ new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess),
+ new("deviceType", ((int)DeviceType.FirefoxBrowser).ToString()),
+ new(SendAccessConstants.TokenRequest.SendId, sendIdBase64)
+ };
+
+ if (!string.IsNullOrEmpty(sendEmail))
+ {
+ parameters.Add(new KeyValuePair(
+ SendAccessConstants.TokenRequest.Email, sendEmail));
+ }
+
+ if (!string.IsNullOrEmpty(emailOtp))
+ {
+ parameters.Add(new KeyValuePair(
+ SendAccessConstants.TokenRequest.Otp, emailOtp));
+ }
+
+ return new FormUrlEncodedContent(parameters);
+ }
+}
diff --git a/test/Identity.Test/IdentityServer/SendAccessGrantValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs
similarity index 90%
rename from test/Identity.Test/IdentityServer/SendAccessGrantValidatorTests.cs
rename to test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs
index c3d422c51a..e651709c47 100644
--- a/test/Identity.Test/IdentityServer/SendAccessGrantValidatorTests.cs
+++ b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs
@@ -17,7 +17,7 @@ using Duende.IdentityServer.Validation;
using NSubstitute;
using Xunit;
-namespace Bit.Identity.Test.IdentityServer;
+namespace Bit.Identity.Test.IdentityServer.SendAccess;
[SutProviderCustomize]
public class SendAccessGrantValidatorTests
@@ -167,7 +167,7 @@ public class SendAccessGrantValidatorTests
// get the claims from the subject
var claims = subject.Claims.ToList();
Assert.NotEmpty(claims);
- Assert.Contains(claims, c => c.Type == Claims.SendId && c.Value == sendId.ToString());
+ 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());
}
@@ -189,8 +189,8 @@ public class SendAccessGrantValidatorTests
.GetAuthenticationMethod(sendId)
.Returns(resourcePassword);
- sutProvider.GetDependency()
- .ValidateSendPassword(context, resourcePassword, sendId)
+ sutProvider.GetDependency>()
+ .ValidateRequestAsync(context, resourcePassword, sendId)
.Returns(expectedResult);
// Act
@@ -198,15 +198,16 @@ public class SendAccessGrantValidatorTests
// Assert
Assert.Equal(expectedResult, context.Result);
- sutProvider.GetDependency()
+ await sutProvider.GetDependency>()
.Received(1)
- .ValidateSendPassword(context, resourcePassword, sendId);
+ .ValidateRequestAsync(context, resourcePassword, sendId);
}
[Theory, BitAutoData]
- public async Task ValidateAsync_EmailOtpMethod_NotImplemented_ThrowsError(
+ public async Task ValidateAsync_EmailOtpMethod_CallsEmailOtp(
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
SutProvider sutProvider,
+ GrantValidationResult expectedResult,
Guid sendId,
EmailOtp emailOtp)
{
@@ -216,15 +217,22 @@ public class SendAccessGrantValidatorTests
sendId,
tokenRequest);
-
sutProvider.GetDependency()
.GetAuthenticationMethod(sendId)
.Returns(emailOtp);
+ sutProvider.GetDependency>()
+ .ValidateRequestAsync(context, emailOtp, sendId)
+ .Returns(expectedResult);
+
// Act
+ await sutProvider.Sut.ValidateAsync(context);
+
// Assert
- // Currently the EmailOtp case doesn't set a result, so it should be null
- await Assert.ThrowsAsync(async () => await sutProvider.Sut.ValidateAsync(context));
+ Assert.Equal(expectedResult, context.Result);
+ await sutProvider.GetDependency>()
+ .Received(1)
+ .ValidateRequestAsync(context, emailOtp, sendId);
}
[Theory, BitAutoData]
@@ -256,7 +264,7 @@ public class SendAccessGrantValidatorTests
public void GrantType_ReturnsCorrectType()
{
// Arrange & Act
- var validator = new SendAccessGrantValidator(null!, null!, null!);
+ var validator = new SendAccessGrantValidator(null!, null!, null!, null!);
// Assert
Assert.Equal(CustomGrantTypes.SendAccess, ((IExtensionGrantValidator)validator).GrantType);
diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs
new file mode 100644
index 0000000000..2fd21fd4cf
--- /dev/null
+++ b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs
@@ -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 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>()
+ .DidNotReceive()
+ .GenerateTokenAsync(Arg.Any(), Arg.Any(), Arg.Any());
+
+ await sutProvider.GetDependency()
+ .DidNotReceive()
+ .SendSendEmailOtpEmailAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateRequestAsync_EmailNotInList_ReturnsInvalidRequest(
+ SutProvider 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>()
+ .DidNotReceive()
+ .GenerateTokenAsync(Arg.Any(), Arg.Any(), Arg.Any());
+
+ await sutProvider.GetDependency()
+ .DidNotReceive()
+ .SendSendEmailOtpEmailAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateRequestAsync_EmailWithoutOtp_GeneratesAndSendsOtp(
+ SutProvider 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>()
+ .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>()
+ .Received(1)
+ .GenerateTokenAsync(
+ SendAccessConstants.OtpToken.TokenProviderName,
+ SendAccessConstants.OtpToken.Purpose,
+ expectedUniqueId);
+
+ // Verify email sending
+ await sutProvider.GetDependency()
+ .Received(1)
+ .SendSendEmailOtpEmailAsync(email, generatedToken, Arg.Any());
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateRequestAsync_OtpGenerationFails_ReturnsGenerationFailedError(
+ SutProvider 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>()
+ .GenerateTokenAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .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()
+ .DidNotReceive()
+ .SendSendEmailOtpEmailAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateRequestAsync_ValidOtp_ReturnsSuccess(
+ SutProvider 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>()
+ .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>()
+ .Received(1)
+ .ValidateTokenAsync(otp, SendAccessConstants.OtpToken.TokenProviderName, SendAccessConstants.OtpToken.Purpose, expectedUniqueId);
+
+ // Verify no email was sent (validation only)
+ await sutProvider.GetDependency()
+ .DidNotReceive()
+ .SendSendEmailOtpEmailAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateRequestAsync_InvalidOtp_ReturnsInvalidGrant(
+ SutProvider 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>()
+ .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>()
+ .Received(1)
+ .ValidateTokenAsync(invalidOtp,
+ SendAccessConstants.OtpToken.TokenProviderName,
+ SendAccessConstants.OtpToken.Purpose,
+ expectedUniqueId);
+ }
+
+ [Fact]
+ public void Constructor_WithValidParameters_CreatesInstance()
+ {
+ // Arrange
+ var otpTokenProvider = Substitute.For>();
+ var mailService = Substitute.For();
+
+ // 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;
+ }
+}
diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs
new file mode 100644
index 0000000000..e2b8b49830
--- /dev/null
+++ b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs
@@ -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 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()
+ .DidNotReceive()
+ .PasswordHashMatches(Arg.Any(), Arg.Any());
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant(
+ SutProvider 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()
+ .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()
+ .Received(1)
+ .PasswordHashMatches(resourcePassword.Hash, clientPasswordHash);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateSendPassword_PasswordHashMatches_ReturnsSuccess(
+ SutProvider 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()
+ .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()
+ .Received(1)
+ .PasswordHashMatches(resourcePassword.Hash, clientPasswordHash);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher(
+ SutProvider sutProvider,
+ [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
+ ResourcePassword resourcePassword,
+ Guid sendId)
+ {
+ // Arrange
+ tokenRequest.Raw = CreateValidatedTokenRequest(sendId, string.Empty);
+
+ var context = new ExtensionGrantValidationContext
+ {
+ Request = tokenRequest
+ };
+
+ sutProvider.GetDependency()
+ .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()
+ .Received(1)
+ .PasswordHashMatches(resourcePassword.Hash, string.Empty);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher(
+ SutProvider 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()
+ .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()
+ .Received(1)
+ .PasswordHashMatches(resourcePassword.Hash, whitespacePassword);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant(
+ SutProvider 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()
+ .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()
+ .Received(1)
+ .PasswordHashMatches(resourcePassword.Hash, $"{firstPassword},{secondPassword}");
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateSendPassword_SuccessResult_ContainsCorrectClaims(
+ SutProvider 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()
+ .PasswordHashMatches(Arg.Any(), Arg.Any())
+ .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();
+
+ // 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;
+ }
+}
diff --git a/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs
index a776a70178..ccee33d8c7 100644
--- a/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs
+++ b/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs
@@ -21,7 +21,7 @@ namespace Bit.Identity.Test.IdentityServer;
public class SendPasswordRequestValidatorTests
{
[Theory, BitAutoData]
- public void ValidateSendPassword_MissingPasswordHash_ReturnsInvalidRequest(
+ public async Task ValidateSendPassword_MissingPasswordHash_ReturnsInvalidRequest(
SutProvider sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword,
@@ -36,7 +36,7 @@ public class SendPasswordRequestValidatorTests
};
// Act
- var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
+ var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert
Assert.True(result.IsError);
@@ -50,7 +50,7 @@ public class SendPasswordRequestValidatorTests
}
[Theory, BitAutoData]
- public void ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant(
+ public async Task ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant(
SutProvider sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword,
@@ -70,7 +70,7 @@ public class SendPasswordRequestValidatorTests
.Returns(false);
// Act
- var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
+ var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert
Assert.True(result.IsError);
@@ -84,7 +84,7 @@ public class SendPasswordRequestValidatorTests
}
[Theory, BitAutoData]
- public void ValidateSendPassword_PasswordHashMatches_ReturnsSuccess(
+ public async Task ValidateSendPassword_PasswordHashMatches_ReturnsSuccess(
SutProvider sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword,
@@ -104,7 +104,7 @@ public class SendPasswordRequestValidatorTests
.Returns(true);
// Act
- var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
+ var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert
Assert.False(result.IsError);
@@ -113,7 +113,7 @@ public class SendPasswordRequestValidatorTests
Assert.Equal(sendId, sub.GetSendId());
// Verify claims
- Assert.Contains(sub.Claims, c => c.Type == Claims.SendId && c.Value == sendId.ToString());
+ 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
@@ -123,7 +123,7 @@ public class SendPasswordRequestValidatorTests
}
[Theory, BitAutoData]
- public void ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher(
+ public async Task ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher(
SutProvider sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword,
@@ -142,7 +142,7 @@ public class SendPasswordRequestValidatorTests
.Returns(false);
// Act
- var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
+ var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert
Assert.True(result.IsError);
@@ -155,7 +155,7 @@ public class SendPasswordRequestValidatorTests
}
[Theory, BitAutoData]
- public void ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher(
+ public async Task ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher(
SutProvider sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword,
@@ -175,7 +175,7 @@ public class SendPasswordRequestValidatorTests
.Returns(false);
// Act
- var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
+ var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert
Assert.True(result.IsError);
@@ -187,7 +187,7 @@ public class SendPasswordRequestValidatorTests
}
[Theory, BitAutoData]
- public void ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant(
+ public async Task ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant(
SutProvider sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword,
@@ -208,7 +208,7 @@ public class SendPasswordRequestValidatorTests
.Returns(true);
// Act
- var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
+ var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert
Assert.True(result.IsError);
@@ -221,7 +221,7 @@ public class SendPasswordRequestValidatorTests
}
[Theory, BitAutoData]
- public void ValidateSendPassword_SuccessResult_ContainsCorrectClaims(
+ public async Task ValidateSendPassword_SuccessResult_ContainsCorrectClaims(
SutProvider sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword,
@@ -241,13 +241,13 @@ public class SendPasswordRequestValidatorTests
.Returns(true);
// Act
- var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
+ 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.SendId);
+ var sendIdClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.SendAccessClaims.SendId);
Assert.NotNull(sendIdClaim);
Assert.Equal(sendId.ToString(), sendIdClaim.Value);