Skip to content

Commit 9ea4f49

Browse files
authored
Merge pull request #4667 from LuckyPennySoftware/license-grace-period
License validation: WARNING during the renewal grace period
2 parents f0ecba7 + 511dbf5 commit 9ea4f49

4 files changed

Lines changed: 152 additions & 11 deletions

File tree

‎docs/source/License-configuration.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,13 @@ No code change is required when using an environment variable—just register Au
4040
Licensing is enforced via log messages at various levels:
4141

4242
- INFO: Valid license message
43-
- WARNING: Missing license message
43+
- WARNING: Missing license message, or a license term that has ended but is still within its grace period
4444
- ERROR: Invalid/expired license message
4545

46+
### Renewal grace period
47+
48+
License keys include a 30-day grace period after the end of their term. When your subscription renews, you receive a new license key, and the previous key keeps logging only a WARNING (not an ERROR) for 30 days after its term ends. This gives you time to deploy the renewed key. Your current license key is always available on your [account page](https://luckypennysoftware.com/account).
49+
4650
There is no other license enforcement besides log messages. No central license server, no outbound HTTP calls, no degrading or disabling of features.
4751

4852
The log messages are logged using standard `Microsoft.Extensions.Logging` loggers under the category name `LuckyPennySoftware.AutoMapper.License`.

‎src/AutoMapper/Licensing/License.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ public License(ClaimsPrincipal claims)
3131
ExpirationDate = expiredAt;
3232
}
3333

34+
// The real end of the license term. Keys issued with a grace period carry it separately from
35+
// exp (term end + grace); older keys omit it and their exp is the term end.
36+
if (long.TryParse(claims.FindFirst("term_end")?.Value, out var termEnd))
37+
{
38+
TermEndDate = DateTimeOffset.FromUnixTimeSeconds(termEnd);
39+
}
40+
3441
if (Enum.TryParse<Edition>(claims.FindFirst("edition")?.Value, out var edition))
3542
{
3643
Edition = edition;
@@ -58,6 +65,7 @@ public License(ClaimsPrincipal claims)
5865
public string SubscriptionId { get; }
5966
public DateTimeOffset? StartDate { get; }
6067
public DateTimeOffset? ExpirationDate { get; }
68+
public DateTimeOffset? TermEndDate { get; }
6169
public Edition? Edition { get; }
6270
public ProductType? ProductType { get; }
6371
public bool IsPerpetual { get; }

‎src/AutoMapper/Licensing/LicenseValidator.cs‎

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ namespace AutoMapper.Licensing;
44

55
internal class LicenseValidator
66
{
7+
// Grace period for keys issued without a term_end claim, measured from their exp (their term end).
8+
internal const int DefaultGracePeriodDays = 30;
9+
710
private readonly ILogger _logger;
811
private readonly DateTimeOffset? _buildDate;
912

@@ -35,23 +38,48 @@ public void Validate(License license)
3538

3639
_logger.LogDebug("The Lucky Penny license key details: {license}", license);
3740

38-
var diff = DateTime.UtcNow.Date.Subtract(license.ExpirationDate.Value.Date).TotalDays;
41+
var today = DateTime.UtcNow.Date;
42+
// Keys without a term_end claim predate the grace period: their exp is the term end, so the grace
43+
// runs from there. Keys with one carry the grace in exp itself (term end + grace).
44+
var termEnd = (license.TermEndDate ?? license.ExpirationDate.Value).Date;
45+
var graceEnd = license.TermEndDate.HasValue
46+
? license.ExpirationDate.Value.Date
47+
: termEnd.AddDays(DefaultGracePeriodDays);
48+
string warning = null;
49+
50+
// Past the end of the term: within the grace period, a warning (the renewed key may not be
51+
// deployed yet); past it, an error.
52+
void Expired(double expiredDaysAgo)
53+
{
54+
if (today <= graceEnd)
55+
{
56+
warning = $"Your license term for the Lucky Penny software AutoMapper ended {expiredDaysAgo} days ago. " +
57+
$"Update your license key by {graceEnd:yyyy-MM-dd} ({graceEnd.Subtract(today).TotalDays} days remaining) to avoid license errors. " +
58+
"If you have renewed, your renewed license key is available at https://luckypennysoftware.com/account.";
59+
}
60+
else
61+
{
62+
errors.Add($"Your license for the Lucky Penny software AutoMapper expired {expiredDaysAgo} days ago.");
63+
}
64+
}
65+
66+
var diff = today.Subtract(termEnd).TotalDays;
3967
if (diff > 0)
4068
{
41-
// If perpetual, check if build date is before expiration
69+
// If perpetual, check if build date is before the end of the term
4270
if (license.IsPerpetual && _buildDate.HasValue)
4371
{
44-
var buildDateDiff = _buildDate.Value.Date.Subtract(license.ExpirationDate.Value.Date).TotalDays;
72+
var buildDateDiff = _buildDate.Value.Date.Subtract(termEnd).TotalDays;
4573
if (buildDateDiff <= 0)
4674
{
4775
_logger.LogInformation(
4876
"Your license for the Lucky Penny software AutoMapper expired {expiredDaysAgo} days ago, but perpetual licensing is active because the build date ({buildDate:O}) is before the license expiration date ({licenseExpiration:O}).",
49-
diff, _buildDate, license.ExpirationDate);
77+
diff, _buildDate, termEnd);
5078
// Don't add to errors - perpetual fallback applies
5179
}
5280
else
5381
{
54-
errors.Add($"Your license for the Lucky Penny software AutoMapper expired {diff} days ago.");
82+
Expired(diff);
5583
}
5684
}
5785
else
@@ -61,7 +89,7 @@ public void Validate(License license)
6189
_logger.LogWarning(
6290
"Your license for the Lucky Penny software AutoMapper has the perpetual flag set, but the build date could not be determined. Perpetual licensing is unavailable.");
6391
}
64-
errors.Add($"Your license for the Lucky Penny software AutoMapper expired {diff} days ago.");
92+
Expired(diff);
6593
}
6694
}
6795

@@ -81,12 +109,16 @@ public void Validate(License license)
81109
_logger.LogError(
82110
"Please visit https://luckypennysoftware.com to obtain a valid license for the Lucky Penny software AutoMapper.");
83111
}
112+
else if (warning != null)
113+
{
114+
_logger.LogWarning(warning);
115+
}
84116
else
85117
{
86118
_logger.LogInformation("You have a valid license key for the Lucky Penny software {type} {edition} edition. The license expires on {licenseExpiration}.",
87119
license.ProductType,
88120
license.Edition,
89-
license.ExpirationDate);
121+
termEnd);
90122
}
91123
}
92124
}

‎src/UnitTests/Licensing/LicenseValidatorTests.cs‎

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ public void Should_return_invalid_when_expired()
9797
new Claim("customer_id", Guid.NewGuid().ToString()),
9898
new Claim("sub_id", Guid.NewGuid().ToString()),
9999
new Claim("iat", DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds().ToString()),
100-
new Claim("exp", DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeSeconds().ToString()),
100+
new Claim("exp", DateTimeOffset.UtcNow.AddDays(-31).ToUnixTimeSeconds().ToString()),
101101
new Claim("edition", nameof(Edition.Professional)),
102102
new Claim("type", nameof(AutoMapper.Licensing.ProductType.AutoMapper)));
103103

@@ -155,7 +155,7 @@ public void Should_reject_perpetual_license_when_build_date_after_expiration()
155155
new Claim("customer_id", Guid.NewGuid().ToString()),
156156
new Claim("sub_id", Guid.NewGuid().ToString()),
157157
new Claim("iat", DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds().ToString()),
158-
new Claim("exp", DateTimeOffset.UtcNow.AddDays(-30).ToUnixTimeSeconds().ToString()),
158+
new Claim("exp", DateTimeOffset.UtcNow.AddDays(-31).ToUnixTimeSeconds().ToString()),
159159
new Claim("edition", nameof(Edition.Professional)),
160160
new Claim("type", nameof(AutoMapper.Licensing.ProductType.AutoMapper)),
161161
new Claim("perpetual", "true"));
@@ -183,7 +183,7 @@ public void Should_warn_and_error_when_perpetual_but_build_date_is_null()
183183
new Claim("customer_id", Guid.NewGuid().ToString()),
184184
new Claim("sub_id", Guid.NewGuid().ToString()),
185185
new Claim("iat", DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds().ToString()),
186-
new Claim("exp", DateTimeOffset.UtcNow.AddDays(-10).ToUnixTimeSeconds().ToString()),
186+
new Claim("exp", DateTimeOffset.UtcNow.AddDays(-40).ToUnixTimeSeconds().ToString()),
187187
new Claim("edition", nameof(Edition.Professional)),
188188
new Claim("type", nameof(AutoMapper.Licensing.ProductType.AutoMapper)),
189189
new Claim("perpetual", "true"));
@@ -225,4 +225,101 @@ public void Should_handle_missing_perpetual_claim()
225225
|| log.Level == LogLevel.Warning
226226
|| log.Level == LogLevel.Critical);
227227
}
228+
229+
private static License TermLicense(int termEndDaysFromNow, int? expDaysFromNow, bool perpetual = false)
230+
{
231+
var claims = new List<Claim>
232+
{
233+
new("account_id", Guid.NewGuid().ToString()),
234+
new("customer_id", Guid.NewGuid().ToString()),
235+
new("sub_id", Guid.NewGuid().ToString()),
236+
new("iat", DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds().ToString()),
237+
new("edition", nameof(Edition.Professional)),
238+
new("type", nameof(AutoMapper.Licensing.ProductType.AutoMapper)),
239+
};
240+
if (expDaysFromNow is { } exp)
241+
{
242+
// A key issued with a grace period: exp is the term end + grace, term_end the real term end.
243+
claims.Add(new("exp", DateTimeOffset.UtcNow.AddDays(exp).ToUnixTimeSeconds().ToString()));
244+
claims.Add(new("term_end", DateTimeOffset.UtcNow.AddDays(termEndDaysFromNow).ToUnixTimeSeconds().ToString()));
245+
}
246+
else
247+
{
248+
// A key issued before the grace period: exp is the term end, no term_end claim.
249+
claims.Add(new("exp", DateTimeOffset.UtcNow.AddDays(termEndDaysFromNow).ToUnixTimeSeconds().ToString()));
250+
}
251+
if (perpetual)
252+
claims.Add(new("perpetual", "true"));
253+
return new License(claims.ToArray());
254+
}
255+
256+
private static IReadOnlyList<FakeLogRecord> Validate(License license, DateTimeOffset? buildDate = null)
257+
{
258+
var factory = new LoggerFactory();
259+
var provider = new FakeLoggerProvider();
260+
factory.AddProvider(provider);
261+
262+
license.IsConfigured.ShouldBeTrue();
263+
new LicenseValidator(factory, buildDate).Validate(license);
264+
265+
return provider.Collector.GetSnapshot();
266+
}
267+
268+
[Fact]
269+
public void Should_warn_within_grace_period_after_term_end()
270+
{
271+
var logMessages = Validate(TermLicense(termEndDaysFromNow: -5, expDaysFromNow: 25));
272+
273+
logMessages.ShouldNotContain(log => log.Level == LogLevel.Error);
274+
logMessages.ShouldContain(log => log.Level == LogLevel.Warning && log.Message.Contains("ended 5 days ago"));
275+
}
276+
277+
[Fact]
278+
public void Should_error_after_grace_period()
279+
{
280+
var logMessages = Validate(TermLicense(termEndDaysFromNow: -31, expDaysFromNow: -1));
281+
282+
logMessages.ShouldContain(log => log.Level == LogLevel.Error && log.Message.Contains("expired 31 days ago"));
283+
}
284+
285+
[Fact]
286+
public void Should_be_valid_before_term_end()
287+
{
288+
var logMessages = Validate(TermLicense(termEndDaysFromNow: 10, expDaysFromNow: 40));
289+
290+
logMessages.ShouldNotContain(log => log.Level == LogLevel.Error || log.Level == LogLevel.Warning);
291+
logMessages.ShouldContain(log => log.Level == LogLevel.Information);
292+
}
293+
294+
[Fact]
295+
public void Should_warn_within_default_grace_period_for_keys_without_term_end()
296+
{
297+
var logMessages = Validate(TermLicense(termEndDaysFromNow: -10, expDaysFromNow: null));
298+
299+
logMessages.ShouldNotContain(log => log.Level == LogLevel.Error);
300+
logMessages.ShouldContain(log => log.Level == LogLevel.Warning && log.Message.Contains("ended 10 days ago"));
301+
}
302+
303+
[Fact]
304+
public void Should_use_term_end_for_perpetual_cutoff()
305+
{
306+
// Built during the grace period: after the term end, so perpetual fallback does not apply.
307+
var logMessages = Validate(
308+
TermLicense(termEndDaysFromNow: -40, expDaysFromNow: -10, perpetual: true),
309+
buildDate: DateTimeOffset.UtcNow.AddDays(-20));
310+
311+
logMessages.ShouldContain(log => log.Level == LogLevel.Error && log.Message.Contains("expired"));
312+
logMessages.ShouldNotContain(log => log.Message.Contains("perpetual licensing is active"));
313+
}
314+
315+
[Fact]
316+
public void Should_allow_perpetual_when_built_before_term_end()
317+
{
318+
var logMessages = Validate(
319+
TermLicense(termEndDaysFromNow: -40, expDaysFromNow: -10, perpetual: true),
320+
buildDate: DateTimeOffset.UtcNow.AddDays(-50));
321+
322+
logMessages.ShouldNotContain(log => log.Level == LogLevel.Error || log.Level == LogLevel.Warning);
323+
logMessages.ShouldContain(log => log.Level == LogLevel.Information && log.Message.Contains("perpetual"));
324+
}
228325
}

0 commit comments

Comments
 (0)