feat: Update Sprint 110 documentation and enhance Advisory AI tests for determinism and mTLS validation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
master
2025-11-08 23:28:41 +02:00
parent ae69b1a8a1
commit d71c81e45d
9 changed files with 395 additions and 19 deletions

View File

@@ -4352,6 +4352,24 @@ internal sealed class RecordingCertificateValidator : IAuthorityClientCertificat
}
}
internal sealed class StubCertificateValidator : IAuthorityClientCertificateValidator
{
private readonly Func<AuthorityClientCertificateValidationResult> factory;
public StubCertificateValidator(Func<AuthorityClientCertificateValidationResult> factory)
{
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
}
public bool Invoked { get; private set; }
public ValueTask<AuthorityClientCertificateValidationResult> ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken)
{
Invoked = true;
return ValueTask.FromResult(factory());
}
}
internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor
{
public ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default)
@@ -4865,6 +4883,118 @@ public class ObservabilityIncidentTokenHandlerTests
Assert.False(context.IsRejected);
}
[Fact]
public async Task ValidateRefreshTokenGrantHandler_RejectsMtlsRefresh_WhenCertificateMissing()
{
var clientDocument = CreateClient(
clientId: "mtls-refresh",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials refresh_token");
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding { Thumbprint = "ABCDEF" });
var clientStore = new TestClientStore(clientDocument);
var validator = new StubCertificateValidator(() => AuthorityClientCertificateValidationResult.Success(
"stub",
"ABCDEF",
clientDocument.CertificateBindings[0]));
var handler = new ValidateRefreshTokenGrantHandler(
clientStore,
validator,
NullLogger<ValidateRefreshTokenGrantHandler>.Instance);
var transaction = TestHelpers.CreateRefreshTransaction(clientDocument.ClientId, "s3cr3t!", "refresh-token");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)
{
Principal = CreateMtlsRefreshPrincipal(clientDocument.ClientId, "C0FFEE")
};
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("Sender certificate is required for this token.", context.ErrorDescription);
Assert.False(validator.Invoked);
}
[Fact]
public async Task ValidateRefreshTokenGrantHandler_RejectsMtlsRefresh_WhenThumbprintMismatch()
{
var clientDocument = CreateClient(
clientId: "mtls-refresh-mismatch",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials refresh_token");
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding { Thumbprint = "ABCDEF" });
var clientStore = new TestClientStore(clientDocument);
var validator = new StubCertificateValidator(() => AuthorityClientCertificateValidationResult.Success(
Base64UrlEncoder.Encode(Convert.FromHexString("ABCDEF")),
"ABCDEF",
clientDocument.CertificateBindings[0]));
var handler = new ValidateRefreshTokenGrantHandler(
clientStore,
validator,
NullLogger<ValidateRefreshTokenGrantHandler>.Instance);
var transaction = TestHelpers.CreateRefreshTransaction(clientDocument.ClientId, "s3cr3t!", "refresh-token");
var httpContext = new DefaultHttpContext();
transaction.Properties[typeof(HttpContext).FullName!] = httpContext;
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)
{
Principal = CreateMtlsRefreshPrincipal(clientDocument.ClientId, "DEADBEEF")
};
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("Sender certificate mismatch.", context.ErrorDescription);
Assert.True(validator.Invoked);
}
[Fact]
public async Task ValidateRefreshTokenGrantHandler_SetsTransactionState_WhenMtlsRefreshSucceeds()
{
const string HexThumbprint = "CAFEBABE";
var binding = new AuthorityClientCertificateBinding { Thumbprint = HexThumbprint };
var clientDocument = CreateClient(
clientId: "mtls-refresh-success",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials refresh_token");
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
clientDocument.CertificateBindings.Add(binding);
var clientStore = new TestClientStore(clientDocument);
var validator = new StubCertificateValidator(() => AuthorityClientCertificateValidationResult.Success(
Base64UrlEncoder.Encode(Convert.FromHexString(HexThumbprint)),
HexThumbprint,
binding));
var handler = new ValidateRefreshTokenGrantHandler(
clientStore,
validator,
NullLogger<ValidateRefreshTokenGrantHandler>.Instance);
var transaction = TestHelpers.CreateRefreshTransaction(clientDocument.ClientId, "s3cr3t!", "refresh-token");
var httpContext = new DefaultHttpContext();
transaction.Properties[typeof(HttpContext).FullName!] = httpContext;
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)
{
Principal = CreateMtlsRefreshPrincipal(clientDocument.ClientId, HexThumbprint)
};
await handler.HandleAsync(context);
Assert.False(context.IsRejected);
Assert.Equal(AuthoritySenderConstraintKinds.Mtls, context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty]);
Assert.Equal(HexThumbprint, context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateHexProperty]);
Assert.NotNull(context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty]);
}
}
internal static class TestInstruments
@@ -5065,6 +5195,23 @@ internal static class TestHelpers
};
}
public static ClaimsPrincipal CreateMtlsRefreshPrincipal(string clientId, string hexThumbprint)
{
var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
identity.AddClaim(new Claim(OpenIddictConstants.Claims.ClientId, clientId));
identity.AddClaim(new Claim(AuthorityOpenIddictConstants.SenderConstraintClaimType, AuthoritySenderConstraintKinds.Mtls));
identity.AddClaim(new Claim(AuthorityOpenIddictConstants.MtlsCertificateHexClaimType, hexThumbprint));
var normalizedHex = hexThumbprint.Length % 2 == 0 ? hexThumbprint : "0" + hexThumbprint;
var confirmation = JsonSerializer.Serialize(new Dictionary<string, string>
{
["x5t#S256"] = Base64UrlEncoder.Encode(Convert.FromHexString(normalizedHex))
});
identity.AddClaim(new Claim(AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation));
return new ClaimsPrincipal(identity);
}
public static string ConvertThumbprintToString(object thumbprint)
=> thumbprint switch
{

View File

@@ -1,30 +1,41 @@
using System;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Security;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.OpenIddict.Handlers;
internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenRequestContext>
{
private readonly IAuthorityClientStore clientStore;
private readonly IAuthorityClientCertificateValidator certificateValidator;
private readonly ILogger<ValidateRefreshTokenGrantHandler> logger;
public ValidateRefreshTokenGrantHandler(ILogger<ValidateRefreshTokenGrantHandler> logger)
public ValidateRefreshTokenGrantHandler(
IAuthorityClientStore clientStore,
IAuthorityClientCertificateValidator certificateValidator,
ILogger<ValidateRefreshTokenGrantHandler> logger)
{
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.certificateValidator = certificateValidator ?? throw new ArgumentNullException(nameof(certificateValidator));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (!context.Request.IsRefreshTokenGrantType())
{
return ValueTask.CompletedTask;
return;
}
var requestedScopes = context.Request.GetScopes();
@@ -33,8 +44,86 @@ internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandle
{
context.Reject(OpenIddictConstants.Errors.InvalidGrant, "obs:incident tokens require fresh authentication; refresh is not permitted.");
logger.LogWarning("Refresh token validation failed for client {ClientId}: obs:incident scope requires fresh authentication.", context.ClientId ?? context.Request.ClientId ?? "<unknown>");
return;
}
return ValueTask.CompletedTask;
var senderConstraint = refreshPrincipal?.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType);
if (string.Equals(senderConstraint, AuthoritySenderConstraintKinds.Mtls, StringComparison.Ordinal))
{
if (!await EnsureMtlsBindingAsync(context, refreshPrincipal!).ConfigureAwait(false))
{
return;
}
}
}
private async ValueTask<bool> EnsureMtlsBindingAsync(OpenIddictServerEvents.ValidateTokenRequestContext context, ClaimsPrincipal principal)
{
var clientId = context.ClientId ?? context.Request.ClientId;
if (string.IsNullOrWhiteSpace(clientId))
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client identifier is required for mTLS-bound refresh tokens.");
logger.LogWarning("mTLS refresh validation failed: client id missing.");
return false;
}
var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false);
if (clientDocument is null)
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Unknown client.");
logger.LogWarning("mTLS refresh validation failed for {ClientId}: client not found.", clientId);
return false;
}
if (!TryGetHttpContext(context.Transaction, out var httpContext))
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Sender certificate is required for this token.");
logger.LogWarning("mTLS refresh validation failed for {ClientId}: HTTP context unavailable.", clientId);
return false;
}
var validation = await certificateValidator.ValidateAsync(httpContext, clientDocument, context.CancellationToken).ConfigureAwait(false);
if (!validation.Succeeded ||
string.IsNullOrWhiteSpace(validation.HexThumbprint) ||
string.IsNullOrWhiteSpace(validation.ConfirmationThumbprint))
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Sender certificate validation failed.");
logger.LogWarning(
"mTLS refresh validation failed for {ClientId}: certificate validation error {Reason}.",
clientId,
validation.Error ?? "unknown");
return false;
}
var expectedHex = principal.GetClaim(AuthorityOpenIddictConstants.MtlsCertificateHexClaimType);
if (!string.IsNullOrWhiteSpace(expectedHex) &&
!string.Equals(expectedHex, validation.HexThumbprint, StringComparison.OrdinalIgnoreCase))
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Sender certificate mismatch.");
logger.LogWarning(
"mTLS refresh validation failed for {ClientId}: certificate thumbprint {Thumbprint} did not match refresh token binding {Expected}.",
clientId,
validation.HexThumbprint,
expectedHex);
return false;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty] = AuthoritySenderConstraintKinds.Mtls;
context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty] = validation.ConfirmationThumbprint;
context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateHexProperty] = validation.HexThumbprint;
return true;
}
private static bool TryGetHttpContext(OpenIddictServerTransaction transaction, out HttpContext? httpContext)
{
if (transaction.Properties.TryGetValue(typeof(HttpContext).FullName!, out var property) &&
property is HttpContext typedContext)
{
httpContext = typedContext;
return true;
}
httpContext = null;
return false;
}
}