Rewriting the tests to use new test setup (#2052)

* refactor IAccessTokenRetrieverTests

* refactoring tests

* ManagementBasePathTests

* LogoutEndpointTests
This commit is contained in:
Erwin van der Valk 2025-06-12 16:32:55 +02:00 committed by GitHub
parent 2189d8d874
commit e47064fa1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 350 additions and 198 deletions

View file

@ -98,7 +98,7 @@ app.UseAntiforgery();
app.MapBffManagementEndpoints();
app.MapRemoteBffApiEndpoint("/remote-apis/user-token", "https://localhost:5010")
app.MapRemoteBffApiEndpoint("/remote-apis/user-token", new Uri("https://localhost:5010"))
.WithAccessToken(RequiredTokenType.User);
app.MapRazorComponents<App>()

View file

@ -184,22 +184,22 @@ internal static class Extensions
private static void MapRemoteUrls(IEndpointRouteBuilder app)
{
// On this path, we use a client credentials token
app.MapRemoteBffApiEndpoint("/api/client-token", "https://localhost:5011")
app.MapRemoteBffApiEndpoint("/api/client-token", new Uri("https://localhost:5011"))
.WithAccessToken(RequiredTokenType.Client);
// On this path, we use a user token if logged in, and fall back to a client credentials token if not
app.MapRemoteBffApiEndpoint("/api/user-or-client-token", "https://localhost:5011")
app.MapRemoteBffApiEndpoint("/api/user-or-client-token", new Uri("https://localhost:5011"))
.WithAccessToken(RequiredTokenType.UserOrClient);
// On this path, we make anonymous requests
app.MapRemoteBffApiEndpoint("/api/anonymous", "https://localhost:5011");
app.MapRemoteBffApiEndpoint("/api/anonymous", new Uri("https://localhost:5011"));
// On this path, we use the client token only if the user is logged in
app.MapRemoteBffApiEndpoint("/api/optional-user-token", "https://localhost:5011")
app.MapRemoteBffApiEndpoint("/api/optional-user-token", new Uri("https://localhost:5011"))
.WithAccessToken(RequiredTokenType.UserOrNone);
// On this path, we require the user token
app.MapRemoteBffApiEndpoint("/api/user-token", "https://localhost:5011")
app.MapRemoteBffApiEndpoint("/api/user-token", new Uri("https://localhost:5011"))
.WithAccessToken();
}
}

View file

@ -107,7 +107,7 @@ internal static class Extensions
// all calls to /api/* will be forwarded to the remote API
// user or client access token will be attached in API call
// user access token will be managed automatically using the refresh token
app.MapRemoteBffApiEndpoint("/api", "https://localhost:5010")
app.MapRemoteBffApiEndpoint("/api", new Uri("https://localhost:5010"))
.WithAccessToken(RequiredTokenType.UserOrClient);
return app;

View file

@ -128,33 +128,33 @@ internal static class Extensions
//////////////////////////////////////
// On this path, we use a client credentials token
app.MapRemoteBffApiEndpoint("/api/client-token", "https://localhost:5010")
app.MapRemoteBffApiEndpoint("/api/client-token", new Uri("https://localhost:5010"))
.WithAccessToken(RequiredTokenType.Client);
// On this path, we use a user token if logged in, and fall back to a client credentials token if not
app.MapRemoteBffApiEndpoint("/api/user-or-client-token", "https://localhost:5010")
app.MapRemoteBffApiEndpoint("/api/user-or-client-token", new Uri("https://localhost:5010"))
.WithAccessToken(RequiredTokenType.UserOrClient);
// On this path, we make anonymous requests
app.MapRemoteBffApiEndpoint("/api/anonymous", "https://localhost:5010");
app.MapRemoteBffApiEndpoint("/api/anonymous", new Uri("https://localhost:5010"));
// On this path, we use the client token only if the user is logged in
app.MapRemoteBffApiEndpoint("/api/optional-user-token", "https://localhost:5010")
app.MapRemoteBffApiEndpoint("/api/optional-user-token", new Uri("https://localhost:5010"))
.WithAccessToken(RequiredTokenType.UserOrNone);
// On this path, we require the user token
app.MapRemoteBffApiEndpoint("/api/user-token", "https://localhost:5010")
app.MapRemoteBffApiEndpoint("/api/user-token", new Uri("https://localhost:5010"))
.WithAccessToken();
// On this path, we perform token exchange to impersonate a different user
// before making the api request
app.MapRemoteBffApiEndpoint("/api/impersonation", "https://localhost:5010")
app.MapRemoteBffApiEndpoint("/api/impersonation", new Uri("https://localhost:5010"))
.WithAccessToken()
.WithAccessTokenRetriever<ImpersonationAccessTokenRetriever>();
// On this path, we obtain an audience constrained token and invoke
// a different api that requires such a token
app.MapRemoteBffApiEndpoint("/api/audience-constrained", "https://localhost:5012")
app.MapRemoteBffApiEndpoint("/api/audience-constrained", new Uri("https://localhost:5012"))
.WithAccessToken()
.WithUserAccessTokenParameter(new BffUserAccessTokenParameters { Resource = Resource.Parse("urn:isolated-api") });

View file

@ -51,11 +51,11 @@ public class BffHost : Host
app.MapBffManagementEndpoints();
app.MapRemoteBffApiEndpoint("/allow_anon", apiUri.ToString());
app.MapRemoteBffApiEndpoint("/client_token", apiUri.ToString())
app.MapRemoteBffApiEndpoint("/allow_anon", apiUri);
app.MapRemoteBffApiEndpoint("/client_token", apiUri)
.WithAccessToken(RequiredTokenType.Client);
app.MapRemoteBffApiEndpoint("/user_token", apiUri.ToString())
app.MapRemoteBffApiEndpoint("/user_token", apiUri)
.WithAccessToken(RequiredTokenType.User);
};
}

View file

@ -26,7 +26,7 @@ public static class RouteBuilderExtensions
public static IEndpointConventionBuilder MapRemoteBffApiEndpoint(
this IEndpointRouteBuilder endpoints,
PathString localPath,
string apiAddress,
Uri apiAddress,
Action<TransformBuilderContext>? yarpTransformBuilder = null)
{
endpoints.CheckLicense();
@ -45,11 +45,12 @@ public static class RouteBuilderExtensions
return endpoints.MapForwarder(
pattern: localPath.Add("/{**catch-all}").Value!,
destinationPrefix: apiAddress,
destinationPrefix: apiAddress.ToString(),
configureTransform: context =>
{
yarpTransformBuilder(context);
})
.WithMetadata(new BffRemoteApiEndpointMetadata());
}
}

View file

@ -32,6 +32,15 @@ internal class OpenIdConnectCallbackMiddleware(RequestDelegate next,
return;
}
}
if (context.Request.Path.StartsWithSegments(options.SignedOutCallbackPath))
{
var handlers = context.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
if (await handlers.GetHandlerAsync(context, frontend.OidcSchemeName) is IAuthenticationRequestHandler handler)
{
await handler.HandleRequestAsync();
return;
}
}
await next(context);
}

View file

@ -44,7 +44,7 @@ public class DPoPTestsWithManualAuthentication : BffTestBase, IAsyncLifetime
Bff.OnConfigureBff += bff => bff.AddRemoteApis();
Bff.OnConfigureEndpoints += endpoints =>
{
endpoints.MapRemoteBffApiEndpoint(The.Path, Api.Url().ToString())
endpoints.MapRemoteBffApiEndpoint(The.Path, Api.Url())
.WithAccessToken(RequiredTokenType.Client)
;
};

View file

@ -29,7 +29,7 @@ public class DpopRemoteEndpointTests : BffTestBase, IAsyncLifetime
Bff.OnConfigureBff += bff => bff.AddRemoteApis();
Bff.OnConfigureEndpoints += endpoints =>
{
endpoints.MapRemoteBffApiEndpoint(The.Path, Api.Url().ToString())
endpoints.MapRemoteBffApiEndpoint(The.Path, Api.Url())
.WithAccessToken(RequiredTokenType.Client)
;
};

View file

@ -2,19 +2,21 @@
// See LICENSE in the project root for license information.
using System.Net;
using Duende.Bff.Configuration;
using Duende.Bff.Tests.TestHosts;
using System.Security.Claims;
using Duende.Bff.Tests.TestInfra;
using Duende.IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Xunit.Abstractions;
namespace Duende.Bff.Tests.Endpoints.Management;
public class LogoutEndpointTests(ITestOutputHelper output) : BffIntegrationTestBase(output)
public class LogoutEndpointTests(ITestOutputHelper output) : BffTestBase(output)
{
[Fact]
public async Task logout_endpoint_should_allow_anonymous()
[Theory]
[MemberData(nameof(AllSetups))]
public async Task logout_endpoint_should_allow_anonymous(BffSetupType setup)
{
ConfigureBff(setup);
Bff.OnConfigureServices += svcs =>
{
svcs.AddAuthorization(opts =>
@ -25,134 +27,155 @@ public class LogoutEndpointTests(ITestOutputHelper output) : BffIntegrationTestB
.Build();
});
};
await Bff.InitializeAsync();
await InitializeAsync();
var response = await Bff.BrowserClient.GetAsync(Bff.Url("/bff/logout"));
response.StatusCode.ShouldNotBe(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task logout_endpoint_should_signout()
[Theory]
[MemberData(nameof(AllSetups))]
public async Task logout_endpoint_should_signout(BffSetupType setup)
{
await Bff.BffLoginAsync("alice", "sid123");
ConfigureBff(setup);
await InitializeAsync();
await Bff.BffLogoutAsync("sid123");
await Bff.BrowserClient.Login();
(await Bff.GetIsUserLoggedInAsync()).ShouldBeFalse();
var response = await Bff.BrowserClient.Logout();
response.StatusCode.ShouldBe(HttpStatusCode.OK);
response.RequestMessage!.RequestUri.ShouldBe(Bff.Url());
(await Bff.BrowserClient.GetIsUserLoggedInAsync()).ShouldBeFalse();
}
[Fact]
public async Task logout_endpoint_for_authenticated_should_require_sid()
[Theory]
[MemberData(nameof(AllSetups))]
public async Task logout_endpoint_for_authenticated_should_require_sid(BffSetupType setup)
{
await Bff.BffLoginAsync("alice", "sid123");
ConfigureBff(setup);
await InitializeAsync();
await Bff.BrowserClient.Login();
var problem = await Bff.BrowserClient.GetAsync(Bff.Url("/bff/logout"))
.ShouldBeProblem();
problem.Errors.ShouldContainKey(JwtClaimTypes.SessionId);
(await Bff.GetIsUserLoggedInAsync()).ShouldBeTrue();
(await Bff.BrowserClient.GetIsUserLoggedInAsync()).ShouldBeTrue();
}
[Fact]
public async Task logout_endpoint_for_authenticated_when_require_option_is_false_should_not_require_sid()
[Theory]
[MemberData(nameof(AllSetups))]
public async Task logout_endpoint_for_authenticated_when_require_option_is_false_should_not_require_sid(BffSetupType setup)
{
await Bff.BffLoginAsync("alice", "sid123");
ConfigureBff(setup);
await InitializeAsync();
await Bff.BrowserClient.Login();
Bff.BffOptions.RequireLogoutSessionId = false;
Bff.BrowserClient.RedirectHandler.AutoFollowRedirects = false;
var response = await Bff.BrowserClient.GetAsync(Bff.Url("/bff/logout"));
response.StatusCode.ShouldBe(HttpStatusCode.Redirect); // endsession
response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(IdentityServer.Url("/connect/endsession"));
response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(IdentityServer.Url("/connect/endsession").ToString());
}
[Fact]
public async Task logout_endpoint_for_authenticated_user_without_sid_should_succeed()
[Theory]
[MemberData(nameof(AllSetups))]
public async Task logout_endpoint_for_authenticated_user_without_sid_should_succeed(BffSetupType setup)
{
// workaround for RevokeUserRefreshTokenAsync throwing when no RT in session
Bff.OnConfigureServices += svcs =>
// Workaround to place a session cookie in the BFF without a session id claim.
Bff.OnConfigureEndpoints += endpoints =>
{
svcs.Configure<BffOptions>(options =>
endpoints.MapGet("/__signin", async ctx =>
{
options.RevokeRefreshTokenOnLogout = false;
var props = new AuthenticationProperties();
await ctx.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, The.Sub)], "test", "name", "role")), props);
ctx.Response.StatusCode = 204;
});
};
await Bff.InitializeAsync();
await Bff.IssueSessionCookieAsync("alice");
ConfigureBff(setup);
await InitializeAsync();
await Bff.BrowserClient.GetAsync("__signin");
// workaround for RevokeUserRefreshTokenAsync throwing when no RT in session
Bff.BffOptions.RevokeRefreshTokenOnLogout = false;
Bff.BrowserClient.RedirectHandler.AutoFollowRedirects = false;
var response = await Bff.BrowserClient.GetAsync(Bff.Url("/bff/logout"));
response.StatusCode.ShouldBe(HttpStatusCode.Redirect); // endsession
response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(IdentityServer.Url("/connect/endsession"));
response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(IdentityServer.Url("/connect/endsession").ToString());
}
[Fact]
public async Task logout_endpoint_for_anonymous_user_without_sid_should_succeed()
[Theory]
[MemberData(nameof(AllSetups))]
public async Task logout_endpoint_for_anonymous_user_without_sid_should_succeed(BffSetupType setup)
{
ConfigureBff(setup);
await InitializeAsync();
Bff.BrowserClient.RedirectHandler.AutoFollowRedirects = false;
var response = await Bff.BrowserClient.GetAsync(Bff.Url("/bff/logout"));
response.StatusCode.ShouldBe(HttpStatusCode.Redirect); // endsession
response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(IdentityServer.Url("/connect/endsession"));
response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(IdentityServer.Url("/connect/endsession").ToString());
}
[Fact]
public async Task logout_endpoint_should_redirect_to_external_signout_and_return_to_root()
[Theory]
[MemberData(nameof(AllSetups))]
public async Task can_logout_twice(BffSetupType setup)
{
await Bff.BffLoginAsync("alice", "sid123");
ConfigureBff(setup);
await InitializeAsync();
await Bff.BrowserClient.Login();
await Bff.BffLogoutAsync("sid123");
var sid = await Bff.BrowserClient.GetSid();
await Bff.BrowserClient.Logout(sid)
.CheckHttpStatusCode();
Bff.BrowserClient.CurrentUri
.ShouldNotBeNull()
.ToString()
.ToLowerInvariant()
.ShouldBe(Bff.Url("/"));
Bff.BrowserClient.RedirectHandler.AutoFollowRedirects = false;
var response = await Bff.BrowserClient.Logout(sid);
(await Bff.GetIsUserLoggedInAsync()).ShouldBeFalse();
}
[Fact]
public async Task can_logout_twice()
{
await Bff.BffLoginAsync("alice", "sid123");
await Bff.BffLogoutAsync("sid123");
var response = await Bff.BrowserClient.GetAsync(Bff.Url("/bff/logout") + "?sid=123");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect); // endsession
response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(IdentityServer.Url("/connect/endsession"));
response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(IdentityServer.Url("/connect/endsession").ToString());
(await Bff.GetIsUserLoggedInAsync()).ShouldBeFalse();
(await Bff.BrowserClient.GetIsUserLoggedInAsync()).ShouldBeFalse();
}
[Fact]
public async Task logout_endpoint_should_accept_returnUrl()
[Theory]
[MemberData(nameof(AllSetups))]
public async Task logout_endpoint_should_accept_returnUrl(BffSetupType setup)
{
await Bff.BffLoginAsync("alice", "sid123");
Bff.OnConfigureEndpoints += endpoints => endpoints.MapGet("/foo", () => "foo'd you");
var response = await Bff.BrowserClient.GetAsync(Bff.Url("/bff/logout") + "?sid=sid123&returnUrl=/foo");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect); // endsession
response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(IdentityServer.Url("/connect/endsession"));
ConfigureBff(setup);
await InitializeAsync();
await Bff.BrowserClient.Login();
response = await IdentityServer.BrowserClient.GetAsync(response.Headers.Location!.ToString());
response.StatusCode.ShouldBe(HttpStatusCode.Redirect); // logout
response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(IdentityServer.Url("/account/logout"));
var response = await Bff.BrowserClient.Logout(returnUrl: new Uri("/foo", UriKind.Relative))
.CheckHttpStatusCode();
response = await IdentityServer.BrowserClient.GetAsync(response.Headers.Location!.ToString());
response.StatusCode.ShouldBe(HttpStatusCode.Redirect); // post logout redirect uri
response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(Bff.Url("/signout-callback-oidc"));
response.RequestMessage!.RequestUri.ShouldBe(Bff.Url("/foo"));
response = await Bff.BrowserClient.GetAsync(response.Headers.Location!.ToString());
response.StatusCode.ShouldBe(HttpStatusCode.Redirect); // root
response.Headers.Location!.ToString().ToLowerInvariant().ShouldBe("/foo");
}
[Fact]
public async Task logout_endpoint_should_reject_non_local_returnUrl()
[Theory]
[MemberData(nameof(AllSetups))]
public async Task logout_endpoint_should_reject_non_local_returnUrl(BffSetupType setup)
{
await Bff.BffLoginAsync("alice", "sid123");
ConfigureBff(setup);
await InitializeAsync();
await Bff.BrowserClient.Login();
var problem = await Bff.BrowserClient.GetAsync(Bff.Url("/bff/logout") + "?sid=sid123&returnUrl=https://foo")
var problem = await Bff.BrowserClient.Logout(returnUrl: new Uri("https://foo"))
.ShouldBeProblem();
problem.Errors.ShouldContainKey(Constants.RequestParameters.ReturnUrl);

View file

@ -3,23 +3,24 @@
using System.Net;
using Duende.Bff.Configuration;
using Duende.Bff.Tests.TestHosts;
using Duende.Bff.Tests.TestInfra;
using Xunit.Abstractions;
namespace Duende.Bff.Tests.Endpoints.Management;
public class ManagementBasePathTests(ITestOutputHelper output) : BffIntegrationTestBase(output)
public class ManagementBasePathTests(ITestOutputHelper output) : BffTestBase(output)
{
[Theory]
[InlineData(Constants.ManagementEndpoints.Login)]
[InlineData(Constants.ManagementEndpoints.Logout)]
[InlineData(Constants.ManagementEndpoints.Login, HttpStatusCode.Redirect)]
[InlineData(Constants.ManagementEndpoints.Logout, HttpStatusCode.Redirect)]
#pragma warning disable CS0618 // Type or member is obsolete
[InlineData(Constants.ManagementEndpoints.SilentLogin)]
[InlineData(Constants.ManagementEndpoints.SilentLogin, HttpStatusCode.Redirect)]
#pragma warning restore CS0618 // Type or member is obsolete
[InlineData(Constants.ManagementEndpoints.SilentLoginCallback)]
[InlineData(Constants.ManagementEndpoints.User)]
public async Task custom_ManagementBasePath_should_affect_basepath(string path)
[InlineData(Constants.ManagementEndpoints.SilentLoginCallback, HttpStatusCode.OK)]
[InlineData(Constants.ManagementEndpoints.User, HttpStatusCode.Unauthorized)]
public async Task custom_ManagementBasePath_should_affect_basepath(string path, HttpStatusCode expectedStatusCode)
{
SetupDefaultBffAuthentication();
Bff.OnConfigureServices += svcs =>
{
svcs.Configure<BffOptions>(options =>
@ -27,13 +28,26 @@ public class ManagementBasePathTests(ITestOutputHelper output) : BffIntegrationT
options.ManagementBasePath = new PathString("/{path:regex(^[a-zA-Z\\d-]+$)}/bff");
});
};
await Bff.InitializeAsync();
await InitializeAsync();
var req = new HttpRequestMessage(HttpMethod.Get, Bff.Url("/custom/bff" + path));
// Don't follow the redirects, becuase otherwise we might folow a redirect flow that ends up in a 404
Bff.BrowserClient.RedirectHandler.AutoFollowRedirects = false;
// Make sure the 'original path' doesn't work
await VerifyRoute(path, HttpStatusCode.NotFound);
// but the custom path does work
await VerifyRoute(path, expectedStatusCode, "/custom/bff");
}
private async Task VerifyRoute(string path, HttpStatusCode expectedStatusCode, string? prefix = null)
{
var req = new HttpRequestMessage(HttpMethod.Get, Bff.Url(prefix + path));
req.Headers.Add("x-csrf", "1");
var response = await Bff.BrowserClient.SendAsync(req);
response.StatusCode.ShouldBe(expectedStatusCode);
response.StatusCode.ShouldNotBe(HttpStatusCode.NotFound);
}
}

View file

@ -4,25 +4,47 @@
using System.Net;
using System.Security.Claims;
using Duende.Bff.Configuration;
using Duende.Bff.Tests.TestHosts;
using Duende.Bff.Tests.TestInfra;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Xunit.Abstractions;
namespace Duende.Bff.Tests.Endpoints.Management;
public class UserEndpointTests(ITestOutputHelper output) : BffIntegrationTestBase(output)
public class UserEndpointTests : BffTestBase, IAsyncLifetime
{
private List<Claim> ClaimsToAdd = [];
public UserEndpointTests(ITestOutputHelper output) : base(output)
{
SetupDefaultBffAuthentication(ClaimsToAdd);
Bff.OnConfigureEndpoints += endpoints =>
{
// Setup a login endpoint that allows you to simulate signing in as a specific
// user in the BFF.
endpoints.MapGet("/__signin", async ctx =>
{
var props = new AuthenticationProperties();
await ctx.SignInAsync(UserToSignIn!, props);
ctx.Response.StatusCode = 204;
});
};
}
public ClaimsPrincipal? UserToSignIn { get; set; }
[Fact]
public async Task user_endpoint_for_authenticated_user_should_return_claims()
{
await Bff.IssueSessionCookieAsync(
new Claim("sub", "alice"),
new Claim("foo", "foo1"),
new Claim("foo", "foo2"));
ClaimsToAdd.Add(new Claim("foo", "foo1"));
ClaimsToAdd.Add(new Claim("foo", "foo2"));
await Bff.BrowserClient.Login();
var data = await Bff.CallUserEndpointAsync();
var data = await Bff.BrowserClient.CallUserEndpointAsync();
data.Count.ShouldBe(5);
data.First(d => d.Type == "sub").Value.GetString().ShouldBe("alice");
data.First(d => d.Type == "sub").Value.GetString().ShouldBe(The.Sub);
var foos = data.Where(d => d.Type == "foo");
foos.Count().ShouldBe(2);
@ -30,17 +52,20 @@ public class UserEndpointTests(ITestOutputHelper output) : BffIntegrationTestBas
foos.Skip(1).First().Value.GetString().ShouldBe("foo2");
data.First(d => d.Type == Constants.ClaimTypes.SessionExpiresIn).Value.GetInt32().ShouldBePositive();
data.First(d => d.Type == Constants.ClaimTypes.LogoutUrl).Value.GetString().ShouldBe("/bff/logout");
data.First(d => d.Type == Constants.ClaimTypes.LogoutUrl).Value.GetString().ShouldStartWith("/bff/logout?sid=");
}
[Fact]
public async Task user_endpoint_for_authenticated_user_with_sid_should_return_claims_including_logout()
{
await Bff.IssueSessionCookieAsync(
UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([
new Claim("sub", "alice"),
new Claim("sid", "123"));
new Claim("sid", "123"),
], "test", "name", "role"));
var data = await Bff.CallUserEndpointAsync();
await Bff.BrowserClient.GetAsync("/__signin");
var data = await Bff.BrowserClient.CallUserEndpointAsync();
data.Count.ShouldBe(4);
data.First(d => d.Type == "sub").Value.GetString().ShouldBe("alice");
@ -52,7 +77,7 @@ public class UserEndpointTests(ITestOutputHelper output) : BffIntegrationTestBas
[Fact]
public async Task user_endpoint_for_authenticated_user_without_csrf_header_should_fail()
{
await Bff.IssueSessionCookieAsync(new Claim("sub", "alice"), new Claim("foo", "foo1"), new Claim("foo", "foo2"));
await Bff.BrowserClient.IssueSessionCookieAsync(new Claim("sub", "alice"), new Claim("foo", "foo1"), new Claim("foo", "foo2"));
var req = new HttpRequestMessage(HttpMethod.Get, Bff.Url("/bff/user"));
var response = await Bff.BrowserClient.SendAsync(req);
@ -73,9 +98,12 @@ public class UserEndpointTests(ITestOutputHelper output) : BffIntegrationTestBas
[Fact]
public async Task when_configured_user_endpoint_for_unauthenticated_user_should_return_200_and_empty()
{
Bff.BffOptions.AnonymousSessionResponse = AnonymousSessionResponse.Response200;
var data = await Bff.CallUserEndpointAsync();
var options = Bff.Resolve<IOptions<BffOptions>>();
options.Value.AnonymousSessionResponse = AnonymousSessionResponse.Response200;
var data = await Bff.BrowserClient.CallUserEndpointAsync();
data.ShouldBeEmpty();
}
}

View file

@ -37,7 +37,7 @@ public class ApiAndBffUseForwardedHeaders : BffTestBase, IAsyncLifetime
Bff.OnConfigureEndpoints += endpoints =>
{
endpoints.MapRemoteBffApiEndpoint(The.Path, Api.Url().ToString());
endpoints.MapRemoteBffApiEndpoint(The.Path, Api.Url());
};
}

View file

@ -27,7 +27,7 @@ public class ApiUseForwardedHeaders : BffTestBase
Bff.OnConfigureEndpoints += endpoints =>
{
endpoints.MapRemoteBffApiEndpoint(The.Path, Api.Url().ToString());
endpoints.MapRemoteBffApiEndpoint(The.Path, Api.Url());
};
}

View file

@ -3,7 +3,7 @@
using Duende.Bff.AccessTokenManagement;
using Duende.Bff.Internal;
using Duende.Bff.Tests.TestHosts;
using Duende.Bff.Tests.TestInfra;
using Duende.Bff.Yarp;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit.Abstractions;
@ -13,26 +13,52 @@ namespace Duende.Bff.Tests;
/// <summary>
/// These tests prove that you can use a custom IAccessTokenRetriever and that the context is populated correctly.
/// </summary>
public class IAccessTokenRetriever_Extensibility_tests : BffIntegrationTestBase
public class IAccessTokenRetriever_Extensibility_tests : BffTestBase
{
private ContextCapturingAccessTokenRetriever _customAccessTokenReceiver { get; } = new(NullLogger<DefaultAccessTokenRetriever>.Instance);
public IAccessTokenRetriever_Extensibility_tests(ITestOutputHelper output) : base(output)
{
IdentityServer.AddClient(The.ClientId, Bff.Url());
Bff.OnConfigureBff += bff => bff
.WithDefaultOpenIdConnectOptions(The.DefaultOpenIdConnectConfiguration)
.AddRemoteApis();
Bff.OnConfigureServices += services =>
{
services.AddSingleton(_customAccessTokenReceiver);
};
}
[Fact]
public async Task When_calling_custom_endpoint_then_AccessTokenRetrievalContext_has_api_address_and_localpath()
{
Bff.OnConfigureEndpoints += endpoints =>
{
endpoints.MapRemoteBffApiEndpoint("/custom", Api.Url("/some/path"))
.WithAccessToken()
.WithAccessTokenRetriever<ContextCapturingAccessTokenRetriever>();
};
await InitializeAsync();
await Bff.BrowserClient.Login();
await Bff.BrowserClient.CallBffHostApi(Bff.Url("/custom"));
var usedContext = _customAccessTokenReceiver.UsedContext.ShouldNotBeNull();
usedContext.Metadata.TokenType.ShouldBe(RequiredTokenType.User);
usedContext.ApiAddress.ShouldBe(Api.Url("/some/path"));
usedContext.LocalPath.ToString().ShouldBe("/custom");
}
[Fact]
public async Task When_calling_sub_custom_endpoint_then_AccessTokenRetrievalContext_has_api_address_and_localpath()
{
Bff.OnConfigure += app =>
{
app.UseEndpoints((endpoints) =>
{
endpoints.MapRemoteBffApiEndpoint("/custom", Api.Url("/some/path"))
.WithAccessToken()
.WithAccessTokenRetriever<ContextCapturingAccessTokenRetriever>();
});
app.Map("/subPath",
subPath =>
@ -47,34 +73,13 @@ public class IAccessTokenRetriever_Extensibility_tests : BffIntegrationTestBase
});
};
}
[Fact]
public async Task When_calling_custom_endpoint_then_AccessTokenRetrievalContext_has_api_address_and_localpath()
{
await Bff.BffLoginAsync("alice");
await Bff.BrowserClient.CallBffHostApi(Bff.Url("/custom"));
var usedContext = _customAccessTokenReceiver.UsedContext.ShouldNotBeNull();
usedContext.Metadata.TokenType.ShouldBe(RequiredTokenType.User);
usedContext.ApiAddress.ShouldBe(new Uri(Api.Url("/some/path")));
usedContext.LocalPath.ToString().ShouldBe("/custom");
}
[Fact]
public async Task When_calling_sub_custom_endpoint_then_AccessTokenRetrievalContext_has_api_address_and_localpath()
{
await Bff.BffLoginAsync("alice");
await InitializeAsync();
await Bff.BrowserClient.Login();
await Bff.BrowserClient.CallBffHostApi(Bff.Url("/subPath/custom_within_subpath"));
var usedContext = _customAccessTokenReceiver.UsedContext.ShouldNotBeNull();
usedContext.ApiAddress.ShouldBe(new Uri(Api.Url("/some/path")));
usedContext.ApiAddress.ShouldBe(Api.Url("/some/path"));
usedContext.LocalPath.ToString().ShouldBe("/custom_within_subpath");
}
@ -94,9 +99,10 @@ public class IAccessTokenRetriever_Extensibility_tests : BffIntegrationTestBase
UsedContext = context;
if (context.Metadata.TokenType.HasValue)
{
return await context.HttpContext.GetManagedAccessToken(
var managedAccessToken = await context.HttpContext.GetManagedAccessToken(
requiredTokenType: context.Metadata.TokenType.Value,
context.UserTokenRequestParameters, ct: ct);
return managedAccessToken;
}
else
{

View file

@ -65,7 +65,7 @@
}
public static class RouteBuilderExtensions
{
public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapRemoteBffApiEndpoint(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, Microsoft.AspNetCore.Http.PathString localPath, string apiAddress, System.Action<Yarp.ReverseProxy.Transforms.Builder.TransformBuilderContext>? yarpTransformBuilder = null) { }
public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapRemoteBffApiEndpoint(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, Microsoft.AspNetCore.Http.PathString localPath, System.Uri apiAddress, System.Action<Yarp.ReverseProxy.Transforms.Builder.TransformBuilderContext>? yarpTransformBuilder = null) { }
}
public sealed class UserAccessTokenParameters : System.IEquatable<Duende.Bff.Yarp.UserAccessTokenParameters>
{

View file

@ -407,45 +407,45 @@ public class BffHost : GenericHost
.RequireAuthorization("AlwaysFail");
endpoints.MapRemoteBffApiEndpoint(
"/api_user", _apiHost.Url())
"/api_user", new Uri(_apiHost.Url()))
.WithAccessToken();
endpoints.MapRemoteBffApiEndpoint(
"/api_user_no_csrf", _apiHost.Url())
"/api_user_no_csrf", new Uri(_apiHost.Url()))
.SkipAntiforgery()
.WithAccessToken();
endpoints.MapRemoteBffApiEndpoint(
"/api_client", _apiHost.Url())
"/api_client", new Uri(_apiHost.Url()))
.WithAccessToken(RequiredTokenType.Client);
endpoints.MapRemoteBffApiEndpoint(
"/api_user_or_client", _apiHost.Url())
"/api_user_or_client", new Uri(_apiHost.Url()))
.WithAccessToken(RequiredTokenType.UserOrClient);
endpoints.MapRemoteBffApiEndpoint(
"/api_unauthenticated", _apiHost.Url() + "return_unauthenticated")
"/api_unauthenticated", new Uri(_apiHost.Url() + "return_unauthenticated"))
.WithAccessToken(RequiredTokenType.UserOrClient);
endpoints.MapRemoteBffApiEndpoint(
"/api_forbidden", _apiHost.Url() + "return_forbidden")
"/api_forbidden", new Uri(_apiHost.Url() + "return_forbidden"))
.WithAccessToken(RequiredTokenType.UserOrClient);
#pragma warning disable CS0618 // Type or member is obsolete
endpoints.MapRemoteBffApiEndpoint(
"/api_user_or_anon", _apiHost.Url())
"/api_user_or_anon", new Uri(_apiHost.Url()))
.WithOptionalUserAccessToken();
#pragma warning restore CS0618 // Type or member is obsolete
endpoints.MapRemoteBffApiEndpoint(
"/api_anon_only", _apiHost.Url());
"/api_anon_only", new Uri(_apiHost.Url()));
// Add a custom transform. This transform just copies the request headers
// which allows the tests to see if this custom transform works
endpoints.MapRemoteBffApiEndpoint(
"/api_custom_transform", _apiHost.Url(),
"/api_custom_transform", new Uri(_apiHost.Url()),
c =>
{
c.CopyRequestHeaders = true;
@ -453,12 +453,12 @@ public class BffHost : GenericHost
});
endpoints.MapRemoteBffApiEndpoint(
"/api_with_access_token_retriever", _apiHost.Url())
"/api_with_access_token_retriever", new Uri(_apiHost.Url()))
.WithAccessToken(RequiredTokenType.UserOrClient)
.WithAccessTokenRetriever<TestAccessTokenRetriever>();
endpoints.MapRemoteBffApiEndpoint(
"/api_with_access_token_retrieval_that_fails", _apiHost.Url())
"/api_with_access_token_retrieval_that_fails", new Uri(_apiHost.Url()))
.WithAccessToken(RequiredTokenType.UserOrClient)
.WithAccessTokenRetriever<FailureAccessTokenRetriever>();
});

View file

@ -142,7 +142,7 @@ public class BffHostUsingResourceNamedTokens : GenericHost
endpoints.MapBffManagementEndpoints();
endpoints.MapRemoteBffApiEndpoint(
"/api_user_with_useraccesstokenparameters_having_stored_named_token", _apiHost.Url())
"/api_user_with_useraccesstokenparameters_having_stored_named_token", new Uri(_apiHost.Url()))
.WithUserAccessTokenParameter(new BffUserAccessTokenParameters
{
SignInScheme = Scheme.Parse("cookie"),
@ -152,7 +152,7 @@ public class BffHostUsingResourceNamedTokens : GenericHost
.WithAccessToken();
endpoints.MapRemoteBffApiEndpoint(
"/api_user_with_useraccesstokenparameters_having_not_stored_named_token", _apiHost.Url())
"/api_user_with_useraccesstokenparameters_having_not_stored_named_token", new Uri(_apiHost.Url()))
.WithUserAccessTokenParameter(new BffUserAccessTokenParameters
{
SignInScheme = Scheme.Parse("cookie"),

View file

@ -20,7 +20,7 @@ public static class CookieContainerExtensions
}
}
public class BffHttpClient(RedirectHandler handler, CookieContainer cookies) : HttpClient(handler), IHttpClient<BffHttpClient>
public class BffHttpClient(RedirectHandler handler, CookieContainer cookies, IdentityServerTestHost identityServer) : HttpClient(handler)
{
public CookieContainer Cookies { get; } = cookies;
@ -30,8 +30,20 @@ public class BffHttpClient(RedirectHandler handler, CookieContainer cookies) : H
.CheckHttpStatusCode(expectedStatusCode);
public static BffHttpClient Build(RedirectHandler handler, CookieContainer cookies) => new(handler, cookies);
public async Task<List<JsonRecord>> CallUserEndpointAsync()
{
var req = new HttpRequestMessage(HttpMethod.Get, "/bff/user");
req.Headers.Add("x-csrf", "1");
var response = await SendAsync(req);
response.StatusCode.ShouldBe(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.ShouldBe("application/json");
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<List<JsonRecord>>(json, TestSerializerOptions.Default) ?? [];
}
internal Task<TestBrowserClient.BffHostResponse> CallBffHostApi(
PathString path,
@ -115,23 +127,44 @@ public class BffHttpClient(RedirectHandler handler, CookieContainer cookies) : H
host.PropsToSignIn.Items.Add("session_id", sid);
}
await IssueSessionCookieAsync(host, new Claim("sub", sub));
await IssueSessionCookieAsync(new Claim("sub", sub));
}
public async Task IssueSessionCookieAsync(IdentityServerTestHost host, params Claim[] claims)
public async Task IssueSessionCookieAsync(params Claim[] claims)
{
var previousUser = host.UserToSignIn;
var previousUser = identityServer.UserToSignIn;
try
{
host.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "test", "name", "role"));
var response = await GetAsync(host.Url("__signin"));
identityServer.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "test", "name", "role"));
var response = await GetAsync(identityServer.Url("__signin"));
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
}
finally
{
host.UserToSignIn = previousUser;
identityServer.UserToSignIn = previousUser;
}
}
public async Task RevokeIdentityServerSession(Uri url) => await GetAsync(new Uri(url, "__signout")).CheckHttpStatusCode(HttpStatusCode.NoContent);
public async Task<HttpResponseMessage> Logout(string? sid = null, Uri? returnUrl = null)
{
sid ??= await GetSid();
var returnParams = returnUrl == null ? null : $"&returnUrl={Uri.EscapeDataString(returnUrl.ToString())}";
var req = new HttpRequestMessage(HttpMethod.Get, "/bff/logout?sid=" + sid + returnParams);
req.Headers.Add("x-csrf", "1");
return await SendAsync(req);
}
public async Task<string> GetSid()
{
var claims = await CallUserEndpointAsync();
var sidClaim = claims.FirstOrDefault(c => c.Type == "sid")?.Value;
sidClaim.ShouldNotBeNull();
var sid = sidClaim.Value.ToString();
return sid;
}
}

View file

@ -1,6 +1,7 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Security.Claims;
using Duende.Bff.DynamicFrontends;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
@ -39,7 +40,7 @@ public abstract class BffTestBase : IAsyncDisposable
Api = new ApiHost(Context, IdentityServer);
Bff = new BffTestHost(Context);
Bff = new BffTestHost(Context, IdentityServer);
Cdn = new CdnHost(Context);
IdentityServer.AddClient(DefaultOidcClient.ClientId, Bff.Url());
Some = Context.Some;
@ -188,5 +189,28 @@ public abstract class BffTestBase : IAsyncDisposable
yield return [BffSetupType.ManuallyConfiguredBff];
}
protected void SetupDefaultBffAuthentication(IEnumerable<Claim>? claimsToAdd = null)
{
Bff.OnConfigureBff += bff => bff
.WithDefaultOpenIdConnectOptions(opt =>
{
if (claimsToAdd != null)
{
opt.Events.OnTokenValidated = context =>
{
// Add custom claims to the identity
var identity = (ClaimsIdentity)context.Principal!.Identity!;
foreach (var claim in claimsToAdd)
{
identity.AddClaim(claim);
}
return Task.CompletedTask;
}; The.DefaultOpenIdConnectConfiguration(opt);
}
});
AddOrUpdateFrontend(Some.BffFrontend());
}
}

View file

@ -1,17 +1,20 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Net;
using Duende.Bff.Configuration;
using Duende.Bff.DynamicFrontends;
using Duende.Bff.DynamicFrontends.Internal;
using Microsoft.Extensions.Options;
using Yarp.ReverseProxy.Forwarder;
namespace Duende.Bff.Tests.TestInfra;
public class BffTestHost(TestHostContext context) : TestHost(context, new Uri("https://bff"))
public class BffTestHost(TestHostContext context, IdentityServerTestHost identityServer) : TestHost(context, new Uri("https://bff"))
{
public readonly string DefaultRootResponse = "Default response from root";
private BffHttpClient _browserClient = null!;
public BffOptions BffOptions => Resolve<IOptions<BffOptions>>().Value;
/// <summary>
/// Should a default response for "/" be mapped?
@ -25,7 +28,17 @@ public class BffTestHost(TestHostContext context) : TestHost(context, new Uri("h
public override void Initialize()
{
BrowserClient = Internet.BuildHttpClient<BffHttpClient>(Url());
var cookieContainer = new CookieContainer();
var cookieHandler = new CookieHandler(Internet, cookieContainer);
var redirectHandler = new RedirectHandler(WriteOutput)
{
InnerHandler = cookieHandler
};
BrowserClient = new BffHttpClient(redirectHandler, cookieContainer, identityServer)
{
BaseAddress = Url()
};
OnConfigureServices += services =>
{
if (EnableBackChannelHandler)
@ -46,15 +59,6 @@ public class BffTestHost(TestHostContext context) : TestHost(context, new Uri("h
OnConfigureBff(builder);
};
OnConfigure += app =>
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseBff();
};
OnConfigureEndpoints += endpoints =>
{
if (MapGetForRoot)
@ -67,6 +71,17 @@ public class BffTestHost(TestHostContext context) : TestHost(context, new Uri("h
};
}
protected override void ConfigureApp(IApplicationBuilder app)
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseBff();
base.ConfigureApp(app);
}
public BffHttpClient BrowserClient
{
get => _browserClient ?? throw new InvalidOperationException("Not yet initialized");

View file

@ -142,8 +142,7 @@ public class IdentityServerTestHost : TestHost
ClientSecrets = { new Secret(clientSecret.Sha256()) },
AllowedGrantTypes = GrantTypes.CodeAndClientCredentials,
RedirectUris = [redirectUri.ToString()],
PostLogoutRedirectUris = ["/"], // not implemented
BackChannelLogoutUri = "/", // not implemented
PostLogoutRedirectUris = [new Uri(baseUri, "signout-callback-oidc").ToString()],
AllowOfflineAccess = true,
AllowedScopes = options.Scope.Any()
? options.Scope

View file

@ -33,11 +33,11 @@ public class SimulatedInternet : DelegatingHandler
public T BuildHttpClient<T>(Uri baseUrl) where T : HttpClient, IHttpClient<T>
{
var handler = new RedirectHandler(_outputWriter);
var recirectHandler = new RedirectHandler(_outputWriter);
var cookieContainer = new CookieContainer();
handler.InnerHandler = new CookieHandler(this, cookieContainer);
recirectHandler.InnerHandler = new CookieHandler(this, cookieContainer);
var client = T.Build(handler, cookieContainer);
var client = T.Build(recirectHandler, cookieContainer);
client.BaseAddress = baseUrl;
return client;

View file

@ -88,6 +88,7 @@ public class TestData
opt.Scope.Add("openid");
opt.Scope.Add("profile");
opt.Scope.Add(Scope);
opt.SignedOutRedirectUri = "/";
};
DefaultOpenIdConnectConfiguration(OpenIdConnectOptions);

View file

@ -7,15 +7,14 @@ namespace Duende.Bff.Tests.TestInfra;
public class TestHost(TestHostContext context, Uri baseAddress) : IAsyncDisposable
{
public TestHost(TestHostContext context) : this(context, new("https://server"))
{
}
internal TestDataBuilder Some => context.Some;
public TestData The => context.The;
protected SimulatedInternet Internet => context.Internet;
protected void WriteOutput(string output) => context.WriteOutput(output);
IServiceProvider? _appServices = null!;
public TestServer Server { get; private set; } = null!;

View file