diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs index da14b0dc2..a18f3bcb4 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -17,6 +17,8 @@ using Duende.IdentityServer.Internal; using Duende.IdentityServer.Licensing; using Duende.IdentityServer.Licensing.V2; +using Duende.IdentityServer.Licensing.V2.Diagnostics; +using Duende.IdentityServer.Licensing.V2.Diagnostics.DiagnosticEntries; using Duende.IdentityServer.Logging; using Duende.IdentityServer.Models; using Duende.IdentityServer.ResponseHandling; @@ -210,6 +212,10 @@ public static IIdentityServerBuilder AddCoreServices(this IIdentityServerBuilder builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); + return builder; } @@ -306,7 +312,7 @@ public static IIdentityServerBuilder AddDynamicProvidersCore(this IIdentityServe builder.Services.AddTransientDecorator(); builder.Services.TryAddSingleton(); // the per-request cache is to ensure that a scheme loaded from the cache is still available later in the - // request and made available anywhere else during this request (in case the static cache times out across + // request and made available anywhere else during this request (in case the static cache times out across // 2 calls within the same request) builder.Services.AddScoped(); diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/IdentityServerOptions.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/IdentityServerOptions.cs index 93b024526..9801927a1 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/IdentityServerOptions.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/IdentityServerOptions.cs @@ -207,11 +207,11 @@ public class IdentityServerOptions /// /// The allowed clock skew for JWT lifetime validation. Except for DPoP proofs, /// all JWTs that have their lifetime validated use this setting to control the - /// clock skew of lifetime validation. This includes JWT access tokens passed - /// to the user info, introspection, and local api endpoints, client + /// clock skew of lifetime validation. This includes JWT access tokens passed + /// to the user info, introspection, and local api endpoints, client /// authentication JWTs used in private_key_jwt authentication, JWT secured - /// authorization requests (JAR), and custom usage of the - /// , such as in a token exchange implementation. + /// authorization requests (JAR), and custom usage of the + /// , such as in a token exchange implementation. /// Defaults to five minutes. /// public TimeSpan JwtValidationClockSkew { get; set; } = TimeSpan.FromMinutes(5); @@ -236,4 +236,10 @@ public class IdentityServerOptions /// Options for configuring preview features in the server. /// public PreviewFeatureOptions Preview { get; set; } = new PreviewFeatureOptions(); + + /// + /// Frequency at which the diagnostic summary is logged. + /// The default value is 1 hour. + /// + public TimeSpan DiagnosticSummaryLogFrequency { get; set; } = TimeSpan.FromHours(1); } diff --git a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/AssemblyInfoDiagnosticEntry.cs b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/AssemblyInfoDiagnosticEntry.cs new file mode 100644 index 000000000..188108253 --- /dev/null +++ b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/AssemblyInfoDiagnosticEntry.cs @@ -0,0 +1,41 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Reflection; +using System.Runtime.Loader; +using System.Text.Json; + +namespace Duende.IdentityServer.Licensing.V2.Diagnostics.DiagnosticEntries; + +internal class AssemblyInfoDiagnosticEntry : IDiagnosticEntry +{ + public Task WriteAsync(Utf8JsonWriter writer) + { + var assemblies = GetAssemblyInfo(); + writer.WriteStartObject("AssemblyInfo"); + writer.WriteNumber("AssemblyCount", assemblies.Count); + + writer.WriteStartArray("Assemblies"); + foreach (var assembly in assemblies) + { + writer.WriteStartObject(); + writer.WriteString("Name", assembly.GetName().Name); + writer.WriteString("Version", assembly.GetName().Version?.ToString() ?? "Unknown"); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + + return Task.CompletedTask; + } + + private List GetAssemblyInfo() + { + var assemblies = AssemblyLoadContext.Default.Assemblies + .OrderBy(a => a.FullName) + .ToList(); + + return assemblies; + } +} diff --git a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticHostedService.cs b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticHostedService.cs new file mode 100644 index 000000000..b4262f5ac --- /dev/null +++ b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticHostedService.cs @@ -0,0 +1,28 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Duende.IdentityServer.Licensing.V2.Diagnostics; + +internal class DiagnosticHostedService(DiagnosticSummary diagnosticSummary, IOptions options, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(options.Value.DiagnosticSummaryLogFrequency); + while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + await diagnosticSummary.PrintSummary(); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while logging the diagnostic summary: {Message}", ex.Message); + } + } + } +} diff --git a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticSummary.cs b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticSummary.cs new file mode 100644 index 000000000..6a8476685 --- /dev/null +++ b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticSummary.cs @@ -0,0 +1,31 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Buffers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Licensing.V2.Diagnostics; + +internal class DiagnosticSummary(IEnumerable entries, ILogger logger) +{ + public async Task PrintSummary() + { + var bufferWriter = new ArrayBufferWriter(); + await using var writer = new Utf8JsonWriter(bufferWriter, new JsonWriterOptions { Indented = false }); + + writer.WriteStartObject(); + + foreach (var diagnosticEntry in entries) + { + await diagnosticEntry.WriteAsync(writer); + } + + writer.WriteEndObject(); + + await writer.FlushAsync(); + + logger.LogInformation("{Message}", Encoding.UTF8.GetString(bufferWriter.WrittenSpan)); + } +} diff --git a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/IDiagnosticEntry.cs b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/IDiagnosticEntry.cs new file mode 100644 index 000000000..22114e69a --- /dev/null +++ b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/IDiagnosticEntry.cs @@ -0,0 +1,11 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Text.Json; + +namespace Duende.IdentityServer.Licensing.V2.Diagnostics; + +internal interface IDiagnosticEntry +{ + Task WriteAsync(Utf8JsonWriter writer); +} diff --git a/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticEntries/AssemblyInfoDiagnosticEntryTests.cs b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticEntries/AssemblyInfoDiagnosticEntryTests.cs new file mode 100644 index 000000000..ecdedda77 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticEntries/AssemblyInfoDiagnosticEntryTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Buffers; +using System.Text; +using System.Text.Json; +using Duende.IdentityServer.Licensing.V2.Diagnostics.DiagnosticEntries; + +namespace IdentityServer.UnitTests.Licensing.V2.DiagnosticEntries; + +public class AssemblyInfoDiagnosticEntryTests +{ + [Fact] + public async Task WriteAsync_ShouldWriteAssemblyInfo() + { + var subject = new AssemblyInfoDiagnosticEntry(); + var bufferWriter = new ArrayBufferWriter(); + await using var writer = new Utf8JsonWriter(bufferWriter, new JsonWriterOptions { Indented = false }); + writer.WriteStartObject(); + + await subject.WriteAsync(writer); + + writer.WriteEndObject(); + await writer.FlushAsync(); + var json = Encoding.UTF8.GetString(bufferWriter.WrittenSpan); + var jsonDocument = JsonDocument.Parse(json); + var assemblyInfo = jsonDocument.RootElement.GetProperty("AssemblyInfo"); + assemblyInfo.GetProperty("AssemblyCount").ValueKind.ShouldBe(JsonValueKind.Number); + var assemblies = assemblyInfo.GetProperty("Assemblies"); + assemblies.ValueKind.ShouldBe(JsonValueKind.Array); + var firstEntry = assemblies.EnumerateArray().First(); + firstEntry.GetProperty("Name").ValueKind.ShouldBe(JsonValueKind.String); + firstEntry.GetProperty("Version").ValueKind.ShouldBe(JsonValueKind.String); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticSummaryTests.cs b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticSummaryTests.cs new file mode 100644 index 000000000..dae7b5469 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticSummaryTests.cs @@ -0,0 +1,43 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Text.Json; +using Duende.IdentityServer.Licensing.V2.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; + +namespace IdentityServer.UnitTests.Licensing.V2; + +public class DiagnosticSummaryTests +{ + [Fact] + public async Task PrintSummary_ShouldCallWriteAsyncOnEveryDiagnosticEntry() + { + var fakeLogger = new NullLogger(); + var firstDiagnosticEntry = new TestDiagnosticEntry(); + var secondDiagnosticEntry = new TestDiagnosticEntry(); + var thirdDiagnosticEntry = new TestDiagnosticEntry(); + var entries = new List + { + firstDiagnosticEntry, + secondDiagnosticEntry, + thirdDiagnosticEntry + }; + var summary = new DiagnosticSummary(entries, fakeLogger); + + await summary.PrintSummary(); + + firstDiagnosticEntry.WasCalled.ShouldBeTrue(); + secondDiagnosticEntry.WasCalled.ShouldBeTrue(); + thirdDiagnosticEntry.WasCalled.ShouldBeTrue(); + } + + private class TestDiagnosticEntry : IDiagnosticEntry + { + public bool WasCalled { get; private set; } + public Task WriteAsync(Utf8JsonWriter writer) + { + WasCalled = true; + return Task.CompletedTask; + } + } +}