up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,92 +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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,178 +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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user