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
Next Next commit
Work On Licensing
  • Loading branch information
justindbaur committed Oct 29, 2024

Verified

This commit was created on GitHub.com and signed with GitHubโ€™s verified signature.
commit 14e878bd243aff245e9b9c5804e1d29c166f3a52
Original file line number Diff line number Diff line change
@@ -24,13 +24,15 @@
<ItemGroup>
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.5.2" />
<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>
Original file line number Diff line number Diff line change
@@ -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;
@@ -74,6 +76,7 @@ public static TBuilder UseBitwardenDefaults<TBuilder>(this TBuilder builder, Bit
}

AddFeatureFlagServices(builder.Services, builder.Configuration);
AddLicensingServices(builder.Services, builder.Configuration);

return builder;
}
@@ -133,6 +136,7 @@ public static IHostBuilder UseBitwardenDefaults(this IHostBuilder hostBuilder, B
hostBuilder.ConfigureServices((context, services) =>
{
AddFeatureFlagServices(services, context.Configuration);
AddLicensingServices(services, context.Configuration);
});

return hostBuilder;
@@ -241,4 +245,13 @@ private static void AddFeatureFlagServices(IServiceCollection services, IConfigu
services.TryAddScoped<ILdClient>(sp => sp.GetRequiredService<LaunchDarklyClientProvider>().Get());
services.TryAddScoped<IFeatureService, LaunchDarklyFeatureService>();
}

private static void AddLicensingServices(IServiceCollection services, IConfiguration configuration)
{
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IPostConfigureOptions<LicensingOptions>, PostConfigureLicensingOptions>()
);

services.Configure<LicensingOptions>(configuration.GetSection("Licensing"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

namespace Bitwarden.Extensions.Hosting.Licensing;

internal sealed class DefaultLicensingService : ILicensingService
{
private readonly LicensingOptions _licensingOptions;
private readonly TimeProvider _timeProvider;

public DefaultLicensingService(IOptions<LicensingOptions> licensingOptions, TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(licensingOptions);

// TODO: Do we need to support runtime changes to these settings at all, I don't think we do...
Copy link
Contributor

Choose a reason for hiding this comment

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

โ„น๏ธ No.

_licensingOptions = licensingOptions.Value;
_timeProvider = timeProvider;

// 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, TimeSpan validFor)
{
ArgumentNullException.ThrowIfNull(claims);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(validFor, TimeSpan.Zero);

if (!IsCloud)
{
throw new InvalidOperationException("Self-hosted services can not create a license, please check 'IsCloud' before calling this method.");
}

var now = _timeProvider.GetUtcNow().UtcDateTime;


var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
// Issuer = "bitwarden.com", // TODO: Is this what we want?
// Audience = "configurable_product_name??", // TODO: Is this what we want?
SigningCredentials = new SigningCredentials(
new X509SecurityKey(_licensingOptions.SigningCertificate), SecurityAlgorithms.RsaSha256),
IssuedAt = now,
NotBefore = now,
Expires = now.Add(validFor),
};

var tokenHandler = new JwtSecurityTokenHandler();

var token = tokenHandler.CreateToken(tokenDescriptor);

return tokenHandler.WriteToken(token);
}

public async Task<IEnumerable<Claim>> VerifyLicenseAsync(string license)
{
ArgumentNullException.ThrowIfNull(license);
// TODO: Should we validate that this is self host?
Copy link
Contributor

Choose a reason for hiding this comment

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

โ„น๏ธ I'd say no. I can make up some ideas around how we'd offer license verification capabilities somewhere new so we don't need to constrain this.

Copy link
Member Author

Choose a reason for hiding this comment

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

Sweet, I concur.

// It's not technically wrong to be able to do that but we don't do it currently
// so we could disallow it.

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,
ValidateIssuer = false, // TODO: Do we want to have no issuer?
ValidateAudience = false, // TODO: Do we want to have no audience?
};

var tokenValidationResult = await tokenHandler.ValidateTokenAsync(license, tokenValidateParameters);

if (!tokenValidationResult.IsValid)
{
throw tokenValidationResult.Exception;
}

// Should I even take a ClaimsIdentity and return it here instead of a list of claims?
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,35 @@
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="validFor">How long the license should be valid for.</param>
/// <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, TimeSpan validFor);

/// <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>
/// <returns>An enumerable of claims included in the license.</returns>
Task<IEnumerable<Claim>> VerifyLicenseAsync(string license);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
namespace Bitwarden.Extensions.Hosting.Licensing;

/// <summary>
/// A set of reasons explaining why a license was invalid.
/// </summary>
public enum InvalidLicenseReason
{
/// <summary>
/// The given license was in an invalid format and could not be read further.
/// </summary>
InvalidFormat,

/// <summary>
/// The given license may have been valid previously but has expired and should no longer be used.
/// </summary>
Expired,

/// <summary>
/// The license is invalid for an unknown reason, checks logs for additional details.
/// </summary>
Unknown,
}

/// <summary>
/// The exception that is thrown when a license is invalid and cannot be verified.
/// </summary>
public class InvalidLicenseException : Exception
{
private const string DefaultMessage = "The license is invalid and cannot be trusted.";

/// <summary>
/// Initializes a new instance of <see cref="InvalidLicenseException"/>.
/// </summary>
/// <param name="reason"></param>
public InvalidLicenseException(InvalidLicenseReason reason)
: base(DefaultMessage)
{
Reason = reason;
}

/// <summary>
/// Initializes a new instance of <see cref="InvalidLicenseException"/>.
/// </summary>
/// <param name="reason"></param>
/// <param name="message"></param>
public InvalidLicenseException(InvalidLicenseReason reason, string? message)
: this(reason, message, null)
{ }

/// <summary>
/// Initializes a new instance of <see cref="InvalidLicenseException"/>.
/// </summary>
/// <param name="reason"></param>
/// <param name="message"></param>
/// <param name="innerException"></param>
public InvalidLicenseException(InvalidLicenseReason reason, string? message, Exception? innerException)
: base(message ?? DefaultMessage, innerException)
{
Reason = reason;
}

/// <summary>
/// The reason the license was found to be invalid.
/// </summary>
public InvalidLicenseReason Reason { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Security.Cryptography.X509Certificates;

namespace Bitwarden.Extensions.Hosting.Licensing;

/// <summary>
/// A set of options for customizing how licensing behaves.
/// </summary>
public sealed class LicensingOptions
{
/// <summary>
/// Options for configuring license retrieval from azure blob storage.
/// </summary>
public AzureBlobLicensingOptions AzureBlob { get; set; } = new AzureBlobLicensingOptions();

/// <summary>
/// The certificate that will be used to either sign or validate licenses.
/// </summary>
public X509Certificate2 SigningCertificate { get; set; } = null!;

/// <summary>
/// Development option to force the usage of self hosted organization even though a certificate
/// that can sign licenses it available.
/// </summary>
public bool ForceSelfHost { get; set; }
}

/// <summary>
/// A set of options for customizing how to find a certificate in Azure blob storage.
/// </summary>
public sealed class AzureBlobLicensingOptions
{
/// <summary>
/// The connection string to the azure blob storage account.
/// </summary>
public string? ConnectionString { get; set; }

/// <summary>
/// The password for the certificate stored in azure blob storage.
/// </summary>
public string? CertificatePassword { get; set; }

/// <summary>
/// The name of the blob the certificate is stored in, defaults to <c>certificates</c>.
/// </summary>
public string BlobName { get; set; } = "certificates";

/// <summary>
/// The name of the license stored in azure blob storage, defaults to <c>licensing.pfx</c>.
/// </summary>
public string LicenseName { get; set; } = "licensing.pfx";
}

/// <summary>
/// It's important that these can't be set through configuration, only through code.
/// </summary>
/// <remarks>
/// We would need to make this public for services to customize this.
/// </remarks>
internal sealed class InternalLicensingOptions
{
public string DevelopmentThumbprint { get; set; } = "207E64A231E8AA32AAF68A61037C075EBEBD553F";
public string NonDevelopmentThumbprint { get; set; } = "B34876439FCDA2846505B2EFBBA6C4A951313EBE";
}
Loading