using System.Net; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization.Infrastructure; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Nexus.Api.Controllers; using Nexus.Api.Extensions; using Nexus.Api.Middleware; using Nexus.Api.Security; using Nexus.Api.Services; using Xunit; namespace Nexus.Api.Tests; public sealed class SecurityBoundaryTests { [Fact] public void Authorization_UsesAuthenticatedFallbackPolicy() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Jwt:Key"] = new string('k', 48), ["Jwt:Issuer"] = "nexus-test", ["Jwt:Audience"] = "nexus-test" }) .Build(); var services = new ServiceCollection(); services.AddNexusAuth(configuration); using var provider = services.BuildServiceProvider(); var options = provider.GetRequiredService>().Value; Assert.NotNull(options.FallbackPolicy); Assert.Contains( options.FallbackPolicy!.Requirements, requirement => requirement is DenyAnonymousAuthorizationRequirement); } [Fact] public void DomainControllers_DoNotOptOutOfAuthentication() { Type[] domainControllers = [ typeof(ActivityController), typeof(AgentsController), typeof(CalendarController), typeof(DocsController), typeof(GatewayBridgeController), typeof(IncidentsController), typeof(MemoryController), typeof(RoutingController), typeof(TeamController) ]; foreach (var controller in domainControllers) { Assert.Empty(controller.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true)); Assert.DoesNotContain( controller.GetMethods(), method => method.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true).Length > 0); } } [Fact] public void TaskAutomationEndpoints_DoNotOptOutOfAuthentication() { var board = typeof(TasksController).GetMethod(nameof(TasksController.GetBoard)); var reset = typeof(TasksController).GetMethod(nameof(TasksController.ResetStale)); Assert.NotNull(board); Assert.NotNull(reset); Assert.Empty(board!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true)); Assert.Empty(reset!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true)); } [Fact] public void AgentCommand_RequiresOwnerRole() { var command = typeof(AgentsController).GetMethod(nameof(AgentsController.SendCommand)); Assert.NotNull(command); var authorize = Assert.Single( command!.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true) .OfType()); Assert.Equal("owner", authorize.Roles); } [Theory] [InlineData(nameof(AuthController.Login))] [InlineData(nameof(AuthController.Refresh))] [InlineData(nameof(AuthController.Logout))] [InlineData(nameof(AuthController.AdminResetPassword))] public void PublicAuthBootstrapEndpoints_AreExplicitlyAnonymous(string methodName) { var method = typeof(AuthController).GetMethod(methodName); Assert.NotNull(method); Assert.NotEmpty(method!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true)); } [Theory] [InlineData(nameof(HealthController.Live))] [InlineData(nameof(HealthController.Ready))] [InlineData(nameof(HealthController.Get))] public void PublicHealthEndpoints_AreExplicitlyAnonymous(string methodName) { var method = typeof(HealthController).GetMethod(methodName); Assert.NotNull(method); Assert.NotEmpty(method!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true)); } [Theory] [InlineData(nameof(AuthController.GetMe))] [InlineData(nameof(AuthController.UpdateProfile))] [InlineData(nameof(AuthController.ChangePassword))] public void AccountEndpoints_InheritAuthenticatedFallback(string methodName) { var method = typeof(AuthController).GetMethod(methodName); Assert.NotNull(method); Assert.Empty(method!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true)); } [Fact] public void CookieBackedAuth_AllowsSameOriginBrowserRequests() { var context = new DefaultHttpContext(); context.Request.Scheme = "https"; context.Request.Host = new HostString("nexus.noveria.net"); context.Request.Headers.Origin = "https://nexus.noveria.net"; context.Request.Headers["Sec-Fetch-Site"] = "same-origin"; Assert.True(BrowserRequestOriginGuard.IsAllowed(context.Request)); } [Theory] [InlineData("https://attacker.invalid", "cross-site")] [InlineData("https://attacker.invalid", "")] [InlineData("not-an-origin", "same-origin")] public void CookieBackedAuth_RejectsExplicitCrossSiteRequests(string origin, string fetchSite) { var context = new DefaultHttpContext(); context.Request.Scheme = "https"; context.Request.Host = new HostString("nexus.noveria.net"); context.Request.Headers.Origin = origin; if (fetchSite.Length > 0) context.Request.Headers["Sec-Fetch-Site"] = fetchSite; Assert.False(BrowserRequestOriginGuard.IsAllowed(context.Request)); } [Fact] public async Task AgentIdentityHeader_IsRejectedWithoutVerifiedAuthentication() { await using var fixture = await TaskWorkflowFixture.CreateAsync(); var context = TaskWorkflowFixture.CreateHttpContext(agentId: "iris"); var resolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync( context, fixture.AgentService, fixture.Configuration, CancellationToken.None); Assert.Null(resolution.AgentId); Assert.True(resolution.HeaderProvided); Assert.True(resolution.IsRecognized); Assert.False(resolution.CredentialVerified); Assert.False(resolution.IdentityHintAuthorized); } [Fact] public async Task AgentIdentityHeader_IsAcceptedAsHintAfterVerifiedServiceKey() { await using var fixture = await TaskWorkflowFixture.CreateAsync(); var context = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary { ["X-Agent-Id"] = "iris", ["X-Nexus-Api-Key"] = "test-service-key" }); var resolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync( context, fixture.AgentService, fixture.Configuration, CancellationToken.None); Assert.Equal("iris", resolution.AgentId); Assert.True(resolution.CredentialVerified); Assert.True(resolution.IdentityHintAuthorized); } [Fact] public async Task AgentIdentityHeader_CannotEscalateAnOrdinaryJwtUser() { await using var fixture = await TaskWorkflowFixture.CreateAsync(); var context = TaskWorkflowFixture.CreateHttpContext( agentId: "iris", user: TaskWorkflowFixture.CreateUser("ordinary-user", "user")); var resolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync( context, fixture.AgentService, fixture.Configuration, CancellationToken.None); Assert.Null(resolution.AgentId); Assert.True(resolution.CredentialVerified); Assert.False(resolution.IdentityHintAuthorized); } [Fact] public async Task ApiKeyMiddleware_AuthenticatesMcpRequests() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["NexusApiKey"] = "service-secret" }) .Build(); var services = new ServiceCollection(); services.AddSingleton(configuration); using var provider = services.BuildServiceProvider(); var context = new DefaultHttpContext { RequestServices = provider }; context.Request.Path = "/mcp"; context.Request.Headers["X-Nexus-Api-Key"] = "service-secret"; var nextWasCalled = false; var middleware = new ApiKeyMiddleware(nextContext => { nextWasCalled = true; Assert.True(nextContext.User.Identity?.IsAuthenticated); Assert.True(nextContext.User.IsInRole("Service")); return Task.CompletedTask; }); await middleware.InvokeAsync(context); Assert.True(nextWasCalled); } [Fact] public async Task ApiKeyMiddleware_DoesNotAuthenticateMcpFromAgentHeaderAlone() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["NexusApiKey"] = "service-secret" }) .Build(); var services = new ServiceCollection(); services.AddSingleton(configuration); using var provider = services.BuildServiceProvider(); var context = new DefaultHttpContext { RequestServices = provider }; context.Request.Path = "/mcp"; context.Request.Headers["X-Agent-Id"] = "iris"; var middleware = new ApiKeyMiddleware(nextContext => { Assert.False(nextContext.User.Identity?.IsAuthenticated); return Task.CompletedTask; }); await middleware.InvokeAsync(context); } [Fact] public void ForwardedHeaders_TrustOnlyDefaultsAndConfiguredProxyRanges() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["ForwardedHeaders:ForwardLimit"] = "2", ["ForwardedHeaders:KnownProxies:0"] = "10.10.0.12", ["ForwardedHeaders:KnownNetworks:0"] = "10.20.0.0/24" }) .Build(); var services = new ServiceCollection(); services.AddNexusForwardedHeaders(configuration); using var provider = services.BuildServiceProvider(); var options = provider.GetRequiredService>().Value; Assert.Equal(2, options.ForwardLimit); Assert.Contains(IPAddress.Parse("10.10.0.12"), options.KnownProxies); Assert.Contains(options.KnownIPNetworks, network => network.ToString() == "10.20.0.0/24"); Assert.DoesNotContain(options.KnownIPNetworks, network => network.ToString() == "0.0.0.0/0"); } }