From f284444bdac99f2e6bd8b8d55108ef5dac001818 Mon Sep 17 00:00:00 2001 From: steve wojciechowski Date: Thu, 12 Feb 2026 07:52:58 +0100 Subject: [PATCH 1/3] Add host policy support to AuthService and new unit tests Refactored AuthenticateHostAsync to support host authorization policies using IAuthorizationService. Added comprehensive unit tests for host mode authentication, including policy checks and multiple identity scenarios. --- .../Authentication/AuthService.cs | 19 ++- tests/TickerQ.Tests/AuthServiceHostTests.cs | 149 ++++++++++++++++++ 2 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 tests/TickerQ.Tests/AuthServiceHostTests.cs diff --git a/src/TickerQ.Dashboard/Authentication/AuthService.cs b/src/TickerQ.Dashboard/Authentication/AuthService.cs index f3695a78..1df332a4 100644 --- a/src/TickerQ.Dashboard/Authentication/AuthService.cs +++ b/src/TickerQ.Dashboard/Authentication/AuthService.cs @@ -3,6 +3,7 @@ using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace TickerQ.Dashboard.Authentication; @@ -157,15 +158,27 @@ private Task AuthenticateCustomAsync(string authHeader) } } - private Task AuthenticateHostAsync(HttpContext context) + private async Task AuthenticateHostAsync(HttpContext context) { + if (!string.IsNullOrEmpty(_config.HostAuthorizationPolicy)) + { + var authorizationService = context.RequestServices.GetRequiredService(); + var authResult = await authorizationService.AuthorizeAsync(context.User, null, _config.HostAuthorizationPolicy); + if (!authResult.Succeeded) + { + return AuthResult.Failure("Host authorization policy not satisfied"); + } + + return AuthResult.Success(context.User.Identity?.Name ?? "host-user"); + } + // Delegate to host application's authentication if (context.User?.Identity?.IsAuthenticated == true) { var username = context.User.Identity.Name ?? "host-user"; - return Task.FromResult(AuthResult.Success(username)); + return AuthResult.Success(username); } - return Task.FromResult(AuthResult.Failure("Host authentication required")); + return AuthResult.Failure("Host authentication required"); } } diff --git a/tests/TickerQ.Tests/AuthServiceHostTests.cs b/tests/TickerQ.Tests/AuthServiceHostTests.cs new file mode 100644 index 00000000..b38120aa --- /dev/null +++ b/tests/TickerQ.Tests/AuthServiceHostTests.cs @@ -0,0 +1,149 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using System.Security.Claims; +using TickerQ.Dashboard.Authentication; + +namespace TickerQ.Tests; + +public class AuthServiceHostTests +{ + [Fact] + public async Task AuthenticateAsync_HostMode_UserAuthenticated_WithName_ReturnsSuccess() + { + var config = new AuthConfig { Mode = AuthMode.Host }; + var logger = Substitute.For>(); + var svc = new AuthService(config, logger); + + var context = new DefaultHttpContext(); + var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "alice") }, "TestAuth"); + context.User = new ClaimsPrincipal(identity); + + var result = await svc.AuthenticateAsync(context); + + result.IsAuthenticated.Should().BeTrue(); + result.Username.Should().Be("alice"); + } + + [Fact] + public async Task AuthenticateAsync_HostMode_UserAuthenticated_WithoutName_ReturnsHostUser() + { + var config = new AuthConfig { Mode = AuthMode.Host }; + var logger = Substitute.For>(); + var svc = new AuthService(config, logger); + + var context = new DefaultHttpContext(); + // Create an authenticated identity without a name + var identity = new ClaimsIdentity(System.Array.Empty(), "TestAuth"); + context.User = new ClaimsPrincipal(identity); + + var result = await svc.AuthenticateAsync(context); + + result.IsAuthenticated.Should().BeTrue(); + result.Username.Should().Be("host-user"); + } + + [Fact] + public async Task AuthenticateAsync_HostMode_UserNotAuthenticated_ReturnsFailure() + { + var config = new AuthConfig { Mode = AuthMode.Host }; + var logger = Substitute.For>(); + var svc = new AuthService(config, logger); + + var context = new DefaultHttpContext(); + // Unauthenticated identity + context.User = new ClaimsPrincipal(new ClaimsIdentity()); + + var result = await svc.AuthenticateAsync(context); + + result.IsAuthenticated.Should().BeFalse(); + result.ErrorMessage.Should().Contain("Host authentication required"); + } + + [Fact] + public async Task AuthenticateAsync_HostMode_WithPolicy_AuthorizationFails_But_DefaultIdentityAuthenticates_ShouldFail() + { + var config = new AuthConfig { Mode = AuthMode.Host, HostAuthorizationPolicy = "MyPolicy" }; + var logger = Substitute.For>(); + var svc = new AuthService(config, logger); + + var context = new DefaultHttpContext(); + var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "alice") }, "TestAuth"); + context.User = new ClaimsPrincipal(identity); + + // Mock IAuthorizationService to return failure for the policy + var authorizationService = Substitute.For(); + authorizationService.AuthorizeAsync(Arg.Any(), Arg.Any(), "MyPolicy") + .Returns(Task.FromResult(AuthorizationResult.Failed())); + + var services = new ServiceCollection(); + services.AddSingleton(authorizationService); + context.RequestServices = services.BuildServiceProvider(); + + var result = await svc.AuthenticateAsync(context); + + // Expected: authentication should fail because the policy was not satisfied. + result.IsAuthenticated.Should().BeFalse(); + } + + [Fact] + public async Task AuthenticateAsync_HostMode_WithPolicy_Authorized_ReturnsSuccess() + { + var config = new AuthConfig { Mode = AuthMode.Host, HostAuthorizationPolicy = "MyPolicy" }; + var logger = Substitute.For>(); + var svc = new AuthService(config, logger); + + var context = new DefaultHttpContext(); + var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "alice") }, "TestAuth"); + context.User = new ClaimsPrincipal(identity); + + // Mock IAuthorizationService to return success for the given policy + var authorizationService = Substitute.For(); + authorizationService.AuthorizeAsync(Arg.Any(), Arg.Any(), "MyPolicy") + .Returns(Task.FromResult(AuthorizationResult.Success())); + + var services = new ServiceCollection(); + services.AddSingleton(authorizationService); + context.RequestServices = services.BuildServiceProvider(); + + var result = await svc.AuthenticateAsync(context); + + result.IsAuthenticated.Should().BeTrue(); + result.Username.Should().Be("alice"); + } + + [Fact] + public async Task AuthenticateAsync_HostMode_WithMultipleIdentities_SecondaryIdentitySatisfiesPolicy_ReturnsSuccessWithSecondaryName() + { + var config = new AuthConfig { Mode = AuthMode.Host, HostAuthorizationPolicy = "MyPolicy" }; + var logger = Substitute.For>(); + var svc = new AuthService(config, logger); + + var context = new DefaultHttpContext(); + + // First identity is unauthenticated (default) + var id1 = new ClaimsIdentity(); + + // Second identity is authenticated + var id2 = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "second-identity") }, "AuthType"); + + context.User = new ClaimsPrincipal(new[] { id1, id2 }); + + // Mock IAuthorizationService to return success when invoked with the policy + var authorizationService = Substitute.For(); + authorizationService.AuthorizeAsync(Arg.Any(), Arg.Any(), "MyPolicy") + .Returns(Task.FromResult(AuthorizationResult.Success())); + + var services = new ServiceCollection(); + services.AddSingleton(authorizationService); + context.RequestServices = services.BuildServiceProvider(); + + var result = await svc.AuthenticateAsync(context); + + result.IsAuthenticated.Should().BeTrue(); + result.Username.Should().Be("host-user"); + } +} From 8bdcd26ee955cd06517b49d53c29fdfe92f46e12 Mon Sep 17 00:00:00 2001 From: steve wojciechowski Date: Thu, 12 Feb 2026 08:02:49 +0100 Subject: [PATCH 2/3] Rename test method for clarity in AuthServiceHostTests Renamed AuthenticateAsync_HostMode_WithMultipleIdentities_SecondaryIdentitySatisfiesPolicy_ReturnsSuccessWithSecondaryName to ...ReturnsSuccess for improved clarity and consistency. No changes to the test logic or implementation. --- tests/TickerQ.Tests/AuthServiceHostTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/TickerQ.Tests/AuthServiceHostTests.cs b/tests/TickerQ.Tests/AuthServiceHostTests.cs index b38120aa..98698919 100644 --- a/tests/TickerQ.Tests/AuthServiceHostTests.cs +++ b/tests/TickerQ.Tests/AuthServiceHostTests.cs @@ -116,7 +116,7 @@ public async Task AuthenticateAsync_HostMode_WithPolicy_Authorized_ReturnsSucces } [Fact] - public async Task AuthenticateAsync_HostMode_WithMultipleIdentities_SecondaryIdentitySatisfiesPolicy_ReturnsSuccessWithSecondaryName() + public async Task AuthenticateAsync_HostMode_WithMultipleIdentities_SecondaryIdentitySatisfiesPolicy_ReturnsSuccess() { var config = new AuthConfig { Mode = AuthMode.Host, HostAuthorizationPolicy = "MyPolicy" }; var logger = Substitute.For>(); From 05780344b361dd95eb2085e1e2085d52c8aa8bc6 Mon Sep 17 00:00:00 2001 From: steve wojciechowski Date: Thu, 12 Feb 2026 08:03:54 +0100 Subject: [PATCH 3/3] Update AuthService to pass HttpContext as auth resource Changed the resource parameter in AuthorizeAsync from null to the current HttpContext. This enables authorization policies to access request-specific context during evaluation, allowing for more flexible and informed authorization decisions. --- src/TickerQ.Dashboard/Authentication/AuthService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TickerQ.Dashboard/Authentication/AuthService.cs b/src/TickerQ.Dashboard/Authentication/AuthService.cs index 1df332a4..b3f0af89 100644 --- a/src/TickerQ.Dashboard/Authentication/AuthService.cs +++ b/src/TickerQ.Dashboard/Authentication/AuthService.cs @@ -163,7 +163,7 @@ private async Task AuthenticateHostAsync(HttpContext context) if (!string.IsNullOrEmpty(_config.HostAuthorizationPolicy)) { var authorizationService = context.RequestServices.GetRequiredService(); - var authResult = await authorizationService.AuthorizeAsync(context.User, null, _config.HostAuthorizationPolicy); + var authResult = await authorizationService.AuthorizeAsync(context.User, context, _config.HostAuthorizationPolicy); if (!authResult.Succeeded) { return AuthResult.Failure("Host authorization policy not satisfied");