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
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user