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:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user