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:
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
11
src/Identity/IdentityServer/Enums/CustomGrantTypes.cs
Normal file
11
src/Identity/IdentityServer/Enums/CustomGrantTypes.cs
Normal 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";
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user