diff --git a/.github/workflows/bff-ci.yml b/.github/workflows/bff-ci.yml index e3f430c6d..c403150ef 100644 --- a/.github/workflows/bff-ci.yml +++ b/.github/workflows/bff-ci.yml @@ -23,7 +23,7 @@ jobs: strategy: fail-fast: false matrix: - runs-on: [macOS-latest, ubuntu-latest, windows-latest] + runs-on: [ubuntu-latest, windows-latest] name: ${{ matrix.runs-on }} runs-on: ${{ matrix.runs-on }} defaults: diff --git a/bff/Duende.Bff.sln b/bff/Duende.Bff.sln index 61a082d35..ed3cd0edf 100644 --- a/bff/Duende.Bff.sln +++ b/bff/Duende.Bff.sln @@ -65,6 +65,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hosts.AppHost", "samples\Ho EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hosts.ServiceDefaults", "samples\Hosts.ServiceDefaults\Hosts.ServiceDefaults.csproj", "{2740EDB1-6F59-4A99-B0EE-808D8F61BEC0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hosts.Tests", "samples\Hosts.Tests\Hosts.Tests.csproj", "{A0B771BA-ACF9-4DE2-A2A6-430F6E6E8C07}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "build", "build\build.csproj", "{E083402C-EB22-4F8A-BFD6-A4F448678376}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -351,6 +355,30 @@ Global {2740EDB1-6F59-4A99-B0EE-808D8F61BEC0}.Release|x64.Build.0 = Release|Any CPU {2740EDB1-6F59-4A99-B0EE-808D8F61BEC0}.Release|x86.ActiveCfg = Release|Any CPU {2740EDB1-6F59-4A99-B0EE-808D8F61BEC0}.Release|x86.Build.0 = Release|Any CPU + {A0B771BA-ACF9-4DE2-A2A6-430F6E6E8C07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A0B771BA-ACF9-4DE2-A2A6-430F6E6E8C07}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A0B771BA-ACF9-4DE2-A2A6-430F6E6E8C07}.Debug|x64.ActiveCfg = Debug|Any CPU + {A0B771BA-ACF9-4DE2-A2A6-430F6E6E8C07}.Debug|x64.Build.0 = Debug|Any CPU + {A0B771BA-ACF9-4DE2-A2A6-430F6E6E8C07}.Debug|x86.ActiveCfg = Debug|Any CPU + {A0B771BA-ACF9-4DE2-A2A6-430F6E6E8C07}.Debug|x86.Build.0 = Debug|Any CPU + {A0B771BA-ACF9-4DE2-A2A6-430F6E6E8C07}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A0B771BA-ACF9-4DE2-A2A6-430F6E6E8C07}.Release|Any CPU.Build.0 = Release|Any CPU + {A0B771BA-ACF9-4DE2-A2A6-430F6E6E8C07}.Release|x64.ActiveCfg = Release|Any CPU + {A0B771BA-ACF9-4DE2-A2A6-430F6E6E8C07}.Release|x64.Build.0 = Release|Any CPU + {A0B771BA-ACF9-4DE2-A2A6-430F6E6E8C07}.Release|x86.ActiveCfg = Release|Any CPU + {A0B771BA-ACF9-4DE2-A2A6-430F6E6E8C07}.Release|x86.Build.0 = Release|Any CPU + {E083402C-EB22-4F8A-BFD6-A4F448678376}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E083402C-EB22-4F8A-BFD6-A4F448678376}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E083402C-EB22-4F8A-BFD6-A4F448678376}.Debug|x64.ActiveCfg = Debug|Any CPU + {E083402C-EB22-4F8A-BFD6-A4F448678376}.Debug|x64.Build.0 = Debug|Any CPU + {E083402C-EB22-4F8A-BFD6-A4F448678376}.Debug|x86.ActiveCfg = Debug|Any CPU + {E083402C-EB22-4F8A-BFD6-A4F448678376}.Debug|x86.Build.0 = Debug|Any CPU + {E083402C-EB22-4F8A-BFD6-A4F448678376}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E083402C-EB22-4F8A-BFD6-A4F448678376}.Release|Any CPU.Build.0 = Release|Any CPU + {E083402C-EB22-4F8A-BFD6-A4F448678376}.Release|x64.ActiveCfg = Release|Any CPU + {E083402C-EB22-4F8A-BFD6-A4F448678376}.Release|x64.Build.0 = Release|Any CPU + {E083402C-EB22-4F8A-BFD6-A4F448678376}.Release|x86.ActiveCfg = Release|Any CPU + {E083402C-EB22-4F8A-BFD6-A4F448678376}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -383,6 +411,7 @@ Global {2A04808A-A06C-4F10-87B9-2D12E065F729} = {B2A776DB-385B-4AD4-96A5-61746FD909C3} {8B943A54-F50C-4946-8D0E-DA1B886F13D6} = {E14F66D1-EA3E-40C6-835A-91A4382D4646} {2740EDB1-6F59-4A99-B0EE-808D8F61BEC0} = {E14F66D1-EA3E-40C6-835A-91A4382D4646} + {A0B771BA-ACF9-4DE2-A2A6-430F6E6E8C07} = {B2A776DB-385B-4AD4-96A5-61746FD909C3} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3DAD5980-4688-4794-9CF0-6F3CB67194E7} diff --git a/bff/build/Program.cs b/bff/build/Program.cs index cd325290d..ff3b39ea7 100644 --- a/bff/build/Program.cs +++ b/bff/build/Program.cs @@ -1,5 +1,8 @@ -using System; + using System; using System.IO; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Threading; using System.Threading.Tasks; using static Bullseye.Targets; @@ -43,7 +46,16 @@ internal static async Task Main(string[] args) Target(Targets.Test, DependsOn(Targets.Build), () => { - Run("dotnet", "test -c Release --no-build --nologo"); + // Only running the tests on linux on the build agents because trusting the SSL Cert doesn't work there. + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Run("dotnet", "dev-certs https --trust"); + Run("dotnet", "test -c Release --no-build --nologo"); + } + else + { + Console.WriteLine("Skipping tests on windows and mac-os"); + } }); Target(Targets.CleanPackOutput, () => diff --git a/bff/migrations/UserSessionDb/UserSessionDb.csproj b/bff/migrations/UserSessionDb/UserSessionDb.csproj index 665ed07e5..c844137cf 100644 --- a/bff/migrations/UserSessionDb/UserSessionDb.csproj +++ b/bff/migrations/UserSessionDb/UserSessionDb.csproj @@ -1,7 +1,7 @@ - net8.0;net9.0 + net9.0 diff --git a/bff/samples/Apis/Api.DPoP/Api.DPoP.csproj b/bff/samples/Apis/Api.DPoP/Api.DPoP.csproj index cb6490b2f..90e7f4fc0 100644 --- a/bff/samples/Apis/Api.DPoP/Api.DPoP.csproj +++ b/bff/samples/Apis/Api.DPoP/Api.DPoP.csproj @@ -1,7 +1,7 @@ - net8.0;net9.0 + net9.0 diff --git a/bff/samples/Apis/Api.Isolated/Api.Isolated.csproj b/bff/samples/Apis/Api.Isolated/Api.Isolated.csproj index 9ea54385b..b5be3be2c 100644 --- a/bff/samples/Apis/Api.Isolated/Api.Isolated.csproj +++ b/bff/samples/Apis/Api.Isolated/Api.Isolated.csproj @@ -1,7 +1,7 @@ - net8.0;net9.0 + net9.0 diff --git a/bff/samples/Apis/Api/Api.csproj b/bff/samples/Apis/Api/Api.csproj index aef945598..29cf3a0f8 100644 --- a/bff/samples/Apis/Api/Api.csproj +++ b/bff/samples/Apis/Api/Api.csproj @@ -1,7 +1,7 @@  - net8.0;net9.0 + net9.0 diff --git a/bff/samples/Bff.DPoP/Bff.DPoP.csproj b/bff/samples/Bff.DPoP/Bff.DPoP.csproj index 7ce67789d..054162b59 100644 --- a/bff/samples/Bff.DPoP/Bff.DPoP.csproj +++ b/bff/samples/Bff.DPoP/Bff.DPoP.csproj @@ -1,7 +1,7 @@ - net8.0;net9.0 + net9.0 Bff.DPoP enable diff --git a/bff/samples/Bff.EF/Bff.EF.csproj b/bff/samples/Bff.EF/Bff.EF.csproj index 6b65bb9e2..08eb52583 100644 --- a/bff/samples/Bff.EF/Bff.EF.csproj +++ b/bff/samples/Bff.EF/Bff.EF.csproj @@ -1,7 +1,7 @@ - net8.0;net9.0 + net9.0 Bff.DPoP diff --git a/bff/samples/Bff/Bff.csproj b/bff/samples/Bff/Bff.csproj index a28965f63..7225227df 100644 --- a/bff/samples/Bff/Bff.csproj +++ b/bff/samples/Bff/Bff.csproj @@ -1,7 +1,7 @@  - net8.0;net9.0 + net9.0 Bff enable diff --git a/bff/samples/Bff/Extensions.cs b/bff/samples/Bff/Extensions.cs index 3c53695d2..e2cc07563 100644 --- a/bff/samples/Bff/Extensions.cs +++ b/bff/samples/Bff/Extensions.cs @@ -2,17 +2,24 @@ // See LICENSE in the project root for license information. using System; +using System.Threading; using Duende.Bff; using Duende.Bff.Yarp; using Host8; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.ServiceDiscovery; using Serilog; internal static class Extensions { - public static WebApplication ConfigureServices(this WebApplicationBuilder builder) + public static WebApplication ConfigureServices( + this WebApplicationBuilder builder, + + // The serviceprovider is needed to do service discovery + Func getServiceProvider + ) { var services = builder.Services; @@ -48,42 +55,53 @@ public static WebApplication ConfigureServices(this WebApplicationBuilder builde // strict SameSite handling options.Cookie.SameSite = SameSiteMode.Strict; }) - .AddOpenIdConnect("oidc", options => - { - options.Authority = "https://localhost:5001"; - - // confidential client using code flow + PKCE - options.ClientId = "bff"; - options.ClientSecret = "secret"; - options.ResponseType = "code"; - options.ResponseMode = "query"; - - options.MapInboundClaims = false; - options.GetClaimsFromUserInfoEndpoint = true; - options.SaveTokens = true; - - // request scopes + refresh tokens - options.Scope.Clear(); - options.Scope.Add("openid"); - options.Scope.Add("profile"); - options.Scope.Add("api"); - options.Scope.Add("scope-for-isolated-api"); - options.Scope.Add("offline_access"); - - options.Resource = "urn:isolated-api"; - }); + .AddOpenIdConnect("oidc", options => + { + // Normally, here you simply configure the authority. But here we want to + // use service discovery, because aspire can change the url's at run-time. + // So, it needs to be discovered at runtime. + var authority = DiscoverAuthorityByName(getServiceProvider, "identity-server"); + options.Authority = authority; + + // confidential client using code flow + PKCE + options.ClientId = "bff"; + options.ClientSecret = "secret"; + options.ResponseType = "code"; + options.ResponseMode = "query"; + + options.MapInboundClaims = false; + options.GetClaimsFromUserInfoEndpoint = true; + options.SaveTokens = true; + + // request scopes + refresh tokens + options.Scope.Clear(); + options.Scope.Add("openid"); + options.Scope.Add("profile"); + options.Scope.Add("api"); + options.Scope.Add("scope-for-isolated-api"); + options.Scope.Add("offline_access"); + + options.Resource = "urn:isolated-api"; + }); services.AddSingleton(); services.AddUserAccessTokenHttpClient("api", - configureClient: client => - { - client.BaseAddress = new Uri("https://localhost:5010/api"); - }); + configureClient: client => { client.BaseAddress = new Uri("https://localhost:5010/api"); }); return builder.Build(); } + private static string DiscoverAuthorityByName(Func getServiceProvider, string serviceName) + { + // Use the ServiceEndpointResolver to perform service discovery + var resolver = getServiceProvider().GetRequiredService(); + var authorityEndpoint = resolver.GetEndpointsAsync("https://" + serviceName, CancellationToken.None) + .GetAwaiter().GetResult(); // Right now I have no way to add this async. + var authority = authorityEndpoint.Endpoints[0].ToString()!.TrimEnd('/'); + return authority; + } + public static WebApplication ConfigurePipeline(this WebApplication app) { app.UseSerilogRequestLogging(); @@ -144,7 +162,7 @@ public static WebApplication ConfigurePipeline(this WebApplication app) .RequireAccessToken(TokenType.User) .WithUserAccessTokenParameter(new BffUserAccessTokenParameters(resource: "urn:isolated-api")); - return app; } + } diff --git a/bff/samples/Bff/Program.cs b/bff/samples/Bff/Program.cs index 460b33bd9..139d1f84d 100644 --- a/bff/samples/Bff/Program.cs +++ b/bff/samples/Bff/Program.cs @@ -1,6 +1,7 @@ using System; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Hosting; +using Microsoft.IdentityModel.Tokens; using Serilog; using Serilog.Events; @@ -27,11 +28,17 @@ .MinimumLevel.Override("Microsoft.AspNetCore.Authentication", LogEventLevel.Information) .ReadFrom.Configuration(ctx.Configuration)); + var serviceProviderAccessor = new ServiceProviderAccessor(); + var app = builder - .ConfigureServices() + .ConfigureServices(() => serviceProviderAccessor.ServiceProvider ?? throw new InvalidOperationException("Service Provider must be set")) .ConfigurePipeline(); + serviceProviderAccessor.ServiceProvider = app.Services; + app.Run(); + + } catch (Exception ex) { @@ -41,4 +48,13 @@ { Log.Information("Shut down complete"); Log.CloseAndFlush(); -} \ No newline at end of file +} + +/// +/// A workaround to get the service provider available in the ConfigureServices method +/// +class ServiceProviderAccessor +{ + public IServiceProvider? ServiceProvider { get; set; } + +} diff --git a/bff/samples/Blazor/PerComponent/PerComponent.Client/PerComponent.Client.csproj b/bff/samples/Blazor/PerComponent/PerComponent.Client/PerComponent.Client.csproj index 33a3ce8bd..76baf2723 100644 --- a/bff/samples/Blazor/PerComponent/PerComponent.Client/PerComponent.Client.csproj +++ b/bff/samples/Blazor/PerComponent/PerComponent.Client/PerComponent.Client.csproj @@ -1,7 +1,7 @@  - net8.0;net9.0 + net9.0 enable enable true diff --git a/bff/samples/Blazor/PerComponent/PerComponent/PerComponent.csproj b/bff/samples/Blazor/PerComponent/PerComponent/PerComponent.csproj index 0471f453e..97ca42fdc 100644 --- a/bff/samples/Blazor/PerComponent/PerComponent/PerComponent.csproj +++ b/bff/samples/Blazor/PerComponent/PerComponent/PerComponent.csproj @@ -1,7 +1,7 @@  - net8.0;net9.0 + net9.0 enable enable diff --git a/bff/samples/Blazor/WebAssembly/WebAssembly.Client/WebAssembly.Client.csproj b/bff/samples/Blazor/WebAssembly/WebAssembly.Client/WebAssembly.Client.csproj index 4e49008b7..cf558d9e4 100644 --- a/bff/samples/Blazor/WebAssembly/WebAssembly.Client/WebAssembly.Client.csproj +++ b/bff/samples/Blazor/WebAssembly/WebAssembly.Client/WebAssembly.Client.csproj @@ -1,7 +1,7 @@ - net8.0;net9.0 + net9.0 enable enable true diff --git a/bff/samples/Blazor/WebAssembly/WebAssembly/WebAssembly.csproj b/bff/samples/Blazor/WebAssembly/WebAssembly/WebAssembly.csproj index f74797578..454355895 100644 --- a/bff/samples/Blazor/WebAssembly/WebAssembly/WebAssembly.csproj +++ b/bff/samples/Blazor/WebAssembly/WebAssembly/WebAssembly.csproj @@ -1,7 +1,7 @@ - net8.0;net9.0 + net9.0 enable enable diff --git a/bff/samples/Hosts.AppHost/Hosts.AppHost.csproj b/bff/samples/Hosts.AppHost/Hosts.AppHost.csproj index 65d135aac..0b4c8a7b6 100644 --- a/bff/samples/Hosts.AppHost/Hosts.AppHost.csproj +++ b/bff/samples/Hosts.AppHost/Hosts.AppHost.csproj @@ -4,7 +4,7 @@ Exe - net8.0;net9.0 + net9.0 enable enable true diff --git a/bff/samples/Hosts.AppHost/Hosts.AppHost.v3.ncrunchproject b/bff/samples/Hosts.AppHost/Hosts.AppHost.v3.ncrunchproject new file mode 100644 index 000000000..319cd523c --- /dev/null +++ b/bff/samples/Hosts.AppHost/Hosts.AppHost.v3.ncrunchproject @@ -0,0 +1,5 @@ + + + True + + \ No newline at end of file diff --git a/bff/samples/Hosts.AppHost/Program.cs b/bff/samples/Hosts.AppHost/Program.cs index 9179339fd..7e563bf79 100644 --- a/bff/samples/Hosts.AppHost/Program.cs +++ b/bff/samples/Hosts.AppHost/Program.cs @@ -1,3 +1,6 @@ +using System.Runtime.CompilerServices; +using Projects; + var builder = DistributedApplication.CreateBuilder(args); var idServer = builder.AddProject("identity-server"); @@ -5,39 +8,51 @@ var api = builder.AddProject("api"); var isolatedApi = builder.AddProject("api-isolated"); -builder.AddProject("bff") +var bff = builder.AddProject("bff") .WithExternalHttpEndpoints() - .WithReference(idServer) - .WithReference(isolatedApi) - .WithReference(api); + .WithAwaitedReference(idServer) + .WithAwaitedReference(isolatedApi) + .WithAwaitedReference(api) + ; builder.AddProject("bff-ef") .WithExternalHttpEndpoints() - .WithReference(idServer) - .WithReference(isolatedApi) - .WithReference(api); + .WithAwaitedReference(idServer) + .WithAwaitedReference(isolatedApi) + .WithAwaitedReference(api); builder.AddProject("bff-webassembly-per-component") .WithExternalHttpEndpoints() - .WithReference(idServer) - .WithReference(isolatedApi) - .WithReference(api); + .WithAwaitedReference(idServer) + .WithAwaitedReference(isolatedApi) + .WithAwaitedReference(api); builder.AddProject("bff-blazor-per-component") .WithExternalHttpEndpoints() - .WithReference(idServer) - .WithReference(isolatedApi) - .WithReference(api); + .WithAwaitedReference(idServer) + .WithAwaitedReference(isolatedApi) + .WithAwaitedReference(api); var apiDPop = builder.AddProject("api-dpop"); builder.AddProject("bff-dpop") .WithExternalHttpEndpoints() - .WithReference(idServer) - .WithReference(apiDPop); + .WithAwaitedReference(idServer) + .WithAwaitedReference(apiDPop); builder.AddProject("migrations"); +idServer.WithReference(bff); + builder.Build().Run(); + +public static class Extensions +{ + public static IResourceBuilder WithAwaitedReference(this IResourceBuilder builder, IResourceBuilder source) + where TDestination : IResourceWithEnvironment, IResourceWithWaitSupport + { + return builder.WithReference(source).WaitFor(source); + } +} \ No newline at end of file diff --git a/bff/samples/Hosts.ServiceDefaults/Hosts.ServiceDefaults.csproj b/bff/samples/Hosts.ServiceDefaults/Hosts.ServiceDefaults.csproj index e56b9630b..db1591a34 100644 --- a/bff/samples/Hosts.ServiceDefaults/Hosts.ServiceDefaults.csproj +++ b/bff/samples/Hosts.ServiceDefaults/Hosts.ServiceDefaults.csproj @@ -1,7 +1,7 @@ - net8.0;net9.0 + net9.0 enable enable true diff --git a/bff/samples/Hosts.Tests/BffTests.cs b/bff/samples/Hosts.Tests/BffTests.cs new file mode 100644 index 000000000..9836ce74d --- /dev/null +++ b/bff/samples/Hosts.Tests/BffTests.cs @@ -0,0 +1,66 @@ +using Hosts.Tests.TestInfra; +using HtmlAgilityPack; +using Shouldly; +using Xunit.Abstractions; +using Console = System.Console; + +namespace Hosts.Tests +{ + public class BffTests : IntegrationTestBase + { + private readonly HttpClient _httpClient; + private BffClient _bffClient; + + public BffTests(ITestOutputHelper output, AppHostFixture fixture) : base(output, fixture) + { + _httpClient = CreateHttpClient("bff"); + _bffClient = new BffClient(CreateHttpClient("bff")); + + } + + [SkippableFact] + public async Task Can_invoke_home() + { + var response = await _httpClient.GetAsync("/"); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + } + + [SkippableFact] + public async Task Can_initiate_login() + { + await _bffClient.TriggerLogin(); + + // Verify that there are user claims + var claims = await _bffClient.GetUserClaims(); + claims.Any().ShouldBeTrue(); + } + + [SkippableTheory] + [InlineData("/local/self-contained")] + [InlineData("/local/invokes-external-api")] + [InlineData("/api/user-token")] + [InlineData("/api/client-token")] + [InlineData("/api/user-or-client-token")] + [InlineData("/api/anonymous")] + [InlineData("/api/optional-user-token")] + [InlineData("/api/impersonation")] + [InlineData("/api/audience-constrained")] + public async Task Once_authenticated_can_call_proxied_urls(string url) + { + await _bffClient.TriggerLogin(); + await _bffClient.InvokeApi(url); + } + + [SkippableFact] + public async Task Can_logout() + { + await _bffClient.TriggerLogin(); + await _bffClient.TriggerLogout(); + + await _bffClient.InvokeApi("/local/self-contained", expectedResponse: HttpStatusCode.Unauthorized); + } + + + } + +} diff --git a/bff/samples/Hosts.Tests/Hosts.Tests.csproj b/bff/samples/Hosts.Tests/Hosts.Tests.csproj new file mode 100644 index 000000000..5aae43134 --- /dev/null +++ b/bff/samples/Hosts.Tests/Hosts.Tests.csproj @@ -0,0 +1,47 @@ + + + + net9.0 + enable + enable + false + Debug;Release;Debug_ncrunch + + + + $(DefineConstants);DEBUG_NCRUNCH + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bff/samples/Hosts.Tests/Hosts.Tests.v3.ncrunchproject b/bff/samples/Hosts.Tests/Hosts.Tests.v3.ncrunchproject new file mode 100644 index 000000000..707446918 --- /dev/null +++ b/bff/samples/Hosts.Tests/Hosts.Tests.v3.ncrunchproject @@ -0,0 +1,6 @@ + + + True + Debug_NCrunch + + \ No newline at end of file diff --git a/bff/samples/Hosts.Tests/TestInfra/AppHostFixture.cs b/bff/samples/Hosts.Tests/TestInfra/AppHostFixture.cs new file mode 100644 index 000000000..7e99419f7 --- /dev/null +++ b/bff/samples/Hosts.Tests/TestInfra/AppHostFixture.cs @@ -0,0 +1,240 @@ +using System; +using System.Net; +using System.Net.Sockets; +using Aspire.Hosting; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using Serilog.Extensions.Logging; + +namespace Hosts.Tests.TestInfra; + +/// +/// This fixture will launch the app host, if needed. +/// +/// It has 3 modes: +/// - Directly. Then the test fixture will launch an aspire test host. It will run all tests against the aspire test host. +/// In order to make this work, there were two things that I needed to overcome (see below). Service Discovery and Shared CookieContainers. +/// - With manually run aspire host.The advantage of this is that you can keep your aspire host running +/// and only iterate on your tests. This is more efficient for writing the tests.It also leaves the door open to re-using these tests to run them against a deployed in stance somewhere in the future.Downside is that you cannot debug both your tests and host at the same time because visual studio compiles them in the same location. +/// - With NCrunch. It turns out that NCrunch doesn't support building aspire projects. +/// However, I've always found that iterating over tests using ncrunch is the fastest way to get feedback.So, to make this work, I had to add a conditional compilation. +/// +/// +public class AppHostFixture : IAsyncLifetime +{ + private WriteTestOutput? _activeWriter = null; + + private readonly TextWriter _startupLogs = new StringWriter(); + public IDisposable ConnectLogger(WriteTestOutput output) + { + _activeWriter = output; + return new DelegateDisposable(() => _activeWriter = null); + } + + public bool UsingAlreadyRunningInstance { get; private set; } + public string StartupLogs => _startupLogs.ToString() ?? string.Empty; + protected DistributedApplication? App = null!; + private Logger _logger = null!; + + public async Task InitializeAsync() + { + using var startupLogWriter = ConnectLogger(s => _startupLogs.Write(s)); + + + var loggerConfiguration = new LoggerConfiguration() + .WriteTo + .TextWriter(new DelegateTextWriter(WriteLogs), LogEventLevel.Verbose, outputTemplate: "{Message} - {SourceContext} {NewLine}");//, outputTemplate: "{Message:lj}"); + + _logger = loggerConfiguration.CreateLogger(); + + UsingAlreadyRunningInstance = await CheckIfAspireIsRunning(); + + if (UsingAlreadyRunningInstance) + { + WriteLogs("Running tests against real test server"); + return; + } + +#if !DEBUG_NCRUNCH + // Not running in ncrunch AND no service found running. + // So, create an apphost that will be used for the duration of this testrun. + var appHost = await DistributedApplicationTestingBuilder + .CreateAsync(); + appHost.Configuration["DcpPublisher:RandomizePorts"] = "false"; + + appHost.Services.ConfigureHttpClientDefaults(c => c.ConfigurePrimaryHttpMessageHandler(() => + { + return new SocketsHttpHandler() + { + UseCookies = false, + AllowAutoRedirect = false + }; + })); + + appHost.Services.AddLogging(x => + { + x.AddSerilog(_logger); + }); + + App = await appHost.BuildAsync(); + + var resourceNotificationService = (await appHost.BuildAsync()).Services + .GetRequiredService(); + + await (await appHost.BuildAsync()).StartAsync(); + + // Wait for all the services so that their logs are mostly written. + await resourceNotificationService.WaitForResourceAsync( + "bff", + KnownResourceStates.Running + ) + .WaitAsync(TimeSpan.FromSeconds(30)); + + await resourceNotificationService.WaitForResourceAsync( + "bff-ef", + KnownResourceStates.Running + ) + .WaitAsync(TimeSpan.FromSeconds(30)); + + await resourceNotificationService.WaitForResourceAsync( + "bff-webassembly-per-component", + KnownResourceStates.Running + ) + .WaitAsync(TimeSpan.FromSeconds(30)); + await resourceNotificationService.WaitForResourceAsync( + "bff-dpop", + KnownResourceStates.Running + ) + .WaitAsync(TimeSpan.FromSeconds(30)); + await resourceNotificationService.WaitForResourceAsync( + "migrations", + KnownResourceStates.Running + ) + .WaitAsync(TimeSpan.FromSeconds(30)); + +#endif //#DEBUG_NCRUNCH + } + + public async Task CheckIfAspireIsRunning() + { + try + { + var client = new HttpClient(); + client.Timeout = TimeSpan.FromMilliseconds(1000); + var response = await client.GetAsync("https://localhost:17052"); + + if (response.IsSuccessStatusCode) + { + return true; + } + + return false; + } + catch (HttpRequestException) + { + return false; + } + catch (TaskCanceledException) + { + return false; + } + } + private void WriteLogs(string logMessage) + { + _activeWriter?.Invoke(logMessage); + } + public async Task DisposeAsync() + { + if (App != null) + { + await App.DisposeAsync(); + } + } + + /// + /// This method builds an http client. + /// + /// + /// + public HttpClient CreateHttpClient(string clientName) + { + HttpMessageHandler inner; + Uri? baseAddress = null; + + if (UsingAlreadyRunningInstance) + { + // An aspire host is already found (likely was started manually) + // so build a http client that directly points to this host. + var url = clientName switch + { + "bff" => "https://localhost:5002", + _ => throw new InvalidOperationException("client not configured") + }; + baseAddress = new Uri(url); + + inner = new SocketsHttpHandler() + { + // We need to disable cookies and follow redirects + // because we do this manually (see below). + UseCookies = false, + AllowAutoRedirect = false, + }; + + } + else + { +#if DEBUG_NCRUNCH + // This should not be reached for NCrunch because either the servcie is already running + // or the test base has thrown a SkipException. + throw new InvalidOperationException("This should not be reached in NCrunch"); +#else + // If we're here, that means that we need to create a http client that's pointing to + // aspire. + if (App == null) throw new NotSupportedException("App should not be null"); + var client = App.CreateHttpClient(clientName); + baseAddress = client.BaseAddress; + + // We can't directly use the HTTP Client, beause we need cookie support, but if we + // enable that the cookies get shared across multiple requests + // https://github.com/dotnet/AspNetCore.Docs/issues/15848 + // By wrapping the http client, then handling all the cookies + // ourselves, we bypass this problem. + inner = new CloningHttpMessageHandler(client); +#endif + } + // Log every call that's made (including if it was part of a redirect). + var loggingHandler = + new OutboundRequestLoggingHandler( + CreateLogger() + , _ => true) + { + InnerHandler = inner + }; + + // Manually take care of cookies (see reason why above) + var cookieHandler = new CookieHandler(loggingHandler, new CookieContainer()); + + // Follow redirects when needed. + var redirectHandler = new AutoFollowRedirectHandler(CreateLogger()) + { + InnerHandler = cookieHandler + }; + + // Return a http client that follows redirects, uses cookies and logs all requests. + // For aspire, this is needed otherwise cookies are shared, but it also + // gives a much clearer debug output (each request get's logged. + return new HttpClient(redirectHandler) + { + BaseAddress = baseAddress + }; + + } + + public ILogger CreateLogger() + { + var loggerProvider = new SerilogLoggerProvider(_logger); + return new LoggerFactory([loggerProvider]).CreateLogger(); + } +} \ No newline at end of file diff --git a/bff/samples/Hosts.Tests/TestInfra/AutoFollowRedirectHandler.cs b/bff/samples/Hosts.Tests/TestInfra/AutoFollowRedirectHandler.cs new file mode 100644 index 000000000..f9793f393 --- /dev/null +++ b/bff/samples/Hosts.Tests/TestInfra/AutoFollowRedirectHandler.cs @@ -0,0 +1,121 @@ +using Microsoft.Extensions.Logging; + +namespace Hosts.Tests.TestInfra; + +public class AutoFollowRedirectHandler(ILogger logger) : DelegatingHandler +{ + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + HttpResponseMessage result; + var previousUri = request.RequestUri; + for (var i = 0; i < 20; i++) + { + result = await base.SendAsync(request, cancellationToken); + if (result.StatusCode == HttpStatusCode.Found && result.Headers.Location != null) + { + logger.LogInformation("Redirecting from {0} to {1}", previousUri, result.Headers.Location); + + var newUri = result.Headers.Location; + if (!newUri.IsAbsoluteUri) newUri = new Uri(previousUri!, newUri); + var headers = request.Headers; + request = new HttpRequestMessage(HttpMethod.Get, newUri); + foreach (var header in headers) request.Headers.Add(header.Key, header.Value); + previousUri = request.RequestUri; + continue; + } + + return result; + } + + throw new InvalidOperationException("Keeps redirecting forever"); + } +} + +public class CloningHttpMessageHandler : HttpMessageHandler +{ + private readonly HttpClient _innerHttpClient; + + public CloningHttpMessageHandler(HttpClient innerHttpClient) + { + _innerHttpClient = innerHttpClient ?? throw new ArgumentNullException(nameof(innerHttpClient)); + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // Clone the incoming request + var clonedRequest = await CloneHttpRequestMessageAsync(request); + + // Send the cloned request using the inner HttpClient + var response = await _innerHttpClient.SendAsync(clonedRequest, cancellationToken); + + // Clone the response and return it + return await CloneHttpResponseMessageAsync(response); + } + + private async Task CloneHttpRequestMessageAsync(HttpRequestMessage original) + { + var cloned = new HttpRequestMessage(original.Method, original.RequestUri) + { + Version = original.Version + }; + + // Copy the content if present + if (original.Content != null) + { + //var memoryStream = new MemoryStream(); + //await original.Content.CopyToAsync(memoryStream); + //memoryStream.Position = 0; + //cloned.Content = new StreamContent(memoryStream); + cloned.Content = new StreamContent(await original.Content.ReadAsStreamAsync()); + + // Copy headers from the original content to the cloned content + foreach (var header in original.Content.Headers) + { + cloned.Content.Headers.Add(header.Key, header.Value); + } + } + + // Copy headers + foreach (var header in original.Headers) + { + cloned.Headers.Add(header.Key, header.Value); + } + + return cloned; + } + + private async Task CloneHttpResponseMessageAsync(HttpResponseMessage original) + { + var cloned = new HttpResponseMessage(original.StatusCode) + { + Version = original.Version, + ReasonPhrase = original.ReasonPhrase, + RequestMessage = original.RequestMessage, + }; + + // Copy the content if present + if (original.Content != null) + { + //var memoryStream = new MemoryStream(); + //await original.Content.CopyToAsync(memoryStream); + //memoryStream.Position = 0; + //cloned.Content = new StreamContent(memoryStream); + cloned.Content = new StreamContent(await original.Content.ReadAsStreamAsync()); + + // Copy headers from the original content to the cloned content + foreach (var header in original.Content.Headers) + { + cloned.Content.Headers.Add(header.Key, header.Value); + } + } + + // Copy headers + foreach (var header in original.Headers) + { + cloned.Headers.Add(header.Key, header.Value); + } + + return cloned; + } +} \ No newline at end of file diff --git a/bff/samples/Hosts.Tests/TestInfra/BffClient.cs b/bff/samples/Hosts.Tests/TestInfra/BffClient.cs new file mode 100644 index 000000000..784d03066 --- /dev/null +++ b/bff/samples/Hosts.Tests/TestInfra/BffClient.cs @@ -0,0 +1,131 @@ +// // Copyright (c) Duende Software. All rights reserved. +// // See LICENSE in the project root for license information. + +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text.Json; +using System.Web; +using HtmlAgilityPack; +using Shouldly; + +namespace Hosts.Tests.TestInfra; + +/// +/// Client for the BFF. All the methods that can be invoked are here. +/// +public class BffClient +{ + private readonly HttpClient _client; + + public BffClient(HttpClient client) + { + _client = client; + + // Add a header that will trigger pre-flight cors checks + _client.DefaultRequestHeaders.Add("X-CSRF", "1"); + } + + public async Task TriggerLogin(string userName = "alice", string password = "alice", CancellationToken ct = default) + { + var triggerLoginResponse = await _client.GetAsync("/bff/login"); + + triggerLoginResponse.StatusCode.ShouldBe(HttpStatusCode.OK); + + var loginPage = triggerLoginResponse.RequestMessage?.RequestUri ?? throw new InvalidOperationException("Can't find the login page."); + loginPage.AbsolutePath.ShouldBe("/Account/Login"); + + var html = await triggerLoginResponse.Content.ReadAsStringAsync(); + var form = ExtractForm(html); + + form.Fields["Input.Username"] = "alice"; + form.Fields["Input.Password"] = "alice"; + form.Fields["Input.Button"] = "login"; + + var postLoginResponse = await _client.PostAsync(new Uri(loginPage, form.FormUrl), new FormUrlEncodedContent(form.Fields), ct); + + postLoginResponse.StatusCode.ShouldBe(HttpStatusCode.OK); + postLoginResponse.RequestMessage?.RequestUri?.Authority.ShouldBe(_client.BaseAddress?.Authority, await postLoginResponse.Content.ReadAsStringAsync(ct)); + } + + + /// Parses the HTML content and extracts all form fields into a dictionary. + /// + /// The HTML content to parse. + /// A dictionary where the keys are field names and the values are their default values. + private Form ExtractForm(string htmlContent) + { + var formFields = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Load the HTML content + var htmlDoc = new HtmlDocument(); + htmlDoc.LoadHtml(htmlContent); + + var form = htmlDoc.DocumentNode.SelectSingleNode("//form"); + + // Select all input elements + var inputNodes = form.SelectNodes("//input"); + if (inputNodes != null) + { + foreach (var inputNode in inputNodes) + { + var name = inputNode.GetAttributeValue("name", null); + var value = inputNode.GetAttributeValue("value", string.Empty); + + if (!string.IsNullOrEmpty(name)) + { + + formFields[name] = HttpUtility.HtmlDecode(value); + } + } + } + + return new Form() + { + Fields = formFields, + FormUrl = form.Attributes["action"].Value + }; + } + + public async Task TriggerLogout() + { + // To trigger a logout, we need the logout claim + var userClaims = await GetUserClaims(); + + var logoutLink = userClaims.FirstOrDefault(x => x.Type == "bff:logout_url") + ?? throw new InvalidOperationException("Failed to find logout link claim"); + + var logoutResponse = await _client.GetAsync(logoutLink.Value.ToString()); + logoutResponse.StatusCode.ShouldBe(HttpStatusCode.OK); + } + + public async Task GetUserClaims() + { + var userClaimsString = await _client.GetStringAsync("/bff/user"); + var userClaims = JsonSerializer.Deserialize(userClaimsString, new JsonSerializerOptions() + { + PropertyNameCaseInsensitive = true + })!; + return userClaims; + } + + public async Task InvokeApi(string url, HttpStatusCode expectedResponse = HttpStatusCode.OK) + { + var response = await _client.GetAsync(url); + + response.StatusCode.ShouldBe(expectedResponse); + } + + public record UserClaim + { + public required string Type { get; init; } + public required object Value { get; init; } + } + + private record Form + { + public required string FormUrl { get; init; } + public required Dictionary Fields { get; init; } + } + + +} \ No newline at end of file diff --git a/bff/samples/Hosts.Tests/TestInfra/CookieHandler.cs b/bff/samples/Hosts.Tests/TestInfra/CookieHandler.cs new file mode 100644 index 000000000..9cfe692fa --- /dev/null +++ b/bff/samples/Hosts.Tests/TestInfra/CookieHandler.cs @@ -0,0 +1,40 @@ +using Microsoft.Net.Http.Headers; + +namespace Hosts.Tests.TestInfra; + +public class CookieHandler : DelegatingHandler +{ + private readonly CookieContainer _cookieContainer; + + public CookieHandler(HttpMessageHandler innerHandler, CookieContainer cookieContainer) + : base(innerHandler) + { + _cookieContainer = cookieContainer; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + var requestUri = request.RequestUri; + var header = _cookieContainer.GetCookieHeader(requestUri!); + if (!string.IsNullOrEmpty(header)) + { + request.Headers.Add(HeaderNames.Cookie, header); + } + var response = await base.SendAsync(request, ct); + + if (response.Headers.TryGetValues(HeaderNames.SetCookie, out IEnumerable? setCookieHeaders)) + { + foreach (var cookieHeader in SetCookieHeaderValue.ParseList(setCookieHeaders.ToList())) + { + Cookie cookie = new Cookie(cookieHeader.Name.Value!, cookieHeader.Value.Value, cookieHeader.Path.Value); + if (cookieHeader.Expires.HasValue) + { + cookie.Expires = cookieHeader.Expires.Value.UtcDateTime; + } + _cookieContainer.Add(requestUri!, cookie); + } + } + + return response; + } +} \ No newline at end of file diff --git a/bff/samples/Hosts.Tests/TestInfra/DelegateDisposable.cs b/bff/samples/Hosts.Tests/TestInfra/DelegateDisposable.cs new file mode 100644 index 000000000..b8801dfd4 --- /dev/null +++ b/bff/samples/Hosts.Tests/TestInfra/DelegateDisposable.cs @@ -0,0 +1,9 @@ +namespace Hosts.Tests.TestInfra; + +public class DelegateDisposable(Action onDispose) : IDisposable +{ + public void Dispose() + { + onDispose(); + } +} \ No newline at end of file diff --git a/bff/samples/Hosts.Tests/TestInfra/DelegateTextWriter.cs b/bff/samples/Hosts.Tests/TestInfra/DelegateTextWriter.cs new file mode 100644 index 000000000..60d8e7073 --- /dev/null +++ b/bff/samples/Hosts.Tests/TestInfra/DelegateTextWriter.cs @@ -0,0 +1,62 @@ +namespace Hosts.Tests.TestInfra; + +public class DelegateTextWriter : TextWriter +{ + private WriteTestOutput _writeAction; + private string _currentLine; + + public DelegateTextWriter(WriteTestOutput writeAction) + { + _writeAction = writeAction ?? throw new ArgumentNullException(nameof(writeAction)); + _currentLine = string.Empty; + } + + public override void Write(char value) + { + if (value == '\r') + { + // let's ignore carriage returns + return; + } + + if (value == '\n') + { + _writeAction(_currentLine); + _currentLine = string.Empty; + } + else + { + _currentLine += value; + } + } + + public override void Write(string? value) + { + if (value == null) + return; + _currentLine += value.Replace("\r\n", "\n"); + + if (_currentLine.Contains('\n')) + { + string[] lines = _currentLine.Split(new[] { '\n' }, StringSplitOptions.None); + + for (int i = 0; i < lines.Length- 1; i++) + { + _writeAction(lines[i]); + } + + _currentLine = lines[^1]; + } + } + + public override void WriteLine(string? value) + { + if (value == null) + return; + + _writeAction(value + Environment.NewLine); + _currentLine = string.Empty; + } + + public override System.Text.Encoding Encoding => System.Text.Encoding.Default; +} \ No newline at end of file diff --git a/bff/samples/Hosts.Tests/TestInfra/IntegrationTestBase.cs b/bff/samples/Hosts.Tests/TestInfra/IntegrationTestBase.cs new file mode 100644 index 000000000..679c4b52e --- /dev/null +++ b/bff/samples/Hosts.Tests/TestInfra/IntegrationTestBase.cs @@ -0,0 +1,50 @@ +using Xunit.Abstractions; +using SkipException = Xunit.Sdk.SkipException; + +namespace Hosts.Tests.TestInfra; + +public class IntegrationTestBase : IClassFixture, IDisposable +{ + private readonly IDisposable _loggingScope; + private readonly ITestOutputHelper _output; + private readonly AppHostFixture _fixture; + + public IntegrationTestBase(ITestOutputHelper output, AppHostFixture fixture) + { + _output = output; + _fixture = fixture; + _loggingScope = fixture.ConnectLogger(output.WriteLine); + if (_fixture.UsingAlreadyRunningInstance) + { + output.WriteLine("Running tests against locally running instance"); + } + else + { +#if DEBUG_NCRUNCH + Skip.If(true, "already attached"); +#endif + + } + } + + public AppHostFixture Fixture => _fixture; + public ITestOutputHelper Output => _output; + + public HttpClient CreateHttpClient(string clientName) => _fixture.CreateHttpClient(clientName); + + public void Dispose() + { + if (!_fixture.UsingAlreadyRunningInstance) + { + _output.WriteLine(Environment.NewLine); + _output.WriteLine(Environment.NewLine); + _output.WriteLine(Environment.NewLine); + _output.WriteLine("*************************************************"); + _output.WriteLine("** Startup logs ***"); + _output.WriteLine("*************************************************"); + _output.WriteLine(_fixture.StartupLogs); + } + + _loggingScope.Dispose(); + } +} \ No newline at end of file diff --git a/bff/samples/Hosts.Tests/TestInfra/OutboundRequestLoggingHandler.cs b/bff/samples/Hosts.Tests/TestInfra/OutboundRequestLoggingHandler.cs new file mode 100644 index 000000000..a6851b912 --- /dev/null +++ b/bff/samples/Hosts.Tests/TestInfra/OutboundRequestLoggingHandler.cs @@ -0,0 +1,80 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Hosts.Tests.TestInfra; + +public class OutboundRequestLoggingHandler : DelegatingHandler +{ + private readonly ILogger _log; + private readonly Func _shouldLog; + + public OutboundRequestLoggingHandler(ILogger log, + Func shouldLog) + { + _log = log; + _shouldLog = shouldLog; + } + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (!_shouldLog(request)) + { + return await base.SendAsync(request, cancellationToken); + } + + var stopwatch = Stopwatch.StartNew(); + try + { + _log.LogDebug("Started executing {method} on {url}", + request.Method, + request.RequestUri?.GetLeftPart(UriPartial.Scheme | UriPartial.Authority | UriPartial.Path)); + + var result = await base.SendAsync(request, cancellationToken); + + LogLevel logLevel; + + if (stopwatch.Elapsed > TimeSpan.FromSeconds(1)) + { + logLevel = LogLevel.Warning; + } + else if ((int)result.StatusCode >= 500) + { + logLevel = LogLevel.Warning; + } + else if ((int)result.StatusCode >= 400 || (int)result.StatusCode < 200) + { + logLevel = LogLevel.Information; + } + else + { + logLevel = LogLevel.Information; + } + + _log.Log(logLevel, "Executing {method} on {url} returned {statuscode} in {ms} ms", + request.Method, + request.RequestUri, + result.StatusCode, + stopwatch.ElapsedMilliseconds); + + return result; + } + catch (OperationCanceledException) + { + _log.LogWarning("Executing {method} on {url} was cancelled in {ms} ms", + request.Method, + request.RequestUri, + stopwatch.ElapsedMilliseconds); + throw; + } + catch (Exception ex) + { + _log.LogWarning(ex, "Exception while executing {method} on {url} in {ms} ms", + request.Method, + request.RequestUri, + stopwatch.ElapsedMilliseconds); + + throw; + } + } +} \ No newline at end of file diff --git a/bff/samples/Hosts.Tests/TestInfra/WriteTestOutput.cs b/bff/samples/Hosts.Tests/TestInfra/WriteTestOutput.cs new file mode 100644 index 000000000..b409a6976 --- /dev/null +++ b/bff/samples/Hosts.Tests/TestInfra/WriteTestOutput.cs @@ -0,0 +1,3 @@ +namespace Hosts.Tests.TestInfra; + +public delegate void WriteTestOutput(string message); \ No newline at end of file diff --git a/bff/samples/Hosts.Tests/readme.md b/bff/samples/Hosts.Tests/readme.md new file mode 100644 index 000000000..be9686801 --- /dev/null +++ b/bff/samples/Hosts.Tests/readme.md @@ -0,0 +1,31 @@ +# Hosts.Tests + +This project contains the integration tests for the various hosts. This host is built using Aspnet Aspire. + +The actual tests have been written in such a way that they only need a HTTP client to work. If you don't do anything, +then the system will start an aspire test host, run the tests, then kill the aspire host again. + +Howeve,if you want faster feedback, you can also start the aspire host yourself. To avoid collisions between the running +application and the testing framework trying to rebuild all libraries, I recommend running the system in release mode. + +``` +cd src/samples/Hosts.Tests +dotnet run --configuration Release +``` + +During startup of the first test, the system checks if the aspire host is already running. If so, it will skip starting +and simply configure a http client to work against the host. + + +## Running under NCrunch + +It turns out that aspnet aspire doesn't work well with NCrunch. See this link for more info. +https://forum.ncrunch.net/yaf_postst3541_Aspire.aspx + +But as NCrunch is a really fast way to get feedback on the tests, I've tried to make this work differently. + +I've added a new configuration: Debug_Ncrunch and have included conditional compilation. If you have this enabled +(as it is in NCrunch) then it not use any of the aspire initialization and simply proceed to running the tests. +This is by far the fastest way to develop the tests. You can even have the aspire host running in debug AND debug the tests +at the same time. + diff --git a/bff/samples/IdentityServer/Config.cs b/bff/samples/IdentityServer/Config.cs index a3deddeec..805f9a01a 100644 --- a/bff/samples/IdentityServer/Config.cs +++ b/bff/samples/IdentityServer/Config.cs @@ -3,7 +3,6 @@ using Duende.IdentityServer.Models; -using Duende.IdentityModel; namespace IdentityServerHost { @@ -30,93 +29,6 @@ public static class Config } ]; - public static IEnumerable Clients => - [ - new Client - { - ClientId = "bff", - ClientSecrets = { new Secret("secret".Sha256()) }, - - AllowedGrantTypes = - { - GrantType.AuthorizationCode, - GrantType.ClientCredentials, - OidcConstants.GrantTypes.TokenExchange - }, - RedirectUris = { "https://localhost:5002/signin-oidc" }, - FrontChannelLogoutUri = "https://localhost:5002/signout-oidc", - PostLogoutRedirectUris = { "https://localhost:5002/signout-callback-oidc" }, - - AllowOfflineAccess = true, - AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" }, - - AccessTokenLifetime = 75 // Force refresh - }, - new Client - { - ClientId = "bff.dpop", - ClientSecrets = { new Secret("secret".Sha256()) }, - RequireDPoP = true, - - AllowedGrantTypes = - { - GrantType.AuthorizationCode, - GrantType.ClientCredentials, - OidcConstants.GrantTypes.TokenExchange - }, - - RedirectUris = { "https://localhost:5003/signin-oidc" }, - FrontChannelLogoutUri = "https://localhost:5003/signout-oidc", - PostLogoutRedirectUris = { "https://localhost:5003/signout-callback-oidc" }, - - AllowOfflineAccess = true, - AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" }, - - AccessTokenLifetime = 75 // Force refresh - }, - new Client - { - ClientId = "bff.ef", - ClientSecrets = { new Secret("secret".Sha256()) }, - - AllowedGrantTypes = - { - GrantType.AuthorizationCode, - GrantType.ClientCredentials, - OidcConstants.GrantTypes.TokenExchange - }, - RedirectUris = { "https://localhost:5004/signin-oidc" }, - FrontChannelLogoutUri = "https://localhost:5004/signout-oidc", - BackChannelLogoutUri = "https://localhost:5004/bff/backchannel", - PostLogoutRedirectUris = { "https://localhost:5004/signout-callback-oidc" }, - - AllowOfflineAccess = true, - AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" }, - - AccessTokenLifetime = 75 // Force refresh - }, - - new Client - { - ClientId = "blazor", - ClientSecrets = { new Secret("secret".Sha256()) }, - - AllowedGrantTypes = - { - GrantType.AuthorizationCode, - GrantType.ClientCredentials, - OidcConstants.GrantTypes.TokenExchange - }, - - RedirectUris = { "https://localhost:5005/signin-oidc", "https://localhost:5105/signin-oidc" }, - PostLogoutRedirectUris = { "https://localhost:5005/signout-callback-oidc", "https://localhost:5105/signout-callback-oidc" }, - - AllowOfflineAccess = true, - AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" }, - - AccessTokenLifetime = 75 - } - ]; } } \ No newline at end of file diff --git a/bff/samples/IdentityServer/Extensions.cs b/bff/samples/IdentityServer/Extensions.cs index b2cd375c4..bd1238a4c 100644 --- a/bff/samples/IdentityServer/Extensions.cs +++ b/bff/samples/IdentityServer/Extensions.cs @@ -1,6 +1,9 @@ // // Copyright (c) Duende Software. All rights reserved. // // See LICENSE in the project root for license information. +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Stores; +using IdentityServer; using IdentityServerHost; using Serilog; @@ -24,7 +27,7 @@ public static WebApplication ConfigureServices(this WebApplicationBuilder builde // in-memory, code config isBuilder.AddInMemoryIdentityResources(Config.IdentityResources); isBuilder.AddInMemoryApiScopes(Config.ApiScopes); - isBuilder.AddInMemoryClients(Config.Clients); + isBuilder.AddClientStore(); isBuilder.AddInMemoryApiResources(Config.ApiResources); isBuilder.AddExtensionGrantValidator(); @@ -45,4 +48,4 @@ public static WebApplication ConfigurePipeline(this WebApplication app) return app; } -} \ No newline at end of file +} diff --git a/bff/samples/IdentityServer/IdentityServer.csproj b/bff/samples/IdentityServer/IdentityServer.csproj index 549c356d7..82f82d412 100644 --- a/bff/samples/IdentityServer/IdentityServer.csproj +++ b/bff/samples/IdentityServer/IdentityServer.csproj @@ -1,7 +1,7 @@  - net8.0;net9.0 + net9.0 true diff --git a/bff/samples/IdentityServer/ServiceDiscoveringClientStore.cs b/bff/samples/IdentityServer/ServiceDiscoveringClientStore.cs new file mode 100644 index 000000000..e3befdbd5 --- /dev/null +++ b/bff/samples/IdentityServer/ServiceDiscoveringClientStore.cs @@ -0,0 +1,141 @@ +// // Copyright (c) Duende Software. All rights reserved. +// // See LICENSE in the project root for license information. + +using Duende.IdentityModel; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Stores; +using Microsoft.Extensions.ServiceDiscovery; + +namespace IdentityServer; + +/// +/// This client store will register a list of hard coded clients but will use +/// service discovery to ask for the correct urls. +/// +/// This is needed because the actually used url's need to be set in Identity Server. +/// +/// +public class ServiceDiscoveringClientStore(ServiceEndpointResolver resolver) : IClientStore +{ + private List _clients = null; + private SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); + private async Task Initialize() + { + + await _semaphore.WaitAsync(); + try + { + + + if (_clients != null) + { + return; + } + // Get the BFF URL from the service discovery system. Then use this for building the redirect urls etc.. + var bffUrl = (await resolver.GetEndpointsAsync("https://bff", CancellationToken.None)).Endpoints.First().EndPoint.ToString(); + + _clients = [ + new Client + { + ClientId = "bff", + ClientSecrets = { new Secret("secret".Sha256()) }, + + AllowedGrantTypes = + { + GrantType.AuthorizationCode, + GrantType.ClientCredentials, + OidcConstants.GrantTypes.TokenExchange + }, + + RedirectUris = { $"{bffUrl}signin-oidc" }, + FrontChannelLogoutUri = $"{bffUrl}signout-oidc", + PostLogoutRedirectUris = { $"{bffUrl}signout-callback-oidc" }, + + AllowOfflineAccess = true, + AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" }, + + AccessTokenLifetime = 75 // Force refresh + }, + new Client + { + ClientId = "bff.dpop", + ClientSecrets = { new Secret("secret".Sha256()) }, + RequireDPoP = true, + + AllowedGrantTypes = + { + GrantType.AuthorizationCode, + GrantType.ClientCredentials, + OidcConstants.GrantTypes.TokenExchange + }, + + RedirectUris = { "https://localhost:5003/signin-oidc" }, + FrontChannelLogoutUri = "https://localhost:5003/signout-oidc", + PostLogoutRedirectUris = { "https://localhost:5003/signout-callback-oidc" }, + + AllowOfflineAccess = true, + AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" }, + + AccessTokenLifetime = 75 // Force refresh + }, + new Client + { + ClientId = "bff.ef", + ClientSecrets = { new Secret("secret".Sha256()) }, + + AllowedGrantTypes = + { + GrantType.AuthorizationCode, + GrantType.ClientCredentials, + OidcConstants.GrantTypes.TokenExchange + }, + RedirectUris = { "https://localhost:5004/signin-oidc" }, + FrontChannelLogoutUri = "https://localhost:5004/signout-oidc", + BackChannelLogoutUri = "https://localhost:5004/bff/backchannel", + PostLogoutRedirectUris = { "https://localhost:5004/signout-callback-oidc" }, + + AllowOfflineAccess = true, + AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" }, + + AccessTokenLifetime = 75 // Force refresh + }, + + new Client + { + ClientId = "blazor", + ClientSecrets = { new Secret("secret".Sha256()) }, + + AllowedGrantTypes = + { + GrantType.AuthorizationCode, + GrantType.ClientCredentials, + OidcConstants.GrantTypes.TokenExchange + }, + + RedirectUris = { "https://localhost:5005/signin-oidc", "https://localhost:5105/signin-oidc" }, + PostLogoutRedirectUris = + { + "https://localhost:5005/signout-callback-oidc", "https://localhost:5105/signout-callback-oidc" + }, + + AllowOfflineAccess = true, + AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" }, + + AccessTokenLifetime = 75 + } + ]; + } + finally + { + _semaphore.Release(); + } + + } + + + public async Task FindClientByIdAsync(string clientId) + { + await Initialize(); + return _clients?.FirstOrDefault(x => x.ClientId == clientId); + } +} \ No newline at end of file