Merge pull request #2278 from DuendeSoftware/pg/bff-licensing

Log out number of Frontends
This commit is contained in:
Pieter Germishuys 2025-11-26 11:19:46 +01:00 committed by GitHub
commit b35a7fd236
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1367 additions and 858 deletions

View file

@ -28,7 +28,7 @@ namespace Duende.Bff.Blazor;
/// <summary>
/// This is a server-side AuthenticationStateProvider that uses
/// PersistentComponentState to flow the authentication state to the client which
/// is then used to initialize the authentication state in the WASM application.
/// is then used to initialize the authentication state in the WASM application.
/// </summary>
internal sealed class BffServerAuthenticationStateProvider : RevalidatingServerAuthenticationStateProvider, IDisposable
{
@ -68,16 +68,7 @@ internal sealed class BffServerAuthenticationStateProvider : RevalidatingServerA
AuthenticationStateChanged += OnAuthenticationStateChanged;
_subscription = _state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly);
CheckLicense(licenseValidator);
}
internal static void CheckLicense(LicenseValidator validator)
{
if (!validator.IsValid())
{
// todo: license enforcement
}
licenseValidator.CheckLicense();
}
private void OnAuthenticationStateChanged(Task<AuthenticationState> task) => _authenticationStateTask = task;

View file

@ -5,8 +5,8 @@
<AssemblyName>Duende.BFF</AssemblyName>
<Description>Backend for frontend (BFF) host for ASP.NET Core</Description>
<!-- Related to https://github.com/dotnet/sdk/issues/50676-->
<!-- <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>-->
<!-- <CompilerGeneratedFilesOutputPath>Otel/Generated</CompilerGeneratedFilesOutputPath>-->
<!-- <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>-->
<!-- <CompilerGeneratedFilesOutputPath>Otel/Generated</CompilerGeneratedFilesOutputPath>-->
</PropertyGroup>
<ItemGroup>
@ -25,8 +25,4 @@
<InternalsVisibleTo Include="Duende.Bff.Yarp"/>
<InternalsVisibleTo Include="Duende.Bff.Tests"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Duende.Private.Licensing"/>
</ItemGroup>
</Project>

View file

@ -5,6 +5,8 @@ using Duende.AccessTokenManagement.OpenIdConnect;
using Duende.Bff.AccessTokenManagement;
using Duende.Bff.Builder;
using Duende.Bff.Configuration;
using Duende.Bff.Diagnostics;
using Duende.Bff.Diagnostics.DiagnosticEntries;
using Duende.Bff.DynamicFrontends;
using Duende.Bff.DynamicFrontends.Internal;
using Duende.Bff.Endpoints;
@ -16,7 +18,6 @@ using Duende.Bff.SessionManagement.Configuration;
using Duende.Bff.SessionManagement.Revocation;
using Duende.Bff.SessionManagement.SessionStore;
using Duende.Bff.SessionManagement.TicketStore;
using Duende.Private.Licensing;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
@ -46,9 +47,14 @@ public static class BffBuilderExtensions
internal static T AddBaseBffServices<T>(this T builder) where T : IBffServicesBuilder
{
builder.Services.AddSingleton<GetLicenseKey>(sp => () => sp.GetRequiredService<IOptions<BffOptions>>().Value.LicenseKey);
builder.Services.AddSingleton<LicenseAccessor<BffLicense>>();
builder.Services.AddSingleton<BffLicense>(sp => sp.GetRequiredService<LicenseAccessor<BffLicense>>().Current);
builder.Services.AddSingleton<GetLicenseKey>(sp =>
() => sp.GetRequiredService<IOptions<BffOptions>>().Value.LicenseKey);
builder.Services.AddSingleton<License>(sp =>
{
var accessor = sp.GetRequiredService<LicenseAccessor>();
return accessor.Current;
});
builder.Services.AddSingleton<LicenseAccessor>();
builder.Services.TryAddSingleton<LicenseValidator>();
builder.Services.AddDistributedMemoryCache();
@ -59,7 +65,8 @@ public static class BffBuilderExtensions
builder.Services.AddSingleton<IConfigureOptions<OpenIdConnectOptions>, BffConfigureOpenIdConnectOptions>();
builder.Services.AddOpenIdConnectAccessTokenManagement();
builder.Services.AddSingleton<IConfigureOptions<UserTokenManagementOptions>, ConfigureUserTokenManagementOptions>();
builder.Services
.AddSingleton<IConfigureOptions<UserTokenManagementOptions>, ConfigureUserTokenManagementOptions>();
builder.Services.AddTransient<IReturnUrlValidator, LocalUrlReturnUrlValidator>();
builder.Services.TryAddSingleton<IAccessTokenRetriever, DefaultAccessTokenRetriever>();
@ -79,12 +86,16 @@ public static class BffBuilderExtensions
builder.Services.TryAddTransient<ISessionRevocationService, NopSessionRevocationService>();
// cookie configuration
builder.Services.AddSingleton<IPostConfigureOptions<CookieAuthenticationOptions>, PostConfigureSlidingExpirationCheck>();
builder.Services.AddSingleton<IPostConfigureOptions<CookieAuthenticationOptions>, PostConfigureApplicationCookieRevokeRefreshToken>();
builder.Services
.AddSingleton<IPostConfigureOptions<CookieAuthenticationOptions>, PostConfigureSlidingExpirationCheck>();
builder.Services
.AddSingleton<IPostConfigureOptions<CookieAuthenticationOptions>,
PostConfigureApplicationCookieRevokeRefreshToken>();
builder.Services.AddSingleton<ActiveCookieAuthenticationScheme>();
builder.Services.AddSingleton<ActiveOpenIdConnectAuthenticationScheme>();
builder.Services.AddSingleton<IPostConfigureOptions<OpenIdConnectOptions>, PostConfigureOidcOptionsForSilentLogin>();
builder.Services
.AddSingleton<IPostConfigureOptions<OpenIdConnectOptions>, PostConfigureOidcOptionsForSilentLogin>();
AddBffMetrics(builder);
@ -94,14 +105,30 @@ public static class BffBuilderExtensions
// Make sure the session partitioning is registered. There are a few codepaths that require this injected
// even if you are not using session management.
builder.Services.AddSingleton<BuildUserSessionPartitionKey>(sp => sp.GetRequiredService<UserSessionPartitionKeyBuilder>().BuildPartitionKey);
builder.Services.AddSingleton<BuildUserSessionPartitionKey>(sp =>
sp.GetRequiredService<UserSessionPartitionKeyBuilder>().BuildPartitionKey);
builder.Services.AddSingleton<UserSessionPartitionKeyBuilder>();
return builder;
}
internal static void AddBffMetrics<T>(T builder) where T : IBffBuilder => builder.Services.AddSingleton<BffMetrics>();
internal static void AddBffMetrics<T>(T builder) where T : IBffBuilder =>
builder.Services.AddSingleton<BffMetrics>();
internal static T AddDiagnostics<T>(this T builder)
where T : IBffServicesBuilder
{
builder.Services.AddSingleton<IDiagnosticEntry, BasicServerInfoDiagnosticEntry>();
builder.Services.AddSingleton<IDiagnosticEntry, AssemblyInfoDiagnosticEntry>();
builder.Services.AddSingleton<IDiagnosticEntry, FrontendCountDiagnosticEntry>();
builder.Services.AddSingleton<DiagnosticSummary>();
builder.Services.AddSingleton(serviceProvider => new DiagnosticDataService(
serviceProvider.GetRequiredService<TimeProvider>().GetUtcNow().UtcDateTime,
serviceProvider.GetServices<IDiagnosticEntry>()));
builder.Services.AddHostedService<DiagnosticHostedService>();
return builder;
}
internal static T AddDynamicFrontends<T>(this T builder)
where T : IBffServicesBuilder
@ -125,7 +152,8 @@ public static class BffBuilderExtensions
// Configure the AspNet Core Authentication settings if no
// .AddAuthentication().AddCookie().AddOpenIdConnect() was added
builder.Services.AddSingleton<IPostConfigureOptions<AuthenticationOptions>, BffConfigureAuthenticationOptions>();
builder.Services
.AddSingleton<IPostConfigureOptions<AuthenticationOptions>, BffConfigureAuthenticationOptions>();
builder.Services.AddSingleton<IConfigureOptions<CookieAuthenticationOptions>, BffConfigureCookieOptions>();
@ -133,8 +161,10 @@ public static class BffBuilderExtensions
// Add 'default' configure methods that would have been added by
// .AddAuthentication().AddCookie().AddOpenIdConnect()
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<OpenIdConnectOptions>, OpenIdConnectPostConfigureOptions>());
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<CookieAuthenticationOptions>, PostConfigureCookieAuthenticationOptions>());
builder.Services.TryAddEnumerable(ServiceDescriptor
.Singleton<IPostConfigureOptions<OpenIdConnectOptions>, OpenIdConnectPostConfigureOptions>());
builder.Services.TryAddEnumerable(ServiceDescriptor
.Singleton<IPostConfigureOptions<CookieAuthenticationOptions>, PostConfigureCookieAuthenticationOptions>());
builder.Services.TryAddSingleton<IStaticFilesClient, StaticFilesHttpClient>();
@ -180,11 +210,14 @@ public static class BffBuilderExtensions
internal static void AddServerSideSessionsSupportingServices(this IServiceCollection services)
{
services.AddSingleton<BuildUserSessionPartitionKey>(sp => sp.GetRequiredService<UserSessionPartitionKeyBuilder>().BuildPartitionKey);
services.AddSingleton<BuildUserSessionPartitionKey>(sp =>
sp.GetRequiredService<UserSessionPartitionKeyBuilder>().BuildPartitionKey);
services.AddSingleton<UserSessionPartitionKeyBuilder>();
services.AddSingleton<UserSessionPartitionKeyBuilder>();
services.AddSingleton<IPostConfigureOptions<CookieAuthenticationOptions>, PostConfigureApplicationCookieTicketStore>();
services
.AddSingleton<IPostConfigureOptions<CookieAuthenticationOptions>,
PostConfigureApplicationCookieTicketStore>();
services.AddTransient<IServerTicketStore, ServerSideTicketStore>();
services.AddTransient<ISessionRevocationService, SessionRevocationService>();
// only add if not already in DI

View file

@ -191,22 +191,16 @@ public static class BffEndpointRouteBuilderExtensions
internal static bool AlreadyMappedManagementEndpoint(
this IEndpointRouteBuilder endpoints,
PathString route) => endpoints.DataSources.Any(ds =>
ds.Endpoints
.OfType<RouteEndpoint>()
.Any(e =>
e.RoutePattern.RawText == route.Value));
ds.Endpoints
.OfType<RouteEndpoint>()
.Any(e =>
e.RoutePattern.RawText == route.Value));
internal static void CheckLicense(this IEndpointRouteBuilder endpoints) => endpoints.ServiceProvider.CheckLicense();
internal static void CheckLicense(this IServiceProvider serviceProvider)
{
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var options = serviceProvider.GetRequiredService<IOptions<BffOptions>>().Value;
var license = serviceProvider.GetRequiredService<LicenseValidator>();
if (!license.IsValid())
{
// Todo, enforce license validation
}
license.CheckLicense();
}
}

View file

@ -179,4 +179,9 @@ public sealed class BffOptions
/// </summary>
public Collection<string> AllowedSilentLoginReferers { get; } = new();
/// <summary>
/// Options for diagnostics logging
/// </summary>
public DiagnosticsOptions Diagnostics { get; set; } = new();
}

View file

@ -0,0 +1,23 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.Bff.Configuration;
/// <summary>
/// Options that control the way that diagnostic data is logged.
/// </summary>
public sealed class DiagnosticsOptions
{
/// <summary>
/// Frequency at which diagnostic summaries are logged.
/// Defaults to 1 hour.
/// </summary>
public TimeSpan LogFrequency { get; set; } = TimeSpan.FromHours(1);
/// <summary>
/// Max size of diagnostic data log message chunks in kilobytes.
/// Defaults to 8160 bytes. 8 KB is a conservative limit for the max size of a log message that is imposed by
/// some logging tools. We take 32 bytes less than that to allow for additional formatting of the log message.
/// </summary>
public int ChunkSize { get; set; } = 1024 * 8 - 32;
}

View file

@ -0,0 +1,6 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.Bff.Diagnostics;
internal record DiagnosticContext(DateTime ServerStartTime, DateTime CurrentServerTime);

View file

@ -0,0 +1,30 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Buffers;
using System.Text.Json;
namespace Duende.Bff.Diagnostics;
internal class DiagnosticDataService(DateTime serverStartTime, IEnumerable<IDiagnosticEntry> entries)
{
public async Task<ReadOnlyMemory<byte>> GetJsonBytesAsync(CancellationToken cancellationToken = default)
{
var bufferWriter = new ArrayBufferWriter<byte>();
await using var writer = new Utf8JsonWriter(bufferWriter, new JsonWriterOptions { Indented = false });
writer.WriteStartObject();
var diagnosticContext = new DiagnosticContext(serverStartTime, DateTime.UtcNow);
foreach (var diagnosticEntry in entries)
{
diagnosticEntry.Write(diagnosticContext, writer);
}
writer.WriteEndObject();
await writer.FlushAsync(cancellationToken);
return bufferWriter.WrittenMemory;
}
}

View file

@ -0,0 +1,63 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Reflection;
using System.Runtime.InteropServices;
using System.Runtime.Loader;
using System.Text.Json;
namespace Duende.Bff.Diagnostics.DiagnosticEntries;
internal class AssemblyInfoDiagnosticEntry : IDiagnosticEntry
{
private readonly IReadOnlyList<string> _exactMatches =
[
"Microsoft.AspNetCore"
];
private readonly IReadOnlyList<string> _startsWithMatches =
[
"Duende.",
"Microsoft.AspNetCore.Components.",
"Microsoft.AspNetCore.Authentication.",
"Microsoft.IdentityModel.",
"System.IdentityModel.",
"System.IdentityModel",
"Microsoft.EntityFrameworkCore",
];
public void Write(DiagnosticContext _, Utf8JsonWriter writer)
{
var assemblies = GetAssemblyInfo();
writer.WriteStartObject("AssemblyInfo");
writer.WriteString("DotNetVersion", RuntimeInformation.FrameworkDescription);
writer.WriteString("BFF",
typeof(DiagnosticHostedService).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!
.InformationalVersion);
writer.WriteStartArray("Assemblies");
foreach (var assembly in assemblies.Where(assembly => assembly.GetName().Name != null &&
(_exactMatches.Contains(assembly.GetName().Name) ||
_startsWithMatches.Any(prefix =>
assembly.GetName().Name!.StartsWith(prefix,
StringComparison.Ordinal)))))
{
writer.WriteStartObject();
writer.WriteString("Name", assembly.GetName().Name);
writer.WriteString("Version", assembly.GetName().Version?.ToString() ?? "Unknown");
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
private static List<Assembly> GetAssemblyInfo()
{
var assemblies = AssemblyLoadContext.Default.Assemblies
.OrderBy(a => a.FullName)
.ToList();
return assemblies;
}
}

View file

@ -0,0 +1,22 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Net;
using System.Text.Json;
namespace Duende.Bff.Diagnostics.DiagnosticEntries;
internal class BasicServerInfoDiagnosticEntry(TimeProvider timeProvider)
: IDiagnosticEntry
{
public void Write(DiagnosticContext context, Utf8JsonWriter writer)
{
writer.WriteStartObject("BasicServerInfo");
writer.WriteString("HostName", Dns.GetHostName());
writer.WriteString("ServerStartTime", context.ServerStartTime.ToString("o"));
writer.WriteString("CurrentServerTime", timeProvider.GetUtcNow().UtcDateTime.ToString("o"));
writer.WriteEndObject();
}
}

View file

@ -0,0 +1,14 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Text.Json;
using Duende.Bff.DynamicFrontends;
namespace Duende.Bff.Diagnostics.DiagnosticEntries;
internal class FrontendCountDiagnosticEntry(IFrontendCollection frontendCollection)
: IDiagnosticEntry
{
public void Write(DiagnosticContext context, Utf8JsonWriter writer) =>
writer.WriteNumber("FrontendCount", frontendCollection.Count);
}

View file

@ -0,0 +1,49 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.Bff.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Duende.Bff.Diagnostics;
internal class DiagnosticHostedService(
IOptions<BffOptions> options,
DiagnosticSummary diagnosticsSummary,
ILogger<DiagnosticHostedService> logger,
TimeProvider timeProvider) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(options.Value.Diagnostics.LogFrequency, timeProvider);
try
{
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await diagnosticsSummary.PrintSummaryAsync(stoppingToken);
}
#pragma warning disable CA1031
// Catching general exceptions here to prevent the host from crashing.
catch (Exception ex)
#pragma warning restore CA1031
{
logger.FailedToLogDiagnosticsSummary(ex.Message);
}
}
}
catch (OperationCanceledException)
{
// When stopping this hosted service, "await timer.WaitForNextTickAsync(stoppingToken)" can throw an OperationCanceledException.
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
await diagnosticsSummary.PrintSummaryAsync(cancellationToken);
await base.StopAsync(cancellationToken);
}
}

View file

@ -0,0 +1,41 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Text;
using Duende.Bff.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Duende.Bff.Diagnostics;
internal class DiagnosticSummary(
DiagnosticDataService diagnosticDataService,
IOptions<BffOptions> options,
ILoggerFactory loggerFactory)
{
private readonly ILogger _logger = loggerFactory.CreateLogger("Duende.BFF.Diagnostics.Summary");
public async Task PrintSummaryAsync(CancellationToken cancellationToken = default)
{
var bffOptions = options.Value;
var jsonMemory = await diagnosticDataService.GetJsonBytesAsync(cancellationToken);
var span = jsonMemory.Span;
var chunkSize = bffOptions.Diagnostics.ChunkSize;
if (span.Length > chunkSize)
{
var totalChunks = (span.Length + bffOptions.Diagnostics.ChunkSize - 1) / chunkSize;
for (var i = 0; i < totalChunks; i++)
{
var offset = i * chunkSize;
var length = Math.Min(chunkSize, span.Length - offset);
var chunk = span.Slice(offset, length);
_logger.DiagnosticSummaryLogged(i + 1, totalChunks, Encoding.UTF8.GetString(chunk));
}
}
else
{
_logger.DiagnosticSummaryLogged(1, 1, Encoding.UTF8.GetString(span));
}
}
}

View file

@ -0,0 +1,16 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
// using Microsoft.Extensions.Logging;
//
// namespace Duende.Bff.Diagnostics;
//
// internal static partial class DiagnosticsLog
// {
// [LoggerMessage(1, LogLevel.Information, "Diagnostic summary ({current}/{total}): {diagnosticData}")]
// internal static partial void DiagnosticSummaryLogged(this ILogger logger, int current, int total,
// string diagnosticData);
//
// [LoggerMessage(2, LogLevel.Warning, "An error occurred while logging the diagnostic summary: {Message}")]
// internal static partial void FailedToLogDiagnosticsSummary(this ILogger logger, string message);
// }

View file

@ -0,0 +1,11 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Text.Json;
namespace Duende.Bff.Diagnostics;
internal interface IDiagnosticEntry
{
public void Write(DiagnosticContext context, Utf8JsonWriter writer);
}

View file

@ -61,13 +61,6 @@ internal class FrontendCollection : IDisposable, IFrontendCollection
.Where(frontend => oldFrontends.All(x => x.Name != frontend.Name))
.ToArray();
var totalFrontends = oldFrontends.Length - removedFrontends.Length;
foreach (var frontend in addedFrontends)
{
_licenseValidator.LogFrontendAdded(frontend.Name, ++totalFrontends);
}
Interlocked.Exchange(ref _frontends, newFrontends);
}
@ -160,7 +153,7 @@ internal class FrontendCollection : IDisposable, IFrontendCollection
public void AddOrUpdate(BffFrontend frontend)
{
var existingUpdated = false;
// Lock to avoid dirty writes from multiple threads.
// Lock to avoid dirty writes from multiple threads.
lock (_syncRoot)
{
var existing = _frontends.FirstOrDefault(x => x.Name == frontend.Name);
@ -188,7 +181,6 @@ internal class FrontendCollection : IDisposable, IFrontendCollection
}
else
{
_licenseValidator.LogFrontendAdded(frontend.Name, _frontends.Length);
OnFrontendAdded(frontend);
}

View file

@ -0,0 +1,6 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.Bff.Licensing;
internal delegate string? GetLicenseKey();

View file

@ -0,0 +1,72 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
namespace Duende.Bff.Licensing;
/// <summary>
/// Models a Duende commercial license.
/// </summary>
internal class License
{
/// <summary>
/// Initializes the license from the claims in a key.
/// </summary>
internal License(ClaimsPrincipal claims)
{
if (int.TryParse(claims.FindFirst(LicenseClaimTypes.Id)?.Value, out var id))
{
SerialNumber = id;
}
CompanyName = claims.FindFirst(LicenseClaimTypes.CompanyName)?.Value;
ContactInfo = claims.FindFirst(LicenseClaimTypes.ContactInfo)?.Value;
if (long.TryParse(claims.FindFirst(LicenseClaimTypes.Expiration)?.Value, out var exp))
{
Expiration = DateTimeOffset.FromUnixTimeSeconds(exp);
}
// IsConfigured needs to be set prior to checking for clients and issuers claims or the Redistribution check will not return an appropriate value
IsConfigured = true;
}
/// <summary>
/// The serial number
/// </summary>
public int? SerialNumber { get; init; }
/// <summary>
/// The company name
/// </summary>
public string? CompanyName { get; init; }
/// <summary>
/// The company contact info
/// </summary>
public string? ContactInfo { get; init; }
/// <summary>
/// The license expiration
/// </summary>
public DateTimeOffset? Expiration { get; init; }
/// <summary>
/// Extras
/// </summary>
public string? Extras { get; init; }
/// <summary>
/// True if the license was configured in options or from a file, and false otherwise.
/// </summary>
[MemberNotNullWhen(true,
nameof(SerialNumber),
nameof(CompanyName),
nameof(ContactInfo),
nameof(Expiration),
nameof(Extras))
]
public bool IsConfigured { get; init; }
}

View file

@ -0,0 +1,101 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
namespace Duende.Bff.Licensing;
/// <summary>
/// Loads the license from configuration or a file, and validates its contents.
/// </summary>
internal class LicenseAccessor(GetLicenseKey getLicenseKey, ILogger<LicenseAccessor> logger)
{
private static readonly string[] LicenseFileNames =
[
"Duende_License.key",
"Duende_IdentityServer_License.key",
];
private License? _license;
private readonly object _lock = new();
public License Current => _license ??= Initialize();
private License Initialize()
{
lock (_lock)
{
if (_license != null)
{
return _license;
}
var key = getLicenseKey() ?? LoadLicenseKeyFromFile();
if (key == null)
{
return new License(new ClaimsPrincipal(new ClaimsIdentity()))
{
IsConfigured = false
};
}
var licenseClaims = ValidateKey(key);
return new License(new ClaimsPrincipal(new ClaimsIdentity(licenseClaims)));
}
}
private static string? LoadLicenseKeyFromFile()
{
foreach (var name in LicenseFileNames)
{
var path = Path.Combine(Directory.GetCurrentDirectory(), name);
if (File.Exists(path))
{
return File.ReadAllText(path).Trim();
}
}
return null;
}
private Claim[] ValidateKey(string licenseKey)
{
var handler = new JsonWebTokenHandler();
var rsa = new RSAParameters
{
Exponent = Convert.FromBase64String("AQAB"),
Modulus = Convert.FromBase64String(
"tAHAfvtmGBng322TqUXF/Aar7726jFELj73lywuCvpGsh3JTpImuoSYsJxy5GZCRF7ppIIbsJBmWwSiesYfxWxBsfnpOmAHU3OTMDt383mf0USdqq/F0yFxBL9IQuDdvhlPfFcTrWEL0U2JsAzUjt9AfsPHNQbiEkOXlIwtNkqMP2naynW8y4WbaGG1n2NohyN6nfNb42KoNSR83nlbBJSwcc3heE3muTt3ZvbpguanyfFXeoP6yyqatnymWp/C0aQBEI5kDahOU641aDiSagG7zX1WaF9+hwfWCbkMDKYxeSWUkQOUOdfUQ89CQS5wrLpcU0D0xf7/SrRdY2TRHvQ=="),
};
var key = new RsaSecurityKey(rsa)
{
KeyId = "IdentityServerLicensekey/7ceadbb78130469e8806891025414f16"
};
var parms = new TokenValidationParameters
{
ValidIssuer = "https://duendesoftware.com",
ValidAudience = "IdentityServer",
IssuerSigningKey = key,
#pragma warning disable CA5404 // CA5404: Do not use ValidateLifetime in TokenValidationParameters
// We're validating the lifetime somewhere else.
ValidateLifetime = false
#pragma warning restore CA5404
};
var validateResult = handler.ValidateTokenAsync(licenseKey, parms).Result;
if (!validateResult.IsValid)
{
logger.ErrorValidatingLicenseKey(LogLevel.Error, validateResult.Exception);
}
return validateResult.ClaimsIdentity?.Claims.ToArray() ?? [];
}
}

View file

@ -0,0 +1,12 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.Bff.Licensing;
internal static class LicenseClaimTypes
{
public const string Id = "id";
public const string CompanyName = "company_name";
public const string ContactInfo = "contact_info";
public const string Expiration = "exp";
}

View file

@ -1,38 +1,33 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Globalization;
using System.Security.Claims;
using Duende.Bff.DynamicFrontends;
using Duende.Private.Licensing;
using Microsoft.Extensions.Logging;
namespace Duende.Bff.Licensing;
internal class LicenseValidator(ILogger<LicenseValidator> logger, BffLicense license, TimeProvider timeProvider)
internal class LicenseValidator(ILogger<LicenseValidator> logger, License license, TimeProvider timeProvider)
{
internal LicenseValidator(ILogger<LicenseValidator> logger, ClaimsPrincipal claims, TimeProvider timeProvider)
: this(logger, new BffLicense(claims), timeProvider)
: this(logger, new License(claims), timeProvider)
{
}
private bool? _licenseCheckResult;
public bool IsValid()
public bool CheckLicense()
{
if (_licenseCheckResult != null)
{
return _licenseCheckResult.Value;
}
_licenseCheckResult = CheckLicense();
return _licenseCheckResult.Value;
_licenseCheckResult = CheckLicenseValidity();
return _licenseCheckResult.Value;
}
private bool CheckLicense()
private bool CheckLicenseValidity()
{
if (!license.IsConfigured)
{
logger.NoValidLicense(LogLevel.Error);
@ -41,55 +36,11 @@ internal class LicenseValidator(ILogger<LicenseValidator> logger, BffLicense lic
if (license.Expiration <= timeProvider.GetUtcNow())
{
logger.LicenseHasExpired(LogLevel.Error, license.Expiration, license.ContactInfo, license.CompanyName);
logger.LicenseHasExpired(LogLevel.Error, license.Expiration, license.ContactInfo ?? "",
license.CompanyName ?? "");
return false;
}
if (!license.BffFeature)
{
logger.NotLicensedForBff(LogLevel.Error, license.ContactInfo, license.CompanyName);
return false;
}
logger.LicenseDetails(
LogLevel.Debug,
license.Edition.ToString(),
license.Expiration,
license.ContactInfo,
license.CompanyName,
license.FrontendLimit switch
{
null => "not licensed for multi-frontend feature",
0 => "not licensed for multi-frontend feature",
-1 => "unlimited",
> 0 => license.FrontendLimit.Value.ToString(CultureInfo.InvariantCulture),
// Should't happen, but just in case
_ => "not licensed for multi-frontend feature"
});
return true;
}
public void LogFrontendAdded(BffFrontendName frontendName, int frontendCount)
{
if (license?.FrontendLimit == null)
{
logger.NotLicensedForMultiFrontend(LogLevel.Error, frontendName);
return;
}
if (license.FrontendLimit == -1)
{
// unlimited frontends
logger.UnlimitedFrontends(LogLevel.Debug, frontendName, frontendCount);
return;
}
if (license.FrontendLimit < frontendCount)
{
logger.FrontendLimitExceeded(LogLevel.Error, frontendName, frontendCount, license.FrontendLimit.Value);
return;
}
logger.FrontendAdded(LogLevel.Debug, frontendName, frontendCount, license.FrontendLimit.Value);
}
}

View file

@ -2,9 +2,9 @@
// See LICENSE in the project root for license information.
// using Microsoft.Extensions.Logging;
//
// namespace Duende.Bff.Licensing;
//
// internal static partial class LicensingLogMessages
// {
// [LoggerMessage(
@ -19,9 +19,9 @@
// public static partial void LicenseDetails(this ILogger logger, LogLevel level, string? edition, DateTimeOffset? expirationDate, string licenseContact, string licenseCompany, string? numberOfFrontends);
//
// [LoggerMessage(
// Message = """
// Your license for Duende BFF Security Framework has expired on {ExpirationDate}.
// Please contact {licenseContact} from {licenseCompany} to obtain a valid license for the Duende software,
// Message = $$"""
// Your license for the Duende Software has expired on {ExpirationDate}.
// Please contact {licenseContact} from {licenseCompany} to obtain a valid license for Duende software,
// or start a conversation with us: https://duende.link/l/bff/contact
//
// See https://duende.link/l/bff/expired for more information.
@ -31,12 +31,10 @@
//
// [LoggerMessage(
// message: """
// You do not have a valid license key for the Duende BFF Security Framework.
// When unlicensed, BFF will run in trial mode. It will limit the number of active sessions to 5.
// You do not have a valid license key for the Duende software.
// This is allowed for development and testing scenarios.
// If you are running in production you are required to have a licensed version.
// Please start a conversation with us: https://duende.link/l/bff/contact
//
// See https://duende.link/l/bff/trial for more information.
// Please start a conversation with us: https://duende.link/l/contact"
// """)]
// public static partial void NoValidLicense(this ILogger logger, LogLevel logLevel);
//

View file

@ -7,7 +7,7 @@
// using Duende.Bff.SessionManagement.SessionStore;
// using Microsoft.AspNetCore.Http;
// using Microsoft.Extensions.Logging;
//
// namespace Duende.Bff.Otel;
//
// internal static partial class LogMessages

View file

@ -28,7 +28,8 @@ public static class ServiceCollectionExtensions
return builder
.AddBaseBffServices()
.AddDynamicFrontends();
.AddDynamicFrontends()
.AddDiagnostics();
}
}

View file

@ -7,6 +7,7 @@ using Duende.Bff.AccessTokenManagement;
using Duende.Bff.Blazor;
using Duende.Bff.Blazor.Client;
using Duende.Bff.Blazor.Client.Internals;
using Duende.Bff.Diagnostics;
using Duende.Bff.DynamicFrontends.Internal;
using Duende.Bff.Endpoints.Internal;
using Duende.Bff.EntityFramework;
@ -25,13 +26,14 @@ public class ConventionTests(ITestOutputHelper output)
public static readonly Assembly BffBlazorClientAssembly = typeof(BffBlazorClientOptions).Assembly;
public static readonly Assembly BffEntityFrameworkAssembly = typeof(UserSessionEntity).Assembly;
public static readonly Assembly BffYarpAssembly = typeof(BffYarpTransformBuilder).Assembly;
public static readonly Type[] AllTypes =
BffAssembly.GetTypes()
.Union(BffBlazorAssembly.GetTypes())
.Union(BffBlazorClientAssembly.GetTypes())
.Union(BffEntityFrameworkAssembly.GetTypes())
.Union(BffYarpAssembly.GetTypes())
.ToArray();
.ToArray();
[Fact]
public void All_strongly_typed_strings_Have_private_value()
@ -66,7 +68,8 @@ public class ConventionTests(ITestOutputHelper output)
{
var buildMethod = type.GetMethods(BindingFlags.Static)
.FirstOrDefault(m => m.Name == "Create");
buildMethod.ShouldBeNull("The IStonglyTypedString defines a Create method, but it should be implemented explicitly on the interface, not on the type. \r\n IE: " +
buildMethod.ShouldBeNull(
"The IStonglyTypedString defines a Create method, but it should be implemented explicitly on the interface, not on the type. \r\n IE: " +
" static AccessTokenString IStonglyTypedString<AccessTokenString>.Create(string result) => new(result);");
}
}
@ -83,9 +86,11 @@ public class ConventionTests(ITestOutputHelper output)
// Try to invoke the constructor with a value and expect an exception
var ex = Should.Throw<TargetInvocationException>(() => ctor.Invoke([]));
ex.InnerException.ShouldBeOfType<InvalidOperationException>().Message.ShouldContain("Can't create null value");
ex.InnerException.ShouldBeOfType<InvalidOperationException>().Message
.ShouldContain("Can't create null value");
}
}
[Fact]
public void All_strongly_typed_strings_should_have_only_expected_constructors()
{
@ -96,7 +101,8 @@ public class ConventionTests(ITestOutputHelper output)
var ctors = type.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
// There must be exactly two constructors
ctors.Length.ShouldBe(2, $"{type.Name} should have exactly two constructors: one public parameterless and one private with a single string parameter.");
ctors.Length.ShouldBe(2,
$"{type.Name} should have exactly two constructors: one public parameterless and one private with a single string parameter.");
// Find the public parameterless constructor
var publicParameterlessCtor = ctors.FirstOrDefault(c =>
@ -111,7 +117,8 @@ public class ConventionTests(ITestOutputHelper output)
c.GetParameters().Length == 1 &&
c.GetParameters()[0].ParameterType == typeof(string));
privateStringCtor.ShouldNotBeNull($"{type.Name} should have a private constructor with a single string parameter.");
privateStringCtor.ShouldNotBeNull(
$"{type.Name} should have a private constructor with a single string parameter.");
}
}
@ -135,13 +142,15 @@ public class ConventionTests(ITestOutputHelper output)
[Fact()]
public void All_types_not_in_Internal_namespace_should_be_sealed_or_static()
{
Type[] exclusions = [
Type[] exclusions =
[
typeof(SessionDbContext),
typeof(SessionDbContext<>),
typeof(UserSessionEntity),
typeof(UserSession),
typeof(UserSessionUpdate),
typeof(AccessTokenRetrievalError)];
typeof(AccessTokenRetrievalError)
];
// Find all types NOT in a '.Internal' namespace
var nonInternalTypes = AllTypes
@ -169,7 +178,8 @@ public class ConventionTests(ITestOutputHelper output)
public void All_async_methods_should_end_with_Async_and_have_cancellation_token_as_last_parameter()
{
var failures = new List<string>();
Type[] exclusions = [
Type[] exclusions =
[
typeof(BffAuthenticationSchemeProvider),
typeof(BffOpenIdConnectEvents),
typeof(BffAuthenticationService),
@ -182,8 +192,8 @@ public class ConventionTests(ITestOutputHelper output)
typeof(BffCacheClearingHostedService),
typeof(SessionDbContext),
typeof(SessionDbContext<>),
typeof(DiagnosticHostedService),
typeof(ServerSideTokenStore), // This one needs to be removed after move to ATM 4.0
];
foreach (var type in AllTypes
.Where(t => !exclusions.Contains(t))
@ -206,7 +216,8 @@ public class ConventionTests(ITestOutputHelper output)
var parameters = method.GetParameters();
if (parameters.Length == 0 || parameters.Last().ParameterType != typeof(CT))
{
failures.Add($"{type.FullName}.{method.Name}: Async method should have a CT as the last parameter.");
failures.Add(
$"{type.FullName}.{method.Name}: Async method should have a CT as the last parameter.");
}
}
}
@ -218,18 +229,18 @@ public class ConventionTests(ITestOutputHelper output)
failures.ShouldBeEmpty();
}
public static bool IsInternal(Type type)
{
if (type.IsNested)
{
return true;
}
return type.IsNestedPrivate || type.IsNotPublic;
}
#nullable disable
[Fact]
public void AccessTokenManagement_is_not_exposed()
@ -252,7 +263,8 @@ public class ConventionTests(ITestOutputHelper output)
}
// Check public members for forbidden types
var members = type.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly);
var members = type.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static |
BindingFlags.DeclaredOnly);
foreach (var member in members)
{
switch (member)
@ -263,30 +275,36 @@ public class ConventionTests(ITestOutputHelper output)
{
break;
}
if (IsForbiddenType(method.ReturnType))
{
errors.Add($"{type.FullName}.{method.Name} returns forbidden type {method.ReturnType.FullName}");
errors.Add(
$"{type.FullName}.{method.Name} returns forbidden type {method.ReturnType.FullName}");
}
foreach (var param in method.GetParameters())
{
if (IsForbiddenType(param.ParameterType))
{
errors.Add($"{type.FullName}.{method.Name} parameter '{param.Name}' is forbidden type {param.ParameterType.FullName}");
errors.Add(
$"{type.FullName}.{method.Name} parameter '{param.Name}' is forbidden type {param.ParameterType.FullName}");
}
}
break;
case PropertyInfo prop:
if (IsForbiddenType(prop.PropertyType))
{
errors.Add($"{type.FullName}.{prop.Name} property is forbidden type {prop.PropertyType.FullName}");
errors.Add(
$"{type.FullName}.{prop.Name} property is forbidden type {prop.PropertyType.FullName}");
}
break;
case FieldInfo field:
if (IsForbiddenType(field.FieldType))
{
errors.Add($"{type.FullName}.{field.Name} field is forbidden type {field.FieldType.FullName}");
errors.Add(
$"{type.FullName}.{field.Name} field is forbidden type {field.FieldType.FullName}");
}
break;
@ -294,7 +312,8 @@ public class ConventionTests(ITestOutputHelper output)
if (IsForbiddenType(evt.EventHandlerType!))
{
errors.Add($"{type.FullName}.{evt.Name} event is forbidden type {evt.EventHandlerType.FullName}");
errors.Add(
$"{type.FullName}.{evt.Name} event is forbidden type {evt.EventHandlerType.FullName}");
}
break;
@ -331,10 +350,12 @@ public class ConventionTests(ITestOutputHelper output)
}
}
}
if (t.IsArray)
{
return IsForbiddenType(t.GetElementType()!);
}
return false;
}
}
@ -346,15 +367,16 @@ public class ConventionTests(ITestOutputHelper output)
// Find all types implementing IStringValue<TSelf>
var stringValueTypes =
AllTypes.Where(t => t.IsValueType && !t.IsAbstract)
.SelectMany(t =>
t.GetInterfaces()
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IStronglyTypedValue<>)
&& i.GenericTypeArguments[0] == t)
.Select(_ => t))
.Distinct()
.ToList();
.SelectMany(t =>
t.GetInterfaces()
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IStronglyTypedValue<>)
&& i.GenericTypeArguments[0] == t)
.Select(_ => t))
.Distinct()
.ToList();
return stringValueTypes;
}
[Fact]
public void All_interface_async_methods_should_have_cancellation_token_with_default()
{
@ -368,18 +390,22 @@ public class ConventionTests(ITestOutputHelper output)
var parameters = method.GetParameters();
if (parameters.Length == 0)
{
failures.Add($"{type.FullName}.{method.Name}: Async method should have a CancellationToken parameter with a default value.");
failures.Add(
$"{type.FullName}.{method.Name}: Async method should have a CancellationToken parameter with a default value.");
continue;
}
var ctParam = parameters.Last();
if (ctParam.ParameterType != typeof(System.Threading.CancellationToken))
{
failures.Add($"{type.FullName}.{method.Name}: Last parameter should be CancellationToken.");
continue;
}
if (!ctParam.HasDefaultValue)
{
failures.Add($"{type.FullName}.{method.Name}: CancellationToken parameter should have a default value.");
failures.Add(
$"{type.FullName}.{method.Name}: CancellationToken parameter should have a default value.");
}
}
}

View file

@ -0,0 +1,45 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.Bff.DynamicFrontends;
using Duende.Bff.Tests.TestInfra;
using Xunit.Abstractions;
namespace Duende.Bff.Tests.Diagnostics;
public class FrontendCountDiagnosticEntryTests(ITestOutputHelper testOutputHelper) : BffTestBase(testOutputHelper)
{
[Fact]
public async Task Should_print_the_number_of_frontends_during_defined_interval()
{
Bff.OnConfigureBffOptions += options =>
{
options.Diagnostics.LogFrequency = TimeSpan.FromHours(1);
};
await InitializeAsync();
AdvanceClock(TimeSpan.FromHours(1));
await Task.Delay(100);
var bffLogMessages = Context.LogMessages.ToString().Split(Environment.NewLine).Where(x => x.StartsWith("bff"));
bffLogMessages.ShouldContain(x => x.Contains("\"FrontendCount\":0"));
AddOrUpdateFrontend(new BffFrontend
{
Name = BffFrontendName.Parse("frontend1"),
});
AddOrUpdateFrontend(new BffFrontend
{
Name = BffFrontendName.Parse("frontend2"),
});
AdvanceClock(TimeSpan.FromHours(1));
await Task.Delay(100);
bffLogMessages = Context.LogMessages.ToString().Split(Environment.NewLine).Where(x => x.StartsWith("bff"));
bffLogMessages.ShouldContain(x => x.Contains("\"FrontendCount\":2"));
}
}

View file

@ -1,9 +1,6 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Security.Claims;
using Duende.Bff.DynamicFrontends;
using Duende.Bff.Licensing;
using Duende.Bff.Tests.TestInfra;
using Microsoft.Extensions.Time.Testing;
using Xunit.Abstractions;
@ -18,112 +15,38 @@ public class LicensingTests(ITestOutputHelper output) : BffTestBase(output)
Bff.LicenseKey = null;
await InitializeAsync();
Context.LogMessages.ToString().ShouldContain("You do not have a valid license key for the Duende BFF security framework");
var bffLogMessages = Context.LogMessages.ToString().Split(Environment.NewLine).Where(x => x.StartsWith("bff"));
bffLogMessages.ShouldContain(x => x.Contains("You do not have a valid license key for the Duende software."));
}
[Fact]
public async Task Given_expired_license_then_log_error()
{
Bff.LicenseKey = "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzA0MDY3MjAwLCJleHAiOjE3MzE2Mjg4MDAsImNvbXBhbnlfbmFtZSI6Il90ZXN0IiwiY29udGFjdF9pbmZvIjoiam9lQGR1ZW5kZXNvZnR3YXJlLmNvbSIsImVkaXRpb24iOiJTdGFydGVyIiwiaWQiOiI3ODk2IiwiZmVhdHVyZSI6ImJmZiJ9.YcRGLlVuNBSqNuO1mdXk4GvvVEQFfQUNAnTkzs9W2iNKCxLXrZ5mDPuyTNsDSwEqsfXG8bUCVFxFGp1Bfkxs8hUIBiKuVXfeIB_lmpj5f-KueZ_XlWm0pYT-ROAzVbDdNgMR9YqCPAw8ANclk7HwRcXc0VnLNcKRFrZ0OOWNysFIanTmg7hRIQmDuMLNc2j8HCZSRJ06fijecS72lM4Vv9a6myJvAsASQhKnWTLzQvdzW7T99eobLy45qJu39LMTQkPkkJUS41YPmi2_kEmeMcRucgU4dQKHD5zT9KmzPVWJwsyowWIJ6U7lZ8FXZ8c9POsQeTeQEJY6FheJ2Ut-6Q";
Bff.LicenseKey =
"eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzA0MDY3MjAwLCJleHAiOjE3MzE2Mjg4MDAsImNvbXBhbnlfbmFtZSI6Il90ZXN0IiwiY29udGFjdF9pbmZvIjoiam9lQGR1ZW5kZXNvZnR3YXJlLmNvbSIsImVkaXRpb24iOiJTdGFydGVyIiwiaWQiOiI3ODk2IiwiZmVhdHVyZSI6ImJmZiJ9.YcRGLlVuNBSqNuO1mdXk4GvvVEQFfQUNAnTkzs9W2iNKCxLXrZ5mDPuyTNsDSwEqsfXG8bUCVFxFGp1Bfkxs8hUIBiKuVXfeIB_lmpj5f-KueZ_XlWm0pYT-ROAzVbDdNgMR9YqCPAw8ANclk7HwRcXc0VnLNcKRFrZ0OOWNysFIanTmg7hRIQmDuMLNc2j8HCZSRJ06fijecS72lM4Vv9a6myJvAsASQhKnWTLzQvdzW7T99eobLy45qJu39LMTQkPkkJUS41YPmi2_kEmeMcRucgU4dQKHD5zT9KmzPVWJwsyowWIJ6U7lZ8FXZ8c9POsQeTeQEJY6FheJ2Ut-6Q";
await InitializeAsync();
Context.LogMessages.ToString().ShouldContain("Your license for Duende BFF security framework has expired on");
var bffLogMessages = Context.LogMessages.ToString().Split(Environment.NewLine).Where(x => x.StartsWith("bff"));
bffLogMessages.ShouldContain(x => x.Contains("Your license for the Duende Software has expired on "));
}
[Fact]
public async Task Given_valid_license_then_details()
{
SetupValidLicenseWithoutFrontends();
await InitializeAsync();
var log = Context.LogMessages.ToString();
log.ShouldContain("Duende BFF security Framework License information:");
log.ShouldContain("- Edition: Starter");
log.ShouldContain("- Expiration: 11/15/2024 00:00:00 +00:00");
log.ShouldContain("- LicenseContact: joe@duendesoftware.com");
log.ShouldContain("- LicenseContact: joe@duendesoftware.com");
log.ShouldContain("- Number of frontends licensed: not licensed for multi-frontend feature");
var bffLogMessages = Context.LogMessages.ToString().Split(Environment.NewLine).Where(x => x.StartsWith("bff"))
.ToList();
bffLogMessages.ShouldNotContain(x => x.Contains("You do not have a valid license key for the Duende software."));
bffLogMessages.ShouldNotContain(x => x.Contains("Your license for the Duende Software has expired on "));
}
private void SetupValidLicenseWithoutFrontends()
{
The.Clock = new FakeTimeProvider(new DateTimeOffset(2024, 1, 1, 1, 1, 1, TimeSpan.Zero));
Bff.LicenseKey = "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzA0MDY3MjAwLCJleHAiOjE3MzE2Mjg4MDAsImNvbXBhbnlfbmFtZSI6Il90ZXN0IiwiY29udGFjdF9pbmZvIjoiam9lQGR1ZW5kZXNvZnR3YXJlLmNvbSIsImVkaXRpb24iOiJTdGFydGVyIiwiaWQiOiI3ODk2IiwiZmVhdHVyZSI6ImJmZiJ9.YcRGLlVuNBSqNuO1mdXk4GvvVEQFfQUNAnTkzs9W2iNKCxLXrZ5mDPuyTNsDSwEqsfXG8bUCVFxFGp1Bfkxs8hUIBiKuVXfeIB_lmpj5f-KueZ_XlWm0pYT-ROAzVbDdNgMR9YqCPAw8ANclk7HwRcXc0VnLNcKRFrZ0OOWNysFIanTmg7hRIQmDuMLNc2j8HCZSRJ06fijecS72lM4Vv9a6myJvAsASQhKnWTLzQvdzW7T99eobLy45qJu39LMTQkPkkJUS41YPmi2_kEmeMcRucgU4dQKHD5zT9KmzPVWJwsyowWIJ6U7lZ8FXZ8c9POsQeTeQEJY6FheJ2Ut-6Q";
}
[Fact]
public async Task When_not_licence_for_multi_frontends_then_warns()
{
SetupValidLicenseWithoutFrontends();
await InitializeAsync();
Bff.AddOrUpdateFrontend(Some.BffFrontend());
var log = Context.LogMessages.ToString();
log.ShouldContain($"Frontend {The.FrontendName} was added. However, your current license does not support multiple frontends");
}
[Fact]
public async Task When_licenced_for_frontends_then_info()
{
SetupValidLicenseWithoutFrontends();
Bff.OnConfigureServices += services =>
{
Claim[] claims = [
new Claim("feature", "bff"),
new Claim("bff_frontend_limit", "10"),
];
services.AddSingleton<LicenseValidator>(sp =>
{
return new LicenseValidator(
logger: sp.GetRequiredService<ILogger<LicenseValidator>>(),
claims: new ClaimsPrincipal(new ClaimsIdentity(claims)),
timeProvider: The.Clock);
});
};
await InitializeAsync();
Bff.AddOrUpdateFrontend(Some.BffFrontend());
var log = Context.LogMessages.ToString();
log.ShouldContain($"Frontend {The.FrontendName} was added. Currently using 1 of 10 in the BFF License");
}
[Fact]
public async Task When_exceeding_number_of_licensed_frontends_then_warning()
{
SetupValidLicenseWithoutFrontends();
Bff.OnConfigureServices += services =>
{
Claim[] claims = [
new Claim("feature", "bff"),
new Claim("bff_frontend_limit", "1"),
];
services.AddSingleton<LicenseValidator>(sp =>
{
return new LicenseValidator(
logger: sp.GetRequiredService<ILogger<LicenseValidator>>(),
claims: new ClaimsPrincipal(new ClaimsIdentity(claims)),
timeProvider: The.Clock);
});
};
await InitializeAsync();
Bff.AddOrUpdateFrontend(Some.BffFrontend());
Bff.AddOrUpdateFrontend(Some.BffFrontend() with
{
Name = BffFrontendName.Parse("second")
});
var log = Context.LogMessages.ToString();
log.ShouldContain($"Frontend {The.FrontendName} was added. Currently using 1 of 1 in the BFF License");
log.ShouldContain($"Frontend second was added. This exceeds the maximum number of frontends allowed by your license");
Bff.LicenseKey =
"eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzA0MDY3MjAwLCJleHAiOjE3MzE2Mjg4MDAsImNvbXBhbnlfbmFtZSI6Il90ZXN0IiwiY29udGFjdF9pbmZvIjoiam9lQGR1ZW5kZXNvZnR3YXJlLmNvbSIsImVkaXRpb24iOiJTdGFydGVyIiwiaWQiOiI3ODk2IiwiZmVhdHVyZSI6ImJmZiJ9.YcRGLlVuNBSqNuO1mdXk4GvvVEQFfQUNAnTkzs9W2iNKCxLXrZ5mDPuyTNsDSwEqsfXG8bUCVFxFGp1Bfkxs8hUIBiKuVXfeIB_lmpj5f-KueZ_XlWm0pYT-ROAzVbDdNgMR9YqCPAw8ANclk7HwRcXc0VnLNcKRFrZ0OOWNysFIanTmg7hRIQmDuMLNc2j8HCZSRJ06fijecS72lM4Vv9a6myJvAsASQhKnWTLzQvdzW7T99eobLy45qJu39LMTQkPkkJUS41YPmi2_kEmeMcRucgU4dQKHD5zT9KmzPVWJwsyowWIJ6U7lZ8FXZ8c9POsQeTeQEJY6FheJ2Ut-6Q";
}
}

View file

@ -309,6 +309,7 @@ namespace Duende.Bff.Configuration
public System.Net.Http.HttpMessageHandler? BackchannelHttpHandler { get; set; }
public bool BackchannelLogoutAllUserSessions { get; set; }
public Duende.Bff.AccessTokenManagement.DPoPProofKey? DPoPJsonWebKey { get; set; }
public Duende.Bff.Configuration.DiagnosticsOptions Diagnostics { get; set; }
public System.Collections.Generic.ICollection<string> DiagnosticsEnvironments { get; }
public Microsoft.AspNetCore.Http.PathString DiagnosticsPath { get; }
public Duende.Bff.Configuration.DisableAntiForgeryCheck DisableAntiForgeryCheck { get; set; }
@ -344,6 +345,12 @@ namespace Duende.Bff.Configuration
public Duende.Bff.AccessTokenManagement.Resource? Resource { get; init; }
public Duende.Bff.AccessTokenManagement.Scheme? SignInScheme { get; init; }
}
public sealed class DiagnosticsOptions
{
public DiagnosticsOptions() { }
public int ChunkSize { get; set; }
public System.TimeSpan LogFrequency { get; set; }
}
public delegate bool DisableAntiForgeryCheck(Microsoft.AspNetCore.Http.HttpContext context);
}
namespace Duende.Bff.DynamicFrontends

View file

@ -20,7 +20,7 @@ public class TestDataBuilder(TestData the)
public readonly TestData The = the;
internal LicenseValidator LicenseValidator =>
new LicenseValidator(new NullLogger<LicenseValidator>(), new ClaimsPrincipal(), The.Clock);
new(new NullLogger<LicenseValidator>(), new ClaimsPrincipal(), The.Clock);
public BffFrontend BffFrontend(BffFrontendName? name = null) =>
new()

View file

@ -22,7 +22,7 @@ public class DiagnosticDataService
_entries = entries;
}
public async Task<ReadOnlyMemory<byte>> GetJsonBytesAsync()
public async Task<ReadOnlyMemory<byte>> GetJsonBytesAsync(CancellationToken cancellationToken = default)
{
var bufferWriter = new ArrayBufferWriter<byte>();
await using var writer = new Utf8JsonWriter(bufferWriter, new JsonWriterOptions { Indented = false });
@ -37,14 +37,14 @@ public class DiagnosticDataService
writer.WriteEndObject();
await writer.FlushAsync();
await writer.FlushAsync(cancellationToken);
return bufferWriter.WrittenMemory;
}
public async Task<string> GetJsonStringAsync()
public async Task<string> GetJsonStringAsync(CancellationToken cancellationToken = default)
{
var bytes = await GetJsonBytesAsync();
var bytes = await GetJsonBytesAsync(cancellationToken);
return Encoding.UTF8.GetString(bytes.Span);
}
}