save progress

This commit is contained in:
StellaOps Bot
2026-01-03 00:47:24 +02:00
parent 3f197814c5
commit ca578801fd
319 changed files with 32478 additions and 2202 deletions

View File

@@ -8,6 +8,7 @@ using System.Security.Claims;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net.Http;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
@@ -25,6 +26,7 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
private readonly IOptionsMonitor<OidcPluginOptions> optionsMonitor;
private readonly IMemoryCache sessionCache;
private readonly ILogger<OidcCredentialStore> logger;
private readonly IHttpClientFactory httpClientFactory;
private readonly ConfigurationManager<OpenIdConnectConfiguration> configurationManager;
private readonly JwtSecurityTokenHandler tokenHandler;
@@ -32,20 +34,24 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
string pluginName,
IOptionsMonitor<OidcPluginOptions> optionsMonitor,
IMemoryCache sessionCache,
ILogger<OidcCredentialStore> logger)
ILogger<OidcCredentialStore> logger,
IHttpClientFactory httpClientFactory)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.sessionCache = sessionCache ?? throw new ArgumentNullException(nameof(sessionCache));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
var options = optionsMonitor.Get(pluginName);
var metadataAddress = $"{options.Authority.TrimEnd('/')}/.well-known/openid-configuration";
var httpClient = httpClientFactory.CreateClient(OidcPluginRegistrar.GetHttpClientName(pluginName));
httpClient.Timeout = TimeSpan.FromSeconds(options.MetadataTimeoutSeconds);
configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
metadataAddress,
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever { RequireHttps = options.RequireHttpsMetadata })
new HttpDocumentRetriever(httpClient) { RequireHttps = options.RequireHttpsMetadata })
{
RefreshInterval = options.MetadataRefreshInterval,
AutomaticRefreshInterval = options.AutomaticRefreshInterval
@@ -66,17 +72,27 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
// The "password" field contains the access token or ID token.
var token = password;
if (string.IsNullOrWhiteSpace(token))
{
return AuthorityCredentialVerificationResult.Failure(
AuthorityCredentialFailureCode.InvalidCredentials,
"Token is required for OIDC authentication.");
}
if (string.IsNullOrWhiteSpace(token))
{
return AuthorityCredentialVerificationResult.Failure(
AuthorityCredentialFailureCode.InvalidCredentials,
"Token is required for OIDC authentication.");
}
try
{
var options = optionsMonitor.Get(pluginName);
var configuration = await configurationManager.GetConfigurationAsync(cancellationToken).ConfigureAwait(false);
try
{
var options = optionsMonitor.Get(pluginName);
if (options.RequireAsymmetricKey &&
TryGetAlgorithm(token, out var algorithm) &&
IsSymmetricAlgorithm(algorithm))
{
return AuthorityCredentialVerificationResult.Failure(
AuthorityCredentialFailureCode.InvalidCredentials,
"Token uses a symmetric algorithm but asymmetric keys are required.");
}
var configuration = await configurationManager.GetConfigurationAsync(cancellationToken).ConfigureAwait(false);
var validationParameters = new TokenValidationParameters
{
@@ -132,7 +148,7 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
attributes: attributes);
// Cache the session
var cacheKey = $"oidc:session:{subjectId}";
var cacheKey = BuildSessionCacheKey(pluginName, subjectId);
sessionCache.Set(cacheKey, user, options.SessionCacheDuration);
logger.LogInformation(
@@ -196,7 +212,7 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
string subjectId,
CancellationToken cancellationToken)
{
var cacheKey = $"oidc:session:{subjectId}";
var cacheKey = BuildSessionCacheKey(pluginName, subjectId);
if (sessionCache.TryGetValue<AuthorityUserDescriptor>(cacheKey, out var cached))
{
@@ -206,6 +222,9 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
return ValueTask.FromResult<AuthorityUserDescriptor?>(null);
}
internal static string BuildSessionCacheKey(string pluginName, string subjectId)
=> $"oidc:{pluginName}:session:{subjectId}";
private static string? GetClaimValue(IEnumerable<Claim> claims, string claimType)
{
return claims
@@ -213,6 +232,37 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
?.Value;
}
private static bool IsSymmetricAlgorithm(string? algorithm)
{
if (string.IsNullOrWhiteSpace(algorithm))
{
return false;
}
return algorithm.StartsWith("HS", StringComparison.OrdinalIgnoreCase);
}
private bool TryGetAlgorithm(string token, out string? algorithm)
{
algorithm = null;
if (!tokenHandler.CanReadToken(token))
{
return false;
}
try
{
var jwtToken = tokenHandler.ReadJwtToken(token);
algorithm = jwtToken.Header.Alg;
return !string.IsNullOrWhiteSpace(algorithm);
}
catch (Exception)
{
return false;
}
}
private static List<string> ExtractRoles(IEnumerable<Claim> claims, OidcPluginOptions options)
{
var roles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

View File

@@ -5,6 +5,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net.Http;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Oidc.Claims;
using StellaOps.Authority.Plugin.Oidc.Credentials;
@@ -21,6 +22,7 @@ internal sealed class OidcIdentityProviderPlugin : IIdentityProviderPlugin
private readonly OidcClaimsEnricher claimsEnricher;
private readonly IOptionsMonitor<OidcPluginOptions> optionsMonitor;
private readonly ILogger<OidcIdentityProviderPlugin> logger;
private readonly IHttpClientFactory httpClientFactory;
private readonly AuthorityIdentityProviderCapabilities capabilities;
public OidcIdentityProviderPlugin(
@@ -28,13 +30,15 @@ internal sealed class OidcIdentityProviderPlugin : IIdentityProviderPlugin
OidcCredentialStore credentialStore,
OidcClaimsEnricher claimsEnricher,
IOptionsMonitor<OidcPluginOptions> optionsMonitor,
ILogger<OidcIdentityProviderPlugin> logger)
ILogger<OidcIdentityProviderPlugin> logger,
IHttpClientFactory httpClientFactory)
{
this.pluginContext = pluginContext ?? throw new ArgumentNullException(nameof(pluginContext));
this.credentialStore = credentialStore ?? throw new ArgumentNullException(nameof(credentialStore));
this.claimsEnricher = claimsEnricher ?? throw new ArgumentNullException(nameof(claimsEnricher));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
// Validate configuration on startup
var options = optionsMonitor.Get(pluginContext.Manifest.Name);
@@ -78,7 +82,8 @@ internal sealed class OidcIdentityProviderPlugin : IIdentityProviderPlugin
var options = optionsMonitor.Get(Name);
var metadataAddress = $"{options.Authority.TrimEnd('/')}/.well-known/openid-configuration";
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
using var httpClient = httpClientFactory.CreateClient(OidcPluginRegistrar.GetHttpClientName(Name));
httpClient.Timeout = TimeSpan.FromSeconds(options.MetadataTimeoutSeconds);
var response = await httpClient.GetAsync(metadataAddress, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)

View File

@@ -101,6 +101,11 @@ public sealed class OidcPluginOptions
/// </summary>
public TimeSpan AutomaticRefreshInterval { get; set; } = TimeSpan.FromHours(12);
/// <summary>
/// Timeout in seconds for metadata retrieval and health checks.
/// </summary>
public int MetadataTimeoutSeconds { get; set; } = 10;
/// <summary>
/// Cache duration for user sessions.
/// </summary>
@@ -160,6 +165,55 @@ public sealed class OidcPluginOptions
{
throw new InvalidOperationException("OIDC Authority must use HTTPS when RequireHttpsMetadata is true.");
}
if (MetadataTimeoutSeconds <= 0)
{
throw new InvalidOperationException("OIDC MetadataTimeoutSeconds must be greater than zero.");
}
ValidateScopes(Scopes, "Scopes");
if (TokenExchange is { Enabled: true })
{
ValidateScopes(TokenExchange.Scopes, "TokenExchange.Scopes");
}
ValidateRedirectUri(nameof(RedirectUri), RedirectUri);
ValidateRedirectUri(nameof(PostLogoutRedirectUri), PostLogoutRedirectUri);
}
private void ValidateRedirectUri(string name, Uri? uri)
{
if (uri is null)
{
return;
}
if (!uri.IsAbsoluteUri)
{
throw new InvalidOperationException($"OIDC {name} must be an absolute URI.");
}
if (RequireHttpsMetadata && !string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"OIDC {name} must use HTTPS when RequireHttpsMetadata is true.");
}
}
private static void ValidateScopes(IReadOnlyCollection<string> scopes, string name)
{
if (scopes is null || scopes.Count == 0)
{
throw new InvalidOperationException($"OIDC {name} must include at least one scope.");
}
foreach (var scope in scopes)
{
if (string.IsNullOrWhiteSpace(scope))
{
throw new InvalidOperationException($"OIDC {name} cannot include empty scopes.");
}
}
}
}

View File

@@ -7,6 +7,7 @@ using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net.Http;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Oidc.Claims;
using StellaOps.Authority.Plugin.Oidc.Credentials;
@@ -23,6 +24,9 @@ public static class OidcPluginRegistrar
/// </summary>
public const string PluginType = "oidc";
public static string GetHttpClientName(string pluginName)
=> $"oidc:{pluginName}";
/// <summary>
/// Registers the OIDC plugin with the given context.
/// </summary>
@@ -39,15 +43,17 @@ public static class OidcPluginRegistrar
var optionsMonitor = serviceProvider.GetRequiredService<IOptionsMonitor<OidcPluginOptions>>();
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
// Get or create a memory cache for sessions
var sessionCache = serviceProvider.GetService<IMemoryCache>()
?? new MemoryCache(new MemoryCacheOptions());
optionsMonitor.Get(pluginName).Validate();
var sessionCache = serviceProvider.GetRequiredService<IMemoryCache>();
var httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
var credentialStore = new OidcCredentialStore(
pluginName,
optionsMonitor,
sessionCache,
loggerFactory.CreateLogger<OidcCredentialStore>());
loggerFactory.CreateLogger<OidcCredentialStore>(),
httpClientFactory);
var claimsEnricher = new OidcClaimsEnricher(
pluginName,
@@ -59,7 +65,8 @@ public static class OidcPluginRegistrar
credentialStore,
claimsEnricher,
optionsMonitor,
loggerFactory.CreateLogger<OidcIdentityProviderPlugin>());
loggerFactory.CreateLogger<OidcIdentityProviderPlugin>(),
httpClientFactory);
return plugin;
}
@@ -73,7 +80,7 @@ public static class OidcPluginRegistrar
Action<OidcPluginOptions>? configureOptions = null)
{
services.AddMemoryCache();
services.AddHttpClient();
services.AddHttpClient(GetHttpClientName(pluginName));
if (configureOptions != null)
{

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Authority.Plugin.Oidc.Tests")]

View File

@@ -2,6 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Authority.Plugin.Oidc</RootNamespace>
<Description>StellaOps Authority OIDC Identity Provider Plugin</Description>
<IsAuthorityPlugin>true</IsAuthorityPlugin>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.10.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.10.0" />
</ItemGroup>
</Project>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0092-M | DONE | Maintainability audit for StellaOps.Authority.Plugin.Oidc. |
| AUDIT-0092-T | DONE | Test coverage audit for StellaOps.Authority.Plugin.Oidc. |
| AUDIT-0092-A | TODO | Pending approval for changes. |
| AUDIT-0092-A | DONE | Applied OIDC plugin updates and tests. |