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()
+ {
+ }
+ }
+}