1
0
mirror of https://github.com/bitwarden/server synced 2025-12-10 13:23:27 +00:00

[PM-20592] [PM-22737] [PM-22738] Send grant validator (#6151)

**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`
This commit is contained in:
Ike
2025-08-13 18:38:00 -04:00
committed by GitHub
parent 87877aeb3d
commit 43d753dcb1
24 changed files with 961 additions and 19 deletions

View File

@@ -20,7 +20,7 @@ public class StaticClientStoreTests
[Benchmark]
public Client? TryGetValue()
{
return _store.ApiClients.TryGetValue(ClientId, out var client)
return _store.Clients.TryGetValue(ClientId, out var client)
? client
: null;
}

View File

@@ -191,6 +191,7 @@ public static class FeatureFlagKeys
public const string UserManagedPrivilegedApps = "pm-18970-user-managed-privileged-apps";
public const string EnablePMPreloginSettings = "enable-pm-prelogin-settings";
public const string AppIntents = "app-intents";
public const string SendAccess = "pm-19394-send-access-control";
/* Platform Team */
public const string PersistPopupView = "persist-popup-view";

View File

@@ -8,5 +8,6 @@ public static class BitwardenClient
Desktop = "desktop",
Mobile = "mobile",
Cli = "cli",
DirectoryConnector = "connector";
DirectoryConnector = "connector",
Send = "send";
}

View File

@@ -39,4 +39,6 @@ public static class Claims
public const string ManageResetPassword = "manageresetpassword";
public const string ManageScim = "managescim";
}
public const string SendId = "send_id";
}

View File

@@ -5,4 +5,5 @@ public enum IdentityClientType : byte
User = 0,
Organization = 1,
ServiceAccount = 2,
Send = 3
}

View File

@@ -11,6 +11,7 @@ public static class ApiScopes
public const string ApiPush = "api.push";
public const string ApiSecrets = "api.secrets";
public const string Internal = "internal";
public const string ApiSendAccess = "api.send.access";
public static IEnumerable<ApiScope> GetApiScopes()
{
@@ -23,6 +24,7 @@ public static class ApiScopes
new(ApiInstallation, "API Installation Access"),
new(Internal, "Internal Access"),
new(ApiSecrets, "Secrets Manager Access"),
new(ApiSendAccess, "API Send Access"),
};
}
}

View File

@@ -9,6 +9,7 @@ public static class KeyManagementServiceCollectionExtensions
public static void AddKeyManagementServices(this IServiceCollection services)
{
services.AddKeyManagementCommands();
services.AddSendPasswordServices();
}
private static void AddKeyManagementCommands(this IServiceCollection services)

View File

@@ -9,7 +9,6 @@ public interface ISendPasswordHasher
/// <param name="sendPasswordHash">The send password that is hashed by the server.</param>
/// <param name="clientPasswordHash">The user provided password hash that has not yet been hashed by the server for comparison.</param>
/// <returns>true if hashes match false otherwise</returns>
/// <exception cref="InvalidOperationException">Thrown if the server password hash or client password hash is null or empty.</exception>
bool PasswordHashMatches(string sendPasswordHash, string clientPasswordHash);
/// <summary>

View File

@@ -89,6 +89,7 @@ public class GlobalSettings : IGlobalSettings
public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings();
public virtual IPhishingDomainSettings PhishingDomain { get; set; } = new PhishingDomainSettings();
public virtual int SendAccessTokenLifetimeInMinutes { get; set; } = 5;
public virtual bool EnableEmailVerification { get; set; }
public virtual string KdfDefaultHashKey { get; set; }
public virtual string PricingUri { get; set; }

View File

@@ -25,8 +25,12 @@ public class ApiResources
Claims.OrganizationCustom,
Claims.ProviderAdmin,
Claims.ProviderServiceUser,
Claims.SecretsManagerAccess,
Claims.SecretsManagerAccess
}),
new(ApiScopes.ApiSendAccess, [
JwtClaimTypes.Subject,
Claims.SendId
]),
new(ApiScopes.Internal, new[] { JwtClaimTypes.Subject }),
new(ApiScopes.ApiPush, new[] { JwtClaimTypes.Subject }),
new(ApiScopes.ApiLicensing, new[] { JwtClaimTypes.Subject }),

View File

@@ -37,7 +37,7 @@ internal class DynamicClientStore : IClientStore
if (firstPeriod == -1)
{
// No splitter, attempt but don't fail for a static client
if (_staticClientStore.ApiClients.TryGetValue(clientId, out var client))
if (_staticClientStore.Clients.TryGetValue(clientId, out var client))
{
return Task.FromResult<Client?>(client);
}

View File

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

View File

@@ -1,10 +1,8 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Security.Claims;
using System.Security.Claims;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Identity;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -42,8 +40,22 @@ public class ProfileService : IProfileService
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var existingClaims = context.Subject.Claims;
var newClaims = new List<Claim>();
// If the client is a Send client, we do not add any additional claims
if (context.Client.ClientId == BitwardenClient.Send)
{
// preserve all claims that were already on context.Subject
// which includes the ones added by the SendAccessGrantValidator
context.IssuedClaims.AddRange(existingClaims);
return;
}
// Whenever IdentityServer issues a new access token or services a UserInfo request, it calls
// GetProfileDataAsync to determine which claims to include in the token or response.
// In normal user identity scenarios, we have to look up the user to get their claims and update
// the issued claims collection as claim info can have changed since the last time the user logged in or the
// last time the token was issued.
var newClaims = new List<Claim>();
var user = await _userService.GetUserByPrincipalAsync(context.Subject);
if (user != null)
{
@@ -63,12 +75,16 @@ public class ProfileService : IProfileService
// filter out any of the new claims
var existingClaimsToKeep = existingClaims
.Where(c => !c.Type.StartsWith("org") &&
(newClaims.Count == 0 || !newClaims.Any(nc => nc.Type == c.Type)))
.ToList();
.Where(c =>
// Drop any org claims
!c.Type.StartsWith("org") &&
// If we have no new claims, then keep the existing claims
// If we have new claims, then keep the existing claim if it does not match a new claim type
(newClaims.Count == 0 || !newClaims.Any(nc => nc.Type == c.Type))
).ToList();
newClaims.AddRange(existingClaimsToKeep);
if (newClaims.Any())
if (newClaims.Count != 0)
{
context.IssuedClaims.AddRange(newClaims);
}
@@ -76,6 +92,13 @@ public class ProfileService : IProfileService
public async Task IsActiveAsync(IsActiveContext context)
{
// Send Tokens are not refreshed so when the token has expired the user must request a new one via the authentication method assigned to the send.
if (context.Client.ClientId == BitwardenClient.Send)
{
context.IsActive = true;
return;
}
// We add the security stamp claim to the persisted grant when we issue the refresh token.
// IdentityServer will add this claim to the subject, and here we evaluate whether the security stamp that
// was persisted matches the current security stamp of the user. If it does not match, then the user has performed

View File

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

View File

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

View File

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

View File

@@ -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:
// shouldnt 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);
}
}

View File

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

View File

@@ -1,6 +1,7 @@
using System.Collections.Frozen;
using Bit.Core.Enums;
using Bit.Core.Settings;
using Bit.Identity.IdentityServer.StaticClients;
using Duende.IdentityServer.Models;
namespace Bit.Identity.IdentityServer;
@@ -9,16 +10,17 @@ public class StaticClientStore
{
public StaticClientStore(GlobalSettings globalSettings)
{
ApiClients = new List<Client>
Clients = new List<Client>
{
new ApiClient(globalSettings, BitwardenClient.Mobile, 60, 1),
new ApiClient(globalSettings, BitwardenClient.Web, 7, 1),
new ApiClient(globalSettings, BitwardenClient.Browser, 30, 1),
new ApiClient(globalSettings, BitwardenClient.Desktop, 30, 1),
new ApiClient(globalSettings, BitwardenClient.Cli, 30, 1),
new ApiClient(globalSettings, BitwardenClient.DirectoryConnector, 30, 24)
new ApiClient(globalSettings, BitwardenClient.DirectoryConnector, 30, 24),
SendClientBuilder.Build(globalSettings),
}.ToFrozenDictionary(c => c.ClientId);
}
public FrozenDictionary<string, Client> ApiClients { get; }
public FrozenDictionary<string, Client> Clients { get; }
}

View File

@@ -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],
};
}
}

View File

@@ -5,6 +5,7 @@ using Bit.Core.Utilities;
using Bit.Identity.IdentityServer;
using Bit.Identity.IdentityServer.ClientProviders;
using Bit.Identity.IdentityServer.RequestValidators;
using Bit.Identity.IdentityServer.RequestValidators.SendAccess;
using Bit.SharedWeb.Utilities;
using Duende.IdentityServer.ResponseHandling;
using Duende.IdentityServer.Services;
@@ -25,6 +26,7 @@ public static class ServiceCollectionExtensions
services.AddTransient<IDeviceValidator, DeviceValidator>();
services.AddTransient<ITwoFactorAuthenticationValidator, TwoFactorAuthenticationValidator>();
services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>();
services.AddTransient<ISendPasswordRequestValidator, SendPasswordRequestValidator>();
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
var identityServerBuilder = services
@@ -55,7 +57,8 @@ public static class ServiceCollectionExtensions
.AddResourceOwnerValidator<ResourceOwnerPasswordValidator>()
.AddClientStore<DynamicClientStore>()
.AddIdentityServerCertificate(env, globalSettings)
.AddExtensionGrantValidator<WebAuthnGrantValidator>();
.AddExtensionGrantValidator<WebAuthnGrantValidator>()
.AddExtensionGrantValidator<SendAccessGrantValidator>();
if (!globalSettings.SelfHosted)
{

View File

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

View File

@@ -15,6 +15,7 @@
"api.installation",
"internal",
"api.secrets",
"api.send.access",
"offline_access"
],
"claims_supported": [
@@ -33,6 +34,7 @@
"providerserviceuser",
"accesssecretsmanager",
"sub",
"send_id",
"organization"
],
"grant_types_supported": [
@@ -43,7 +45,8 @@
"password",
"urn:ietf:params:oauth:grant-type:device_code",
"urn:openid:params:grant-type:ciba",
"webauthn"
"webauthn",
"send_access"
],
"response_types_supported": [
"code",

View File

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