Skip to content
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ jobs:
- name: Build and Test
run: ./Build.ps1
shell: pwsh
- name: Get package version
id: version
shell: pwsh
run: |
$pkg = Get-ChildItem ./artifacts -Filter "MediatR.*.nupkg" | Select-Object -First 1
$version = $pkg.BaseName -replace '^MediatR\.', ''
"version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
- name: Generate SBOM
shell: pwsh
run: |
dotnet tool install --global Microsoft.Sbom.DotNetTool
sbom-tool generate `
-b ./artifacts `
-bc ./src/MediatR `
-pn MediatR `
-pv ${{ steps.version.outputs.version }} `
-ps "Lucky Penny Software LLC" `
-nsb https://mediatr.io
- name: Push to MyGet
if: github.ref == 'refs/heads/main'
env:
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ jobs:
- name: Build and Test
run: ./Build.ps1
shell: pwsh
- name: Get package version
id: version
shell: pwsh
run: |
$pkg = Get-ChildItem ./artifacts -Filter "MediatR.*.nupkg" | Select-Object -First 1
$version = $pkg.BaseName -replace '^MediatR\.', ''
"version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
- name: Generate SBOM
shell: pwsh
run: |
dotnet tool install --global Microsoft.Sbom.DotNetTool
sbom-tool generate `
-b ./artifacts `
-bc ./src/MediatR `
-pn MediatR `
-pv ${{ steps.version.outputs.version }} `
-ps "Lucky Penny Software LLC" `
-nsb https://mediatr.io
- name: Sign packages
run: |-
foreach ($f in Get-ChildItem "./artifacts" -Filter "*.nupkg") {
Expand Down
27 changes: 27 additions & 0 deletions src/MediatR/Licensing/BuildInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System;
using System.Linq;
using System.Reflection;

namespace MediatR.Licensing;

internal static class BuildInfo
{
public static DateTimeOffset? BuildDate { get; } = GetBuildDate();

private static DateTimeOffset? GetBuildDate()
{
var assembly = typeof(BuildInfo).Assembly;

var buildDateAttribute = assembly
.GetCustomAttributes<AssemblyMetadataAttribute>()
.FirstOrDefault(a => a.Key == "BuildDateUtc");

if (buildDateAttribute?.Value != null &&
DateTimeOffset.TryParse(buildDateAttribute.Value, out var buildDate))
{
return buildDate;
}

return null;
}
}
8 changes: 6 additions & 2 deletions src/MediatR/Licensing/License.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,13 @@ public License(ClaimsPrincipal claims)
}

if (Enum.TryParse<ProductType>(claims.FindFirst("type")?.Value, out var productType))
{
{
ProductType = productType;
}

var perpetualValue = claims.FindFirst("perpetual")?.Value;
IsPerpetual = perpetualValue?.ToLowerInvariant() is "true" or "1";

IsConfigured = AccountId != null
&& CustomerId != null
&& SubscriptionId != null
Expand All @@ -59,6 +62,7 @@ public License(ClaimsPrincipal claims)
public DateTimeOffset? ExpirationDate { get; }
public Edition? Edition { get; }
public ProductType? ProductType { get; }

public bool IsPerpetual { get; }

public bool IsConfigured { get; }
}
38 changes: 35 additions & 3 deletions src/MediatR/Licensing/LicenseValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,17 @@ namespace MediatR.Licensing;
internal class LicenseValidator
{
private readonly ILogger _logger;
private readonly DateTimeOffset? _buildDate;

public LicenseValidator(ILoggerFactory loggerFactory)
=> _logger = loggerFactory.CreateLogger("LuckyPennySoftware.MediatR.License");
public LicenseValidator(ILoggerFactory loggerFactory) : this(loggerFactory, BuildInfo.BuildDate)
{
}

public LicenseValidator(ILoggerFactory loggerFactory, DateTimeOffset? buildDate)
{
_logger = loggerFactory.CreateLogger("LuckyPennySoftware.MediatR.License");
_buildDate = buildDate;
}

public void Validate(License license)
{
Expand All @@ -31,7 +39,31 @@ public void Validate(License license)
var diff = DateTime.UtcNow.Date.Subtract(license.ExpirationDate!.Value.Date).TotalDays;
if (diff > 0)
{
errors.Add($"Your license for the Lucky Penny software MediatR expired {diff} days ago.");
// If perpetual, check if build date is before expiration
if (license.IsPerpetual && _buildDate.HasValue)
{
var buildDateDiff = _buildDate.Value.Date.Subtract(license.ExpirationDate.Value.Date).TotalDays;
if (buildDateDiff <= 0)
{
_logger.LogInformation(
"Your license for the Lucky Penny software MediatR expired {expiredDaysAgo} days ago, but perpetual licensing is active because the build date ({buildDate:O}) is before the license expiration date ({licenseExpiration:O}).",
diff, _buildDate, license.ExpirationDate);
// Don't add to errors - perpetual fallback applies
}
else
{
errors.Add($"Your license for the Lucky Penny software MediatR expired {diff} days ago.");
}
}
else
{
if (license.IsPerpetual)
{
_logger.LogWarning(
"Your license for the Lucky Penny software MediatR has perpetual licensing enabled, but the build date could not be determined. Perpetual licensing cannot be applied. Please ensure the assembly metadata is correctly embedded at build time.");
}
errors.Add($"Your license for the Lucky Penny software MediatR expired {diff} days ago.");
}
}

if (license.ProductType!.Value != ProductType.MediatR
Expand Down
13 changes: 13 additions & 0 deletions src/MediatR/MediatR.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@
<TargetFrameworks>$(TargetFrameworks);net462</TargetFrameworks>
</PropertyGroup>

<Target Name="EmbedBuildDate" BeforeTargets="CoreCompile">
<Exec Command="git log -1 --format=%25cI" ConsoleToMSBuild="true" IgnoreExitCode="true">
<Output TaskParameter="ConsoleOutput" PropertyName="BuildDateUtc" />
</Exec>
<PropertyGroup>
<BuildDateUtc Condition="'$(BuildDateUtc)' == ''">$([System.DateTime]::UtcNow.ToString("O"))</BuildDateUtc>
</PropertyGroup>
<WriteLinesToFile File="$(IntermediateOutputPath)BuildDateGenerated.cs" Lines="[assembly: System.Reflection.AssemblyMetadata(&quot;BuildDateUtc&quot;, &quot;$(BuildDateUtc)&quot;)]" Overwrite="true" />
<ItemGroup>
<Compile Include="$(IntermediateOutputPath)BuildDateGenerated.cs" />
</ItemGroup>
</Target>

<ItemGroup>
<None Include="..\..\assets\logo\gradient_128x128.png" Pack="true" PackagePath="" />
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="" />
Expand Down
116 changes: 116 additions & 0 deletions test/MediatR.Tests/Licensing/LicenseValidatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,122 @@ public void Should_return_invalid_when_expired()
.ShouldContain(log => log.Level == LogLevel.Error);
}

[Fact]
public void Should_allow_perpetual_license_when_build_date_before_expiration()
{
var factory = new LoggerFactory();
var provider = new FakeLoggerProvider();
factory.AddProvider(provider);

var buildDate = DateTimeOffset.UtcNow.AddDays(-30);
var licenseValidator = new LicenseValidator(factory, buildDate);
var license = new License(
new Claim("account_id", Guid.NewGuid().ToString()),
new Claim("customer_id", Guid.NewGuid().ToString()),
new Claim("sub_id", Guid.NewGuid().ToString()),
new Claim("iat", DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds().ToString()),
new Claim("exp", DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeSeconds().ToString()),
new Claim("edition", nameof(Edition.Professional)),
new Claim("type", nameof(ProductType.MediatR)),
new Claim("perpetual", "true"));

license.IsConfigured.ShouldBeTrue();
license.IsPerpetual.ShouldBeTrue();

licenseValidator.Validate(license);

var logMessages = provider.Collector.GetSnapshot();
logMessages.ShouldNotContain(log => log.Level == LogLevel.Error);
logMessages.ShouldContain(log => log.Level == LogLevel.Information &&
log.Message.Contains("perpetual"));
}

[Fact]
public void Should_reject_perpetual_license_when_build_date_after_expiration()
{
var factory = new LoggerFactory();
var provider = new FakeLoggerProvider();
factory.AddProvider(provider);

var buildDate = DateTimeOffset.UtcNow.AddDays(-5); // Build date in past, after expiration
var licenseValidator = new LicenseValidator(factory, buildDate);
var license = new License(
new Claim("account_id", Guid.NewGuid().ToString()),
new Claim("customer_id", Guid.NewGuid().ToString()),
new Claim("sub_id", Guid.NewGuid().ToString()),
new Claim("iat", DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds().ToString()),
new Claim("exp", DateTimeOffset.UtcNow.AddDays(-10).ToUnixTimeSeconds().ToString()),
new Claim("edition", nameof(Edition.Professional)),
new Claim("type", nameof(ProductType.MediatR)),
new Claim("perpetual", "true"));

license.IsConfigured.ShouldBeTrue();
license.IsPerpetual.ShouldBeTrue();

licenseValidator.Validate(license);

var logMessages = provider.Collector.GetSnapshot();
logMessages.ShouldContain(log => log.Level == LogLevel.Error &&
log.Message.Contains("expired"));
}

[Fact]
public void Should_handle_missing_perpetual_claim()
{
var factory = new LoggerFactory();
var provider = new FakeLoggerProvider();
factory.AddProvider(provider);

var licenseValidator = new LicenseValidator(factory);
var license = new License(
new Claim("account_id", Guid.NewGuid().ToString()),
new Claim("customer_id", Guid.NewGuid().ToString()),
new Claim("sub_id", Guid.NewGuid().ToString()),
new Claim("iat", DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeSeconds().ToString()),
new Claim("exp", DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeSeconds().ToString()),
new Claim("edition", nameof(Edition.Community)),
new Claim("type", nameof(ProductType.Bundle)));

license.IsConfigured.ShouldBeTrue();
license.IsPerpetual.ShouldBeFalse();

licenseValidator.Validate(license);

var logMessages = provider.Collector.GetSnapshot();
logMessages.ShouldNotContain(log => log.Level == LogLevel.Error
|| log.Level == LogLevel.Warning
|| log.Level == LogLevel.Critical);
}

[Fact]
public void Should_fall_back_to_expiration_error_when_perpetual_and_build_date_is_null()
{
var factory = new LoggerFactory();
var provider = new FakeLoggerProvider();
factory.AddProvider(provider);

var licenseValidator = new LicenseValidator(factory, (DateTimeOffset?)null);
var license = new License(
new Claim("account_id", Guid.NewGuid().ToString()),
new Claim("customer_id", Guid.NewGuid().ToString()),
new Claim("sub_id", Guid.NewGuid().ToString()),
new Claim("iat", DateTimeOffset.UtcNow.AddDays(-10).ToUnixTimeSeconds().ToString()),
new Claim("exp", DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeSeconds().ToString()),
new Claim("edition", nameof(Edition.Community)),
new Claim("type", nameof(ProductType.Bundle)),
new Claim("perpetual", "true"));

license.IsConfigured.ShouldBeTrue();
license.IsPerpetual.ShouldBeTrue();

// Validator was created with null buildDate - perpetual licensing cannot be applied.
licenseValidator.Validate(license);

var logMessages = provider.Collector.GetSnapshot();
logMessages.ShouldContain(log => log.Level == LogLevel.Warning && log.Message.Contains("perpetual"));
logMessages.ShouldContain(log => log.Level == LogLevel.Error);
}

[Fact(Skip = "Needs license")]
public void Should_return_valid_for_actual_valid_license()
{
Expand Down