diff --git a/src/TickerQ.Dashboard/Authentication/AuthService.cs b/src/TickerQ.Dashboard/Authentication/AuthService.cs index f3695a78..b3f0af89 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, context, _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..98698919 --- /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_ReturnsSuccess() + { + 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"); + } +}