mirror of
https://github.com/bitwarden/server
synced 2025-12-20 10:13:39 +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:
@@ -20,7 +20,7 @@ public class StaticClientStoreTests
|
|||||||
[Benchmark]
|
[Benchmark]
|
||||||
public Client? TryGetValue()
|
public Client? TryGetValue()
|
||||||
{
|
{
|
||||||
return _store.ApiClients.TryGetValue(ClientId, out var client)
|
return _store.Clients.TryGetValue(ClientId, out var client)
|
||||||
? client
|
? client
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ public static class FeatureFlagKeys
|
|||||||
public const string UserManagedPrivilegedApps = "pm-18970-user-managed-privileged-apps";
|
public const string UserManagedPrivilegedApps = "pm-18970-user-managed-privileged-apps";
|
||||||
public const string EnablePMPreloginSettings = "enable-pm-prelogin-settings";
|
public const string EnablePMPreloginSettings = "enable-pm-prelogin-settings";
|
||||||
public const string AppIntents = "app-intents";
|
public const string AppIntents = "app-intents";
|
||||||
|
public const string SendAccess = "pm-19394-send-access-control";
|
||||||
|
|
||||||
/* Platform Team */
|
/* Platform Team */
|
||||||
public const string PersistPopupView = "persist-popup-view";
|
public const string PersistPopupView = "persist-popup-view";
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ public static class BitwardenClient
|
|||||||
Desktop = "desktop",
|
Desktop = "desktop",
|
||||||
Mobile = "mobile",
|
Mobile = "mobile",
|
||||||
Cli = "cli",
|
Cli = "cli",
|
||||||
DirectoryConnector = "connector";
|
DirectoryConnector = "connector",
|
||||||
|
Send = "send";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,6 @@ public static class Claims
|
|||||||
public const string ManageResetPassword = "manageresetpassword";
|
public const string ManageResetPassword = "manageresetpassword";
|
||||||
public const string ManageScim = "managescim";
|
public const string ManageScim = "managescim";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public const string SendId = "send_id";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ public enum IdentityClientType : byte
|
|||||||
User = 0,
|
User = 0,
|
||||||
Organization = 1,
|
Organization = 1,
|
||||||
ServiceAccount = 2,
|
ServiceAccount = 2,
|
||||||
|
Send = 3
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public static class ApiScopes
|
|||||||
public const string ApiPush = "api.push";
|
public const string ApiPush = "api.push";
|
||||||
public const string ApiSecrets = "api.secrets";
|
public const string ApiSecrets = "api.secrets";
|
||||||
public const string Internal = "internal";
|
public const string Internal = "internal";
|
||||||
|
public const string ApiSendAccess = "api.send.access";
|
||||||
|
|
||||||
public static IEnumerable<ApiScope> GetApiScopes()
|
public static IEnumerable<ApiScope> GetApiScopes()
|
||||||
{
|
{
|
||||||
@@ -23,6 +24,7 @@ public static class ApiScopes
|
|||||||
new(ApiInstallation, "API Installation Access"),
|
new(ApiInstallation, "API Installation Access"),
|
||||||
new(Internal, "Internal Access"),
|
new(Internal, "Internal Access"),
|
||||||
new(ApiSecrets, "Secrets Manager Access"),
|
new(ApiSecrets, "Secrets Manager Access"),
|
||||||
|
new(ApiSendAccess, "API Send Access"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public static class KeyManagementServiceCollectionExtensions
|
|||||||
public static void AddKeyManagementServices(this IServiceCollection services)
|
public static void AddKeyManagementServices(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddKeyManagementCommands();
|
services.AddKeyManagementCommands();
|
||||||
|
services.AddSendPasswordServices();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddKeyManagementCommands(this IServiceCollection services)
|
private static void AddKeyManagementCommands(this IServiceCollection services)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ public interface ISendPasswordHasher
|
|||||||
/// <param name="sendPasswordHash">The send password that is hashed by the server.</param>
|
/// <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>
|
/// <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>
|
/// <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);
|
bool PasswordHashMatches(string sendPasswordHash, string clientPasswordHash);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ public class GlobalSettings : IGlobalSettings
|
|||||||
public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings();
|
public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings();
|
||||||
public virtual IPhishingDomainSettings PhishingDomain { get; set; } = new PhishingDomainSettings();
|
public virtual IPhishingDomainSettings PhishingDomain { get; set; } = new PhishingDomainSettings();
|
||||||
|
|
||||||
|
public virtual int SendAccessTokenLifetimeInMinutes { get; set; } = 5;
|
||||||
public virtual bool EnableEmailVerification { get; set; }
|
public virtual bool EnableEmailVerification { get; set; }
|
||||||
public virtual string KdfDefaultHashKey { get; set; }
|
public virtual string KdfDefaultHashKey { get; set; }
|
||||||
public virtual string PricingUri { get; set; }
|
public virtual string PricingUri { get; set; }
|
||||||
|
|||||||
@@ -25,8 +25,12 @@ public class ApiResources
|
|||||||
Claims.OrganizationCustom,
|
Claims.OrganizationCustom,
|
||||||
Claims.ProviderAdmin,
|
Claims.ProviderAdmin,
|
||||||
Claims.ProviderServiceUser,
|
Claims.ProviderServiceUser,
|
||||||
Claims.SecretsManagerAccess,
|
Claims.SecretsManagerAccess
|
||||||
}),
|
}),
|
||||||
|
new(ApiScopes.ApiSendAccess, [
|
||||||
|
JwtClaimTypes.Subject,
|
||||||
|
Claims.SendId
|
||||||
|
]),
|
||||||
new(ApiScopes.Internal, new[] { JwtClaimTypes.Subject }),
|
new(ApiScopes.Internal, new[] { JwtClaimTypes.Subject }),
|
||||||
new(ApiScopes.ApiPush, new[] { JwtClaimTypes.Subject }),
|
new(ApiScopes.ApiPush, new[] { JwtClaimTypes.Subject }),
|
||||||
new(ApiScopes.ApiLicensing, new[] { JwtClaimTypes.Subject }),
|
new(ApiScopes.ApiLicensing, new[] { JwtClaimTypes.Subject }),
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ internal class DynamicClientStore : IClientStore
|
|||||||
if (firstPeriod == -1)
|
if (firstPeriod == -1)
|
||||||
{
|
{
|
||||||
// No splitter, attempt but don't fail for a static client
|
// 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);
|
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
|
using System.Security.Claims;
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System.Security.Claims;
|
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Identity;
|
using Bit.Core.Identity;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
@@ -42,8 +40,22 @@ public class ProfileService : IProfileService
|
|||||||
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
|
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
|
||||||
{
|
{
|
||||||
var existingClaims = context.Subject.Claims;
|
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);
|
var user = await _userService.GetUserByPrincipalAsync(context.Subject);
|
||||||
if (user != null)
|
if (user != null)
|
||||||
{
|
{
|
||||||
@@ -63,12 +75,16 @@ public class ProfileService : IProfileService
|
|||||||
|
|
||||||
// filter out any of the new claims
|
// filter out any of the new claims
|
||||||
var existingClaimsToKeep = existingClaims
|
var existingClaimsToKeep = existingClaims
|
||||||
.Where(c => !c.Type.StartsWith("org") &&
|
.Where(c =>
|
||||||
(newClaims.Count == 0 || !newClaims.Any(nc => nc.Type == c.Type)))
|
// Drop any org claims
|
||||||
.ToList();
|
!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);
|
newClaims.AddRange(existingClaimsToKeep);
|
||||||
if (newClaims.Any())
|
if (newClaims.Count != 0)
|
||||||
{
|
{
|
||||||
context.IssuedClaims.AddRange(newClaims);
|
context.IssuedClaims.AddRange(newClaims);
|
||||||
}
|
}
|
||||||
@@ -76,6 +92,13 @@ public class ProfileService : IProfileService
|
|||||||
|
|
||||||
public async Task IsActiveAsync(IsActiveContext context)
|
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.
|
// 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
|
// 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
|
// 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 System.Collections.Frozen;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
|
using Bit.Identity.IdentityServer.StaticClients;
|
||||||
using Duende.IdentityServer.Models;
|
using Duende.IdentityServer.Models;
|
||||||
|
|
||||||
namespace Bit.Identity.IdentityServer;
|
namespace Bit.Identity.IdentityServer;
|
||||||
@@ -9,16 +10,17 @@ public class StaticClientStore
|
|||||||
{
|
{
|
||||||
public StaticClientStore(GlobalSettings globalSettings)
|
public StaticClientStore(GlobalSettings globalSettings)
|
||||||
{
|
{
|
||||||
ApiClients = new List<Client>
|
Clients = new List<Client>
|
||||||
{
|
{
|
||||||
new ApiClient(globalSettings, BitwardenClient.Mobile, 60, 1),
|
new ApiClient(globalSettings, BitwardenClient.Mobile, 60, 1),
|
||||||
new ApiClient(globalSettings, BitwardenClient.Web, 7, 1),
|
new ApiClient(globalSettings, BitwardenClient.Web, 7, 1),
|
||||||
new ApiClient(globalSettings, BitwardenClient.Browser, 30, 1),
|
new ApiClient(globalSettings, BitwardenClient.Browser, 30, 1),
|
||||||
new ApiClient(globalSettings, BitwardenClient.Desktop, 30, 1),
|
new ApiClient(globalSettings, BitwardenClient.Desktop, 30, 1),
|
||||||
new ApiClient(globalSettings, BitwardenClient.Cli, 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);
|
}.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;
|
||||||
using Bit.Identity.IdentityServer.ClientProviders;
|
using Bit.Identity.IdentityServer.ClientProviders;
|
||||||
using Bit.Identity.IdentityServer.RequestValidators;
|
using Bit.Identity.IdentityServer.RequestValidators;
|
||||||
|
using Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||||
using Bit.SharedWeb.Utilities;
|
using Bit.SharedWeb.Utilities;
|
||||||
using Duende.IdentityServer.ResponseHandling;
|
using Duende.IdentityServer.ResponseHandling;
|
||||||
using Duende.IdentityServer.Services;
|
using Duende.IdentityServer.Services;
|
||||||
@@ -25,6 +26,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddTransient<IDeviceValidator, DeviceValidator>();
|
services.AddTransient<IDeviceValidator, DeviceValidator>();
|
||||||
services.AddTransient<ITwoFactorAuthenticationValidator, TwoFactorAuthenticationValidator>();
|
services.AddTransient<ITwoFactorAuthenticationValidator, TwoFactorAuthenticationValidator>();
|
||||||
services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>();
|
services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>();
|
||||||
|
services.AddTransient<ISendPasswordRequestValidator, SendPasswordRequestValidator>();
|
||||||
|
|
||||||
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
|
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
|
||||||
var identityServerBuilder = services
|
var identityServerBuilder = services
|
||||||
@@ -55,7 +57,8 @@ public static class ServiceCollectionExtensions
|
|||||||
.AddResourceOwnerValidator<ResourceOwnerPasswordValidator>()
|
.AddResourceOwnerValidator<ResourceOwnerPasswordValidator>()
|
||||||
.AddClientStore<DynamicClientStore>()
|
.AddClientStore<DynamicClientStore>()
|
||||||
.AddIdentityServerCertificate(env, globalSettings)
|
.AddIdentityServerCertificate(env, globalSettings)
|
||||||
.AddExtensionGrantValidator<WebAuthnGrantValidator>();
|
.AddExtensionGrantValidator<WebAuthnGrantValidator>()
|
||||||
|
.AddExtensionGrantValidator<SendAccessGrantValidator>();
|
||||||
|
|
||||||
if (!globalSettings.SelfHosted)
|
if (!globalSettings.SelfHosted)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"api.installation",
|
"api.installation",
|
||||||
"internal",
|
"internal",
|
||||||
"api.secrets",
|
"api.secrets",
|
||||||
|
"api.send.access",
|
||||||
"offline_access"
|
"offline_access"
|
||||||
],
|
],
|
||||||
"claims_supported": [
|
"claims_supported": [
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"providerserviceuser",
|
"providerserviceuser",
|
||||||
"accesssecretsmanager",
|
"accesssecretsmanager",
|
||||||
"sub",
|
"sub",
|
||||||
|
"send_id",
|
||||||
"organization"
|
"organization"
|
||||||
],
|
],
|
||||||
"grant_types_supported": [
|
"grant_types_supported": [
|
||||||
@@ -43,7 +45,8 @@
|
|||||||
"password",
|
"password",
|
||||||
"urn:ietf:params:oauth:grant-type:device_code",
|
"urn:ietf:params:oauth:grant-type:device_code",
|
||||||
"urn:openid:params:grant-type:ciba",
|
"urn:openid:params:grant-type:ciba",
|
||||||
"webauthn"
|
"webauthn",
|
||||||
|
"send_access"
|
||||||
],
|
],
|
||||||
"response_types_supported": [
|
"response_types_supported": [
|
||||||
"code",
|
"code",
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user