diff --git a/Directory.Build.targets b/Directory.Build.targets
index f763a4d8..44a19bb0 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -3,13 +3,13 @@
6.0.0
6.0.11
- 1.1.0
+ 2.0.0
7.0.0
7.0.0
- 1.1.0
+ 2.0.0
diff --git a/src/Duende.Bff.Yarp/AccessTokenTransformProvider.cs b/src/Duende.Bff.Yarp/AccessTokenTransformProvider.cs
index b4a5a176..c5ba4c97 100644
--- a/src/Duende.Bff.Yarp/AccessTokenTransformProvider.cs
+++ b/src/Duende.Bff.Yarp/AccessTokenTransformProvider.cs
@@ -83,6 +83,9 @@ public void Apply(TransformBuilderContext transformBuildContext)
}
else
{
+ // short circuit forwarder and return 401
+ transformContext.HttpContext.Response.StatusCode = 401;
+
_logger.AccessTokenMissing(transformBuildContext?.Route?.RouteId ?? "unknown route", tokenType);
}
});
diff --git a/src/Duende.Bff.Yarp/InMemoryConfigProvider.cs b/src/Duende.Bff.Yarp/InMemoryConfigProvider.cs
deleted file mode 100644
index 6f9b9027..00000000
--- a/src/Duende.Bff.Yarp/InMemoryConfigProvider.cs
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-using System.Collections.Generic;
-using System.Threading;
-using Microsoft.Extensions.Primitives;
-using Yarp.ReverseProxy.Configuration;
-
-namespace Microsoft.Extensions.DependencyInjection;
-
-///
-/// Extends the IReverseProxyBuilder to support the InMemoryConfigProvider
-///
-public static class InMemoryConfigProviderExtensions
-{
- ///
- /// Loads YARP configuration from in-memory
- ///
- ///
- ///
- ///
- ///
- public static IReverseProxyBuilder LoadFromMemory(this IReverseProxyBuilder builder, IReadOnlyList routes, IReadOnlyList clusters)
- {
- builder.Services.AddSingleton(new InMemoryConfigProvider(routes, clusters));
- return builder;
- }
-}
-
-///
-/// Provides an implementation of IProxyConfigProvider to support config being generated by code.
-///
-public class InMemoryConfigProvider : IProxyConfigProvider
-{
- // Marked as volatile so that updates are atomic
- private volatile InMemoryConfig _config;
-
- ///
- /// ctor
- ///
- ///
- ///
- public InMemoryConfigProvider(IReadOnlyList routes, IReadOnlyList clusters)
- {
- _config = new InMemoryConfig(routes, clusters);
- }
-
- ///
- /// Implementation of the IProxyConfigProvider.GetConfig method to supply the current snapshot of configuration
- ///
- /// An immutable snapshot of the current configuration state
- public IProxyConfig GetConfig() => _config;
-
- ///
- /// Swaps the config state with a new snapshot of the configuration, then signals the change
- ///
- public void Update(IReadOnlyList routes, IReadOnlyList clusters)
- {
- var oldConfig = _config;
- _config = new InMemoryConfig(routes, clusters);
- oldConfig.SignalChange();
- }
-
- ///
- /// Implementation of IProxyConfig which is a snapshot of the current config state. The data for this class should be immutable.
- ///
- private class InMemoryConfig : IProxyConfig
- {
- // Used to implement the change token for the state
- private readonly CancellationTokenSource _cts = new();
-
- public InMemoryConfig(IReadOnlyList routes, IReadOnlyList clusters)
- {
- Routes = routes;
- Clusters = clusters;
- ChangeToken = new CancellationChangeToken(_cts.Token);
- }
-
- ///
- /// A snapshot of the list of routes for the proxy
- ///
- public IReadOnlyList Routes { get; }
-
- ///
- /// A snapshot of the list of Clusters which are collections of interchangable destination endpoints
- ///
- public IReadOnlyList Clusters { get; }
-
- ///
- /// Fired to indicate the the proxy state has changed, and that this snapshot is now stale
- ///
- public IChangeToken ChangeToken { get; }
-
- internal void SignalChange()
- {
- _cts.Cancel();
- }
- }
-}
\ No newline at end of file
diff --git a/test/Duende.Bff.Tests/Endpoints/YarpRemoteEndpointTests.cs b/test/Duende.Bff.Tests/Endpoints/YarpRemoteEndpointTests.cs
new file mode 100644
index 00000000..b54448e9
--- /dev/null
+++ b/test/Duende.Bff.Tests/Endpoints/YarpRemoteEndpointTests.cs
@@ -0,0 +1,193 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.Bff.Tests.TestHosts;
+using FluentAssertions;
+using System.Net;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Duende.Bff.Tests.TestFramework;
+using Xunit;
+
+namespace Duende.Bff.Tests.Endpoints
+{
+ public class YarpRemoteEndpointTests : YarpBffIntegrationTestBase
+ {
+ [Fact]
+ public async Task anonymous_call_with_no_csrf_header_to_no_token_requirement_no_csrf_route_should_succeed()
+ {
+ var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url("/api_anon_no_csrf/test"));
+ var response = await BffHost.BrowserClient.SendAsync(req);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+
+ [Fact]
+ public async Task anonymous_call_with_no_csrf_header_to_csrf_route_should_fail()
+ {
+ var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url("/api_anon/test"));
+ var response = await BffHost.BrowserClient.SendAsync(req);
+
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task anonymous_call_to_no_token_requirement_route_should_succeed()
+ {
+ var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url("/api_anon/test"));
+ req.Headers.Add("x-csrf", "1");
+ var response = await BffHost.BrowserClient.SendAsync(req);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+
+ [Fact]
+ public async Task anonymous_call_to_user_token_requirement_route_should_fail()
+ {
+ var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url("/api_user/test"));
+ req.Headers.Add("x-csrf", "1");
+ var response = await BffHost.BrowserClient.SendAsync(req);
+
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task authenticated_GET_should_forward_user_to_api()
+ {
+ await BffHost.BffLoginAsync("alice");
+
+ var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url("/api_user/test"));
+ req.Headers.Add("x-csrf", "1");
+ var response = await BffHost.BrowserClient.SendAsync(req);
+
+ response.IsSuccessStatusCode.Should().BeTrue();
+ response.Content.Headers.ContentType.MediaType.Should().Be("application/json");
+ var json = await response.Content.ReadAsStringAsync();
+ var apiResult = JsonSerializer.Deserialize(json);
+ apiResult.Method.Should().Be("GET");
+ apiResult.Path.Should().Be("/api_user/test");
+ apiResult.Sub.Should().Be("alice");
+ apiResult.ClientId.Should().Be("spa");
+ }
+
+ [Fact]
+ public async Task authenticated_PUT_should_forward_user_to_api()
+ {
+ await BffHost.BffLoginAsync("alice");
+
+ var req = new HttpRequestMessage(HttpMethod.Put, BffHost.Url("/api_user/test"));
+ req.Headers.Add("x-csrf", "1");
+ var response = await BffHost.BrowserClient.SendAsync(req);
+
+ response.IsSuccessStatusCode.Should().BeTrue();
+ response.Content.Headers.ContentType.MediaType.Should().Be("application/json");
+ var json = await response.Content.ReadAsStringAsync();
+ var apiResult = JsonSerializer.Deserialize(json);
+ apiResult.Method.Should().Be("PUT");
+ apiResult.Path.Should().Be("/api_user/test");
+ apiResult.Sub.Should().Be("alice");
+ apiResult.ClientId.Should().Be("spa");
+ }
+
+ [Fact]
+ public async Task authenticated_POST_should_forward_user_to_api()
+ {
+ await BffHost.BffLoginAsync("alice");
+
+ var req = new HttpRequestMessage(HttpMethod.Post, BffHost.Url("/api_user/test"));
+ req.Headers.Add("x-csrf", "1");
+ var response = await BffHost.BrowserClient.SendAsync(req);
+
+ response.IsSuccessStatusCode.Should().BeTrue();
+ response.Content.Headers.ContentType.MediaType.Should().Be("application/json");
+ var json = await response.Content.ReadAsStringAsync();
+ var apiResult = JsonSerializer.Deserialize(json);
+ apiResult.Method.Should().Be("POST");
+ apiResult.Path.Should().Be("/api_user/test");
+ apiResult.Sub.Should().Be("alice");
+ apiResult.ClientId.Should().Be("spa");
+ }
+
+ [Fact]
+ public async Task call_to_client_token_route_should_forward_client_token_to_api()
+ {
+ await BffHost.BffLoginAsync("alice");
+
+ var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url("/api_client/test"));
+ req.Headers.Add("x-csrf", "1");
+ var response = await BffHost.BrowserClient.SendAsync(req);
+
+ response.IsSuccessStatusCode.Should().BeTrue();
+ response.Content.Headers.ContentType.MediaType.Should().Be("application/json");
+ var json = await response.Content.ReadAsStringAsync();
+ var apiResult = JsonSerializer.Deserialize(json);
+ apiResult.Method.Should().Be("GET");
+ apiResult.Path.Should().Be("/api_client/test");
+ apiResult.Sub.Should().BeNull();
+ apiResult.ClientId.Should().Be("spa");
+ }
+
+ [Fact]
+ public async Task call_to_user_or_client_token_route_should_forward_user_or_client_token_to_api()
+ {
+ {
+ var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url("/api_user_or_client/test"));
+ req.Headers.Add("x-csrf", "1");
+ var response = await BffHost.BrowserClient.SendAsync(req);
+
+ response.IsSuccessStatusCode.Should().BeTrue();
+ response.Content.Headers.ContentType.MediaType.Should().Be("application/json");
+ var json = await response.Content.ReadAsStringAsync();
+ var apiResult = JsonSerializer.Deserialize(json);
+ apiResult.Method.Should().Be("GET");
+ apiResult.Path.Should().Be("/api_user_or_client/test");
+ apiResult.Sub.Should().BeNull();
+ apiResult.ClientId.Should().Be("spa");
+ }
+
+ {
+ await BffHost.BffLoginAsync("alice");
+
+ var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url("/api_user_or_client/test"));
+ req.Headers.Add("x-csrf", "1");
+ var response = await BffHost.BrowserClient.SendAsync(req);
+
+ response.IsSuccessStatusCode.Should().BeTrue();
+ response.Content.Headers.ContentType.MediaType.Should().Be("application/json");
+ var json = await response.Content.ReadAsStringAsync();
+ var apiResult = JsonSerializer.Deserialize(json);
+ apiResult.Method.Should().Be("GET");
+ apiResult.Path.Should().Be("/api_user_or_client/test");
+ apiResult.Sub.Should().Be("alice");
+ apiResult.ClientId.Should().Be("spa");
+ }
+ }
+
+ [Fact]
+ public async Task response_status_401_from_remote_endpoint_should_return_401_from_bff()
+ {
+ await BffHost.BffLoginAsync("alice");
+ ApiHost.ApiStatusCodeToReturn = 401;
+
+ var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url("/api_user/test"));
+ req.Headers.Add("x-csrf", "1");
+ var response = await BffHost.BrowserClient.SendAsync(req);
+
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task response_status_403_from_remote_endpoint_should_return_403_from_bff()
+ {
+ await BffHost.BffLoginAsync("alice");
+ ApiHost.ApiStatusCodeToReturn = 403;
+
+ var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url("/api_user/test"));
+ req.Headers.Add("x-csrf", "1");
+ var response = await BffHost.BrowserClient.SendAsync(req);
+
+ response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
+ }
+ }
+}
diff --git a/test/Duende.Bff.Tests/TestHosts/YarpBffHost.cs b/test/Duende.Bff.Tests/TestHosts/YarpBffHost.cs
new file mode 100644
index 00000000..d7e87c32
--- /dev/null
+++ b/test/Duende.Bff.Tests/TestHosts/YarpBffHost.cs
@@ -0,0 +1,345 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.Bff.Tests.TestFramework;
+using FluentAssertions;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Duende.Bff.Yarp;
+using Microsoft.AspNetCore.HttpOverrides;
+using Yarp.ReverseProxy.Configuration;
+using Yarp.ReverseProxy.Forwarder;
+
+namespace Duende.Bff.Tests.TestHosts
+{
+ public class YarpBffHost : GenericHost
+ {
+ public enum ResponseStatus
+ {
+ Ok, Challenge, Forbid
+ }
+ public ResponseStatus LocalApiResponseStatus { get; set; } = ResponseStatus.Ok;
+
+ private readonly IdentityServerHost _identityServerHost;
+ private readonly ApiHost _apiHost;
+ private readonly string _clientId;
+ private readonly bool _useForwardedHeaders;
+
+ public BffOptions BffOptions { get; private set; }
+
+ public YarpBffHost(IdentityServerHost identityServerHost, ApiHost apiHost, string clientId,
+ string baseAddress = "https://app", bool useForwardedHeaders = false)
+ : base(baseAddress)
+ {
+ _identityServerHost = identityServerHost;
+ _apiHost = apiHost;
+ _clientId = clientId;
+ _useForwardedHeaders = useForwardedHeaders;
+
+ OnConfigureServices += ConfigureServices;
+ OnConfigure += Configure;
+ }
+
+ private void ConfigureServices(IServiceCollection services)
+ {
+ services.AddRouting();
+ services.AddAuthorization();
+
+ var bff = services.AddBff(options =>
+ {
+ BffOptions = options;
+ });
+
+ services.AddSingleton(
+ new CallbackForwarderHttpClientFactory(
+ context => new HttpMessageInvoker(_apiHost.Server.CreateHandler())));
+
+ var yarpBuilder = services.AddReverseProxy()
+ .AddBffExtensions();
+
+ yarpBuilder.LoadFromMemory(
+ new[]
+ {
+ new RouteConfig()
+ {
+ RouteId = "api_anon_no_csrf",
+ ClusterId = "cluster1",
+
+ Match = new()
+ {
+ Path = "/api_anon_no_csrf/{**catch-all}"
+ }
+ },
+
+ new RouteConfig()
+ {
+ RouteId = "api_anon",
+ ClusterId = "cluster1",
+
+ Match = new()
+ {
+ Path = "/api_anon/{**catch-all}"
+ }
+ }.WithAntiforgeryCheck(),
+
+ new RouteConfig()
+ {
+ RouteId = "api_user",
+ ClusterId = "cluster1",
+
+ Match = new()
+ {
+ Path = "/api_user/{**catch-all}"
+ }
+ }.WithAntiforgeryCheck()
+ .WithAccessToken(TokenType.User),
+
+ new RouteConfig()
+ {
+ RouteId = "api_client",
+ ClusterId = "cluster1",
+
+ Match = new()
+ {
+ Path = "/api_client/{**catch-all}"
+ }
+ }.WithAntiforgeryCheck()
+ .WithAccessToken(TokenType.Client),
+
+ new RouteConfig()
+ {
+ RouteId = "api_user_or_client",
+ ClusterId = "cluster1",
+
+ Match = new()
+ {
+ Path = "/api_user_or_client/{**catch-all}"
+ }
+ }.WithAntiforgeryCheck()
+ .WithAccessToken(TokenType.UserOrClient)
+ },
+
+ new[]
+ {
+ new ClusterConfig
+ {
+ ClusterId = "cluster1",
+
+ Destinations = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ { "destination1", new() { Address = _apiHost.Url() } },
+ }
+ }
+ });
+
+ // todo: need YARP equivalent
+ // services.AddSingleton(
+ // new CallbackHttpMessageInvokerFactory(
+ // path => new HttpMessageInvoker(_apiHost.Server.CreateHandler())));
+
+ services.AddAuthentication("cookie")
+ .AddCookie("cookie", options =>
+ {
+ options.Cookie.Name = "bff";
+ });
+
+ bff.AddServerSideSessions();
+
+ services.AddAuthentication(options =>
+ {
+ options.DefaultChallengeScheme = "oidc";
+ options.DefaultSignOutScheme = "oidc";
+ })
+ .AddOpenIdConnect("oidc", options =>
+ {
+ options.Authority = _identityServerHost.Url();
+
+ options.ClientId = _clientId;
+ options.ClientSecret = "secret";
+ options.ResponseType = "code";
+ options.ResponseMode = "query";
+
+ options.MapInboundClaims = false;
+ options.GetClaimsFromUserInfoEndpoint = true;
+ options.SaveTokens = true;
+
+ options.Scope.Clear();
+ var client = _identityServerHost.Clients.Single(x => x.ClientId == _clientId);
+ foreach (var scope in client.AllowedScopes)
+ {
+ options.Scope.Add(scope);
+ }
+
+ if (client.AllowOfflineAccess)
+ {
+ options.Scope.Add("offline_access");
+ }
+
+ options.BackchannelHttpHandler = _identityServerHost.Server.CreateHandler();
+ });
+
+ services.AddAuthorization(options =>
+ {
+ options.AddPolicy("AlwaysFail", policy => { policy.RequireAssertion(ctx => false); });
+ });
+ }
+
+ private void Configure(IApplicationBuilder app)
+ {
+ if (_useForwardedHeaders)
+ {
+ app.UseForwardedHeaders(new ForwardedHeadersOptions
+ {
+ ForwardedHeaders = ForwardedHeaders.XForwardedFor |
+ ForwardedHeaders.XForwardedProto |
+ ForwardedHeaders.XForwardedHost
+ });
+ }
+
+ app.UseAuthentication();
+
+ app.UseRouting();
+
+ app.UseBff();
+ app.UseAuthorization();
+
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapBffManagementEndpoints();
+
+ endpoints.MapReverseProxy(proxyApp =>
+ {
+ proxyApp.UseAntiforgeryCheck();
+ });
+
+ // replace with YARP endpoints
+ // endpoints.MapRemoteBffApiEndpoint(
+ // "/api_user", _apiHost.Url())
+ // .RequireAccessToken();
+ //
+ // endpoints.MapRemoteBffApiEndpoint(
+ // "/api_user_no_csrf", _apiHost.Url())
+ // .SkipAntiforgery()
+ // .RequireAccessToken();
+ //
+ // endpoints.MapRemoteBffApiEndpoint(
+ // "/api_client", _apiHost.Url())
+ // .RequireAccessToken(TokenType.Client);
+ //
+ // endpoints.MapRemoteBffApiEndpoint(
+ // "/api_user_or_client", _apiHost.Url())
+ // .RequireAccessToken(TokenType.UserOrClient);
+ //
+ // endpoints.MapRemoteBffApiEndpoint(
+ // "/api_user_or_anon", _apiHost.Url())
+ // .WithOptionalUserAccessToken();
+ //
+ // endpoints.MapRemoteBffApiEndpoint(
+ // "/api_anon_only", _apiHost.Url());
+ });
+ }
+
+ public async Task GetIsUserLoggedInAsync(string userQuery = null)
+ {
+ if (userQuery != null) userQuery = "?" + userQuery;
+
+ var req = new HttpRequestMessage(HttpMethod.Get, Url("/bff/user") + userQuery);
+ req.Headers.Add("x-csrf", "1");
+ var response = await BrowserClient.SendAsync(req);
+
+ (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.Unauthorized).Should()
+ .BeTrue();
+
+ return response.StatusCode == HttpStatusCode.OK;
+ }
+
+ public async Task> CallUserEndpointAsync()
+ {
+ var req = new HttpRequestMessage(HttpMethod.Get, Url("/bff/user"));
+ req.Headers.Add("x-csrf", "1");
+
+ var response = await BrowserClient.SendAsync(req);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ response.Content.Headers.ContentType.MediaType.Should().Be("application/json");
+
+ var json = await response.Content.ReadAsStringAsync();
+ return JsonSerializer.Deserialize>(json);
+ }
+
+ public async Task BffLoginAsync(string sub, string sid = null)
+ {
+ await _identityServerHost.CreateIdentityServerSessionCookieAsync(sub, sid);
+ return await BffOidcLoginAsync();
+ }
+
+ public async Task BffOidcLoginAsync()
+ {
+ var response = await BrowserClient.GetAsync(Url("/bff/login"));
+ response.StatusCode.Should().Be(HttpStatusCode.Redirect); // authorize
+ response.Headers.Location.ToString().ToLowerInvariant().Should()
+ .StartWith(_identityServerHost.Url("/connect/authorize"));
+
+ response = await _identityServerHost.BrowserClient.GetAsync(response.Headers.Location.ToString());
+ response.StatusCode.Should().Be(HttpStatusCode.Redirect); // client callback
+ response.Headers.Location.ToString().ToLowerInvariant().Should().StartWith(Url("/signin-oidc"));
+
+ response = await BrowserClient.GetAsync(response.Headers.Location.ToString());
+ response.StatusCode.Should().Be(HttpStatusCode.Redirect); // root
+ response.Headers.Location.ToString().ToLowerInvariant().Should().Be("/");
+
+ (await GetIsUserLoggedInAsync()).Should().BeTrue();
+
+ response = await BrowserClient.GetAsync(Url(response.Headers.Location.ToString()));
+ return response;
+ }
+
+ public async Task BffLogoutAsync(string sid = null)
+ {
+ var response = await BrowserClient.GetAsync(Url("/bff/logout") + "?sid=" + sid);
+ response.StatusCode.Should().Be(HttpStatusCode.Redirect); // endsession
+ response.Headers.Location.ToString().ToLowerInvariant().Should()
+ .StartWith(_identityServerHost.Url("/connect/endsession"));
+
+ response = await _identityServerHost.BrowserClient.GetAsync(response.Headers.Location.ToString());
+ response.StatusCode.Should().Be(HttpStatusCode.Redirect); // logout
+ response.Headers.Location.ToString().ToLowerInvariant().Should()
+ .StartWith(_identityServerHost.Url("/account/logout"));
+
+ response = await _identityServerHost.BrowserClient.GetAsync(response.Headers.Location.ToString());
+ response.StatusCode.Should().Be(HttpStatusCode.Redirect); // post logout redirect uri
+ response.Headers.Location.ToString().ToLowerInvariant().Should().StartWith(Url("/signout-callback-oidc"));
+
+ response = await BrowserClient.GetAsync(response.Headers.Location.ToString());
+ response.StatusCode.Should().Be(HttpStatusCode.Redirect); // root
+ response.Headers.Location.ToString().ToLowerInvariant().Should().Be("/");
+
+ (await GetIsUserLoggedInAsync()).Should().BeFalse();
+
+ response = await BrowserClient.GetAsync(Url(response.Headers.Location.ToString()));
+ return response;
+ }
+
+ public class CallbackForwarderHttpClientFactory : IForwarderHttpClientFactory
+ {
+ public Func CreateInvoker { get; set; }
+
+ public CallbackForwarderHttpClientFactory(Func callback)
+ {
+ CreateInvoker = callback;
+ }
+
+ public HttpMessageInvoker CreateClient(ForwarderHttpClientContext context)
+ {
+ return CreateInvoker.Invoke(context);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/Duende.Bff.Tests/TestHosts/YarpBffIntegrationTestBase.cs b/test/Duende.Bff.Tests/TestHosts/YarpBffIntegrationTestBase.cs
new file mode 100644
index 00000000..3dcfc7b9
--- /dev/null
+++ b/test/Duende.Bff.Tests/TestHosts/YarpBffIntegrationTestBase.cs
@@ -0,0 +1,62 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.IdentityServer.Models;
+using Duende.IdentityServer.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using System.Security.Claims;
+using System.Threading.Tasks;
+
+namespace Duende.Bff.Tests.TestHosts
+{
+ public class YarpBffIntegrationTestBase
+ {
+ protected readonly IdentityServerHost IdentityServerHost;
+ protected ApiHost ApiHost;
+ protected YarpBffHost BffHost;
+ protected BffHostUsingResourceNamedTokens BffHostWithNamedTokens;
+
+ public YarpBffIntegrationTestBase()
+ {
+ IdentityServerHost = new IdentityServerHost();
+
+ IdentityServerHost.Clients.Add(new Client
+ {
+ ClientId = "spa",
+ ClientSecrets = { new Secret("secret".Sha256()) },
+ AllowedGrantTypes = GrantTypes.CodeAndClientCredentials,
+ RedirectUris = { "https://app/signin-oidc" },
+ PostLogoutRedirectUris = { "https://app/signout-callback-oidc" },
+ BackChannelLogoutUri = "https://app/bff/backchannel",
+ AllowOfflineAccess = true,
+ AllowedScopes = { "openid", "profile", "scope1" }
+ });
+
+
+ IdentityServerHost.OnConfigureServices += services => {
+ services.AddTransient(provider =>
+ new DefaultBackChannelLogoutHttpClient(
+ BffHost.HttpClient,
+ provider.GetRequiredService(),
+ provider.GetRequiredService()));
+ };
+
+ IdentityServerHost.InitializeAsync().Wait();
+
+ ApiHost = new ApiHost(IdentityServerHost, "scope1");
+ ApiHost.InitializeAsync().Wait();
+
+ BffHost = new YarpBffHost(IdentityServerHost, ApiHost, "spa");
+ BffHost.InitializeAsync().Wait();
+
+ BffHostWithNamedTokens = new BffHostUsingResourceNamedTokens(IdentityServerHost, ApiHost, "spa");
+ BffHostWithNamedTokens.InitializeAsync().Wait();
+ }
+
+ public async Task Login(string sub)
+ {
+ await IdentityServerHost.IssueSessionCookieAsync(new Claim("sub", sub));
+ }
+ }
+}