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.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.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(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.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(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.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.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.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(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); Assert.Equal(new[] { "effective:write" }, grantedScopes); var tenant = Assert.IsType(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.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.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.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.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(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); Assert.Equal(new[] { "graph:read" }, grantedScopes); var tenant = Assert.IsType(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.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(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); Assert.Equal(new[] { "graph:write" }, grantedScopes); var tenant = Assert.IsType(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.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.Instance); var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger.Instance); var dpopHandler = new ValidateDpopProofHandler( options, clientStore, dpopValidator, nonceStore, rateMetadata, auditSink, TimeProvider.System, TestActivitySource, NullLogger.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.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.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.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.Instance); var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger.Instance); var dpopHandler = new ValidateDpopProofHandler( options, clientStore, dpopValidator, nonceStore, rateMetadata, auditSink, TimeProvider.System, TestActivitySource, NullLogger.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.Instance); var handler = new ValidateClientCredentialsHandler( new TestClientStore(clientDocument), registry, TestActivitySource, auditSink, metadataAccessor, TimeProvider.System, validator, httpContextAccessor, options, NullLogger.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.Instance); var handler = new ValidateClientCredentialsHandler( new TestClientStore(clientDocument), registry, TestActivitySource, new TestAuthEventSink(), new TestRateLimiterMetadataAccessor(), TimeProvider.System, validator, httpContextAccessor, options, NullLogger.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.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.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.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.Instance); var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, TimeProvider.System, TestActivitySource, NullLogger.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(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.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.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.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 { 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.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.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.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.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 { "spiffe://client" } }); var httpContext = new DefaultHttpContext(); httpContext.Connection.ClientCertificate = certificate; var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.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 clients = new(StringComparer.OrdinalIgnoreCase); public TestClientStore(params AuthorityClientDocument[] documents) { foreach (var document in documents) { clients[document.ClientId] = document; } } public ValueTask 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 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? UsageCallback { get; set; } public ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) { Inserted = document; return ValueTask.CompletedTask; } public ValueTask 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 FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null) => ValueTask.FromResult(null); public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary? metadata, CancellationToken cancellationToken, IClientSessionHandle? session = null) => ValueTask.CompletedTask; public ValueTask DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null) => ValueTask.FromResult(0L); public ValueTask 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> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null) => ValueTask.FromResult>(Array.Empty()); } 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 VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken) => ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials)); public ValueTask> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken) => ValueTask.FromResult(AuthorityPluginOperationResult.Failure("unsupported", "not implemented")); public ValueTask 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> CreateOrUpdateAsync(AuthorityClientRegistration registration, CancellationToken cancellationToken) => ValueTask.FromResult(AuthorityPluginOperationResult.Failure("unsupported", "not implemented")); public ValueTask FindByClientIdAsync(string clientId, CancellationToken cancellationToken) => ValueTask.FromResult(descriptor); public ValueTask 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 CheckHealthAsync(CancellationToken cancellationToken) => ValueTask.FromResult(AuthorityPluginHealthResult.Healthy()); } internal sealed class TestAuthEventSink : IAuthEventSink { public List 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 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 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 GetSessionAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult(null!); public ValueTask DisposeAsync() => ValueTask.CompletedTask; } internal static class TestHelpers { public static StellaOpsAuthorityOptions CreateAuthorityOptions(Action? 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(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(); var allowedScopes = document.Properties.TryGetValue(AuthorityClientMetadataKeys.AllowedScopes, out var scopes) ? scopes?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty(); var allowedAudiences = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Audiences, out var audiences) ? audiences?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty(); return new AuthorityClientDescriptor( document.ClientId, document.DisplayName, confidential: string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase), allowedGrantTypes, allowedScopes, allowedAudiences, redirectUris: Array.Empty(), postLogoutRedirectUris: Array.Empty(), 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(); var manifest = new AuthorityPluginManifest( name, "standard", true, null, null, capabilities, new Dictionary(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(plugin); } var provider = services.BuildServiceProvider(); return new AuthorityIdentityProviderRegistry(provider, NullLogger.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 { ["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); } }