diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Attributes/SelfHostedAttribute.cs b/extensions/Bitwarden.Extensions.Hosting/src/Attributes/SelfHostedAttribute.cs index 2fc9f0ec..5b130c9f 100644 --- a/extensions/Bitwarden.Extensions.Hosting/src/Attributes/SelfHostedAttribute.cs +++ b/extensions/Bitwarden.Extensions.Hosting/src/Attributes/SelfHostedAttribute.cs @@ -1,4 +1,5 @@ using Bitwarden.Extensions.Hosting.Exceptions; +using Bitwarden.Extensions.Hosting.Licensing; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.DependencyInjection; @@ -25,12 +26,12 @@ public class SelfHostedAttribute : ActionFilterAttribute /// public override void OnActionExecuting(ActionExecutingContext context) { - var globalSettings = context.HttpContext.RequestServices.GetRequiredService(); - if (SelfHostedOnly && !globalSettings.IsSelfHosted) + var licensingService = context.HttpContext.RequestServices.GetRequiredService(); + if (SelfHostedOnly && licensingService.IsCloud) { throw new BadRequestException("Only allowed when self-hosted."); } - else if (NotSelfHostedOnly && globalSettings.IsSelfHosted) + else if (NotSelfHostedOnly && !licensingService.IsCloud) { throw new BadRequestException("Only allowed when not self-hosted."); } diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Bitwarden.Extensions.Hosting.csproj b/extensions/Bitwarden.Extensions.Hosting/src/Bitwarden.Extensions.Hosting.csproj index 0bab2d25..4f8af4ab 100644 --- a/extensions/Bitwarden.Extensions.Hosting/src/Bitwarden.Extensions.Hosting.csproj +++ b/extensions/Bitwarden.Extensions.Hosting/src/Bitwarden.Extensions.Hosting.csproj @@ -22,8 +22,10 @@ + + @@ -31,6 +33,7 @@ + diff --git a/extensions/Bitwarden.Extensions.Hosting/src/BitwardenHostOptions.cs b/extensions/Bitwarden.Extensions.Hosting/src/BitwardenHostOptions.cs index c844be61..041d5c29 100644 --- a/extensions/Bitwarden.Extensions.Hosting/src/BitwardenHostOptions.cs +++ b/extensions/Bitwarden.Extensions.Hosting/src/BitwardenHostOptions.cs @@ -6,11 +6,19 @@ namespace Bitwarden.Extensions.Hosting; public class BitwardenHostOptions { /// - /// Gets or sets a value indicating whether to include request logging. + /// Gets or sets a value indicating whether to include request logging, defaults to true. /// public bool IncludeLogging { get; set; } = true; /// - /// Gets or sets a value indicating whether to include metrics. + /// Gets or sets a value indicating whether to include metrics, defaults to true. /// public bool IncludeMetrics { get; set; } = true; + + /// + /// Gets or sets a value indicating if self-hosting capabilities should be added to the service, defaults to false. + /// + /// + /// If this is not turned on, the assumption is made that the service is running in a cloud environment. + /// + public bool IncludeSelfHosting { get; set; } } diff --git a/extensions/Bitwarden.Extensions.Hosting/src/GlobalSettingsBase.cs b/extensions/Bitwarden.Extensions.Hosting/src/GlobalSettingsBase.cs deleted file mode 100644 index cc5bf4bc..00000000 --- a/extensions/Bitwarden.Extensions.Hosting/src/GlobalSettingsBase.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Bitwarden.Extensions.Hosting; - -/// -/// Global settings. -/// -public class GlobalSettingsBase -{ - /// - /// Gets or sets a value indicating whether the application is self-hosted. - /// - public bool IsSelfHosted { get; set; } -} diff --git a/extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs b/extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs index 7612f5b2..3daec34a 100644 --- a/extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs +++ b/extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs @@ -2,11 +2,13 @@ using System.Reflection; using Bitwarden.Extensions.Hosting; using Bitwarden.Extensions.Hosting.Features; +using Bitwarden.Extensions.Hosting.Licensing; using LaunchDarkly.Sdk.Server.Interfaces; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; using Serilog; @@ -51,13 +53,6 @@ public static TBuilder UseBitwardenDefaults(this TBuilder builder, Bit ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(bitwardenHostOptions); - builder.Services.AddOptions() - .Configure((options, config) => - { - options.IsSelfHosted = config.GetValue(SelfHostedConfigKey, false); - }); - - if (builder.Configuration.GetValue(SelfHostedConfigKey, false)) { AddSelfHostedConfig(builder.Configuration, builder.Environment); @@ -75,6 +70,11 @@ public static TBuilder UseBitwardenDefaults(this TBuilder builder, Bit AddFeatureFlagServices(builder.Services, builder.Configuration); + if (bitwardenHostOptions.IncludeSelfHosting) + { + AddLicensingServices(builder.Services, builder.Configuration); + } + return builder; } @@ -97,15 +97,6 @@ public static IHostBuilder UseBitwardenDefaults(this IHostBuilder hostBuilder, A /// public static IHostBuilder UseBitwardenDefaults(this IHostBuilder hostBuilder, BitwardenHostOptions bitwardenHostOptions) { - hostBuilder.ConfigureServices((_, services) => - { - services.AddOptions() - .Configure((options, config) => - { - options.IsSelfHosted = config.GetValue("globalSettings:selfHosted", false); - }); - }); - hostBuilder.ConfigureAppConfiguration((context, builder) => { if (context.Configuration.GetValue(SelfHostedConfigKey, false)) @@ -135,6 +126,14 @@ public static IHostBuilder UseBitwardenDefaults(this IHostBuilder hostBuilder, B AddFeatureFlagServices(services, context.Configuration); }); + if (bitwardenHostOptions.IncludeSelfHosting) + { + hostBuilder.ConfigureServices((context, services) => + { + AddLicensingServices(services, context.Configuration); + }); + } + return hostBuilder; } @@ -241,4 +240,23 @@ private static void AddFeatureFlagServices(IServiceCollection services, IConfigu services.TryAddScoped(sp => sp.GetRequiredService().Get()); services.TryAddScoped(); } + + private static void AddLicensingServices(IServiceCollection services, IConfiguration configuration) + { + // Default the product name to the application name if no one else has added it. + services.AddOptions() + .PostConfigure((options, environment) => + { + if (string.IsNullOrEmpty(options.ProductName)) + { + options.ProductName = environment.ApplicationName; + } + }); + + services.TryAddEnumerable( + ServiceDescriptor.Singleton, PostConfigureLicensingOptions>() + ); + + services.Configure(configuration.GetSection("Licensing")); + } } diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Licensing/DefaultLicensingService.cs b/extensions/Bitwarden.Extensions.Hosting/src/Licensing/DefaultLicensingService.cs new file mode 100644 index 00000000..55165b27 --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/src/Licensing/DefaultLicensingService.cs @@ -0,0 +1,119 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; + +namespace Bitwarden.Extensions.Hosting.Licensing; + +internal sealed class DefaultLicensingService : ILicensingService +{ + private readonly LicensingOptions _licensingOptions; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly InternalLicensingOptions _internalLicensingOptions; + + public DefaultLicensingService( + IOptions licensingOptions, + TimeProvider timeProvider, + ILogger logger, + IOptions internalLicensingOptions) + { + ArgumentNullException.ThrowIfNull(licensingOptions); + ArgumentNullException.ThrowIfNull(timeProvider); + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(internalLicensingOptions); + + _licensingOptions = licensingOptions.Value; + _timeProvider = timeProvider; + _logger = logger; + _internalLicensingOptions = internalLicensingOptions.Value; + + // We are cloud if the signing certificate has a private key that can sign licenses and local development + // hasn't forced self host. + IsCloud = _licensingOptions.SigningCertificate.HasPrivateKey && !_licensingOptions.ForceSelfHost; + } + + public bool IsCloud { get; } + + public string CreateLicense(IEnumerable claims, DateTime expirationDate) + { + ArgumentNullException.ThrowIfNull(claims); + var now = _timeProvider.GetUtcNow().UtcDateTime; + + // Expiration date must be in the future + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(expirationDate, now); + + if (!IsCloud) + { + throw new InvalidOperationException("Self-hosted services can not create a license, please check 'IsCloud' before calling this method."); + } + + + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Issuer = _licensingOptions.CloudHost, + Audience = _internalLicensingOptions.ProductName, + SigningCredentials = new SigningCredentials( + new X509SecurityKey(_licensingOptions.SigningCertificate), SecurityAlgorithms.RsaSha256), + IssuedAt = now, + NotBefore = now, + Expires = expirationDate, + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + + var token = tokenHandler.CreateToken(tokenDescriptor); + + return tokenHandler.WriteToken(token); + } + + public async Task> VerifyLicenseAsync(string license) + { + ArgumentNullException.ThrowIfNull(license); + + var tokenHandler = new JwtSecurityTokenHandler(); + + if (!tokenHandler.CanReadToken(license)) + { + throw new InvalidLicenseException(InvalidLicenseReason.InvalidFormat); + } + + var tokenValidateParameters = new TokenValidationParameters + { + IssuerSigningKey = new X509SecurityKey(_licensingOptions.SigningCertificate), + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + ValidIssuer = _licensingOptions.CloudHost, + ValidateIssuer = true, + ValidAudience = _internalLicensingOptions.ProductName, + ValidateAudience = true, +#if DEBUG + // It's useful to be stricter in tests so that we don't have to wait 5 minutes + ClockSkew = TimeSpan.Zero, +#endif + }; + + var tokenValidationResult = await tokenHandler.ValidateTokenAsync(license, tokenValidateParameters); + + if (!tokenValidationResult.IsValid) + { + var exception = tokenValidationResult.Exception; + _logger.LogWarning(exception, "The given license is not valid."); + if (exception is SecurityTokenExpiredException securityTokenExpiredException) + { + throw new InvalidLicenseException(InvalidLicenseReason.Expired, null, securityTokenExpiredException); + } + else if (exception is SecurityTokenSignatureKeyNotFoundException securityTokenSignatureKeyNotFoundException) + { + throw new InvalidLicenseException(InvalidLicenseReason.WrongKey, null, securityTokenSignatureKeyNotFoundException); + } + // TODO: Handle other known failures + throw new InvalidLicenseException(InvalidLicenseReason.Unknown, null, exception); + } + + return tokenValidationResult.ClaimsIdentity.Claims; + } +} diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Licensing/ILicensingService.cs b/extensions/Bitwarden.Extensions.Hosting/src/Licensing/ILicensingService.cs new file mode 100644 index 00000000..fd19bd8c --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/src/Licensing/ILicensingService.cs @@ -0,0 +1,41 @@ +using System.Security.Claims; + +namespace Bitwarden.Extensions.Hosting.Licensing; + +/// +/// A service with the ability to consume and create licenses. +/// +public interface ILicensingService +{ + /// + /// Returns whether or not the current service is running as a cloud instance. + /// + bool IsCloud { get; } + + // TODO: Any other options than valid for? + /// + /// Creates a signed license that can be consumed on self-hosted instances. + /// + /// + /// This method can only be called when returns . + /// + /// The claims to include in the license file. + /// The date the generated license should expire. + /// + /// The exception that is thrown if this method is called when the service is not running as a cloud service. + /// + /// + /// A string representation of the license that can be given to people to store with their self hosted instance. + /// + string CreateLicense(IEnumerable claims, DateTime expirationDate); + + /// + /// Verifies that the given license is valid and can have it's contents be trusted. + /// + /// The license to check. + /// + /// The exception that is thrown when the given license is invalid and data stored in it can not be trusted. + /// + /// An enumerable of claims included in the license. + Task> VerifyLicenseAsync(string license); +} diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Licensing/InvalidLicenseException.cs b/extensions/Bitwarden.Extensions.Hosting/src/Licensing/InvalidLicenseException.cs new file mode 100644 index 00000000..b4039f41 --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/src/Licensing/InvalidLicenseException.cs @@ -0,0 +1,71 @@ +namespace Bitwarden.Extensions.Hosting.Licensing; + +/// +/// A set of reasons explaining why a license was invalid. +/// +public enum InvalidLicenseReason +{ + /// + /// The given license was in an invalid format and could not be read further. + /// + InvalidFormat, + + /// + /// The given license may have been valid previously but has expired and should no longer be used. + /// + Expired, + + /// + /// The license was signed with a different key than the one that was used to verify it. + /// + WrongKey, + + /// + /// The license is invalid for an unknown reason, checks logs for additional details. + /// + Unknown, +} + +/// +/// The exception that is thrown when a license is invalid and cannot be verified. +/// +public class InvalidLicenseException : Exception +{ + private const string DefaultMessage = "The license is invalid and cannot be trusted."; + + /// + /// Initializes a new instance of . + /// + /// + public InvalidLicenseException(InvalidLicenseReason reason) + : base(DefaultMessage) + { + Reason = reason; + } + + /// + /// Initializes a new instance of . + /// + /// + /// + public InvalidLicenseException(InvalidLicenseReason reason, string? message) + : this(reason, message, null) + { } + + /// + /// Initializes a new instance of . + /// + /// + /// + /// + public InvalidLicenseException(InvalidLicenseReason reason, string? message, Exception? innerException) + : base(message ?? DefaultMessage, innerException) + { + Reason = reason; + } + + /// + /// The reason the license was found to be invalid. + /// + public InvalidLicenseReason Reason { get; } +} diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Licensing/LicensingOptions.cs b/extensions/Bitwarden.Extensions.Hosting/src/Licensing/LicensingOptions.cs new file mode 100644 index 00000000..6714c6c4 --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/src/Licensing/LicensingOptions.cs @@ -0,0 +1,85 @@ +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Hosting; + +namespace Bitwarden.Extensions.Hosting.Licensing; + +/// +/// A set of options for customizing how licensing behaves. +/// +public sealed class LicensingOptions +{ + /// + /// The base url of the cloud instance. + /// + public string CloudHost { get; set; } = "bitwarden.com"; + + /// + /// Options for configuring license retrieval from azure blob storage. + /// + public AzureBlobLicensingOptions AzureBlob { get; set; } = new AzureBlobLicensingOptions(); + + /// + /// The certificate that will be used to either sign or validate licenses. + /// + public X509Certificate2 SigningCertificate { get; set; } = null!; + + /// + /// Development option to force the usage of self hosted organization even though a certificate + /// that can sign licenses it available. + /// + public bool ForceSelfHost { get; set; } +} + +/// +/// A set of options for customizing how to find a certificate in Azure blob storage. +/// +public sealed class AzureBlobLicensingOptions +{ + /// + /// The connection string to the azure blob storage account. + /// + public string? ConnectionString { get; set; } + + /// + /// The password for the certificate stored in azure blob storage. + /// + public string? CertificatePassword { get; set; } + + // TODO: Do we actually need to allow these to be customized? + /// + /// The name of the blob the certificate is stored in, defaults to certificates. + /// + public string BlobName { get; set; } = "certificates"; + + /// + /// The name of the license stored in azure blob storage, defaults to licensing.pfx. + /// + public string LicenseName { get; set; } = "licensing.pfx"; +} + +/// +/// +/// +/// +/// You should not allow these to be set through configuration, only through code. +/// +public sealed class InternalLicensingOptions +{ + /// + /// The name of the product, defaults to the . + /// + /// + /// This should NOT be changed once you have issued licenses, changing this will invalidate any already existing licenses. + /// + public string ProductName { get; set; } = null!; + + /// + /// The thumbprint of the certificate that should be allowed when running in development mode. + /// + public string DevelopmentThumbprint { get; set; } = "207E64A231E8AA32AAF68A61037C075EBEBD553F"; + + /// + /// The thumbprint of the certificate that should be allowed when running in non-development mode. + /// + public string NonDevelopmentThumbprint { get; set; } = "B34876439FCDA2846505B2EFBBA6C4A951313EBE"; +} diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Licensing/PostConfigureLicensingOptions.cs b/extensions/Bitwarden.Extensions.Hosting/src/Licensing/PostConfigureLicensingOptions.cs new file mode 100644 index 00000000..0f0d2c13 --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/src/Licensing/PostConfigureLicensingOptions.cs @@ -0,0 +1,175 @@ +using System.Reflection; +using System.Security.Cryptography.X509Certificates; +using Azure.Storage.Blobs; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Bitwarden.Extensions.Hosting.Licensing; + +internal sealed class PostConfigureLicensingOptions : IPostConfigureOptions +{ + private readonly InternalLicensingOptions _internalLicensingOptions; + private readonly ILogger _logger; + private readonly IHostEnvironment _hostEnvironment; + + public PostConfigureLicensingOptions( + IOptions internalLicensingOptions, + ILogger logger, + IHostEnvironment hostEnvironment) + { + _internalLicensingOptions = internalLicensingOptions.Value; + _logger = logger; + _hostEnvironment = hostEnvironment; + } + + public void PostConfigure(string? name, LicensingOptions options) + { + if (name != Options.DefaultName) + { + return; + } + + if (options.SigningCertificate != null) + { + // Something already set it, no problem, return early. + return; + } + + // TODO: Apply old config locations to new place + + void DoFinalValidation() + { + var signingCertificate = options.SigningCertificate; + + if (signingCertificate == null) + { + throw new InvalidOperationException("No signing certificate could be retrieved."); + } + + var expectedThumbprint = _hostEnvironment.IsDevelopment() + ? _internalLicensingOptions.DevelopmentThumbprint + : _internalLicensingOptions.NonDevelopmentThumbprint; + + if (!string.Equals(expectedThumbprint, signingCertificate.Thumbprint, StringComparison.InvariantCultureIgnoreCase)) + { + throw new InvalidOperationException("The supplied certificate does not contain the expected thumbprint."); + } + + if (options.ForceSelfHost && !_hostEnvironment.IsDevelopment()) + { + // Force self host is only allowed when running as development + options.ForceSelfHost = false; + } + } + + // Try Azure Blob first + if (TryAzureBlob(options)) + { + DoFinalValidation(); + return; + } + + + // Try Cert store + if (TryCertStore(options)) + { + DoFinalValidation(); + return; + } + + + // Try Assembly embedded + if (TryGetEmbeddedCert(options)) + { + DoFinalValidation(); + return; + } + + // TODO: Throw good exception + throw new InvalidOperationException("Signing certificate could not be attained."); + } + + private bool TryAzureBlob(LicensingOptions options) + { + if (string.IsNullOrEmpty(options.AzureBlob.ConnectionString)) + { + return false; + } + + // Infer them as trying to do azure blob + if (string.IsNullOrEmpty(options.AzureBlob.CertificatePassword)) + { + // TODO: Use logger generator + _logger.LogWarning("An Azure Blob connection string but not a certificate password -- did you miss something?"); + return false; + } + + try + { + var blobServiceClient = new BlobServiceClient(options.AzureBlob.ConnectionString); + var blobContainerClient = blobServiceClient.GetBlobContainerClient(options.AzureBlob.BlobName); + var blobClient = blobContainerClient.GetBlobClient(options.AzureBlob.LicenseName); + using var memoryStream = new MemoryStream(); + blobClient.DownloadTo(memoryStream); + var certificate = new X509Certificate2(memoryStream.ToArray(), options.AzureBlob.CertificatePassword); + + options.SigningCertificate = certificate; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while retrieving signing certificate from azure blob storage."); + throw; // I think we should want this to be as fatal as possible. + } + + return true; + } + + private bool TryCertStore(LicensingOptions options) + { + var thumbprint = _hostEnvironment.IsDevelopment() + ? _internalLicensingOptions.DevelopmentThumbprint + : _internalLicensingOptions.NonDevelopmentThumbprint; + + using var certStore = new X509Store(StoreName.My, StoreLocation.CurrentUser); + certStore.Open(OpenFlags.ReadOnly); + var certCollection = certStore.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + if (certCollection.Count == 0) + { + return false; + } + + options.SigningCertificate = certCollection[0]; + return true; + } + + private bool TryGetEmbeddedCert(LicensingOptions options) + { + try + { + var appAssembly = Assembly.Load(new AssemblyName(_hostEnvironment.ApplicationName)); + // TODO: Make cert name configurable through internal options? + var certName = _hostEnvironment.IsDevelopment() + ? "licensing_dev.cer" + : "licensing.cer"; + + var resourceName = appAssembly.GetManifestResourceNames().SingleOrDefault(n => n.EndsWith(certName)); + + if (resourceName == null) + { + throw new InvalidOperationException($"An embedded certificate ending with the name {certName} could not be found."); + } + + using var resourceStream = appAssembly.GetManifestResourceStream(resourceName)!; + using var memoryStream = new MemoryStream(); + resourceStream.CopyTo(memoryStream); + options.SigningCertificate = new X509Certificate2(memoryStream.ToArray()); + return true; + } + catch (FileNotFoundException) + { + // TODO: Log warning + return false; + } + } +} diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/Bitwarden.Extensions.Hosting.Tests.csproj b/extensions/Bitwarden.Extensions.Hosting/tests/Bitwarden.Extensions.Hosting.Tests.csproj index 55682400..7d853579 100644 --- a/extensions/Bitwarden.Extensions.Hosting/tests/Bitwarden.Extensions.Hosting.Tests.csproj +++ b/extensions/Bitwarden.Extensions.Hosting/tests/Bitwarden.Extensions.Hosting.Tests.csproj @@ -16,8 +16,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -27,7 +29,11 @@ - + + + + + diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/Licensing/DefaultLicensingServiceTests.cs b/extensions/Bitwarden.Extensions.Hosting/tests/Licensing/DefaultLicensingServiceTests.cs new file mode 100644 index 00000000..71272fa9 --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/tests/Licensing/DefaultLicensingServiceTests.cs @@ -0,0 +1,210 @@ +using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; +using Bitwarden.Extensions.Hosting.Licensing; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using Xunit.Abstractions; + +namespace Bitwarden.Extensions.Hosting.Tests.Licensing; + +public class DefaultLicensingServiceTests +{ + private readonly ITestOutputHelper _outputHelper; + private readonly FakeTimeProvider _fakeTimeProvider; + + public DefaultLicensingServiceTests(ITestOutputHelper outputHelper) + { + _outputHelper = outputHelper; + _fakeTimeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); + } + + [Fact] + public async Task RoundTrip_Works() + { + var cloudSut = CreateSut(options => + { + options.SigningCertificate = new X509Certificate2(TestData.TestCertificateWithPrivateKey, TestData.PfxPassword); + }); + + var license = cloudSut.CreateLicense( + [ + new Claim("myClaim", "hello world!"), + ], Expiration(TimeSpan.FromMinutes(5))); + + _fakeTimeProvider.Advance(TimeSpan.FromSeconds(1)); + + var selfHostSut = CreateSut(options => + { + options.SigningCertificate = new X509Certificate2(TestData.TestCertificateCerFormat); + }); + + var claims = await selfHostSut.VerifyLicenseAsync(license); + + Assert.NotEmpty(claims); + Assert.Contains(claims, c => c.Type == "myClaim" && c.Value == "hello world!"); + } + + [Fact] + public async Task VerifyLicenseAsync_DifferentProduct_Fails() + { + var cloudSut = CreateSut(options => + { + options.SigningCertificate = new X509Certificate2(TestData.TestCertificateWithPrivateKey, TestData.PfxPassword); + }, "product1"); + + var license = cloudSut.CreateLicense( + [ + new Claim("myClaim", "hello world!"), + ], Expiration(TimeSpan.FromMinutes(5))); + + _fakeTimeProvider.Advance(TimeSpan.FromSeconds(1)); + + var selfHostSut = CreateSut(options => + { + options.SigningCertificate = new X509Certificate2(TestData.TestCertificateCerFormat); + }, "product2"); + + await Assert.ThrowsAsync( + async () => await selfHostSut.VerifyLicenseAsync(license) + ); + } + + [Fact] + public async Task VerifyLicenseAsync_DifferentCloudHost_Fails() + { + var cloudSut = CreateSut(options => + { + options.SigningCertificate = new X509Certificate2(TestData.TestCertificateWithPrivateKey, TestData.PfxPassword); + }); + + var license = cloudSut.CreateLicense( + [ + new Claim("myClaim", "hello world!"), + ], Expiration(TimeSpan.FromMinutes(5))); + + _fakeTimeProvider.Advance(TimeSpan.FromSeconds(1)); + + var selfHostSut = CreateSut(options => + { + options.CloudHost = "bitwarden.eu"; + options.SigningCertificate = new X509Certificate2(TestData.TestCertificateCerFormat); + }); + + await Assert.ThrowsAsync( + async () => await selfHostSut.VerifyLicenseAsync(license) + ); + } + + [Fact] + public void CreateLicense_WithSelfHost_Fails() + { + var selfHostSut = CreateSut(options => + { + options.SigningCertificate = new X509Certificate2(TestData.TestCertificateCerFormat); + }); + + var invalidOperation = Assert.Throws( + () => selfHostSut.CreateLicense(Enumerable.Empty(), Expiration(TimeSpan.FromMinutes(5))) + ); + + Assert.Equal( + "Self-hosted services can not create a license, please check 'IsCloud' before calling this method.", + invalidOperation.Message + ); + } + + [Fact] + public async Task RoundTrip_Expired_Fails() + { + + var cloudSut = CreateSut(options => + { + options.SigningCertificate = new X509Certificate2(TestData.TestCertificateWithPrivateKey, TestData.PfxPassword); + }); + + var license = cloudSut.CreateLicense(Enumerable.Empty(), Expiration(TimeSpan.FromMilliseconds(10))); + + await Task.Delay(TimeSpan.FromMilliseconds(100)); + + var selfHostSut = CreateSut(options => + { + options.SigningCertificate = new X509Certificate2(TestData.TestCertificateCerFormat); + }); + + var validationException = await Assert.ThrowsAsync( + async () => await selfHostSut.VerifyLicenseAsync(license) + ); + + Assert.Equal(InvalidLicenseReason.Expired, validationException.Reason); + } + + [Fact] + public async Task SignedWithAlternateKey_Fails() + { + var cloudSut = CreateSut(options => + { + options.SigningCertificate = new X509Certificate2(TestData.TestCertificateAlternate, "PfxPassword"); + }); + + Assert.True(cloudSut.IsCloud); + + var license = cloudSut.CreateLicense(Enumerable.Empty(), Expiration(TimeSpan.FromMinutes(5))); + + var selfHostSut = CreateSut(options => + { + options.SigningCertificate = new X509Certificate2(TestData.TestCertificateCerFormat); + }); + + var invalidLicenseException = await Assert.ThrowsAsync( + async () => await selfHostSut.VerifyLicenseAsync(license) + ); + + Assert.Equal(InvalidLicenseReason.WrongKey, invalidLicenseException.Reason); + } + + + [Fact] + public async Task SignedWithMainKey_VerifyingWithAlternateKey_Fails() + { + var cloudSut = CreateSut(options => + { + options.SigningCertificate = new X509Certificate2(TestData.TestCertificateWithPrivateKey, "PfxPassword"); + }); + + Assert.True(cloudSut.IsCloud); + + var license = cloudSut.CreateLicense(Enumerable.Empty(), Expiration(TimeSpan.FromMinutes(5))); + + var selfHostSut = CreateSut(options => + { + options.SigningCertificate = new X509Certificate2(TestData.TestCertificateCerFormatAlternate); + }); + + var invalidLicenseException = await Assert.ThrowsAsync( + async () => await selfHostSut.VerifyLicenseAsync(license) + ); + + Assert.Equal(InvalidLicenseReason.WrongKey, invalidLicenseException.Reason); + } + + private DateTime Expiration(TimeSpan timeSpan) + { + return _fakeTimeProvider.GetUtcNow().UtcDateTime.Add(timeSpan); + } + + private DefaultLicensingService CreateSut(Action configureOptions, string productName = "test") + { + var options = new LicensingOptions(); + configureOptions(options); + return new DefaultLicensingService( + Options.Create(options), + _fakeTimeProvider, + NullLogger.Instance, + Options.Create(new InternalLicensingOptions + { + ProductName = productName, + }) + ); + } +} diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/Licensing/PostConfigureLicensingOptionsTests.cs b/extensions/Bitwarden.Extensions.Hosting/tests/Licensing/PostConfigureLicensingOptionsTests.cs new file mode 100644 index 00000000..4e73204f --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/tests/Licensing/PostConfigureLicensingOptionsTests.cs @@ -0,0 +1,243 @@ +using System.Diagnostics; +using System.Reflection; +using System.Security.Cryptography.X509Certificates; +using Azure.Storage.Blobs; +using Bitwarden.Extensions.Hosting.Licensing; +using DotNet.Testcontainers.Builders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using Xunit.Abstractions; + +namespace Bitwarden.Extensions.Hosting.Tests.Licensing; + +public class PostConfigureLicensingOptionsTests +{ + private readonly InternalLicensingOptions _internalLicensingOptions; + private readonly IHostEnvironment _hostEnvironment; + private readonly ILoggerFactory _loggerFactory; + + private readonly PostConfigureLicensingOptions _sut; + + public PostConfigureLicensingOptionsTests(ITestOutputHelper testOutputHelper) + { + _loggerFactory = LoggerFactory.Create(builder => + { + builder.AddXunit(testOutputHelper); + }); + + _internalLicensingOptions = new InternalLicensingOptions(); + _hostEnvironment = Substitute.For(); + + _sut = new PostConfigureLicensingOptions( + Options.Create(_internalLicensingOptions), + _loggerFactory.CreateLogger(), + _hostEnvironment + ); + } + + [Fact] + public void PostConfigure_NoBlobConfigured_NotInStore_LoadsProductionCert() + { + var allowedCertThumbprint = "569A8AD2907FB3A20DE200C04C8B1069E90F20AD"; + + // Update our test cert as the allowed certificate + _internalLicensingOptions.NonDevelopmentThumbprint = allowedCertThumbprint; + + _hostEnvironment + .ApplicationName + .Returns("Bitwarden.Extensions.Hosting.Tests"); + + _hostEnvironment + .EnvironmentName + .Returns("Production"); + + var options = new LicensingOptions(); + + _sut.PostConfigure(Options.DefaultName, options); + + Assert.NotNull(options.SigningCertificate); + Assert.Equal(allowedCertThumbprint, options.SigningCertificate.Thumbprint); + } + + [Fact] + public void PostConfigure_NoBlobConfigured_NotInStore_LoadsDevCert() + { + var allowedCertThumbprint = "AC6C1CDD9050FC943A4A67DAA181C85CF89AE9C7"; + + // Update our test cert as the allowed certificate + _internalLicensingOptions.DevelopmentThumbprint = allowedCertThumbprint; + + _hostEnvironment + .ApplicationName + .Returns("Bitwarden.Extensions.Hosting.Tests"); + + _hostEnvironment + .EnvironmentName + .Returns("Development"); + + var options = new LicensingOptions(); + + _sut.PostConfigure(Options.DefaultName, options); + + Assert.NotNull(options.SigningCertificate); + Assert.Equal(allowedCertThumbprint, options.SigningCertificate.Thumbprint); + } + + [Fact] + public void PostConfigure_NoBlobConfigured_InStore_Development_LoadsStoreCert() + { + var allowedCertThumbprint = "AC6C1CDD9050FC943A4A67DAA181C85CF89AE9C7"; + + // Update our test cert as the allowed certificate + _internalLicensingOptions.DevelopmentThumbprint = allowedCertThumbprint; + + _hostEnvironment + .EnvironmentName + .Returns("Development"); + + var options = new LicensingOptions(); + + UseTempStoreCert( + "Bitwarden.Extensions.Hosting.Tests.Resources.licensing_dev.cer", + allowedCertThumbprint, () => + { + _sut.PostConfigure(Options.DefaultName, options); + }); + + Assert.NotNull(options.SigningCertificate); + Assert.Equal(allowedCertThumbprint, options.SigningCertificate.Thumbprint); + } + + [Fact] + public void PostConfigure_NoBlobConfigured_InStore_Production_LoadsStoreCert() + { + var allowedCertThumbprint = "569A8AD2907FB3A20DE200C04C8B1069E90F20AD"; + + // Update our test cert as the allowed certificate + _internalLicensingOptions.NonDevelopmentThumbprint = allowedCertThumbprint; + + _hostEnvironment + .EnvironmentName + .Returns("Production"); + + var options = new LicensingOptions(); + + UseTempStoreCert( + "Bitwarden.Extensions.Hosting.Tests.Resources.licensing.cer", + allowedCertThumbprint, () => + { + _sut.PostConfigure(Options.DefaultName, options); + }); + + Assert.NotNull(options.SigningCertificate); + Assert.Equal(allowedCertThumbprint, options.SigningCertificate.Thumbprint); + } + + [Fact] + public async Task PostConfigure_InBlob_RetrievesCertFromBlob() + { + await using var test = await PrepareBlobStorageAsync(); + + var allowedCertThumbprint = "AC6C1CDD9050FC943A4A67DAA181C85CF89AE9C7"; + + _hostEnvironment + .EnvironmentName + .Returns("Development"); + + _internalLicensingOptions.DevelopmentThumbprint = allowedCertThumbprint; + + var options = new LicensingOptions(); + options.AzureBlob.ConnectionString = "UseDevelopmentStorage=true;"; + options.AzureBlob.CertificatePassword = TestData.PfxPassword; + + _sut.PostConfigure(Options.DefaultName, options); + + Assert.NotNull(options.SigningCertificate); + Assert.Equal(allowedCertThumbprint, options.SigningCertificate.Thumbprint); + } + + [Fact] + public async Task PostConfigure_InBlob_CustomOptions_RetrievesCertFromBlob() + { + await using var test = await PrepareBlobStorageAsync("custom", "myLicense.pfx"); + + var allowedCertThumbprint = "AC6C1CDD9050FC943A4A67DAA181C85CF89AE9C7"; + + _hostEnvironment + .EnvironmentName + .Returns("Development"); + + _internalLicensingOptions.DevelopmentThumbprint = allowedCertThumbprint; + + var options = new LicensingOptions(); + options.AzureBlob.ConnectionString = "UseDevelopmentStorage=true;"; + options.AzureBlob.CertificatePassword = TestData.PfxPassword; + options.AzureBlob.BlobName = "custom"; + options.AzureBlob.LicenseName = "myLicense.pfx"; + + _sut.PostConfigure(Options.DefaultName, options); + + Assert.NotNull(options.SigningCertificate); + Assert.Equal(allowedCertThumbprint, options.SigningCertificate.Thumbprint); + } + + private async Task PrepareBlobStorageAsync( + string containerName = "certificates", + string licenseName = "licensing.pfx") + { + var container = new ContainerBuilder() + .WithImage("mcr.microsoft.com/azure-storage/azurite:3.33.0") + .WithPortBinding(10000, 10000) // Default port for blob storage + .WithLogger(_loggerFactory.CreateLogger("Testcontainer")) + .Build(); + + await container.StartAsync(); + + // Add certs to blob storage + var blobServiceClient = new BlobServiceClient("UseDevelopmentStorage=true;"); + var blobContainerClient = blobServiceClient.CreateBlobContainer(containerName).Value; + var blobClient = blobContainerClient.GetBlobClient(licenseName); + + await blobClient.UploadAsync(new BinaryData(TestData.TestCertificateWithPrivateKey)); + + return container; + } + + private void UseTempStoreCert(string resourceName, string thumbprint, Action test) + { + X509Certificate2? certificate = null; + try + { + var certStore = new X509Store(StoreName.My, StoreLocation.CurrentUser); + certStore.Open(OpenFlags.ReadWrite); + + using var resourceStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName)!; + using var memoryStream = new MemoryStream(); + resourceStream.CopyTo(memoryStream); + certificate = new X509Certificate2(memoryStream.ToArray()); + + // Test code should never have us place a cert that different from the given thumbprint. + Debug.Assert(certificate.Thumbprint == thumbprint); + + certStore.Add(certificate); + + // Close the store before running the test + certStore.Dispose(); + + test(); + } + finally + { + // Was the certificate loaded? + if (certificate != null) + { + // Delete from store via thumbprint + using var deletingCertStore = new X509Store(StoreName.My, StoreLocation.CurrentUser); + deletingCertStore.Open(OpenFlags.ReadWrite); + deletingCertStore.Remove(certificate); + } + } + } +} diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/Licensing/TestData.cs b/extensions/Bitwarden.Extensions.Hosting/tests/Licensing/TestData.cs new file mode 100644 index 00000000..33042866 --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/tests/Licensing/TestData.cs @@ -0,0 +1,276 @@ +namespace Bitwarden.Extensions.Hosting.Tests; + +public static class TestData +{ + // Created using: + // openssl req -x509 -newkey rsa:4096 -sha256 -nodes -keyout test.key -out test.crt -subj "/CN=LicenseTest" -days 3650 + // openssl pkcs12 -export -legacy -out test.pfx -inkey test.key -in test.crt -certfile test.crt + // Password: PfxPassword + // Thumbprint: AC6C1CDD9050FC943A4A67DAA181C85CF89AE9C7 + public static byte[] TestCertificateWithPrivateKey = Convert.FromBase64String(@" +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 +aGur7E/GqOtPjjECAgfQ"); + + public static byte[] TestCertificateAlternate = Convert.FromBase64String(@" +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"); + + public static string PfxPassword = "PfxPassword"; + + // Thumbprint: 569A8AD2907FB3A20DE200C04C8B1069E90F20AD + public static byte[] TestCertificateCerFormat = Convert.FromBase64String(@" +MIIFDTCCAvWgAwIBAgIUMOrXEcRBfYU9Jr6coUXIpSbdLkEwDQYJKoZIhvcNAQEL +BQAwFjEUMBIGA1UEAwwLTGljZW5zZVRlc3QwHhcNMjQxMDI3MTYyNzM4WhcNMzQx +MDI1MTYyNzM4WjAWMRQwEgYDVQQDDAtMaWNlbnNlVGVzdDCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBAIiQRt6N+M/tUri0f/9gVr1RnW9KNy4NvIhOtGrw +Z3gbai/gRHdmaL8mNunTqPBYQYm+JOVklmHqJSyI6/cUuy+WgrUU9ewrVZAQUoK7 +DdirP1X0igtOa2gnyQxL7TXzmtQiNSFEbk8SknEteCaMqixpZ3vZHGVSEw0IuFQT +pPSDobmmZxRnOrsfS2TWw1hdiYTrpRCUnjsQ67XjFQmVG1OMuTc4eP611cK+5OfS +g0CFXqjHrQEzbguSbAidlOilkvKwDI1Cb5WOae97JiZEhZoARMuE+XvQdHcFk/u9 +CDpDfO9zK4PeDXFE0u45dsyKEIVFqZ2Ts21Cfa4HwKmtxr//L4jJ4Q2zcOZAqHhO +PbdshhvWKLeypzT22M/02tfy/2uLUfG9SOoejVN0y3vRZEkheTLUypC0AGx5m48D +tnzR2lFZzelIXqIBXJUVBAW45t+KUhHiekQ8GAlK9NDW/9WTNwwPUbhoJyI/Fid9 +B8VJCHC9Qof80+ymqmjoRPZMQTE6/e2A2U3ESUFhbJLKU50e/CQoDOdD+x83zz3U +ayYAjZMdkYEKV/Okj6wKWptnUhzgArnOJxCiuTgbcddoKKSaOzbKA222eR66rIdv +bSAJHyMIwzmwVqyKaDTZdKBsX15vmbQp8pZmSjP0CHk8bQM++lyK8wLTYj321cHl +M2x7AgMBAAGjUzBRMB0GA1UdDgQWBBT9Xt+mJZlanInw5CmJArjMXFbTEDAfBgNV +HSMEGDAWgBT9Xt+mJZlanInw5CmJArjMXFbTEDAPBgNVHRMBAf8EBTADAQH/MA0G +CSqGSIb3DQEBCwUAA4ICAQB2mPFm7hr37eyL9y8OV7hq1sB0n2CjEMoTI36p0NpV +V2rlTnboVifDXtdx/EhMAeiHKcks4Ccn/DvP6sthYsRAGv20+TCjuIkjvt/I4u7C +0udT65oIDUSwcguZ1KYISqE2xBmziN1hJgNKG9hPPeN+mRVX0JxQq3lH4nQSwZD9 +mxX0Ep6eZlKsEvoViYel24KO0WRF+nSx5u7xhi1sA6sS9hQK1NgdQYNqweOFmYuA +G+/PcVXJbVXdleXfsCiQidXHqmlPaNgKVoH/udsBMe+y6V70towJRZsjSAAZjK87 +L4UZFENROmcUMOQE0rCO0KYUBFQPg1UkZFUoOHC3o0Mt0SNC1uJF50MpJDc78Txf +hVraMXRziy9ZSeiqvfffNVv3rVVCs6MrJObmVsm4ZkGVQX7Z7AM7IFVNghy/YOUu +Q9srnDdBXunRt6PYroWD3u4kWFkrJeQMU/TthEm/BfxEkShk59bVVworxYs8d7zL +bp07BTFJ0xhFwv3+jLvwT2WRnmPmWZSySbyeWrU1Pf//4vRlVtnNenxqEUrQW0P7 +p3FMP7lYoJAc0J1uTcJzJT6ncr4+YAkpvMiINYI75fPCVKnGKn71bC70oryPn5ZD +SD9PbavjyDl00o5G6XWPy5x7XfjjLwDN5Lah1t1fCIAFDEleCj1Ml0FaapygrlGw +oA=="); + + + public static byte[] TestCertificateCerFormatAlternate = Convert.FromBase64String(@" +MIIFFTCCAv2gAwIBAgIUUo4yDMDAjrVZ/d3fte5qHGiAfY0wDQYJKoZIhvcNAQEL +BQAwGjEYMBYGA1UEAwwPTGljZW5zZVRlc3RQcm9kMB4XDTI0MTAzMTAwNTMzMVoX +DTM0MTAyOTAwNTMzMVowGjEYMBYGA1UEAwwPTGljZW5zZVRlc3RQcm9kMIICIjAN +BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAxyyF4doNTnaGWYGZN2gSbtSynKyK +Bou9rwDO2tM8FrO7YfIWPEZhldKcoDtwDRhhari8UgKCtRz23iwcUFbzNSdFbwov +ymKqEM0kWRasww+MT0BoYZxj4u+k9mFcVicKF2D7eQ4Tn7cLopkaqEkHJ8KrUB3N +2PFJY35PoHl9zH676KaXqTzx2KlycrlCiaaDVyk3GbFIBo/z3LSO9lWxAkDXcTOe +GW4aBg69sTO4Ti1BIIg/M3XYITOtf9Vbr5vq/BewnM0P1uG4KAgeJGC4BWqyihoS +S7k5ey3IfxKyEa28vAuIBerLpN2HnsMw4UFED8+vy3zKNYIttA4ey92SQ6l/Vib/ +qd8KQo+c2ueMcUBya5PER21QLW962ts8VpLoMCdaYZqIGXpBiPbJeTD8Aig2mZoX +oPY6szW8qK1Udwp+x3bT35JUvl4MCDenCzlq84WTIYAI8sDr+lVJywwhTfKI2/5I +sXKxQt+N4U0dcyj4lFCnfgmZI6GC1wM9JnKnkb7gmh+7jTofzVWpO7ZDe+aZ4KBR +N29dx3KNOUtku+fQ5rj2E7oa+QwVjrYGz69u03+nIe1UVb5tZuKglFgabS11oYJj +wj3+FufXAxmdpmAeShO7E77vNy6RIEvX/DRCLCB7QWS4CP7BqLxwNh4S/C8Z4N+E +IUqqU4EkI5GrxJUCAwEAAaNTMFEwHQYDVR0OBBYEFEgZY5hbaET9M2axWxlJfmEm +yE8XMB8GA1UdIwQYMBaAFEgZY5hbaET9M2axWxlJfmEmyE8XMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADggIBAJcpiRO0fF319BSS0uB+xmMYjx3d6o4D +yb/JPyHv664UuKV1ofQpx2DLFMl07OVc0e9ujrHpcGOsBTt4e/EkSRYwNcri3oZV +zdSgddk28DSzi9uOH/Ws8zP8UZ/gWjNMJs06Nh+3VfAE8ZmZBZ9LvWVNuLWNzpsv +D9qqiagr9jWFd54Y+gHMNR041kQ0Ndrm5yrK/+0C4qqtQ3XpJ1qL+mTihDl5SUwh +5zEdFxUqq25SSZvOwDDCch+BoagwPEp7NMExb09d/UCwLwurJLkqnkzeOQAKe+Qb +4zMJf4bgUAmvNP9mWbMCDjrCDKr0WXEVXoFz7FU5pRuNQKbz2U16I4ajPmqW1zwf +EMugXwDZT+w47U4uYhKQJTX8CKkj6ugWqR5sC9U+kiFlja4zbcM3VCk2UyxzoOqV +imj/9svPuMSoU8JJKXGeamNCkvEOcmE6GqAOj5XEMEOXPI6owVB8gdDRehCuWQA0 +xn8r+tROZJqiWhr4MQ/HAGddRg1UVpfInUbeop6TAs8vKhS8KJtDDtkLZ2DysQml +EwFL5RoC1Pb42bXknEQEuP4h24N7uCaPYFCoB1ypoHtzoYVuCeUpaQiNyk9ce6n8 ++dO+4VfO0KJjqXqty8TW1zPcAMSqqSt52w5NUPiyH8DVIMviH9GeKh5W3/wOEYe6 +5zlWzoWX3ZKf"); + +} diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/Resources/licensing.cer b/extensions/Bitwarden.Extensions.Hosting/tests/Resources/licensing.cer new file mode 100644 index 00000000..857e3dc8 Binary files /dev/null and b/extensions/Bitwarden.Extensions.Hosting/tests/Resources/licensing.cer differ diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/Resources/licensing_dev.cer b/extensions/Bitwarden.Extensions.Hosting/tests/Resources/licensing_dev.cer new file mode 100644 index 00000000..8a65d88a Binary files /dev/null and b/extensions/Bitwarden.Extensions.Hosting/tests/Resources/licensing_dev.cer differ diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/SelfHostedAttributeTests.cs b/extensions/Bitwarden.Extensions.Hosting/tests/SelfHostedAttributeTests.cs index 54064439..98aa9ad2 100644 --- a/extensions/Bitwarden.Extensions.Hosting/tests/SelfHostedAttributeTests.cs +++ b/extensions/Bitwarden.Extensions.Hosting/tests/SelfHostedAttributeTests.cs @@ -1,5 +1,6 @@ using Bitwarden.Extensions.Hosting.Attributes; using Bitwarden.Extensions.Hosting.Exceptions; +using Bitwarden.Extensions.Hosting.Licensing; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; @@ -64,12 +65,12 @@ private ActionExecutingContext GetContext(bool selfHosted) { IServiceCollection services = new ServiceCollection(); - var globalSettings = new GlobalSettingsBase - { - IsSelfHosted = selfHosted - }; + var licensingService = Substitute.For(); + licensingService + .IsCloud + .Returns(!selfHosted); - services.AddSingleton(globalSettings); + services.AddSingleton(licensingService); var httpContext = new DefaultHttpContext { diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/XUnitLoggerProvider.cs b/extensions/Bitwarden.Extensions.Hosting/tests/XUnitLoggerProvider.cs new file mode 100644 index 00000000..fd9636f7 --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/tests/XUnitLoggerProvider.cs @@ -0,0 +1,138 @@ +using System.Globalization; +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + + +namespace Bitwarden.Extensions.Hosting.Tests; + +public static class XunitLoggerFactoryExtensions +{ + public static ILoggingBuilder AddXunit(this ILoggingBuilder builder, ITestOutputHelper output) + { + builder.Services.AddSingleton(new XunitLoggerProvider(output)); + return builder; + } +} + +// Ref: https://github.com/dotnet/aspnetcore/blob/main/src/Testing/src/Logging/XunitLoggerProvider.cs +// Perma-link: https://github.com/dotnet/aspnetcore/blob/7f62ae6455a90b1026bd1522f4af3e6de64ee2a9/src/Testing/src/Logging/XunitLoggerProvider.cs +public class XunitLoggerProvider : ILoggerProvider +{ + private readonly ITestOutputHelper _output; + private readonly LogLevel _minLevel; + private readonly DateTimeOffset? _logStart; + + public XunitLoggerProvider(ITestOutputHelper output) + : this(output, LogLevel.Trace) + { + } + + public XunitLoggerProvider(ITestOutputHelper output, LogLevel minLevel) + : this(output, minLevel, null) + { + } + + public XunitLoggerProvider(ITestOutputHelper output, LogLevel minLevel, DateTimeOffset? logStart) + { + _output = output; + _minLevel = minLevel; + _logStart = logStart; + } + + public ILogger CreateLogger(string categoryName) + { + return new XunitLogger(_output, categoryName, _minLevel, _logStart); + } + + public void Dispose() + { + } +} + +public class XunitLogger : ILogger +{ + private static readonly string[] NewLineChars = new[] { Environment.NewLine }; + private readonly string _category; + private readonly LogLevel _minLogLevel; + private readonly ITestOutputHelper _output; + private readonly DateTimeOffset? _logStart; + + public XunitLogger(ITestOutputHelper output, string category, LogLevel minLogLevel, DateTimeOffset? logStart) + { + _minLogLevel = minLogLevel; + _category = category; + _output = output; + _logStart = logStart; + } + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + // Buffer the message into a single string in order to avoid shearing the message when running across multiple threads. + var messageBuilder = new StringBuilder(); + + var timestamp = _logStart.HasValue ? + $"{(DateTimeOffset.UtcNow - _logStart.Value).TotalSeconds.ToString("N3", CultureInfo.InvariantCulture)}s" : + DateTimeOffset.UtcNow.ToString("s", CultureInfo.InvariantCulture); + + var firstLinePrefix = $"| [{timestamp}] {_category} {logLevel}: "; + var lines = formatter(state, exception).Split(NewLineChars, StringSplitOptions.RemoveEmptyEntries); + messageBuilder.AppendLine(firstLinePrefix + lines.FirstOrDefault() ?? string.Empty); + + var additionalLinePrefix = "|" + new string(' ', firstLinePrefix.Length - 1); + foreach (var line in lines.Skip(1)) + { + messageBuilder.AppendLine(additionalLinePrefix + line); + } + + if (exception != null) + { + lines = exception.ToString().Split(NewLineChars, StringSplitOptions.RemoveEmptyEntries); + additionalLinePrefix = "| "; + foreach (var line in lines) + { + messageBuilder.AppendLine(additionalLinePrefix + line); + } + } + + // Remove the last line-break, because ITestOutputHelper only has WriteLine. + var message = messageBuilder.ToString(); + if (message.EndsWith(Environment.NewLine, StringComparison.Ordinal)) + { + message = message.Substring(0, message.Length - Environment.NewLine.Length); + } + + try + { + _output.WriteLine(message); + } + catch (Exception) + { + // We could fail because we're on a background thread and our captured ITestOutputHelper is + // busted (if the test "completed" before the background thread fired). + // So, ignore this. There isn't really anything we can do but hope the + // caller has additional loggers registered + } + } + + public bool IsEnabled(LogLevel logLevel) + => logLevel >= _minLogLevel; + + public IDisposable BeginScope(TState state) + where TState : notnull + => new NullScope(); + + private sealed class NullScope : IDisposable + { + public void Dispose() + { + } + } +}