Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,9 @@
# StellaOps.Auth.ServerIntegration
ASP.NET Core helpers that enable resource servers to authenticate with **StellaOps Authority**:
- `AddStellaOpsResourceServerAuthentication` extension for JWT bearer + scope policies.
- Network bypass mask evaluation for on-host automation.
- Consistent `ProblemDetails` responses and policy helpers shared with Concelier/Backend services.
Pair this package with `StellaOps.Auth.Abstractions` and `StellaOps.Auth.Client` for end-to-end Authority integration.

View File

@@ -0,0 +1,92 @@
using System;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Dependency injection helpers for configuring StellaOps resource server authentication.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers JWT bearer authentication and related authorisation helpers using the provided configuration section.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">Application configuration.</param>
/// <param name="configurationSection">
/// Optional configuration section path. Defaults to <c>Authority:ResourceServer</c>. Provide <c>null</c> to skip binding.
/// </param>
/// <param name="configure">Optional callback allowing additional mutation of <see cref="StellaOpsResourceServerOptions"/>.</param>
public static IServiceCollection AddStellaOpsResourceServerAuthentication(
this IServiceCollection services,
IConfiguration configuration,
string? configurationSection = "Authority:ResourceServer",
Action<StellaOpsResourceServerOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.AddHttpContextAccessor();
services.AddAuthorization();
services.AddStellaOpsScopeHandler();
services.TryAddSingleton<StellaOpsBypassEvaluator>();
services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
services.AddHttpClient(StellaOpsAuthorityConfigurationManager.HttpClientName);
services.AddSingleton<StellaOpsAuthorityConfigurationManager>();
var optionsBuilder = services.AddOptions<StellaOpsResourceServerOptions>();
if (!string.IsNullOrWhiteSpace(configurationSection))
{
optionsBuilder.Bind(configuration.GetSection(configurationSection));
}
if (configure is not null)
{
optionsBuilder.Configure(configure);
}
optionsBuilder.PostConfigure(static options => options.Validate());
var authenticationBuilder = services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme ??= StellaOpsAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme ??= StellaOpsAuthenticationDefaults.AuthenticationScheme;
});
authenticationBuilder.AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme);
services.AddOptions<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme)
.Configure<IServiceProvider, IOptionsMonitor<StellaOpsResourceServerOptions>>((jwt, provider, monitor) =>
{
var resourceOptions = monitor.CurrentValue;
jwt.Authority = resourceOptions.AuthorityUri.ToString();
if (!string.IsNullOrWhiteSpace(resourceOptions.MetadataAddress))
{
jwt.MetadataAddress = resourceOptions.MetadataAddress;
}
jwt.RequireHttpsMetadata = resourceOptions.RequireHttpsMetadata;
jwt.BackchannelTimeout = resourceOptions.BackchannelTimeout;
jwt.MapInboundClaims = false;
jwt.SaveToken = false;
jwt.TokenValidationParameters ??= new TokenValidationParameters();
jwt.TokenValidationParameters.ValidIssuer = resourceOptions.AuthorityUri.ToString();
jwt.TokenValidationParameters.ValidateAudience = resourceOptions.Audiences.Count > 0;
jwt.TokenValidationParameters.ValidAudiences = resourceOptions.Audiences;
jwt.TokenValidationParameters.ClockSkew = resourceOptions.TokenClockSkew;
jwt.TokenValidationParameters.NameClaimType = ClaimTypes.Name;
jwt.TokenValidationParameters.RoleClaimType = ClaimTypes.Role;
jwt.ConfigurationManager = provider.GetRequiredService<StellaOpsAuthorityConfigurationManager>();
});
return services;
}
}

View File

@@ -0,0 +1,47 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<PackageId>StellaOps.Auth.ServerIntegration</PackageId>
<Description>ASP.NET server integration helpers for StellaOps Authority, including JWT validation and bypass masks.</Description>
<Authors>StellaOps</Authors>
<Company>StellaOps</Company>
<PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression>
<PackageProjectUrl>https://stella-ops.org</PackageProjectUrl>
<RepositoryUrl>https://git.stella-ops.org/stella-ops.org/git.stella-ops.org</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PackageTags>stellaops;authentication;authority;aspnet</PackageTags>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
<PackageReadmeFile>README.NuGet.md</PackageReadmeFile>
<VersionPrefix>1.0.0-preview.1</VersionPrefix>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0-rc.1.25451.107" />
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<None Include="README.NuGet.md" Pack="true" PackagePath="" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>StellaOps.Auth.ServerIntegration.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,116 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Cached configuration manager for StellaOps Authority metadata and JWKS.
/// </summary>
internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationManager<OpenIdConnectConfiguration>
{
internal const string HttpClientName = "StellaOps.Auth.ServerIntegration.Metadata";
private readonly IHttpClientFactory httpClientFactory;
private readonly IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor;
private readonly TimeProvider timeProvider;
private readonly ILogger<StellaOpsAuthorityConfigurationManager> logger;
private readonly SemaphoreSlim refreshLock = new(1, 1);
private OpenIdConnectConfiguration? cachedConfiguration;
private DateTimeOffset cacheExpiresAt;
public StellaOpsAuthorityConfigurationManager(
IHttpClientFactory httpClientFactory,
IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor,
TimeProvider timeProvider,
ILogger<StellaOpsAuthorityConfigurationManager> logger)
{
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<OpenIdConnectConfiguration> GetConfigurationAsync(CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
var current = Volatile.Read(ref cachedConfiguration);
if (current is not null && now < cacheExpiresAt)
{
return current;
}
await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (cachedConfiguration is not null && now < cacheExpiresAt)
{
return cachedConfiguration;
}
var options = optionsMonitor.CurrentValue;
var metadataAddress = ResolveMetadataAddress(options);
var httpClient = httpClientFactory.CreateClient(HttpClientName);
httpClient.Timeout = options.BackchannelTimeout;
var retriever = new HttpDocumentRetriever(httpClient)
{
RequireHttps = options.RequireHttpsMetadata
};
logger.LogDebug("Fetching OpenID Connect configuration from {MetadataAddress}.", metadataAddress);
var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(metadataAddress, retriever, cancellationToken).ConfigureAwait(false);
configuration.Issuer ??= options.AuthorityUri.ToString();
if (!string.IsNullOrWhiteSpace(configuration.JwksUri))
{
logger.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksUri);
var jwksDocument = await retriever.GetDocumentAsync(configuration.JwksUri, cancellationToken).ConfigureAwait(false);
var jsonWebKeySet = new JsonWebKeySet(jwksDocument);
configuration.SigningKeys.Clear();
foreach (JsonWebKey key in jsonWebKeySet.Keys)
{
configuration.SigningKeys.Add(key);
}
}
cachedConfiguration = configuration;
cacheExpiresAt = now + options.MetadataCacheLifetime;
return configuration;
}
finally
{
refreshLock.Release();
}
}
public void RequestRefresh()
{
Volatile.Write(ref cachedConfiguration, null);
cacheExpiresAt = DateTimeOffset.MinValue;
}
private static string ResolveMetadataAddress(StellaOpsResourceServerOptions options)
{
if (!string.IsNullOrWhiteSpace(options.MetadataAddress))
{
return options.MetadataAddress;
}
var authority = options.AuthorityUri;
if (!authority.AbsoluteUri.EndsWith("/", StringComparison.Ordinal))
{
authority = new Uri(authority.AbsoluteUri + "/", UriKind.Absolute);
}
return new Uri(authority, ".well-known/openid-configuration").AbsoluteUri;
}
}

View File

@@ -0,0 +1,56 @@
using System;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Extension methods for configuring StellaOps authorisation policies.
/// </summary>
public static class StellaOpsAuthorizationPolicyBuilderExtensions
{
/// <summary>
/// Requires the specified scopes using the StellaOps scope requirement.
/// </summary>
public static AuthorizationPolicyBuilder RequireStellaOpsScopes(
this AuthorizationPolicyBuilder builder,
params string[] scopes)
{
ArgumentNullException.ThrowIfNull(builder);
var requirement = new StellaOpsScopeRequirement(scopes);
builder.AddRequirements(requirement);
builder.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
return builder;
}
/// <summary>
/// Registers a named policy that enforces the provided scopes.
/// </summary>
public static void AddStellaOpsScopePolicy(
this AuthorizationOptions options,
string policyName,
params string[] scopes)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(policyName);
options.AddPolicy(policyName, policy =>
{
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
});
}
/// <summary>
/// Adds the scope handler to the DI container.
/// </summary>
public static IServiceCollection AddStellaOpsScopeHandler(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<IAuthorizationHandler, StellaOpsScopeAuthorizationHandler>();
return services;
}
}

View File

@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Evaluates whether a request qualifies for network-based bypass.
/// </summary>
public sealed class StellaOpsBypassEvaluator
{
private readonly IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor;
private readonly ILogger<StellaOpsBypassEvaluator> logger;
public StellaOpsBypassEvaluator(
IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor,
ILogger<StellaOpsBypassEvaluator> logger)
{
this.optionsMonitor = optionsMonitor;
this.logger = logger;
}
public bool ShouldBypass(HttpContext context, IReadOnlyCollection<string> requiredScopes)
{
ArgumentNullException.ThrowIfNull(context);
var options = optionsMonitor.CurrentValue;
var matcher = options.BypassMatcher;
if (matcher.IsEmpty)
{
return false;
}
var remoteAddress = context.Connection.RemoteIpAddress;
if (remoteAddress is null)
{
logger.LogDebug("Bypass skipped because remote IP address is unavailable.");
return false;
}
if (!matcher.IsAllowed(remoteAddress))
{
return false;
}
if (context.Request.Headers.ContainsKey("Authorization"))
{
logger.LogDebug("Bypass skipped because Authorization header is present for {RemoteIp}.", remoteAddress);
return false;
}
logger.LogInformation(
"Granting StellaOps bypass for remote {RemoteIp}; required scopes {RequiredScopes}.",
remoteAddress,
string.Join(", ", requiredScopes));
return true;
}
}

View File

@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Options controlling StellaOps resource server authentication.
/// </summary>
public sealed class StellaOpsResourceServerOptions
{
private readonly List<string> audiences = new();
private readonly List<string> requiredScopes = new();
private readonly List<string> requiredTenants = new();
private readonly List<string> bypassNetworks = new();
/// <summary>
/// Gets or sets the Authority (issuer) URL that exposes OpenID discovery.
/// </summary>
public string Authority { get; set; } = string.Empty;
/// <summary>
/// Optional explicit OpenID Connect metadata address.
/// </summary>
public string? MetadataAddress { get; set; }
/// <summary>
/// Audiences accepted by the resource server (validated against the <c>aud</c> claim).
/// </summary>
public IList<string> Audiences => audiences;
/// <summary>
/// Scopes enforced by default authorisation policies.
/// </summary>
public IList<string> RequiredScopes => requiredScopes;
/// <summary>
/// Tenants permitted to access the resource server (empty list disables tenant checks).
/// </summary>
public IList<string> RequiredTenants => requiredTenants;
/// <summary>
/// Networks permitted to bypass authentication (used for trusted on-host automation).
/// </summary>
public IList<string> BypassNetworks => bypassNetworks;
/// <summary>
/// Whether HTTPS metadata is required when communicating with Authority.
/// </summary>
public bool RequireHttpsMetadata { get; set; } = true;
/// <summary>
/// Back-channel timeout when fetching metadata/JWKS.
/// </summary>
public TimeSpan BackchannelTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Clock skew tolerated when validating tokens.
/// </summary>
public TimeSpan TokenClockSkew { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>
/// Lifetime for cached discovery/JWKS metadata before forcing a refresh.
/// </summary>
public TimeSpan MetadataCacheLifetime { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets the canonical Authority URI (populated during validation).
/// </summary>
public Uri AuthorityUri { get; private set; } = null!;
/// <summary>
/// Gets the normalised scope list (populated during validation).
/// </summary>
public IReadOnlyList<string> NormalizedScopes { get; private set; } = Array.Empty<string>();
/// <summary>
/// Gets the normalised tenant list (populated during validation).
/// </summary>
public IReadOnlyList<string> NormalizedTenants { get; private set; } = Array.Empty<string>();
/// <summary>
/// Gets the network matcher used for bypass checks (populated during validation).
/// </summary>
public NetworkMaskMatcher BypassMatcher { get; private set; } = NetworkMaskMatcher.DenyAll;
/// <summary>
/// Validates provided configuration and normalises collections.
/// </summary>
public void Validate()
{
if (string.IsNullOrWhiteSpace(Authority))
{
throw new InvalidOperationException("Resource server authentication requires an Authority URL.");
}
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var authorityUri))
{
throw new InvalidOperationException("Resource server Authority URL must be an absolute URI.");
}
if (RequireHttpsMetadata &&
!authorityUri.IsLoopback &&
!string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Resource server Authority URL must use HTTPS when HTTPS metadata is required.");
}
if (BackchannelTimeout <= TimeSpan.Zero)
{
throw new InvalidOperationException("Resource server back-channel timeout must be greater than zero.");
}
if (TokenClockSkew < TimeSpan.Zero || TokenClockSkew > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException("Resource server token clock skew must be between 0 seconds and 5 minutes.");
}
if (MetadataCacheLifetime <= TimeSpan.Zero || MetadataCacheLifetime > TimeSpan.FromHours(24))
{
throw new InvalidOperationException("Resource server metadata cache lifetime must be greater than zero and less than or equal to 24 hours.");
}
AuthorityUri = authorityUri;
NormalizeList(audiences, toLower: false);
NormalizeList(requiredScopes, toLower: true);
NormalizeList(requiredTenants, toLower: true);
NormalizeList(bypassNetworks, toLower: false);
NormalizedScopes = requiredScopes.Count == 0
? Array.Empty<string>()
: requiredScopes.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
NormalizedTenants = requiredTenants.Count == 0
? Array.Empty<string>()
: requiredTenants.OrderBy(static tenant => tenant, StringComparer.Ordinal).ToArray();
BypassMatcher = bypassNetworks.Count == 0
? NetworkMaskMatcher.DenyAll
: new NetworkMaskMatcher(bypassNetworks);
}
private static void NormalizeList(IList<string> values, bool toLower)
{
if (values.Count == 0)
{
return;
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = values.Count - 1; index >= 0; index--)
{
var value = values[index];
if (string.IsNullOrWhiteSpace(value))
{
values.RemoveAt(index);
continue;
}
var trimmed = value.Trim();
if (toLower)
{
trimmed = trimmed.ToLowerInvariant();
}
if (!seen.Add(trimmed))
{
values.RemoveAt(index);
continue;
}
values[index] = trimmed;
}
}
}

View File

@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Handles <see cref="StellaOpsScopeRequirement"/> evaluation.
/// </summary>
internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<StellaOpsScopeRequirement>
{
private readonly IHttpContextAccessor httpContextAccessor;
private readonly StellaOpsBypassEvaluator bypassEvaluator;
private readonly IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor;
private readonly ILogger<StellaOpsScopeAuthorizationHandler> logger;
public StellaOpsScopeAuthorizationHandler(
IHttpContextAccessor httpContextAccessor,
StellaOpsBypassEvaluator bypassEvaluator,
IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor,
ILogger<StellaOpsScopeAuthorizationHandler> logger)
{
this.httpContextAccessor = httpContextAccessor;
this.bypassEvaluator = bypassEvaluator;
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.logger = logger;
}
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
StellaOpsScopeRequirement requirement)
{
var resourceOptions = optionsMonitor.CurrentValue;
var httpContext = httpContextAccessor.HttpContext;
var combinedScopes = CombineRequiredScopes(resourceOptions.NormalizedScopes, requirement.RequiredScopes);
HashSet<string>? userScopes = null;
if (context.User?.Identity?.IsAuthenticated == true)
{
userScopes = ExtractScopes(context.User);
foreach (var scope in combinedScopes)
{
if (!userScopes.Contains(scope))
{
continue;
}
if (TenantAllowed(context.User, resourceOptions, out var normalizedTenant))
{
context.Succeed(requirement);
return Task.CompletedTask;
}
if (logger.IsEnabled(LogLevel.Debug))
{
var allowedTenants = resourceOptions.NormalizedTenants.Count == 0
? "(none)"
: string.Join(", ", resourceOptions.NormalizedTenants);
logger.LogDebug(
"Tenant requirement not satisfied. RequiredTenants={RequiredTenants}; PrincipalTenant={PrincipalTenant}; Remote={Remote}",
allowedTenants,
normalizedTenant ?? "(none)",
httpContext?.Connection.RemoteIpAddress);
}
// tenant mismatch cannot be resolved by checking additional scopes for this principal
break;
}
}
if (httpContext is not null && bypassEvaluator.ShouldBypass(httpContext, combinedScopes))
{
context.Succeed(requirement);
return Task.CompletedTask;
}
if (logger.IsEnabled(LogLevel.Debug))
{
var required = string.Join(", ", combinedScopes);
var principalScopes = userScopes is null || userScopes.Count == 0
? "(none)"
: string.Join(", ", userScopes);
var tenantValue = context.User?.FindFirstValue(StellaOpsClaimTypes.Tenant) ?? "(none)";
logger.LogDebug(
"Scope requirement not satisfied. Required={RequiredScopes}; PrincipalScopes={PrincipalScopes}; Tenant={Tenant}; Remote={Remote}",
required,
principalScopes,
tenantValue,
httpContext?.Connection.RemoteIpAddress);
}
return Task.CompletedTask;
}
private static bool TenantAllowed(ClaimsPrincipal principal, StellaOpsResourceServerOptions options, out string? normalizedTenant)
{
normalizedTenant = null;
if (options.NormalizedTenants.Count == 0)
{
return true;
}
var rawTenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
if (string.IsNullOrWhiteSpace(rawTenant))
{
return false;
}
normalizedTenant = rawTenant.Trim().ToLowerInvariant();
foreach (var allowed in options.NormalizedTenants)
{
if (string.Equals(allowed, normalizedTenant, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static HashSet<string> ExtractScopes(ClaimsPrincipal principal)
{
var scopes = new HashSet<string>(StringComparer.Ordinal);
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
scopes.Add(claim.Value);
}
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
var normalized = StellaOpsScopes.Normalize(part);
if (normalized is not null)
{
scopes.Add(normalized);
}
}
}
return scopes;
}
private static IReadOnlyList<string> CombineRequiredScopes(
IReadOnlyList<string> defaultScopes,
IReadOnlyCollection<string> requirementScopes)
{
if ((defaultScopes is null || defaultScopes.Count == 0) && (requirementScopes is null || requirementScopes.Count == 0))
{
return Array.Empty<string>();
}
if (defaultScopes is null || defaultScopes.Count == 0)
{
return requirementScopes is string[] requirementArray
? requirementArray
: requirementScopes.ToArray();
}
var combined = new HashSet<string>(defaultScopes, StringComparer.Ordinal);
if (requirementScopes is not null)
{
foreach (var scope in requirementScopes)
{
if (!string.IsNullOrWhiteSpace(scope))
{
combined.Add(scope);
}
}
}
return combined.Count == defaultScopes.Count && requirementScopes is null
? defaultScopes
: combined.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Authorization;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Authorisation requirement enforcing StellaOps scope membership.
/// </summary>
public sealed class StellaOpsScopeRequirement : IAuthorizationRequirement
{
/// <summary>
/// Initialises a new instance of the <see cref="StellaOpsScopeRequirement"/> class.
/// </summary>
/// <param name="scopes">Scopes that satisfy the requirement.</param>
public StellaOpsScopeRequirement(IEnumerable<string> scopes)
{
ArgumentNullException.ThrowIfNull(scopes);
var normalized = new HashSet<string>(StringComparer.Ordinal);
foreach (var scope in scopes)
{
var value = StellaOpsScopes.Normalize(scope);
if (value is null)
{
continue;
}
normalized.Add(value);
}
if (normalized.Count == 0)
{
throw new ArgumentException("At least one scope must be provided.", nameof(scopes));
}
RequiredScopes = normalized.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
}
/// <summary>
/// Gets the required scopes.
/// </summary>
public IReadOnlyCollection<string> RequiredScopes { get; }
}