sprints work
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OidcClaimsEnricher.cs
|
||||
// Claims enricher for OIDC-authenticated principals.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Oidc.Claims;
|
||||
|
||||
/// <summary>
|
||||
/// Enriches claims for OIDC-authenticated users.
|
||||
/// </summary>
|
||||
internal sealed class OidcClaimsEnricher : IClaimsEnricher
|
||||
{
|
||||
private readonly string pluginName;
|
||||
private readonly IOptionsMonitor<OidcPluginOptions> optionsMonitor;
|
||||
private readonly ILogger<OidcClaimsEnricher> logger;
|
||||
|
||||
public OidcClaimsEnricher(
|
||||
string pluginName,
|
||||
IOptionsMonitor<OidcPluginOptions> optionsMonitor,
|
||||
ILogger<OidcClaimsEnricher> logger)
|
||||
{
|
||||
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public ValueTask EnrichAsync(
|
||||
ClaimsIdentity identity,
|
||||
AuthorityClaimsEnrichmentContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (identity == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(identity));
|
||||
}
|
||||
|
||||
if (context == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
var options = optionsMonitor.Get(pluginName);
|
||||
|
||||
// Add OIDC-specific claims
|
||||
AddClaimIfMissing(identity, "idp", "oidc");
|
||||
AddClaimIfMissing(identity, "auth_method", "oidc");
|
||||
|
||||
// Add user attributes as claims
|
||||
if (context.User != null)
|
||||
{
|
||||
foreach (var attr in context.User.Attributes)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(attr.Value))
|
||||
{
|
||||
AddClaimIfMissing(identity, $"oidc_{attr.Key}", attr.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure roles are added
|
||||
foreach (var role in context.User.Roles)
|
||||
{
|
||||
var roleClaim = identity.Claims.FirstOrDefault(c =>
|
||||
c.Type == ClaimTypes.Role && string.Equals(c.Value, role, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (roleClaim == null)
|
||||
{
|
||||
identity.AddClaim(new Claim(ClaimTypes.Role, role));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
"Enriched OIDC claims for identity {Name}. Total claims: {Count}",
|
||||
identity.Name ?? "unknown",
|
||||
identity.Claims.Count());
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static void AddClaimIfMissing(ClaimsIdentity identity, string type, string value)
|
||||
{
|
||||
if (!identity.HasClaim(c => string.Equals(c.Type, type, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
identity.AddClaim(new Claim(type, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OidcCredentialStore.cs
|
||||
// Credential store for validating OIDC tokens.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Oidc.Credentials;
|
||||
|
||||
/// <summary>
|
||||
/// Credential store that validates OIDC access tokens and ID tokens.
|
||||
/// </summary>
|
||||
internal sealed class OidcCredentialStore : IUserCredentialStore
|
||||
{
|
||||
private readonly string pluginName;
|
||||
private readonly IOptionsMonitor<OidcPluginOptions> optionsMonitor;
|
||||
private readonly IMemoryCache sessionCache;
|
||||
private readonly ILogger<OidcCredentialStore> logger;
|
||||
private readonly ConfigurationManager<OpenIdConnectConfiguration> configurationManager;
|
||||
private readonly JwtSecurityTokenHandler tokenHandler;
|
||||
|
||||
public OidcCredentialStore(
|
||||
string pluginName,
|
||||
IOptionsMonitor<OidcPluginOptions> optionsMonitor,
|
||||
IMemoryCache sessionCache,
|
||||
ILogger<OidcCredentialStore> logger)
|
||||
{
|
||||
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));
|
||||
|
||||
var options = optionsMonitor.Get(pluginName);
|
||||
var metadataAddress = $"{options.Authority.TrimEnd('/')}/.well-known/openid-configuration";
|
||||
|
||||
configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
metadataAddress,
|
||||
new OpenIdConnectConfigurationRetriever(),
|
||||
new HttpDocumentRetriever { RequireHttps = options.RequireHttpsMetadata })
|
||||
{
|
||||
RefreshInterval = options.MetadataRefreshInterval,
|
||||
AutomaticRefreshInterval = options.AutomaticRefreshInterval
|
||||
};
|
||||
|
||||
tokenHandler = new JwtSecurityTokenHandler
|
||||
{
|
||||
MapInboundClaims = false
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(
|
||||
string username,
|
||||
string password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// OIDC plugin validates tokens, not passwords.
|
||||
// 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.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var options = optionsMonitor.Get(pluginName);
|
||||
var configuration = await configurationManager.GetConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = options.ValidateIssuer,
|
||||
ValidIssuer = configuration.Issuer,
|
||||
ValidateAudience = options.ValidateAudience,
|
||||
ValidAudience = options.Audience ?? options.ClientId,
|
||||
ValidateLifetime = options.ValidateLifetime,
|
||||
ClockSkew = options.ClockSkew,
|
||||
IssuerSigningKeys = configuration.SigningKeys,
|
||||
ValidateIssuerSigningKey = true,
|
||||
NameClaimType = options.UsernameClaimType,
|
||||
RoleClaimType = options.RoleClaimTypes.FirstOrDefault() ?? "roles"
|
||||
};
|
||||
|
||||
var principal = tokenHandler.ValidateToken(token, validationParameters, out var validatedToken);
|
||||
var jwtToken = validatedToken as JwtSecurityToken;
|
||||
|
||||
if (jwtToken == null)
|
||||
{
|
||||
return AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
"Invalid token format.");
|
||||
}
|
||||
|
||||
var subjectId = GetClaimValue(principal.Claims, options.SubjectClaimType) ?? jwtToken.Subject;
|
||||
var usernameValue = GetClaimValue(principal.Claims, options.UsernameClaimType) ?? username;
|
||||
var displayName = GetClaimValue(principal.Claims, options.DisplayNameClaimType);
|
||||
var email = GetClaimValue(principal.Claims, options.EmailClaimType);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subjectId))
|
||||
{
|
||||
return AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
"Token does not contain a valid subject claim.");
|
||||
}
|
||||
|
||||
var roles = ExtractRoles(principal.Claims, options);
|
||||
var attributes = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["email"] = email,
|
||||
["issuer"] = jwtToken.Issuer,
|
||||
["audience"] = string.Join(",", jwtToken.Audiences),
|
||||
["token_type"] = GetClaimValue(principal.Claims, "token_type") ?? "access_token"
|
||||
};
|
||||
|
||||
var user = new AuthorityUserDescriptor(
|
||||
subjectId: subjectId,
|
||||
username: usernameValue,
|
||||
displayName: displayName,
|
||||
requiresPasswordReset: false,
|
||||
roles: roles.ToArray(),
|
||||
attributes: attributes);
|
||||
|
||||
// Cache the session
|
||||
var cacheKey = $"oidc:session:{subjectId}";
|
||||
sessionCache.Set(cacheKey, user, options.SessionCacheDuration);
|
||||
|
||||
logger.LogInformation(
|
||||
"OIDC token validated for user {Username} (subject: {SubjectId}) from issuer {Issuer}",
|
||||
usernameValue, subjectId, jwtToken.Issuer);
|
||||
|
||||
return AuthorityCredentialVerificationResult.Success(
|
||||
user,
|
||||
"Token validated successfully.",
|
||||
new[]
|
||||
{
|
||||
new AuthEventProperty { Name = "oidc_issuer", Value = ClassifiedString.Public(jwtToken.Issuer) },
|
||||
new AuthEventProperty { Name = "token_valid_until", Value = ClassifiedString.Public(jwtToken.ValidTo.ToString("O")) }
|
||||
});
|
||||
}
|
||||
catch (SecurityTokenExpiredException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "OIDC token expired for user {Username}", username);
|
||||
return AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
"Token has expired.");
|
||||
}
|
||||
catch (SecurityTokenInvalidSignatureException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "OIDC token signature invalid for user {Username}", username);
|
||||
return AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
"Token signature is invalid.");
|
||||
}
|
||||
catch (SecurityTokenException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "OIDC token validation failed for user {Username}", username);
|
||||
return AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
$"Token validation failed: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected error during OIDC token validation for user {Username}", username);
|
||||
return AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.UnknownError,
|
||||
"An unexpected error occurred during token validation.");
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(
|
||||
AuthorityUserRegistration registration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// OIDC is a federated identity provider - users are managed externally.
|
||||
// We only cache session data, not user records.
|
||||
logger.LogDebug("UpsertUserAsync called on OIDC plugin - operation not supported for federated IdP.");
|
||||
|
||||
return ValueTask.FromResult(
|
||||
AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure(
|
||||
"not_supported",
|
||||
"OIDC plugin does not support user provisioning - users are managed by the external identity provider."));
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(
|
||||
string subjectId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = $"oidc:session:{subjectId}";
|
||||
|
||||
if (sessionCache.TryGetValue<AuthorityUserDescriptor>(cacheKey, out var cached))
|
||||
{
|
||||
return ValueTask.FromResult<AuthorityUserDescriptor?>(cached);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<AuthorityUserDescriptor?>(null);
|
||||
}
|
||||
|
||||
private static string? GetClaimValue(IEnumerable<Claim> claims, string claimType)
|
||||
{
|
||||
return claims
|
||||
.FirstOrDefault(c => string.Equals(c.Type, claimType, StringComparison.OrdinalIgnoreCase))
|
||||
?.Value;
|
||||
}
|
||||
|
||||
private static List<string> ExtractRoles(IEnumerable<Claim> claims, OidcPluginOptions options)
|
||||
{
|
||||
var roles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Add default roles
|
||||
foreach (var defaultRole in options.RoleMapping.DefaultRoles)
|
||||
{
|
||||
roles.Add(defaultRole);
|
||||
}
|
||||
|
||||
// Extract roles from configured claim types
|
||||
foreach (var claimType in options.RoleClaimTypes)
|
||||
{
|
||||
var roleClaims = claims.Where(c =>
|
||||
string.Equals(c.Type, claimType, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
foreach (var claim in roleClaims)
|
||||
{
|
||||
var roleValue = claim.Value;
|
||||
|
||||
// Try to map the role
|
||||
if (options.RoleMapping.Enabled &&
|
||||
options.RoleMapping.Mappings.TryGetValue(roleValue, out var mappedRole))
|
||||
{
|
||||
roles.Add(mappedRole);
|
||||
}
|
||||
else if (options.RoleMapping.IncludeUnmappedRoles || !options.RoleMapping.Enabled)
|
||||
{
|
||||
roles.Add(roleValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return roles.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OidcIdentityProviderPlugin.cs
|
||||
// OIDC identity provider plugin implementation.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Oidc.Claims;
|
||||
using StellaOps.Authority.Plugin.Oidc.Credentials;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Oidc;
|
||||
|
||||
/// <summary>
|
||||
/// OIDC identity provider plugin for federated authentication.
|
||||
/// </summary>
|
||||
internal sealed class OidcIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
{
|
||||
private readonly AuthorityPluginContext pluginContext;
|
||||
private readonly OidcCredentialStore credentialStore;
|
||||
private readonly OidcClaimsEnricher claimsEnricher;
|
||||
private readonly IOptionsMonitor<OidcPluginOptions> optionsMonitor;
|
||||
private readonly ILogger<OidcIdentityProviderPlugin> logger;
|
||||
private readonly AuthorityIdentityProviderCapabilities capabilities;
|
||||
|
||||
public OidcIdentityProviderPlugin(
|
||||
AuthorityPluginContext pluginContext,
|
||||
OidcCredentialStore credentialStore,
|
||||
OidcClaimsEnricher claimsEnricher,
|
||||
IOptionsMonitor<OidcPluginOptions> optionsMonitor,
|
||||
ILogger<OidcIdentityProviderPlugin> logger)
|
||||
{
|
||||
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));
|
||||
|
||||
// Validate configuration on startup
|
||||
var options = optionsMonitor.Get(pluginContext.Manifest.Name);
|
||||
options.Validate();
|
||||
|
||||
// OIDC supports password (token validation) but not client provisioning
|
||||
// (since users are managed by the external IdP)
|
||||
var manifestCapabilities = AuthorityIdentityProviderCapabilities.FromCapabilities(
|
||||
pluginContext.Manifest.Capabilities);
|
||||
|
||||
capabilities = new AuthorityIdentityProviderCapabilities(
|
||||
SupportsPassword: true,
|
||||
SupportsMfa: manifestCapabilities.SupportsMfa,
|
||||
SupportsClientProvisioning: false,
|
||||
SupportsBootstrap: false);
|
||||
|
||||
logger.LogInformation(
|
||||
"OIDC plugin '{PluginName}' initialized with authority: {Authority}",
|
||||
pluginContext.Manifest.Name,
|
||||
options.Authority);
|
||||
}
|
||||
|
||||
public string Name => pluginContext.Manifest.Name;
|
||||
|
||||
public string Type => pluginContext.Manifest.Type;
|
||||
|
||||
public AuthorityPluginContext Context => pluginContext;
|
||||
|
||||
public IUserCredentialStore Credentials => credentialStore;
|
||||
|
||||
public IClaimsEnricher ClaimsEnricher => claimsEnricher;
|
||||
|
||||
public IClientProvisioningStore? ClientProvisioning => null;
|
||||
|
||||
public AuthorityIdentityProviderCapabilities Capabilities => capabilities;
|
||||
|
||||
public async ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var options = optionsMonitor.Get(Name);
|
||||
var metadataAddress = $"{options.Authority.TrimEnd('/')}/.well-known/openid-configuration";
|
||||
|
||||
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
var response = await httpClient.GetAsync(metadataAddress, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogDebug("OIDC plugin '{PluginName}' health check passed.", Name);
|
||||
return AuthorityPluginHealthResult.Healthy(
|
||||
"OIDC metadata endpoint is accessible.",
|
||||
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["authority"] = options.Authority,
|
||||
["metadata_status"] = "ok"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"OIDC plugin '{PluginName}' health check degraded: metadata returned {StatusCode}.",
|
||||
Name, response.StatusCode);
|
||||
|
||||
return AuthorityPluginHealthResult.Degraded(
|
||||
$"OIDC metadata endpoint returned {response.StatusCode}.",
|
||||
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["authority"] = options.Authority,
|
||||
["http_status"] = ((int)response.StatusCode).ToString()
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
logger.LogWarning("OIDC plugin '{PluginName}' health check timed out.", Name);
|
||||
return AuthorityPluginHealthResult.Degraded("OIDC metadata endpoint request timed out.");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "OIDC plugin '{PluginName}' health check failed.", Name);
|
||||
return AuthorityPluginHealthResult.Unavailable($"Cannot reach OIDC authority: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "OIDC plugin '{PluginName}' health check failed unexpectedly.", Name);
|
||||
return AuthorityPluginHealthResult.Unavailable($"Health check failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OidcPluginOptions.cs
|
||||
// Configuration options for the OIDC identity provider plugin.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Oidc;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the OIDC identity provider plugin.
|
||||
/// </summary>
|
||||
public sealed class OidcPluginOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The OIDC authority URL (e.g., https://login.microsoftonline.com/tenant).
|
||||
/// </summary>
|
||||
public string Authority { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The OAuth2 client ID for this application.
|
||||
/// </summary>
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The OAuth2 client secret (for confidential clients).
|
||||
/// </summary>
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected audience for token validation.
|
||||
/// </summary>
|
||||
public string? Audience { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Scopes to request during authorization.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Scopes { get; set; } = new[] { "openid", "profile", "email" };
|
||||
|
||||
/// <summary>
|
||||
/// Claim type used as the unique user identifier.
|
||||
/// </summary>
|
||||
public string SubjectClaimType { get; set; } = "sub";
|
||||
|
||||
/// <summary>
|
||||
/// Claim type used for the username.
|
||||
/// </summary>
|
||||
public string UsernameClaimType { get; set; } = "preferred_username";
|
||||
|
||||
/// <summary>
|
||||
/// Claim type used for the display name.
|
||||
/// </summary>
|
||||
public string DisplayNameClaimType { get; set; } = "name";
|
||||
|
||||
/// <summary>
|
||||
/// Claim type used for email.
|
||||
/// </summary>
|
||||
public string EmailClaimType { get; set; } = "email";
|
||||
|
||||
/// <summary>
|
||||
/// Claim types containing user roles.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> RoleClaimTypes { get; set; } = new[] { "roles", "role", "groups" };
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate the issuer.
|
||||
/// </summary>
|
||||
public bool ValidateIssuer { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate the audience.
|
||||
/// </summary>
|
||||
public bool ValidateAudience { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate token lifetime.
|
||||
/// </summary>
|
||||
public bool ValidateLifetime { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Clock skew tolerance for token validation.
|
||||
/// </summary>
|
||||
public TimeSpan ClockSkew { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require HTTPS for metadata endpoint.
|
||||
/// </summary>
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require asymmetric key algorithms (RS*, ES*).
|
||||
/// Rejects symmetric algorithms (HS*) when enabled.
|
||||
/// </summary>
|
||||
public bool RequireAsymmetricKey { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata refresh interval.
|
||||
/// </summary>
|
||||
public TimeSpan MetadataRefreshInterval { get; set; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// Automatic metadata refresh interval (when keys change).
|
||||
/// </summary>
|
||||
public TimeSpan AutomaticRefreshInterval { get; set; } = TimeSpan.FromHours(12);
|
||||
|
||||
/// <summary>
|
||||
/// Cache duration for user sessions.
|
||||
/// </summary>
|
||||
public TimeSpan SessionCacheDuration { get; set; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to support client credentials flow.
|
||||
/// </summary>
|
||||
public bool SupportClientCredentials { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to support authorization code flow.
|
||||
/// </summary>
|
||||
public bool SupportAuthorizationCode { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Redirect URI for authorization code flow.
|
||||
/// </summary>
|
||||
public Uri? RedirectUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Post-logout redirect URI.
|
||||
/// </summary>
|
||||
public Uri? PostLogoutRedirectUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Role mapping configuration.
|
||||
/// </summary>
|
||||
public OidcRoleMappingOptions RoleMapping { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Token exchange options (for on-behalf-of flow).
|
||||
/// </summary>
|
||||
public OidcTokenExchangeOptions TokenExchange { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Validates the options are properly configured.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Authority))
|
||||
{
|
||||
throw new InvalidOperationException("OIDC Authority is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ClientId))
|
||||
{
|
||||
throw new InvalidOperationException("OIDC ClientId is required.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Authority, UriKind.Absolute, out var authorityUri))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid OIDC Authority URL: {Authority}");
|
||||
}
|
||||
|
||||
if (RequireHttpsMetadata && !string.Equals(authorityUri.Scheme, "https", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("OIDC Authority must use HTTPS when RequireHttpsMetadata is true.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Role mapping configuration for OIDC.
|
||||
/// </summary>
|
||||
public sealed class OidcRoleMappingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to enable role mapping.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Mapping from IdP group/role names to StellaOps roles.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Mappings { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Default roles assigned to all authenticated users.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> DefaultRoles { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include unmapped roles from the IdP.
|
||||
/// </summary>
|
||||
public bool IncludeUnmappedRoles { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Token exchange options for on-behalf-of flows.
|
||||
/// </summary>
|
||||
public sealed class OidcTokenExchangeOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether token exchange is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Token exchange endpoint (if different from token endpoint).
|
||||
/// </summary>
|
||||
public string? TokenExchangeEndpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Scopes to request during token exchange.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Scopes { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OidcPluginRegistrar.cs
|
||||
// Registrar for the OIDC identity provider plugin.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Oidc.Claims;
|
||||
using StellaOps.Authority.Plugin.Oidc.Credentials;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Oidc;
|
||||
|
||||
/// <summary>
|
||||
/// Registrar for the OIDC identity provider plugin.
|
||||
/// </summary>
|
||||
public static class OidcPluginRegistrar
|
||||
{
|
||||
/// <summary>
|
||||
/// The plugin type identifier.
|
||||
/// </summary>
|
||||
public const string PluginType = "oidc";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the OIDC plugin with the given context.
|
||||
/// </summary>
|
||||
public static IIdentityProviderPlugin Register(
|
||||
AuthorityPluginRegistrationContext registrationContext,
|
||||
IServiceProvider serviceProvider)
|
||||
{
|
||||
if (registrationContext == null) throw new ArgumentNullException(nameof(registrationContext));
|
||||
if (serviceProvider == null) throw new ArgumentNullException(nameof(serviceProvider));
|
||||
|
||||
var pluginContext = registrationContext.Plugin;
|
||||
var pluginName = pluginContext.Manifest.Name;
|
||||
|
||||
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());
|
||||
|
||||
var credentialStore = new OidcCredentialStore(
|
||||
pluginName,
|
||||
optionsMonitor,
|
||||
sessionCache,
|
||||
loggerFactory.CreateLogger<OidcCredentialStore>());
|
||||
|
||||
var claimsEnricher = new OidcClaimsEnricher(
|
||||
pluginName,
|
||||
optionsMonitor,
|
||||
loggerFactory.CreateLogger<OidcClaimsEnricher>());
|
||||
|
||||
var plugin = new OidcIdentityProviderPlugin(
|
||||
pluginContext,
|
||||
credentialStore,
|
||||
claimsEnricher,
|
||||
optionsMonitor,
|
||||
loggerFactory.CreateLogger<OidcIdentityProviderPlugin>());
|
||||
|
||||
return plugin;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures services required by the OIDC plugin.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddOidcPlugin(
|
||||
this IServiceCollection services,
|
||||
string pluginName,
|
||||
Action<OidcPluginOptions>? configureOptions = null)
|
||||
{
|
||||
services.AddMemoryCache();
|
||||
services.AddHttpClient();
|
||||
|
||||
if (configureOptions != null)
|
||||
{
|
||||
services.Configure(pluginName, configureOptions);
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.10.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.10.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user