Initial commit (history squashed)
This commit is contained in:
		@@ -0,0 +1,44 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
 | 
			
		||||
using Microsoft.Extensions.Configuration;
 | 
			
		||||
using Microsoft.Extensions.DependencyInjection;
 | 
			
		||||
using Microsoft.Extensions.Options;
 | 
			
		||||
using StellaOps.Auth.Abstractions;
 | 
			
		||||
using StellaOps.Auth.ServerIntegration;
 | 
			
		||||
using Xunit;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Auth.ServerIntegration.Tests;
 | 
			
		||||
 | 
			
		||||
public class ServiceCollectionExtensionsTests
 | 
			
		||||
{
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public void AddStellaOpsResourceServerAuthentication_ConfiguresJwtBearer()
 | 
			
		||||
    {
 | 
			
		||||
        var configuration = new ConfigurationBuilder()
 | 
			
		||||
            .AddInMemoryCollection(new Dictionary<string, string?>
 | 
			
		||||
            {
 | 
			
		||||
                ["Authority:ResourceServer:Authority"] = "https://authority.example",
 | 
			
		||||
                ["Authority:ResourceServer:Audiences:0"] = "api://feedser",
 | 
			
		||||
                ["Authority:ResourceServer:RequiredScopes:0"] = "feedser.jobs.trigger",
 | 
			
		||||
                ["Authority:ResourceServer:BypassNetworks:0"] = "127.0.0.1/32"
 | 
			
		||||
            })
 | 
			
		||||
            .Build();
 | 
			
		||||
 | 
			
		||||
        var services = new ServiceCollection();
 | 
			
		||||
        services.AddLogging();
 | 
			
		||||
        services.AddStellaOpsResourceServerAuthentication(configuration);
 | 
			
		||||
 | 
			
		||||
        using var provider = services.BuildServiceProvider();
 | 
			
		||||
 | 
			
		||||
        var resourceOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsResourceServerOptions>>().CurrentValue;
 | 
			
		||||
        var jwtOptions = provider.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>().Get(StellaOpsAuthenticationDefaults.AuthenticationScheme);
 | 
			
		||||
 | 
			
		||||
        Assert.NotNull(jwtOptions.Authority);
 | 
			
		||||
        Assert.Equal(new Uri("https://authority.example/"), new Uri(jwtOptions.Authority!));
 | 
			
		||||
        Assert.True(jwtOptions.TokenValidationParameters.ValidateAudience);
 | 
			
		||||
        Assert.Contains("api://feedser", jwtOptions.TokenValidationParameters.ValidAudiences);
 | 
			
		||||
        Assert.Equal(TimeSpan.FromSeconds(60), jwtOptions.TokenValidationParameters.ClockSkew);
 | 
			
		||||
        Assert.Equal(new[] { "feedser.jobs.trigger" }, resourceOptions.NormalizedScopes);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,11 @@
 | 
			
		||||
<Project Sdk="Microsoft.NET.Sdk">
 | 
			
		||||
  <PropertyGroup>
 | 
			
		||||
    <TargetFramework>net10.0</TargetFramework>
 | 
			
		||||
    <ImplicitUsings>enable</ImplicitUsings>
 | 
			
		||||
    <Nullable>enable</Nullable>
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <ProjectReference Include="..\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
 | 
			
		||||
    <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
</Project>
 | 
			
		||||
@@ -0,0 +1,50 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Net;
 | 
			
		||||
using StellaOps.Auth.ServerIntegration;
 | 
			
		||||
using Xunit;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Auth.ServerIntegration.Tests;
 | 
			
		||||
 | 
			
		||||
public class StellaOpsResourceServerOptionsTests
 | 
			
		||||
{
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public void Validate_NormalisesCollections()
 | 
			
		||||
    {
 | 
			
		||||
        var options = new StellaOpsResourceServerOptions
 | 
			
		||||
        {
 | 
			
		||||
            Authority = "https://authority.stella-ops.test",
 | 
			
		||||
            BackchannelTimeout = TimeSpan.FromSeconds(10),
 | 
			
		||||
            TokenClockSkew = TimeSpan.FromSeconds(30)
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        options.Audiences.Add(" api://feedser ");
 | 
			
		||||
        options.Audiences.Add("api://feedser");
 | 
			
		||||
        options.Audiences.Add("api://feedser-admin");
 | 
			
		||||
 | 
			
		||||
        options.RequiredScopes.Add(" Feedser.Jobs.Trigger ");
 | 
			
		||||
        options.RequiredScopes.Add("feedser.jobs.trigger");
 | 
			
		||||
        options.RequiredScopes.Add("AUTHORITY.USERS.MANAGE");
 | 
			
		||||
 | 
			
		||||
        options.BypassNetworks.Add("127.0.0.1/32");
 | 
			
		||||
        options.BypassNetworks.Add(" 127.0.0.1/32 ");
 | 
			
		||||
        options.BypassNetworks.Add("::1/128");
 | 
			
		||||
 | 
			
		||||
        options.Validate();
 | 
			
		||||
 | 
			
		||||
        Assert.Equal(new Uri("https://authority.stella-ops.test"), options.AuthorityUri);
 | 
			
		||||
        Assert.Equal(new[] { "api://feedser", "api://feedser-admin" }, options.Audiences);
 | 
			
		||||
        Assert.Equal(new[] { "authority.users.manage", "feedser.jobs.trigger" }, options.NormalizedScopes);
 | 
			
		||||
        Assert.True(options.BypassMatcher.IsAllowed(IPAddress.Parse("127.0.0.1")));
 | 
			
		||||
        Assert.True(options.BypassMatcher.IsAllowed(IPAddress.IPv6Loopback));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public void Validate_Throws_When_AuthorityMissing()
 | 
			
		||||
    {
 | 
			
		||||
        var options = new StellaOpsResourceServerOptions();
 | 
			
		||||
 | 
			
		||||
        var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
 | 
			
		||||
 | 
			
		||||
        Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,123 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Net;
 | 
			
		||||
using System.Security.Claims;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.AspNetCore.Authorization;
 | 
			
		||||
using Microsoft.AspNetCore.Http;
 | 
			
		||||
using Microsoft.Extensions.Logging.Abstractions;
 | 
			
		||||
using Microsoft.Extensions.Options;
 | 
			
		||||
using StellaOps.Auth.Abstractions;
 | 
			
		||||
using StellaOps.Auth.ServerIntegration;
 | 
			
		||||
using Xunit;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Auth.ServerIntegration.Tests;
 | 
			
		||||
 | 
			
		||||
public class StellaOpsScopeAuthorizationHandlerTests
 | 
			
		||||
{
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public async Task HandleRequirement_Succeeds_WhenScopePresent()
 | 
			
		||||
    {
 | 
			
		||||
        var optionsMonitor = CreateOptionsMonitor(options =>
 | 
			
		||||
        {
 | 
			
		||||
            options.Authority = "https://authority.example";
 | 
			
		||||
            options.Validate();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
 | 
			
		||||
        var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.FeedserJobsTrigger });
 | 
			
		||||
        var principal = new StellaOpsPrincipalBuilder()
 | 
			
		||||
            .WithSubject("user-1")
 | 
			
		||||
            .WithScopes(new[] { StellaOpsScopes.FeedserJobsTrigger })
 | 
			
		||||
            .Build();
 | 
			
		||||
 | 
			
		||||
        var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
 | 
			
		||||
 | 
			
		||||
        await handler.HandleAsync(context);
 | 
			
		||||
 | 
			
		||||
        Assert.True(context.HasSucceeded);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public async Task HandleRequirement_Succeeds_WhenBypassNetworkMatches()
 | 
			
		||||
    {
 | 
			
		||||
        var optionsMonitor = CreateOptionsMonitor(options =>
 | 
			
		||||
        {
 | 
			
		||||
            options.Authority = "https://authority.example";
 | 
			
		||||
            options.BypassNetworks.Add("127.0.0.1/32");
 | 
			
		||||
            options.Validate();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("127.0.0.1"));
 | 
			
		||||
        var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.FeedserJobsTrigger });
 | 
			
		||||
        var principal = new ClaimsPrincipal(new ClaimsIdentity());
 | 
			
		||||
        var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
 | 
			
		||||
 | 
			
		||||
        await handler.HandleAsync(context);
 | 
			
		||||
 | 
			
		||||
        Assert.True(context.HasSucceeded);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public async Task HandleRequirement_Fails_WhenScopeMissingAndNoBypass()
 | 
			
		||||
    {
 | 
			
		||||
        var optionsMonitor = CreateOptionsMonitor(options =>
 | 
			
		||||
        {
 | 
			
		||||
            options.Authority = "https://authority.example";
 | 
			
		||||
            options.Validate();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("203.0.113.10"));
 | 
			
		||||
        var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.FeedserJobsTrigger });
 | 
			
		||||
        var principal = new ClaimsPrincipal(new ClaimsIdentity());
 | 
			
		||||
        var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
 | 
			
		||||
 | 
			
		||||
        await handler.HandleAsync(context);
 | 
			
		||||
 | 
			
		||||
        Assert.False(context.HasSucceeded);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static (StellaOpsScopeAuthorizationHandler Handler, IHttpContextAccessor Accessor) CreateHandler(IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor, IPAddress remoteAddress)
 | 
			
		||||
    {
 | 
			
		||||
        var accessor = new HttpContextAccessor();
 | 
			
		||||
        var httpContext = new DefaultHttpContext();
 | 
			
		||||
        httpContext.Connection.RemoteIpAddress = remoteAddress;
 | 
			
		||||
        accessor.HttpContext = httpContext;
 | 
			
		||||
 | 
			
		||||
        var bypassEvaluator = new StellaOpsBypassEvaluator(optionsMonitor, NullLogger<StellaOpsBypassEvaluator>.Instance);
 | 
			
		||||
 | 
			
		||||
        var handler = new StellaOpsScopeAuthorizationHandler(
 | 
			
		||||
            accessor,
 | 
			
		||||
            bypassEvaluator,
 | 
			
		||||
            NullLogger<StellaOpsScopeAuthorizationHandler>.Instance);
 | 
			
		||||
        return (handler, accessor);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IOptionsMonitor<StellaOpsResourceServerOptions> CreateOptionsMonitor(Action<StellaOpsResourceServerOptions> configure)
 | 
			
		||||
        => new TestOptionsMonitor<StellaOpsResourceServerOptions>(configure);
 | 
			
		||||
 | 
			
		||||
    private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
 | 
			
		||||
        where TOptions : class, new()
 | 
			
		||||
    {
 | 
			
		||||
        private readonly TOptions value;
 | 
			
		||||
 | 
			
		||||
        public TestOptionsMonitor(Action<TOptions> configure)
 | 
			
		||||
        {
 | 
			
		||||
            value = new TOptions();
 | 
			
		||||
            configure(value);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public TOptions CurrentValue => value;
 | 
			
		||||
 | 
			
		||||
        public TOptions Get(string? name) => value;
 | 
			
		||||
 | 
			
		||||
        public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
 | 
			
		||||
 | 
			
		||||
        private sealed class NullDisposable : IDisposable
 | 
			
		||||
        {
 | 
			
		||||
            public static NullDisposable Instance { get; } = new();
 | 
			
		||||
            public void Dispose()
 | 
			
		||||
            {
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user