save progress
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Authority.Plugin.Oidc.Tests")]
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user