1
0
mirror of https://github.com/bitwarden/server synced 2025-12-10 13:23:27 +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

@@ -9,12 +9,12 @@ public static class SendAccessClaimsPrincipalExtensions
{ {
ArgumentNullException.ThrowIfNull(user); ArgumentNullException.ThrowIfNull(user);
var sendIdClaim = user.FindFirst(Claims.SendId) var sendIdClaim = user.FindFirst(Claims.SendAccessClaims.SendId)
?? throw new InvalidOperationException("Send ID claim not found."); ?? throw new InvalidOperationException("send_id claim not found.");
if (!Guid.TryParse(sendIdClaim.Value, out var sendGuid)) 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; return sendGuid;

View File

@@ -39,6 +39,9 @@ public static class Claims
public const string ManageResetPassword = "manageresetpassword"; public const string ManageResetPassword = "manageresetpassword";
public const string ManageScim = "managescim"; public const string ManageScim = "managescim";
} }
public static class SendAccessClaims
public const string SendId = "send_id"; {
public const string SendId = "send_id";
public const string Email = "send_email";
}
} }

View File

@@ -0,0 +1,28 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
Verify your email to access this Bitwarden Send.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
Your verification code is: <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{Token}}</b>
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<hr />
{{TheDate}} at {{TheTime}} {{TimeZone}}
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@@ -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}}

View File

@@ -0,0 +1,12 @@
namespace Bit.Core.Models.Mail.Auth;
/// <summary>
/// Send email OTP view model
/// </summary>
public class DefaultEmailOtpViewModel : BaseMailModel
{
public string? Token { get; set; }
public string? TheDate { get; set; }
public string? TheTime { get; set; }
public string? TimeZone { get; set; }
}

View File

@@ -30,6 +30,7 @@ public interface IMailService
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail); Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
Task SendChangeEmailEmailAsync(string newEmailAddress, string token); Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose); 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 SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType type, DateTime utcNow, string ip);
Task SendNoMasterPasswordHintEmailAsync(string email); Task SendNoMasterPasswordHintEmailAsync(string email);
Task SendMasterPasswordHintEmailAsync(string email, string hint); Task SendMasterPasswordHintEmailAsync(string email, string hint);

View File

@@ -15,6 +15,7 @@ using Bit.Core.Billing.Models.Mail;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Mail; using Bit.Core.Models.Mail;
using Bit.Core.Models.Mail.Auth;
using Bit.Core.Models.Mail.Billing; using Bit.Core.Models.Mail.Billing;
using Bit.Core.Models.Mail.FamiliesForEnterprise; using Bit.Core.Models.Mail.FamiliesForEnterprise;
using Bit.Core.Models.Mail.Provider; using Bit.Core.Models.Mail.Provider;
@@ -199,6 +200,26 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message); 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) public async Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
{ {
// Check if we've sent this email within the last hour // Check if we've sent this email within the last hour

View File

@@ -93,6 +93,11 @@ public class NoopMailService : IMailService
return Task.FromResult(0); 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) public Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
{ {
return Task.FromResult(0); return Task.FromResult(0);

View File

@@ -29,7 +29,7 @@ public class ApiResources
}), }),
new(ApiScopes.ApiSendAccess, [ new(ApiScopes.ApiSendAccess, [
JwtClaimTypes.Subject, JwtClaimTypes.Subject,
Claims.SendId Claims.SendAccessClaims.SendId
]), ]),
new(ApiScopes.Internal, new[] { JwtClaimTypes.Subject }), new(ApiScopes.Internal, new[] { JwtClaimTypes.Subject }),
new(ApiScopes.ApiPush, new[] { JwtClaimTypes.Subject }), new(ApiScopes.ApiPush, new[] { JwtClaimTypes.Subject }),

View File

@@ -0,0 +1,15 @@
using Bit.Core.Tools.Models.Data;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
public interface ISendAuthenticationMethodValidator<T> where T : SendAuthenticationMethod
{
/// <summary>
/// </summary>
/// <param name="context">request context</param>
/// <param name="authMethod">SendAuthenticationRecord that contains the information to be compared against the context</param>
/// <param name="sendId">the sendId being accessed</param>
/// <returns>returns the result of the validation; A failed result will be an error a successful will contain the claims and a success</returns>
Task<GrantValidationResult> ValidateRequestAsync(ExtensionGrantValidationContext context, T authMethod, Guid sendId);
}

View File

@@ -1,16 +0,0 @@
using Bit.Core.Tools.Models.Data;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
public interface ISendPasswordRequestValidator
{
/// <summary>
/// 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.
/// </summary>
/// <param name="context">request context</param>
/// <param name="resourcePassword">resource password authentication method containing the hash of the Send being retrieved</param>
/// <returns>returns the result of the validation; A failed result will be an error a successful will contain the claims and a success</returns>
GrantValidationResult ValidateSendPassword(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId);
}

View File

@@ -1,4 +1,5 @@
using Duende.IdentityServer.Validation; using Bit.Core.Auth.Identity.TokenProviders;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
@@ -34,7 +35,7 @@ public static class SendAccessConstants
public static class GrantValidatorResults public static class GrantValidatorResults
{ {
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
public const string ValidSendGuid = "valid_send_guid"; public const string ValidSendGuid = "valid_send_guid";
/// <summary> /// <summary>
@@ -66,8 +67,40 @@ public static class SendAccessConstants
/// </summary> /// </summary>
public const string EmailRequired = "email_required"; public const string EmailRequired = "email_required";
/// <summary> /// <summary>
/// Represents the error code indicating that an email address is invalid.
/// </summary>
public const string EmailInvalid = "email_invalid";
/// <summary>
/// Represents the status indicating that both email and OTP are required, and the OTP has been sent. /// Represents the status indicating that both email and OTP are required, and the OTP has been sent.
/// </summary> /// </summary>
public const string EmailOtpSent = "email_and_otp_required_otp_sent"; public const string EmailOtpSent = "email_and_otp_required_otp_sent";
/// <summary>
/// Represents the status indicating that both email and OTP are required, and the OTP is invalid.
/// </summary>
public const string EmailOtpInvalid = "otp_invalid";
/// <summary>
/// For what ever reason the OTP was not able to be generated
/// </summary>
public const string OtpGenerationFailed = "otp_generation_failed";
}
/// <summary>
/// These are the constants for the OTP token that is generated during the email otp authentication process.
/// These items are required by <see cref="IOtpTokenProvider{TOptions}"/> to aid in the creation of a unique lookup key.
/// Look up key format is: {TokenProviderName}_{Purpose}_{TokenUniqueIdentifier}
/// </summary>
public static class OtpToken
{
public const string TokenProviderName = "send_access";
public const string Purpose = "email_otp";
/// <summary>
/// This will be send_id {0} and email {1}
/// </summary>
public const string TokenUniqueIdentifier = "{0}_{1}";
}
public static class OtpEmail
{
public const string Subject = "Your Bitwarden Send verification code is {0}";
} }
} }

View File

@@ -13,7 +13,8 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
public class SendAccessGrantValidator( public class SendAccessGrantValidator(
ISendAuthenticationQuery _sendAuthenticationQuery, ISendAuthenticationQuery _sendAuthenticationQuery,
ISendPasswordRequestValidator _sendPasswordRequestValidator, ISendAuthenticationMethodValidator<ResourcePassword> _sendPasswordRequestValidator,
ISendAuthenticationMethodValidator<EmailOtp> _sendEmailOtpRequestValidator,
IFeatureService _featureService) IFeatureService _featureService)
: IExtensionGrantValidator : IExtensionGrantValidator
{ {
@@ -61,16 +62,14 @@ public class SendAccessGrantValidator(
// automatically issue access token // automatically issue access token
context.Result = BuildBaseSuccessResult(sendIdGuid); context.Result = BuildBaseSuccessResult(sendIdGuid);
return; return;
case ResourcePassword rp: case ResourcePassword rp:
// Validate if the password is correct, or if we need to respond with a 400 stating a password has is required // Validate if the password is correct, or if we need to respond with a 400 stating a password is invalid or required.
context.Result = _sendPasswordRequestValidator.ValidateSendPassword(context, rp, sendIdGuid); context.Result = await _sendPasswordRequestValidator.ValidateRequestAsync(context, rp, sendIdGuid);
return; return;
case EmailOtp eo: case EmailOtp eo:
// TODO PM-22678: We will either send the OTP here or validate it based on if otp exists in the request. // Validate if the request has the correct email and OTP. If not, respond with a 400 and information about the failure.
// SendOtpToEmail(eo.Emails) or ValidateOtp(eo.Emails); context.Result = await _sendEmailOtpRequestValidator.ValidateRequestAsync(context, eo, sendIdGuid);
// break; return;
default: default:
// shouldnt ever hit this // shouldnt ever hit this
throw new InvalidOperationException($"Unknown auth method: {method.GetType()}"); throw new InvalidOperationException($"Unknown auth method: {method.GetType()}");
@@ -114,28 +113,27 @@ public class SendAccessGrantValidator(
/// <summary> /// <summary>
/// Builds an error result for the specified error type. /// Builds an error result for the specified error type.
/// </summary> /// </summary>
/// <param name="error">The error type.</param> /// <param name="error">This error is a constant string from <see cref="SendAccessConstants.GrantValidatorResults"/></param>
/// <returns>The error result.</returns> /// <returns>The error result.</returns>
private static GrantValidationResult BuildErrorResult(string error) private static GrantValidationResult BuildErrorResult(string error)
{ {
var customResponse = new Dictionary<string, object>
{
{ SendAccessConstants.SendAccessError, error }
};
return error switch return error switch
{ {
// Request is the wrong shape // Request is the wrong shape
SendAccessConstants.GrantValidatorResults.MissingSendId => new GrantValidationResult( SendAccessConstants.GrantValidatorResults.MissingSendId => new GrantValidationResult(
TokenRequestErrors.InvalidRequest, TokenRequestErrors.InvalidRequest,
errorDescription: _sendGrantValidatorErrorDescriptions[SendAccessConstants.GrantValidatorResults.MissingSendId], errorDescription: _sendGrantValidatorErrorDescriptions[error],
new Dictionary<string, object> customResponse),
{
{ SendAccessConstants.SendAccessError, SendAccessConstants.GrantValidatorResults.MissingSendId}
}),
// Request is correct shape but data is bad // Request is correct shape but data is bad
SendAccessConstants.GrantValidatorResults.InvalidSendId => new GrantValidationResult( SendAccessConstants.GrantValidatorResults.InvalidSendId => new GrantValidationResult(
TokenRequestErrors.InvalidGrant, TokenRequestErrors.InvalidGrant,
errorDescription: _sendGrantValidatorErrorDescriptions[SendAccessConstants.GrantValidatorResults.InvalidSendId], errorDescription: _sendGrantValidatorErrorDescriptions[error],
new Dictionary<string, object> customResponse),
{
{ SendAccessConstants.SendAccessError, SendAccessConstants.GrantValidatorResults.InvalidSendId }
}),
// should never get here // should never get here
_ => new GrantValidationResult(TokenRequestErrors.InvalidRequest) _ => new GrantValidationResult(TokenRequestErrors.InvalidRequest)
}; };
@@ -145,7 +143,7 @@ public class SendAccessGrantValidator(
{ {
var claims = new List<Claim> var claims = new List<Claim>
{ {
new(Claims.SendId, sendId.ToString()), new(Claims.SendAccessClaims.SendId, sendId.ToString()),
new(Claims.Type, IdentityClientType.Send.ToString()) new(Claims.Type, IdentityClientType.Send.ToString())
}; };

View File

@@ -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<DefaultOtpTokenProviderOptions> otpTokenProvider,
IMailService mailService) : ISendAuthenticationMethodValidator<EmailOtp>
{
/// <summary>
/// static object that contains the error messages for the SendEmailOtpRequestValidator.
/// </summary>
private static readonly Dictionary<string, string> _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<GrantValidationResult> 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<string, object>
{
{ SendAccessConstants.SendAccessError, error }
});
case SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid:
case SendAccessConstants.EmailOtpValidatorResults.EmailInvalid:
return new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
errorDescription: _sendEmailOtpValidatorErrorDescriptions[error],
new Dictionary<string, object>
{
{ SendAccessConstants.SendAccessError, error }
});
default:
return new GrantValidationResult(
TokenRequestErrors.InvalidRequest,
errorDescription: error);
}
}
/// <summary>
/// Builds a successful validation result for the Send password send_access grant.
/// </summary>
/// <param name="sendId">Guid of the send being accessed.</param>
/// <returns>successful grant validation result</returns>
private static GrantValidationResult BuildSuccessResult(Guid sendId, string email)
{
var claims = new List<Claim>
{
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);
}
}

View File

@@ -8,7 +8,7 @@ using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher) : ISendPasswordRequestValidator public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher) : ISendAuthenticationMethodValidator<ResourcePassword>
{ {
private readonly ISendPasswordHasher _sendPasswordHasher = sendPasswordHasher; private readonly ISendPasswordHasher _sendPasswordHasher = sendPasswordHasher;
@@ -21,7 +21,7 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
{ SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired, $"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is required." } { SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired, $"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is required." }
}; };
public GrantValidationResult ValidateSendPassword(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId) public Task<GrantValidationResult> ValidateRequestAsync(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId)
{ {
var request = context.Request.Raw; var request = context.Request.Raw;
var clientHashedPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword); var clientHashedPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword);
@@ -30,13 +30,13 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
if (clientHashedPassword == null) if (clientHashedPassword == null)
{ {
// Request is the wrong shape and doesn't contain a passwordHashB64 field. // Request is the wrong shape and doesn't contain a passwordHashB64 field.
return new GrantValidationResult( return Task.FromResult(new GrantValidationResult(
TokenRequestErrors.InvalidRequest, TokenRequestErrors.InvalidRequest,
errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired], errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired],
new Dictionary<string, object> new Dictionary<string, object>
{ {
{ SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired } { SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired }
}); }));
} }
// _sendPasswordHasher.PasswordHashMatches checks for an empty string so no need to do it before we make the call. // _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) if (!hashMatches)
{ {
// Request is the correct shape but the passwordHashB64 doesn't match, hash could be empty. // 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, TokenRequestErrors.InvalidGrant,
errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch], errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch],
new Dictionary<string, object> new Dictionary<string, object>
{ {
{ SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch } { SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch }
}); }));
} }
return BuildSendPasswordSuccessResult(sendId); return Task.FromResult(BuildSendPasswordSuccessResult(sendId));
} }
/// <summary> /// <summary>
@@ -67,7 +67,7 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
{ {
var claims = new List<Claim> var claims = new List<Claim>
{ {
new(Claims.SendId, sendId.ToString()), new(Claims.SendAccessClaims.SendId, sendId.ToString()),
new(Claims.Type, IdentityClientType.Send.ToString()) new(Claims.Type, IdentityClientType.Send.ToString())
}; };

View File

@@ -1,6 +1,7 @@
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.IdentityServer; using Bit.Core.IdentityServer;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Identity.IdentityServer; using Bit.Identity.IdentityServer;
using Bit.Identity.IdentityServer.ClientProviders; using Bit.Identity.IdentityServer.ClientProviders;
@@ -26,7 +27,8 @@ public static class ServiceCollectionExtensions
services.AddTransient<IDeviceValidator, DeviceValidator>(); services.AddTransient<IDeviceValidator, DeviceValidator>();
services.AddTransient<ITwoFactorAuthenticationValidator, TwoFactorAuthenticationValidator>(); services.AddTransient<ITwoFactorAuthenticationValidator, TwoFactorAuthenticationValidator>();
services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>(); services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>();
services.AddTransient<ISendPasswordRequestValidator, SendPasswordRequestValidator>(); services.AddTransient<ISendAuthenticationMethodValidator<ResourcePassword>, SendPasswordRequestValidator>();
services.AddTransient<ISendAuthenticationMethodValidator<EmailOtp>, SendEmailOtpRequestValidator>();
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity); var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
var identityServerBuilder = services var identityServerBuilder = services

View File

@@ -12,7 +12,7 @@ public class SendAccessClaimsPrincipalExtensionsTests
{ {
// Arrange // Arrange
var guid = Guid.NewGuid(); 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)); var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));
// Act // Act
@@ -30,19 +30,19 @@ public class SendAccessClaimsPrincipalExtensionsTests
// Act & Assert // Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => principal.GetSendId()); var ex = Assert.Throws<InvalidOperationException>(() => principal.GetSendId());
Assert.Equal("Send ID claim not found.", ex.Message); Assert.Equal("send_id claim not found.", ex.Message);
} }
[Fact] [Fact]
public void GetSendId_ThrowsInvalidOperationException_WhenClaimValueIsInvalid() public void GetSendId_ThrowsInvalidOperationException_WhenClaimValueIsInvalid()
{ {
// Arrange // 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)); var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));
// Act & Assert // Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => principal.GetSendId()); var ex = Assert.Throws<InvalidOperationException>(() => principal.GetSendId());
Assert.Equal("Invalid Send ID claim value.", ex.Message); Assert.Equal("Invalid send_id claim value.", ex.Message);
} }
[Fact] [Fact]

View File

@@ -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] [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<MailMessage>());
} }
} }

View File

@@ -213,8 +213,8 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory
services.AddSingleton(sendAuthQuery); services.AddSingleton(sendAuthQuery);
// Mock password validator to return success // Mock password validator to return success
var passwordValidator = Substitute.For<ISendPasswordRequestValidator>(); var passwordValidator = Substitute.For<ISendAuthenticationMethodValidator<ResourcePassword>>();
passwordValidator.ValidateSendPassword( passwordValidator.ValidateRequestAsync(
Arg.Any<ExtensionGrantValidationContext>(), Arg.Any<ExtensionGrantValidationContext>(),
Arg.Any<ResourcePassword>(), Arg.Any<ResourcePassword>(),
Arg.Any<Guid>()) Arg.Any<Guid>())

View File

@@ -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<IdentityApplicationFactory>
{
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<IFeatureService>();
featureService.IsEnabled(Arg.Any<string>()).Returns(true);
services.AddSingleton(featureService);
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
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<IFeatureService>();
featureService.IsEnabled(Arg.Any<string>()).Returns(true);
services.AddSingleton(featureService);
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
sendAuthQuery.GetAuthenticationMethod(sendId)
.Returns(new EmailOtp([email]));
services.AddSingleton(sendAuthQuery);
// Mock OTP token provider
var otpProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
otpProvider.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(generatedToken);
services.AddSingleton(otpProvider);
// Mock mail service
var mailService = Substitute.For<IMailService>();
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<IFeatureService>();
featureService.IsEnabled(Arg.Any<string>()).Returns(true);
services.AddSingleton(featureService);
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
sendAuthQuery.GetAuthenticationMethod(sendId)
.Returns(new EmailOtp(new[] { email }));
services.AddSingleton(sendAuthQuery);
// Mock OTP token provider to validate successfully
var otpProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
otpProvider.ValidateTokenAsync(otp, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(true);
services.AddSingleton(otpProvider);
var mailService = Substitute.For<IMailService>();
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<IFeatureService>();
featureService.IsEnabled(Arg.Any<string>()).Returns(true);
services.AddSingleton(featureService);
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
sendAuthQuery.GetAuthenticationMethod(sendId)
.Returns(new EmailOtp(new[] { email }));
services.AddSingleton(sendAuthQuery);
// Mock OTP token provider to validate as false
var otpProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
otpProvider.ValidateTokenAsync(invalidOtp, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(false);
services.AddSingleton(otpProvider);
var mailService = Substitute.For<IMailService>();
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<IFeatureService>();
featureService.IsEnabled(Arg.Any<string>()).Returns(true);
services.AddSingleton(featureService);
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
sendAuthQuery.GetAuthenticationMethod(sendId)
.Returns(new EmailOtp(new[] { email }));
services.AddSingleton(sendAuthQuery);
// Mock OTP token provider to fail generation
var otpProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
otpProvider.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns((string)null);
services.AddSingleton(otpProvider);
var mailService = Substitute.For<IMailService>();
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<KeyValuePair<string, string>>
{
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<string, string>(
SendAccessConstants.TokenRequest.Email, sendEmail));
}
if (!string.IsNullOrEmpty(emailOtp))
{
parameters.Add(new KeyValuePair<string, string>(
SendAccessConstants.TokenRequest.Otp, emailOtp));
}
return new FormUrlEncodedContent(parameters);
}
}

View File

@@ -17,7 +17,7 @@ using Duende.IdentityServer.Validation;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Bit.Identity.Test.IdentityServer; namespace Bit.Identity.Test.IdentityServer.SendAccess;
[SutProviderCustomize] [SutProviderCustomize]
public class SendAccessGrantValidatorTests public class SendAccessGrantValidatorTests
@@ -167,7 +167,7 @@ public class SendAccessGrantValidatorTests
// get the claims from the subject // get the claims from the subject
var claims = subject.Claims.ToList(); var claims = subject.Claims.ToList();
Assert.NotEmpty(claims); 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()); Assert.Contains(claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString());
} }
@@ -189,8 +189,8 @@ public class SendAccessGrantValidatorTests
.GetAuthenticationMethod(sendId) .GetAuthenticationMethod(sendId)
.Returns(resourcePassword); .Returns(resourcePassword);
sutProvider.GetDependency<ISendPasswordRequestValidator>() sutProvider.GetDependency<ISendAuthenticationMethodValidator<ResourcePassword>>()
.ValidateSendPassword(context, resourcePassword, sendId) .ValidateRequestAsync(context, resourcePassword, sendId)
.Returns(expectedResult); .Returns(expectedResult);
// Act // Act
@@ -198,15 +198,16 @@ public class SendAccessGrantValidatorTests
// Assert // Assert
Assert.Equal(expectedResult, context.Result); Assert.Equal(expectedResult, context.Result);
sutProvider.GetDependency<ISendPasswordRequestValidator>() await sutProvider.GetDependency<ISendAuthenticationMethodValidator<ResourcePassword>>()
.Received(1) .Received(1)
.ValidateSendPassword(context, resourcePassword, sendId); .ValidateRequestAsync(context, resourcePassword, sendId);
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task ValidateAsync_EmailOtpMethod_NotImplemented_ThrowsError( public async Task ValidateAsync_EmailOtpMethod_CallsEmailOtp(
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
SutProvider<SendAccessGrantValidator> sutProvider, SutProvider<SendAccessGrantValidator> sutProvider,
GrantValidationResult expectedResult,
Guid sendId, Guid sendId,
EmailOtp emailOtp) EmailOtp emailOtp)
{ {
@@ -216,15 +217,22 @@ public class SendAccessGrantValidatorTests
sendId, sendId,
tokenRequest); tokenRequest);
sutProvider.GetDependency<ISendAuthenticationQuery>() sutProvider.GetDependency<ISendAuthenticationQuery>()
.GetAuthenticationMethod(sendId) .GetAuthenticationMethod(sendId)
.Returns(emailOtp); .Returns(emailOtp);
sutProvider.GetDependency<ISendAuthenticationMethodValidator<EmailOtp>>()
.ValidateRequestAsync(context, emailOtp, sendId)
.Returns(expectedResult);
// Act // Act
await sutProvider.Sut.ValidateAsync(context);
// Assert // Assert
// Currently the EmailOtp case doesn't set a result, so it should be null Assert.Equal(expectedResult, context.Result);
await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.ValidateAsync(context)); await sutProvider.GetDependency<ISendAuthenticationMethodValidator<EmailOtp>>()
.Received(1)
.ValidateRequestAsync(context, emailOtp, sendId);
} }
[Theory, BitAutoData] [Theory, BitAutoData]
@@ -256,7 +264,7 @@ public class SendAccessGrantValidatorTests
public void GrantType_ReturnsCorrectType() public void GrantType_ReturnsCorrectType()
{ {
// Arrange & Act // Arrange & Act
var validator = new SendAccessGrantValidator(null!, null!, null!); var validator = new SendAccessGrantValidator(null!, null!, null!, null!);
// Assert // Assert
Assert.Equal(CustomGrantTypes.SendAccess, ((IExtensionGrantValidator)validator).GrantType); Assert.Equal(CustomGrantTypes.SendAccess, ((IExtensionGrantValidator)validator).GrantType);

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

View File

@@ -21,7 +21,7 @@ namespace Bit.Identity.Test.IdentityServer;
public class SendPasswordRequestValidatorTests public class SendPasswordRequestValidatorTests
{ {
[Theory, BitAutoData] [Theory, BitAutoData]
public void ValidateSendPassword_MissingPasswordHash_ReturnsInvalidRequest( public async Task ValidateSendPassword_MissingPasswordHash_ReturnsInvalidRequest(
SutProvider<SendPasswordRequestValidator> sutProvider, SutProvider<SendPasswordRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword, ResourcePassword resourcePassword,
@@ -36,7 +36,7 @@ public class SendPasswordRequestValidatorTests
}; };
// Act // Act
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert // Assert
Assert.True(result.IsError); Assert.True(result.IsError);
@@ -50,7 +50,7 @@ public class SendPasswordRequestValidatorTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public void ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant( public async Task ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant(
SutProvider<SendPasswordRequestValidator> sutProvider, SutProvider<SendPasswordRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword, ResourcePassword resourcePassword,
@@ -70,7 +70,7 @@ public class SendPasswordRequestValidatorTests
.Returns(false); .Returns(false);
// Act // Act
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert // Assert
Assert.True(result.IsError); Assert.True(result.IsError);
@@ -84,7 +84,7 @@ public class SendPasswordRequestValidatorTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public void ValidateSendPassword_PasswordHashMatches_ReturnsSuccess( public async Task ValidateSendPassword_PasswordHashMatches_ReturnsSuccess(
SutProvider<SendPasswordRequestValidator> sutProvider, SutProvider<SendPasswordRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword, ResourcePassword resourcePassword,
@@ -104,7 +104,7 @@ public class SendPasswordRequestValidatorTests
.Returns(true); .Returns(true);
// Act // Act
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert // Assert
Assert.False(result.IsError); Assert.False(result.IsError);
@@ -113,7 +113,7 @@ public class SendPasswordRequestValidatorTests
Assert.Equal(sendId, sub.GetSendId()); Assert.Equal(sendId, sub.GetSendId());
// Verify claims // 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()); Assert.Contains(sub.Claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString());
// Verify password hasher was called // Verify password hasher was called
@@ -123,7 +123,7 @@ public class SendPasswordRequestValidatorTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public void ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher( public async Task ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher(
SutProvider<SendPasswordRequestValidator> sutProvider, SutProvider<SendPasswordRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword, ResourcePassword resourcePassword,
@@ -142,7 +142,7 @@ public class SendPasswordRequestValidatorTests
.Returns(false); .Returns(false);
// Act // Act
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert // Assert
Assert.True(result.IsError); Assert.True(result.IsError);
@@ -155,7 +155,7 @@ public class SendPasswordRequestValidatorTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public void ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher( public async Task ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher(
SutProvider<SendPasswordRequestValidator> sutProvider, SutProvider<SendPasswordRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword, ResourcePassword resourcePassword,
@@ -175,7 +175,7 @@ public class SendPasswordRequestValidatorTests
.Returns(false); .Returns(false);
// Act // Act
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert // Assert
Assert.True(result.IsError); Assert.True(result.IsError);
@@ -187,7 +187,7 @@ public class SendPasswordRequestValidatorTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public void ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant( public async Task ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant(
SutProvider<SendPasswordRequestValidator> sutProvider, SutProvider<SendPasswordRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword, ResourcePassword resourcePassword,
@@ -208,7 +208,7 @@ public class SendPasswordRequestValidatorTests
.Returns(true); .Returns(true);
// Act // Act
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert // Assert
Assert.True(result.IsError); Assert.True(result.IsError);
@@ -221,7 +221,7 @@ public class SendPasswordRequestValidatorTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public void ValidateSendPassword_SuccessResult_ContainsCorrectClaims( public async Task ValidateSendPassword_SuccessResult_ContainsCorrectClaims(
SutProvider<SendPasswordRequestValidator> sutProvider, SutProvider<SendPasswordRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
ResourcePassword resourcePassword, ResourcePassword resourcePassword,
@@ -241,13 +241,13 @@ public class SendPasswordRequestValidatorTests
.Returns(true); .Returns(true);
// Act // Act
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
// Assert // Assert
Assert.False(result.IsError); Assert.False(result.IsError);
var sub = result.Subject; 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.NotNull(sendIdClaim);
Assert.Equal(sendId.ToString(), sendIdClaim.Value); Assert.Equal(sendId.ToString(), sendIdClaim.Value);