From 978f37c6e24f452222a248358adf4a4ae55a13fd Mon Sep 17 00:00:00 2001 From: Joe DeCock Date: Wed, 11 Dec 2024 09:48:26 -0600 Subject: [PATCH] Added new license management services (#1637) --- Directory.Build.targets | 3 +- hosts/main/Host.Main.csproj | 1 - hosts/main/HostingExtensions.cs | 4 - hosts/main/IdentityServerExtensions.cs | 13 +- hosts/main/Program.cs | 23 +- .../BuilderExtensions/Core.cs | 15 ++ ...stConfigureApplicationCookieTicketStore.cs | 4 + ...ntityServerApplicationBuilderExtensions.cs | 7 + .../Endpoints/PushedAuthorizationEndpoint.cs | 7 +- .../DynamicAuthenticationSchemeProvider.cs | 5 + src/IdentityServer/Hosting/EndpointRouter.cs | 27 ++- .../Hosting/IdentityServerMiddleware.cs | 9 +- .../Licensing/LicenseUsageSummary.cs | 21 ++ src/IdentityServer/Licensing/V2/License.cs | 205 ++++++++++++++++++ .../Licensing/V2/LicenseAccessor.cs | 102 +++++++++ .../Licensing/V2/LicenseEdition.cs | 35 +++ .../Licensing/V2/LicenseFeature.cs | 73 +++++++ .../Licensing/V2/LicenseUsageTracker.cs | 75 +++++++ .../V2/LicenseUsageTrackerExtensions.cs | 28 +++ .../Licensing/V2/ProtocolRequestCounter.cs | 40 ++++ .../Default/AuthorizeRequestValidator.cs | 7 + ...ckchannelAuthenticationRequestValidator.cs | 7 +- .../PushedAuthorizationRequestValidator.cs | 9 +- .../Default/TokenRequestValidator.cs | 39 +++- .../Common/IdentityServerPipeline.cs | 11 +- .../Common/MockLogger.cs | 51 +++++ .../Hosting/LicenseTests.cs | 85 ++++++++ .../IdentityServer.IntegrationTests.csproj | 7 + .../Hosting/EndpointRouterTests.cs | 12 +- .../IdentityServer.UnitTests.csproj | 6 + .../Licensing/v2/LicenseAccessorTests.cs | 90 ++++++++ .../Licensing/v2/LicenseFactory.cs | 27 +++ .../Licensing/v2/LicenseUsageTests.cs | 79 +++++++ .../v2/ProtocolRequestCounterTests.cs | 62 ++++++ .../Licensing/v2/StubLoggerFactory.cs | 24 ++ .../Authorize_ProtocolValidation_Resources.cs | 3 + .../Validation/Setup/Factory.cs | 7 +- 37 files changed, 1168 insertions(+), 55 deletions(-) create mode 100644 src/IdentityServer/Licensing/LicenseUsageSummary.cs create mode 100644 src/IdentityServer/Licensing/V2/License.cs create mode 100644 src/IdentityServer/Licensing/V2/LicenseAccessor.cs create mode 100644 src/IdentityServer/Licensing/V2/LicenseEdition.cs create mode 100644 src/IdentityServer/Licensing/V2/LicenseFeature.cs create mode 100644 src/IdentityServer/Licensing/V2/LicenseUsageTracker.cs create mode 100644 src/IdentityServer/Licensing/V2/LicenseUsageTrackerExtensions.cs create mode 100644 src/IdentityServer/Licensing/V2/ProtocolRequestCounter.cs create mode 100644 test/IdentityServer.IntegrationTests/Common/MockLogger.cs create mode 100644 test/IdentityServer.IntegrationTests/Hosting/LicenseTests.cs create mode 100644 test/IdentityServer.UnitTests/Licensing/v2/LicenseAccessorTests.cs create mode 100644 test/IdentityServer.UnitTests/Licensing/v2/LicenseFactory.cs create mode 100644 test/IdentityServer.UnitTests/Licensing/v2/LicenseUsageTests.cs create mode 100644 test/IdentityServer.UnitTests/Licensing/v2/ProtocolRequestCounterTests.cs create mode 100644 test/IdentityServer.UnitTests/Licensing/v2/StubLoggerFactory.cs diff --git a/Directory.Build.targets b/Directory.Build.targets index 547a4e496..7b3436797 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -35,7 +35,8 @@ - + + diff --git a/hosts/main/Host.Main.csproj b/hosts/main/Host.Main.csproj index 42a6668cd..b9c0bcdba 100644 --- a/hosts/main/Host.Main.csproj +++ b/hosts/main/Host.Main.csproj @@ -38,7 +38,6 @@ - diff --git a/hosts/main/HostingExtensions.cs b/hosts/main/HostingExtensions.cs index 051a69024..d0e8a6baf 100644 --- a/hosts/main/HostingExtensions.cs +++ b/hosts/main/HostingExtensions.cs @@ -3,7 +3,6 @@ using System.Security.Claims; using Duende.IdentityServer; -using Duende.IdentityServer.Configuration; using IdentityServerHost.Extensions; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.IdentityModel.Tokens; @@ -159,9 +158,6 @@ internal static WebApplication ConfigurePipeline(this WebApplication app) app.MapRazorPages() .RequireAuthorization(); - app.MapDynamicClientRegistration() - .AllowAnonymous(); - // Map /metrics that displays Otel data in human readable form. app.UseOpenTelemetryPrometheusScrapingEndpoint(); diff --git a/hosts/main/IdentityServerExtensions.cs b/hosts/main/IdentityServerExtensions.cs index de1097ea1..457a1084c 100644 --- a/hosts/main/IdentityServerExtensions.cs +++ b/hosts/main/IdentityServerExtensions.cs @@ -38,7 +38,7 @@ internal static WebApplicationBuilder ConfigureIdentityServer(this WebApplicatio UseX509Certificate = true }); }) - //.AddServerSideSessions() + .AddServerSideSessions() .AddInMemoryClients(Clients.Get().ToList()) .AddInMemoryIdentityResources(Resources.IdentityResources) .AddInMemoryApiScopes(Resources.ApiScopes) @@ -64,15 +64,16 @@ internal static WebApplicationBuilder ConfigureIdentityServer(this WebApplicatio ResponseType = "id_token", Scope = "openid profile" } - ]); + ]) + .AddLicenseSummary(); builder.Services.AddDistributedMemoryCache(); - builder.Services.AddIdentityServerConfiguration(opt => - { - // opt.DynamicClientRegistration.SecretLifetime = TimeSpan.FromHours(1); - }).AddInMemoryClientConfigurationStore(); + // builder.Services.AddIdentityServerConfiguration(opt => + // { + // // opt.DynamicClientRegistration.SecretLifetime = TimeSpan.FromHours(1); + // }).AddInMemoryClientConfigurationStore(); return builder; } diff --git a/hosts/main/Program.cs b/hosts/main/Program.cs index d2762d576..cee27845f 100644 --- a/hosts/main/Program.cs +++ b/hosts/main/Program.cs @@ -1,11 +1,13 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +using Duende.IdentityServer.Licensing; using IdentityServerHost; using Serilog; using Serilog.Events; using Serilog.Sinks.SystemConsole.Themes; using System.Globalization; +using System.Text; Console.Title = "IdentityServer (Main)"; @@ -36,7 +38,12 @@ .ConfigureServices() .ConfigurePipeline(); + var usage = app.Services.GetRequiredService(); + app.Run(); + + Console.Write(Summary(usage)); + Console.ReadKey(); } catch (Exception ex) { @@ -46,4 +53,18 @@ { Log.Information("Shut down complete"); Log.CloseAndFlush(); -} \ No newline at end of file +} + +string Summary(LicenseUsageSummary usage) +{ + var sb = new StringBuilder(); + sb.AppendLine("IdentityServer Usage Summary:"); + sb.AppendLine(CultureInfo.InvariantCulture, $" License: {usage.LicenseEdition}"); + var features = usage.FeaturesUsed.Count > 0 ? string.Join(", ", usage.FeaturesUsed) : "None"; + sb.AppendLine(CultureInfo.InvariantCulture, $" Business and Enterprise Edition Features Used: {features}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" {usage.ClientsUsed.Count} Client Id(s) Used"); + sb.AppendLine(CultureInfo.InvariantCulture, $" {usage.IssuersUsed.Count} Issuer(s) Used"); + + return sb.ToString(); +} + \ No newline at end of file diff --git a/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs index e554d86be..4c552c777 100644 --- a/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -34,6 +34,8 @@ using Duende.IdentityServer.Internal; using Duende.IdentityServer.Stores.Empty; using Duende.IdentityServer.Endpoints.Results; +using Duende.IdentityServer.Licensing; +using Duende.IdentityServer.Licensing.V2; namespace Microsoft.Extensions.DependencyInjection; @@ -206,6 +208,10 @@ public static IIdentityServerBuilder AddCoreServices(this IIdentityServerBuilder builder.Services.AddTransient(services => IdentityServerLicenseValidator.Instance.GetLicense()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + return builder; } @@ -392,6 +398,15 @@ public static IIdentityServerBuilder AddDefaultSecretValidators(this IIdentitySe return builder; } + /// + /// Adds the license summary, which provides information about the current license usage. + /// + public static IIdentityServerBuilder AddLicenseSummary(this IIdentityServerBuilder builder) + { + builder.Services.AddTransient(services => services.GetRequiredService().GetSummary()); + return builder; + } + internal static void AddTransientDecorator(this IServiceCollection services) where TService : class where TImplementation : class, TService diff --git a/src/IdentityServer/Configuration/DependencyInjection/PostConfigureApplicationCookieTicketStore.cs b/src/IdentityServer/Configuration/DependencyInjection/PostConfigureApplicationCookieTicketStore.cs index 4dcf7c0b4..bfa04987e 100644 --- a/src/IdentityServer/Configuration/DependencyInjection/PostConfigureApplicationCookieTicketStore.cs +++ b/src/IdentityServer/Configuration/DependencyInjection/PostConfigureApplicationCookieTicketStore.cs @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. +using Duende.IdentityServer.Licensing.V2; using Duende.IdentityServer.Stores; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; @@ -20,6 +21,7 @@ public class PostConfigureApplicationCookieTicketStore : IPostConfigureOptions _logger; /// @@ -36,6 +38,7 @@ public PostConfigureApplicationCookieTicketStore( ILogger logger) { _httpContextAccessor = httpContextAccessor; + _licenseUsage = httpContextAccessor.HttpContext?.RequestServices.GetRequiredService(); _logger = logger; _scheme = identityServerOptions.Authentication.CookieAuthenticationScheme ?? @@ -66,6 +69,7 @@ public void PostConfigure(string name, CookieAuthenticationOptions options) } IdentityServerLicenseValidator.Instance.ValidateServerSideSessions(); + _licenseUsage.FeatureUsed(LicenseFeature.ServerSideSessions); var sessionStore = _httpContextAccessor.HttpContext!.RequestServices.GetService(); if (sessionStore is InMemoryServerSideSessionStore) diff --git a/src/IdentityServer/Configuration/IdentityServerApplicationBuilderExtensions.cs b/src/IdentityServer/Configuration/IdentityServerApplicationBuilderExtensions.cs index 7040f8d5d..dd1e24212 100644 --- a/src/IdentityServer/Configuration/IdentityServerApplicationBuilderExtensions.cs +++ b/src/IdentityServer/Configuration/IdentityServerApplicationBuilderExtensions.cs @@ -20,6 +20,7 @@ using System.Reflection; using System.Runtime.InteropServices; using System.Threading.Tasks; +using Duende.IdentityServer.Licensing.V2; namespace Microsoft.AspNetCore.Builder; @@ -78,6 +79,12 @@ internal static void Validate(this IApplicationBuilder app) var env = serviceProvider.GetRequiredService(); IdentityServerLicenseValidator.Instance.Initialize(loggerFactory, options, env.IsDevelopment()); + if (options.KeyManagement.Enabled) + { + var licenseUsage = serviceProvider.GetRequiredService(); + licenseUsage.FeatureUsed(LicenseFeature.KeyManagement); + } + TestService(serviceProvider, typeof(IPersistedGrantStore), logger, "No storage mechanism for grants specified. Use the 'AddInMemoryPersistedGrants' extension method to register a development version."); TestService(serviceProvider, typeof(IClientStore), logger, "No storage mechanism for clients specified. Use the 'AddInMemoryClients' extension method to register a development version."); TestService(serviceProvider, typeof(IResourceStore), logger, "No storage mechanism for resources specified. Use the 'AddInMemoryIdentityResources' or 'AddInMemoryApiResources' extension method to register a development version."); diff --git a/src/IdentityServer/Endpoints/PushedAuthorizationEndpoint.cs b/src/IdentityServer/Endpoints/PushedAuthorizationEndpoint.cs index 3847d7ac9..8a9f876ea 100644 --- a/src/IdentityServer/Endpoints/PushedAuthorizationEndpoint.cs +++ b/src/IdentityServer/Endpoints/PushedAuthorizationEndpoint.cs @@ -16,6 +16,7 @@ using System.Collections.Specialized; using System.Net; using System.Threading.Tasks; +using Duende.IdentityServer.Licensing.V2; namespace Duende.IdentityServer.Endpoints; internal class PushedAuthorizationEndpoint : IEndpointHandler @@ -23,14 +24,15 @@ internal class PushedAuthorizationEndpoint : IEndpointHandler private readonly IClientSecretValidator _clientValidator; private readonly IPushedAuthorizationRequestValidator _parValidator; private readonly IPushedAuthorizationResponseGenerator _responseGenerator; + private readonly LicenseUsageTracker _features; private readonly IdentityServerOptions _options; private readonly ILogger _logger; public PushedAuthorizationEndpoint( IClientSecretValidator clientValidator, IPushedAuthorizationRequestValidator parValidator, - IAuthorizeRequestValidator authorizeRequestValidator, IPushedAuthorizationResponseGenerator responseGenerator, + LicenseUsageTracker features, IdentityServerOptions options, ILogger logger ) @@ -38,6 +40,7 @@ ILogger logger _clientValidator = clientValidator; _parValidator = parValidator; _responseGenerator = responseGenerator; + _features = features; _options = options; _logger = logger; } @@ -48,6 +51,8 @@ public async Task ProcessAsync(HttpContext context) _logger.LogDebug("Start pushed authorization request"); + _features.FeatureUsed(LicenseFeature.PAR); + NameValueCollection values; IFormCollection form; if(HttpMethods.IsPost(context.Request.Method)) diff --git a/src/IdentityServer/Hosting/DynamicProviders/DynamicSchemes/DynamicAuthenticationSchemeProvider.cs b/src/IdentityServer/Hosting/DynamicProviders/DynamicSchemes/DynamicAuthenticationSchemeProvider.cs index ca59b3f64..0cd0df802 100644 --- a/src/IdentityServer/Hosting/DynamicProviders/DynamicSchemes/DynamicAuthenticationSchemeProvider.cs +++ b/src/IdentityServer/Hosting/DynamicProviders/DynamicSchemes/DynamicAuthenticationSchemeProvider.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging; using System.Collections.Generic; using System.Threading.Tasks; +using Duende.IdentityServer.Licensing.V2; namespace Duende.IdentityServer.Hosting.DynamicProviders; @@ -18,12 +19,14 @@ class DynamicAuthenticationSchemeProvider : IAuthenticationSchemeProvider { private readonly IAuthenticationSchemeProvider _inner; private readonly DynamicProviderOptions _options; + private readonly LicenseUsageTracker _licenseUsageTracker; private readonly IHttpContextAccessor _httpContextAccessor; private readonly ILogger _logger; public DynamicAuthenticationSchemeProvider( Decorator inner, DynamicProviderOptions options, + LicenseUsageTracker licenseUsageTracker, IHttpContextAccessor httpContextAccessor, ILogger logger) { @@ -31,6 +34,7 @@ public DynamicAuthenticationSchemeProvider( _options = options; _httpContextAccessor = httpContextAccessor; _logger = logger; + _licenseUsageTracker = licenseUsageTracker; } public void AddScheme(AuthenticationScheme scheme) @@ -115,6 +119,7 @@ private async Task GetDynamicSchemeAsync(string name) if (providerType != null) { IdentityServerLicenseValidator.Instance.ValidateDynamicProviders(); + _licenseUsageTracker.FeatureUsed(LicenseFeature.DynamicProviders); dynamicScheme = new DynamicAuthenticationScheme(idp, providerType.HandlerType); cache.Add(name, dynamicScheme); } diff --git a/src/IdentityServer/Hosting/EndpointRouter.cs b/src/IdentityServer/Hosting/EndpointRouter.cs index f2e2a390a..fdc96f747 100644 --- a/src/IdentityServer/Hosting/EndpointRouter.cs +++ b/src/IdentityServer/Hosting/EndpointRouter.cs @@ -1,4 +1,4 @@ -// Copyright (c) Duende Software. All rights reserved. +// Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. @@ -8,27 +8,24 @@ using System; using System.Collections.Generic; using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Licensing.V2; namespace Duende.IdentityServer.Hosting; -internal class EndpointRouter : IEndpointRouter +internal class EndpointRouter( + IEnumerable endpoints, + ProtocolRequestCounter requestCounter, + IdentityServerOptions options, + ILogger logger) + : IEndpointRouter { - private readonly IEnumerable _endpoints; - private readonly IdentityServerOptions _options; - private readonly ILogger _logger; - - public EndpointRouter(IEnumerable endpoints, IdentityServerOptions options, ILogger logger) - { - _endpoints = endpoints; - _options = options; - _logger = logger; - } + private readonly ILogger _logger = logger; public IEndpointHandler Find(HttpContext context) { ArgumentNullException.ThrowIfNull(context); - foreach(var endpoint in _endpoints) + foreach(var endpoint in endpoints) { var path = endpoint.Path; if (context.Request.Path.Equals(path, StringComparison.OrdinalIgnoreCase)) @@ -36,6 +33,8 @@ public IEndpointHandler Find(HttpContext context) var endpointName = endpoint.Name; _logger.LogDebug("Request path {path} matched to endpoint type {endpoint}", context.Request.Path, endpointName); + requestCounter.Increment(); + return GetEndpointHandler(endpoint, context); } } @@ -47,7 +46,7 @@ public IEndpointHandler Find(HttpContext context) private IEndpointHandler GetEndpointHandler(Endpoint endpoint, HttpContext context) { - if (_options.Endpoints.IsEndpointEnabled(endpoint)) + if (options.Endpoints.IsEndpointEnabled(endpoint)) { if (context.RequestServices.GetService(endpoint.Handler) is IEndpointHandler handler) { diff --git a/src/IdentityServer/Hosting/IdentityServerMiddleware.cs b/src/IdentityServer/Hosting/IdentityServerMiddleware.cs index 120f2b9a4..91a9fae0e 100644 --- a/src/IdentityServer/Hosting/IdentityServerMiddleware.cs +++ b/src/IdentityServer/Hosting/IdentityServerMiddleware.cs @@ -12,8 +12,8 @@ using Duende.IdentityServer.Models; using System.Linq; using Duende.IdentityServer.Configuration; -using Microsoft.AspNetCore.WebUtilities; -using System.Collections.Generic; +using Duende.IdentityServer.Licensing.V2; +using Microsoft.Extensions.DependencyInjection; namespace Duende.IdentityServer.Hosting; @@ -99,7 +99,10 @@ public async Task Invoke( using var activity = Tracing.BasicActivitySource.StartActivity("IdentityServerProtocolRequest"); activity?.SetTag(Tracing.Properties.EndpointType, endpointType); - IdentityServerLicenseValidator.Instance.ValidateIssuer(await issuerNameService.GetCurrentAsync()); + var issuer = await issuerNameService.GetCurrentAsync(); + var licenseUsage = context.RequestServices.GetRequiredService(); + licenseUsage.IssuerUsed(issuer); + IdentityServerLicenseValidator.Instance.ValidateIssuer(issuer); _logger.LogInformation("Invoking IdentityServer endpoint: {endpointType} for {url}", endpointType, requestPath); diff --git a/src/IdentityServer/Licensing/LicenseUsageSummary.cs b/src/IdentityServer/Licensing/LicenseUsageSummary.cs new file mode 100644 index 000000000..38451b33a --- /dev/null +++ b/src/IdentityServer/Licensing/LicenseUsageSummary.cs @@ -0,0 +1,21 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using System.Collections.Generic; + +namespace Duende.IdentityServer.Licensing; + +/// +/// Usage summary for the current license. +/// +/// +/// +/// +/// +public record LicenseUsageSummary( + string LicenseEdition, + IReadOnlyCollection ClientsUsed, + IReadOnlyCollection IssuersUsed, + IReadOnlyCollection FeaturesUsed); \ No newline at end of file diff --git a/src/IdentityServer/Licensing/V2/License.cs b/src/IdentityServer/Licensing/V2/License.cs new file mode 100644 index 000000000..73572ed08 --- /dev/null +++ b/src/IdentityServer/Licensing/V2/License.cs @@ -0,0 +1,205 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Security.Claims; + +namespace Duende.IdentityServer.Licensing.V2; + +/// +/// Models a Duende commercial license. +/// +internal class License +{ + /// + /// Initializes an empty (non-configured) license. + /// + internal License() + { + } + + /// + /// Initializes the license from the claims in a key. + /// + internal License(ClaimsPrincipal claims) + { + if (Int32.TryParse(claims.FindFirst("id")?.Value, out var id)) + { + SerialNumber = id; + } + + CompanyName = claims.FindFirst("company_name")?.Value; + ContactInfo = claims.FindFirst("contact_info")?.Value; + + if (Int64.TryParse(claims.FindFirst("exp")?.Value, out var exp)) + { + Expiration = DateTimeOffset.FromUnixTimeSeconds(exp); + } + + var edition = claims.FindFirstValue("edition"); + if (edition != null) + { + if (!Enum.TryParse(edition, true, out var editionValue)) + { + throw new Exception($"Invalid edition in license: '{edition}'"); + } + Edition = editionValue; + } + + Features = claims.FindAll("feature").Select(f => f.Value).ToArray(); + + Extras = claims.FindFirst("extras")?.Value ?? string.Empty; + IsConfigured = true; + } + + /// + /// The serial number + /// + public int? SerialNumber { get; init; } + + /// + /// The company name + /// + public string? CompanyName { get; init; } + /// + /// The company contact info + /// + public string? ContactInfo { get; init; } + + /// + /// The license expiration + /// + public DateTimeOffset? Expiration { get; init; } + + /// + /// The license edition + /// + public LicenseEdition? Edition { get; init; } + + /// + /// True if redistribution is enabled for this license, and false otherwise. + /// + public bool Redistribution => IsEnabled(LicenseFeature.Redistribution) || IsEnabled(LicenseFeature.ISV); + + /// + /// The license features + /// + public string[] Features { get; init; } = []; + + /// + /// Extras + /// + public string? Extras { get; init; } + + /// + /// True if the license was configured in options or from a file, and false otherwise. + /// + [MemberNotNullWhen(true, + nameof(SerialNumber), + nameof(CompanyName), + nameof(ContactInfo), + nameof(Expiration), + nameof(Edition), + nameof(Extras)) + ] + public bool IsConfigured { get; init; } + + /// + /// Checks if a LicenseFeature is enabled in the current license. If there + /// is no configured license, this always returns true. + /// + /// + /// + public bool IsEnabled(LicenseFeature feature) + { + return !IsConfigured || (AllowedFeatureMask & (ulong) feature) != 0; + } + + + private ulong? _allowedFeatureMask; + private ulong AllowedFeatureMask + { + get + { + if (_allowedFeatureMask == null) + { + var features = FeatureMaskForEdition(); + foreach (var featureClaim in Features) + { + var feature = ToFeatureEnum(featureClaim); + features |= (ulong) feature; + } + + _allowedFeatureMask = features; + } + return _allowedFeatureMask.Value; + } + } + + private LicenseFeature ToFeatureEnum(string claimValue) + { + foreach(var field in typeof(LicenseFeature).GetFields()) + { + if (Attribute.GetCustomAttribute(field, typeof(DescriptionAttribute)) is DescriptionAttribute attribute) + { + if (string.Equals(attribute.Description, claimValue, StringComparison.OrdinalIgnoreCase)) + { + return (LicenseFeature) field.GetValue(null)!; + } + } + } + throw new ArgumentException("Unknown license feature {feature}", claimValue); + } + + + private ulong FeatureMaskForEdition() + { + return Edition switch + { + null => FeatureMaskForFeatures(), + LicenseEdition.Bff => FeatureMaskForFeatures(), + LicenseEdition.Starter => FeatureMaskForFeatures(), + LicenseEdition.Business => FeatureMaskForFeatures( + LicenseFeature.KeyManagement, + LicenseFeature.PAR, + LicenseFeature.ServerSideSessions, + LicenseFeature.DCR), + LicenseEdition.Enterprise => FeatureMaskForFeatures( + LicenseFeature.KeyManagement, + LicenseFeature.PAR, + LicenseFeature.ResourceIsolation, + LicenseFeature.DynamicProviders, + LicenseFeature.CIBA, + LicenseFeature.ServerSideSessions, + LicenseFeature.DPoP, + LicenseFeature.DCR + ), + LicenseEdition.Community => FeatureMaskForFeatures( + LicenseFeature.KeyManagement, + LicenseFeature.PAR, + LicenseFeature.ResourceIsolation, + LicenseFeature.DynamicProviders, + LicenseFeature.CIBA, + LicenseFeature.ServerSideSessions, + LicenseFeature.DPoP, + LicenseFeature.DCR + ), + _ => throw new ArgumentException(), + }; + } + + private ulong FeatureMaskForFeatures(params LicenseFeature[] licenseFeatures) + { + var result = 0UL; + foreach(var feature in licenseFeatures) + { + result |= (ulong) feature; + } + return result; + } +} \ No newline at end of file diff --git a/src/IdentityServer/Licensing/V2/LicenseAccessor.cs b/src/IdentityServer/Licensing/V2/LicenseAccessor.cs new file mode 100644 index 000000000..2538258b7 --- /dev/null +++ b/src/IdentityServer/Licensing/V2/LicenseAccessor.cs @@ -0,0 +1,102 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using System; +using System.IO; +using System.Linq; +using System.Security.Claims; +using System.Security.Cryptography; +using Duende.IdentityServer.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Duende.IdentityServer.Licensing.V2; + +/// +/// Loads the license from configuration or a file, and validates its contents. +/// +internal class LicenseAccessor(IdentityServerOptions options, ILogger logger) +{ + 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 = options.LicenseKey ?? LoadLicenseKeyFromFile(); + if (key == null) + { + return new License(); + } + + var licenseClaims = ValidateKey(key); + return licenseClaims.Any() ? // (ValidateKey will return an empty collection if it fails) + new License(new ClaimsPrincipal(new ClaimsIdentity(licenseClaims))) : new License(); + } + } + + 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, + ValidateLifetime = false + }; + + var validateResult = handler.ValidateTokenAsync(licenseKey, parms).Result; + if (!validateResult.IsValid) + { + logger.LogCritical(validateResult.Exception, "Error validating the Duende software license key"); + } + + return validateResult.ClaimsIdentity?.Claims.ToArray() ?? []; + } + +} \ No newline at end of file diff --git a/src/IdentityServer/Licensing/V2/LicenseEdition.cs b/src/IdentityServer/Licensing/V2/LicenseEdition.cs new file mode 100644 index 000000000..745663647 --- /dev/null +++ b/src/IdentityServer/Licensing/V2/LicenseEdition.cs @@ -0,0 +1,35 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.Licensing.V2; + +/// +/// The editions of our license, which give access to different features. +/// +internal enum LicenseEdition +{ + /// + /// Enterprise license edition + /// + Enterprise, + + /// + /// Business license edition + /// + Business, + + /// + /// Starter license edition + /// + Starter, + + /// + /// Community license edition + /// + Community, + + /// + /// Bff license edition + /// + Bff +} \ No newline at end of file diff --git a/src/IdentityServer/Licensing/V2/LicenseFeature.cs b/src/IdentityServer/Licensing/V2/LicenseFeature.cs new file mode 100644 index 000000000..9373ce624 --- /dev/null +++ b/src/IdentityServer/Licensing/V2/LicenseFeature.cs @@ -0,0 +1,73 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.ComponentModel; + +namespace Duende.IdentityServer.Licensing.V2; + +/// +/// The features of IdentityServer that can be enabled or disabled through the License. +/// +internal enum LicenseFeature : ulong +{ + /// + /// Automatic Key Management + /// + [Description("key_management")] + KeyManagement = 1, + + /// + /// Pushed Authorization Requests + /// + [Description("par")] + PAR = 2, + + /// + /// Resource Isolation + /// + [Description("resource_isolation")] + ResourceIsolation = 4, + + /// + /// Dyanmic External Providers + /// + [Description("dynamic_providers")] + DynamicProviders = 8, + + /// + /// Client Initiated Backchannel Authorization + /// + [Description("ciba")] + CIBA = 16, + + /// + /// Server-Side Sessions + /// + [Description("server_side_sessions")] + ServerSideSessions = 32, + + /// + /// Demonstrating Proof of Possession + /// + [Description("dpop")] + DPoP = 64, + + /// + /// Configuration API + /// + [Description("config_api")] + DCR = 128, + + /// + /// ISV (same as Redistribution) + /// + [Description("isv")] + ISV = 256, + + /// + /// Redistribution + /// + [Description("redistribution")] + Redistribution = 512, +} diff --git a/src/IdentityServer/Licensing/V2/LicenseUsageTracker.cs b/src/IdentityServer/Licensing/V2/LicenseUsageTracker.cs new file mode 100644 index 000000000..4c0427392 --- /dev/null +++ b/src/IdentityServer/Licensing/V2/LicenseUsageTracker.cs @@ -0,0 +1,75 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace Duende.IdentityServer.Licensing.V2; + +internal class LicenseUsageTracker(LicenseAccessor licenseAccessor) +{ + private readonly ConcurrentHashSet _otherFeatures = new(); + private readonly ConcurrentHashSet _businessFeatures = new(); + private readonly ConcurrentHashSet _enterpriseFeatures = new(); + private readonly ConcurrentHashSet _clientsUsed = new(); + private readonly ConcurrentHashSet _issuersUsed = new(); + + public void FeatureUsed(LicenseFeature feature) + { + switch (feature) + { + case LicenseFeature.ResourceIsolation: + case LicenseFeature.DynamicProviders: + case LicenseFeature.CIBA: + case LicenseFeature.DPoP: + _enterpriseFeatures.Add(feature); + break; + case LicenseFeature.KeyManagement: + case LicenseFeature.PAR: + case LicenseFeature.ServerSideSessions: + case LicenseFeature.DCR: + _businessFeatures.Add(feature); + break; + case LicenseFeature.ISV: + case LicenseFeature.Redistribution: + _otherFeatures.Add(feature); + break; + } + } + + public void ClientUsed(string clientId) => _clientsUsed.Add(clientId); + + public void IssuerUsed(string issuer) => _issuersUsed.Add(issuer); + + public LicenseUsageSummary GetSummary() + { + var licenseEdition = licenseAccessor.Current.Edition?.ToString() ?? "None"; + var featuresUsed = _enterpriseFeatures.Values + .Concat(_businessFeatures.Values) + .Concat(_otherFeatures.Values) + .Select(f => f.ToString()) + .ToList() + .AsReadOnly(); + return new LicenseUsageSummary(licenseEdition, _clientsUsed.Values, _issuersUsed.Values, featuresUsed); + } + + private class ConcurrentHashSet where T : notnull + { + private readonly ConcurrentDictionary _dictionary = new(); + + // We check if the dictionary contains the key first, because it + // performs better given our workload. Typically these sets will contain + // a small number of elements, and won't change much over time (e.g., + // the first time we try to use DPoP, that gets added, and then all + // subsequent requests with a proof don't need to do anything here). + // ConcurrentDictionary's ContainsKey method is lock free, while TryAdd + // always acquires a lock, so in the (by far more common) steady state, + // the ContainsKey check is much faster. + public bool Add(T item) => _dictionary.ContainsKey(item) ? false : _dictionary.TryAdd(item, 0); + + public IReadOnlyCollection Values => _dictionary.Keys.ToList().AsReadOnly(); + } +} \ No newline at end of file diff --git a/src/IdentityServer/Licensing/V2/LicenseUsageTrackerExtensions.cs b/src/IdentityServer/Licensing/V2/LicenseUsageTrackerExtensions.cs new file mode 100644 index 000000000..82dcde9fd --- /dev/null +++ b/src/IdentityServer/Licensing/V2/LicenseUsageTrackerExtensions.cs @@ -0,0 +1,28 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using System.Collections.Generic; +using System.Linq; + +namespace Duende.IdentityServer.Licensing.V2; + +internal static class LicenseUsageTrackerExtensions +{ + internal static void ResourceIndicatorUsed(this LicenseUsageTracker tracker, string? resourceIndicator) + { + if (!string.IsNullOrWhiteSpace(resourceIndicator)) + { + tracker.FeatureUsed(LicenseFeature.ResourceIsolation); + } + } + + internal static void ResourceIndicatorsUsed(this LicenseUsageTracker tracker, IEnumerable resourceIndicators) + { + if (resourceIndicators?.Any() == true) + { + tracker.FeatureUsed(LicenseFeature.ResourceIsolation); + } + } +} \ No newline at end of file diff --git a/src/IdentityServer/Licensing/V2/ProtocolRequestCounter.cs b/src/IdentityServer/Licensing/V2/ProtocolRequestCounter.cs new file mode 100644 index 000000000..e282aa027 --- /dev/null +++ b/src/IdentityServer/Licensing/V2/ProtocolRequestCounter.cs @@ -0,0 +1,40 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using System.Threading; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Licensing.V2; + +internal class ProtocolRequestCounter( + LicenseAccessor license, + ILoggerFactory loggerFactory) +{ + private readonly ILogger _logger = loggerFactory.CreateLogger("Duende.IdentityServer.License"); + private bool _warned; + private ulong _requestCount; + + /// + /// The number of protocol requests allowed for unlicensed use. This should only be changed in tests. + /// + internal ulong Threshold = 500; + + internal ulong RequestCount => _requestCount; + + internal void Increment() + { + if (license.Current.IsConfigured) + { + return; + } + var total = Interlocked.Increment(ref _requestCount); + if (total <= Threshold || _warned) + { + return; + } + _logger.LogError("IdentityServer has handled {total} protocol requests without a license. In future versions, unlicensed IdentityServer instances will shut down after {threshold} protocol requests. Please contact sales to obtain a license. If you are running in a test environment, please use a test license", total, Threshold); + _warned = true; + } +} diff --git a/src/IdentityServer/Validation/Default/AuthorizeRequestValidator.cs b/src/IdentityServer/Validation/Default/AuthorizeRequestValidator.cs index 952b3a12e..6911239f4 100644 --- a/src/IdentityServer/Validation/Default/AuthorizeRequestValidator.cs +++ b/src/IdentityServer/Validation/Default/AuthorizeRequestValidator.cs @@ -13,6 +13,7 @@ using System.Security.Claims; using System.Threading.Tasks; using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Licensing.V2; using Duende.IdentityServer.Logging.Models; using Duende.IdentityServer.Services; using static Duende.IdentityServer.IdentityServerConstants; @@ -29,11 +30,13 @@ internal class AuthorizeRequestValidator : IAuthorizeRequestValidator private readonly IResourceValidator _resourceValidator; private readonly IUserSession _userSession; private readonly IRequestObjectValidator _requestObjectValidator; + private readonly LicenseUsageTracker _licenseUsage; private readonly ILogger _logger; private readonly ResponseTypeEqualityComparer _responseTypeEqualityComparer = new ResponseTypeEqualityComparer(); + public AuthorizeRequestValidator( IdentityServerOptions options, IIssuerNameService issuerNameService, @@ -43,6 +46,7 @@ public AuthorizeRequestValidator( IResourceValidator resourceValidator, IUserSession userSession, IRequestObjectValidator requestObjectValidator, + LicenseUsageTracker licenseUsage, ILogger logger) { _options = options; @@ -53,6 +57,7 @@ public AuthorizeRequestValidator( _resourceValidator = resourceValidator; _requestObjectValidator = requestObjectValidator; _userSession = userSession; + _licenseUsage = licenseUsage; _logger = logger; } @@ -141,6 +146,7 @@ public async Task ValidateAsync( _logger.LogTrace("Authorize request protocol validation successful"); + _licenseUsage.ClientUsed(request.ClientId); IdentityServerLicenseValidator.Instance.ValidateClient(request.ClientId); return Valid(request); @@ -513,6 +519,7 @@ private async Task ValidateScopeAndResourceAsy } } + _licenseUsage.ResourceIndicatorsUsed(resourceIndicators); IdentityServerLicenseValidator.Instance.ValidateResourceIndicators(resourceIndicators); if (validatedResources.Resources.IdentityResources.Any() && !request.IsOpenIdRequest) diff --git a/src/IdentityServer/Validation/Default/BackchannelAuthenticationRequestValidator.cs b/src/IdentityServer/Validation/Default/BackchannelAuthenticationRequestValidator.cs index a492233e6..38981e198 100644 --- a/src/IdentityServer/Validation/Default/BackchannelAuthenticationRequestValidator.cs +++ b/src/IdentityServer/Validation/Default/BackchannelAuthenticationRequestValidator.cs @@ -10,10 +10,10 @@ using System.Linq; using System.Threading.Tasks; using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Licensing.V2; using Duende.IdentityServer.Logging.Models; using Duende.IdentityServer.Models; using static Duende.IdentityServer.Constants; -using Duende.IdentityServer.Services; namespace Duende.IdentityServer.Validation; @@ -25,6 +25,7 @@ internal class BackchannelAuthenticationRequestValidator : IBackchannelAuthentic private readonly IBackchannelAuthenticationUserValidator _backchannelAuthenticationUserValidator; private readonly IJwtRequestValidator _jwtRequestValidator; private readonly ICustomBackchannelAuthenticationValidator _customValidator; + private readonly LicenseUsageTracker _licenseUsage; private readonly ILogger _logger; private ValidatedBackchannelAuthenticationRequest _validatedRequest; @@ -36,6 +37,7 @@ public BackchannelAuthenticationRequestValidator( IBackchannelAuthenticationUserValidator backchannelAuthenticationUserValidator, IJwtRequestValidator jwtRequestValidator, ICustomBackchannelAuthenticationValidator customValidator, + LicenseUsageTracker licenseUsage, ILogger logger) { _options = options; @@ -45,6 +47,7 @@ public BackchannelAuthenticationRequestValidator( _jwtRequestValidator = jwtRequestValidator; _customValidator = customValidator; _logger = logger; + _licenseUsage = licenseUsage; } public async Task ValidateRequestAsync(NameValueCollection parameters, ClientSecretValidationResult clientValidationResult) @@ -71,6 +74,7 @@ public async Task ValidateRequ return Invalid(OidcConstants.BackchannelAuthenticationRequestErrors.UnauthorizedClient, "Unauthorized client"); } + _licenseUsage.FeatureUsed(LicenseFeature.CIBA); IdentityServerLicenseValidator.Instance.ValidateCiba(); ////////////////////////////////////////////////////////// @@ -179,6 +183,7 @@ public async Task ValidateRequ } } + _licenseUsage.ResourceIndicatorsUsed(resourceIndicators); IdentityServerLicenseValidator.Instance.ValidateResourceIndicators(resourceIndicators); _validatedRequest.ValidatedResources = validatedResources; diff --git a/src/IdentityServer/Validation/Default/PushedAuthorizationRequestValidator.cs b/src/IdentityServer/Validation/Default/PushedAuthorizationRequestValidator.cs index 4f11ccb8b..5a46802f8 100644 --- a/src/IdentityServer/Validation/Default/PushedAuthorizationRequestValidator.cs +++ b/src/IdentityServer/Validation/Default/PushedAuthorizationRequestValidator.cs @@ -1,4 +1,4 @@ -// Copyright (c) Duende Software. All rights reserved. +// Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Duende.IdentityServer.Extensions; using Duende.IdentityModel; +using Duende.IdentityServer.Licensing.V2; namespace Duende.IdentityServer.Validation; @@ -19,6 +20,7 @@ namespace Duende.IdentityServer.Validation; internal class PushedAuthorizationRequestValidator : IPushedAuthorizationRequestValidator { + private readonly LicenseUsageTracker _features; private readonly IAuthorizeRequestValidator _authorizeRequestValidator; /// @@ -28,14 +30,17 @@ internal class PushedAuthorizationRequestValidator : IPushedAuthorizationRequest /// The authorize request validator, /// used to validate the pushed authorization parameters as if they were /// used directly at the authorize endpoint. - public PushedAuthorizationRequestValidator(IAuthorizeRequestValidator authorizeRequestValidator) + /// The feature manager + public PushedAuthorizationRequestValidator(IAuthorizeRequestValidator authorizeRequestValidator, LicenseUsageTracker features) { _authorizeRequestValidator = authorizeRequestValidator; + _features = features; } /// public async Task ValidateAsync(PushedAuthorizationRequestValidationContext context) { + _features.FeatureUsed(LicenseFeature.PAR); IdentityServerLicenseValidator.Instance.ValidatePar(); var validatedRequest = await ValidateRequestUriAsync(context); if(validatedRequest.IsError) diff --git a/src/IdentityServer/Validation/Default/TokenRequestValidator.cs b/src/IdentityServer/Validation/Default/TokenRequestValidator.cs index 0a4f9bc5e..7a6027cf6 100644 --- a/src/IdentityServer/Validation/Default/TokenRequestValidator.cs +++ b/src/IdentityServer/Validation/Default/TokenRequestValidator.cs @@ -14,6 +14,7 @@ using System.Threading.Tasks; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Events; +using Duende.IdentityServer.Licensing.V2; using Duende.IdentityServer.Logging.Models; using Duende.IdentityServer.Models; using Duende.IdentityServer.Services; @@ -39,6 +40,7 @@ internal class TokenRequestValidator : ITokenRequestValidator private readonly IDeviceCodeValidator _deviceCodeValidator; private readonly IBackchannelAuthenticationRequestIdValidator _backchannelAuthenticationRequestIdValidator; private readonly IClock _clock; + private readonly LicenseUsageTracker _licenseUsage; private readonly ILogger _logger; private ValidatedTokenRequest _validatedRequest; @@ -60,9 +62,11 @@ public TokenRequestValidator( IDPoPProofValidator dPoPProofValidator, IEventService events, IClock clock, + LicenseUsageTracker licenseUsage, ILogger logger) { _logger = logger; + _licenseUsage = licenseUsage; _options = options; _issuerNameService = issuerNameService; _serverUrls = serverUrls; @@ -235,6 +239,7 @@ private async Task ValidateProofToken(TokenRequest // DPoP if (context.DPoPProofToken.IsPresent()) { + _licenseUsage.FeatureUsed(LicenseFeature.DPoP); IdentityServerLicenseValidator.Instance.ValidateDPoP(); if (context.DPoPProofToken.Length > _options.InputLengthRestrictions.DPoPProofToken) @@ -305,7 +310,9 @@ private async Task RunValidationAsync(Func ValidateAuthorizationCodeReques } } - IdentityServerLicenseValidator.Instance.ValidateResourceIndicators(_validatedRequest.RequestedResourceIndicator); - _validatedRequest.ValidatedResources = validatedResources.FilterByResourceIndicator(_validatedRequest.RequestedResourceIndicator); + var requestedIndicator = _validatedRequest.RequestedResourceIndicator; + _licenseUsage.ResourceIndicatorUsed(requestedIndicator); + IdentityServerLicenseValidator.Instance.ValidateResourceIndicators(requestedIndicator); + _validatedRequest.ValidatedResources = validatedResources.FilterByResourceIndicator(requestedIndicator); ///////////////////////////////////////////// // validate PKCE parameters @@ -810,8 +819,10 @@ private async Task ValidateRefreshTokenRequestAsyn } } - IdentityServerLicenseValidator.Instance.ValidateResourceIndicators(_validatedRequest.RequestedResourceIndicator); - _validatedRequest.ValidatedResources = validatedResources.FilterByResourceIndicator(_validatedRequest.RequestedResourceIndicator); + var requestedIndicator = _validatedRequest.RequestedResourceIndicator; + _licenseUsage.ResourceIndicatorUsed(requestedIndicator); + IdentityServerLicenseValidator.Instance.ValidateResourceIndicators(requestedIndicator); + _validatedRequest.ValidatedResources = validatedResources.FilterByResourceIndicator(requestedIndicator); _logger.LogDebug("Validation of refresh token request success"); // todo: more logging - similar to TokenValidator before @@ -890,7 +901,9 @@ private async Task ValidateDeviceCodeRequestAsync( } } - IdentityServerLicenseValidator.Instance.ValidateResourceIndicators(_validatedRequest.RequestedResourceIndicator); + var requestedIndicator = _validatedRequest.RequestedResourceIndicator; + _licenseUsage.ResourceIndicatorUsed(requestedIndicator); + IdentityServerLicenseValidator.Instance.ValidateResourceIndicators(requestedIndicator); _validatedRequest.ValidatedResources = validatedResources; _logger.LogDebug("Validation of device code token request success"); @@ -911,6 +924,7 @@ private async Task ValidateCibaRequestRequestAsync return Invalid(OidcConstants.TokenErrors.UnauthorizedClient); } + _licenseUsage.FeatureUsed(LicenseFeature.CIBA); IdentityServerLicenseValidator.Instance.ValidateCiba(); ///////////////////////////////////////////// @@ -976,9 +990,10 @@ private async Task ValidateCibaRequestRequestAsync } } - IdentityServerLicenseValidator.Instance.ValidateResourceIndicators(_validatedRequest.RequestedResourceIndicator); - _validatedRequest.ValidatedResources = validatedResources.FilterByResourceIndicator(_validatedRequest.RequestedResourceIndicator); - + var requestedIndicator = _validatedRequest.RequestedResourceIndicator; + _licenseUsage.ResourceIndicatorUsed(requestedIndicator); + IdentityServerLicenseValidator.Instance.ValidateResourceIndicators(requestedIndicator); + _validatedRequest.ValidatedResources = validatedResources.FilterByResourceIndicator(requestedIndicator); _logger.LogDebug("Validation of CIBA token request success"); @@ -1158,8 +1173,10 @@ private async Task ValidateRequestedScopesAndResourcesAsync(NameValueCol _validatedRequest.RequestedScopes = requestedScopes; - IdentityServerLicenseValidator.Instance.ValidateResourceIndicators(_validatedRequest.RequestedResourceIndicator); - _validatedRequest.ValidatedResources = resourceValidationResult.FilterByResourceIndicator(_validatedRequest.RequestedResourceIndicator); + var requestedIndicator = _validatedRequest.RequestedResourceIndicator; + _licenseUsage.ResourceIndicatorUsed(requestedIndicator); + IdentityServerLicenseValidator.Instance.ValidateResourceIndicators(requestedIndicator); + _validatedRequest.ValidatedResources = resourceValidationResult.FilterByResourceIndicator(requestedIndicator); return null; } diff --git a/test/IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs b/test/IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs index 2ab260b13..fe91e6843 100644 --- a/test/IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs +++ b/test/IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs @@ -7,7 +7,6 @@ using System.Linq; using System.Net; using System.Net.Http; -using System.Net.Http.Json; using System.Security.Claims; using System.Text.Json; using System.Threading; @@ -16,11 +15,11 @@ using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Models; -using Duende.IdentityServer.ResponseHandling; using Duende.IdentityServer.Services; using Duende.IdentityServer.Test; using FluentAssertions; using Duende.IdentityModel.Client; +using IdentityServer.IntegrationTests.Common; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -50,7 +49,6 @@ public class IdentityServerPipeline public const string RevocationEndpoint = BaseUrl + "/connect/revocation"; public const string UserInfoEndpoint = BaseUrl + "/connect/userinfo"; public const string IntrospectionEndpoint = BaseUrl + "/connect/introspect"; - public const string IdentityTokenValidationEndpoint = BaseUrl + "/connect/identityTokenValidation"; public const string EndSessionEndpoint = BaseUrl + "/connect/endsession"; public const string EndSessionCallbackEndpoint = BaseUrl + "/connect/endsession/callback"; public const string CheckSessionEndpoint = BaseUrl + "/connect/checksession"; @@ -75,6 +73,8 @@ public class IdentityServerPipeline public MockMessageHandler BackChannelMessageHandler { get; set; } = new MockMessageHandler(); public MockMessageHandler JwtRequestMessageHandler { get; set; } = new MockMessageHandler(); + public MockLogger MockLogger { get; set; } = MockLogger.Create(); + public event Action OnPreConfigureServices = services => { }; public event Action OnPostConfigureServices = services => { }; public event Action OnPreConfigure = app => { }; @@ -103,7 +103,10 @@ public void Initialize(string basePath = null, bool enableLogging = false) if (enableLogging) { - builder.ConfigureLogging((ctx, b) => b.AddConsole()); + // Configure logging so that the logger provider will always use our mock logger + // The MockLogger allows us to verify that particular messages were logged. + builder.ConfigureLogging((ctx, b) => + b.Services.AddSingleton(new MockLoggerProvider(MockLogger))); } Server = new TestServer(builder); diff --git a/test/IdentityServer.IntegrationTests/Common/MockLogger.cs b/test/IdentityServer.IntegrationTests/Common/MockLogger.cs new file mode 100644 index 000000000..83468898a --- /dev/null +++ b/test/IdentityServer.IntegrationTests/Common/MockLogger.cs @@ -0,0 +1,51 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; + +namespace IdentityServer.IntegrationTests.Common; + +public class MockLogger : ILogger +{ + public static MockLogger Create() => new MockLogger(new LoggerExternalScopeProvider()); + public MockLogger(LoggerExternalScopeProvider scopeProvider) + { + _scopeProvider = scopeProvider; + } + + public readonly List LogMessages = new(); + + + private readonly LoggerExternalScopeProvider _scopeProvider; + + + public IDisposable BeginScope(TState state) where TState : notnull => _scopeProvider.Push(state); + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + LogMessages.Add(formatter(state, exception)); + } +} + +public class MockLogger : MockLogger, ILogger +{ + public MockLogger(LoggerExternalScopeProvider scopeProvider) : base(scopeProvider) + { + } +} + +public class MockLoggerProvider(MockLogger logger) : ILoggerProvider +{ + public void Dispose() + { + } + + public ILogger CreateLogger(string categoryName) + { + return logger; + } +} \ No newline at end of file diff --git a/test/IdentityServer.IntegrationTests/Hosting/LicenseTests.cs b/test/IdentityServer.IntegrationTests/Hosting/LicenseTests.cs new file mode 100644 index 000000000..8793a15b7 --- /dev/null +++ b/test/IdentityServer.IntegrationTests/Hosting/LicenseTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using Duende.IdentityServer.Licensing.V2; +using Duende.IdentityServer.Models; +using FluentAssertions; +using IntegrationTests.Common; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace IntegrationTests.Hosting; + +public class LicenseTests : IDisposable +{ + private string client_id = "client"; + private string client_secret = "secret"; + private string scope_name = "api"; + + private IdentityServerPipeline _mockPipeline = new(); + + public LicenseTests() + { + _mockPipeline.Clients.Add(new Client + { + ClientId = client_id, + ClientSecrets = [new Secret(client_secret.Sha256())], + AllowedGrantTypes = GrantTypes.ClientCredentials, + AllowedScopes = ["api"], + }); + _mockPipeline.ApiScopes = [new ApiScope(scope_name)]; + } + + public void Dispose() + { + // Some of our tests involve copying test license files so that the pipeline will read them. + // This should ensure that they are cleanup up after each test. + var contentRoot = Path.GetFullPath(Directory.GetCurrentDirectory()); + var path1 = Path.Combine(contentRoot, "Duende_License.key"); + if (File.Exists(path1)) + { + File.Delete(path1); + } + var path2 = Path.Combine(contentRoot, "Duende_IdentityServer_License.key"); + if (File.Exists(path2)) + { + File.Delete(path2); + } + } + + [Fact] + public async Task unlicensed_protocol_requests_log_a_warning() + { + var threshold = 5u; + _mockPipeline.OnPostConfigure += builder => + { + var counter = builder.ApplicationServices.GetRequiredService(); + counter.Threshold = threshold; + }; + _mockPipeline.Initialize(enableLogging: true); + + // The actual protocol parameters aren't the point of this test, this could be any protocol request + var data = new Dictionary + { + { "grant_type", "client_credentials" }, + { "client_id", client_id }, + { "client_secret", client_secret }, + { "scope", scope_name }, + }; + var form = new FormUrlEncodedContent(data); + + for (int i = 0; i < threshold + 1; i++) + { + await _mockPipeline.BackChannelClient.PostAsync(IdentityServerPipeline.TokenEndpoint, form); + } + + _mockPipeline.MockLogger.LogMessages.Should().Contain( + $"IdentityServer has handled {threshold + 1} protocol requests without a license. In future versions, unlicensed IdentityServer instances will shut down after {threshold} protocol requests. Please contact sales to obtain a license. If you are running in a test environment, please use a test license"); + } +} \ No newline at end of file diff --git a/test/IdentityServer.IntegrationTests/IdentityServer.IntegrationTests.csproj b/test/IdentityServer.IntegrationTests/IdentityServer.IntegrationTests.csproj index 5fe373652..25203da80 100644 --- a/test/IdentityServer.IntegrationTests/IdentityServer.IntegrationTests.csproj +++ b/test/IdentityServer.IntegrationTests/IdentityServer.IntegrationTests.csproj @@ -9,6 +9,7 @@ + @@ -36,6 +37,12 @@ + + + PreserveNewest + + + diff --git a/test/IdentityServer.UnitTests/Hosting/EndpointRouterTests.cs b/test/IdentityServer.UnitTests/Hosting/EndpointRouterTests.cs index e2a99b14d..ee7915f95 100644 --- a/test/IdentityServer.UnitTests/Hosting/EndpointRouterTests.cs +++ b/test/IdentityServer.UnitTests/Hosting/EndpointRouterTests.cs @@ -8,9 +8,11 @@ using Duende.IdentityServer; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Hosting; +using Duende.IdentityServer.Licensing.V2; using FluentAssertions; using UnitTests.Common; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace UnitTests.Hosting; @@ -18,16 +20,18 @@ namespace UnitTests.Hosting; public class EndpointRouterTests { private Dictionary _pathMap; - private List _endpoints; - private IdentityServerOptions _options; - private EndpointRouter _subject; + private readonly List _endpoints; + private readonly IdentityServerOptions _options; + private readonly EndpointRouter _subject; public EndpointRouterTests() { _pathMap = new Dictionary(); _endpoints = new List(); _options = new IdentityServerOptions(); - _subject = new EndpointRouter(_endpoints, _options, TestLogger.Create()); + var licenseAccessor = new LicenseAccessor(new IdentityServerOptions(), NullLogger.Instance); + var protocolRequestCounter = new ProtocolRequestCounter(licenseAccessor, new NullLoggerFactory()); + _subject = new EndpointRouter(_endpoints, protocolRequestCounter, _options, TestLogger.Create()); } [Fact] diff --git a/test/IdentityServer.UnitTests/IdentityServer.UnitTests.csproj b/test/IdentityServer.UnitTests/IdentityServer.UnitTests.csproj index 3a0c3fe86..674ea4fce 100644 --- a/test/IdentityServer.UnitTests/IdentityServer.UnitTests.csproj +++ b/test/IdentityServer.UnitTests/IdentityServer.UnitTests.csproj @@ -10,6 +10,8 @@ + + @@ -29,5 +31,9 @@ + + + + diff --git a/test/IdentityServer.UnitTests/Licensing/v2/LicenseAccessorTests.cs b/test/IdentityServer.UnitTests/Licensing/v2/LicenseAccessorTests.cs new file mode 100644 index 000000000..3b5104fb0 --- /dev/null +++ b/test/IdentityServer.UnitTests/Licensing/v2/LicenseAccessorTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using Duende.IdentityServer.Licensing.V2; +using Duende.IdentityServer.Configuration; +using FluentAssertions; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace IdentityServer.UnitTests.Licensing.V2; + +public class LicenseAccessorTests +{ + private readonly IdentityServerOptions _options; + private readonly LicenseAccessor _licenseAccessor; + private readonly FakeLogger _logger; + + public LicenseAccessorTests() + { + _options = new IdentityServerOptions(); + _logger = new FakeLogger(); + _licenseAccessor = new LicenseAccessor(_options, _logger); + } + + [Theory] + [MemberData(nameof(LicenseTestCases))] + internal void license_set_in_options_is_parsed_correctly(int serialNumber, LicenseEdition edition, bool isRedistribution, string contact, bool addDynamicProviders, bool addKeyManagement, string key) + { + _options.LicenseKey = key; + + var l = _licenseAccessor.Current; + + l.IsConfigured.Should().BeTrue(); + l.Edition.Should().Be(edition); + l.Extras.Should().BeEmpty(); + l.CompanyName.Should().Be("_test"); + l.ContactInfo.Should().Be(contact); + l.SerialNumber.Should().Be(serialNumber); + l.Expiration!.Value.Date.Should().Be(new DateTime(2024, 11, 15)); + l.Redistribution.Should().Be(isRedistribution); + + var enterpriseFeaturesEnabled = edition == LicenseEdition.Enterprise || edition == LicenseEdition.Community; + var businessFeaturesEnabled = enterpriseFeaturesEnabled || edition == LicenseEdition.Business; + + _licenseAccessor.Current.IsEnabled(LicenseFeature.DynamicProviders).Should().Be(enterpriseFeaturesEnabled || addDynamicProviders); + _licenseAccessor.Current.IsEnabled(LicenseFeature.ResourceIsolation).Should().Be(enterpriseFeaturesEnabled); + _licenseAccessor.Current.IsEnabled(LicenseFeature.DPoP).Should().Be(enterpriseFeaturesEnabled); + _licenseAccessor.Current.IsEnabled(LicenseFeature.CIBA).Should().Be(enterpriseFeaturesEnabled); + + _licenseAccessor.Current.IsEnabled(LicenseFeature.KeyManagement).Should().Be(businessFeaturesEnabled || addKeyManagement); + _licenseAccessor.Current.IsEnabled(LicenseFeature.PAR).Should().Be(businessFeaturesEnabled); + _licenseAccessor.Current.IsEnabled(LicenseFeature.DCR).Should().Be(businessFeaturesEnabled); + _licenseAccessor.Current.IsEnabled(LicenseFeature.ServerSideSessions).Should().Be(businessFeaturesEnabled); + } + + + public static IEnumerable LicenseTestCases() => + [ + // Order of parameters is: int serialNumber, LicenseEdition edition, bool isRedistribution, string contact, string key + + // Standard licenses + [6685, LicenseEdition.Enterprise, false, "joe@duendesoftware.com", false, false, "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzMwNDE5MjAwLCJleHAiOjE3MzE2Mjg4MDAsImNvbXBhbnlfbmFtZSI6Il90ZXN0IiwiY29udGFjdF9pbmZvIjoiam9lQGR1ZW5kZXNvZnR3YXJlLmNvbSIsImVkaXRpb24iOiJFbnRlcnByaXNlIiwiaWQiOiI2Njg1In0.UgguIFVBciR8lpTF5RuM3FNcIm8m8wGR4Mt0xOCgo-XknFwXBpxOfr0zVjciGboteOl9AFtrqZLopEjsYXGFh2dkl5AzRyq--Ai5y7aezszlMpq8SkjRRCeBUYLNnEO41_YnfjYhNrcmb0Jx9wMomCv74vU3f8Hulz1ppWtoL-MVcGq0fhv_KOCP49aImCgiawPJ6a_bfs2C1QLpj-GG411OhdyrO9QLIH_We4BEvRUyajraisljB1VQzC8Q6188Mm_BLwl4ZENPaoNE4egiqTAuoTS5tb1l732-CGZwpGuU80NSpJbrUc6jd3rVi_pNf_1rH-O4Xt0HRCWiNCDYgg"], + [6678, LicenseEdition.Business, false, "joe@duendesoftware.com", false, false, "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzMwNDE5MjAwLCJleHAiOjE3MzE2Mjg4MDAsImNvbXBhbnlfbmFtZSI6Il90ZXN0IiwiY29udGFjdF9pbmZvIjoiam9lQGR1ZW5kZXNvZnR3YXJlLmNvbSIsImVkaXRpb24iOiJCdXNpbmVzcyIsImlkIjoiNjY3OCJ9.qps2bV5C9TXG-U9hLM7hMrdm8PVMxqDFjAVHSkdvDs7fb03ejOE_8_D1RAUIJtNlZQw2zO1aCgWMEC8O2so8HzwxG4ic9tTZj-Nccn6azkRJ-R412LEl8jpRS64Y0FXqrv2cmhpd82dLEneK8IikoqryJjF0f12Fsqadpveuz_IMAPixOX1X0thXyblDH58FJnP6N0sSp94yT8Gr4V8G5wfX7fM3vBu_Aa9YZIaUxDLtW7eujHFkRoqfCOwxpa_4gqBg5Q8XwvU9fLJrHkQvtpqEbJWydixRstKF4XBwzyCKfXCegas8OH6yW8FN3V-tgaj-WeCpmgDkE5ngTCuV6g"], + [6677, LicenseEdition.Starter, false, "joe@duendesoftware.com", false, false, "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzMwNDE5MjAwLCJleHAiOjE3MzE2Mjg4MDAsImNvbXBhbnlfbmFtZSI6Il90ZXN0IiwiY29udGFjdF9pbmZvIjoiam9lQGR1ZW5kZXNvZnR3YXJlLmNvbSIsImVkaXRpb24iOiJTdGFydGVyIiwiaWQiOiI2Njc3In0.WEEZFmwoSmJYVJ9geeSKvpB5GaJKQBUUFfABeeQEwh3Tkdg4gnjEme9WJS03MZkxMPj7nEfv8i0Tl1xwTC4gWpV2bfqDzj3R3eKCvz6BZflcmr14j4fbhbc7jDD26b5wAdyiD3krvkd2VsvVnYTTRCilK1UKr6ZVhmSgU8oXgth8JjQ2wIQ80p9D2nurHuWq6UdFdNqbO8aDu6C2eOQuAVmp6gKo7zBbFTbO1G1J1rGyWX8kXYBZMN0Rj_Xp_sdj34uwvzFsJN0i1EwhFATFS6vf6na_xpNz9giBNL04ulDRR95ZSE1vmRoCuP96fsgK7aYCJV1WSRBHXIrwfJhd7A"], + [6679, LicenseEdition.Bff, false, "joe@duendesoftware.com", false, false, "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzMwNDE5MjAwLCJleHAiOjE3MzE2Mjg4MDAsImNvbXBhbnlfbmFtZSI6Il90ZXN0IiwiY29udGFjdF9pbmZvIjoiam9lQGR1ZW5kZXNvZnR3YXJlLmNvbSIsImVkaXRpb24iOiJCZmYiLCJpZCI6IjY2NzkifQ.kZFlPuSZRG-p_S5M5inZjHEFB2mGjRri8ogSXj-CnyUmHcoNOaRzQFIC6YqZQjBNmd8-BWRhfyCDj_Ux8hJpPBPKzfYfpd__YvmF3gdRCgZJVSgwCsETH5b0neoPh1SixVxKtpYPHzQF3t-MRfoej50omwCpMBa7phfqJ7aMQnQxQnFe9yVaTJC63HFsOnaXLkGpGGz_Xm-J14uqwXmJsi2qyJV_9elk6Ip_6hK2tcZBanMsLcyPDXVdciCuUZ8hbzmcfeuNSD-UYsMb2NTtrFEBEuAQ5_JIXZ4ZzRfeXFJOOYW43UyPxwjC1XjmN9ruUSAK_ouYiaOScBhH00kLlg"], + [6703, LicenseEdition.Community, false, "joe@duendesoftware.com", false, false,"eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzAwODcwNDAwLCJleHAiOjE3MzE2Mjg4MDAsImNvbXBhbnlfbmFtZSI6Il90ZXN0IiwiY29udGFjdF9pbmZvIjoiam9lQGR1ZW5kZXNvZnR3YXJlLmNvbSIsImVkaXRpb24iOiJDb21tdW5pdHkiLCJpZCI6IjY3MDMifQ.UxC5uefxXA9sC2sdIEP6FCUEeBhl1EQuVsjL9kpXpPhvy3xCj0sSKcxkw1QshpM-u23hOEj0enzAgwUPNrkC8QH5xIigpI2FknFKSNjKl2dD5s_fqPTr7re_fefbE3WeImogpKOcwMHETr_BeUbvUbrvCw_5sZcYtsUy15d8wf6ZZlVqUCL027qNB5Ssg-fTq3j_8QNRoJCFEnl2Q6MIVY4wyeb2fxF63V2vpNFh8zDUJlLerDhCIvWngLOc8VyArTrjmrIsHSP2xFSFrMZfen_vOjo9-Oo8BU7JUw1PWdAMIqTO0CWK5DrZfTsEWDPCdHzmbzMSmyjKxoejfAH1og"], + + // Redistribution licenses + [6684, LicenseEdition.Enterprise, true, "contact@duendesoftware.com", false, false, "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzMwNDE5MjAwLCJleHAiOjE3MzE2Mjg4MDAsImNvbXBhbnlfbmFtZSI6Il90ZXN0IiwiY29udGFjdF9pbmZvIjoiY29udGFjdEBkdWVuZGVzb2Z0d2FyZS5jb20iLCJlZGl0aW9uIjoiRW50ZXJwcmlzZSIsImlkIjoiNjY4NCIsImZlYXR1cmUiOiJpc3YiLCJwcm9kdWN0IjoiVEJEIn0.Y-bbdSsdHHzrJs40CpEIsgi7ugc8ScTa2ArCuL-wM__O6znygAUTGOLrzhFaeRibud5lNXSYaA0vkkF1UFQS4HJF_wTMe5pYH4DT1vVYaVXd9Xyqn-klQvBLcoo4JAoFNau0Az-czbo6UBkejKn-7QDnJunFcHaYenDpzgsXHiaK4mkIMRI_OnBYKegNa_xvYRRzorKkT3x8q1n7vUnx80-b6Jf2Y0u6fPsLwE2Or-VBXRpTGL20MBtcPS56wQDDdl4eKkW716lHS-Iyh5KW3K5HVKRxd86ot18MY6Bd3PPUQocFYXd5KhTH_YKvwVqAUkc0MhHYJLFV_5Q8qSRECA"], + [6683, LicenseEdition.Business, true, "contact@duendesoftware.com", false, false, "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzMwNDE5MjAwLCJleHAiOjE3MzE2Mjg4MDAsImNvbXBhbnlfbmFtZSI6Il90ZXN0IiwiY29udGFjdF9pbmZvIjoiY29udGFjdEBkdWVuZGVzb2Z0d2FyZS5jb20iLCJlZGl0aW9uIjoiQnVzaW5lc3MiLCJpZCI6IjY2ODMiLCJmZWF0dXJlIjoiaXN2IiwicHJvZHVjdCI6IlRCRCJ9.rYDrY6UUKgZfnfx7GA1PILYj9XICIjC9aS06P8rUAuXYjxiagEIEkacKt3GcccJI6k0lMb6qbd3Hv-Q9rDDyDSxUZxwvGzVlhRrIditOI38FoN3trUd5RU6S7A_RSDd4uV0L1T8NKUKGlOvu8_7egcIy-E8q34GA5BNU2lV2Gsaa7yWAyTKZh7YPIP4y_TwLxOcw2GRn6dQq73-O_XaAIf0AxFowW1GsiBrirzE_TKwJ8VkbvN3O-yVT-ntPvoK0tHRKoG5yh8GPuDORQtlis_5bZHHFzazXVMul1rkYWSU9OhIdixvI44q1q1_5VGoGJ3SLFIFsdWM0ZvnPx7_Bqg"], + [6682, LicenseEdition.Starter, true, "contact@duendesoftware.com", false, false, "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzMwNDE5MjAwLCJleHAiOjE3MzE2Mjg4MDAsImNvbXBhbnlfbmFtZSI6Il90ZXN0IiwiY29udGFjdF9pbmZvIjoiY29udGFjdEBkdWVuZGVzb2Z0d2FyZS5jb20iLCJlZGl0aW9uIjoiU3RhcnRlciIsImlkIjoiNjY4MiIsImZlYXR1cmUiOiJpc3YiLCJwcm9kdWN0IjoiVEJEIn0.Ag4HLR1TVJ2VYgW1MJbpIHvAerx7zaHoM4CLu7baipsZVwc82ZkmLUeO_yB3CqN7N6XepofwZ-RcloxN8UGZ6qPRGQPE1cOMrp8YqxLOI38gJbxALOBG5BB6YTCMf_TKciXn1c3XhrsxVDayMGxAU68fKDCg1rnamBehZfXr2uENipNPkGDh_iuRw2MUgeGY96CGvwCC5R0E6UnvGZbjQ7dFYV-CkAHuE8dEAr0pX_gD77YsYcSxq5rNUavcNnWV7-3knFwozNqi02wTDpcKtqaL2mAr0nRof1E8Df9C8RwCTWXSaWhr9_47W2I1r_IhLYS2Jnq6m_3BgAIvWL4cjQ"], + + // Licenses with extra features + [6681, LicenseEdition.Business, false, "joe@duendesoftware.com", true, false, "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzMwNDE5MjAwLCJleHAiOjE3MzE2Mjg4MDAsImNvbXBhbnlfbmFtZSI6Il90ZXN0IiwiY29udGFjdF9pbmZvIjoiam9lQGR1ZW5kZXNvZnR3YXJlLmNvbSIsImVkaXRpb24iOiJCdXNpbmVzcyIsImlkIjoiNjY4MSIsImZlYXR1cmUiOiJkeW5hbWljX3Byb3ZpZGVycyJ9.HeCNt4O1cXsw4Ujkn2W_sDRmWUDstYtLPQ7UhYvneUgxed7auFyroBJojkwh9RwflWD1HphHYx4KRuZML_OO0BYzGr865gWI55x6KxHM5mxY5hpVJMTLottSgIv-hyXdNxTWCxP1jluzs1b4JgWmXnU83AuRtAenMpZpZcOY7Pldkd84JA1BXE5gEM6v2U8HCTgydY1QmTd_RjYlicGqmDOkKALiHOxREyXLsRgy4pmQfG6gs99heXdzs2k4jRLLXsTFHP7UxupRTYDPCgXT19ub6l4KG95rPBSMV_vXEwydcFGJe1uFQdd1btUSVe50XX1hmZx4P4SymlX0iuimMg"], + [6680, LicenseEdition.Starter, false, "joe@duendesoftware.com", false, true, "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzMwNDE5MjAwLCJleHAiOjE3MzE2Mjg4MDAsImNvbXBhbnlfbmFtZSI6Il90ZXN0IiwiY29udGFjdF9pbmZvIjoiam9lQGR1ZW5kZXNvZnR3YXJlLmNvbSIsImVkaXRpb24iOiJTdGFydGVyIiwiaWQiOiI2NjgwIiwiZmVhdHVyZSI6ImtleV9tYW5hZ2VtZW50In0.kmArT0vjFE4nhRNg_kchOh_uklaqm3KeworQ9up_4jIBOinbZtVv3NkXtJoHX_lzjs1ftp0eNMSyGg6E29GR7ZZ2hx3SQdQrSdrH4v_sNSFcRZrwzipXBkANssH-0hMQ0s3kdfXdwfmN_8IfCkPCugeMemwUWwbC7QHBdCa6Fr7ZExuMNLpml932D72LMzhlLf780BSic9PKn6odvzGikYK9e2WhYL1zL0REdNHzgwrrUZHesZF98u-gel7skS1Frg6cBcPl_QSSP5KhxmfdPw0b2FUM_B0Tpi-gN54efz0stzccjr9PgcpAfXO82y3vOBB7f44cdv6DG67YwAvv0A"] + ]; + + + + [Fact] + public void keys_that_cannot_be_parsed_are_treated_the_same_as_an_absent_license() + { + _options.LicenseKey = "invalid key"; + _licenseAccessor.Current.IsConfigured.Should().BeFalse(); + _logger.Collector.GetSnapshot().Should().Contain(r => + r.Message == "Error validating the Duende software license key"); + } +} \ No newline at end of file diff --git a/test/IdentityServer.UnitTests/Licensing/v2/LicenseFactory.cs b/test/IdentityServer.UnitTests/Licensing/v2/LicenseFactory.cs new file mode 100644 index 000000000..2a4be7362 --- /dev/null +++ b/test/IdentityServer.UnitTests/Licensing/v2/LicenseFactory.cs @@ -0,0 +1,27 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Security.Claims; +using Duende.IdentityServer.Licensing.V2; + +namespace IdentityServer.UnitTests.Licensing.V2; + +internal static class LicenseFactory +{ + public static License Create(LicenseEdition edition, DateTimeOffset? expiration = null, bool redistribution = false) + { + expiration ??= DateTimeOffset.MaxValue; + var claims = new List + { + new Claim("exp", expiration.Value.ToUnixTimeSeconds().ToString()), + new Claim("edition", edition.ToString()), + }; + if (redistribution) + { + claims.Add(new Claim("feature", "redistribution")); + } + return new(new ClaimsPrincipal(new ClaimsIdentity(claims))); + } +} \ No newline at end of file diff --git a/test/IdentityServer.UnitTests/Licensing/v2/LicenseUsageTests.cs b/test/IdentityServer.UnitTests/Licensing/v2/LicenseUsageTests.cs new file mode 100644 index 000000000..9cbaf5b4f --- /dev/null +++ b/test/IdentityServer.UnitTests/Licensing/v2/LicenseUsageTests.cs @@ -0,0 +1,79 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Licensing.V2; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace IdentityServer.UnitTests.Licensing.V2; + +public class LicenseUsageTests +{ + private readonly LicenseUsageTracker _licenseUsageTracker; + + public LicenseUsageTests() + { + var options = new IdentityServerOptions(); + var licenseAccessor = new LicenseAccessor(options, NullLogger.Instance); + _licenseUsageTracker = new LicenseUsageTracker(licenseAccessor); + } + + [Fact] + public void used_features_are_reported() + { + _licenseUsageTracker.FeatureUsed(LicenseFeature.KeyManagement); + _licenseUsageTracker.FeatureUsed(LicenseFeature.PAR); + _licenseUsageTracker.FeatureUsed(LicenseFeature.ResourceIsolation); + _licenseUsageTracker.FeatureUsed(LicenseFeature.DynamicProviders); + _licenseUsageTracker.FeatureUsed(LicenseFeature.CIBA); + _licenseUsageTracker.FeatureUsed(LicenseFeature.ServerSideSessions); + _licenseUsageTracker.FeatureUsed(LicenseFeature.DPoP); + _licenseUsageTracker.FeatureUsed(LicenseFeature.DCR); + _licenseUsageTracker.FeatureUsed(LicenseFeature.ISV); + _licenseUsageTracker.FeatureUsed(LicenseFeature.Redistribution); + + var summary = _licenseUsageTracker.GetSummary(); + + summary.FeaturesUsed.Should().Contain(LicenseFeature.KeyManagement.ToString()); + summary.FeaturesUsed.Should().Contain(LicenseFeature.PAR.ToString()); + summary.FeaturesUsed.Should().Contain(LicenseFeature.ServerSideSessions.ToString()); + summary.FeaturesUsed.Should().Contain(LicenseFeature.DCR.ToString()); + summary.FeaturesUsed.Should().Contain(LicenseFeature.KeyManagement.ToString()); + + summary.FeaturesUsed.Should().Contain(LicenseFeature.ResourceIsolation.ToString()); + summary.FeaturesUsed.Should().Contain(LicenseFeature.DynamicProviders.ToString()); + summary.FeaturesUsed.Should().Contain(LicenseFeature.CIBA.ToString()); + summary.FeaturesUsed.Should().Contain(LicenseFeature.DPoP.ToString()); + + summary.FeaturesUsed.Should().Contain(LicenseFeature.ISV.ToString()); + summary.FeaturesUsed.Should().Contain(LicenseFeature.Redistribution.ToString()); + } + + [Fact] + public void used_clients_are_reported() + { + _licenseUsageTracker.ClientUsed("mvc.code"); + _licenseUsageTracker.ClientUsed("mvc.dpop"); + + var summary = _licenseUsageTracker.GetSummary(); + + summary.ClientsUsed.Count.Should().Be(2); + summary.ClientsUsed.Should().Contain("mvc.code"); + summary.ClientsUsed.Should().Contain("mvc.dpop"); + } + + [Fact] + public void used_issuers_are_reported() + { + _licenseUsageTracker.IssuerUsed("https://localhost:5001"); + _licenseUsageTracker.IssuerUsed("https://acme.com"); + + var summary = _licenseUsageTracker.GetSummary(); + + summary.IssuersUsed.Count.Should().Be(2); + summary.IssuersUsed.Should().Contain("https://localhost:5001"); + summary.IssuersUsed.Should().Contain("https://acme.com"); + } +} \ No newline at end of file diff --git a/test/IdentityServer.UnitTests/Licensing/v2/ProtocolRequestCounterTests.cs b/test/IdentityServer.UnitTests/Licensing/v2/ProtocolRequestCounterTests.cs new file mode 100644 index 000000000..593e8a457 --- /dev/null +++ b/test/IdentityServer.UnitTests/Licensing/v2/ProtocolRequestCounterTests.cs @@ -0,0 +1,62 @@ +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Licensing.V2; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace IdentityServer.UnitTests.Licensing.V2; + +public class ProtocolRequestCounterTests +{ + private readonly ProtocolRequestCounter _counter; + private readonly FakeLogger _logger; + + public ProtocolRequestCounterTests() + { + var licenseAccessor = new LicenseAccessor(new IdentityServerOptions(), NullLogger.Instance); + _logger = new FakeLogger(); + _counter = new ProtocolRequestCounter(licenseAccessor, new StubLoggerFactory(_logger)); + } + + [Fact] + public void number_of_protocol_requests_is_counted() + { + for (uint i = 0; i < 10; i++) + { + _counter.Increment(); + _counter.RequestCount.Should().Be(i + 1); + } + } + + [Fact] + public void warning_is_logged_once_after_too_many_protocol_requests_are_handled() + { + _counter.Threshold = 10; + for (uint i = 0; i < _counter.Threshold * 10; i++) + { + _counter.Increment(); + } + + // REMINDER - If this test needs to change because the log message was updated, so should warning_is_not_logged_before_too_many_protocol_requests_are_handled + _logger.Collector.GetSnapshot().Should() + .ContainSingle(r => + r.Message == + $"IdentityServer has handled {_counter.Threshold + 1} protocol requests without a license. In future versions, unlicensed IdentityServer instances will shut down after {_counter.Threshold} protocol requests. Please contact sales to obtain a license. If you are running in a test environment, please use a test license"); + } + + [Fact] + public void warning_is_not_logged_before_too_many_protocol_requests_are_handled() + { + _counter.Threshold = 10; + for (uint i = 0; i < _counter.Threshold; i++) + { + _counter.Increment(); + } + + _logger.Collector.GetSnapshot().Should() + .NotContain(r => + r.Message == + $"IdentityServer has handled {_counter.Threshold + 1} protocol requests without a license. In future versions, unlicensed IdentityServer instances will shut down after {_counter.Threshold} protocol requests. Please contact sales to obtain a license. If you are running in a test environment, please use a test license"); + } +} diff --git a/test/IdentityServer.UnitTests/Licensing/v2/StubLoggerFactory.cs b/test/IdentityServer.UnitTests/Licensing/v2/StubLoggerFactory.cs new file mode 100644 index 000000000..0439cc283 --- /dev/null +++ b/test/IdentityServer.UnitTests/Licensing/v2/StubLoggerFactory.cs @@ -0,0 +1,24 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using Microsoft.Extensions.Logging; + +namespace IdentityServer.UnitTests.Licensing.V2; + +public class StubLoggerFactory(ILogger logger) : ILoggerFactory +{ + public ILogger CreateLogger(string categoryName) + { + return logger; + } + + public void Dispose() + { + } + + public void AddProvider(ILoggerProvider provider) + { + } + + +} \ No newline at end of file diff --git a/test/IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Resources.cs b/test/IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Resources.cs index bc7a72b13..d2bcc206a 100644 --- a/test/IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Resources.cs +++ b/test/IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Resources.cs @@ -11,6 +11,8 @@ using Duende.IdentityServer.Validation; using FluentAssertions; using Duende.IdentityModel; +using Duende.IdentityServer.Licensing.V2; +using Microsoft.Extensions.Logging.Abstractions; using UnitTests.Common; using UnitTests.Validation.Setup; using Xunit; @@ -56,6 +58,7 @@ public Authorize_ProtocolValidation_Resources() _mockResourceValidator, _mockUserSession, Factory.CreateRequestObjectValidator(), + new LicenseUsageTracker(new LicenseAccessor(new IdentityServerOptions(), NullLogger.Instance)), TestLogger.Create()); } diff --git a/test/IdentityServer.UnitTests/Validation/Setup/Factory.cs b/test/IdentityServer.UnitTests/Validation/Setup/Factory.cs index 28e251c5a..d1b523796 100644 --- a/test/IdentityServer.UnitTests/Validation/Setup/Factory.cs +++ b/test/IdentityServer.UnitTests/Validation/Setup/Factory.cs @@ -17,7 +17,8 @@ using Microsoft.Extensions.Logging; using Duende.IdentityServer.Services.KeyManagement; using Duende.IdentityServer; -using Microsoft.AspNetCore.DataProtection; +using Duende.IdentityServer.Licensing.V2; +using Microsoft.Extensions.Logging.Abstractions; namespace UnitTests.Validation.Setup; @@ -138,9 +139,10 @@ public static TokenRequestValidator CreateTokenRequestValidator( resourceValidator, resourceStore, refreshTokenService, - new DefaultDPoPProofValidator(options, new MockReplayCache(), new StubClock(), new StubDataProtectionProvider(), new LoggerFactory().CreateLogger< DefaultDPoPProofValidator >()), + new DefaultDPoPProofValidator(options, new MockReplayCache(), new StubClock(), new StubDataProtectionProvider(), new LoggerFactory().CreateLogger()), new TestEventService(), new StubClock(), + new LicenseUsageTracker(new LicenseAccessor(new IdentityServerOptions(), NullLogger.Instance)), TestLogger.Create()); } @@ -273,6 +275,7 @@ public static AuthorizeRequestValidator CreateAuthorizeRequestValidator( resourceValidator, userSession, requestObjectValidator, + new LicenseUsageTracker(new LicenseAccessor(new IdentityServerOptions(), NullLogger.Instance)), TestLogger.Create()); }