Files
git.stella-ops.org/src/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs
master 96d52884e8
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Add Policy DSL Validator, Schema Exporter, and Simulation Smoke tools
- Implemented PolicyDslValidator with command-line options for strict mode and JSON output.
- Created PolicySchemaExporter to generate JSON schemas for policy-related models.
- Developed PolicySimulationSmoke tool to validate policy simulations against expected outcomes.
- Added project files and necessary dependencies for each tool.
- Ensured proper error handling and usage instructions across tools.
2025-10-27 08:00:11 +02:00

1771 lines
76 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Configuration;
using StellaOps.Authority.Security;
using StellaOps.Auth.Security.Dpop;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.OpenIddict.Handlers;
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.Cryptography.Audit;
using Xunit;
using MongoDB.Bson;
using MongoDB.Driver;
using static StellaOps.Authority.Tests.OpenIddict.TestHelpers;
namespace StellaOps.Authority.Tests.OpenIddict;
public class ClientCredentialsHandlersTests
{
private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests");
[Fact]
public async Task ValidateClientCredentials_Rejects_WhenScopeNotAllowed()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
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:write");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error);
Assert.Equal("Scope 'jobs:write' is not allowed for this client.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_Allows_WhenConfigurationMatches()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
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,
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.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
Assert.False(context.Transaction.Properties.ContainsKey(AuthorityOpenIddictConstants.ClientTenantProperty));
Assert.Same(clientDocument, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty]);
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
Assert.Equal(new[] { "jobs:read" }, grantedScopes);
Assert.Equal(clientDocument.Plugin, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty]);
}
[Fact]
public async Task ValidateClientCredentials_Allows_NewIngestionScopes()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "advisory:ingest advisory:read",
tenant: "tenant-alpha");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
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: "advisory:ingest");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
Assert.Equal(new[] { "advisory:ingest" }, grantedScopes);
}
[Fact]
public async Task ValidateClientCredentials_RejectsEffectiveWrite_WhenServiceIdentityMissing()
{
var clientDocument = CreateClient(
clientId: "policy-engine",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "effective:write findings:read policy:run",
tenant: "tenant-default");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
Assert.True(clientDocument.Properties.ContainsKey(AuthorityClientMetadataKeys.Tenant));
Assert.Equal("tenant-default", clientDocument.Properties[AuthorityClientMetadataKeys.Tenant]);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "effective:write");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error);
Assert.Equal("Scope 'effective:write' is reserved for the Policy Engine service identity.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_RejectsEffectiveWrite_WhenTenantMissing()
{
var clientDocument = CreateClient(
clientId: "policy-engine",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "effective:write findings:read policy:run");
clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine;
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
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: "effective:write");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("Policy Engine service identity requires a tenant assignment.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_AllowsEffectiveWrite_ForPolicyEngineServiceIdentity()
{
var clientDocument = CreateClient(
clientId: "policy-engine",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "effective:write findings:read policy:run",
tenant: "tenant-default");
clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine;
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
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: "effective:write");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
Assert.Equal(new[] { "effective:write" }, grantedScopes);
var tenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
Assert.Equal("tenant-default", tenant);
}
[Fact]
public async Task ValidateClientCredentials_RejectsGraphWrite_WhenServiceIdentityMissing()
{
var clientDocument = CreateClient(
clientId: "cartographer-service",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "graph:write graph:read",
tenant: "tenant-default");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
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: "graph:write");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error);
Assert.Equal("Scope 'graph:write' is reserved for the Cartographer service identity.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_RejectsGraphWrite_WhenServiceIdentityMismatch()
{
var clientDocument = CreateClient(
clientId: "cartographer-service",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "graph:write graph:read",
tenant: "tenant-default");
clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine;
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
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: "graph:write");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error);
Assert.Equal("Scope 'graph:write' is reserved for the Cartographer service identity.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_RejectsGraphScopes_WhenTenantMissing()
{
var clientDocument = CreateClient(
clientId: "graph-api",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "graph:read graph:export");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
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: "graph:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("Graph scopes require a tenant assignment.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_AllowsGraphRead_WithTenant()
{
var clientDocument = CreateClient(
clientId: "graph-api",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "graph:read graph:export",
tenant: "tenant-default");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
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: "graph:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
Assert.Equal(new[] { "graph:read" }, grantedScopes);
var tenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
Assert.Equal("tenant-default", tenant);
}
[Fact]
public async Task ValidateClientCredentials_AllowsGraphWrite_ForCartographerServiceIdentity()
{
var clientDocument = CreateClient(
clientId: "cartographer-service",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "graph:write graph:read",
tenant: "tenant-default");
clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.Cartographer;
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
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: "graph:write");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
Assert.Equal(new[] { "graph:write" }, grantedScopes);
var tenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
Assert.Equal("tenant-default", tenant);
}
[Fact]
public async Task ValidateClientCredentials_EmitsTamperAuditEvent_WhenUnexpectedParametersPresent()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var sink = new TestAuthEventSink();
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
sink,
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
transaction.Request?.SetParameter("unexpected_param", "value");
await handler.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction));
var tamperEvent = Assert.Single(sink.Events, record => record.EventType == "authority.token.tamper");
Assert.Contains(tamperEvent.Properties, property =>
string.Equals(property.Name, "request.unexpected_parameter", StringComparison.OrdinalIgnoreCase) &&
string.Equals(property.Value.Value, "unexpected_param", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task ValidateDpopProof_AllowsSenderConstrainedClient()
{
var options = TestHelpers.CreateAuthorityOptions(opts =>
{
opts.Security.SenderConstraints.Dpop.Enabled = true;
opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false;
});
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read");
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop;
clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop;
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var securityKey = new ECDsaSecurityKey(ecdsa)
{
KeyId = Guid.NewGuid().ToString("N")
};
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey);
var expectedThumbprint = ConvertThumbprintToString(jwk.ComputeJwkThumbprint());
var clientStore = new TestClientStore(clientDocument);
var auditSink = new TestAuthEventSink();
var rateMetadata = new TestRateLimiterMetadataAccessor();
var dpopValidator = new DpopProofValidator(
Options.Create(new DpopValidationOptions()),
new InMemoryDpopReplayCache(TimeProvider.System),
TimeProvider.System,
NullLogger<DpopProofValidator>.Instance);
var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger<InMemoryDpopNonceStore>.Instance);
var dpopHandler = new ValidateDpopProofHandler(
options,
clientStore,
dpopValidator,
nonceStore,
rateMetadata,
auditSink,
TimeProvider.System,
TestActivitySource,
NullLogger<ValidateDpopProofHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
transaction.Options = new OpenIddictServerOptions();
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "POST";
httpContext.Request.Scheme = "https";
httpContext.Request.Host = new HostString("authority.test");
httpContext.Request.Path = "/token";
var now = TimeProvider.System.GetUtcNow();
var proof = TestHelpers.CreateDpopProof(securityKey, httpContext.Request.Method, httpContext.Request.GetDisplayUrl(), now.ToUnixTimeSeconds());
httpContext.Request.Headers["DPoP"] = proof;
transaction.Properties[typeof(HttpContext).FullName!] = httpContext;
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await dpopHandler.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected);
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var validateHandler = new ValidateClientCredentialsHandler(
clientStore,
registry,
TestActivitySource,
auditSink,
rateMetadata,
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
await validateHandler.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected);
var tokenStore = new TestTokenStore();
var sessionAccessor = new NullMongoSessionAccessor();
var handleHandler = new HandleClientCredentialsHandler(
registry,
tokenStore,
sessionAccessor,
rateMetadata,
TimeProvider.System,
TestActivitySource,
NullLogger<HandleClientCredentialsHandler>.Instance);
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
await handleHandler.HandleAsync(handleContext);
Assert.True(handleContext.IsRequestHandled);
var persistHandler = new PersistTokensHandler(
tokenStore,
sessionAccessor,
TimeProvider.System,
TestActivitySource,
NullLogger<PersistTokensHandler>.Instance);
var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction)
{
Principal = handleContext.Principal,
AccessTokenPrincipal = handleContext.Principal
};
await persistHandler.HandleAsync(signInContext);
var confirmationClaim = handleContext.Principal?.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType);
Assert.False(string.IsNullOrWhiteSpace(confirmationClaim));
using (var confirmationJson = JsonDocument.Parse(confirmationClaim!))
{
Assert.Equal(expectedThumbprint, confirmationJson.RootElement.GetProperty("jkt").GetString());
}
Assert.NotNull(tokenStore.Inserted);
Assert.Equal(AuthoritySenderConstraintKinds.Dpop, tokenStore.Inserted!.SenderConstraint);
Assert.Equal(expectedThumbprint, tokenStore.Inserted!.SenderKeyThumbprint);
}
[Fact]
public async Task ValidateDpopProof_IssuesNonceChallenge_WhenNonceMissing()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test")
};
options.Security.SenderConstraints.Dpop.Enabled = true;
options.Security.SenderConstraints.Dpop.Nonce.Enabled = true;
options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Clear();
options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Add("signer");
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Storage.ConnectionString = "mongodb://localhost/test";
Assert.Contains("signer", options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences);
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read",
allowedAudiences: "signer");
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop;
clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop;
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var securityKey = new ECDsaSecurityKey(ecdsa)
{
KeyId = Guid.NewGuid().ToString("N")
};
var clientStore = new TestClientStore(clientDocument);
var auditSink = new TestAuthEventSink();
var rateMetadata = new TestRateLimiterMetadataAccessor();
var dpopValidator = new DpopProofValidator(
Options.Create(new DpopValidationOptions()),
new InMemoryDpopReplayCache(TimeProvider.System),
TimeProvider.System,
NullLogger<DpopProofValidator>.Instance);
var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger<InMemoryDpopNonceStore>.Instance);
var dpopHandler = new ValidateDpopProofHandler(
options,
clientStore,
dpopValidator,
nonceStore,
rateMetadata,
auditSink,
TimeProvider.System,
TestActivitySource,
NullLogger<ValidateDpopProofHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
transaction.Options = new OpenIddictServerOptions();
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "POST";
httpContext.Request.Scheme = "https";
httpContext.Request.Host = new HostString("authority.test");
httpContext.Request.Path = "/token";
var now = TimeProvider.System.GetUtcNow();
var proof = TestHelpers.CreateDpopProof(securityKey, httpContext.Request.Method, httpContext.Request.GetDisplayUrl(), now.ToUnixTimeSeconds());
httpContext.Request.Headers["DPoP"] = proof;
transaction.Properties[typeof(HttpContext).FullName!] = httpContext;
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await dpopHandler.HandleAsync(validateContext);
Assert.True(validateContext.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, validateContext.Error);
var authenticateHeader = Assert.Single(httpContext.Response.Headers.Select(header => header)
.Where(header => string.Equals(header.Key, "WWW-Authenticate", StringComparison.OrdinalIgnoreCase))).Value;
Assert.Contains("use_dpop_nonce", authenticateHeader.ToString());
Assert.True(httpContext.Response.Headers.TryGetValue("DPoP-Nonce", out var nonceValues));
Assert.False(StringValues.IsNullOrEmpty(nonceValues));
Assert.Contains(auditSink.Events, record => record.EventType == "authority.dpop.proof.challenge");
}
[Fact]
public async Task ValidateClientCredentials_AllowsMtlsClient_WithValidCertificate()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test")
};
options.Security.SenderConstraints.Mtls.Enabled = true;
options.Security.SenderConstraints.Mtls.RequireChainValidation = false;
options.Security.SenderConstraints.Mtls.AllowedSanTypes.Clear();
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Storage.ConnectionString = "mongodb://localhost/test";
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read");
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Mtls;
using var rsa = RSA.Create(2048);
var certificateRequest = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
using var certificate = certificateRequest.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
var hexThumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256));
clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding
{
Thumbprint = hexThumbprint
});
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var auditSink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() };
httpContextAccessor.HttpContext!.Connection.ClientCertificate = certificate;
var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger<AuthorityClientCertificateValidator>.Instance);
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
auditSink,
metadataAccessor,
TimeProvider.System,
validator,
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.False(context.IsRejected, context.ErrorDescription ?? context.Error);
Assert.Equal(AuthoritySenderConstraintKinds.Mtls, context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty]);
var expectedBase64 = Base64UrlEncoder.Encode(certificate.GetCertHash(HashAlgorithmName.SHA256));
Assert.Equal(expectedBase64, context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty]);
}
[Fact]
public async Task ValidateClientCredentials_RejectsMtlsClient_WhenCertificateMissing()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test")
};
options.Security.SenderConstraints.Mtls.Enabled = true;
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Storage.ConnectionString = "mongodb://localhost/test";
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read");
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Mtls;
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() };
var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger<AuthorityClientCertificateValidator>.Instance);
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
validator,
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);
}
[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()
{
var clientDocument = CreateClient(
secret: null,
clientType: "public",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:trigger",
allowedAudiences: "signer",
tenant: "Tenant-Alpha");
var descriptor = CreateDescriptor(clientDocument);
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor);
var tokenStore = new TestTokenStore();
var sessionAccessor = new NullMongoSessionAccessor();
var authSink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var options = TestHelpers.CreateAuthorityOptions();
var validateHandler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
authSink,
metadataAccessor,
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, secret: null, scope: "jobs:trigger");
transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(30);
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validateHandler.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected);
var handler = new HandleClientCredentialsHandler(
registry,
tokenStore,
sessionAccessor,
metadataAccessor,
TimeProvider.System,
TestActivitySource,
NullLogger<HandleClientCredentialsHandler>.Instance);
var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, TimeProvider.System, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
var context = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRequestHandled);
Assert.NotNull(context.Principal);
Assert.Contains("signer", context.Principal!.GetAudiences());
Assert.Contains(authSink.Events, record => record.EventType == "authority.client_credentials.grant" && record.Outcome == AuthEventOutcome.Success);
var identityProviderClaim = context.Principal?.GetClaim(StellaOpsClaimTypes.IdentityProvider);
Assert.Equal(clientDocument.Plugin, identityProviderClaim);
var principal = context.Principal ?? throw new InvalidOperationException("Principal missing");
Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId);
Assert.False(string.IsNullOrWhiteSpace(tokenId));
var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction)
{
Principal = principal,
AccessTokenPrincipal = principal
};
await persistHandler.HandleAsync(signInContext);
var persisted = Assert.IsType<AuthorityTokenDocument>(tokenStore.Inserted);
Assert.Equal(tokenId, persisted.TokenId);
Assert.Equal(clientDocument.ClientId, persisted.ClientId);
Assert.Equal("valid", persisted.Status);
Assert.Equal("tenant-alpha", persisted.Tenant);
Assert.Equal(new[] { "jobs:trigger" }, persisted.Scope);
}
}
public class TokenValidationHandlersTests
{
private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests.TokenValidation");
[Fact]
public async Task ValidateAccessTokenHandler_Rejects_WhenTokenRevoked()
{
var tokenStore = new TestTokenStore();
tokenStore.Inserted = new AuthorityTokenDocument
{
TokenId = "token-1",
Status = "revoked",
ClientId = "concelier"
};
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(CreateClient()),
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(CreateClient())),
metadataAccessor,
auditSink,
TimeProvider.System,
TestActivitySource,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Token,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal("concelier", "token-1", "standard");
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = "token-1"
};
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
}
[Fact]
public async Task ValidateAccessTokenHandler_EnrichesClaims_WhenProviderAvailable()
{
var clientDocument = CreateClient();
var userDescriptor = new AuthorityUserDescriptor("user-1", "alice", displayName: "Alice", requiresPasswordReset: false);
var plugin = CreatePlugin(
name: "standard",
supportsClientProvisioning: true,
descriptor: CreateDescriptor(clientDocument),
user: userDescriptor);
var registry = CreateRegistryFromPlugins(plugin);
var metadataAccessorSuccess = new TestRateLimiterMetadataAccessor();
var auditSinkSuccess = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
new TestTokenStore(),
sessionAccessor,
new TestClientStore(clientDocument),
registry,
metadataAccessorSuccess,
auditSinkSuccess,
TimeProvider.System,
TestActivitySource,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Token,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal(clientDocument.ClientId, "token-123", plugin.Name, subject: userDescriptor.SubjectId);
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal
};
await handler.HandleAsync(context);
Assert.False(context.IsRejected);
Assert.Contains(principal.Claims, claim => claim.Type == "enriched" && claim.Value == "true");
}
[Fact]
public async Task ValidateAccessTokenHandler_AddsConfirmationClaim_ForMtlsToken()
{
var tokenDocument = new AuthorityTokenDocument
{
TokenId = "token-mtls",
Status = "valid",
ClientId = "mtls-client",
SenderConstraint = AuthoritySenderConstraintKinds.Mtls,
SenderKeyThumbprint = "thumb-print"
};
var tokenStore = new TestTokenStore
{
Inserted = tokenDocument
};
var clientDocument = CreateClient();
var registry = CreateRegistry(withClientProvisioning: false, clientDescriptor: null);
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(clientDocument),
registry,
metadataAccessor,
auditSink,
TimeProvider.System,
TestActivitySource,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Introspection,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal(clientDocument.ClientId, tokenDocument.TokenId, clientDocument.Plugin);
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = tokenDocument.TokenId
};
await handler.HandleAsync(context);
Assert.False(context.IsRejected);
var confirmation = context.Principal?.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType);
Assert.False(string.IsNullOrWhiteSpace(confirmation));
using var json = JsonDocument.Parse(confirmation!);
Assert.Equal(tokenDocument.SenderKeyThumbprint, json.RootElement.GetProperty("x5t#S256").GetString());
}
[Fact]
public async Task ValidateAccessTokenHandler_EmitsReplayAudit_WhenStoreDetectsSuspectedReplay()
{
var tokenStore = new TestTokenStore();
tokenStore.Inserted = new AuthorityTokenDocument
{
TokenId = "token-replay",
Status = "valid",
ClientId = "agent",
Devices = new List<BsonDocument>
{
new BsonDocument
{
{ "remoteAddress", "10.0.0.1" },
{ "userAgent", "agent/1.0" },
{ "firstSeen", BsonDateTime.Create(DateTimeOffset.UtcNow.AddMinutes(-15)) },
{ "lastSeen", BsonDateTime.Create(DateTimeOffset.UtcNow.AddMinutes(-5)) },
{ "useCount", 2 }
}
}
};
tokenStore.UsageCallback = (remote, agent) => new TokenUsageUpdateResult(TokenUsageUpdateStatus.SuspectedReplay, remote, agent);
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var metadata = metadataAccessor.GetMetadata();
if (metadata is not null)
{
metadata.RemoteIp = "203.0.113.7";
metadata.UserAgent = "agent/2.0";
}
var clientDocument = CreateClient();
clientDocument.ClientId = "agent";
var auditSink = new TestAuthEventSink();
var registry = CreateRegistry(withClientProvisioning: false, clientDescriptor: null);
var sessionAccessorReplay = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessorReplay,
new TestClientStore(clientDocument),
registry,
metadataAccessor,
auditSink,
TimeProvider.System,
TestActivitySource,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Introspection,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal("agent", "token-replay", "standard");
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = "token-replay"
};
await handler.HandleAsync(context);
Assert.False(context.IsRejected);
var replayEvent = Assert.Single(auditSink.Events, record => record.EventType == "authority.token.replay.suspected");
Assert.Equal(AuthEventOutcome.Error, replayEvent.Outcome);
Assert.NotNull(replayEvent.Network);
Assert.Equal("203.0.113.7", replayEvent.Network?.RemoteAddress.Value);
Assert.Contains(replayEvent.Properties, property => property.Name == "token.devices.total");
}
}
public class AuthorityClientCertificateValidatorTests
{
[Fact]
public async Task ValidateAsync_Rejects_WhenSanTypeNotAllowed()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test")
};
options.Security.SenderConstraints.Mtls.Enabled = true;
options.Security.SenderConstraints.Mtls.RequireChainValidation = false;
options.Security.SenderConstraints.Mtls.AllowedSanTypes.Clear();
options.Security.SenderConstraints.Mtls.AllowedSanTypes.Add("uri");
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))
});
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_san_type", result.Error);
}
[Fact]
public async Task ValidateAsync_AllowsBindingWithinRotationGrace()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test")
};
options.Security.SenderConstraints.Mtls.Enabled = true;
options.Security.SenderConstraints.Mtls.RequireChainValidation = false;
options.Security.SenderConstraints.Mtls.RotationGrace = TimeSpan.FromMinutes(5);
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(10));
var thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256));
var clientDocument = CreateClient();
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding
{
Thumbprint = thumbprint,
NotBefore = TimeProvider.System.GetUtcNow().AddMinutes(2)
});
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.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
{
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
public TestClientStore(params AuthorityClientDocument[] documents)
{
foreach (var document in documents)
{
clients[document.ClientId] = document;
}
}
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(clients.Remove(clientId));
}
internal sealed class TestTokenStore : IAuthorityTokenStore
{
public AuthorityTokenDocument? Inserted { get; set; }
public Func<string?, string?, TokenUsageUpdateResult>? UsageCallback { get; set; }
public ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Inserted = document;
return ValueTask.CompletedTask;
}
public ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(Inserted is not null && string.Equals(Inserted.TokenId, tokenId, StringComparison.OrdinalIgnoreCase) ? Inserted : null);
public ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<AuthorityTokenDocument?>(null);
public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary<string, string?>? metadata, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.CompletedTask;
public ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(0L);
public ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(UsageCallback?.Invoke(remoteAddress, userAgent) ?? new TokenUsageUpdateResult(TokenUsageUpdateStatus.Recorded, remoteAddress, userAgent));
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(Array.Empty<AuthorityTokenDocument>());
}
internal sealed class TestClaimsEnricher : IClaimsEnricher
{
public ValueTask EnrichAsync(ClaimsIdentity identity, AuthorityClaimsEnrichmentContext context, CancellationToken cancellationToken)
{
if (!identity.HasClaim(c => c.Type == "enriched"))
{
identity.AddClaim(new Claim("enriched", "true"));
}
return ValueTask.CompletedTask;
}
}
internal sealed class TestUserCredentialStore : IUserCredentialStore
{
private readonly AuthorityUserDescriptor? user;
public TestUserCredentialStore(AuthorityUserDescriptor? user)
{
this.user = user;
}
public ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken)
=> ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials));
public ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken)
=> ValueTask.FromResult(AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("unsupported", "not implemented"));
public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
=> ValueTask.FromResult(user);
}
internal sealed class TestClientProvisioningStore : IClientProvisioningStore
{
private readonly AuthorityClientDescriptor? descriptor;
public TestClientProvisioningStore(AuthorityClientDescriptor? descriptor)
{
this.descriptor = descriptor;
}
public ValueTask<AuthorityPluginOperationResult<AuthorityClientDescriptor>> CreateOrUpdateAsync(AuthorityClientRegistration registration, CancellationToken cancellationToken)
=> ValueTask.FromResult(AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("unsupported", "not implemented"));
public ValueTask<AuthorityClientDescriptor?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
=> ValueTask.FromResult(descriptor);
public ValueTask<AuthorityPluginOperationResult> DeleteAsync(string clientId, CancellationToken cancellationToken)
=> ValueTask.FromResult(AuthorityPluginOperationResult.Success());
}
internal sealed class TestIdentityProviderPlugin : IIdentityProviderPlugin
{
public TestIdentityProviderPlugin(
AuthorityPluginContext context,
IUserCredentialStore credentialStore,
IClaimsEnricher claimsEnricher,
IClientProvisioningStore? clientProvisioning,
AuthorityIdentityProviderCapabilities capabilities)
{
Context = context;
Credentials = credentialStore;
ClaimsEnricher = claimsEnricher;
ClientProvisioning = clientProvisioning;
Capabilities = capabilities;
}
public string Name => Context.Manifest.Name;
public string Type => Context.Manifest.Type;
public AuthorityPluginContext Context { get; }
public IUserCredentialStore Credentials { get; }
public IClaimsEnricher ClaimsEnricher { get; }
public IClientProvisioningStore? ClientProvisioning { get; }
public AuthorityIdentityProviderCapabilities Capabilities { get; }
public ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(AuthorityPluginHealthResult.Healthy());
}
internal sealed class TestAuthEventSink : IAuthEventSink
{
public List<AuthEventRecord> Events { get; } = new();
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
{
Events.Add(record);
return ValueTask.CompletedTask;
}
}
internal sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMetadataAccessor
{
private readonly AuthorityRateLimiterMetadata metadata = new();
public AuthorityRateLimiterMetadata? GetMetadata() => metadata;
public void SetClientId(string? clientId) => metadata.ClientId = clientId;
public void SetSubjectId(string? subjectId) => metadata.SubjectId = subjectId;
public void SetTenant(string? tenant)
{
metadata.Tenant = string.IsNullOrWhiteSpace(tenant) ? null : tenant.Trim().ToLowerInvariant();
metadata.SetTag("authority.tenant", metadata.Tenant);
}
public void SetTag(string name, string? value) => metadata.SetTag(name, value);
}
internal sealed class NoopCertificateValidator : IAuthorityClientCertificateValidator
{
public ValueTask<AuthorityClientCertificateValidationResult> ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken)
{
var binding = new AuthorityClientCertificateBinding
{
Thumbprint = "stub"
};
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Success("stub", "stub", binding));
}
}
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)
=> ValueTask.FromResult<IClientSessionHandle>(null!);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
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 clientId = "concelier",
string? secret = "s3cr3t!",
string clientType = "confidential",
string allowedGrantTypes = "client_credentials",
string allowedScopes = "jobs:read",
string allowedAudiences = "",
string? tenant = null)
{
var document = new AuthorityClientDocument
{
ClientId = clientId,
ClientType = clientType,
SecretHash = secret is null ? null : AuthoritySecretHasher.ComputeHash(secret),
Plugin = "standard",
Properties = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
[AuthorityClientMetadataKeys.AllowedGrantTypes] = allowedGrantTypes,
[AuthorityClientMetadataKeys.AllowedScopes] = allowedScopes
}
};
if (!string.IsNullOrWhiteSpace(allowedAudiences))
{
document.Properties[AuthorityClientMetadataKeys.Audiences] = allowedAudiences;
}
var normalizedTenant = NormalizeTenant(tenant);
if (normalizedTenant is not null)
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenant;
}
return document;
}
private static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
public static AuthorityClientDescriptor CreateDescriptor(AuthorityClientDocument document)
{
var allowedGrantTypes = document.Properties.TryGetValue(AuthorityClientMetadataKeys.AllowedGrantTypes, out var grants) ? grants?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty<string>();
var allowedScopes = document.Properties.TryGetValue(AuthorityClientMetadataKeys.AllowedScopes, out var scopes) ? scopes?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty<string>();
var allowedAudiences = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Audiences, out var audiences) ? audiences?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty<string>();
return new AuthorityClientDescriptor(
document.ClientId,
document.DisplayName,
confidential: string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase),
allowedGrantTypes,
allowedScopes,
allowedAudiences,
redirectUris: Array.Empty<Uri>(),
postLogoutRedirectUris: Array.Empty<Uri>(),
properties: document.Properties);
}
public static AuthorityIdentityProviderRegistry CreateRegistry(bool withClientProvisioning, AuthorityClientDescriptor? clientDescriptor)
{
var plugin = CreatePlugin(
name: "standard",
supportsClientProvisioning: withClientProvisioning,
descriptor: clientDescriptor,
user: null);
return CreateRegistryFromPlugins(plugin);
}
public static TestIdentityProviderPlugin CreatePlugin(
string name,
bool supportsClientProvisioning,
AuthorityClientDescriptor? descriptor,
AuthorityUserDescriptor? user)
{
var capabilities = supportsClientProvisioning
? new[] { AuthorityPluginCapabilities.ClientProvisioning }
: Array.Empty<string>();
var manifest = new AuthorityPluginManifest(
name,
"standard",
true,
null,
null,
capabilities,
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
$"{name}.yaml");
var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
return new TestIdentityProviderPlugin(
context,
new TestUserCredentialStore(user),
new TestClaimsEnricher(),
supportsClientProvisioning ? new TestClientProvisioningStore(descriptor) : null,
new AuthorityIdentityProviderCapabilities(
SupportsPassword: true,
SupportsMfa: false,
SupportsClientProvisioning: supportsClientProvisioning));
}
public static AuthorityIdentityProviderRegistry CreateRegistryFromPlugins(params IIdentityProviderPlugin[] plugins)
{
var services = new ServiceCollection();
services.AddLogging();
foreach (var plugin in plugins)
{
services.AddSingleton<IIdentityProviderPlugin>(plugin);
}
var provider = services.BuildServiceProvider();
return new AuthorityIdentityProviderRegistry(provider, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
}
public static OpenIddictServerTransaction CreateTokenTransaction(string clientId, string? secret, string? scope)
{
var request = new OpenIddictRequest
{
GrantType = OpenIddictConstants.GrantTypes.ClientCredentials,
ClientId = clientId,
ClientSecret = secret
};
if (!string.IsNullOrWhiteSpace(scope))
{
request.Scope = scope;
}
return new OpenIddictServerTransaction
{
EndpointType = OpenIddictServerEndpointType.Token,
Options = new OpenIddictServerOptions(),
Request = request
};
}
public static string ConvertThumbprintToString(object thumbprint)
=> thumbprint switch
{
string value => value,
byte[] bytes => Base64UrlEncoder.Encode(bytes),
_ => throw new InvalidOperationException("Unsupported thumbprint representation.")
};
public static string CreateDpopProof(ECDsaSecurityKey key, string method, string url, long issuedAt, string? nonce = null)
{
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(key);
jwk.KeyId ??= key.KeyId ?? Guid.NewGuid().ToString("N");
var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
var header = new JwtHeader(signingCredentials)
{
["typ"] = "dpop+jwt",
["jwk"] = new Dictionary<string, object?>
{
["kty"] = jwk.Kty,
["crv"] = jwk.Crv,
["x"] = jwk.X,
["y"] = jwk.Y,
["kid"] = jwk.Kid ?? jwk.KeyId
}
};
var payload = new JwtPayload
{
["htm"] = method.ToUpperInvariant(),
["htu"] = url,
["iat"] = issuedAt,
["jti"] = Guid.NewGuid().ToString("N")
};
if (!string.IsNullOrWhiteSpace(nonce))
{
payload["nonce"] = nonce;
}
var token = new JwtSecurityToken(header, payload);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public static X509Certificate2 CreateTestCertificate(string subjectName)
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
}
public static ClaimsPrincipal CreatePrincipal(string clientId, string tokenId, string provider, string? subject = null)
{
var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
identity.AddClaim(new Claim(OpenIddictConstants.Claims.ClientId, clientId));
identity.AddClaim(new Claim(OpenIddictConstants.Claims.JwtId, tokenId));
identity.AddClaim(new Claim(StellaOpsClaimTypes.IdentityProvider, provider));
if (!string.IsNullOrWhiteSpace(subject))
{
identity.AddClaim(new Claim(OpenIddictConstants.Claims.Subject, subject));
}
return new ClaimsPrincipal(identity);
}
}