server/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs
Ike d2d3e0f11b
[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
2025-09-02 16:48:57 -04:00

273 lines
10 KiB
C#

using Bit.Core;
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 Duende.IdentityServer.Validation;
using NSubstitute;
using Xunit;
namespace Bit.Identity.IntegrationTest.RequestValidation;
// in order to test the default case for the authentication method, we need to create a custom one so we can ensure the
// method throws as expected.
internal record AnUnknownAuthenticationMethod : SendAuthenticationMethod { }
public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory factory) : IClassFixture<IdentityApplicationFactory>
{
private readonly IdentityApplicationFactory _factory = factory;
[Fact]
public async Task SendAccessGrant_FeatureFlagDisabled_ReturnsUnsupportedGrantType()
{
// Arrange
var sendId = Guid.NewGuid();
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Mock feature service to return false
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(false);
services.AddSingleton(featureService);
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
// Assert
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("unsupported_grant_type", content);
}
[Fact]
public async Task SendAccessGrant_ValidNotAuthenticatedSend_ReturnsAccessToken()
{
// Arrange
var sendId = Guid.NewGuid();
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Mock feature service to return true
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(true);
services.AddSingleton(featureService);
// Mock send authentication query
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
sendAuthQuery.GetAuthenticationMethod(sendId).Returns(new NotAuthenticated());
services.AddSingleton(sendAuthQuery);
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
// Assert
Assert.True(response.IsSuccessStatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("access_token", content);
Assert.Contains("bearer", content.ToLower());
}
[Fact]
public async Task SendAccessGrant_MissingSendId_ReturnsInvalidRequest()
{
// Arrange
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(true);
services.AddSingleton(featureService);
});
}).CreateClient();
var requestBody = new FormUrlEncodedContent([
new KeyValuePair<string, string>(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess),
new KeyValuePair<string, string>(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send)
]);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
// Assert
var content = await response.Content.ReadAsStringAsync();
Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content);
Assert.Contains($"{SendAccessConstants.TokenRequest.SendId} is required", content);
}
[Fact]
public async Task SendAccessGrant_EmptySendGuid_ReturnsInvalidGrant()
{
// Arrange
var sendId = Guid.Empty;
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(true);
services.AddSingleton(featureService);
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
// Assert
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("invalid_grant", content);
}
[Fact]
public async Task SendAccessGrant_NeverAuthenticateSend_ReturnsInvalidGrant()
{
// Arrange
var sendId = Guid.NewGuid();
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(true);
services.AddSingleton(featureService);
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
sendAuthQuery.GetAuthenticationMethod(sendId).Returns(new NeverAuthenticate());
services.AddSingleton(sendAuthQuery);
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
// Assert
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("invalid_grant", content);
}
[Fact]
public async Task SendAccessGrant_UnknownAuthenticationMethod_ThrowsInvalidOperation()
{
// Arrange
var sendId = Guid.NewGuid();
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(true);
services.AddSingleton(featureService);
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
sendAuthQuery.GetAuthenticationMethod(sendId).Returns(new AnUnknownAuthenticationMethod());
services.AddSingleton(sendAuthQuery);
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId);
// Act
var error = await client.PostAsync("/connect/token", requestBody);
// Assert
// We want to parse the response and ensure we get the correct error from the server
var content = await error.Content.ReadAsStringAsync();
Assert.Contains("invalid_grant", content);
}
[Fact]
public async Task SendAccessGrant_PasswordProtectedSend_CallsPasswordValidator()
{
// Arrange
var sendId = Guid.NewGuid();
var resourcePassword = new ResourcePassword("test-password-hash");
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(true);
services.AddSingleton(featureService);
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
sendAuthQuery.GetAuthenticationMethod(sendId).Returns(resourcePassword);
services.AddSingleton(sendAuthQuery);
// Mock password validator to return success
var passwordValidator = Substitute.For<ISendAuthenticationMethodValidator<ResourcePassword>>();
passwordValidator.ValidateRequestAsync(
Arg.Any<ExtensionGrantValidationContext>(),
Arg.Any<ResourcePassword>(),
Arg.Any<Guid>())
.Returns(new GrantValidationResult(
subject: sendId.ToString(),
authenticationMethod: CustomGrantTypes.SendAccess));
services.AddSingleton(passwordValidator);
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId, "password123");
// Act
var response = await client.PostAsync("/connect/token", requestBody);
// Assert
Assert.True(response.IsSuccessStatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("access_token", content);
Assert.Contains("Bearer", content);
}
private static FormUrlEncodedContent CreateTokenRequestBody(
Guid sendId,
string password = null,
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(password))
{
parameters.Add(new(SendAccessConstants.TokenRequest.ClientB64HashedPassword, password));
}
if (!string.IsNullOrEmpty(emailOtp) && !string.IsNullOrEmpty(sendEmail))
{
parameters.AddRange(
[
new KeyValuePair<string, string>("email", sendEmail),
new KeyValuePair<string, string>("email_otp", emailOtp)
]);
}
return new FormUrlEncodedContent(parameters);
}
}