diff --git a/.gitignore b/.gitignore
index 86aecdff9..4918295c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -226,4 +226,6 @@ artifacts
#exclude for verify.tests
*.received.txt
-*.Artifacts/
\ No newline at end of file
+*.Artifacts/
+
+reports
\ No newline at end of file
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 4b9f970c6..4c32bd041 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -86,11 +86,15 @@
+
+
+
+
@@ -135,4 +139,4 @@
-
+
\ No newline at end of file
diff --git a/bff/bff.slnf b/bff/bff.slnf
index 019f529f9..f695161cd 100644
--- a/bff/bff.slnf
+++ b/bff/bff.slnf
@@ -3,6 +3,7 @@
"path": "..\\products.slnx",
"projects": [
"bff\\performance\\Bff.Benchmarks\\Bff.Benchmarks.csproj",
+ "bff\\performance\\Bff.Performance\\Bff.Performance.csproj",
".github\\workflow-gen\\workflow-gen.csproj",
"bff\\hosts\\Blazor\\PerComponent\\Hosts.Bff.Blazor.PerComponent.Client\\Hosts.Bff.Blazor.PerComponent.Client.csproj",
"bff\\hosts\\Blazor\\PerComponent\\Hosts.Bff.Blazor.PerComponent\\Hosts.Bff.Blazor.PerComponent.csproj",
@@ -12,6 +13,7 @@
"bff\\hosts\\Hosts.Bff.DPoP\\Hosts.Bff.DPoP.csproj",
"bff\\hosts\\Hosts.Bff.EF\\Hosts.Bff.EF.csproj",
"bff\\hosts\\Hosts.Bff.InMemory\\Hosts.Bff.InMemory.csproj",
+ "bff\\hosts\\Hosts.Bff.Performance\\Hosts.Bff.Performance.csproj",
"bff\\hosts\\Hosts.Bff.MultiFrontend\\Hosts.Bff.MultiFrontend.csproj",
"bff\\hosts\\Hosts.IdentityServer\\Hosts.IdentityServer.csproj",
"bff\\hosts\\Hosts.ServiceDefaults\\Hosts.ServiceDefaults.csproj",
diff --git a/bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj b/bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj
index d12abcbcb..36272dabc 100644
--- a/bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj
+++ b/bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj
@@ -17,9 +17,6 @@
-
-
-
@@ -27,6 +24,7 @@
+
diff --git a/bff/hosts/Hosts.AppHost/Program.cs b/bff/hosts/Hosts.AppHost/Program.cs
index 7d9a04ea9..a047cf865 100644
--- a/bff/hosts/Hosts.AppHost/Program.cs
+++ b/bff/hosts/Hosts.AppHost/Program.cs
@@ -18,6 +18,14 @@ var bff = builder.AddProject(AppHostServices.Bff)
.WithAwaitedReference(api)
;
+var perf = builder.AddProject(AppHostServices.BffPerf)
+ .WithExternalHttpEndpoints()
+ .WithEndpoint(6100, isProxied: false, scheme: "https", name: "idsrv")
+ .WithEndpoint(6001, isProxied: false, scheme: "https", name: "api")
+ .WithEndpoint(6002, isProxied: false, scheme: "https", name: "single")
+ .WithEndpoint(6003, isProxied: false, scheme: "https", name: "multi")
+ ;
+
var bffMulti = builder.AddProject(AppHostServices.BffMultiFrontend)
.WithExternalHttpEndpoints()
.WithUrl("https://app1.localhost:5005", "https://app1.localhost:5005")
@@ -58,6 +66,7 @@ builder.AddProject(AppHostServices.Migrations);
idServer
.WithReference(bff)
+ .WithReference(perf)
.WithReference(bffMulti)
.WithReference(bffEf)
.WithReference(bffBlazorPerComponent)
@@ -74,8 +83,6 @@ builder.AddProject(AppHostServices.TemplateBffRemote, launchProfil
builder.AddProject(AppHostServices.TemplateBffBlazor);
-
-
builder.Build().Run();
public static class Extensions
diff --git a/bff/hosts/Hosts.Bff.Performance/Config.cs b/bff/hosts/Hosts.Bff.Performance/Config.cs
new file mode 100644
index 000000000..feeb66966
--- /dev/null
+++ b/bff/hosts/Hosts.Bff.Performance/Config.cs
@@ -0,0 +1,121 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+
+using Duende.IdentityModel;
+using Duende.IdentityServer.Models;
+using Hosts.ServiceDefaults;
+
+namespace IdentityServer;
+
+public static class Config
+{
+ public static IEnumerable IdentityResources =>
+ [
+ new IdentityResources.OpenId(),
+ new IdentityResources.Profile(),
+ ];
+
+ public static IEnumerable ApiScopes =>
+ [
+ new("api", ["name"]),
+ new("scope-for-isolated-api", ["name"]),
+ ];
+
+ public static IEnumerable ApiResources =>
+ [
+ new("urn:isolated-api", "isolated api")
+ {
+ RequireResourceIndicator = true,
+ Scopes = { "scope-for-isolated-api" }
+ }
+ ];
+ // Get the BFF URL from the service discovery system. Then use this for building the redirect urls etc..
+ private static Uri bffUrl = ServiceDiscovery.ResolveService(AppHostServices.Bff);
+ private static Uri bffMultiFrontendUrl = ServiceDiscovery.ResolveService(AppHostServices.BffMultiFrontend);
+ private static Uri bffDPopUrl = ServiceDiscovery.ResolveService(AppHostServices.BffDpop);
+ private static Uri bffEfUrl = ServiceDiscovery.ResolveService(AppHostServices.BffEf);
+ private static Uri bffBlazorPerComponentUrl = ServiceDiscovery.ResolveService(AppHostServices.BffBlazorPerComponent);
+ private static Uri bffBlazorWebAssemblyUrl = ServiceDiscovery.ResolveService(AppHostServices.BffBlazorWebassembly); public static IEnumerable Clients =>
+ [
+ BuildClient("bff.perf",
+ ServiceDiscovery.ResolveService(AppHostServices.BffPerf, "single"),
+ ServiceDiscovery.ResolveService(AppHostServices.BffPerf, "multi"),
+ new Uri("https://app1.localhost:6002")
+ ),
+
+ BuildClient("bff", client =>
+ {
+ client.AllowedScopes.Add("scope-for-isolated-api");
+ }, bffUrl),
+
+ BuildClient("bff.multi-frontend.default", client =>
+ {
+ client.AllowedScopes.Add("scope-for-isolated-api");
+ }, bffMultiFrontendUrl),
+
+ BuildClient("bff.multi-frontend.config", client =>
+ {
+ client.AllowedScopes.Add("scope-for-isolated-api");
+ }, new Uri(bffMultiFrontendUrl, "from-config")),
+
+ BuildClient("bff.multi-frontend.with-path", client =>
+ {
+ client.AllowedScopes.Add("scope-for-isolated-api");
+ }, new Uri(bffMultiFrontendUrl, "with-path")),
+
+ BuildClient("bff.multi-frontend.with-domain", client =>
+ {
+ client.AllowedScopes.Add("scope-for-isolated-api");
+ }, new Uri("https://app1.localhost:5005")),
+
+ BuildClient("bff.dpop", client =>
+ {
+ client.RequireDPoP = true;
+ client.AllowedScopes.Add("scope-for-isolated-api");
+ }, bffDPopUrl),
+
+ BuildClient("bff.ef", client =>
+ {
+ client.BackChannelLogoutUri = $"{bffEfUrl}bff/backchannel";
+ client.AllowedScopes.Add("scope-for-isolated-api");
+ }, bffEfUrl),
+
+ BuildClient("blazor", client =>
+ {
+ client.AllowedScopes.Add("scope-for-isolated-api");
+ }, bffBlazorWebAssemblyUrl, bffBlazorPerComponentUrl, new Uri("https://localhost:7035"))
+ ];
+
+
+ private static Client BuildClient(string clientId, Action postConfigure, params Uri[] uris)
+ {
+ var client = BuildClient(clientId, uris);
+ postConfigure(client);
+ return client;
+ }
+
+ private static Client BuildClient(string clientId, params Uri[] uris) => new Client
+ {
+ ClientId = clientId,
+ ClientSecrets = { new Secret("secret".Sha256()) },
+
+ AllowedGrantTypes =
+ {
+ GrantType.AuthorizationCode,
+ GrantType.ClientCredentials,
+ OidcConstants.GrantTypes.TokenExchange
+ },
+ RedirectUris = uris.Select(u => new Uri(u, "signin-oidc").ToString()).ToList(),
+ FrontChannelLogoutUri = new Uri(uris.First(), "signout-oidc").ToString(),
+ PostLogoutRedirectUris = uris.Select(u => new Uri(u, "signout-callback-oidc").ToString()).ToList(),
+
+ AllowOfflineAccess = true,
+ AllowedScopes = { "openid", "profile", "api" },
+
+ RefreshTokenExpiration = TokenExpiration.Absolute,
+ AbsoluteRefreshTokenLifetime = 60,
+ AccessTokenLifetime = 15 // Force refresh
+ };
+
+}
diff --git a/bff/hosts/Hosts.Bff.Performance/DefaultOpenIdConfiguration.cs b/bff/hosts/Hosts.Bff.Performance/DefaultOpenIdConfiguration.cs
new file mode 100644
index 000000000..d2c576363
--- /dev/null
+++ b/bff/hosts/Hosts.Bff.Performance/DefaultOpenIdConfiguration.cs
@@ -0,0 +1,30 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Hosts.Bff.Performance.Services;
+using Microsoft.AspNetCore.Authentication.OpenIdConnect;
+
+public static class DefaultOpenIdConfiguration
+{
+ public static void Apply(OpenIdConnectOptions options, BffSettings settings)
+ {
+ options.Authority = settings.IdentityServerUrl.ToString();
+
+ // confidential client using code flow + PKCE
+ options.ClientId = "bff.perf";
+ 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("offline_access");
+ }
+}
diff --git a/bff/hosts/Hosts.Bff.Performance/Hosts.Bff.Performance.csproj b/bff/hosts/Hosts.Bff.Performance/Hosts.Bff.Performance.csproj
new file mode 100644
index 000000000..ebc2f57de
--- /dev/null
+++ b/bff/hosts/Hosts.Bff.Performance/Hosts.Bff.Performance.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bff/hosts/Hosts.Bff.Performance/Program.cs b/bff/hosts/Hosts.Bff.Performance/Program.cs
new file mode 100644
index 000000000..1528158b1
--- /dev/null
+++ b/bff/hosts/Hosts.Bff.Performance/Program.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Hosts.Bff.Performance.Services;
+
+var builder = Host.CreateApplicationBuilder();
+
+builder.Services.Configure(builder.Configuration);
+builder.Services.Configure(builder.Configuration);
+builder.Services.Configure(builder.Configuration);
+
+builder.Services.AddHostedService();
+builder.Services.AddHostedService();
+builder.Services.AddHostedService();
+builder.Services.AddHostedService();
+// Add services to the container.
+
+var app = builder.Build();
+
+// Configure the HTTP request pipeline.
+
+// spin up multiple applications:
+// Plain yarp
+
+
+// single frontend
+// multi-frontend
+// bff with server side EF sessions
+
+
+
+
+app.Run();
diff --git a/bff/hosts/Hosts.Bff.Performance/Properties/launchSettings.json b/bff/hosts/Hosts.Bff.Performance/Properties/launchSettings.json
new file mode 100644
index 000000000..56b0bb23e
--- /dev/null
+++ b/bff/hosts/Hosts.Bff.Performance/Properties/launchSettings.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "Hosts.Bff.Performance": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "environmentVariables": {
+ "ApiUrl": "https://localhost:6100",
+ "IdentityServerUrl": "https://localhost:6001",
+ "BffUrl1": "https://localhost:6002",
+ "BffUrl2": "https://localhost:6003",
+ "BffUrl3": "https://app1.localhost:6003"
+ },
+ "applicationUrl": "https://localhost:6100;https://localhost:6001;https://localhost:6002;https://localhost:6003;https://app1.localhost:6003"
+ }
+ }
+}
diff --git a/bff/hosts/Hosts.Bff.Performance/Services/ApiHostedService.cs b/bff/hosts/Hosts.Bff.Performance/Services/ApiHostedService.cs
new file mode 100644
index 000000000..877cb67cc
--- /dev/null
+++ b/bff/hosts/Hosts.Bff.Performance/Services/ApiHostedService.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Microsoft.Extensions.Options;
+
+namespace Hosts.Bff.Performance.Services;
+
+public class ApiHostedService(IOptions apiSettings) : BackgroundService
+{
+ public ApiSettings Settings { get; } = apiSettings.Value;
+
+ protected override Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.AddServiceDefaults();
+
+ // Configure Kestrel to listen on the specified Uri
+ builder.WebHost.UseUrls(Settings.ApiUrl.ToString());
+ var app = builder.Build();
+
+
+ app.UseRouting();
+
+ app.MapGet("/", () => "ok");
+ return app.RunAsync(stoppingToken);
+
+ }
+}
diff --git a/bff/hosts/Hosts.Bff.Performance/Services/ApiSettings.cs b/bff/hosts/Hosts.Bff.Performance/Services/ApiSettings.cs
new file mode 100644
index 000000000..e6f3e922e
--- /dev/null
+++ b/bff/hosts/Hosts.Bff.Performance/Services/ApiSettings.cs
@@ -0,0 +1,10 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace Hosts.Bff.Performance.Services;
+
+public class ApiSettings
+{
+ public required Uri ApiUrl { get; set; }
+ public required Uri IdentityServerUrl { get; set; }
+}
diff --git a/bff/hosts/Hosts.Bff.Performance/Services/BffService.cs b/bff/hosts/Hosts.Bff.Performance/Services/BffService.cs
new file mode 100644
index 000000000..512bc45d7
--- /dev/null
+++ b/bff/hosts/Hosts.Bff.Performance/Services/BffService.cs
@@ -0,0 +1,85 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.Bff;
+using Duende.Bff.AccessTokenManagement;
+using Duende.Bff.Yarp;
+using Microsoft.Extensions.Options;
+
+namespace Hosts.Bff.Performance.Services;
+
+public abstract class BffService(string[] urlConfigKeys, IConfiguration config, IOptions bffSettings) : BackgroundService
+{
+ public IConfiguration Config { get; } = config;
+ public BffSettings Settings { get; } = bffSettings.Value;
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var urls = urlConfigKeys
+ .Select(x => Config[x])
+ .OfType()
+ .ToArray();
+
+ var builder = WebApplication.CreateBuilder();
+ builder.AddServiceDefaults();
+ // Configure Kestrel to listen on the specified Uri
+ builder.WebHost.UseUrls(urls);
+
+ builder.Services.AddAuthorization();
+ ConfigureServices(builder.Services);
+
+ var bffBuilder = builder.Services.AddBff()
+ .AddRemoteApis();
+
+ ConfigureBff(bffBuilder);
+
+ // Build and run the web app
+ var app = builder.Build();
+
+ app.UseHttpsRedirection();
+
+ app.UseRouting();
+
+ app.UseAuthentication();
+ app.UseAuthorization();
+
+ app.UseBff();
+
+ ConfigureApp(app);
+
+ app.MapGet("/local_anon", () => DateTime.Now.ToString("s"))
+ .AsBffApiEndpoint()
+ .AllowAnonymous();
+
+ app.MapGet("/local", () => DateTime.Now.ToString("s"))
+ .RequireAuthorization()
+ .AsBffApiEndpoint();
+
+ app.MapRemoteBffApiEndpoint("/remote_anon", Settings.ApiUrl)
+ .WithAccessToken(RequiredTokenType.None);
+
+
+ app.MapRemoteBffApiEndpoint("/remote_user", Settings.ApiUrl)
+ .WithAccessToken();
+
+ app.MapRemoteBffApiEndpoint("/remote_client", Settings.ApiUrl)
+ .WithAccessToken(RequiredTokenType.Client);
+
+ // Todo: Make sure this is mapped implicitly
+ app.MapBffManagementEndpoints();
+
+ await app.RunAsync(stoppingToken);
+ }
+
+ public virtual void ConfigureServices(IServiceCollection services)
+ {
+ }
+
+ public virtual void ConfigureBff(BffBuilder bff)
+ {
+ }
+
+ public virtual void ConfigureApp(WebApplication app)
+ {
+ }
+}
diff --git a/bff/hosts/Hosts.Bff.Performance/Services/BffServiceSettings.cs b/bff/hosts/Hosts.Bff.Performance/Services/BffServiceSettings.cs
new file mode 100644
index 000000000..e58f8fdc6
--- /dev/null
+++ b/bff/hosts/Hosts.Bff.Performance/Services/BffServiceSettings.cs
@@ -0,0 +1,11 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace Hosts.Bff.Performance.Services;
+
+public class BffServiceSettings
+{
+ public required string Uri { get; set; }
+
+ public Uri ApiUrl { get; } = new Uri("https://localhost:5999");
+}
diff --git a/bff/hosts/Hosts.Bff.Performance/Services/BffSettings.cs b/bff/hosts/Hosts.Bff.Performance/Services/BffSettings.cs
new file mode 100644
index 000000000..716e5a9b7
--- /dev/null
+++ b/bff/hosts/Hosts.Bff.Performance/Services/BffSettings.cs
@@ -0,0 +1,10 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace Hosts.Bff.Performance.Services;
+
+public class BffSettings
+{
+ public required Uri IdentityServerUrl { get; set; }
+ public required Uri ApiUrl { get; set; }
+}
diff --git a/bff/hosts/Hosts.Bff.Performance/Services/IdentityServerService.cs b/bff/hosts/Hosts.Bff.Performance/Services/IdentityServerService.cs
new file mode 100644
index 000000000..67643f7fa
--- /dev/null
+++ b/bff/hosts/Hosts.Bff.Performance/Services/IdentityServerService.cs
@@ -0,0 +1,123 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Security.Claims;
+using Duende.IdentityModel;
+using Duende.IdentityServer.Models;
+using Duende.IdentityServer.Services;
+using Duende.IdentityServer.Test;
+using IdentityServer;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.Extensions.Options;
+
+namespace Hosts.Bff.Performance.Services;
+
+public class IdentityServerService(IOptions settings, IConfiguration config) : BackgroundService
+{
+ public IdentityServerSettings Settings { get; } = settings.Value;
+
+ protected override Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.AddServiceDefaults();
+ // Configure Kestrel to listen on the specified Uri
+ builder.WebHost.UseUrls(Settings.IdentityServerUrl);
+
+ builder.Services.AddAuthorization();
+ builder.Services.AddHttpLogging();
+
+ var isBuilder = builder.Services.AddIdentityServer(options =>
+ {
+ options.Events.RaiseErrorEvents = true;
+ options.Events.RaiseInformationEvents = true;
+ options.Events.RaiseFailureEvents = true;
+ options.Events.RaiseSuccessEvents = true;
+
+ options.EmitStaticAudienceClaim = true;
+ })
+ .AddTestUsers([new TestUser()
+ {
+ SubjectId = "bob",
+ Username = "bob",
+ Password = "bob",
+ Claims = [
+ new Claim(JwtClaimTypes.Name, "Bob Smith"),
+ new Claim(JwtClaimTypes.GivenName, "Bob"),
+ new Claim(JwtClaimTypes.FamilyName, "Smith"),
+ new Claim(JwtClaimTypes.Email, "bob@duende.com")
+ ],
+ IsActive = true
+
+ }])
+ ;
+
+ // in-memory, code config
+ isBuilder.AddInMemoryIdentityResources(Config.IdentityResources);
+ isBuilder.AddInMemoryApiScopes(Config.ApiScopes);
+
+ var bffUrls = config.AsEnumerable()
+ .Where(x => x.Key.StartsWith("BffUrl"))
+ .Select(x => x.Value)
+ .OfType();
+
+ isBuilder.AddInMemoryClients([new Client
+ {
+ ClientId = "bff.perf",
+ ClientSecrets = [new Secret("secret".Sha256())],
+ RedirectUris = bffUrls.Select(x => x.TrimEnd('/') + "/signin-oidc").ToArray(),
+ AllowOfflineAccess = true,
+ AllowedScopes = { "openid", "profile", "api" },
+ AllowedGrantTypes =
+ {
+ GrantType.AuthorizationCode,
+ GrantType.ClientCredentials,
+ OidcConstants.GrantTypes.TokenExchange
+ },
+
+ RefreshTokenExpiration = TokenExpiration.Absolute,
+ AbsoluteRefreshTokenLifetime = 60,
+ AccessTokenLifetime = 15 // Force refresh
+
+ }]);
+ isBuilder.AddInMemoryApiResources(Config.ApiResources);
+
+ var app = builder.Build();
+
+ app.UseHttpLogging();
+ app.UseDeveloperExceptionPage();
+ app.UseStaticFiles();
+
+ app.UseRouting();
+ app.UseIdentityServer();
+ app.UseAuthorization();
+
+ app.MapGet("/", () => "identity server");
+
+ app.MapGet("/account/login", async ctx =>
+ {
+ var props = new AuthenticationProperties();
+ await ctx.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity(
+ [
+ new Claim(JwtClaimTypes.Subject, "bob"),
+ new Claim(JwtClaimTypes.Name, "bob")
+ ],
+ "login", "name", "role")), props);
+ });
+
+ app.MapGet("/account/logout", async ctx =>
+ {
+ // signout as if the user were prompted
+ await ctx.SignOutAsync();
+
+ var logoutId = ctx.Request.Query["logoutId"];
+ var interaction = ctx.RequestServices.GetRequiredService();
+
+ var signOutContext = await interaction.GetLogoutContextAsync(logoutId);
+
+ ctx.Response.Redirect(signOutContext.PostLogoutRedirectUri ?? "/");
+ });
+
+ return app.RunAsync(stoppingToken);
+ }
+
+}
diff --git a/bff/hosts/Hosts.Bff.Performance/Services/IdentityServerSettings.cs b/bff/hosts/Hosts.Bff.Performance/Services/IdentityServerSettings.cs
new file mode 100644
index 000000000..ba8689db1
--- /dev/null
+++ b/bff/hosts/Hosts.Bff.Performance/Services/IdentityServerSettings.cs
@@ -0,0 +1,9 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace Hosts.Bff.Performance.Services;
+
+public class IdentityServerSettings
+{
+ public required string IdentityServerUrl { get; set; }
+}
diff --git a/bff/hosts/Hosts.Bff.Performance/Services/MultiFrontendBffService.cs b/bff/hosts/Hosts.Bff.Performance/Services/MultiFrontendBffService.cs
new file mode 100644
index 000000000..b600a5a0e
--- /dev/null
+++ b/bff/hosts/Hosts.Bff.Performance/Services/MultiFrontendBffService.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.Bff;
+using Duende.Bff.DynamicFrontends;
+using Microsoft.Extensions.Options;
+
+namespace Hosts.Bff.Performance.Services;
+
+public class MultiFrontendBffService(IConfiguration config, IOptions settings) : BffService(["BffUrl2", "BffUrl3"], config, settings)
+{
+ public override void ConfigureServices(IServiceCollection services)
+ {
+ }
+
+ public override void ConfigureBff(BffBuilder bff) => bff.WithDefaultOpenIdConnectOptions(o => DefaultOpenIdConfiguration.Apply(o, Settings))
+ .AddFrontends(new BffFrontend(BffFrontendName.Parse("default")))
+
+ // Note, in order for this to work, we'll need to inject this as config
+ .AddFrontends(new BffFrontend(BffFrontendName.Parse("app1")).MappedToOrigin(Config.GetValue("BffUrl3") ?? throw new InvalidOperationException("BFFUrl3 is null")));
+
+ public override void ConfigureApp(WebApplication app) => app.MapGet("/", (SelectedFrontend selectedFrontend) => "multi - " + selectedFrontend.Get().Name);
+}
+
diff --git a/bff/hosts/Hosts.Bff.Performance/Services/SingleFrontendBffService.cs b/bff/hosts/Hosts.Bff.Performance/Services/SingleFrontendBffService.cs
new file mode 100644
index 000000000..a7c43f768
--- /dev/null
+++ b/bff/hosts/Hosts.Bff.Performance/Services/SingleFrontendBffService.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.Bff;
+using Duende.Bff.DynamicFrontends;
+using Microsoft.Extensions.Options;
+
+namespace Hosts.Bff.Performance.Services;
+
+public class SingleFrontendBffService(IConfiguration config, IOptions settings) : BffService(["BffUrl1"], config, settings)
+{
+ public override void ConfigureServices(IServiceCollection services)
+ {
+ }
+
+ public override void ConfigureBff(BffBuilder bff) => bff.WithDefaultOpenIdConnectOptions(o => DefaultOpenIdConfiguration.Apply(o, Settings))
+ .AddFrontends(new BffFrontend(BffFrontendName.Parse("default")));
+
+ public override void ConfigureApp(WebApplication app) => app.MapGet("/", () => "single");
+}
diff --git a/bff/hosts/Hosts.Bff.Performance/appsettings.Development.json b/bff/hosts/Hosts.Bff.Performance/appsettings.Development.json
new file mode 100644
index 000000000..0c208ae91
--- /dev/null
+++ b/bff/hosts/Hosts.Bff.Performance/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/bff/hosts/Hosts.Bff.Performance/appsettings.json b/bff/hosts/Hosts.Bff.Performance/appsettings.json
new file mode 100644
index 000000000..10f68b8c8
--- /dev/null
+++ b/bff/hosts/Hosts.Bff.Performance/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/bff/hosts/Hosts.IdentityServer/Config.cs b/bff/hosts/Hosts.IdentityServer/Config.cs
index 248f5574e..feeb66966 100644
--- a/bff/hosts/Hosts.IdentityServer/Config.cs
+++ b/bff/hosts/Hosts.IdentityServer/Config.cs
@@ -36,203 +36,86 @@ public static class Config
private static Uri bffDPopUrl = ServiceDiscovery.ResolveService(AppHostServices.BffDpop);
private static Uri bffEfUrl = ServiceDiscovery.ResolveService(AppHostServices.BffEf);
private static Uri bffBlazorPerComponentUrl = ServiceDiscovery.ResolveService(AppHostServices.BffBlazorPerComponent);
- private static Uri bffBlazorWebAssemblyUrl = ServiceDiscovery.ResolveService(AppHostServices.BffBlazorWebassembly);
-
- public static IEnumerable Clients =>
+ private static Uri bffBlazorWebAssemblyUrl = ServiceDiscovery.ResolveService(AppHostServices.BffBlazorWebassembly); public static IEnumerable Clients =>
[
- new Client
- {
- ClientId = "bff",
- ClientSecrets = { new Secret("secret".Sha256()) },
+ BuildClient("bff.perf",
+ ServiceDiscovery.ResolveService(AppHostServices.BffPerf, "single"),
+ ServiceDiscovery.ResolveService(AppHostServices.BffPerf, "multi"),
+ new Uri("https://app1.localhost:6002")
+ ),
- AllowedGrantTypes =
- {
- GrantType.AuthorizationCode,
- GrantType.ClientCredentials,
- OidcConstants.GrantTypes.TokenExchange
- },
+ BuildClient("bff", client =>
+ {
+ client.AllowedScopes.Add("scope-for-isolated-api");
+ }, bffUrl),
- RedirectUris = { $"{bffUrl}signin-oidc" },
- FrontChannelLogoutUri = $"{bffUrl}signout-oidc",
- PostLogoutRedirectUris = { $"{bffUrl}signout-callback-oidc" },
+ BuildClient("bff.multi-frontend.default", client =>
+ {
+ client.AllowedScopes.Add("scope-for-isolated-api");
+ }, bffMultiFrontendUrl),
- AllowOfflineAccess = true,
- AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" },
+ BuildClient("bff.multi-frontend.config", client =>
+ {
+ client.AllowedScopes.Add("scope-for-isolated-api");
+ }, new Uri(bffMultiFrontendUrl, "from-config")),
- RefreshTokenExpiration = TokenExpiration.Absolute,
- AbsoluteRefreshTokenLifetime = 60,
- AccessTokenLifetime = 15 // Force refresh
- },
- new Client
- {
- ClientId = "bff.multi-frontend.default",
- ClientSecrets = { new Secret("secret".Sha256()) },
+ BuildClient("bff.multi-frontend.with-path", client =>
+ {
+ client.AllowedScopes.Add("scope-for-isolated-api");
+ }, new Uri(bffMultiFrontendUrl, "with-path")),
- AllowedGrantTypes =
- {
- GrantType.AuthorizationCode,
- GrantType.ClientCredentials,
- OidcConstants.GrantTypes.TokenExchange
- },
- RedirectUris = { $"{bffMultiFrontendUrl}signin-oidc" },
- FrontChannelLogoutUri = $"{bffMultiFrontendUrl}signout-oidc",
- PostLogoutRedirectUris = { $"{bffMultiFrontendUrl}signout-callback-oidc" },
+ BuildClient("bff.multi-frontend.with-domain", client =>
+ {
+ client.AllowedScopes.Add("scope-for-isolated-api");
+ }, new Uri("https://app1.localhost:5005")),
- AllowOfflineAccess = true,
- AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" },
+ BuildClient("bff.dpop", client =>
+ {
+ client.RequireDPoP = true;
+ client.AllowedScopes.Add("scope-for-isolated-api");
+ }, bffDPopUrl),
- RefreshTokenExpiration = TokenExpiration.Absolute,
- AbsoluteRefreshTokenLifetime = 60,
- AccessTokenLifetime = 15 // Force refresh
- },
- new Client
- {
- ClientId = "bff.multi-frontend.config",
- ClientSecrets = { new Secret("secret".Sha256()) },
+ BuildClient("bff.ef", client =>
+ {
+ client.BackChannelLogoutUri = $"{bffEfUrl}bff/backchannel";
+ client.AllowedScopes.Add("scope-for-isolated-api");
+ }, bffEfUrl),
- AllowedGrantTypes =
- {
- GrantType.AuthorizationCode,
- GrantType.ClientCredentials,
- OidcConstants.GrantTypes.TokenExchange
- },
- RedirectUris = { $"{bffMultiFrontendUrl}from-config/signin-oidc" },
- FrontChannelLogoutUri = $"{bffMultiFrontendUrl}from-config/signout-oidc",
- PostLogoutRedirectUris = { $"{bffMultiFrontendUrl}from-config/signout-callback-oidc" },
+ BuildClient("blazor", client =>
+ {
+ client.AllowedScopes.Add("scope-for-isolated-api");
+ }, bffBlazorWebAssemblyUrl, bffBlazorPerComponentUrl, new Uri("https://localhost:7035"))
+ ];
- AllowOfflineAccess = true,
- AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" },
- RefreshTokenExpiration = TokenExpiration.Absolute,
- AbsoluteRefreshTokenLifetime = 60,
- AccessTokenLifetime = 15 // Force refresh
- },
- new Client
- {
- ClientId = "bff.multi-frontend.with-path",
- ClientSecrets = { new Secret("secret".Sha256()) },
+ private static Client BuildClient(string clientId, Action postConfigure, params Uri[] uris)
+ {
+ var client = BuildClient(clientId, uris);
+ postConfigure(client);
+ return client;
+ }
- AllowedGrantTypes =
- {
- GrantType.AuthorizationCode,
- GrantType.ClientCredentials,
- OidcConstants.GrantTypes.TokenExchange
- },
- RedirectUris = { $"{bffMultiFrontendUrl}with-path/signin-oidc" },
- FrontChannelLogoutUri = $"{bffMultiFrontendUrl}signout-oidc",
- PostLogoutRedirectUris = { $"{bffMultiFrontendUrl}signout-callback-oidc" },
+ private static Client BuildClient(string clientId, params Uri[] uris) => new Client
+ {
+ ClientId = clientId,
+ ClientSecrets = { new Secret("secret".Sha256()) },
- AllowOfflineAccess = true,
- AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" },
+ AllowedGrantTypes =
+ {
+ GrantType.AuthorizationCode,
+ GrantType.ClientCredentials,
+ OidcConstants.GrantTypes.TokenExchange
+ },
+ RedirectUris = uris.Select(u => new Uri(u, "signin-oidc").ToString()).ToList(),
+ FrontChannelLogoutUri = new Uri(uris.First(), "signout-oidc").ToString(),
+ PostLogoutRedirectUris = uris.Select(u => new Uri(u, "signout-callback-oidc").ToString()).ToList(),
- RefreshTokenExpiration = TokenExpiration.Absolute,
- AbsoluteRefreshTokenLifetime = 60,
- AccessTokenLifetime = 15 // Force refresh
- },
- new Client
- {
- ClientId = "bff.multi-frontend.with-domain",
- ClientSecrets = { new Secret("secret".Sha256()) },
-
- AllowedGrantTypes =
- {
- GrantType.AuthorizationCode,
- GrantType.ClientCredentials,
- OidcConstants.GrantTypes.TokenExchange
- },
- RedirectUris = { $"https://app1.localhost:5005/signin-oidc" },
- FrontChannelLogoutUri = $"https://app1.localhost:5005/signout-oidc",
- PostLogoutRedirectUris = { $"https://app1.localhost:5005/signout-callback-oidc" },
-
- AllowOfflineAccess = true,
- AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" },
-
- RefreshTokenExpiration = TokenExpiration.Absolute,
- AbsoluteRefreshTokenLifetime = 60,
- AccessTokenLifetime = 15 // Force refresh
- },
- new Client
- {
- ClientId = "bff.dpop",
- ClientSecrets = { new Secret("secret".Sha256()) },
- RequireDPoP = true,
-
- AllowedGrantTypes =
- {
- GrantType.AuthorizationCode,
- GrantType.ClientCredentials,
- OidcConstants.GrantTypes.TokenExchange
- },
-
- RedirectUris = { $"{bffDPopUrl}signin-oidc" },
- FrontChannelLogoutUri = $"{bffDPopUrl}signout-oidc",
- PostLogoutRedirectUris = { $"{bffDPopUrl}signout-callback-oidc" },
-
- AllowOfflineAccess = true,
- AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" },
-
- // Intentionally set lifetime short to see what happens when access and refresh tokens expire
- AccessTokenLifetime = 15,
- RefreshTokenExpiration = TokenExpiration.Absolute,
- AbsoluteRefreshTokenLifetime = 60
- },
- new Client
- {
- ClientId = "bff.ef",
- ClientSecrets = { new Secret("secret".Sha256()) },
-
- AllowedGrantTypes =
- {
- GrantType.AuthorizationCode,
- GrantType.ClientCredentials,
- OidcConstants.GrantTypes.TokenExchange
- },
- RedirectUris = { $"{bffEfUrl}signin-oidc" },
- FrontChannelLogoutUri = $"{bffEfUrl}signout-oidc",
- BackChannelLogoutUri = $"{bffEfUrl}bff/backchannel",
- PostLogoutRedirectUris = { $"{bffEfUrl}signout-callback-oidc" },
-
- AllowOfflineAccess = true,
- AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" },
-
- // Intentionally set lifetime short to see what happens when access and refresh tokens expire
- AccessTokenLifetime = 15,
- RefreshTokenExpiration = TokenExpiration.Absolute,
- AbsoluteRefreshTokenLifetime = 60
- },
-
- new Client
- {
- ClientId = "blazor",
- ClientSecrets = { new Secret("secret".Sha256()) },
-
- AllowedGrantTypes =
- {
- GrantType.AuthorizationCode,
- GrantType.ClientCredentials,
- OidcConstants.GrantTypes.TokenExchange
- },
-
- RedirectUris =
- {
- $"{bffBlazorWebAssemblyUrl}signin-oidc",
- $"{bffBlazorPerComponentUrl}signin-oidc",
- "https://localhost:7035/signin-oidc"
- },
- PostLogoutRedirectUris =
- {
- $"{bffBlazorWebAssemblyUrl}signout-callback-oidc", $"{bffBlazorPerComponentUrl}signout-callback-oidc"
- },
-
- AllowOfflineAccess = true,
- AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" },
-
- // Intentionally set lifetime short to see what happens when access and refresh tokens expire
- AccessTokenLifetime = 15,
- RefreshTokenExpiration = TokenExpiration.Absolute,
- AbsoluteRefreshTokenLifetime = 60
- }
- ];
+ AllowOfflineAccess = true,
+ AllowedScopes = { "openid", "profile", "api" },
+ RefreshTokenExpiration = TokenExpiration.Absolute,
+ AbsoluteRefreshTokenLifetime = 60,
+ AccessTokenLifetime = 15 // Force refresh
+ };
}
diff --git a/bff/hosts/Hosts.ServiceDefaults/AppHostServices.cs b/bff/hosts/Hosts.ServiceDefaults/AppHostServices.cs
index beaebf93d..f01ac7edc 100644
--- a/bff/hosts/Hosts.ServiceDefaults/AppHostServices.cs
+++ b/bff/hosts/Hosts.ServiceDefaults/AppHostServices.cs
@@ -4,6 +4,7 @@
namespace Hosts.ServiceDefaults;
public static class AppHostServices
{
+ public const string BffPerf = "bff-perf";
public const string IdentityServer = "identity-server";
public const string Api = "api";
public const string IsolatedApi = "api-isolated";
@@ -24,6 +25,7 @@ public static class AppHostServices
Api,
IsolatedApi,
Bff,
+ BffPerf,
BffEf,
BffBlazorWebassembly,
BffBlazorPerComponent,
@@ -39,13 +41,12 @@ public static class AppHostServices
public static class ServiceDiscovery
{
- public static Uri ResolveService(string serviceName)
+ public static Uri ResolveService(string serviceName, string appName = "https")
{
- var scheme = "https";
var host = serviceName;
// Compose the environment variable key
- var envVarKey = $"services__{host}__{scheme}__0";
+ var envVarKey = $"services__{host}__{appName}__0";
// Try to get the value from environment variables
var value = Environment.GetEnvironmentVariable(envVarKey);
diff --git a/bff/performance/Bff.Performance/Bff.Performance.csproj b/bff/performance/Bff.Performance/Bff.Performance.csproj
new file mode 100644
index 000000000..ec46548a3
--- /dev/null
+++ b/bff/performance/Bff.Performance/Bff.Performance.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bff/performance/Bff.Performance/Program.cs b/bff/performance/Bff.Performance/Program.cs
new file mode 100644
index 000000000..9cad1295c
--- /dev/null
+++ b/bff/performance/Bff.Performance/Program.cs
@@ -0,0 +1,53 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+// full login / logout flow
+
+// Api calls:
+
+// plain yarp call
+// Local api
+// multi-frontend index.html response
+// remote api user token
+// - yarp
+// - plain
+// - single multi-frontend
+// - 100 multi-frontends
+// - server side sessions
+// - large response payload
+
+// remote api client credentials token
+// - yarp
+// - plain
+// - single multi-frontend
+// - 100 multi-frontends
+// - large response payload
+
+// with distributed cache + short lifecycle
+
+using Bff.Performance.Scenarios.Bff;
+using Microsoft.Extensions.Configuration;
+using NBomber.Contracts.Stats;
+using NBomber.CSharp;
+using NBomber.Http;
+
+var config = new ConfigurationBuilder()
+ .AddEnvironmentVariables()
+ .AddCommandLine(args)
+ .AddJsonFile("appsettings.json", optional: true)
+ .Build();
+
+var urls = config.GetSection("BffUrls").Get();
+
+if (urls == null || urls.Length == 0)
+{
+ throw new InvalidOperationException("BffUrls configuration is missing or empty.");
+}
+NBomberRunner
+ .RegisterScenarios(new BffScenarios(urls).Scenarios)
+ .WithWorkerPlugins(new HttpMetricsPlugin())
+ .WithReportingInterval(TimeSpan.FromSeconds(5))
+ .WithReportFormats(
+ ReportFormat.Csv, ReportFormat.Html
+ )
+ .Run(args);
diff --git a/bff/performance/Bff.Performance/Properties/launchSettings.json b/bff/performance/Bff.Performance/Properties/launchSettings.json
new file mode 100644
index 000000000..35a4b8e5e
--- /dev/null
+++ b/bff/performance/Bff.Performance/Properties/launchSettings.json
@@ -0,0 +1,11 @@
+{
+ "profiles": {
+ "Bff.Performance": {
+ "commandName": "Project",
+ "environmentVariables": {
+ "BffUrls__0": "https://localhost:6002",
+ "BffUrls__1": "https://localhost:6003"
+ }
+ }
+ }
+}
diff --git a/bff/performance/Bff.Performance/Scenarios/BaseScenario.cs b/bff/performance/Bff.Performance/Scenarios/BaseScenario.cs
new file mode 100644
index 000000000..3ef589e16
--- /dev/null
+++ b/bff/performance/Bff.Performance/Scenarios/BaseScenario.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Net;
+using NBomber.Contracts;
+using NBomber.CSharp;
+
+
+
+namespace Bff.Performance.Scenarios;
+
+public abstract class BaseScenario(string name)
+{
+ public virtual Task Init(IScenarioInitContext c) => Task.CompletedTask;
+ public string Name => GetType().Name + "_" + name;
+
+ public HttpStatusCode SuccessStatusCode { get; set; } = HttpStatusCode.OK;
+
+ public async Task Run(IScenarioContext context)
+ {
+ var result = await RunScenario(context);
+
+ if (result.StatusCode == SuccessStatusCode)
+ {
+ return Response.Ok(result.StatusCode, result.StatusCode.ToString(), result.Content.Headers.ContentLength ?? 0);
+ }
+ return Response.Fail(result.StatusCode, result.StatusCode.ToString(), "Returned an unexpected httpresult", result.Content.Headers.ContentLength ?? 0);
+ }
+
+ public abstract Task RunScenario(IScenarioContext context);
+
+ public TestClient Client { get; set; } = null!;
+
+ public static implicit operator ScenarioProps(BaseScenario scenario) => Scenario.Create(scenario.Name, scenario.Run)
+ .WithoutWarmUp()
+ .WithLoadSimulations(
+ Simulation.Inject(rate: 10,
+ interval: TimeSpan.FromSeconds(1),
+ during: TimeSpan.FromSeconds(30))
+ )
+ .WithInit(scenario.Init);
+}
+
+
+
diff --git a/bff/performance/Bff.Performance/Scenarios/Bff/BffScenarios.cs b/bff/performance/Bff.Performance/Scenarios/Bff/BffScenarios.cs
new file mode 100644
index 000000000..a92ae908c
--- /dev/null
+++ b/bff/performance/Bff.Performance/Scenarios/Bff/BffScenarios.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using NBomber.Contracts;
+
+namespace Bff.Performance.Scenarios.Bff;
+
+public class BffScenarios
+{
+ public ScenarioProps[] Scenarios;
+
+ public BffScenarios(Uri[] baseUris) => Scenarios = baseUris.SelectMany(x => new ScenarioProps[]
+ {
+ new CallAnonymousLocalApi(x),
+ new CallAuthorizedLocalApi(x)
+ })
+ .ToArray();
+}
diff --git a/bff/performance/Bff.Performance/Scenarios/Bff/CallAnonymousLocalApi.cs b/bff/performance/Bff.Performance/Scenarios/Bff/CallAnonymousLocalApi.cs
new file mode 100644
index 000000000..521433594
--- /dev/null
+++ b/bff/performance/Bff.Performance/Scenarios/Bff/CallAnonymousLocalApi.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using NBomber.Contracts;
+
+namespace Bff.Performance.Scenarios.Bff;
+
+public class CallAnonymousLocalApi(Uri baseUri) : BaseScenario(baseUri.ToString())
+{
+ public override Task Init(IScenarioInitContext c)
+ {
+ Client = TestClient.Create(baseUri);
+ return Task.CompletedTask;
+ }
+
+ public override async Task RunScenario(IScenarioContext context) => await Client.GetAsync("/local_anon");
+}
+
+public class CallAuthorizedLocalApi(Uri baseUri) : BaseScenario(baseUri.ToString())
+{
+ public override async Task Init(IScenarioInitContext c)
+ {
+ Client = TestClient.Create(baseUri);
+ await Client.TriggerLogin();
+ }
+
+ public override async Task RunScenario(IScenarioContext context)
+ {
+ var result = await Client.GetAsync("/local");
+
+ return result;
+ }
+}
diff --git a/bff/performance/Bff.Performance/TestClient.cs b/bff/performance/Bff.Performance/TestClient.cs
new file mode 100644
index 000000000..227f989ff
--- /dev/null
+++ b/bff/performance/Bff.Performance/TestClient.cs
@@ -0,0 +1,124 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Bff.Performance.TestInfra;
+
+namespace Bff.Performance;
+
+public class TestClient(Uri baseAddress, CookieHandler cookies, HttpMessageHandler handler)
+{
+
+ public HttpClient Client = new(handler)
+ {
+ BaseAddress = baseAddress
+ };
+ public void ClearCookies() => cookies.ClearCookies();
+
+ public static TestClient Create(Uri baseAddress, CookieContainer? cookies = null)
+ {
+ var inner = new SocketsHttpHandler
+ {
+ // We need to disable cookies and follow redirects
+ // because we do this manually (see below).
+ UseCookies = false,
+ AllowAutoRedirect = false
+ };
+
+ var cookieHandler = new CookieHandler(inner, cookies);
+ var handler = new AutoFollowRedirectHandler((_) => { })
+ {
+ InnerHandler = cookieHandler
+ };
+
+ return new TestClient(baseAddress, cookieHandler, handler);
+ }
+
+ public Task GetAsync(string path, Dictionary? headers = null, CancellationToken ct = default)
+ {
+ var request = BuildRequest(HttpMethod.Get, path, headers);
+
+ return Client.SendAsync(request);
+ }
+
+ public Task PostAsync(string path, object? body, Dictionary? headers = null, CancellationToken ct = default)
+ {
+ var request = BuildRequest(HttpMethod.Post, path, headers);
+ request.Content = JsonContent.Create(body);
+
+ return Client.SendAsync(request);
+ }
+
+ public Task PostAsync(Uri path, T body, Dictionary? headers = null, CancellationToken ct = default)
+ where T : HttpContent
+ {
+ var request = BuildRequest(HttpMethod.Post, path.ToString(), headers);
+ request.Content = body;
+
+ return Client.SendAsync(request);
+ }
+
+ private static HttpRequestMessage BuildRequest(HttpMethod httpMethod, string path,
+ Dictionary? headers = null)
+ {
+ var request = new HttpRequestMessage(httpMethod, path);
+
+ if (headers == null)
+ {
+ request.Headers.Add("x-csrf", "1");
+ }
+ else
+ {
+ foreach (var header in headers)
+ {
+ request.Headers.Add(header.Key, header.Value);
+ }
+ }
+
+ return request;
+ }
+
+
+ public async Task TriggerLogin(string userName = "alice", string password = "alice", CancellationToken ct = default) => await GetAsync("/bff/login");
+
+ 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");
+
+ return await GetAsync(logoutLink.Value.ToString()!);
+ }
+
+ public async Task GetUserClaims()
+ {
+ var userClaimsString = await GetStringAsync("/bff/user");
+ var userClaims = JsonSerializer.Deserialize(userClaimsString, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ })!;
+ return userClaims;
+ }
+
+ private async Task GetStringAsync(string path)
+ {
+ var response = await GetAsync(path);
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new InvalidOperationException($"Failed to get string from {path}. Status code: {response.StatusCode}");
+ }
+ return await response.Content.ReadAsStringAsync();
+ }
+
+ public async Task InvokeApi(string url) => await GetAsync(url);
+
+ public record UserClaim
+ {
+ public required string Type { get; init; }
+ public required object Value { get; init; }
+ }
+}
diff --git a/bff/performance/Bff.Performance/TestInfra/AutoFollowRedirectHandler.cs b/bff/performance/Bff.Performance/TestInfra/AutoFollowRedirectHandler.cs
new file mode 100644
index 000000000..f46f50c42
--- /dev/null
+++ b/bff/performance/Bff.Performance/TestInfra/AutoFollowRedirectHandler.cs
@@ -0,0 +1,43 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Net;
+
+namespace Bff.Performance.TestInfra;
+
+public class AutoFollowRedirectHandler(Action writeOutput) : DelegatingHandler
+{
+ protected override async Task SendAsync(HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ var previousUri = request.RequestUri;
+ for (var i = 0; i < 20; i++)
+ {
+ var result = await base.SendAsync(request, cancellationToken);
+ if ((result.StatusCode == HttpStatusCode.Found || result.StatusCode == HttpStatusCode.RedirectKeepVerb) && result.Headers.Location != null)
+ {
+ writeOutput($"Redirecting from {previousUri} to {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");
+ }
+}
diff --git a/bff/performance/Bff.Performance/TestInfra/CloningHttpMessageHandler.cs b/bff/performance/Bff.Performance/TestInfra/CloningHttpMessageHandler.cs
new file mode 100644
index 000000000..0d808f3e4
--- /dev/null
+++ b/bff/performance/Bff.Performance/TestInfra/CloningHttpMessageHandler.cs
@@ -0,0 +1,81 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace Bff.Performance.TestInfra;
+
+public class CloningHttpMessageHandler(HttpClient innerHttpClient) : HttpMessageHandler
+{
+ private readonly HttpClient _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
+ };
+
+ 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;
+ }
+}
diff --git a/bff/performance/Bff.Performance/TestInfra/CookieHandler.cs b/bff/performance/Bff.Performance/TestInfra/CookieHandler.cs
new file mode 100644
index 000000000..ef4912be5
--- /dev/null
+++ b/bff/performance/Bff.Performance/TestInfra/CookieHandler.cs
@@ -0,0 +1,43 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Net;
+using Microsoft.Net.Http.Headers;
+
+namespace Bff.Performance.TestInfra;
+
+public class CookieHandler(HttpMessageHandler innerHandler, CookieContainer? cookies = null) : DelegatingHandler(innerHandler)
+{
+ public void ClearCookies() => CookieContainer = new CookieContainer();
+ public CookieContainer CookieContainer { get; private set; } = cookies ?? new 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 var setCookieHeaders))
+ {
+ foreach (var cookieHeader in SetCookieHeaderValue.ParseList(setCookieHeaders.ToList()))
+ {
+ var 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;
+ }
+}
diff --git a/bff/performance/Bff.Performance/TestInfra/RequestLoggingHandler.cs b/bff/performance/Bff.Performance/TestInfra/RequestLoggingHandler.cs
new file mode 100644
index 000000000..3933808fa
--- /dev/null
+++ b/bff/performance/Bff.Performance/TestInfra/RequestLoggingHandler.cs
@@ -0,0 +1,54 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
+
+namespace Bff.Performance.TestInfra;
+
+public class RequestLoggingHandler(
+ ILogger log,
+ Func shouldLog)
+ : DelegatingHandler
+{
+ protected override async Task SendAsync(HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ if (!shouldLog(request))
+ {
+ return await base.SendAsync(request, cancellationToken);
+ }
+
+ var stopwatch = Stopwatch.StartNew();
+ try
+ {
+ var result = await base.SendAsync(request, cancellationToken);
+
+ log.LogInformation("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;
+ }
+ }
+}
diff --git a/products.slnx b/products.slnx
index 09749113b..0dd3577a2 100644
--- a/products.slnx
+++ b/products.slnx
@@ -17,6 +17,7 @@
+
@@ -26,6 +27,7 @@
+