1
0
mirror of https://github.com/bitwarden/server synced 2026-01-02 08:33:48 +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

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