1
0
mirror of https://github.com/bitwarden/server synced 2026-01-04 01:23:25 +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

@@ -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)
{