From ed22ffdaeafa4322ec4ecd7d579031307812ddb1 Mon Sep 17 00:00:00 2001 From: Erwin van der Valk Date: Fri, 10 Jan 2025 16:56:33 +0100 Subject: [PATCH] updated docs --- bff/samples/Bff/Extensions.cs | 25 +++- .../Hosts.Tests/TestInfra/AppHostFixture.cs | 45 +++++- .../Hosts.Tests/TestInfra/BffClient.cs | 3 + bff/samples/IdentityServer/Config.cs | 128 ---------------- bff/samples/IdentityServer/Extensions.cs | 2 +- .../ServiceDiscoveringClientStore.cs | 139 ++++++++++++++++++ 6 files changed, 205 insertions(+), 137 deletions(-) create mode 100644 bff/samples/IdentityServer/ServiceDiscoveringClientStore.cs diff --git a/bff/samples/Bff/Extensions.cs b/bff/samples/Bff/Extensions.cs index 8090b97e7..ba59edda8 100644 --- a/bff/samples/Bff/Extensions.cs +++ b/bff/samples/Bff/Extensions.cs @@ -20,7 +20,12 @@ internal static class Extensions { - public static WebApplication ConfigureServices(this WebApplicationBuilder builder, Func getServiceProvider) + public static WebApplication ConfigureServices( + this WebApplicationBuilder builder, + + // The serviceprovider is needed to do service discovery + Func getServiceProvider + ) { var services = builder.Services; @@ -58,9 +63,11 @@ public static WebApplication ConfigureServices(this WebApplicationBuilder builde }) .AddOpenIdConnect("oidc", options => { - var resolver = getServiceProvider().GetRequiredService(); - var authority = resolver.GetEndpointsAsync("https://identity-server", CancellationToken.None).GetAwaiter().GetResult(); - options.Authority = authority.Endpoints.First().ToString().TrimEnd('/'); + // 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"; @@ -91,6 +98,16 @@ public static WebApplication ConfigureServices(this WebApplicationBuilder builde } + 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(); diff --git a/bff/samples/Hosts.Tests/TestInfra/AppHostFixture.cs b/bff/samples/Hosts.Tests/TestInfra/AppHostFixture.cs index 0877266e5..01f4f4db8 100644 --- a/bff/samples/Hosts.Tests/TestInfra/AppHostFixture.cs +++ b/bff/samples/Hosts.Tests/TestInfra/AppHostFixture.cs @@ -10,6 +10,18 @@ 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; @@ -46,7 +58,8 @@ public async Task InitializeAsync() } #if !DEBUG_NCRUNCH - // Arrange + // 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(); @@ -71,13 +84,13 @@ public async Task InitializeAsync() 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 @@ -134,6 +147,11 @@ public async Task DisposeAsync() } } + /// + /// This method builds an http client. + /// + /// + /// public HttpClient CreateHttpClient(string clientName) { HttpMessageHandler inner; @@ -141,6 +159,8 @@ public HttpClient CreateHttpClient(string clientName) 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", @@ -150,6 +170,8 @@ public HttpClient CreateHttpClient(string clientName) inner = new SocketsHttpHandler() { + // We need to disable cookies and follow redirects + // because we do this manually (see below). UseCookies = false, AllowAutoRedirect = false, }; @@ -158,15 +180,25 @@ public HttpClient CreateHttpClient(string clientName) 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() @@ -174,14 +206,19 @@ public HttpClient CreateHttpClient(string clientName) { 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 diff --git a/bff/samples/Hosts.Tests/TestInfra/BffClient.cs b/bff/samples/Hosts.Tests/TestInfra/BffClient.cs index e44a218a8..4a4751b9e 100644 --- a/bff/samples/Hosts.Tests/TestInfra/BffClient.cs +++ b/bff/samples/Hosts.Tests/TestInfra/BffClient.cs @@ -10,6 +10,9 @@ namespace Hosts.Tests.TestInfra; +/// +/// Client for the BFF. All the methods that can be invoked are here. +/// public class BffClient { private readonly HttpClient _client; diff --git a/bff/samples/IdentityServer/Config.cs b/bff/samples/IdentityServer/Config.cs index 7f9e31c5b..805f9a01a 100644 --- a/bff/samples/IdentityServer/Config.cs +++ b/bff/samples/IdentityServer/Config.cs @@ -3,9 +3,6 @@ using Duende.IdentityServer.Models; -using Duende.IdentityModel; -using Microsoft.Extensions.ServiceDiscovery; -using Duende.IdentityServer.Stores; namespace IdentityServerHost { @@ -33,130 +30,5 @@ public static class Config ]; - } -} - - -public class ServiceDiscoveringClientResolver(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; - } - 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 diff --git a/bff/samples/IdentityServer/Extensions.cs b/bff/samples/IdentityServer/Extensions.cs index 37a487b02..2b624bafe 100644 --- a/bff/samples/IdentityServer/Extensions.cs +++ b/bff/samples/IdentityServer/Extensions.cs @@ -26,7 +26,7 @@ public static WebApplication ConfigureServices(this WebApplicationBuilder builde // in-memory, code config isBuilder.AddInMemoryIdentityResources(Config.IdentityResources); isBuilder.AddInMemoryApiScopes(Config.ApiScopes); - isBuilder.AddClientStore(); + isBuilder.AddClientStore(); isBuilder.AddInMemoryApiResources(Config.ApiResources); isBuilder.AddExtensionGrantValidator(); diff --git a/bff/samples/IdentityServer/ServiceDiscoveringClientStore.cs b/bff/samples/IdentityServer/ServiceDiscoveringClientStore.cs new file mode 100644 index 000000000..333843bb3 --- /dev/null +++ b/bff/samples/IdentityServer/ServiceDiscoveringClientStore.cs @@ -0,0 +1,139 @@ +// // 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; + +/// +/// 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