diff --git a/Directory.Packages.props b/Directory.Packages.props index ec0a4e8d5..17ea75af1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -34,9 +34,9 @@ - - - + + + diff --git a/bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj b/bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj index fba694644..3a2ee4144 100644 --- a/bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj +++ b/bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj @@ -1,6 +1,6 @@  - + Exe diff --git a/identity-server/aspire/AppHosts/All/All.csproj b/identity-server/aspire/AppHosts/All/All.csproj index eee62a103..4e2e179c7 100644 --- a/identity-server/aspire/AppHosts/All/All.csproj +++ b/identity-server/aspire/AppHosts/All/All.csproj @@ -1,6 +1,6 @@  - + Exe @@ -58,6 +58,9 @@ + + + diff --git a/identity-server/aspire/AppHosts/All/Program.cs b/identity-server/aspire/AppHosts/All/Program.cs index feb0cdd4f..15bb4cd15 100644 --- a/identity-server/aspire/AppHosts/All/Program.cs +++ b/identity-server/aspire/AppHosts/All/Program.cs @@ -46,13 +46,25 @@ void ConfigureIdentityServerHosts() projectRegistry.Add("is-host", hostMain); } + if (HostIsEnabled(nameof(Projects.Host_Main10))) + { + var hostMain = builder + .AddProject("is-host") + .WithHttpHealthCheck(path: "/.well-known/openid-configuration"); + + projectRegistry.Add("is-host", hostMain); + } + + // These hosts require a database var dbHosts = new List { nameof(Projects.Host_AspNetIdentity8), nameof(Projects.Host_AspNetIdentity9), + nameof(Projects.Host_AspNetIdentity10), nameof(Projects.Host_EntityFramework8), - nameof(Projects.Host_EntityFramework9) + nameof(Projects.Host_EntityFramework9), + nameof(Projects.Host_EntityFramework10) }; if (dbHosts.Any(HostIsEnabled)) @@ -100,6 +112,23 @@ void ConfigureIdentityServerHosts() projectRegistry.Add("is-host", hostAspNetIdentity); } + if (HostIsEnabled(nameof(Projects.Host_AspNetIdentity10))) + { + var hostAspNetIdentity = builder.AddProject(name: "is-host") + .WithHttpHealthCheck(path: "/.well-known/openid-configuration") + .WithReference(identityServerDb, connectionName: "DefaultConnection"); + + if (appConfig.RunDatabaseMigrations) + { + var aspnetMigration = builder.AddProject(name: "aspnetidentitydb-migrations") + .WithReference(identityServerDb, connectionName: "DefaultConnection") + .WaitFor(identityServerDb); + hostAspNetIdentity.WaitForCompletion(aspnetMigration); + } + + projectRegistry.Add("is-host", hostAspNetIdentity); + } + if (HostIsEnabled(nameof(Projects.Host_EntityFramework8))) { var hostEntityFramework = builder.AddProject(name: "is-host") @@ -133,6 +162,23 @@ void ConfigureIdentityServerHosts() projectRegistry.Add("is-host", hostEntityFramework); } + + if (HostIsEnabled(nameof(Projects.Host_EntityFramework10))) + { + var hostEntityFramework = builder.AddProject(name: "is-host") + .WithHttpHealthCheck(path: "/.well-known/openid-configuration") + .WithReference(identityServerDb, connectionName: "DefaultConnection"); + + if (appConfig.RunDatabaseMigrations) + { + var idSrvMigration = builder.AddProject(name: "identityserverdb-migrations") + .WithReference(identityServerDb, connectionName: "DefaultConnection") + .WaitFor(identityServerDb); + hostEntityFramework.WaitForCompletion(idSrvMigration); + } + + projectRegistry.Add("is-host", hostEntityFramework); + } } bool HostIsEnabled(string name) => diff --git a/identity-server/aspire/AppHosts/All/appsettings.json b/identity-server/aspire/AppHosts/All/appsettings.json index b0c269c4d..2951eb626 100644 --- a/identity-server/aspire/AppHosts/All/appsettings.json +++ b/identity-server/aspire/AppHosts/All/appsettings.json @@ -7,7 +7,7 @@ } }, "AspireProjectConfiguration": { - "IdentityHost": "Host_Main9", + "IdentityHost": "Host_Main10", "UseClients": { "ConsoleCibaClient": false, "ConsoleClientCredentialsFlow": false, diff --git a/identity-server/aspire/AppHosts/Dev/Dev.csproj b/identity-server/aspire/AppHosts/Dev/Dev.csproj index 58d0a7413..d058d3780 100644 --- a/identity-server/aspire/AppHosts/Dev/Dev.csproj +++ b/identity-server/aspire/AppHosts/Dev/Dev.csproj @@ -1,6 +1,6 @@  - + Exe diff --git a/identity-server/clients/src/ConsoleResourceOwnerFlowRefreshToken/Program.cs b/identity-server/clients/src/ConsoleResourceOwnerFlowRefreshToken/Program.cs index d968cafa4..e8fc68559 100644 --- a/identity-server/clients/src/ConsoleResourceOwnerFlowRefreshToken/Program.cs +++ b/identity-server/clients/src/ConsoleResourceOwnerFlowRefreshToken/Program.cs @@ -34,12 +34,12 @@ var refresh_token = response.RefreshToken; -while (true) +for (var i = 0; i < 10; i++) { response = await RefreshTokenAsync(refresh_token); response.Show(); - Thread.Sleep(5000); + Thread.Sleep(50); await CallServiceAsync(response.AccessToken); diff --git a/identity-server/clients/src/ConsoleScopesResources/Program.cs b/identity-server/clients/src/ConsoleScopesResources/Program.cs index 2b530f0de..e903b9e96 100644 --- a/identity-server/clients/src/ConsoleScopesResources/Program.cs +++ b/identity-server/clients/src/ConsoleScopesResources/Program.cs @@ -36,8 +36,8 @@ new() { Enabled = true, Id = "J", Name = "No scope (resource: resource1)", Scope = "", Resource = "urn:resource1" }, new() { Enabled = true, Id = "K", Name = "No scope (resource: resource3)", Scope = "", Resource = "urn:resource3" }, new() { Enabled = true, Id = "L", Name = "Isolated scope without resource parameter", Scope = "resource3.scope1" }, - new() { Enabled = true, Id = "M", Name = "Isolated scope without resource parameter", Scope = "resource3.scope1", Resource = "urn:resource3" }, - new() { Enabled = true, Id = "N", Name = "Isolated scope without resource parameter", Scope = "resource3.scope1", Resource = "urn:resource2" } + new() { Enabled = true, Id = "M", Name = "Isolated scope with resource parameter", Scope = "resource3.scope1", Resource = "urn:resource3" }, + new() { Enabled = true, Id = "N", Name = "Shared scope with resource parameter", Scope = "shared.scope", Resource = "urn:resource2" } }; // Execute the planned runs diff --git a/identity-server/hosts/Shared/Configuration/TestClients.cs b/identity-server/hosts/Shared/Configuration/TestClients.cs index 0c8e0705d..92cb99183 100644 --- a/identity-server/hosts/Shared/Configuration/TestClients.cs +++ b/identity-server/hosts/Shared/Configuration/TestClients.cs @@ -7,7 +7,7 @@ namespace Duende.IdentityServer.Hosts.Shared.Configuration; public static class TestClients { - public static IEnumerable Get() + public static List Get() { var clients = new List(); diff --git a/identity-server/hosts/Shared/Customization/CustomClientRegistrationProcessor.cs b/identity-server/hosts/Shared/Customization/CustomClientRegistrationProcessor.cs index 5f4ff5f09..91de8bacc 100644 --- a/identity-server/hosts/Shared/Customization/CustomClientRegistrationProcessor.cs +++ b/identity-server/hosts/Shared/Customization/CustomClientRegistrationProcessor.cs @@ -25,7 +25,7 @@ protected override async Task AddClientId(DynamicClientRegistration var clientId = clientIdParameter.ToString(); if (clientId != null) { - var existingClient = clientStore.FindClientByIdAsync(clientId); + var existingClient = await clientStore.FindClientByIdAsync(clientId); if (existingClient is not null) { return new DynamicClientRegistrationError( diff --git a/identity-server/hosts/net10/AspNetIdentity10/HostingExtensions.cs b/identity-server/hosts/net10/AspNetIdentity10/HostingExtensions.cs index 1023ca2df..d9619ec7d 100644 --- a/identity-server/hosts/net10/AspNetIdentity10/HostingExtensions.cs +++ b/identity-server/hosts/net10/AspNetIdentity10/HostingExtensions.cs @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. using Duende.IdentityServer; +using Duende.IdentityServer.Configuration; using Duende.IdentityServer.UI; using Duende.IdentityServer.UI.AspNetIdentity.Models; using IdentityServerHost.Data; @@ -122,6 +123,9 @@ internal static WebApplication ConfigurePipeline(this WebApplication app) app.MapRazorPages() .RequireAuthorization(); + app.MapDynamicClientRegistration() + .AllowAnonymous(); + return app; } } diff --git a/identity-server/hosts/net10/AspNetIdentity10/IdentityServerExtensions.cs b/identity-server/hosts/net10/AspNetIdentity10/IdentityServerExtensions.cs index fd7c56e7d..a0b121b7e 100644 --- a/identity-server/hosts/net10/AspNetIdentity10/IdentityServerExtensions.cs +++ b/identity-server/hosts/net10/AspNetIdentity10/IdentityServerExtensions.cs @@ -1,7 +1,10 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Configuration.RequestProcessing; using Duende.IdentityServer.Hosts.Shared.Configuration; +using Duende.IdentityServer.Hosts.Shared.Customization; using Duende.IdentityServer.UI.AspNetIdentity.Models; namespace IdentityServerHost; @@ -26,6 +29,10 @@ internal static WebApplicationBuilder ConfigureIdentityServer(this WebApplicatio .AddInMemoryClients(TestClients.Get()) .AddAspNetIdentity(); + builder.Services.AddIdentityServerConfiguration(opt => { }) + .AddInMemoryClientConfigurationStore(); + builder.Services.AddTransient(); + return builder; } } diff --git a/identity-server/hosts/net10/Main10/HostingExtensions.cs b/identity-server/hosts/net10/Main10/HostingExtensions.cs index 7435c6324..bd61b74ac 100644 --- a/identity-server/hosts/net10/Main10/HostingExtensions.cs +++ b/identity-server/hosts/net10/Main10/HostingExtensions.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Security.Claims; using Duende.IdentityServer; +using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Hosts.Shared.Customization; using Duende.IdentityServer.UI; using Microsoft.AspNetCore.Mvc.Razor; @@ -171,6 +172,9 @@ internal static WebApplication ConfigurePipeline(this WebApplication app) app.MapRazorPages() .RequireAuthorization(); + app.MapDynamicClientRegistration() + .AllowAnonymous(); + return app; } } diff --git a/identity-server/hosts/net10/Main10/IdentityServerExtensions.cs b/identity-server/hosts/net10/Main10/IdentityServerExtensions.cs index e9108e231..f42ddb663 100644 --- a/identity-server/hosts/net10/Main10/IdentityServerExtensions.cs +++ b/identity-server/hosts/net10/Main10/IdentityServerExtensions.cs @@ -5,6 +5,7 @@ using Duende.IdentityModel; using Duende.IdentityServer; using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Configuration.RequestProcessing; using Duende.IdentityServer.Hosts.Shared.Configuration; using Duende.IdentityServer.Hosts.Shared.Customization; using Microsoft.AspNetCore.Authentication.Certificate; @@ -51,7 +52,7 @@ internal static WebApplicationBuilder ConfigureIdentityServer(this WebApplicatio options.Diagnostics.ChunkSize = 1024 * 1000 - 32; // 1 MB minus some formatting space; }) .AddServerSideSessions() - .AddInMemoryClients([.. TestClients.Get()]) + .AddInMemoryClients(TestClients.Get()) .AddInMemoryIdentityResources(TestResources.IdentityResources) .AddInMemoryApiResources(TestResources.ApiResources) .AddInMemoryApiScopes(TestResources.ApiScopes) @@ -81,6 +82,7 @@ internal static WebApplicationBuilder ConfigureIdentityServer(this WebApplicatio builder.Services.AddIdentityServerConfiguration(opt => { }) .AddInMemoryClientConfigurationStore(); + builder.Services.AddTransient(); builder.Services.AddDistributedMemoryCache(); diff --git a/identity-server/hosts/net8/AspNetIdentity8/HostingExtensions.cs b/identity-server/hosts/net8/AspNetIdentity8/HostingExtensions.cs index 08a5cf6d6..3e0a04264 100644 --- a/identity-server/hosts/net8/AspNetIdentity8/HostingExtensions.cs +++ b/identity-server/hosts/net8/AspNetIdentity8/HostingExtensions.cs @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. using Duende.IdentityServer; +using Duende.IdentityServer.Configuration; using Duende.IdentityServer.UI; using Duende.IdentityServer.UI.AspNetIdentity.Models; using IdentityServerHost.Data; @@ -120,6 +121,9 @@ internal static WebApplication ConfigurePipeline(this WebApplication app) app.MapRazorPages() .RequireAuthorization(); + app.MapDynamicClientRegistration() + .AllowAnonymous(); + return app; } } diff --git a/identity-server/hosts/net8/AspNetIdentity8/IdentityServerExtensions.cs b/identity-server/hosts/net8/AspNetIdentity8/IdentityServerExtensions.cs index fd7c56e7d..a0b121b7e 100644 --- a/identity-server/hosts/net8/AspNetIdentity8/IdentityServerExtensions.cs +++ b/identity-server/hosts/net8/AspNetIdentity8/IdentityServerExtensions.cs @@ -1,7 +1,10 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Configuration.RequestProcessing; using Duende.IdentityServer.Hosts.Shared.Configuration; +using Duende.IdentityServer.Hosts.Shared.Customization; using Duende.IdentityServer.UI.AspNetIdentity.Models; namespace IdentityServerHost; @@ -26,6 +29,10 @@ internal static WebApplicationBuilder ConfigureIdentityServer(this WebApplicatio .AddInMemoryClients(TestClients.Get()) .AddAspNetIdentity(); + builder.Services.AddIdentityServerConfiguration(opt => { }) + .AddInMemoryClientConfigurationStore(); + builder.Services.AddTransient(); + return builder; } } diff --git a/identity-server/hosts/net8/Main8/HostingExtensions.cs b/identity-server/hosts/net8/Main8/HostingExtensions.cs index 7435c6324..bd61b74ac 100644 --- a/identity-server/hosts/net8/Main8/HostingExtensions.cs +++ b/identity-server/hosts/net8/Main8/HostingExtensions.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Security.Claims; using Duende.IdentityServer; +using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Hosts.Shared.Customization; using Duende.IdentityServer.UI; using Microsoft.AspNetCore.Mvc.Razor; @@ -171,6 +172,9 @@ internal static WebApplication ConfigurePipeline(this WebApplication app) app.MapRazorPages() .RequireAuthorization(); + app.MapDynamicClientRegistration() + .AllowAnonymous(); + return app; } } diff --git a/identity-server/hosts/net8/Main8/IdentityServerExtensions.cs b/identity-server/hosts/net8/Main8/IdentityServerExtensions.cs index e9108e231..f42ddb663 100644 --- a/identity-server/hosts/net8/Main8/IdentityServerExtensions.cs +++ b/identity-server/hosts/net8/Main8/IdentityServerExtensions.cs @@ -5,6 +5,7 @@ using Duende.IdentityModel; using Duende.IdentityServer; using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Configuration.RequestProcessing; using Duende.IdentityServer.Hosts.Shared.Configuration; using Duende.IdentityServer.Hosts.Shared.Customization; using Microsoft.AspNetCore.Authentication.Certificate; @@ -51,7 +52,7 @@ internal static WebApplicationBuilder ConfigureIdentityServer(this WebApplicatio options.Diagnostics.ChunkSize = 1024 * 1000 - 32; // 1 MB minus some formatting space; }) .AddServerSideSessions() - .AddInMemoryClients([.. TestClients.Get()]) + .AddInMemoryClients(TestClients.Get()) .AddInMemoryIdentityResources(TestResources.IdentityResources) .AddInMemoryApiResources(TestResources.ApiResources) .AddInMemoryApiScopes(TestResources.ApiScopes) @@ -81,6 +82,7 @@ internal static WebApplicationBuilder ConfigureIdentityServer(this WebApplicatio builder.Services.AddIdentityServerConfiguration(opt => { }) .AddInMemoryClientConfigurationStore(); + builder.Services.AddTransient(); builder.Services.AddDistributedMemoryCache(); diff --git a/identity-server/hosts/net9/AspNetIdentity9/HostingExtensions.cs b/identity-server/hosts/net9/AspNetIdentity9/HostingExtensions.cs index 1023ca2df..d9619ec7d 100644 --- a/identity-server/hosts/net9/AspNetIdentity9/HostingExtensions.cs +++ b/identity-server/hosts/net9/AspNetIdentity9/HostingExtensions.cs @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. using Duende.IdentityServer; +using Duende.IdentityServer.Configuration; using Duende.IdentityServer.UI; using Duende.IdentityServer.UI.AspNetIdentity.Models; using IdentityServerHost.Data; @@ -122,6 +123,9 @@ internal static WebApplication ConfigurePipeline(this WebApplication app) app.MapRazorPages() .RequireAuthorization(); + app.MapDynamicClientRegistration() + .AllowAnonymous(); + return app; } } diff --git a/identity-server/hosts/net9/AspNetIdentity9/IdentityServerExtensions.cs b/identity-server/hosts/net9/AspNetIdentity9/IdentityServerExtensions.cs index fd7c56e7d..a0b121b7e 100644 --- a/identity-server/hosts/net9/AspNetIdentity9/IdentityServerExtensions.cs +++ b/identity-server/hosts/net9/AspNetIdentity9/IdentityServerExtensions.cs @@ -1,7 +1,10 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Configuration.RequestProcessing; using Duende.IdentityServer.Hosts.Shared.Configuration; +using Duende.IdentityServer.Hosts.Shared.Customization; using Duende.IdentityServer.UI.AspNetIdentity.Models; namespace IdentityServerHost; @@ -26,6 +29,10 @@ internal static WebApplicationBuilder ConfigureIdentityServer(this WebApplicatio .AddInMemoryClients(TestClients.Get()) .AddAspNetIdentity(); + builder.Services.AddIdentityServerConfiguration(opt => { }) + .AddInMemoryClientConfigurationStore(); + builder.Services.AddTransient(); + return builder; } } diff --git a/identity-server/hosts/net9/Main9/HostingExtensions.cs b/identity-server/hosts/net9/Main9/HostingExtensions.cs index 7435c6324..bd61b74ac 100644 --- a/identity-server/hosts/net9/Main9/HostingExtensions.cs +++ b/identity-server/hosts/net9/Main9/HostingExtensions.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Security.Claims; using Duende.IdentityServer; +using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Hosts.Shared.Customization; using Duende.IdentityServer.UI; using Microsoft.AspNetCore.Mvc.Razor; @@ -171,6 +172,9 @@ internal static WebApplication ConfigurePipeline(this WebApplication app) app.MapRazorPages() .RequireAuthorization(); + app.MapDynamicClientRegistration() + .AllowAnonymous(); + return app; } } diff --git a/identity-server/hosts/net9/Main9/IdentityServerExtensions.cs b/identity-server/hosts/net9/Main9/IdentityServerExtensions.cs index e9108e231..f42ddb663 100644 --- a/identity-server/hosts/net9/Main9/IdentityServerExtensions.cs +++ b/identity-server/hosts/net9/Main9/IdentityServerExtensions.cs @@ -5,6 +5,7 @@ using Duende.IdentityModel; using Duende.IdentityServer; using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Configuration.RequestProcessing; using Duende.IdentityServer.Hosts.Shared.Configuration; using Duende.IdentityServer.Hosts.Shared.Customization; using Microsoft.AspNetCore.Authentication.Certificate; @@ -51,7 +52,7 @@ internal static WebApplicationBuilder ConfigureIdentityServer(this WebApplicatio options.Diagnostics.ChunkSize = 1024 * 1000 - 32; // 1 MB minus some formatting space; }) .AddServerSideSessions() - .AddInMemoryClients([.. TestClients.Get()]) + .AddInMemoryClients(TestClients.Get()) .AddInMemoryIdentityResources(TestResources.IdentityResources) .AddInMemoryApiResources(TestResources.ApiResources) .AddInMemoryApiScopes(TestResources.ApiScopes) @@ -81,6 +82,7 @@ internal static WebApplicationBuilder ConfigureIdentityServer(this WebApplicatio builder.Services.AddIdentityServerConfiguration(opt => { }) .AddInMemoryClientConfigurationStore(); + builder.Services.AddTransient(); builder.Services.AddDistributedMemoryCache(); diff --git a/identity-server/src/Configuration/RequestProcessing/DynamicClientRegistrationRequestProcessor.cs b/identity-server/src/Configuration/RequestProcessing/DynamicClientRegistrationRequestProcessor.cs index d8f62495c..55c41b4db 100644 --- a/identity-server/src/Configuration/RequestProcessing/DynamicClientRegistrationRequestProcessor.cs +++ b/identity-server/src/Configuration/RequestProcessing/DynamicClientRegistrationRequestProcessor.cs @@ -95,7 +95,7 @@ public virtual async Task ProcessAsync( protected virtual async Task AddClientSecret( DynamicClientRegistrationContext context) { - if (context.Client.ClientSecrets.Count == 0) + if (context.Client.ClientSecrets.Count == 0 && context.Request.TokenEndpointAuthenticationMethod != "none") { var (secret, plainText) = await GenerateSecret(context); context.Items["secret"] = secret; diff --git a/identity-server/src/Configuration/Validation/DynamicClientRegistration/DynamicClientRegistrationValidator.cs b/identity-server/src/Configuration/Validation/DynamicClientRegistration/DynamicClientRegistrationValidator.cs index ec518dc4b..3e38f8238 100644 --- a/identity-server/src/Configuration/Validation/DynamicClientRegistration/DynamicClientRegistrationValidator.cs +++ b/identity-server/src/Configuration/Validation/DynamicClientRegistration/DynamicClientRegistrationValidator.cs @@ -129,6 +129,12 @@ protected virtual Task SetGrantTypesAsync(DynamicClientRegistration if (context.Request.GrantTypes.Contains(OidcConstants.GrantTypes.ClientCredentials)) { + if (context.Request.RequireClientSecret is false || + context.Request.TokenEndpointAuthenticationMethod == "none") + { + return StepResult.Failure("client secret is required for client credentials grant type"); + } + context.Client.AllowedGrantTypes.Add(GrantType.ClientCredentials); } if (context.Request.GrantTypes.Contains(OidcConstants.GrantTypes.AuthorizationCode)) @@ -482,6 +488,11 @@ protected virtual Task SetPublicClientProperties(DynamicClientRegis { context.Client.RequireClientSecret = context.Request.RequireClientSecret.Value; } + else if (context.Request.TokenEndpointAuthenticationMethod == "none") + { + context.Client.RequireClientSecret = false; + } + return StepResult.Success(); } diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs index a83a1338f..3165efe60 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -236,11 +236,10 @@ public static IIdentityServerBuilder AddCoreServices(this IIdentityServerBuilder builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(serviceProvider => new DiagnosticSummary( + builder.Services.AddSingleton(); + builder.Services.AddSingleton(serviceProvider => new DiagnosticDataService( DateTime.UtcNow, - serviceProvider.GetServices(), - serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService())); + serviceProvider.GetServices())); builder.Services.AddHostedService(); return builder; diff --git a/identity-server/src/IdentityServer/Constants.cs b/identity-server/src/IdentityServer/Constants.cs index 031fb8cd7..98e6a91e4 100644 --- a/identity-server/src/IdentityServer/Constants.cs +++ b/identity-server/src/IdentityServer/Constants.cs @@ -110,7 +110,7 @@ public static class SigningAlgorithms OidcConstants.PromptModes.Consent, OidcConstants.PromptModes.SelectAccount, // Create not in here by default -- it's added if customer sets the CreateAccountUrl user interaction option - //OidcConstants.PromptModes.Create, + //OidcConstants.PromptModes.Create, }; /// @@ -222,6 +222,7 @@ public static class EnvironmentKeys public const string IdentityServerBasePath = "idsvr:IdentityServerBasePath"; public const string SignOutCalled = "idsvr:IdentityServerSignOutCalled"; public const string DetectedExpiredUserSession = "idsvr:IdentityServerDetectedExpiredUserSession"; + public const string BackChannlLogoutTriggered = "idsvr:IdentityServerBackChannlLogoutTriggered"; } public static class TokenTypeHints diff --git a/identity-server/src/IdentityServer/Endpoints/Results/AuthorizeResult.cs b/identity-server/src/IdentityServer/Endpoints/Results/AuthorizeResult.cs index 92334d692..91dc0151e 100644 --- a/identity-server/src/IdentityServer/Endpoints/Results/AuthorizeResult.cs +++ b/identity-server/src/IdentityServer/Endpoints/Results/AuthorizeResult.cs @@ -223,6 +223,19 @@ private async Task RedirectToErrorPageAsync(AuthorizeResponse response, HttpCont await uiLocalesService.StoreUiLocalesForRedirectAsync(response.Request?.UiLocales); } + var errorModel = await CreateErrorMessage(response, context); + + var message = new Message(errorModel, _clock.UtcNow.UtcDateTime); + var id = await _errorMessageStore.WriteAsync(message); + + var errorUrl = _options.UserInteraction.ErrorUrl; + + var url = errorUrl.AddQueryString(_options.UserInteraction.ErrorIdParameter, id); + context.Response.Redirect(_urls.GetAbsoluteUrl(url)); + } + + protected virtual Task CreateErrorMessage(AuthorizeResponse response, HttpContext context) + { var errorModel = new ErrorMessage { ActivityId = System.Diagnostics.Activity.Current?.Id, @@ -234,12 +247,6 @@ private async Task RedirectToErrorPageAsync(AuthorizeResponse response, HttpCont ClientId = response.Request?.ClientId }; - var message = new Message(errorModel, _clock.UtcNow.UtcDateTime); - var id = await _errorMessageStore.WriteAsync(message); - - var errorUrl = _options.UserInteraction.ErrorUrl; - - var url = errorUrl.AddQueryString(_options.UserInteraction.ErrorIdParameter, id); - context.Response.Redirect(_urls.GetAbsoluteUrl(url)); + return Task.FromResult(errorModel); } } diff --git a/identity-server/src/IdentityServer/Extensions/HttpContextExtensions.cs b/identity-server/src/IdentityServer/Extensions/HttpContextExtensions.cs index 14a1f9910..a62cb40b5 100644 --- a/identity-server/src/IdentityServer/Extensions/HttpContextExtensions.cs +++ b/identity-server/src/IdentityServer/Extensions/HttpContextExtensions.cs @@ -23,6 +23,14 @@ internal static void SetSignOutCalled(this HttpContext context) internal static bool GetSignOutCalled(this HttpContext context) => context.Items.ContainsKey(Constants.EnvironmentKeys.SignOutCalled); + internal static void SetBackChannelLogoutTriggered(this HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + context.Items[Constants.EnvironmentKeys.BackChannlLogoutTriggered] = "true"; + } + + internal static bool GetBackChannelLogoutTriggered(this HttpContext context) => context.Items.ContainsKey(Constants.EnvironmentKeys.BackChannlLogoutTriggered); + internal static void SetExpiredUserSession(this HttpContext context, UserSession userSession) { ArgumentNullException.ThrowIfNull(context); diff --git a/identity-server/src/IdentityServer/Hosting/IdentityServerAuthenticationService.cs b/identity-server/src/IdentityServer/Hosting/IdentityServerAuthenticationService.cs index a9a41c376..d906486e3 100644 --- a/identity-server/src/IdentityServer/Hosting/IdentityServerAuthenticationService.cs +++ b/identity-server/src/IdentityServer/Hosting/IdentityServerAuthenticationService.cs @@ -7,6 +7,7 @@ using Duende.IdentityModel; using Duende.IdentityServer.Configuration.DependencyInjection; using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Models; using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; @@ -27,6 +28,8 @@ internal class IdentityServerAuthenticationService : IAuthenticationService private readonly IAuthenticationSchemeProvider _schemes; private readonly IClock _clock; private readonly IUserSession _session; + private readonly IIssuerNameService _issuerNameService; + private readonly ISessionCoordinationService _sessionCoordinationService; private readonly ILogger _logger; public IdentityServerAuthenticationService( @@ -34,6 +37,8 @@ public IdentityServerAuthenticationService( IAuthenticationSchemeProvider schemes, IClock clock, IUserSession session, + IIssuerNameService issuerNameService, + ISessionCoordinationService sessionCoordinationService, ILogger logger) { _inner = decorator.Instance; @@ -41,6 +46,8 @@ public IdentityServerAuthenticationService( _schemes = schemes; _clock = clock; _session = session; + _issuerNameService = issuerNameService; + _sessionCoordinationService = sessionCoordinationService; _logger = logger; } @@ -77,6 +84,38 @@ public async Task SignOutAsync(HttpContext context, string scheme, Authenticatio { // this sets a flag used by middleware to do post-signout work. context.SetSignOutCalled(); + + if (!context.GetBackChannelLogoutTriggered()) + { + // Note: it is important the work for triggering back-channel logout + // is inside the Response.OnStarting event. Otherwise, in some conditions + // the request will never complete. + // See: https://github.com/DuendeArchive/IdentityServer4/issues/4644 + context.Response.OnStarting(async () => + { + _logger.LogDebug("SignOutCalled set; processing post-signout session cleanup."); + + // back channel logout + var user = await _session.GetUserAsync(); + if (user != null) + { + var session = new UserSession + { + SubjectId = user.GetSubjectId(), + SessionId = await _session.GetSessionIdAsync(), + DisplayName = user.GetDisplayName(), + ClientIds = (await _session.GetClientListAsync()).ToList(), + Issuer = await _issuerNameService.GetCurrentAsync() + }; + await _sessionCoordinationService.ProcessLogoutAsync(session); + } + + // this clears our session id cookie so JS clients can detect the user has signed out + await _session.RemoveSessionIdCookieAsync(); + }); + + context.SetBackChannelLogoutTriggered(); + } } await _inner.SignOutAsync(context, scheme, properties); diff --git a/identity-server/src/IdentityServer/Hosting/IdentityServerMiddleware.cs b/identity-server/src/IdentityServer/Hosting/IdentityServerMiddleware.cs index 9d59e692d..315d1e751 100644 --- a/identity-server/src/IdentityServer/Hosting/IdentityServerMiddleware.cs +++ b/identity-server/src/IdentityServer/Hosting/IdentityServerMiddleware.cs @@ -7,7 +7,6 @@ using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Licensing.V2; using Duende.IdentityServer.Logging; -using Duende.IdentityServer.Models; using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; @@ -61,28 +60,6 @@ public async Task Invoke( context.Response.OnStarting(async () => { - if (context.GetSignOutCalled()) - { - _sanitizedLogger.LogDebug("SignOutCalled set; processing post-signout session cleanup."); - - // this clears our session id cookie so JS clients can detect the user has signed out - await userSession.RemoveSessionIdCookieAsync(); - - var user = await userSession.GetUserAsync(); - if (user != null) - { - var session = new UserSession - { - SubjectId = user.GetSubjectId(), - SessionId = await userSession.GetSessionIdAsync(), - DisplayName = user.GetDisplayName(), - ClientIds = (await userSession.GetClientListAsync()).ToList(), - Issuer = await issuerNameService.GetCurrentAsync() - }; - await sessionCoordinationService.ProcessLogoutAsync(session); - } - } - if (context.TryGetExpiredUserSession(out var expiredUserSession)) { _sanitizedLogger.LogDebug("Detected expired session removed; processing post-expiration cleanup."); diff --git a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticSummary.cs b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticSummary.cs index 6e7e78e78..c30e7fc78 100644 --- a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticSummary.cs +++ b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticSummary.cs @@ -1,36 +1,21 @@ // 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.Configuration; +using Duende.IdentityServer.Services; using Microsoft.Extensions.Logging; namespace Duende.IdentityServer.Licensing.V2.Diagnostics; -internal class DiagnosticSummary(DateTime serverStartTime, IEnumerable entries, IdentityServerOptions options, ILoggerFactory loggerFactory) +internal class DiagnosticSummary(DiagnosticDataService diagnosticDataService, IdentityServerOptions options, ILoggerFactory loggerFactory) { private readonly ILogger _logger = loggerFactory.CreateLogger("Duende.IdentityServer.Diagnostics.Summary"); public async Task PrintSummary() { - var bufferWriter = new ArrayBufferWriter(); - await using var writer = new Utf8JsonWriter(bufferWriter, new JsonWriterOptions { Indented = false }); - - writer.WriteStartObject(); - - var diagnosticContext = new DiagnosticContext(serverStartTime, DateTime.UtcNow); - foreach (var diagnosticEntry in entries) - { - await diagnosticEntry.WriteAsync(diagnosticContext, writer); - } - - writer.WriteEndObject(); - - await writer.FlushAsync(); - - var span = bufferWriter.WrittenSpan; + var jsonMemory = await diagnosticDataService.GetJsonBytesAsync(); + var span = jsonMemory.Span; using var diagnosticActivity = Tracing.DiagnosticsActivitySource.StartActivity("DiagnosticSummary"); var chunkSize = options.Diagnostics.ChunkSize; @@ -47,7 +32,7 @@ public async Task PrintSummary() } else { - _logger.DiagnosticSummaryLogged(1, 1, Encoding.UTF8.GetString(bufferWriter.WrittenSpan)); + _logger.DiagnosticSummaryLogged(1, 1, Encoding.UTF8.GetString(span)); } } } diff --git a/identity-server/src/IdentityServer/Services/DiagnosticDataService.cs b/identity-server/src/IdentityServer/Services/DiagnosticDataService.cs new file mode 100644 index 000000000..f53ccf1ef --- /dev/null +++ b/identity-server/src/IdentityServer/Services/DiagnosticDataService.cs @@ -0,0 +1,50 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + + +#nullable enable + +using System.Buffers; +using System.Text; +using System.Text.Json; +using Duende.IdentityServer.Licensing.V2.Diagnostics; + +namespace Duende.IdentityServer.Services; + +public class DiagnosticDataService +{ + private readonly DateTime _serverStartTime; + private readonly IEnumerable _entries; + + internal DiagnosticDataService(DateTime serverStartTime, IEnumerable entries) + { + _serverStartTime = serverStartTime; + _entries = entries; + } + + public async Task> GetJsonBytesAsync() + { + var bufferWriter = new ArrayBufferWriter(); + await using var writer = new Utf8JsonWriter(bufferWriter, new JsonWriterOptions { Indented = false }); + + writer.WriteStartObject(); + + var diagnosticContext = new DiagnosticContext(_serverStartTime, DateTime.UtcNow); + foreach (var diagnosticEntry in _entries) + { + await diagnosticEntry.WriteAsync(diagnosticContext, writer); + } + + writer.WriteEndObject(); + + await writer.FlushAsync(); + + return bufferWriter.WrittenMemory; + } + + public async Task GetJsonStringAsync() + { + var bytes = await GetJsonBytesAsync(); + return Encoding.UTF8.GetString(bytes.Span); + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Configuration/DynamicClientRegistrationTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Configuration/DynamicClientRegistrationTests.cs index 4d4679f3a..54995bd0f 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/Configuration/DynamicClientRegistrationTests.cs +++ b/identity-server/test/IdentityServer.IntegrationTests/Configuration/DynamicClientRegistrationTests.cs @@ -39,4 +39,28 @@ public async Task valid_request_creates_new_client() newClient.ClientSecrets.Count.ShouldBe(1); newClient.ClientSecrets.Single().Value.ShouldBe(response.ClientSecret.Sha256()); } + + [Fact] + public async Task request_for_public_client_does_not_require_client_secret() + { + IdentityServerHost.ApiScopes.Add(new ApiScope("api1")); + + var request = new DynamicClientRegistrationRequest + { + RedirectUris = new[] { new Uri("https://example.com/callback") }, + GrantTypes = new[] { "authorization_code" }, + ClientName = "test", + ClientUri = new Uri("https://example.com"), + DefaultMaxAge = 10000, + Scope = "api1 openid profile", + TokenEndpointAuthenticationMethod = "none" + }; + var httpResponse = await ConfigurationHost.HttpClient!.PostAsJsonAsync("/connect/dcr", request); + + var response = await httpResponse.Content.ReadFromJsonAsync(); + response.ShouldNotBeNull(); + response.ClientSecret.ShouldBeNull(); + response.RequireClientSecret.ShouldNotBeNull(); + response.RequireClientSecret.Value.ShouldBeFalse(); + } } diff --git a/identity-server/test/IdentityServer.IntegrationTests/Configuration/DynamicClientRegistrationValidationTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Configuration/DynamicClientRegistrationValidationTests.cs index 14a1dc0aa..5d233cc2d 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/Configuration/DynamicClientRegistrationValidationTests.cs +++ b/identity-server/test/IdentityServer.IntegrationTests/Configuration/DynamicClientRegistrationValidationTests.cs @@ -113,4 +113,34 @@ public async Task jwks_and_jwks_uri_used_together_should_fail() var error = await response.Content.ReadFromJsonAsync(); error?.Error.ShouldBe("invalid_client_metadata"); } + + [Fact] + public async Task client_credentials_and_do_not_require_client_secret_should_fail() + { + var response = await ConfigurationHost.HttpClient!.PostAsJsonAsync("/connect/dcr", + new DynamicClientRegistrationRequest + { + GrantTypes = { "client_credentials" }, + RequireClientSecret = false + }); + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + + var error = await response.Content.ReadFromJsonAsync(); + error?.Error.ShouldBe("invalid_client_metadata"); + } + + [Fact] + public async Task client_credentials_and_token_endpoint_auth_method_none_should_fail() + { + var response = await ConfigurationHost.HttpClient!.PostAsJsonAsync("/connect/dcr", + new DynamicClientRegistrationRequest + { + GrantTypes = { "client_credentials" }, + TokenEndpointAuthenticationMethod = "none" + }); + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + + var error = await response.Content.ReadFromJsonAsync(); + error?.Error.ShouldBe("invalid_client_metadata"); + } } diff --git a/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticHostedServiceTests.cs b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticHostedServiceTests.cs index 9fba275d3..5969bb650 100644 --- a/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticHostedServiceTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticHostedServiceTests.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Licensing.V2.Diagnostics; +using Duende.IdentityServer.Services; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -24,7 +25,8 @@ public async Task ExecuteAsync_ShouldNotThrowOperationCancelledException() secondDiagnosticEntry, thirdDiagnosticEntry }; - var diagnosticSummary = new DiagnosticSummary(DateTime.UtcNow, entries, new IdentityServerOptions(), new StubLoggerFactory(diagnosticSummaryLogger)); + var diagnosticService = new DiagnosticDataService(DateTime.UtcNow, entries); + var diagnosticSummary = new DiagnosticSummary(diagnosticService, new IdentityServerOptions(), new StubLoggerFactory(diagnosticSummaryLogger)); var options = Options.Create(new IdentityServerOptions()); var logger = new NullLogger(); diff --git a/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticSummaryTests.cs b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticSummaryTests.cs index ab3ae75bd..eaf48b94b 100644 --- a/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticSummaryTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticSummaryTests.cs @@ -5,6 +5,7 @@ using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Events; using Duende.IdentityServer.Licensing.V2.Diagnostics; +using Duende.IdentityServer.Services; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; @@ -25,7 +26,8 @@ public async Task PrintSummary_ShouldCallWriteAsyncOnEveryDiagnosticEntry() secondDiagnosticEntry, thirdDiagnosticEntry }; - var summary = new DiagnosticSummary(DateTime.UtcNow, entries, new IdentityServerOptions(), new StubLoggerFactory(logger)); + var diagnosticService = new DiagnosticDataService(DateTime.UtcNow, entries); + var summary = new DiagnosticSummary(diagnosticService, new IdentityServerOptions(), new StubLoggerFactory(logger)); await summary.PrintSummary(); @@ -42,7 +44,8 @@ public async Task PrintSummary_ShouldChunkLargeOutput() var logger = new FakeLogger(); var diagnosticEntry = new LongDiagnosticEntry { OutputLength = chunkSize * 2 }; - var summary = new DiagnosticSummary(DateTime.UtcNow, [diagnosticEntry], options, new StubLoggerFactory(logger)); + var diagnosticService = new DiagnosticDataService(DateTime.UtcNow, [diagnosticEntry]); + var summary = new DiagnosticSummary(diagnosticService, options, new StubLoggerFactory(logger)); await summary.PrintSummary(); @@ -61,7 +64,9 @@ public async Task PrintSummary_ShouldChunkLargeOutputOfMultibyteCharacters() var logger = new FakeLogger(); var diagnosticEntry = new LongDiagnosticEntry { OutputLength = 2, OutputCharacter = '€' }; - var summary = new DiagnosticSummary(DateTime.UtcNow, [diagnosticEntry], options, new StubLoggerFactory(logger)); + var diagnosticService = new DiagnosticDataService(DateTime.UtcNow, [diagnosticEntry]); + var summary = new DiagnosticSummary(diagnosticService, options, new StubLoggerFactory(logger)); + await summary.PrintSummary(); @@ -76,7 +81,9 @@ public async Task PrintSummary_ShouldCreateChunksWithMaxSizeEightKB() var logger = new FakeLogger(); var diagnosticEntry = new LongDiagnosticEntry { OutputLength = options.Diagnostics.ChunkSize * 2 }; - var summary = new DiagnosticSummary(DateTime.UtcNow, [diagnosticEntry], options, new StubLoggerFactory(logger)); + var diagnosticService = new DiagnosticDataService(DateTime.UtcNow, [diagnosticEntry]); + var summary = new DiagnosticSummary(diagnosticService, options, new StubLoggerFactory(logger)); + await summary.PrintSummary(); foreach (var entry in logger.Collector.GetSnapshot()) @@ -91,7 +98,8 @@ public async Task PrintSummary_ShouldIncludeLogEventId() var options = new IdentityServerOptions(); var logger = new FakeLogger(); var diagnosticEntry = new LongDiagnosticEntry { OutputLength = 100000 }; - var summary = new DiagnosticSummary(DateTime.UtcNow, [diagnosticEntry], options, new StubLoggerFactory(logger)); + var diagnosticService = new DiagnosticDataService(DateTime.UtcNow, [diagnosticEntry]); + var summary = new DiagnosticSummary(diagnosticService, options, new StubLoggerFactory(logger)); await summary.PrintSummary(); diff --git a/identity-server/test/IdentityServer.UnitTests/Services/DiagnosticDataServiceTests.cs b/identity-server/test/IdentityServer.UnitTests/Services/DiagnosticDataServiceTests.cs new file mode 100644 index 000000000..ef4178da0 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Services/DiagnosticDataServiceTests.cs @@ -0,0 +1,272 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Text; +using System.Text.Json; +using Duende.IdentityServer.Licensing.V2.Diagnostics; +using Duende.IdentityServer.Services; + +namespace IdentityServer.UnitTests.Services; + +public class DiagnosticDataServiceTests +{ + [Fact] + public async Task GetJsonBytesAsync_WithNoEntries_ShouldReturnEmptyJsonObject() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List(); + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonBytesAsync(); + + var json = Encoding.UTF8.GetString(result.Span); + json.ShouldBe("{}"); + } + + [Fact] + public async Task GetJsonBytesAsync_WithSingleEntry_ShouldReturnValidJson() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new TestDiagnosticEntry("TestProperty", "TestValue") + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonBytesAsync(); + + var json = Encoding.UTF8.GetString(result.Span); + var jsonDoc = JsonDocument.Parse(json); + jsonDoc.RootElement.GetProperty("TestProperty").GetString().ShouldBe("TestValue"); + } + + [Fact] + public async Task GetJsonBytesAsync_WithMultipleEntries_ShouldIncludeAllEntries() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new TestDiagnosticEntry("Property1", "Value1"), + new TestDiagnosticEntry("Property2", "Value2"), + new TestDiagnosticEntry("Property3", "Value3") + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonBytesAsync(); + + var json = Encoding.UTF8.GetString(result.Span); + var jsonDoc = JsonDocument.Parse(json); + jsonDoc.RootElement.GetProperty("Property1").GetString().ShouldBe("Value1"); + jsonDoc.RootElement.GetProperty("Property2").GetString().ShouldBe("Value2"); + jsonDoc.RootElement.GetProperty("Property3").GetString().ShouldBe("Value3"); + } + + [Fact] + public async Task GetJsonBytesAsync_ShouldPassCorrectDiagnosticContext() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var capturedContext = new TestDiagnosticEntry.ContextCapture(); + var entries = new List + { + new TestDiagnosticEntry("TestProperty", "TestValue", capturedContext) + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + await service.GetJsonBytesAsync(); + + capturedContext.Context.ShouldNotBeNull(); + capturedContext.Context.ServerStartTime.ShouldBe(serverStartTime); + capturedContext.Context.CurrentSeverTime.ShouldBeGreaterThanOrEqualTo(serverStartTime); + } + + [Fact] + public async Task GetJsonBytesAsync_ShouldProduceCompactJson() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new TestDiagnosticEntry("Property1", "Value1"), + new TestDiagnosticEntry("Property2", "Value2") + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonBytesAsync(); + + var json = Encoding.UTF8.GetString(result.Span); + json.ShouldNotContain("\n"); + json.ShouldNotContain("\r"); + json.ShouldNotContain(" "); + } + + [Fact] + public async Task GetJsonStringAsync_WithNoEntries_ShouldReturnEmptyJsonObject() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List(); + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonStringAsync(); + + result.ShouldBe("{}"); + } + + [Fact] + public async Task GetJsonStringAsync_WithSingleEntry_ShouldReturnValidJson() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new TestDiagnosticEntry("TestProperty", "TestValue") + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonStringAsync(); + + var jsonDoc = JsonDocument.Parse(result); + jsonDoc.RootElement.GetProperty("TestProperty").GetString().ShouldBe("TestValue"); + } + + [Fact] + public async Task GetJsonStringAsync_WithMultipleEntries_ShouldIncludeAllEntries() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new TestDiagnosticEntry("Property1", "Value1"), + new TestDiagnosticEntry("Property2", "Value2"), + new TestDiagnosticEntry("Property3", "Value3") + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonStringAsync(); + + var jsonDoc = JsonDocument.Parse(result); + jsonDoc.RootElement.GetProperty("Property1").GetString().ShouldBe("Value1"); + jsonDoc.RootElement.GetProperty("Property2").GetString().ShouldBe("Value2"); + jsonDoc.RootElement.GetProperty("Property3").GetString().ShouldBe("Value3"); + } + + [Fact] + public async Task GetJsonStringAsync_ShouldReturnUtf8EncodedString() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new TestDiagnosticEntry("Property", "Value with émojis 🎉") + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonStringAsync(); + + var jsonDoc = JsonDocument.Parse(result); + jsonDoc.RootElement.GetProperty("Property").GetString().ShouldBe("Value with émojis 🎉"); + } + + [Fact] + public async Task GetJsonStringAsync_ShouldMatchGetJsonBytesAsync() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new TestDiagnosticEntry("Property1", "Value1"), + new TestDiagnosticEntry("Property2", "Value2") + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var stringResult = await service.GetJsonStringAsync(); + var bytesResult = await service.GetJsonBytesAsync(); + var stringFromBytes = Encoding.UTF8.GetString(bytesResult.Span); + + stringResult.ShouldBe(stringFromBytes); + } + + [Fact] + public async Task GetJsonBytesAsync_WithComplexEntry_ShouldWriteNestedObjects() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new ComplexTestDiagnosticEntry() + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonBytesAsync(); + + var json = Encoding.UTF8.GetString(result.Span); + var jsonDoc = JsonDocument.Parse(json); + var complex = jsonDoc.RootElement.GetProperty("ComplexData"); + complex.GetProperty("StringValue").GetString().ShouldBe("test"); + complex.GetProperty("NumberValue").GetInt32().ShouldBe(42); + complex.GetProperty("BoolValue").GetBoolean().ShouldBeTrue(); + } + + [Fact] + public async Task GetJsonBytesAsync_WithAsyncEntry_ShouldHandleAsyncWrites() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new AsyncTestDiagnosticEntry() + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonBytesAsync(); + + var json = Encoding.UTF8.GetString(result.Span); + var jsonDoc = JsonDocument.Parse(json); + jsonDoc.RootElement.GetProperty("AsyncData").GetString().ShouldBe("async value"); + } + + // Test helper classes + private class TestDiagnosticEntry : IDiagnosticEntry + { + private readonly string _propertyName; + private readonly string _propertyValue; + private readonly ContextCapture _contextCapture; + + public TestDiagnosticEntry(string propertyName, string propertyValue, ContextCapture contextCapture = null) + { + _propertyName = propertyName; + _propertyValue = propertyValue; + _contextCapture = contextCapture; + } + + public Task WriteAsync(DiagnosticContext context, Utf8JsonWriter writer) + { + if (_contextCapture != null) + { + _contextCapture.Context = context; + } + writer.WriteString(_propertyName, _propertyValue); + return Task.CompletedTask; + } + + public class ContextCapture + { + public DiagnosticContext Context { get; set; } + } + } + + private class ComplexTestDiagnosticEntry : IDiagnosticEntry + { + public Task WriteAsync(DiagnosticContext context, Utf8JsonWriter writer) + { + writer.WritePropertyName("ComplexData"); + writer.WriteStartObject(); + writer.WriteString("StringValue", "test"); + writer.WriteNumber("NumberValue", 42); + writer.WriteBoolean("BoolValue", true); + writer.WriteEndObject(); + return Task.CompletedTask; + } + } + + private class AsyncTestDiagnosticEntry : IDiagnosticEntry + { + public async Task WriteAsync(DiagnosticContext context, Utf8JsonWriter writer) + { + await Task.Delay(1); + writer.WriteString("AsyncData", "async value"); + } + } +}