using System.Net; using System.Net.Http.Json; using System.Text.Encodings.Web; using Elsa.Common.Multitenancy; using Elsa.ExternalAuthentication.Contracts; using Elsa.ExternalAuthentication.Features; using Elsa.ExternalAuthentication.IntegrationTests.Fixtures; using Elsa.ExternalAuthentication.Models; using Elsa.ExternalAuthentication.Services; using FastEndpoints; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NSubstitute; namespace Elsa.ExternalAuthentication.IntegrationTests.Broker; /// /// The upstream-logout continuation is reached by a top-level browser navigation, after the caller's /// Elsa session has already been revoked. These tests pin that it answers without credentials, and that /// its sibling Logout still does not. /// [Collection(nameof(EndpointSecurityCollection))] public class LogoutAuthorizationTests : IAsyncLifetime { private WebApplication? _app; private HttpClient? _client; private IExternalAuthenticationBroker _broker = null!; private bool _wasSecurityEnabled; public async Task InitializeAsync() { _wasSecurityEnabled = EndpointSecurityOptions.SecurityIsEnabled; EndpointSecurityOptions.SecurityIsEnabled = true; var builder = WebApplication.CreateSlimBuilder(); builder.WebHost.UseTestServer(); builder.Services.AddFastEndpoints(options => { options.Assemblies = [typeof(ExternalAuthenticationFeature).Assembly]; options.Filter = endpoint => endpoint.Namespace == "Elsa.ExternalAuthentication.Endpoints.Broker"; }); _broker = Substitute.For(); _broker.ContinueLogoutAsync("handle-a", Arg.Any()) .Returns(ValueTask.FromResult(BrokerLogoutResult.Navigate(new Uri("https://idp.example/end-session")))); var tenant = Substitute.For(); tenant.TenantId.Returns("tenant-a"); builder.Services.AddSingleton(_broker); builder.Services.AddSingleton(tenant); builder.Services.AddRateLimiter(_ => { }); // A scheme that never authenticates, standing in for a browser navigation that carries no // Authorization header. No principal is injected: that is the condition under test. builder.Services .AddAuthentication(NoCredentialsHandler.SchemeName) .AddScheme(NoCredentialsHandler.SchemeName, _ => { }); builder.Services.AddAuthorization(); _app = builder.Build(); _app.UseAuthentication(); _app.UseAuthorization(); _app.UseFastEndpoints(); await _app.StartAsync(); _client = _app.GetTestClient(); } public async Task DisposeAsync() { EndpointSecurityOptions.SecurityIsEnabled = _wasSecurityEnabled; _client?.Dispose(); if (_app is not null) { await _app.StopAsync(); await _app.DisposeAsync(); } } [Fact] public async Task ContinueLogoutRedirectsUpstreamWithoutCredentials() { var response = await _client!.GetAsync("/external-authentication/logout/continue/handle-a"); Assert.NotEqual(HttpStatusCode.Unauthorized, response.StatusCode); Assert.Equal(HttpStatusCode.Found, response.StatusCode); Assert.Equal("https://idp.example/end-session", response.Headers.Location?.AbsoluteUri); await _broker.Received(1).ContinueLogoutAsync("handle-a", Arg.Any()); } [Fact] public async Task ContinueLogoutRejectsAnUnknownHandleWithoutCredentials() { _broker.ContinueLogoutAsync("unknown", Arg.Any()) .Returns(ValueTask.FromResult(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidRequest)))); var response = await _client!.GetAsync("/external-authentication/logout/continue/unknown"); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task LogoutStillRequiresAnAuthenticatedCaller() { var response = await _client!.PostAsJsonAsync("/external-authentication/logout", new { clientId = "studio", postLogoutRedirectUri = "https://studio.example/logout-callback", mode = "local" }); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); await _broker.DidNotReceive().LogoutAsync(Arg.Any(), Arg.Any(), Arg.Any()); } private sealed class NoCredentialsHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder) { public const string SchemeName = "NoCredentials"; protected override Task HandleAuthenticateAsync() => Task.FromResult(AuthenticateResult.NoResult()); } }