Browse Source
**feat**: create `SendGrantValidator` and initial `SendPasswordValidator` for Send access grants **feat**: add feature flag to toggle Send grant validation logic **feat**: add Send client to Identity and update `ApiClient` to generic `Client` **feat**: register Send services in DI pipeline **feat**: add claims management support to `ProfileService` **feat**: distinguish between invalid grant and invalid request in `SendAccessGrantValidator` **fix**: update parsing of `send_id` from request **fix**: add early return when feature flag is disabled **fix**: rename and organize Send access scope and grant type **fix**: dotnet format **test**: add unit and integration tests for `SendGrantValidator` **test**: update OpenID configuration and API resource claims **doc**: move documentation to interfaces and update inline comments **chore**: add TODO for future support of `CustomGrantTypes`pull/6191/head
24 changed files with 961 additions and 19 deletions
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
namespace Bit.Identity.IdentityServer.Enums; |
||||
|
||||
/// <summary> |
||||
/// A class containing custom grant types used in the Bitwarden IdentityServer implementation |
||||
/// </summary> |
||||
public static class CustomGrantTypes |
||||
{ |
||||
public const string SendAccess = "send_access"; |
||||
// TODO: PM-24471 replace magic string with a constant for webauthn |
||||
public const string WebAuthn = "webauthn"; |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess.Enums; |
||||
|
||||
/// <summary> |
||||
/// These control the results of the SendGrantValidator. <see cref="SendGrantValidator"/> |
||||
/// </summary> |
||||
internal enum SendGrantValidatorResultTypes |
||||
{ |
||||
ValidSendGuid, |
||||
MissingSendId, |
||||
InvalidSendId |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess.Enums; |
||||
|
||||
/// <summary> |
||||
/// These control the results of the SendPasswordValidator. <see cref="SendPasswordRequestValidator"/> |
||||
/// </summary> |
||||
internal enum SendPasswordValidatorResultTypes |
||||
{ |
||||
RequestPasswordDoesNotMatch |
||||
} |
||||
@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
|
||||
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); |
||||
} |
||||
@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
|
||||
using System.Security.Claims; |
||||
using Bit.Core; |
||||
using Bit.Core.Identity; |
||||
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.Enums; |
||||
using Duende.IdentityServer.Models; |
||||
using Duende.IdentityServer.Validation; |
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; |
||||
|
||||
public class SendAccessGrantValidator( |
||||
ISendAuthenticationQuery _sendAuthenticationQuery, |
||||
ISendPasswordRequestValidator _sendPasswordRequestValidator, |
||||
IFeatureService _featureService) |
||||
: IExtensionGrantValidator |
||||
{ |
||||
string IExtensionGrantValidator.GrantType => CustomGrantTypes.SendAccess; |
||||
|
||||
private static readonly Dictionary<SendGrantValidatorResultTypes, string> |
||||
_sendGrantValidatorErrors = new() |
||||
{ |
||||
{ SendGrantValidatorResultTypes.MissingSendId, "send_id is required." }, |
||||
{ SendGrantValidatorResultTypes.InvalidSendId, "send_id is invalid." } |
||||
}; |
||||
|
||||
|
||||
public async Task ValidateAsync(ExtensionGrantValidationContext context) |
||||
{ |
||||
// Check the feature flag |
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.SendAccess)) |
||||
{ |
||||
context.Result = new GrantValidationResult(TokenRequestErrors.UnsupportedGrantType); |
||||
return; |
||||
} |
||||
|
||||
var (sendIdGuid, result) = GetRequestSendId(context); |
||||
if (result != SendGrantValidatorResultTypes.ValidSendGuid) |
||||
{ |
||||
context.Result = BuildErrorResult(result); |
||||
return; |
||||
} |
||||
|
||||
// Look up send by id |
||||
var method = await _sendAuthenticationQuery.GetAuthenticationMethod(sendIdGuid); |
||||
|
||||
switch (method) |
||||
{ |
||||
case NeverAuthenticate: |
||||
// null send scenario. |
||||
// TODO PM-22675: Add send enumeration protection here (primarily benefits self hosted instances). |
||||
// We should only map to password or email + OTP protected. |
||||
// If user submits password guess for a falsely protected send, then we will return invalid password. |
||||
// If user submits email + OTP guess for a falsely protected send, then we will return email sent, do not actually send an email. |
||||
context.Result = BuildErrorResult(SendGrantValidatorResultTypes.InvalidSendId); |
||||
return; |
||||
|
||||
case NotAuthenticated: |
||||
// automatically issue access token |
||||
context.Result = BuildBaseSuccessResult(sendIdGuid); |
||||
return; |
||||
|
||||
case ResourcePassword rp: |
||||
// TODO PM-22675: Validate if the password is correct. |
||||
context.Result = _sendPasswordRequestValidator.ValidateSendPassword(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; |
||||
|
||||
default: |
||||
// shouldn’t ever hit this |
||||
throw new InvalidOperationException($"Unknown auth method: {method.GetType()}"); |
||||
} |
||||
} |
||||
|
||||
/// <summary> |
||||
/// tries to parse the send_id from the request. |
||||
/// If it is not present or invalid, sets the correct result error. |
||||
/// </summary> |
||||
/// <param name="context">request context</param> |
||||
/// <returns>a parsed sendId Guid and success result or a Guid.Empty and error type otherwise</returns> |
||||
private static (Guid, SendGrantValidatorResultTypes) GetRequestSendId(ExtensionGrantValidationContext context) |
||||
{ |
||||
var request = context.Request.Raw; |
||||
var sendId = request.Get("send_id"); |
||||
|
||||
// if the sendId is null then the request is the wrong shape and the request is invalid |
||||
if (sendId == null) |
||||
{ |
||||
return (Guid.Empty, SendGrantValidatorResultTypes.MissingSendId); |
||||
} |
||||
// the send_id is not null so the request is the correct shape, so we will attempt to parse it |
||||
try |
||||
{ |
||||
var guidBytes = CoreHelpers.Base64UrlDecode(sendId); |
||||
var sendGuid = new Guid(guidBytes); |
||||
// Guid.Empty indicates an invalid send_id return invalid grant |
||||
if (sendGuid == Guid.Empty) |
||||
{ |
||||
return (Guid.Empty, SendGrantValidatorResultTypes.InvalidSendId); |
||||
} |
||||
return (sendGuid, SendGrantValidatorResultTypes.ValidSendGuid); |
||||
} |
||||
catch |
||||
{ |
||||
return (Guid.Empty, SendGrantValidatorResultTypes.InvalidSendId); |
||||
} |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Builds an error result for the specified error type. |
||||
/// </summary> |
||||
/// <param name="error">The error type.</param> |
||||
/// <returns>The error result.</returns> |
||||
private static GrantValidationResult BuildErrorResult(SendGrantValidatorResultTypes error) |
||||
{ |
||||
return error switch |
||||
{ |
||||
// Request is the wrong shape |
||||
SendGrantValidatorResultTypes.MissingSendId => new GrantValidationResult( |
||||
TokenRequestErrors.InvalidRequest, |
||||
errorDescription: _sendGrantValidatorErrors[SendGrantValidatorResultTypes.MissingSendId]), |
||||
// Request is correct shape but data is bad |
||||
SendGrantValidatorResultTypes.InvalidSendId => new GrantValidationResult( |
||||
TokenRequestErrors.InvalidGrant, |
||||
errorDescription: _sendGrantValidatorErrors[SendGrantValidatorResultTypes.InvalidSendId]), |
||||
// should never get here |
||||
_ => new GrantValidationResult(TokenRequestErrors.InvalidRequest) |
||||
}; |
||||
} |
||||
|
||||
private static GrantValidationResult BuildBaseSuccessResult(Guid sendId) |
||||
{ |
||||
var claims = new List<Claim> |
||||
{ |
||||
new(Claims.SendId, sendId.ToString()), |
||||
new(Claims.Type, IdentityClientType.Send.ToString()) |
||||
}; |
||||
|
||||
return new GrantValidationResult( |
||||
subject: sendId.ToString(), |
||||
authenticationMethod: CustomGrantTypes.SendAccess, |
||||
claims: claims); |
||||
} |
||||
} |
||||
@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
|
||||
using System.Security.Claims; |
||||
using Bit.Core.Identity; |
||||
using Bit.Core.KeyManagement.Sends; |
||||
using Bit.Core.Tools.Models.Data; |
||||
using Bit.Identity.IdentityServer.Enums; |
||||
using Bit.Identity.IdentityServer.RequestValidators.SendAccess.Enums; |
||||
using Duende.IdentityServer.Models; |
||||
using Duende.IdentityServer.Validation; |
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; |
||||
|
||||
public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher) : ISendPasswordRequestValidator |
||||
{ |
||||
private readonly ISendPasswordHasher _sendPasswordHasher = sendPasswordHasher; |
||||
|
||||
/// <summary> |
||||
/// static object that contains the error messages for the SendPasswordRequestValidator. |
||||
/// </summary> |
||||
private static Dictionary<SendPasswordValidatorResultTypes, string> _sendPasswordValidatorErrors = new() |
||||
{ |
||||
{ SendPasswordValidatorResultTypes.RequestPasswordDoesNotMatch, "Request Password hash is invalid." } |
||||
}; |
||||
|
||||
public GrantValidationResult ValidateSendPassword(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId) |
||||
{ |
||||
var request = context.Request.Raw; |
||||
var clientHashedPassword = request.Get("password_hash"); |
||||
|
||||
if (string.IsNullOrEmpty(clientHashedPassword)) |
||||
{ |
||||
return new GrantValidationResult( |
||||
TokenRequestErrors.InvalidRequest, |
||||
errorDescription: _sendPasswordValidatorErrors[SendPasswordValidatorResultTypes.RequestPasswordDoesNotMatch]); |
||||
} |
||||
|
||||
var hashMatches = _sendPasswordHasher.PasswordHashMatches( |
||||
resourcePassword.Hash, clientHashedPassword); |
||||
|
||||
if (!hashMatches) |
||||
{ |
||||
return new GrantValidationResult( |
||||
TokenRequestErrors.InvalidGrant, |
||||
errorDescription: _sendPasswordValidatorErrors[SendPasswordValidatorResultTypes.RequestPasswordDoesNotMatch]); |
||||
} |
||||
|
||||
return BuildSendPasswordSuccessResult(sendId); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Builds a successful validation result for the Send password send_access grant. |
||||
/// </summary> |
||||
/// <param name="sendId"></param> |
||||
/// <returns></returns> |
||||
private static GrantValidationResult BuildSendPasswordSuccessResult(Guid sendId) |
||||
{ |
||||
var claims = new List<Claim> |
||||
{ |
||||
new(Claims.SendId, sendId.ToString()), |
||||
new(Claims.Type, IdentityClientType.Send.ToString()) |
||||
}; |
||||
|
||||
return new GrantValidationResult( |
||||
subject: sendId.ToString(), |
||||
authenticationMethod: CustomGrantTypes.SendAccess, |
||||
claims: claims); |
||||
} |
||||
} |
||||
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
using Bit.Core.Enums; |
||||
using Bit.Core.IdentityServer; |
||||
using Bit.Core.Settings; |
||||
using Bit.Identity.IdentityServer.Enums; |
||||
using Duende.IdentityServer.Models; |
||||
|
||||
namespace Bit.Identity.IdentityServer.StaticClients; |
||||
public static class SendClientBuilder |
||||
{ |
||||
public static Client Build(GlobalSettings globalSettings) |
||||
{ |
||||
return new Client() |
||||
{ |
||||
ClientId = BitwardenClient.Send, |
||||
AllowedGrantTypes = [CustomGrantTypes.SendAccess], |
||||
AccessTokenLifetime = 60 * globalSettings.SendAccessTokenLifetimeInMinutes, |
||||
|
||||
// Do not allow refresh tokens to be issued. |
||||
AllowOfflineAccess = false, |
||||
|
||||
// Send is a public anonymous client, so no secret is required (or really possible to use securely). |
||||
RequireClientSecret = false, |
||||
|
||||
// Allow web vault to use this client. |
||||
AllowedCorsOrigins = [globalSettings.BaseServiceUri.Vault], |
||||
|
||||
// Setup API scopes that the client can request in the scope property of the token request. |
||||
AllowedScopes = [ApiScopes.ApiSendAccess], |
||||
}; |
||||
} |
||||
} |
||||
@ -0,0 +1,271 @@
@@ -0,0 +1,271 @@
|
||||
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.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>("grant_type", CustomGrantTypes.SendAccess), |
||||
new KeyValuePair<string, string>("client_id", BitwardenClient.Send) |
||||
]); |
||||
|
||||
// Act |
||||
var response = await client.PostAsync("/connect/token", requestBody); |
||||
|
||||
// Assert |
||||
var content = await response.Content.ReadAsStringAsync(); |
||||
Assert.Contains("invalid_request", content); |
||||
Assert.Contains("send_id 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<ISendPasswordRequestValidator>(); |
||||
passwordValidator.ValidateSendPassword( |
||||
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("grant_type", CustomGrantTypes.SendAccess), |
||||
new("client_id", BitwardenClient.Send ), |
||||
new("scope", ApiScopes.ApiSendAccess), |
||||
new("deviceType", ((int)DeviceType.FirefoxBrowser).ToString()), |
||||
new("send_id", sendIdBase64) |
||||
}; |
||||
|
||||
if (!string.IsNullOrEmpty(password)) |
||||
{ |
||||
parameters.Add(new("password_hash", 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); |
||||
} |
||||
} |
||||
@ -0,0 +1,333 @@
@@ -0,0 +1,333 @@
|
||||
using System.Collections.Specialized; |
||||
using Bit.Core; |
||||
using Bit.Core.Enums; |
||||
using Bit.Core.Identity; |
||||
using Bit.Core.IdentityServer; |
||||
using Bit.Core.Services; |
||||
using Bit.Core.Tools.Models.Data; |
||||
using Bit.Core.Tools.SendFeatures.Queries.Interfaces; |
||||
using Bit.Core.Utilities; |
||||
using Bit.Identity.IdentityServer.Enums; |
||||
using Bit.Identity.IdentityServer.RequestValidators.SendAccess; |
||||
using Bit.Test.Common.AutoFixture; |
||||
using Bit.Test.Common.AutoFixture.Attributes; |
||||
using Duende.IdentityServer.Extensions; |
||||
using Duende.IdentityServer.Validation; |
||||
using IdentityModel; |
||||
using NSubstitute; |
||||
using Xunit; |
||||
|
||||
namespace Bit.Identity.Test.IdentityServer; |
||||
|
||||
[SutProviderCustomize] |
||||
public class SendAccessGrantValidatorTests |
||||
{ |
||||
[Theory, BitAutoData] |
||||
public async Task ValidateAsync_FeatureFlagDisabled_ReturnsUnsupportedGrantType( |
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, |
||||
SutProvider<SendAccessGrantValidator> sutProvider) |
||||
{ |
||||
// Arrange |
||||
sutProvider.GetDependency<IFeatureService>() |
||||
.IsEnabled(FeatureFlagKeys.SendAccess) |
||||
.Returns(false); |
||||
|
||||
var context = new ExtensionGrantValidationContext |
||||
{ |
||||
Request = tokenRequest |
||||
}; |
||||
|
||||
// Act |
||||
await sutProvider.Sut.ValidateAsync(context); |
||||
|
||||
// Assert |
||||
Assert.True(context.Result.IsError); |
||||
Assert.Equal(OidcConstants.TokenErrors.UnsupportedGrantType, context.Result.Error); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task ValidateAsync_MissingSendId_ReturnsInvalidRequest( |
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, |
||||
SutProvider<SendAccessGrantValidator> sutProvider) |
||||
{ |
||||
// Arrange |
||||
sutProvider.GetDependency<IFeatureService>() |
||||
.IsEnabled(FeatureFlagKeys.SendAccess) |
||||
.Returns(true); |
||||
|
||||
var context = new ExtensionGrantValidationContext |
||||
{ |
||||
Request = tokenRequest |
||||
}; |
||||
|
||||
// Act |
||||
await sutProvider.Sut.ValidateAsync(context); |
||||
|
||||
// Assert |
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, context.Result.Error); |
||||
Assert.Equal("send_id is required.", context.Result.ErrorDescription); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task ValidateAsync_InvalidSendId_ReturnsInvalidGrant( |
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, |
||||
SutProvider<SendAccessGrantValidator> sutProvider) |
||||
{ |
||||
// Arrange |
||||
sutProvider.GetDependency<IFeatureService>() |
||||
.IsEnabled(FeatureFlagKeys.SendAccess) |
||||
.Returns(true); |
||||
|
||||
var context = new ExtensionGrantValidationContext(); |
||||
|
||||
tokenRequest.GrantType = CustomGrantTypes.SendAccess; |
||||
tokenRequest.Raw = CreateTokenRequestBody(Guid.Empty); |
||||
|
||||
// To preserve the CreateTokenRequestBody method for more general usage we over write the sendId |
||||
tokenRequest.Raw.Set("send_id", "invalid-guid-format"); |
||||
context.Request = tokenRequest; |
||||
|
||||
// Act |
||||
await sutProvider.Sut.ValidateAsync(context); |
||||
|
||||
// Assert |
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.Result.Error); |
||||
Assert.Equal("send_id is invalid.", context.Result.ErrorDescription); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task ValidateAsync_EmptyGuidSendId_ReturnsInvalidGrant( |
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, |
||||
SutProvider<SendAccessGrantValidator> sutProvider) |
||||
{ |
||||
// Arrange |
||||
var context = SetupTokenRequest( |
||||
sutProvider, |
||||
Guid.Empty, // Empty Guid as sendId |
||||
tokenRequest); |
||||
|
||||
// Act |
||||
await sutProvider.Sut.ValidateAsync(context); |
||||
|
||||
// Assert |
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.Result.Error); |
||||
Assert.Equal("send_id is invalid.", context.Result.ErrorDescription); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task ValidateAsync_NeverAuthenticateMethod_ReturnsInvalidGrant( |
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, |
||||
SutProvider<SendAccessGrantValidator> sutProvider, |
||||
Guid sendId) |
||||
{ |
||||
// Arrange |
||||
var context = SetupTokenRequest( |
||||
sutProvider, |
||||
sendId, |
||||
tokenRequest); |
||||
|
||||
sutProvider.GetDependency<ISendAuthenticationQuery>() |
||||
.GetAuthenticationMethod(sendId) |
||||
.Returns(new NeverAuthenticate()); |
||||
|
||||
// Act |
||||
await sutProvider.Sut.ValidateAsync(context); |
||||
|
||||
// Assert |
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.Result.Error); |
||||
Assert.Equal("send_id is invalid.", context.Result.ErrorDescription); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task ValidateAsync_NotAuthenticatedMethod_ReturnsSuccess( |
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, |
||||
SutProvider<SendAccessGrantValidator> sutProvider, |
||||
Guid sendId) |
||||
{ |
||||
// Arrange |
||||
var context = SetupTokenRequest( |
||||
sutProvider, |
||||
sendId, |
||||
tokenRequest); |
||||
|
||||
sutProvider.GetDependency<ISendAuthenticationQuery>() |
||||
.GetAuthenticationMethod(sendId) |
||||
.Returns(new NotAuthenticated()); |
||||
|
||||
// Act |
||||
await sutProvider.Sut.ValidateAsync(context); |
||||
|
||||
// Assert |
||||
Assert.False(context.Result.IsError); |
||||
// get the claims principal from the result |
||||
var subject = context.Result.Subject; |
||||
Assert.NotNull(subject); |
||||
Assert.Equal(sendId.ToString(), subject.GetSubjectId()); |
||||
Assert.Equal(CustomGrantTypes.SendAccess, subject.GetAuthenticationMethod()); |
||||
// get the claims from the subject |
||||
var claims = subject.Claims.ToList(); |
||||
Assert.NotEmpty(claims); |
||||
Assert.Contains(claims, c => c.Type == Claims.SendId && c.Value == sendId.ToString()); |
||||
Assert.Contains(claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString()); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task ValidateAsync_ResourcePasswordMethod_CallsPasswordValidator( |
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, |
||||
SutProvider<SendAccessGrantValidator> sutProvider, |
||||
Guid sendId, |
||||
ResourcePassword resourcePassword, |
||||
GrantValidationResult expectedResult) |
||||
{ |
||||
// Arrange |
||||
var context = SetupTokenRequest( |
||||
sutProvider, |
||||
sendId, |
||||
tokenRequest); |
||||
|
||||
sutProvider.GetDependency<ISendAuthenticationQuery>() |
||||
.GetAuthenticationMethod(sendId) |
||||
.Returns(resourcePassword); |
||||
|
||||
sutProvider.GetDependency<ISendPasswordRequestValidator>() |
||||
.ValidateSendPassword(context, resourcePassword, sendId) |
||||
.Returns(expectedResult); |
||||
|
||||
// Act |
||||
await sutProvider.Sut.ValidateAsync(context); |
||||
|
||||
// Assert |
||||
Assert.Equal(expectedResult, context.Result); |
||||
sutProvider.GetDependency<ISendPasswordRequestValidator>() |
||||
.Received(1) |
||||
.ValidateSendPassword(context, resourcePassword, sendId); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task ValidateAsync_EmailOtpMethod_NotImplemented_ThrowsError( |
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, |
||||
SutProvider<SendAccessGrantValidator> sutProvider, |
||||
Guid sendId, |
||||
EmailOtp emailOtp) |
||||
{ |
||||
// Arrange |
||||
var context = SetupTokenRequest( |
||||
sutProvider, |
||||
sendId, |
||||
tokenRequest); |
||||
|
||||
|
||||
sutProvider.GetDependency<ISendAuthenticationQuery>() |
||||
.GetAuthenticationMethod(sendId) |
||||
.Returns(emailOtp); |
||||
|
||||
// Act |
||||
// Assert |
||||
// Currently the EmailOtp case doesn't set a result, so it should be null |
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.ValidateAsync(context)); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task ValidateAsync_UnknownAuthMethod_ThrowsInvalidOperationException( |
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, |
||||
SutProvider<SendAccessGrantValidator> sutProvider, |
||||
Guid sendId) |
||||
{ |
||||
// Arrange |
||||
var context = SetupTokenRequest( |
||||
sutProvider, |
||||
sendId, |
||||
tokenRequest); |
||||
|
||||
// Create a mock authentication method that's not handled |
||||
var unknownMethod = Substitute.For<SendAuthenticationMethod>(); |
||||
sutProvider.GetDependency<ISendAuthenticationQuery>() |
||||
.GetAuthenticationMethod(sendId) |
||||
.Returns(unknownMethod); |
||||
|
||||
// Act & Assert |
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>( |
||||
() => sutProvider.Sut.ValidateAsync(context)); |
||||
|
||||
Assert.StartsWith("Unknown auth method:", exception.Message); |
||||
} |
||||
|
||||
[Fact] |
||||
public void GrantType_ReturnsCorrectType() |
||||
{ |
||||
// Arrange & Act |
||||
var validator = new SendAccessGrantValidator(null!, null!, null!); |
||||
|
||||
// Assert |
||||
Assert.Equal(CustomGrantTypes.SendAccess, ((IExtensionGrantValidator)validator).GrantType); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Mutator method fo the SutProvider and the Context to set up a valid request |
||||
/// </summary> |
||||
/// <param name="sutProvider">current sut provider</param> |
||||
/// <param name="context">test context</param> |
||||
/// <param name="sendId">the send id</param> |
||||
/// <param name="request">the token request</param> |
||||
private static ExtensionGrantValidationContext SetupTokenRequest( |
||||
SutProvider<SendAccessGrantValidator> sutProvider, |
||||
Guid sendId, |
||||
ValidatedTokenRequest request) |
||||
{ |
||||
sutProvider.GetDependency<IFeatureService>() |
||||
.IsEnabled(FeatureFlagKeys.SendAccess) |
||||
.Returns(true); |
||||
|
||||
var context = new ExtensionGrantValidationContext(); |
||||
|
||||
request.GrantType = CustomGrantTypes.SendAccess; |
||||
request.Raw = CreateTokenRequestBody(sendId); |
||||
context.Request = request; |
||||
|
||||
return context; |
||||
} |
||||
|
||||
private static NameValueCollection CreateTokenRequestBody( |
||||
Guid sendId, |
||||
string passwordHash = null, |
||||
string sendEmail = null, |
||||
string otpCode = null) |
||||
{ |
||||
var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); |
||||
|
||||
var rawRequestParameters = new NameValueCollection |
||||
{ |
||||
{ "grant_type", CustomGrantTypes.SendAccess }, |
||||
{ "client_id", BitwardenClient.Send }, |
||||
{ "scope", ApiScopes.ApiSendAccess }, |
||||
{ "deviceType", ((int)DeviceType.FirefoxBrowser).ToString() }, |
||||
{ "send_id", sendIdBase64 } |
||||
}; |
||||
|
||||
if (passwordHash != null) |
||||
{ |
||||
rawRequestParameters.Add("password_hash", passwordHash); |
||||
} |
||||
|
||||
if (sendEmail != null) |
||||
{ |
||||
rawRequestParameters.Add("send_email", sendEmail); |
||||
} |
||||
|
||||
if (otpCode != null && sendEmail != null) |
||||
{ |
||||
rawRequestParameters.Add("otp_code", otpCode); |
||||
} |
||||
|
||||
return rawRequestParameters; |
||||
} |
||||
|
||||
// we need a list of sendAuthentication methods to test against since we cannot create new objects in the BitAutoData |
||||
public static Dictionary<string, SendAuthenticationMethod> SendAuthenticationMethods => new() |
||||
{ |
||||
{ "NeverAuthenticate", new NeverAuthenticate() }, // Send doesn't exist or is deleted |
||||
{ "NotAuthenticated", new NotAuthenticated() }, // Public send, no auth needed |
||||
// TODO: PM-22675 - {"ResourcePassword", new ResourcePassword("clientHashedPassword")}; // Password protected send |
||||
// TODO: PM-22678 - {"EmailOtp", new EmailOtp(["emailOtp@test.dev"]}; // Email + OTP protected send |
||||
}; |
||||
} |
||||
Loading…
Reference in new issue