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)); + } + } +}