Some checks failed
		
		
	
	Docs CI / lint-and-preview (push) Has been cancelled
				
			- 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.
		
			
				
	
	
		
			1771 lines
		
	
	
		
			76 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			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);
 | |
|     }
 | |
| }
 |