feat: Add RustFS artifact object store and migration tool

- Implemented RustFsArtifactObjectStore for managing artifacts in RustFS.
- Added unit tests for RustFsArtifactObjectStore functionality.
- Created a RustFS migrator tool to transfer objects from S3 to RustFS.
- Introduced policy preview and report models for API integration.
- Added fixtures and tests for policy preview and report functionality.
- Included necessary metadata and scripts for cache_pkg package.
This commit is contained in:
master
2025-10-23 18:53:18 +03:00
parent 5cb3144e5e
commit 70d7fb529e
117 changed files with 4849 additions and 725 deletions

View File

@@ -50,6 +50,7 @@ public class ClientCredentialsHandlersTests
allowedScopes: "jobs:read");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
@@ -59,6 +60,7 @@ public class ClientCredentialsHandlersTests
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:write");
@@ -80,6 +82,7 @@ public class ClientCredentialsHandlersTests
allowedScopes: "jobs:read jobs:trigger");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
@@ -89,6 +92,7 @@ public class ClientCredentialsHandlersTests
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
@@ -114,6 +118,7 @@ public class ClientCredentialsHandlersTests
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var sink = new TestAuthEventSink();
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
@@ -123,6 +128,7 @@ public class ClientCredentialsHandlersTests
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
@@ -139,12 +145,11 @@ public class ClientCredentialsHandlersTests
[Fact]
public async Task ValidateDpopProof_AllowsSenderConstrainedClient()
{
var options = new StellaOpsAuthorityOptions
var options = TestHelpers.CreateAuthorityOptions(opts =>
{
Issuer = new Uri("https://authority.test")
};
options.Security.SenderConstraints.Dpop.Enabled = true;
options.Security.SenderConstraints.Dpop.Nonce.Enabled = false;
opts.Security.SenderConstraints.Dpop.Enabled = true;
opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false;
});
var clientDocument = CreateClient(
secret: "s3cr3t!",
@@ -214,6 +219,7 @@ public class ClientCredentialsHandlersTests
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
await validateHandler.HandleAsync(validateContext);
@@ -389,6 +395,7 @@ public class ClientCredentialsHandlersTests
TimeProvider.System,
validator,
httpContextAccessor,
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
@@ -435,6 +442,7 @@ public class ClientCredentialsHandlersTests
TimeProvider.System,
validator,
httpContextAccessor,
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
@@ -446,6 +454,94 @@ public class ClientCredentialsHandlersTests
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
}
[Fact]
public async Task ValidateClientCredentials_Rejects_WhenAudienceRequiresMtlsButClientConfiguredForDpop()
{
var options = TestHelpers.CreateAuthorityOptions(opts =>
{
opts.Security.SenderConstraints.Mtls.Enabled = true;
opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Clear();
opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Add("signer");
});
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read",
allowedAudiences: "signer");
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop;
clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop;
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("Requested audiences require mutual TLS sender constraint.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_RequiresMtlsWhenAudienceMatchesEnforcement()
{
var options = TestHelpers.CreateAuthorityOptions(opts =>
{
opts.Security.SenderConstraints.Mtls.Enabled = true;
opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Clear();
opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Add("signer");
});
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read",
allowedAudiences: "signer");
clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding
{
Thumbprint = "DEADBEEF"
});
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var certificateValidator = new RecordingCertificateValidator();
var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() };
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
certificateValidator,
httpContextAccessor,
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("client_certificate_required", context.ErrorDescription);
Assert.True(certificateValidator.Invoked);
}
[Fact]
public async Task HandleClientCredentials_PersistsTokenAndEnrichesClaims()
{
@@ -462,6 +558,7 @@ public class ClientCredentialsHandlersTests
var sessionAccessor = new NullMongoSessionAccessor();
var authSink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var options = TestHelpers.CreateAuthorityOptions();
var validateHandler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
@@ -471,6 +568,7 @@ public class ClientCredentialsHandlersTests
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, secret: null, scope: "jobs:trigger");
@@ -828,6 +926,82 @@ public class AuthorityClientCertificateValidatorTests
Assert.True(result.Succeeded);
Assert.Equal(thumbprint, result.HexThumbprint);
}
[Fact]
public async Task ValidateAsync_Rejects_WhenBindingSubjectMismatch()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test")
};
options.Security.SenderConstraints.Mtls.Enabled = true;
options.Security.SenderConstraints.Mtls.RequireChainValidation = false;
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Storage.ConnectionString = "mongodb://localhost/test";
using var rsa = RSA.Create(2048);
var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddDnsName("client.mtls.test");
request.CertificateExtensions.Add(sanBuilder.Build());
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5));
var clientDocument = CreateClient();
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding
{
Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)),
Subject = "CN=different-client"
});
var httpContext = new DefaultHttpContext();
httpContext.Connection.ClientCertificate = certificate;
var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger<AuthorityClientCertificateValidator>.Instance);
var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal("certificate_binding_subject_mismatch", result.Error);
}
[Fact]
public async Task ValidateAsync_Rejects_WhenBindingSansMissing()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test")
};
options.Security.SenderConstraints.Mtls.Enabled = true;
options.Security.SenderConstraints.Mtls.RequireChainValidation = false;
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Storage.ConnectionString = "mongodb://localhost/test";
using var rsa = RSA.Create(2048);
var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddDnsName("client.mtls.test");
request.CertificateExtensions.Add(sanBuilder.Build());
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5));
var clientDocument = CreateClient();
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding
{
Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)),
SubjectAlternativeNames = new List<string> { "spiffe://client" }
});
var httpContext = new DefaultHttpContext();
httpContext.Connection.ClientCertificate = certificate;
var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger<AuthorityClientCertificateValidator>.Instance);
var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal("certificate_binding_san_mismatch", result.Error);
}
}
internal sealed class TestClientStore : IAuthorityClientStore
@@ -1011,6 +1185,33 @@ internal sealed class NoopCertificateValidator : IAuthorityClientCertificateVali
}
}
internal sealed class RecordingCertificateValidator : IAuthorityClientCertificateValidator
{
public bool Invoked { get; private set; }
public ValueTask<AuthorityClientCertificateValidationResult> ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken)
{
Invoked = true;
if (httpContext.Connection.ClientCertificate is null)
{
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("client_certificate_required"));
}
AuthorityClientCertificateBinding binding;
if (client.CertificateBindings.Count > 0)
{
binding = client.CertificateBindings[0];
}
else
{
binding = new AuthorityClientCertificateBinding { Thumbprint = "stub" };
}
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Success("stub", binding.Thumbprint, binding));
}
}
internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor
{
public ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default)
@@ -1021,6 +1222,21 @@ internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor
internal static class TestHelpers
{
public static StellaOpsAuthorityOptions CreateAuthorityOptions(Action<StellaOpsAuthorityOptions>? configure = null)
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test")
};
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Storage.ConnectionString = "mongodb://localhost/test";
configure?.Invoke(options);
return options;
}
public static AuthorityClientDocument CreateClient(
string? secret = "s3cr3t!",
string clientType = "confidential",

View File

@@ -64,7 +64,8 @@ public sealed class TokenPersistenceIntegrationTests
var metadataAccessor = new TestRateLimiterMetadataAccessor();
await using var scope = provider.CreateAsyncScope();
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, authSink, metadataAccessor, clock, new NoopCertificateValidator(), new HttpContextAccessor(), NullLogger<ValidateClientCredentialsHandler>.Instance);
var options = TestHelpers.CreateAuthorityOptions();
var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, authSink, metadataAccessor, clock, new NoopCertificateValidator(), new HttpContextAccessor(), options, NullLogger<ValidateClientCredentialsHandler>.Instance);
var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);

View File

@@ -10,18 +10,19 @@ using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using MongoDB.Driver;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Security;
using StellaOps.Cryptography.Audit;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using MongoDB.Driver;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Security;
using StellaOps.Configuration;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.OpenIddict.Handlers;
@@ -31,33 +32,36 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
private readonly IAuthorityIdentityProviderRegistry registry;
private readonly ActivitySource activitySource;
private readonly IAuthEventSink auditSink;
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
private readonly TimeProvider timeProvider;
private readonly IAuthorityClientCertificateValidator certificateValidator;
private readonly IHttpContextAccessor httpContextAccessor;
private readonly ILogger<ValidateClientCredentialsHandler> logger;
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
private readonly TimeProvider timeProvider;
private readonly IAuthorityClientCertificateValidator certificateValidator;
private readonly IHttpContextAccessor httpContextAccessor;
private readonly StellaOpsAuthorityOptions authorityOptions;
private readonly ILogger<ValidateClientCredentialsHandler> logger;
public ValidateClientCredentialsHandler(
IAuthorityClientStore clientStore,
IAuthorityIdentityProviderRegistry registry,
ActivitySource activitySource,
IAuthEventSink auditSink,
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
TimeProvider timeProvider,
IAuthorityClientCertificateValidator certificateValidator,
IHttpContextAccessor httpContextAccessor,
ILogger<ValidateClientCredentialsHandler> logger)
{
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.certificateValidator = certificateValidator ?? throw new ArgumentNullException(nameof(certificateValidator));
this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
TimeProvider timeProvider,
IAuthorityClientCertificateValidator certificateValidator,
IHttpContextAccessor httpContextAccessor,
StellaOpsAuthorityOptions authorityOptions,
ILogger<ValidateClientCredentialsHandler> logger)
{
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.certificateValidator = certificateValidator ?? throw new ArgumentNullException(nameof(certificateValidator));
this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
this.authorityOptions = authorityOptions ?? throw new ArgumentNullException(nameof(authorityOptions));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
{
@@ -124,14 +128,30 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
? existingConstraint
: null;
var normalizedSenderConstraint = !string.IsNullOrWhiteSpace(existingSenderConstraint)
? existingSenderConstraint
: ClientCredentialHandlerHelpers.NormalizeSenderConstraint(document);
if (!string.IsNullOrWhiteSpace(normalizedSenderConstraint))
{
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientSenderConstraintProperty] = normalizedSenderConstraint;
}
var normalizedSenderConstraint = !string.IsNullOrWhiteSpace(existingSenderConstraint)
? existingSenderConstraint
: ClientCredentialHandlerHelpers.NormalizeSenderConstraint(document);
var (mtlsRequired, matchedAudiences) = EvaluateMtlsRequirement(context.Request, document);
if (mtlsRequired)
{
if (string.IsNullOrWhiteSpace(normalizedSenderConstraint))
{
normalizedSenderConstraint = AuthoritySenderConstraintKinds.Mtls;
logger.LogDebug("Enforcing mTLS sender constraint for {ClientId} due to audiences {Audiences}.", document.ClientId, string.Join(",", matchedAudiences));
}
else if (!string.Equals(normalizedSenderConstraint, AuthoritySenderConstraintKinds.Mtls, StringComparison.Ordinal))
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Requested audiences require mutual TLS sender constraint.");
logger.LogWarning("Client credentials validation failed for {ClientId}: mTLS required for audiences {Audiences} but client sender constraint was {Constraint}.", context.ClientId, string.Join(",", matchedAudiences), normalizedSenderConstraint);
return;
}
}
if (!string.IsNullOrWhiteSpace(normalizedSenderConstraint))
{
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientSenderConstraintProperty] = normalizedSenderConstraint;
}
if (string.Equals(normalizedSenderConstraint, AuthoritySenderConstraintKinds.Mtls, StringComparison.Ordinal))
{
@@ -281,8 +301,95 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
}
}
}
}
private (bool Required, string[] Audiences) EvaluateMtlsRequirement(OpenIddictRequest? request, AuthorityClientDocument document)
{
var mtlsOptions = authorityOptions.Security.SenderConstraints.Mtls;
if (!mtlsOptions.Enabled)
{
return (false, Array.Empty<string>());
}
var enforcedAudiences = ResolveEnforcedAudiences(mtlsOptions);
if (enforcedAudiences.Count == 0)
{
return (false, Array.Empty<string>());
}
static void CollectMatches(IEnumerable<string?> values, ISet<string> enforced, HashSet<string> matches)
{
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
var candidate = value.Trim();
if (candidate.Length == 0)
{
continue;
}
if (enforced.Contains(candidate))
{
matches.Add(candidate);
}
}
}
var matched = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (request?.Resources is { } resources)
{
CollectMatches(resources, enforcedAudiences, matched);
}
if (request?.Audiences is { } audiences)
{
CollectMatches(audiences, enforcedAudiences, matched);
}
var configuredAudiences = ClientCredentialHandlerHelpers.Split(document.Properties, AuthorityClientMetadataKeys.Audiences);
if (configuredAudiences.Count > 0)
{
CollectMatches(configuredAudiences, enforcedAudiences, matched);
}
return matched.Count == 0
? (false, Array.Empty<string>())
: (true, matched.OrderBy(value => value, StringComparer.OrdinalIgnoreCase).ToArray());
}
private static HashSet<string> ResolveEnforcedAudiences(AuthorityMtlsOptions mtlsOptions)
{
if (mtlsOptions.NormalizedAudiences.Count > 0)
{
return new HashSet<string>(mtlsOptions.NormalizedAudiences, StringComparer.OrdinalIgnoreCase);
}
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var audience in mtlsOptions.EnforceForAudiences)
{
if (string.IsNullOrWhiteSpace(audience))
{
continue;
}
var trimmed = audience.Trim();
if (trimmed.Length == 0)
{
continue;
}
set.Add(trimmed);
}
return set;
}
}
internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext>
{

View File

@@ -1,10 +1,11 @@
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using System.Formats.Asn1;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using System.Formats.Asn1;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
@@ -145,12 +146,47 @@ internal sealed class AuthorityClientCertificateValidator : IAuthorityClientCert
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_unbound"));
}
if (binding.NotBefore is { } bindingNotBefore)
{
var effectiveNotBefore = bindingNotBefore - mtlsOptions.RotationGrace;
if (now < effectiveNotBefore)
{
logger.LogWarning("mTLS validation failed for {ClientId}: certificate binding not active until {NotBefore:o} (grace applied).", client.ClientId, bindingNotBefore);
if (!string.IsNullOrWhiteSpace(binding.Subject) &&
!string.Equals(binding.Subject, certificate.Subject, StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning("mTLS validation failed for {ClientId}: certificate subject {Subject} did not match binding subject {BindingSubject}.", client.ClientId, certificate.Subject, binding.Subject);
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_binding_subject_mismatch"));
}
if (!string.IsNullOrWhiteSpace(binding.SerialNumber))
{
var normalizedCertificateSerial = NormalizeSerialNumber(certificate.SerialNumber);
var normalizedBindingSerial = NormalizeSerialNumber(binding.SerialNumber);
if (!string.Equals(normalizedCertificateSerial, normalizedBindingSerial, StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning("mTLS validation failed for {ClientId}: certificate serial {Serial} did not match binding serial {BindingSerial}.", client.ClientId, normalizedCertificateSerial, normalizedBindingSerial);
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_binding_serial_mismatch"));
}
}
if (!string.IsNullOrWhiteSpace(binding.Issuer) &&
!string.Equals(binding.Issuer, certificate.Issuer, StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning("mTLS validation failed for {ClientId}: certificate issuer {Issuer} did not match binding issuer {BindingIssuer}.", client.ClientId, certificate.Issuer, binding.Issuer);
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_binding_issuer_mismatch"));
}
if (binding.SubjectAlternativeNames.Count > 0)
{
var certificateSans = new HashSet<string>(subjectAlternativeNames.Select(san => san.Value), StringComparer.OrdinalIgnoreCase);
if (!binding.SubjectAlternativeNames.All(san => certificateSans.Contains(san)))
{
logger.LogWarning("mTLS validation failed for {ClientId}: certificate SANs did not include all binding values.", client.ClientId);
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_binding_san_mismatch"));
}
}
if (binding.NotBefore is { } bindingNotBefore)
{
var effectiveNotBefore = bindingNotBefore - mtlsOptions.RotationGrace;
if (now < effectiveNotBefore)
{
logger.LogWarning("mTLS validation failed for {ClientId}: certificate binding not active until {NotBefore:o} (grace applied).", client.ClientId, bindingNotBefore);
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_binding_inactive"));
}
}
@@ -197,11 +233,11 @@ internal sealed class AuthorityClientCertificateValidator : IAuthorityClientCert
}
}
private static IReadOnlyList<(string Type, string Value)> GetSubjectAlternativeNames(X509Certificate2 certificate)
{
foreach (var extension in certificate.Extensions)
{
if (!string.Equals(extension.Oid?.Value, "2.5.29.17", StringComparison.Ordinal))
private static IReadOnlyList<(string Type, string Value)> GetSubjectAlternativeNames(X509Certificate2 certificate)
{
foreach (var extension in certificate.Extensions)
{
if (!string.Equals(extension.Oid?.Value, "2.5.29.17", StringComparison.Ordinal))
{
continue;
}
@@ -256,28 +292,28 @@ internal sealed class AuthorityClientCertificateValidator : IAuthorityClientCert
}
}
return Array.Empty<(string, string)>();
}
private bool ValidateCertificateChain(X509Certificate2 certificate)
{
using var chain = new X509Chain
{
ChainPolicy =
{
RevocationMode = X509RevocationMode.NoCheck,
RevocationFlag = X509RevocationFlag.ExcludeRoot,
VerificationFlags = X509VerificationFlags.IgnoreWrongUsage
}
};
try
{
return chain.Build(certificate);
}
catch (Exception ex)
{
logger.LogWarning(ex, "mTLS chain validation threw an exception.");
return false;
}
}
}
return Array.Empty<(string, string)>();
}
private static string NormalizeSerialNumber(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var buffer = new char[value.Length];
var length = 0;
foreach (var character in value)
{
if (character is ':' or ' ')
{
continue;
}
buffer[length++] = char.ToUpperInvariant(character);
}
return new string(buffer, 0, length);
}
}

View File

@@ -23,10 +23,9 @@
| AUTH-PLUGIN-COORD-08-002 | DONE (2025-10-20) | Authority Core, Plugin Platform Guild | PLUGIN-DI-08-001 | Coordinate scoped-service adoption for Authority plug-in registrars and background jobs ahead of PLUGIN-DI-08-002 implementation. | ✅ Workshop completed 2025-10-20 15:0016:05UTC with notes/action log in `docs/dev/authority-plugin-di-coordination.md`; ✅ Follow-up backlog updates assigned via documented action items ahead of PLUGIN-DI-08-002 delivery. |
| AUTH-DPOP-11-001 | DONE (2025-10-20) | Authority Core & Security Guild | — | Implement DPoP proof validation + nonce handling for high-value audiences per architecture. | ✅ Redis-configurable nonce store surfaced via `security.senderConstraints.dpop.nonce` with sample YAML and architecture docs refreshed<br>✅ High-value audience enforcement uses normalised required audiences to avoid whitespace/case drift<br>✅ Operator guide updated with Redis-backed nonce snippet and env-var override guidance; integration test already covers nonce challenge |
> Remark (2025-10-20): `etc/authority.yaml.sample` gains senderConstraint sections (rate limits, DPoP, mTLS), docs (`docs/ARCHITECTURE_AUTHORITY.md`, `docs/11_AUTHORITY.md`, plan) refreshed. `ResolveNonceAudience` now relies on `NormalizedAudiences` and options trim persisted values. `dotnet test StellaOps.Authority.sln` attempted (2025-10-20 15:12UTC) but failed on `NU1900` because the mirrored NuGet service index `https://mirrors.ablera.dev/nuget/nuget-mirror/v3/index.json` was unreachable; no project build executed.
| AUTH-MTLS-11-002 | DOING (2025-10-19) | Authority Core & Security Guild | — | Add OAuth mTLS client credential support with certificate-bound tokens and introspection updates. | • Certificate validator scaffold plus cnf stamping present; tokens persist sender thumbprints<br>• Remaining: provisioning/storage for certificate bindings, SAN/CA validation, introspection propagation, integration tests/docs before marking DONE |
> Remark (2025-10-19): Client provisioning accepts certificate bindings; validator enforces SAN types/CA allow-list with rotation grace; mtls integration tests updated (full suite still blocked by upstream build).
| AUTH-MTLS-11-002 | DONE (2025-10-23) | Authority Core & Security Guild | — | Add OAuth mTLS client credential support with certificate-bound tokens and introspection updates. | ✅ Deterministic provisioning/storage for certificate bindings (thumbprint/subject/issuer/serial/SAN)<br>✅ Audience enforcement auto-switches to mTLS via `security.senderConstraints.mtls.enforceForAudiences`<br>✅ Validator matches binding metadata with rotation grace and emits confirmation thumbprints<br>✅ Introspection returns `cnf.x5t#S256`; docs & sample config refreshed; Authority test suite green |
> Remark (2025-10-23): Audience enforcement now rejects non-mTLS clients targeting high-value audiences; certificate validator checks binding subject/issuer/serial/SAN values and returns deterministic error codes. Docs (`docs/11_AUTHORITY.md`, `docs/ARCHITECTURE_AUTHORITY.md`, `docs/dev/authority-dpop-mtls-plan.md`) and `etc/authority.yaml.sample` updated. `dotnet test src/StellaOps.Authority/StellaOps.Authority.sln` (2025-10-23 18:07UTC) succeeded.
> Remark (2025-10-19, AUTHSTORAGE-MONGO-08-001): Prerequisites re-checked (none outstanding). Session accessor wired through Authority pipeline; stores accept optional sessions; added replica-set election regression test for read-your-write.
> Remark (2025-10-19, AUTH-DPOP-11-001): Handler, nonce store, and persistence hooks merged; Redis-backed configuration + end-to-end nonce enforcement still open. (Superseded by 2025-10-20 update above.)
> Remark (2025-10-19, AUTH-MTLS-11-002): Certificate validator + cnf stamping delivered; binding storage, CA/SAN validation, integration suites outstanding before status can move to DONE.
> Update status columns (TODO / DOING / DONE / BLOCKED) together with code changes. Always run `dotnet test src/StellaOps.Authority.sln` when touching host logic.