- Added `SchedulerWorkerOptions` class to encapsulate configuration for the scheduler worker. - Introduced `PlannerBackgroundService` to manage the planner loop, fetching and processing planning runs. - Created `PlannerExecutionService` to handle the execution logic for planning runs, including impact targeting and run persistence. - Developed `PlannerExecutionResult` and `PlannerExecutionStatus` to standardize execution outcomes. - Implemented validation logic within `SchedulerWorkerOptions` to ensure proper configuration. - Added documentation for the planner loop and impact targeting features. - Established health check endpoints and authentication mechanisms for the Signals service. - Created unit tests for the Signals API to ensure proper functionality and response handling. - Configured options for authority integration and fallback authentication methods.
2072 lines
88 KiB
C#
2072 lines
88 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 System.Linq;
|
|
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_RejectsAdvisoryScopes_WhenTenantMissing()
|
|
{
|
|
var clientDocument = CreateClient(
|
|
clientId: "concelier-ingestor",
|
|
secret: "s3cr3t!",
|
|
allowedGrantTypes: "client_credentials",
|
|
allowedScopes: "advisory:ingest advisory: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: "advisory:ingest");
|
|
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
|
|
|
await handler.HandleAsync(context);
|
|
|
|
Assert.True(context.IsRejected);
|
|
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
|
|
Assert.Equal("Advisory scopes require a tenant assignment.", context.ErrorDescription);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateClientCredentials_RejectsVexScopes_WhenTenantMissing()
|
|
{
|
|
var clientDocument = CreateClient(
|
|
clientId: "excitor-ingestor",
|
|
secret: "s3cr3t!",
|
|
allowedGrantTypes: "client_credentials",
|
|
allowedScopes: "vex:ingest vex: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: "vex:read");
|
|
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
|
|
|
await handler.HandleAsync(context);
|
|
|
|
Assert.True(context.IsRejected);
|
|
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
|
|
Assert.Equal("VEX scopes require a tenant assignment.", context.ErrorDescription);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateClientCredentials_AllowsAdvisoryScopes_WithTenant()
|
|
{
|
|
var clientDocument = CreateClient(
|
|
clientId: "concelier-ingestor",
|
|
secret: "s3cr3t!",
|
|
allowedGrantTypes: "client_credentials",
|
|
allowedScopes: "advisory:ingest advisory: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: "advisory: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[] { "advisory:read" }, grantedScopes);
|
|
}
|
|
|
|
[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_AddsTenantClaim_FromTokenDocument()
|
|
{
|
|
var clientDocument = CreateClient(tenant: "tenant-alpha");
|
|
var tokenStore = new TestTokenStore
|
|
{
|
|
Inserted = new AuthorityTokenDocument
|
|
{
|
|
TokenId = "token-tenant",
|
|
Status = "valid",
|
|
ClientId = clientDocument.ClientId,
|
|
Tenant = "tenant-alpha"
|
|
}
|
|
};
|
|
|
|
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
|
var auditSink = new TestAuthEventSink();
|
|
var sessionAccessor = new NullMongoSessionAccessor();
|
|
var handler = new ValidateAccessTokenHandler(
|
|
tokenStore,
|
|
sessionAccessor,
|
|
new TestClientStore(clientDocument),
|
|
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
|
|
metadataAccessor,
|
|
auditSink,
|
|
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-tenant", clientDocument.Plugin);
|
|
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
|
|
{
|
|
Principal = principal,
|
|
TokenId = "token-tenant"
|
|
};
|
|
|
|
await handler.HandleAsync(context);
|
|
|
|
Assert.False(context.IsRejected);
|
|
Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
|
|
Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAccessTokenHandler_Rejects_WhenTenantDiffersFromToken()
|
|
{
|
|
var clientDocument = CreateClient(tenant: "tenant-alpha");
|
|
var tokenStore = new TestTokenStore
|
|
{
|
|
Inserted = new AuthorityTokenDocument
|
|
{
|
|
TokenId = "token-tenant",
|
|
Status = "valid",
|
|
ClientId = clientDocument.ClientId,
|
|
Tenant = "tenant-alpha"
|
|
}
|
|
};
|
|
|
|
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
|
var auditSink = new TestAuthEventSink();
|
|
var sessionAccessor = new NullMongoSessionAccessor();
|
|
var handler = new ValidateAccessTokenHandler(
|
|
tokenStore,
|
|
sessionAccessor,
|
|
new TestClientStore(clientDocument),
|
|
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
|
|
metadataAccessor,
|
|
auditSink,
|
|
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-tenant", clientDocument.Plugin);
|
|
principal.Identities.First().AddClaim(new Claim(StellaOpsClaimTypes.Tenant, "tenant-beta"));
|
|
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
|
|
{
|
|
Principal = principal,
|
|
TokenId = "token-tenant"
|
|
};
|
|
|
|
await handler.HandleAsync(context);
|
|
|
|
Assert.True(context.IsRejected);
|
|
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
|
|
Assert.Equal("The token tenant does not match the issued tenant.", context.ErrorDescription);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAccessTokenHandler_AssignsTenant_FromClientWhenTokenMissing()
|
|
{
|
|
var clientDocument = CreateClient(tenant: "tenant-alpha");
|
|
var tokenStore = new TestTokenStore
|
|
{
|
|
Inserted = new AuthorityTokenDocument
|
|
{
|
|
TokenId = "token-tenant",
|
|
Status = "valid",
|
|
ClientId = clientDocument.ClientId
|
|
}
|
|
};
|
|
|
|
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
|
var auditSink = new TestAuthEventSink();
|
|
var sessionAccessor = new NullMongoSessionAccessor();
|
|
var handler = new ValidateAccessTokenHandler(
|
|
tokenStore,
|
|
sessionAccessor,
|
|
new TestClientStore(clientDocument),
|
|
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
|
|
metadataAccessor,
|
|
auditSink,
|
|
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-tenant", clientDocument.Plugin);
|
|
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
|
|
{
|
|
Principal = principal,
|
|
TokenId = "token-tenant"
|
|
};
|
|
|
|
await handler.HandleAsync(context);
|
|
|
|
Assert.False(context.IsRejected);
|
|
Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
|
|
Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAccessTokenHandler_Rejects_WhenClientTenantDiffers()
|
|
{
|
|
var clientDocument = CreateClient(tenant: "tenant-beta");
|
|
var tokenStore = new TestTokenStore
|
|
{
|
|
Inserted = new AuthorityTokenDocument
|
|
{
|
|
TokenId = "token-tenant",
|
|
Status = "valid",
|
|
ClientId = clientDocument.ClientId
|
|
}
|
|
};
|
|
|
|
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
|
var auditSink = new TestAuthEventSink();
|
|
var sessionAccessor = new NullMongoSessionAccessor();
|
|
var handler = new ValidateAccessTokenHandler(
|
|
tokenStore,
|
|
sessionAccessor,
|
|
new TestClientStore(clientDocument),
|
|
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
|
|
metadataAccessor,
|
|
auditSink,
|
|
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-tenant", clientDocument.Plugin);
|
|
principal.Identities.First().AddClaim(new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha"));
|
|
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
|
|
{
|
|
Principal = principal,
|
|
TokenId = "token-tenant"
|
|
};
|
|
|
|
await handler.HandleAsync(context);
|
|
|
|
Assert.True(context.IsRejected);
|
|
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
|
|
Assert.Equal("The token tenant does not match the registered client tenant.", context.ErrorDescription);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|