diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs index f641a6b76..7b17c3b6b 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -223,6 +223,8 @@ public static IIdentityServerBuilder AddCoreServices(this IIdentityServerBuilder builder.Services.AddSingleton(); builder.Services.AddSingleton(new BasicServerInfoDiagnosticEntry(Dns.GetHostName)); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/identity-server/src/IdentityServer/Infrastructure/RemovePropertyModifier.cs b/identity-server/src/IdentityServer/Infrastructure/RemovePropertyModifier.cs new file mode 100644 index 000000000..3c6a94e63 --- /dev/null +++ b/identity-server/src/IdentityServer/Infrastructure/RemovePropertyModifier.cs @@ -0,0 +1,25 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Text.Json.Serialization.Metadata; + +namespace Duende.IdentityServer.Infrastructure; + +public class RemovePropertyModifier(List propertiesToRemove) +{ + public void ModifyTypeInfo(JsonTypeInfo typeInfo) + { + if (typeInfo.Type != typeof(T)) + { + return; + } + + var propsToKeep = typeInfo.Properties.Where(propertyInfo => !propertiesToRemove.Contains(propertyInfo.Name)).ToArray(); + + typeInfo.Properties.Clear(); + foreach (var prop in propsToKeep) + { + typeInfo.Properties.Add(prop); + } + } +} diff --git a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/ClientLoadedTracker.cs b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/ClientLoadedTracker.cs new file mode 100644 index 000000000..6c01d115e --- /dev/null +++ b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/ClientLoadedTracker.cs @@ -0,0 +1,114 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Licensing.V2.Diagnostics; + +internal class ClientLoadedTracker : IDisposable +{ + private const int MaxClientsTrackedCount = 100; + private const int ArrayMaxSize = 10; + + private int _clientCount; + + private readonly ConcurrentDictionary _clients = new(); + private readonly List _propertiesToExclude = [nameof(Client.Properties), nameof(Client.LogoUri), nameof(Client.Claims)]; + private readonly JsonDocument _defaultClient; + private readonly JsonSerializerOptions _serializerOptions = new() + { + Converters = { new JsonStringEnumConverter() }, + WriteIndented = false + }; + + public ClientLoadedTracker() => _defaultClient = JsonSerializer.SerializeToDocument(new Client(), _serializerOptions); + + public void TrackClientLoaded(Client client) + { + if (_clientCount >= MaxClientsTrackedCount) + { + return; + } + + using var clientJson = JsonSerializer.SerializeToDocument(client, _serializerOptions); + var clientDiagnosticData = new JsonObject(); + foreach (var property in _defaultClient.RootElement.EnumerateObject()) + { + if (_propertiesToExclude.Contains(property.Name)) + { + continue; + } + + if (!_defaultClient.RootElement.TryGetProperty(property.Name, out var defaultValue) || + !clientJson.RootElement.TryGetProperty(property.Name, out var clientValue)) + { + continue; + } + + if (property.NameEquals(nameof(Client.ClientSecrets))) + { + var secrets = clientValue.EnumerateArray() + .Select(secret => secret.GetProperty(nameof(Secret.Type)).GetString()) + .Distinct() + .Select(secret => JsonValue.Create(secret)) + .Cast(); + clientDiagnosticData["SecretTypes"] = new JsonArray(secrets.ToArray()); + } + else if (defaultValue.ValueKind == JsonValueKind.Array && clientValue.ValueKind == JsonValueKind.Array && clientValue.GetArrayLength() > 0) + { + var arrayEntries = clientValue.EnumerateArray().Take(ArrayMaxSize).Select(CreateJsonValue).Cast(); + clientDiagnosticData[property.Name] = new JsonArray(arrayEntries.ToArray()); + } + else + { + if (!JsonElementEquals(defaultValue, clientValue)) + { + clientDiagnosticData[property.Name] = CreateJsonValue(clientValue); + } + } + } + + if (_clients.ContainsKey(client.ClientId)) + { + return; + } + + if (_clients.TryAdd(client.ClientId, clientDiagnosticData)) + { + Interlocked.Increment(ref _clientCount); + } + } + + private bool JsonElementEquals(JsonElement a, JsonElement b) + { + if (a.ValueKind != b.ValueKind) + { + return false; + } + + return a.ValueKind switch + { + JsonValueKind.String => a.GetString() == b.GetString(), + JsonValueKind.Number => a.GetDouble().CompareTo(b.GetDouble()) == 0, + JsonValueKind.True or JsonValueKind.False => a.GetBoolean() == b.GetBoolean(), + _ => a.ToString() == b.ToString() + }; + } + + private JsonValue? CreateJsonValue(JsonElement element) => element.ValueKind switch + { + JsonValueKind.String => JsonValue.Create(element.GetString()), + JsonValueKind.Number => JsonValue.Create(element.GetDouble()), + JsonValueKind.True or JsonValueKind.False => JsonValue.Create(element.GetBoolean()), + _ => JsonValue.Create(element.ToString()) + }; + + public IReadOnlyDictionary Clients => _clients; + + public void Dispose() => _defaultClient.Dispose(); +} diff --git a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/ClientInfoDiagnosticEntry.cs b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/ClientInfoDiagnosticEntry.cs new file mode 100644 index 000000000..122d5f60a --- /dev/null +++ b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/ClientInfoDiagnosticEntry.cs @@ -0,0 +1,29 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Text.Json; + +namespace Duende.IdentityServer.Licensing.V2.Diagnostics.DiagnosticEntries; + +internal class ClientInfoDiagnosticEntry(ClientLoadedTracker clientLoadedTracker) : IDiagnosticEntry +{ + private readonly JsonSerializerOptions _serializerOptions = new() + { + WriteIndented = false + }; + + public Task WriteAsync(Utf8JsonWriter writer) + { + writer.WriteStartArray("Clients"); + + foreach (var (clientId, client) in clientLoadedTracker.Clients) + { + JsonSerializer.Serialize(writer, client, _serializerOptions); + } + + writer.WriteEndArray(); + + return Task.CompletedTask; + } +} diff --git a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/IdentityServerOptionsDiagnosticEntry.cs b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/IdentityServerOptionsDiagnosticEntry.cs index d8af1dc53..0bd818446 100644 --- a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/IdentityServerOptionsDiagnosticEntry.cs +++ b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/IdentityServerOptionsDiagnosticEntry.cs @@ -4,17 +4,21 @@ using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Infrastructure; using Microsoft.Extensions.Options; namespace Duende.IdentityServer.Licensing.V2.Diagnostics.DiagnosticEntries; internal class IdentityServerOptionsDiagnosticEntry(IOptions options) : IDiagnosticEntry { + private static readonly RemovePropertyModifier RemoveLicenseKeyModifier = new([ + nameof(IdentityServerOptions.LicenseKey) + ]); private readonly JsonSerializerOptions _serializerOptions = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver { - Modifiers = { RemoveLicenseKeyModifier } + Modifiers = { RemoveLicenseKeyModifier.ModifyTypeInfo } }, WriteIndented = false }; @@ -27,22 +31,4 @@ public Task WriteAsync(Utf8JsonWriter writer) return Task.CompletedTask; } - - private static void RemoveLicenseKeyModifier(JsonTypeInfo typeInfo) - { - if (typeInfo.Type != typeof(IdentityServerOptions)) - { - return; - } - - var propsToKeep = typeInfo.Properties - .Where(prop => prop.Name != nameof(IdentityServerOptions.LicenseKey)) - .ToArray(); - - typeInfo.Properties.Clear(); - foreach (var prop in propsToKeep) - { - typeInfo.Properties.Add(prop); - } - } } diff --git a/identity-server/src/IdentityServer/Validation/Default/AuthorizeRequestValidator.cs b/identity-server/src/IdentityServer/Validation/Default/AuthorizeRequestValidator.cs index bf9a5f2c2..cb10de478 100644 --- a/identity-server/src/IdentityServer/Validation/Default/AuthorizeRequestValidator.cs +++ b/identity-server/src/IdentityServer/Validation/Default/AuthorizeRequestValidator.cs @@ -8,6 +8,7 @@ using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Licensing.V2; +using Duende.IdentityServer.Licensing.V2.Diagnostics; using Duende.IdentityServer.Logging; using Duende.IdentityServer.Logging.Models; using Duende.IdentityServer.Models; @@ -28,6 +29,7 @@ internal class AuthorizeRequestValidator : IAuthorizeRequestValidator private readonly IUserSession _userSession; private readonly IRequestObjectValidator _requestObjectValidator; private readonly LicenseUsageTracker _licenseUsage; + private readonly ClientLoadedTracker _clientLoadedTracker; private readonly SanitizedLogger _sanitizedLogger; private readonly ResponseTypeEqualityComparer @@ -44,6 +46,7 @@ public AuthorizeRequestValidator( IUserSession userSession, IRequestObjectValidator requestObjectValidator, LicenseUsageTracker licenseUsage, + ClientLoadedTracker clientLoadedTracker, SanitizedLogger sanitizedLogger) { _options = options; @@ -55,6 +58,7 @@ public AuthorizeRequestValidator( _requestObjectValidator = requestObjectValidator; _userSession = userSession; _licenseUsage = licenseUsage; + _clientLoadedTracker = clientLoadedTracker; _sanitizedLogger = sanitizedLogger; } @@ -144,6 +148,7 @@ public async Task ValidateAsync( _sanitizedLogger.LogTrace("Authorize request protocol validation successful"); _licenseUsage.ClientUsed(request.ClientId); + _clientLoadedTracker.TrackClientLoaded(request.Client); IdentityServerLicenseValidator.Instance.ValidateClient(request.ClientId); return Valid(request); diff --git a/identity-server/src/IdentityServer/Validation/Default/TokenRequestValidator.cs b/identity-server/src/IdentityServer/Validation/Default/TokenRequestValidator.cs index 3b11b972c..a10983bea 100644 --- a/identity-server/src/IdentityServer/Validation/Default/TokenRequestValidator.cs +++ b/identity-server/src/IdentityServer/Validation/Default/TokenRequestValidator.cs @@ -9,6 +9,7 @@ using Duende.IdentityServer.Events; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Licensing.V2; +using Duende.IdentityServer.Licensing.V2.Diagnostics; using Duende.IdentityServer.Logging.Models; using Duende.IdentityServer.Models; using Duende.IdentityServer.Services; @@ -37,6 +38,7 @@ internal class TokenRequestValidator : ITokenRequestValidator private readonly IBackchannelAuthenticationRequestIdValidator _backchannelAuthenticationRequestIdValidator; private readonly IClock _clock; private readonly LicenseUsageTracker _licenseUsage; + private readonly ClientLoadedTracker _clientLoadedTracker; private readonly ILogger _logger; private ValidatedTokenRequest _validatedRequest; @@ -59,6 +61,7 @@ public TokenRequestValidator( IEventService events, IClock clock, LicenseUsageTracker licenseUsage, + ClientLoadedTracker clientLoadedTracker, ILogger logger) { _logger = logger; @@ -79,6 +82,7 @@ public TokenRequestValidator( _refreshTokenService = refreshTokenService; _dPoPProofValidator = dPoPProofValidator; _events = events; + _clientLoadedTracker = clientLoadedTracker; } // only here for legacy unit tests @@ -305,6 +309,7 @@ private async Task RunValidationAsync(Func ValidateAuthorizationCodeReques } ////////////////////////////////////////////////////////// - // resource and scope validation + // resource and scope validation ////////////////////////////////////////////////////////// var validatedResources = await _resourceValidator.ValidateRequestedResourcesAsync(new ResourceValidationRequest { @@ -791,7 +796,7 @@ private async Task ValidateRefreshTokenRequestAsyn } ////////////////////////////////////////////////////////// - // resource and scope validation + // resource and scope validation ////////////////////////////////////////////////////////// var validatedResources = await _resourceValidator.ValidateRequestedResourcesAsync(new ResourceValidationRequest { @@ -873,7 +878,7 @@ private async Task ValidateDeviceCodeRequestAsync( } ////////////////////////////////////////////////////////// - // scope validation + // scope validation ////////////////////////////////////////////////////////// var validatedResources = await _resourceValidator.ValidateRequestedResourcesAsync(new ResourceValidationRequest { @@ -962,7 +967,7 @@ private async Task ValidateCibaRequestRequestAsync } ////////////////////////////////////////////////////////// - // resource and scope validation + // resource and scope validation ////////////////////////////////////////////////////////// var validatedResources = await _resourceValidator.ValidateRequestedResourcesAsync(new ResourceValidationRequest { diff --git a/identity-server/test/IdentityServer.UnitTests/Infrastructure/RemovePropertyModifierTests.cs b/identity-server/test/IdentityServer.UnitTests/Infrastructure/RemovePropertyModifierTests.cs new file mode 100644 index 000000000..88138d175 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Infrastructure/RemovePropertyModifierTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Duende.IdentityServer.Infrastructure; + +namespace IdentityServer.UnitTests.Infrastructure; + +public class RemovePropertyModifierTests +{ + [Fact] + public void ShouldRemoveSpecifiedProperties() + { + var testObject = new TestClass { Property1 = "Value1", Property2 = "Value2", Property3 = "Value3" }; + var modifier = new RemovePropertyModifier([nameof(TestClass.Property1)]); + var serializerOptions = new JsonSerializerOptions + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = { modifier.ModifyTypeInfo } + } + }; + + var result = JsonSerializer.Serialize(testObject, serializerOptions); + + var json = JsonDocument.Parse(result); + json.RootElement.TryGetProperty("Property1", out _).ShouldBeFalse(); + json.RootElement.TryGetProperty("Property2", out var property2).ShouldBeTrue(); + property2.GetString().ShouldBe(testObject.Property2); + json.RootElement.TryGetProperty("Property3", out var property3).ShouldBeTrue(); + property3.GetString().ShouldBe(testObject.Property3); + } + + private class TestClass + { + public string Property1 { get; init; } + public string Property2 { get; init; } + public string Property3 { get; init; } + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Licensing/v2/ClientLoadedTrackerTests.cs b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/ClientLoadedTrackerTests.cs new file mode 100644 index 000000000..5ba034a58 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/ClientLoadedTrackerTests.cs @@ -0,0 +1,215 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Licensing.V2.Diagnostics; +using Duende.IdentityServer.Models; + +namespace IdentityServer.UnitTests.Licensing.V2; + +public class ClientLoadedTrackerTests +{ + [Fact] + public void Should_Include_Only_Client_Secret_Types_For_Tracked_Client() + { + var testClient = new Client + { + ClientId = "test_client", + ClientSecrets = + [ + new Secret { Type = "SharedSecret", Value = "Test" }, + new Secret { Type = "X509", Value = "Test2" } + ] + }; + var subject = new ClientLoadedTracker(); + + subject.TrackClientLoaded(testClient); + + var clientDetails = subject.Clients.FirstOrDefault(client => client.Key == testClient.ClientId); + clientDetails.Value.TryGetPropertyValue("SecretTypes", out var secretTypes); + secretTypes?.AsArray().Select(x => x.GetValue()).ShouldBe(["SharedSecret", "X509"]); + } + + [Fact] + public void Should_Exclude_Properties_From_Tracked_Client() + { + var testClient = new Client + { + ClientId = "test_client", + Properties = { ["TestProperty"] = "TestValue" }, + LogoUri = "https://example.com/logo.png", + Claims = + [ + new ClientClaim("custom_claim", "claim_value") + ] + }; + var subject = new ClientLoadedTracker(); + + subject.TrackClientLoaded(testClient); + + var clientDetails = subject.Clients.FirstOrDefault(client => client.Key == testClient.ClientId); + clientDetails.Value.TryGetPropertyValue("TestProperty", out _).ShouldBeFalse(); + clientDetails.Value.TryGetPropertyValue("LogoUri", out _).ShouldBeFalse(); + clientDetails.Value.TryGetPropertyValue("Claims", out _).ShouldBeFalse(); + } + + [Fact] + public void Should_Limit_Clients_Tracked() + { + var subject = new ClientLoadedTracker(); + + for (var i = 0; i < 105; i++) + { + var testClient = new Client { ClientId = $"test_client_{i}" }; + subject.TrackClientLoaded(testClient); + } + + subject.Clients.Count.ShouldBe(100); + } + + [Fact] + public void Should_Exclude_Empty_Array_Properties() + { + var testClient = new Client + { + ClientId = "test_client", + ClientSecrets = [] + }; + var subject = new ClientLoadedTracker(); + + subject.TrackClientLoaded(testClient); + + var clientDetails = subject.Clients.FirstOrDefault(client => client.Key == testClient.ClientId); + clientDetails.Value.TryGetPropertyValue("ClientSecrets", out _).ShouldBeFalse(); + } + + [Fact] + public void Should_Exclude_Null_Values() + { + var testClient = new Client + { + ClientId = "test_client", + ClientName = null + }; + var subject = new ClientLoadedTracker(); + + subject.TrackClientLoaded(testClient); + + var clientDetails = subject.Clients.FirstOrDefault(client => client.Key == testClient.ClientId); + clientDetails.Value.TryGetPropertyValue("ClientName", out _).ShouldBeFalse(); + } + + [Fact] + public void Should_Restrict_Length_Of_Array_Properties() + { + var testClient = new Client + { + ClientId = "test_client", + AllowedScopes = Enumerable.Range(1, 20).Select(i => $"scope_{i}").ToList() + }; + var subject = new ClientLoadedTracker(); + + subject.TrackClientLoaded(testClient); + + var clientDetails = subject.Clients.FirstOrDefault(client => client.Key == testClient.ClientId); + clientDetails.Value.TryGetPropertyValue("AllowedScopes", out var allowedScopes); + allowedScopes?.AsArray().Count.ShouldBe(10); + } + + [Fact] + public void Should_Handle_String_Property_Correctly() + { + var testClient = new Client + { + ClientId = "test_client", + ClientName = "Test Client" + }; + var subject = new ClientLoadedTracker(); + + subject.TrackClientLoaded(testClient); + + var clientDetails = subject.Clients.FirstOrDefault(client => client.Key == testClient.ClientId); + clientDetails.Value.TryGetPropertyValue("ClientName", out var clientName); + clientName?.GetValue().ShouldBe("Test Client"); + } + + [Fact] + public void Should_Handle_Boolean_Property_Correctly() + { + var testClient = new Client + { + ClientId = "test_client", + Enabled = true + }; + var subject = new ClientLoadedTracker(); + + subject.TrackClientLoaded(testClient); + + var clientDetails = subject.Clients.FirstOrDefault(client => client.Key == testClient.ClientId); + clientDetails.Value.TryGetPropertyValue("Enabled", out var enabled); + enabled?.GetValue().ShouldBeTrue(); + } + + [Fact] + public void Should_Handle_Enum_Property_Correctly() + { + var testClient = new Client + { + ClientId = "test_client", + AccessTokenType = AccessTokenType.Reference + }; + var subject = new ClientLoadedTracker(); + + subject.TrackClientLoaded(testClient); + + var clientDetails = subject.Clients.FirstOrDefault(client => client.Key == testClient.ClientId); + clientDetails.Value.TryGetPropertyValue("AccessTokenType", out var accessTokenType).ShouldBeTrue(); + accessTokenType?.GetValue().ShouldBe(nameof(AccessTokenType.Reference)); + } + + [Fact] + public void Should_Handle_TimeSpan_Property_Correctly() + { + var testClient = new Client + { + ClientId = "test_client", + DPoPClockSkew = TimeSpan.FromDays(30) + }; + var subject = new ClientLoadedTracker(); + + subject.TrackClientLoaded(testClient); + + var clientDetails = subject.Clients.FirstOrDefault(client => client.Key == testClient.ClientId); + clientDetails.Value.TryGetPropertyValue("AbsoluteRefreshTokenLifetime", out var absoluteRefreshTokenLifetime); + absoluteRefreshTokenLifetime?.GetValue().ShouldBe(TimeSpan.FromDays(30).ToString()); + } + + [Fact] + public void Should_Handle_Int_Property_Correctly() + { + var testClient = new Client + { + ClientId = "test_client", + AbsoluteRefreshTokenLifetime = 3600 + }; + var subject = new ClientLoadedTracker(); + + subject.TrackClientLoaded(testClient); + + var clientDetails = subject.Clients.FirstOrDefault(client => client.Key == testClient.ClientId); + clientDetails.Value.TryGetPropertyValue("AbsoluteRefreshTokenLifetime", out var absoluteRefreshTokenLifetime); + absoluteRefreshTokenLifetime?.GetValue().ShouldBe(3600); + } + + [Fact] + public void Should_Not_Track_Client_When_Already_Tracked() + { + var testClient = new Client { ClientId = "test_client" }; + var subject = new ClientLoadedTracker(); + + subject.TrackClientLoaded(testClient); + subject.TrackClientLoaded(testClient); + + subject.Clients.Count.ShouldBe(1); + subject.Clients.ContainsKey(testClient.ClientId).ShouldBeTrue(); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticEntries/ClientInfoDiagnosticEntryTests.cs b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticEntries/ClientInfoDiagnosticEntryTests.cs new file mode 100644 index 000000000..5a58fe246 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticEntries/ClientInfoDiagnosticEntryTests.cs @@ -0,0 +1,77 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityModel.Client; +using Duende.IdentityServer.Licensing.V2.Diagnostics; +using Duende.IdentityServer.Licensing.V2.Diagnostics.DiagnosticEntries; +using Duende.IdentityServer.Models; + +namespace IdentityServer.UnitTests.Licensing.V2.DiagnosticEntries; + +public class ClientInfoDiagnosticEntryTests +{ + [Fact] + public async Task Should_Write_Client_Info() + { + var clientLoadedTracker = new ClientLoadedTracker(); + var testClient = new Client + { + ClientId = "test_client", + ClientSecrets = + [ + new Secret { Type = "SharedSecret", Value = "Test" }, + new Secret { Type = "X509", Value = "Test2" } + ], + AllowOfflineAccess = true, + AllowedGrantTypes = GrantTypes.ClientCredentials, + AccessTokenLifetime = 60, + AllowedScopes = ["api1", "api2"], + AccessTokenType = AccessTokenType.Reference + }; + clientLoadedTracker.TrackClientLoaded(testClient); + var subject = new ClientInfoDiagnosticEntry(clientLoadedTracker); + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(subject); + + var clientInfo = result.RootElement.GetProperty("Clients"); + clientInfo.GetArrayLength().ShouldBe(1); + var client = clientInfo[0]; + client.GetProperty("ClientId").GetString().ShouldBe("test_client"); + client.TryGetStringArray("SecretTypes").ShouldBe(["SharedSecret", "X509"]); + client.GetProperty("AllowOfflineAccess").GetBoolean().ShouldBeTrue(); + client.TryGetStringArray("AllowedGrantTypes").ShouldBe(GrantTypes.ClientCredentials); + client.GetProperty("AccessTokenLifetime").GetInt32().ShouldBe(60); + client.TryGetStringArray("AllowedScopes").ShouldBe(["api1", "api2"]); + client.GetProperty("AccessTokenType").GetString().ShouldBe(nameof(AccessTokenType.Reference)); + } + + [Fact] + public async Task Should_Write_Empty_Client_Info_When_No_Clients_Tracked() + { + var clientLoadedTracker = new ClientLoadedTracker(); + var subject = new ClientInfoDiagnosticEntry(clientLoadedTracker); + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(subject); + + var clientInfo = result.RootElement.GetProperty("Clients"); + clientInfo.GetArrayLength().ShouldBe(0); + } + + [Fact] + public async Task Should_Write_Multiple_Clients() + { + var clientLoadedTracker = new ClientLoadedTracker(); + for (var i = 0; i < 5; i++) + { + var testClient = new Client { ClientId = $"test_client_{i}" }; + clientLoadedTracker.TrackClientLoaded(testClient); + } + var subject = new ClientInfoDiagnosticEntry(clientLoadedTracker); + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(subject); + + var clientInfo = result.RootElement.GetProperty("Clients"); + clientInfo.GetArrayLength().ShouldBe(5); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Resources.cs b/identity-server/test/IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Resources.cs index 5edd40173..a136c03f8 100644 --- a/identity-server/test/IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Resources.cs +++ b/identity-server/test/IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Resources.cs @@ -6,6 +6,7 @@ using Duende.IdentityModel; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Licensing.V2; +using Duende.IdentityServer.Licensing.V2.Diagnostics; using Duende.IdentityServer.Logging; using Duende.IdentityServer.Models; using Duende.IdentityServer.Stores; @@ -54,6 +55,7 @@ public class Authorize_ProtocolValidation_Resources _mockUserSession, Factory.CreateRequestObjectValidator(), new LicenseUsageTracker(new LicenseAccessor(new IdentityServerOptions(), NullLogger.Instance), new NullLoggerFactory()), + new ClientLoadedTracker(), new SanitizedLogger(TestLogger.Create())); [Fact] diff --git a/identity-server/test/IdentityServer.UnitTests/Validation/Setup/Factory.cs b/identity-server/test/IdentityServer.UnitTests/Validation/Setup/Factory.cs index 18d45b8ad..1c617d3bb 100644 --- a/identity-server/test/IdentityServer.UnitTests/Validation/Setup/Factory.cs +++ b/identity-server/test/IdentityServer.UnitTests/Validation/Setup/Factory.cs @@ -5,6 +5,7 @@ using Duende.IdentityServer; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Licensing.V2; +using Duende.IdentityServer.Licensing.V2.Diagnostics; using Duende.IdentityServer.Logging; using Duende.IdentityServer.Models; using Duende.IdentityServer.Services; @@ -137,6 +138,7 @@ public static TokenRequestValidator CreateTokenRequestValidator( new TestEventService(), new StubClock(), new LicenseUsageTracker(new LicenseAccessor(new IdentityServerOptions(), NullLogger.Instance), new NullLoggerFactory()), + new ClientLoadedTracker(), TestLogger.Create()); } @@ -265,6 +267,7 @@ public static AuthorizeRequestValidator CreateAuthorizeRequestValidator( userSession, requestObjectValidator, new LicenseUsageTracker(new LicenseAccessor(new IdentityServerOptions(), NullLogger.Instance), new NullLoggerFactory()), + new ClientLoadedTracker(), new SanitizedLogger(TestLogger.Create())); }