Restructure solution layout by module
This commit is contained in:
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user