feat: Add documentation and task tracking for Sprints 508 to 514 in Ops & Offline

- Created detailed markdown files for Sprints 508 (Ops Offline Kit), 509 (Samples), 510 (AirGap), 511 (Api), 512 (Bench), 513 (Provenance), and 514 (Sovereign Crypto Enablement) outlining tasks, dependencies, and owners.
- Introduced a comprehensive Reachability Evidence Delivery Guide to streamline the reachability signal process.
- Implemented unit tests for Advisory AI to block known injection patterns and redact secrets.
- Added AuthoritySenderConstraintHelper to manage sender constraints in OpenIddict transactions.
This commit is contained in:
master
2025-11-08 23:18:28 +02:00
parent 536f6249a6
commit ae69b1a8a1
187 changed files with 4326 additions and 3196 deletions

View File

@@ -11,6 +11,7 @@ using System.Text.Json;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.DependencyInjection;
@@ -405,7 +406,6 @@ public class ClientCredentialsHandlersTests
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected);
Assert.Equal("prod", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnEnvironmentProperty]);
Assert.Equal("security", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnOwnerProperty]);
@@ -2365,6 +2365,7 @@ public class ClientCredentialsHandlersTests
auditSink,
TimeProvider.System,
TestInstruments.ActivitySource,
TestInstruments.Meter,
NullLogger<ValidateDpopProofHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
@@ -2485,6 +2486,7 @@ public class ClientCredentialsHandlersTests
auditSink,
TimeProvider.System,
TestInstruments.ActivitySource,
TestInstruments.Meter,
NullLogger<ValidateDpopProofHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
@@ -2564,6 +2566,7 @@ public class ClientCredentialsHandlersTests
auditSink,
TimeProvider.System,
TestInstruments.ActivitySource,
TestInstruments.Meter,
NullLogger<ValidateDpopProofHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
@@ -2757,6 +2760,7 @@ public class ClientCredentialsHandlersTests
auditSink,
TimeProvider.System,
TestInstruments.ActivitySource,
TestInstruments.Meter,
NullLogger<ValidateDpopProofHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
@@ -2788,6 +2792,69 @@ public class ClientCredentialsHandlersTests
Assert.Contains(auditSink.Events, record => record.EventType == "authority.dpop.proof.challenge");
}
[Fact]
public async Task ValidateDpopProof_AllowsBypass_WhenEnabled()
{
var options = TestHelpers.CreateAuthorityOptions(opts =>
{
opts.Security.SenderConstraints.Dpop.Enabled = true;
opts.Security.SenderConstraints.Dpop.AllowTemporaryBypass = 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;
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,
TestInstruments.ActivitySource,
TestInstruments.Meter,
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";
transaction.Properties[typeof(HttpContext).FullName!] = httpContext;
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await dpopHandler.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected);
Assert.False(validateContext.Transaction.Properties.ContainsKey(AuthorityOpenIddictConstants.SenderConstraintProperty));
var bypassEvent = Assert.Single(auditSink.Events.Where(record => record.EventType == "authority.dpop.proof.bypass"));
Assert.Equal(AuthEventOutcome.Success, bypassEvent.Outcome);
var reasonProperty = Assert.Single(bypassEvent.Properties.Where(property => property.Name == "dpop.reason_code"));
Assert.Equal("bypass", reasonProperty.Value.Value);
}
[Fact]
public async Task ValidateClientCredentials_AllowsMtlsClient_WithValidCertificate()
{
@@ -3646,6 +3713,9 @@ public class TokenValidationHandlersTests
[Fact]
public async Task ValidateAccessTokenHandler_AddsConfirmationClaim_ForMtlsToken()
{
using var certificate = TestHelpers.CreateTestCertificate("CN=mtls-client");
var expectedHexThumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256));
var tokenDocument = new AuthorityTokenDocument
{
TokenId = "token-mtls",
@@ -3653,7 +3723,7 @@ public class TokenValidationHandlersTests
ClientId = "mtls-client",
SenderConstraint = AuthoritySenderConstraintKinds.Mtls,
SenderKeyThumbprint = "thumb-print",
SenderCertificateHex = "ABCDEF1234"
SenderCertificateHex = expectedHexThumbprint
};
var tokenStore = new TestTokenStore
@@ -3685,6 +3755,14 @@ public class TokenValidationHandlersTests
Request = new OpenIddictRequest()
};
var httpContext = new DefaultHttpContext();
httpContext.Connection.ClientCertificate = certificate;
httpContext.Features.Set<ITlsConnectionFeature>(new TlsConnectionFeature
{
ClientCertificate = certificate
});
transaction.Properties[typeof(HttpContext).FullName!] = httpContext;
var principal = CreatePrincipal(clientDocument.ClientId, tokenDocument.TokenId, ResolveProvider(clientDocument));
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
@@ -3694,7 +3772,7 @@ public class TokenValidationHandlersTests
await handler.HandleAsync(context);
Assert.False(context.IsRejected);
Assert.False(context.IsRejected, $"Validation failed: {context.Error} - {context.ErrorDescription}");
var confirmation = context.Principal?.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType);
Assert.False(string.IsNullOrWhiteSpace(confirmation));
using var json = JsonDocument.Parse(confirmation!);
@@ -4669,6 +4747,124 @@ public class ObservabilityIncidentTokenHandlerTests
Assert.Equal("Sender certificate mismatch.", context.ErrorDescription);
}
[Fact]
public async Task ValidateDpopProofHandler_RejectsRefreshGrant_WhenProofMissing()
{
var options = TestHelpers.CreateAuthorityOptions(opts =>
{
opts.Security.SenderConstraints.Dpop.Enabled = true;
opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false;
});
var clientDocument = CreateClient(
clientId: "refresh-client",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials refresh_token");
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop;
clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop;
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 handler = new ValidateDpopProofHandler(
options,
clientStore,
dpopValidator,
nonceStore,
rateMetadata,
auditSink,
TimeProvider.System,
TestInstruments.ActivitySource,
TestInstruments.Meter,
NullLogger<ValidateDpopProofHandler>.Instance);
var transaction = TestHelpers.CreateRefreshTransaction(clientDocument.ClientId, "s3cr3t!", "refresh-token");
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "POST";
httpContext.Request.Scheme = "https";
httpContext.Request.Host = new HostString("authority.test");
httpContext.Request.Path = "/token";
transaction.Properties[typeof(HttpContext).FullName!] = httpContext;
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("DPoP proof is required.", context.ErrorDescription);
}
[Fact]
public async Task ValidateDpopProofHandler_AllowsRefreshGrant_WhenProofProvided()
{
var options = TestHelpers.CreateAuthorityOptions(opts =>
{
opts.Security.SenderConstraints.Dpop.Enabled = true;
opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false;
});
var clientDocument = CreateClient(
clientId: "refresh-client-success",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials refresh_token");
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop;
clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop;
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 handler = new ValidateDpopProofHandler(
options,
clientStore,
dpopValidator,
nonceStore,
rateMetadata,
auditSink,
TimeProvider.System,
TestInstruments.ActivitySource,
TestInstruments.Meter,
NullLogger<ValidateDpopProofHandler>.Instance);
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var securityKey = new ECDsaSecurityKey(ecdsa)
{
KeyId = Guid.NewGuid().ToString("N")
};
var transaction = TestHelpers.CreateRefreshTransaction(clientDocument.ClientId, "s3cr3t!", "refresh-token");
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 context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected);
}
}
internal static class TestInstruments
@@ -4851,6 +5047,24 @@ internal static class TestHelpers
};
}
public static OpenIddictServerTransaction CreateRefreshTransaction(string clientId, string? secret, string refreshToken)
{
var request = new OpenIddictRequest
{
GrantType = OpenIddictConstants.GrantTypes.RefreshToken,
ClientId = clientId,
ClientSecret = secret,
RefreshToken = refreshToken
};
return new OpenIddictServerTransaction
{
EndpointType = OpenIddictServerEndpointType.Token,
Options = new OpenIddictServerOptions(),
Request = request
};
}
public static string ConvertThumbprintToString(object thumbprint)
=> thumbprint switch
{

View File

@@ -1,38 +1,46 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.OpenIddict.Handlers;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
using StellaOps.Configuration;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Authority.Tests.OpenIddict;
public class PasswordGrantHandlersTests
using System.Diagnostics;
using System.Globalization;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using OpenIddict.Extensions;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.OpenIddict.Handlers;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
using StellaOps.Configuration;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Security.Dpop;
using StellaOps.Authority.Security;
using Xunit;
namespace StellaOps.Authority.Tests.OpenIddict;
public class PasswordGrantHandlersTests
{
private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests");
[Fact]
public async Task HandlePasswordGrant_EmitsSuccessAuditEvent()
{
private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests");
[Fact]
public async Task HandlePasswordGrant_EmitsSuccessAuditEvent()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
@@ -48,10 +56,163 @@ public class PasswordGrantHandlersTests
var successEvent = Assert.Single(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success);
Assert.Equal("tenant-alpha", successEvent.Tenant.Value);
var metadata = metadataAccessor.GetMetadata();
Assert.Equal("tenant-alpha", metadata?.Tenant);
}
var metadata = metadataAccessor.GetMetadata();
Assert.Equal("tenant-alpha", metadata?.Tenant);
}
[Fact]
public async Task ValidateDpopProofHandler_RejectsPasswordGrant_WhenProofMissing()
{
var options = CreateAuthorityOptions(opts =>
{
opts.Security.SenderConstraints.Dpop.Enabled = true;
opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false;
});
var clientDocument = CreateClientDocument();
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop;
var clientStore = new StubClientStore(clientDocument);
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var validator = 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 handler = new ValidateDpopProofHandler(
options,
clientStore,
validator,
nonceStore,
metadataAccessor,
sink,
TimeProvider.System,
TestActivitySource,
TestInstruments.Meter,
NullLogger<ValidateDpopProofHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!");
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";
transaction.Properties[typeof(HttpContext).FullName!] = httpContext;
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("DPoP proof is required.", context.ErrorDescription);
}
[Fact]
public async Task HandlePasswordGrant_AppliesDpopConfirmationClaims()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientDocument = CreateClientDocument();
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop;
var clientStore = new StubClientStore(clientDocument);
var options = CreateAuthorityOptions(opts =>
{
opts.Security.SenderConstraints.Dpop.Enabled = true;
opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false;
});
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,
metadataAccessor,
sink,
TimeProvider.System,
TestActivitySource,
TestInstruments.Meter,
NullLogger<ValidateDpopProofHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(
registry,
TestActivitySource,
sink,
metadataAccessor,
clientStore,
TimeProvider.System,
NullLogger<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(
registry,
clientStore,
TestActivitySource,
sink,
metadataAccessor,
TimeProvider.System,
NullLogger<HandlePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!");
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";
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 = TestHelpers.ConvertThumbprintToString(jwk.ComputeJwkThumbprint());
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);
await validate.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected);
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
await handle.HandleAsync(handleContext);
var principal = handleContext.Principal;
Assert.NotNull(principal);
var confirmation = principal!.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType);
Assert.False(string.IsNullOrWhiteSpace(confirmation));
using var confirmationJson = JsonDocument.Parse(confirmation!);
Assert.Equal(expectedThumbprint, confirmationJson.RootElement.GetProperty("jkt").GetString());
Assert.Equal(AuthoritySenderConstraintKinds.Dpop, principal.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType));
}
[Fact]
public async Task HandlePasswordGrant_EmitsFailureAuditEvent()
{

View File

@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using OpenIddict.Extensions;
using OpenIddict.Server;
using StellaOps.Authority.Security;
namespace StellaOps.Authority.OpenIddict;
internal static class AuthoritySenderConstraintHelper
{
internal static void ApplySenderConstraintClaims(
OpenIddictServerTransaction transaction,
ClaimsIdentity identity)
{
ArgumentNullException.ThrowIfNull(transaction);
ArgumentNullException.ThrowIfNull(identity);
if (!transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.SenderConstraintProperty, out var constraintObj) ||
constraintObj is not string senderConstraint ||
string.IsNullOrWhiteSpace(senderConstraint))
{
return;
}
var normalized = senderConstraint.Trim().ToLowerInvariant();
transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty] = normalized;
SetClaimValue(identity, AuthorityOpenIddictConstants.SenderConstraintClaimType, normalized);
switch (normalized)
{
case AuthoritySenderConstraintKinds.Dpop:
ApplyDpopClaims(transaction, identity);
break;
case AuthoritySenderConstraintKinds.Mtls:
ApplyMtlsClaims(transaction, identity);
break;
}
}
private static void ApplyDpopClaims(OpenIddictServerTransaction transaction, ClaimsIdentity identity)
{
if (transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopKeyThumbprintProperty, out var thumbprintObj) &&
thumbprintObj is string thumbprint &&
!string.IsNullOrWhiteSpace(thumbprint))
{
var confirmation = JsonSerializer.Serialize(new Dictionary<string, string>
{
["jkt"] = thumbprint
});
SetClaimValue(identity, AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation);
}
if (transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopConsumedNonceProperty, out var nonceObj) &&
nonceObj is string consumedNonce &&
!string.IsNullOrWhiteSpace(consumedNonce))
{
SetClaimValue(identity, AuthorityOpenIddictConstants.SenderNonceClaimType, consumedNonce);
}
}
private static void ApplyMtlsClaims(OpenIddictServerTransaction transaction, ClaimsIdentity identity)
{
if (transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty, out var mtlsThumbprintObj) &&
mtlsThumbprintObj is string mtlsThumbprint &&
!string.IsNullOrWhiteSpace(mtlsThumbprint))
{
var confirmation = JsonSerializer.Serialize(new Dictionary<string, string>
{
["x5t#S256"] = mtlsThumbprint
});
SetClaimValue(identity, AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation);
}
if (transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateHexProperty, out var mtlsHexObj) &&
mtlsHexObj is string mtlsHex &&
!string.IsNullOrWhiteSpace(mtlsHex))
{
SetClaimValue(identity, AuthorityOpenIddictConstants.MtlsCertificateHexClaimType, mtlsHex);
}
}
private static void SetClaimValue(ClaimsIdentity identity, string claimType, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
var existingClaims = identity.FindAll(claimType).ToList();
foreach (var claim in existingClaims)
{
identity.RemoveClaim(claim);
}
identity.AddClaim(new Claim(claimType, value));
}
}

View File

@@ -1742,7 +1742,7 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
activity?.SetTag("authority.identity_provider", provider.Name);
}
ApplySenderConstraintClaims(context, identity, document);
AuthoritySenderConstraintHelper.ApplySenderConstraintClaims(context.Transaction, identity);
var principal = new ClaimsPrincipal(identity);
@@ -1994,71 +1994,6 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
activity?.SetTag("authority.token_id", tokenId);
}
private static void ApplySenderConstraintClaims(
OpenIddictServerEvents.HandleTokenRequestContext context,
ClaimsIdentity identity,
AuthorityClientDocument document)
{
_ = document;
if (!context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.SenderConstraintProperty, out var constraintObj) ||
constraintObj is not string senderConstraint ||
string.IsNullOrWhiteSpace(senderConstraint))
{
return;
}
var normalized = senderConstraint.Trim().ToLowerInvariant();
context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty] = normalized;
identity.SetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType, normalized);
switch (normalized)
{
case AuthoritySenderConstraintKinds.Dpop:
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopKeyThumbprintProperty, out var thumbprintObj) &&
thumbprintObj is string thumbprint &&
!string.IsNullOrWhiteSpace(thumbprint))
{
var confirmation = JsonSerializer.Serialize(new Dictionary<string, string>
{
["jkt"] = thumbprint
});
identity.SetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation);
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopConsumedNonceProperty, out var nonceObj) &&
nonceObj is string consumedNonce &&
!string.IsNullOrWhiteSpace(consumedNonce))
{
identity.SetClaim(AuthorityOpenIddictConstants.SenderNonceClaimType, consumedNonce);
}
break;
case AuthoritySenderConstraintKinds.Mtls:
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty, out var mtlsThumbprintObj) &&
mtlsThumbprintObj is string mtlsThumbprint &&
!string.IsNullOrWhiteSpace(mtlsThumbprint))
{
var confirmation = JsonSerializer.Serialize(new Dictionary<string, string>
{
["x5t#S256"] = mtlsThumbprint
});
identity.SetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation);
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateHexProperty, out var mtlsHexObj) &&
mtlsHexObj is string mtlsHex &&
!string.IsNullOrWhiteSpace(mtlsHex))
{
identity.SetClaim(AuthorityOpenIddictConstants.MtlsCertificateHexClaimType, mtlsHex);
}
break;
}
}
private static void ApplyAttributeClaims(
ClaimsIdentity identity,
IReadOnlyDictionary<string, IReadOnlyList<string>> attributeFilters)

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Globalization;
using System.Linq;
using System.Text.Json;
@@ -33,52 +34,66 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
private readonly StellaOpsAuthorityOptions authorityOptions;
private readonly IAuthorityClientStore clientStore;
private readonly IDpopProofValidator proofValidator;
private readonly IDpopNonceStore nonceStore;
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
private readonly IAuthEventSink auditSink;
private readonly TimeProvider clock;
private readonly ActivitySource activitySource;
private readonly ILogger<ValidateDpopProofHandler> logger;
private readonly IDpopNonceStore nonceStore;
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
private readonly IAuthEventSink auditSink;
private readonly TimeProvider clock;
private readonly ActivitySource activitySource;
private readonly Counter<long> dpopNonceMissCounter;
private readonly ILogger<ValidateDpopProofHandler> logger;
public ValidateDpopProofHandler(
StellaOpsAuthorityOptions authorityOptions,
IAuthorityClientStore clientStore,
IDpopProofValidator proofValidator,
IDpopNonceStore nonceStore,
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
IAuthEventSink auditSink,
TimeProvider clock,
ActivitySource activitySource,
ILogger<ValidateDpopProofHandler> logger)
IDpopNonceStore nonceStore,
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
IAuthEventSink auditSink,
TimeProvider clock,
ActivitySource activitySource,
Meter meter,
ILogger<ValidateDpopProofHandler> logger)
{
this.authorityOptions = authorityOptions ?? throw new ArgumentNullException(nameof(authorityOptions));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.proofValidator = proofValidator ?? throw new ArgumentNullException(nameof(proofValidator));
this.nonceStore = nonceStore ?? throw new ArgumentNullException(nameof(nonceStore));
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
if (meter is null)
{
throw new ArgumentNullException(nameof(meter));
}
dpopNonceMissCounter = meter.CreateCounter<long>(
name: "authority_dpop_nonce_miss_total",
description: "Count of DPoP nonce challenges due to missing or invalid proofs.");
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (!context.Request.IsClientCredentialsGrantType())
{
return;
}
using var activity = activitySource.StartActivity("authority.token.validate_dpop", ActivityKind.Internal);
activity?.SetTag("authority.endpoint", "/token");
activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.ClientCredentials);
var clientId = context.ClientId ?? context.Request.ClientId;
if (string.IsNullOrWhiteSpace(clientId))
{
return;
var request = context.Request;
if (request is null)
{
return;
}
using var activity = activitySource.StartActivity("authority.token.validate_dpop", ActivityKind.Internal);
activity?.SetTag("authority.endpoint", "/token");
var grantType = request.GrantType;
if (!string.IsNullOrWhiteSpace(grantType))
{
activity?.SetTag("authority.grant_type", grantType);
}
var clientId = context.ClientId ?? request.ClientId;
if (string.IsNullOrWhiteSpace(clientId))
{
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditClientIdProperty] = clientId;
@@ -91,13 +106,13 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
}
var senderConstraint = NormalizeSenderConstraint(clientDocument);
var configuredAudiences = EnsureRequestAudiences(context.Request, clientDocument);
var configuredAudiences = EnsureRequestAudiences(request, clientDocument);
var nonceOptions = senderConstraintOptions.Dpop.Nonce;
string? matchedNonceAudience = null;
if (senderConstraintOptions.Dpop.Enabled && nonceOptions.Enabled)
{
matchedNonceAudience = ResolveNonceAudience(context.Request, nonceOptions, configuredAudiences);
matchedNonceAudience = ResolveNonceAudience(request, nonceOptions, configuredAudiences);
}
var requiresClientSenderConstraint = string.Equals(senderConstraint, AuthoritySenderConstraintKinds.Dpop, StringComparison.Ordinal);
@@ -119,16 +134,35 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
logger.LogDebug("DPoP enforcement enabled for client {ClientId} targeting audience {Audience}.", clientId, matchedNonceAudience);
}
if (!senderConstraintOptions.Dpop.Enabled)
{
logger.LogError("Client {ClientId} requires DPoP but server-side configuration has DPoP disabled.", clientId);
context.Reject(OpenIddictConstants.Errors.ServerError, "DPoP authentication is not enabled.");
await WriteAuditAsync(context, clientDocument, AuthEventOutcome.Failure, "DPoP disabled server-side.", null, null, null, "authority.dpop.proof.disabled").ConfigureAwait(false);
return;
}
metadataAccessor.SetTag("authority.sender_constraint", AuthoritySenderConstraintKinds.Dpop);
activity?.SetTag("authority.sender_constraint", AuthoritySenderConstraintKinds.Dpop);
if (!senderConstraintOptions.Dpop.Enabled)
{
logger.LogError("Client {ClientId} requires DPoP but server-side configuration has DPoP disabled.", clientId);
context.Reject(OpenIddictConstants.Errors.ServerError, "DPoP authentication is not enabled.");
await WriteAuditAsync(context, clientDocument, AuthEventOutcome.Failure, "DPoP disabled server-side.", null, null, null, "authority.dpop.proof.disabled").ConfigureAwait(false);
return;
}
if (senderConstraintOptions.Dpop.AllowTemporaryBypass)
{
metadataAccessor.SetTag("authority.sender_constraint", "dpop_bypass");
metadataAccessor.SetTag("authority.dpop_result", "bypass");
activity?.SetTag("authority.sender_constraint", "dpop_bypass");
await WriteAuditAsync(
context,
clientDocument,
AuthEventOutcome.Success,
"DPoP enforcement temporarily bypassed.",
thumbprint: null,
validationResult: null,
audience: matchedNonceAudience,
eventType: "authority.dpop.proof.bypass",
reasonCode: "bypass")
.ConfigureAwait(false);
return;
}
metadataAccessor.SetTag("authority.sender_constraint", AuthoritySenderConstraintKinds.Dpop);
activity?.SetTag("authority.sender_constraint", AuthoritySenderConstraintKinds.Dpop);
HttpRequest? httpRequest = null;
HttpResponse? httpResponse = null;
@@ -467,13 +501,14 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
AuthorityClientDocument clientDocument,
string? audience,
string? thumbprint,
string reasonCode,
string description,
AuthoritySenderConstraintOptions senderConstraintOptions,
HttpResponse? httpResponse)
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, description);
metadataAccessor.SetTag("authority.dpop_result", reasonCode);
string reasonCode,
string description,
AuthoritySenderConstraintOptions senderConstraintOptions,
HttpResponse? httpResponse)
{
RecordNonceMiss(reasonCode);
context.Reject(OpenIddictConstants.Errors.InvalidClient, description);
metadataAccessor.SetTag("authority.dpop_result", reasonCode);
string? issuedNonce = null;
DateTimeOffset? expiresAt = null;
@@ -528,6 +563,15 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
.ConfigureAwait(false);
}
private void RecordNonceMiss(string? reason)
{
var normalizedReason = string.IsNullOrWhiteSpace(reason) ? "unknown" : reason;
dpopNonceMissCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("reason", normalizedReason)
});
}
private async ValueTask<DpopNonceConsumeResult> ConsumeNonceAsync(
string nonce,
string audience,

View File

@@ -1094,7 +1094,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyTicketProperty, out var policyTicketObj) &&
policyTicketObj is string policyTicketValue &&
policyTicketObj is string policyTicketValue &&
!string.IsNullOrWhiteSpace(policyTicketValue))
{
identity.SetClaim(StellaOpsClaimTypes.PolicyTicket, policyTicketValue);
@@ -1103,6 +1103,8 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
var issuedAt = timeProvider.GetUtcNow();
identity.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, issuedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
AuthoritySenderConstraintHelper.ApplySenderConstraintClaims(context.Transaction, identity);
identity.SetDestinations(static claim => claim.Type switch
{
OpenIddictConstants.Claims.Subject => new[] { OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken },

View File

@@ -32,7 +32,8 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-DPOP-11-001 | DOING (2025-11-07) | Authority Core & Security Guild | AUTH-AOC-19-002 | Enforce DPoP sender constraints for all Authority token flows (nonce store selection, algorithm allowlist, `cnf.jkt` persistence, structured telemetry). | `/token` enforces configured DPoP policies (nonce, allowed algorithms); cnf claims verified in integration tests; docs/runbooks updated with configuration guidance. |
| AUTH-DPOP-11-001 | DONE (2025-11-08) | Authority Core & Security Guild | AUTH-AOC-19-002 | Enforce DPoP sender constraints for all Authority token flows (nonce store selection, algorithm allowlist, `cnf.jkt` persistence, structured telemetry). | `/token` enforces configured DPoP policies (nonce, allowed algorithms); cnf claims verified in integration tests; docs/runbooks updated with configuration guidance. |
> 2025-11-08: DPoP validation now executes for every `/token` grant (client credentials, password, device, refresh); interactive handlers apply shared sender-constraint claims so tokens emit `cnf.jkt` + telemetry, and docs describe the expanded coverage.
> 2025-11-07: Joint Authority/DevOps stand-up committed to shipping nonce store + telemetry updates by 2025-11-10; config samples and integration tests being updated in tandem.
| AUTH-MTLS-11-002 | DOING (2025-11-07) | Authority Core & Security Guild | AUTH-DPOP-11-001 | Add mTLS-bound access token issuance/validation (client certificate thumbprints, JWKS rotation hooks) for high-assurance tenants and services. | mTLS certificate binding validated end-to-end; audit logs capture cert hashes; docs describe bootstrap/rotation steps. |
> 2025-11-08: Wiring cert thumbprint persistence + audit hooks now that DPoP nonce enforcement is in place; targeting shared delivery window with DEVOPS-AIRGAP-57-002.