diff --git a/perf/MicroBenchmarks/Identity/IdentityServer/StaticClientStoreTests.cs b/perf/MicroBenchmarks/Identity/IdentityServer/StaticClientStoreTests.cs index 9d88f960ea..fcdda22c10 100644 --- a/perf/MicroBenchmarks/Identity/IdentityServer/StaticClientStoreTests.cs +++ b/perf/MicroBenchmarks/Identity/IdentityServer/StaticClientStoreTests.cs @@ -20,7 +20,7 @@ public class StaticClientStoreTests [Benchmark] public Client? TryGetValue() { - return _store.ApiClients.TryGetValue(ClientId, out var client) + return _store.Clients.TryGetValue(ClientId, out var client) ? client : null; } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index be82d9fc21..1ba80d3c9b 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -191,6 +191,7 @@ public static class FeatureFlagKeys public const string UserManagedPrivilegedApps = "pm-18970-user-managed-privileged-apps"; public const string EnablePMPreloginSettings = "enable-pm-prelogin-settings"; public const string AppIntents = "app-intents"; + public const string SendAccess = "pm-19394-send-access-control"; /* Platform Team */ public const string PersistPopupView = "persist-popup-view"; diff --git a/src/Core/Enums/BitwardenClient.cs b/src/Core/Enums/BitwardenClient.cs index 6a1244c0c4..4776e0de3f 100644 --- a/src/Core/Enums/BitwardenClient.cs +++ b/src/Core/Enums/BitwardenClient.cs @@ -8,5 +8,6 @@ public static class BitwardenClient Desktop = "desktop", Mobile = "mobile", Cli = "cli", - DirectoryConnector = "connector"; + DirectoryConnector = "connector", + Send = "send"; } diff --git a/src/Core/Identity/Claims.cs b/src/Core/Identity/Claims.cs index fad7b37b5f..ef3d5e450c 100644 --- a/src/Core/Identity/Claims.cs +++ b/src/Core/Identity/Claims.cs @@ -39,4 +39,6 @@ public static class Claims public const string ManageResetPassword = "manageresetpassword"; public const string ManageScim = "managescim"; } + + public const string SendId = "send_id"; } diff --git a/src/Core/Identity/IdentityClientType.cs b/src/Core/Identity/IdentityClientType.cs index bd5b68ff6f..9c43007f25 100644 --- a/src/Core/Identity/IdentityClientType.cs +++ b/src/Core/Identity/IdentityClientType.cs @@ -5,4 +5,5 @@ public enum IdentityClientType : byte User = 0, Organization = 1, ServiceAccount = 2, + Send = 3 } diff --git a/src/Core/IdentityServer/ApiScopes.cs b/src/Core/IdentityServer/ApiScopes.cs index 6e3ce0d140..77ccb5a58a 100644 --- a/src/Core/IdentityServer/ApiScopes.cs +++ b/src/Core/IdentityServer/ApiScopes.cs @@ -11,6 +11,7 @@ public static class ApiScopes public const string ApiPush = "api.push"; public const string ApiSecrets = "api.secrets"; public const string Internal = "internal"; + public const string ApiSendAccess = "api.send.access"; public static IEnumerable GetApiScopes() { @@ -23,6 +24,7 @@ public static class ApiScopes new(ApiInstallation, "API Installation Access"), new(Internal, "Internal Access"), new(ApiSecrets, "Secrets Manager Access"), + new(ApiSendAccess, "API Send Access"), }; } } diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs index 102630c7e6..cacf3d4140 100644 --- a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ public static class KeyManagementServiceCollectionExtensions public static void AddKeyManagementServices(this IServiceCollection services) { services.AddKeyManagementCommands(); + services.AddSendPasswordServices(); } private static void AddKeyManagementCommands(this IServiceCollection services) diff --git a/src/Core/KeyManagement/Sends/ISendPasswordHasher.cs b/src/Core/KeyManagement/Sends/ISendPasswordHasher.cs index 63bb7f5499..b84be5abc0 100644 --- a/src/Core/KeyManagement/Sends/ISendPasswordHasher.cs +++ b/src/Core/KeyManagement/Sends/ISendPasswordHasher.cs @@ -9,7 +9,6 @@ public interface ISendPasswordHasher /// The send password that is hashed by the server. /// The user provided password hash that has not yet been hashed by the server for comparison. /// true if hashes match false otherwise - /// Thrown if the server password hash or client password hash is null or empty. bool PasswordHashMatches(string sendPasswordHash, string clientPasswordHash); /// diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 2d9301e451..d6e18a4c81 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -89,6 +89,7 @@ public class GlobalSettings : IGlobalSettings public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings(); public virtual IPhishingDomainSettings PhishingDomain { get; set; } = new PhishingDomainSettings(); + public virtual int SendAccessTokenLifetimeInMinutes { get; set; } = 5; public virtual bool EnableEmailVerification { get; set; } public virtual string KdfDefaultHashKey { get; set; } public virtual string PricingUri { get; set; } diff --git a/src/Identity/IdentityServer/ApiResources.cs b/src/Identity/IdentityServer/ApiResources.cs index f969d67908..a195f01bff 100644 --- a/src/Identity/IdentityServer/ApiResources.cs +++ b/src/Identity/IdentityServer/ApiResources.cs @@ -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 }), diff --git a/src/Identity/IdentityServer/DynamicClientStore.cs b/src/Identity/IdentityServer/DynamicClientStore.cs index 9d7764bf42..d7e589a093 100644 --- a/src/Identity/IdentityServer/DynamicClientStore.cs +++ b/src/Identity/IdentityServer/DynamicClientStore.cs @@ -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); } diff --git a/src/Identity/IdentityServer/Enums/CustomGrantTypes.cs b/src/Identity/IdentityServer/Enums/CustomGrantTypes.cs new file mode 100644 index 0000000000..7203386bc5 --- /dev/null +++ b/src/Identity/IdentityServer/Enums/CustomGrantTypes.cs @@ -0,0 +1,11 @@ +namespace Bit.Identity.IdentityServer.Enums; + +/// +/// A class containing custom grant types used in the Bitwarden IdentityServer implementation +/// +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"; +} diff --git a/src/Identity/IdentityServer/ProfileService.cs b/src/Identity/IdentityServer/ProfileService.cs index 742e69758b..74173a7e9d 100644 --- a/src/Identity/IdentityServer/ProfileService.cs +++ b/src/Identity/IdentityServer/ProfileService.cs @@ -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(); + // 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(); 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 diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/Enums/SendGrantValidatorResultTypes.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/Enums/SendGrantValidatorResultTypes.cs new file mode 100644 index 0000000000..343c15bd30 --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/Enums/SendGrantValidatorResultTypes.cs @@ -0,0 +1,11 @@ +namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess.Enums; + +/// +/// These control the results of the SendGrantValidator. +/// +internal enum SendGrantValidatorResultTypes +{ + ValidSendGuid, + MissingSendId, + InvalidSendId +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/Enums/SendPasswordValidatorResultTypes.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/Enums/SendPasswordValidatorResultTypes.cs new file mode 100644 index 0000000000..1950ca2978 --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/Enums/SendPasswordValidatorResultTypes.cs @@ -0,0 +1,9 @@ +namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess.Enums; + +/// +/// These control the results of the SendPasswordValidator. +/// +internal enum SendPasswordValidatorResultTypes +{ + RequestPasswordDoesNotMatch +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendPasswordRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendPasswordRequestValidator.cs new file mode 100644 index 0000000000..a6f33175bd --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendPasswordRequestValidator.cs @@ -0,0 +1,16 @@ +using Bit.Core.Tools.Models.Data; +using Duende.IdentityServer.Validation; + +namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; + +public interface ISendPasswordRequestValidator +{ + /// + /// 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. + /// + /// request context + /// resource password authentication method containing the hash of the Send being retrieved + /// returns the result of the validation; A failed result will be an error a successful will contain the claims and a success + GrantValidationResult ValidateSendPassword(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId); +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs new file mode 100644 index 0000000000..020b3ec5d4 --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs @@ -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 + _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()}"); + } + } + + /// + /// tries to parse the send_id from the request. + /// If it is not present or invalid, sets the correct result error. + /// + /// request context + /// a parsed sendId Guid and success result or a Guid.Empty and error type otherwise + 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); + } + } + + /// + /// Builds an error result for the specified error type. + /// + /// The error type. + /// The error result. + 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 + { + new(Claims.SendId, sendId.ToString()), + new(Claims.Type, IdentityClientType.Send.ToString()) + }; + + return new GrantValidationResult( + subject: sendId.ToString(), + authenticationMethod: CustomGrantTypes.SendAccess, + claims: claims); + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs new file mode 100644 index 0000000000..194a0aaa5c --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs @@ -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; + + /// + /// static object that contains the error messages for the SendPasswordRequestValidator. + /// + private static Dictionary _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); + } + + /// + /// Builds a successful validation result for the Send password send_access grant. + /// + /// + /// + private static GrantValidationResult BuildSendPasswordSuccessResult(Guid sendId) + { + var claims = new List + { + new(Claims.SendId, sendId.ToString()), + new(Claims.Type, IdentityClientType.Send.ToString()) + }; + + return new GrantValidationResult( + subject: sendId.ToString(), + authenticationMethod: CustomGrantTypes.SendAccess, + claims: claims); + } +} diff --git a/src/Identity/IdentityServer/StaticClientStore.cs b/src/Identity/IdentityServer/StaticClientStore.cs index e6880b7670..cab7844f47 100644 --- a/src/Identity/IdentityServer/StaticClientStore.cs +++ b/src/Identity/IdentityServer/StaticClientStore.cs @@ -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 + Clients = new List { 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 ApiClients { get; } + public FrozenDictionary Clients { get; } } diff --git a/src/Identity/IdentityServer/StaticClients/SendClientBuilder.cs b/src/Identity/IdentityServer/StaticClients/SendClientBuilder.cs new file mode 100644 index 0000000000..7197d435ed --- /dev/null +++ b/src/Identity/IdentityServer/StaticClients/SendClientBuilder.cs @@ -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], + }; + } +} diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs index 1476a5ec76..d4f2ad8045 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -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(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity); var identityServerBuilder = services @@ -55,7 +57,8 @@ public static class ServiceCollectionExtensions .AddResourceOwnerValidator() .AddClientStore() .AddIdentityServerCertificate(env, globalSettings) - .AddExtensionGrantValidator(); + .AddExtensionGrantValidator() + .AddExtensionGrantValidator(); if (!globalSettings.SelfHosted) { diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs new file mode 100644 index 0000000000..f27da6e02e --- /dev/null +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs @@ -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 +{ + 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(); + 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(); + featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(true); + services.AddSingleton(featureService); + + // Mock send authentication query + var sendAuthQuery = Substitute.For(); + 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(); + featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(true); + services.AddSingleton(featureService); + }); + }).CreateClient(); + + var requestBody = new FormUrlEncodedContent([ + new KeyValuePair("grant_type", CustomGrantTypes.SendAccess), + new KeyValuePair("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(); + 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(); + featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(true); + services.AddSingleton(featureService); + + var sendAuthQuery = Substitute.For(); + 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(); + featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(true); + services.AddSingleton(featureService); + + var sendAuthQuery = Substitute.For(); + 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(); + featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(true); + services.AddSingleton(featureService); + + var sendAuthQuery = Substitute.For(); + sendAuthQuery.GetAuthenticationMethod(sendId).Returns(resourcePassword); + services.AddSingleton(sendAuthQuery); + + // Mock password validator to return success + var passwordValidator = Substitute.For(); + passwordValidator.ValidateSendPassword( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .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> + { + 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("email", sendEmail), + new KeyValuePair("email_otp", emailOtp) + ]); + } + + return new FormUrlEncodedContent(parameters); + } +} diff --git a/test/Identity.IntegrationTest/openid-configuration.json b/test/Identity.IntegrationTest/openid-configuration.json index 4d74f66009..96014764bd 100644 --- a/test/Identity.IntegrationTest/openid-configuration.json +++ b/test/Identity.IntegrationTest/openid-configuration.json @@ -15,6 +15,7 @@ "api.installation", "internal", "api.secrets", + "api.send.access", "offline_access" ], "claims_supported": [ @@ -33,6 +34,7 @@ "providerserviceuser", "accesssecretsmanager", "sub", + "send_id", "organization" ], "grant_types_supported": [ @@ -43,7 +45,8 @@ "password", "urn:ietf:params:oauth:grant-type:device_code", "urn:openid:params:grant-type:ciba", - "webauthn" + "webauthn", + "send_access" ], "response_types_supported": [ "code", diff --git a/test/Identity.Test/IdentityServer/SendAccessGrantValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccessGrantValidatorTests.cs new file mode 100644 index 0000000000..94f4c1d224 --- /dev/null +++ b/test/Identity.Test/IdentityServer/SendAccessGrantValidatorTests.cs @@ -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 sutProvider) + { + // Arrange + sutProvider.GetDependency() + .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 sutProvider) + { + // Arrange + sutProvider.GetDependency() + .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 sutProvider) + { + // Arrange + sutProvider.GetDependency() + .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 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 sutProvider, + Guid sendId) + { + // Arrange + var context = SetupTokenRequest( + sutProvider, + sendId, + tokenRequest); + + sutProvider.GetDependency() + .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 sutProvider, + Guid sendId) + { + // Arrange + var context = SetupTokenRequest( + sutProvider, + sendId, + tokenRequest); + + sutProvider.GetDependency() + .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 sutProvider, + Guid sendId, + ResourcePassword resourcePassword, + GrantValidationResult expectedResult) + { + // Arrange + var context = SetupTokenRequest( + sutProvider, + sendId, + tokenRequest); + + sutProvider.GetDependency() + .GetAuthenticationMethod(sendId) + .Returns(resourcePassword); + + sutProvider.GetDependency() + .ValidateSendPassword(context, resourcePassword, sendId) + .Returns(expectedResult); + + // Act + await sutProvider.Sut.ValidateAsync(context); + + // Assert + Assert.Equal(expectedResult, context.Result); + sutProvider.GetDependency() + .Received(1) + .ValidateSendPassword(context, resourcePassword, sendId); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EmailOtpMethod_NotImplemented_ThrowsError( + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + SutProvider sutProvider, + Guid sendId, + EmailOtp emailOtp) + { + // Arrange + var context = SetupTokenRequest( + sutProvider, + sendId, + tokenRequest); + + + sutProvider.GetDependency() + .GetAuthenticationMethod(sendId) + .Returns(emailOtp); + + // Act + // Assert + // Currently the EmailOtp case doesn't set a result, so it should be null + await Assert.ThrowsAsync(async () => await sutProvider.Sut.ValidateAsync(context)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_UnknownAuthMethod_ThrowsInvalidOperationException( + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + SutProvider sutProvider, + Guid sendId) + { + // Arrange + var context = SetupTokenRequest( + sutProvider, + sendId, + tokenRequest); + + // Create a mock authentication method that's not handled + var unknownMethod = Substitute.For(); + sutProvider.GetDependency() + .GetAuthenticationMethod(sendId) + .Returns(unknownMethod); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => 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); + } + + /// + /// Mutator method fo the SutProvider and the Context to set up a valid request + /// + /// current sut provider + /// test context + /// the send id + /// the token request + private static ExtensionGrantValidationContext SetupTokenRequest( + SutProvider sutProvider, + Guid sendId, + ValidatedTokenRequest request) + { + sutProvider.GetDependency() + .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 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 + }; +}