Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds LicensingService #40

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Bitwarden.Extensions.Hosting.Exceptions;
using Bitwarden.Extensions.Hosting.Licensing;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;

Expand All @@ -25,12 +26,12 @@ public class SelfHostedAttribute : ActionFilterAttribute
/// <exception cref="BadRequestException"></exception>
public override void OnActionExecuting(ActionExecutingContext context)
{
var globalSettings = context.HttpContext.RequestServices.GetRequiredService<GlobalSettingsBase>();
if (SelfHostedOnly && !globalSettings.IsSelfHosted)
var licensingService = context.HttpContext.RequestServices.GetRequiredService<ILicensingService>();
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.");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,18 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" Version="12.22.2" />
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.6.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.1.2" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.4" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.1.2" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,19 @@ namespace Bitwarden.Extensions.Hosting;
public class BitwardenHostOptions
{
/// <summary>
/// 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.
/// </summary>
public bool IncludeLogging { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether to include metrics.
/// Gets or sets a value indicating whether to include metrics, defaults to true.
/// </summary>
public bool IncludeMetrics { get; set; } = true;

/// <summary>
/// Gets or sets a value indicating if self-hosting capabilities should be added to the service, defaults to false.
/// </summary>
/// <remarks>
/// If this is not turned on, the assumption is made that the service is running in a cloud environment.
/// </remarks>
public bool IncludeSelfHosting { get; set; }
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -51,13 +53,6 @@
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(bitwardenHostOptions);

builder.Services.AddOptions<GlobalSettingsBase>()
.Configure<IConfiguration>((options, config) =>
{
options.IsSelfHosted = config.GetValue(SelfHostedConfigKey, false);
});


if (builder.Configuration.GetValue(SelfHostedConfigKey, false))
{
AddSelfHostedConfig(builder.Configuration, builder.Environment);
Expand All @@ -75,6 +70,11 @@

AddFeatureFlagServices(builder.Services, builder.Configuration);

if (bitwardenHostOptions.IncludeSelfHosting)
{
AddLicensingServices(builder.Services, builder.Configuration);
}

Check warning on line 76 in extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs

View check run for this annotation

Codecov / codecov/patch

extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs#L74-L76

Added lines #L74 - L76 were not covered by tests

return builder;
}

Expand All @@ -97,15 +97,6 @@
/// <returns></returns>
public static IHostBuilder UseBitwardenDefaults(this IHostBuilder hostBuilder, BitwardenHostOptions bitwardenHostOptions)
{
hostBuilder.ConfigureServices((_, services) =>
{
services.AddOptions<GlobalSettingsBase>()
.Configure<IConfiguration>((options, config) =>
{
options.IsSelfHosted = config.GetValue("globalSettings:selfHosted", false);
});
});

hostBuilder.ConfigureAppConfiguration((context, builder) =>
{
if (context.Configuration.GetValue(SelfHostedConfigKey, false))
Expand Down Expand Up @@ -135,6 +126,14 @@
AddFeatureFlagServices(services, context.Configuration);
});

if (bitwardenHostOptions.IncludeSelfHosting)
{
hostBuilder.ConfigureServices((context, services) =>
{
AddLicensingServices(services, context.Configuration);
});
}

Check warning on line 135 in extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs

View check run for this annotation

Codecov / codecov/patch

extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs#L130-L135

Added lines #L130 - L135 were not covered by tests

return hostBuilder;
}

Expand Down Expand Up @@ -241,4 +240,23 @@
services.TryAddScoped<ILdClient>(sp => sp.GetRequiredService<LaunchDarklyClientProvider>().Get());
services.TryAddScoped<IFeatureService, LaunchDarklyFeatureService>();
}

private static void AddLicensingServices(IServiceCollection services, IConfiguration configuration)
{

Check warning on line 245 in extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs

View check run for this annotation

Codecov / codecov/patch

extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs#L245

Added line #L245 was not covered by tests
// Default the product name to the application name if no one else has added it.
services.AddOptions<InternalLicensingOptions>()
.PostConfigure<IHostEnvironment>((options, environment) =>
{

Check warning on line 249 in extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs

View check run for this annotation

Codecov / codecov/patch

extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs#L247-L249

Added lines #L247 - L249 were not covered by tests
if (string.IsNullOrEmpty(options.ProductName))
{
options.ProductName = environment.ApplicationName;
}
});

Check warning on line 254 in extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs

View check run for this annotation

Codecov / codecov/patch

extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs#L251-L254

Added lines #L251 - L254 were not covered by tests

services.TryAddEnumerable(
ServiceDescriptor.Singleton<IPostConfigureOptions<LicensingOptions>, PostConfigureLicensingOptions>()
);

Check warning on line 258 in extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs

View check run for this annotation

Codecov / codecov/patch

extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs#L256-L258

Added lines #L256 - L258 were not covered by tests

services.Configure<LicensingOptions>(configuration.GetSection("Licensing"));
}

Check warning on line 261 in extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs

View check run for this annotation

Codecov / codecov/patch

extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs#L260-L261

Added lines #L260 - L261 were not covered by tests
}
Original file line number Diff line number Diff line change
@@ -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<DefaultLicensingService> _logger;
private readonly InternalLicensingOptions _internalLicensingOptions;

public DefaultLicensingService(
IOptions<LicensingOptions> licensingOptions,
TimeProvider timeProvider,
ILogger<DefaultLicensingService> logger,
IOptions<InternalLicensingOptions> 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<Claim> 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<IEnumerable<Claim>> VerifyLicenseAsync(string license)
{
ArgumentNullException.ThrowIfNull(license);

var tokenHandler = new JwtSecurityTokenHandler();

if (!tokenHandler.CanReadToken(license))
{
throw new InvalidLicenseException(InvalidLicenseReason.InvalidFormat);

Check warning on line 81 in extensions/Bitwarden.Extensions.Hosting/src/Licensing/DefaultLicensingService.cs

View check run for this annotation

Codecov / codecov/patch

extensions/Bitwarden.Extensions.Hosting/src/Licensing/DefaultLicensingService.cs#L80-L81

Added lines #L80 - L81 were not covered by tests
}

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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โ„น๏ธ I took a look and don't see anything else we'd want to return really.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Security.Claims;

namespace Bitwarden.Extensions.Hosting.Licensing;

/// <summary>
/// A service with the ability to consume and create licenses.
/// </summary>
public interface ILicensingService
{
/// <summary>
/// Returns whether or not the current service is running as a cloud instance.
/// </summary>
bool IsCloud { get; }

// TODO: Any other options than valid for?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ’ญ Limit the product names enabled perhaps? Made up constraint by me.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally don't want to limit the product names in anyway, if we did it would require that we update this library whenever we create a new product, which feels like an artificial slowdown. It would also do nothing to stop one product from using another products name. But I do think that maybe taking the product name in this method could be a good idea. Right now, this has a limitation of creating licenses for only a single products licenses per project. Maybe that is a limitation we don't want. Just adding the product name here though would still have all products share the same signing certificate which maybe we'd want that configurable per product too.

But forcing the product name to be passed in here does force them to configure it, vs now I allow it to be configured but grab the IHostEnvironment.ApplicationName (csproj name) which could have someone rename it one day without knowing the ramifications.

Also, I think I am going back on accepting a TimeSpan now and think it should be a DateTime. Most licenses are going to be valid for longer than a month, most likely in terms of years and their aren't TimeSpan.FromYears overloads (because a year is variable based on when you start it), likely expiration date is going to come from something like stripe instead of being something you can hardcode and I'm pretty sure it will come from stripe as a DateTime. Thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not opposed to updating this when a product is added -- that's a major event that requires preparation. That said, it has pitfalls and I am just making this up so we don't need to add it; if we want to license differently in the future we can figure it out then.

Renaming a .csproj seems a bit weird to me and I figure we don't need to account for it.

I like the idea of allowing a set time to be the input instead. Let the caller figure out when they want it to expire.

/// <summary>
/// Creates a signed license that can be consumed on self-hosted instances.
/// </summary>
/// <remarks>
/// This method can only be called when <see cref="IsCloud"/> returns <see langword="true" />.
/// </remarks>
/// <param name="claims">The claims to include in the license file.</param>
/// <param name="expirationDate">The date the generated license should expire.</param>
/// <exception cref="InvalidOperationException">
/// The exception that is thrown if this method is called when the service is not running as a cloud service.
/// </exception>
/// <returns>
/// A string representation of the license that can be given to people to store with their self hosted instance.
/// </returns>
string CreateLicense(IEnumerable<Claim> claims, DateTime expirationDate);

/// <summary>
/// Verifies that the given license is valid and can have it's contents be trusted.
/// </summary>
/// <param name="license">The license to check.</param>
/// <exception cref="InvalidLicenseException">
/// The exception that is thrown when the given license is invalid and data stored in it can not be trusted.
/// </exception>
/// <returns>An enumerable of claims included in the license.</returns>
Task<IEnumerable<Claim>> VerifyLicenseAsync(string license);
}
Loading
Loading