From 2314a28b5981eabca03418aca8126e00359d8b95 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Fri, 7 Aug 2026 15:17:45 -0700 Subject: [PATCH 01/22] feat(security): separate silo and gateway TLS Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e71c4ddd-5362-4204-910f-9a742ddd63de --- .../Hosting/HostingExtensions.ISiloBuilder.cs | 79 +++++++-- .../TlsClientAuthenticationOptions.cs | 9 + .../TlsServerAuthenticationOptions.cs | 9 + .../Orleans.Connections.Security.cs | 8 + .../TlsConnectionTests.cs | 166 ++++++++++++++++++ 5 files changed, 254 insertions(+), 17 deletions(-) diff --git a/src/Orleans.Connections.Security/Hosting/HostingExtensions.ISiloBuilder.cs b/src/Orleans.Connections.Security/Hosting/HostingExtensions.ISiloBuilder.cs index 8b7376ef81a..3ffc2e24c6e 100644 --- a/src/Orleans.Connections.Security/Hosting/HostingExtensions.ISiloBuilder.cs +++ b/src/Orleans.Connections.Security/Hosting/HostingExtensions.ISiloBuilder.cs @@ -104,6 +104,67 @@ public static ISiloBuilder UseTls( public static ISiloBuilder UseTls( this ISiloBuilder builder, Action configureOptions) + { + var options = CreateAndValidateOptions(configureOptions); + return builder + .UseSiloTls(options) + .UseGatewayTls(options); + } + + /// + /// Configures TLS for connections between silos. + /// + /// The builder to configure. + /// An action to configure the . + /// The builder. + public static ISiloBuilder UseSiloTls( + this ISiloBuilder builder, + Action configureOptions) + { + return builder.UseSiloTls(CreateAndValidateOptions(configureOptions)); + } + + /// + /// Configures TLS for gateway connections from clients. + /// + /// The builder to configure. + /// An action to configure the . + /// The builder. + public static ISiloBuilder UseGatewayTls( + this ISiloBuilder builder, + Action configureOptions) + { + return builder.UseGatewayTls(CreateAndValidateOptions(configureOptions)); + } + + private static ISiloBuilder UseSiloTls(this ISiloBuilder builder, TlsOptions options) + { + return builder.Configure(connectionOptions => + { + connectionOptions.ConfigureSiloInboundConnection(connectionBuilder => + { + connectionBuilder.UseServerTls(options); + }); + + connectionOptions.ConfigureSiloOutboundConnection(connectionBuilder => + { + connectionBuilder.UseClientTls(options); + }); + }); + } + + private static ISiloBuilder UseGatewayTls(this ISiloBuilder builder, TlsOptions options) + { + return builder.Configure(connectionOptions => + { + connectionOptions.ConfigureGatewayInboundConnection(connectionBuilder => + { + connectionBuilder.UseServerTls(options); + }); + }); + } + + private static TlsOptions CreateAndValidateOptions(Action configureOptions) { if (configureOptions is null) { @@ -122,23 +183,7 @@ public static ISiloBuilder UseTls( TlsConnectionBuilderExtensions.ThrowNoPrivateKey(certificate, $"{nameof(TlsOptions)}.{nameof(TlsOptions.LocalCertificate)}"); } - return builder.Configure(connectionOptions => - { - connectionOptions.ConfigureSiloInboundConnection(connectionBuilder => - { - connectionBuilder.UseServerTls(options); - }); - - connectionOptions.ConfigureGatewayInboundConnection(connectionBuilder => - { - connectionBuilder.UseServerTls(options); - }); - - connectionOptions.ConfigureSiloOutboundConnection(connectionBuilder => - { - connectionBuilder.UseClientTls(options); - }); - }); + return options; } } } diff --git a/src/Orleans.Connections.Security/Security/TlsClientAuthenticationOptions.cs b/src/Orleans.Connections.Security/Security/TlsClientAuthenticationOptions.cs index ecfce2967ec..ec59cd08491 100644 --- a/src/Orleans.Connections.Security/Security/TlsClientAuthenticationOptions.cs +++ b/src/Orleans.Connections.Security/Security/TlsClientAuthenticationOptions.cs @@ -39,6 +39,15 @@ public X509CertificateCollection? ClientCertificates set => this.Value.ClientCertificates = value; } + /// + /// Gets or sets the application protocols offered by the client during TLS application-layer protocol negotiation. + /// + public List? ApplicationProtocols + { + get => Value.ApplicationProtocols; + set => Value.ApplicationProtocols = value; + } + public SslProtocols EnabledSslProtocols { get => this.Value.EnabledSslProtocols; diff --git a/src/Orleans.Connections.Security/Security/TlsServerAuthenticationOptions.cs b/src/Orleans.Connections.Security/Security/TlsServerAuthenticationOptions.cs index 39cc2f26adb..81d47be7c78 100644 --- a/src/Orleans.Connections.Security/Security/TlsServerAuthenticationOptions.cs +++ b/src/Orleans.Connections.Security/Security/TlsServerAuthenticationOptions.cs @@ -35,6 +35,15 @@ public bool ClientCertificateRequired set => Value.ClientCertificateRequired = value; } + /// + /// Gets or sets the application protocols accepted by the server during TLS application-layer protocol negotiation. + /// + public List? ApplicationProtocols + { + get => Value.ApplicationProtocols; + set => Value.ApplicationProtocols = value; + } + public SslProtocols EnabledSslProtocols { get => Value.EnabledSslProtocols; diff --git a/src/api/Orleans.Connections.Security/Orleans.Connections.Security.cs b/src/api/Orleans.Connections.Security/Orleans.Connections.Security.cs index f0e3ede6834..ca3108a33ce 100644 --- a/src/api/Orleans.Connections.Security/Orleans.Connections.Security.cs +++ b/src/api/Orleans.Connections.Security/Orleans.Connections.Security.cs @@ -68,6 +68,8 @@ public enum RemoteCertificateMode public delegate System.Security.Cryptography.X509Certificates.X509Certificate ServerCertificateSelectionCallback(object sender, string? hostName); public partial class TlsClientAuthenticationOptions { + public System.Collections.Generic.List? ApplicationProtocols { get { throw null; } set { } } + public System.Security.Cryptography.X509Certificates.X509RevocationMode CertificateRevocationCheckMode { get { throw null; } set { } } public System.Security.Cryptography.X509Certificates.X509CertificateCollection? ClientCertificates { get { throw null; } set { } } @@ -110,6 +112,8 @@ public void AllowAnyRemoteCertificate() { } public partial class TlsServerAuthenticationOptions { + public System.Collections.Generic.List? ApplicationProtocols { get { throw null; } set { } } + public System.Security.Cryptography.X509Certificates.X509RevocationMode CertificateRevocationCheckMode { get { throw null; } set { } } public bool ClientCertificateRequired { get { throw null; } set { } } @@ -128,6 +132,10 @@ namespace Orleans.Hosting { public static partial class OrleansConnectionSecurityHostingExtensions { + public static ISiloBuilder UseGatewayTls(this ISiloBuilder builder, System.Action configureOptions) { throw null; } + + public static ISiloBuilder UseSiloTls(this ISiloBuilder builder, System.Action configureOptions) { throw null; } + public static IClientBuilder UseTls(this IClientBuilder builder, System.Action configureOptions) { throw null; } public static IClientBuilder UseTls(this IClientBuilder builder, System.Security.Cryptography.X509Certificates.StoreName storeName, string subject, bool allowInvalid, System.Security.Cryptography.X509Certificates.StoreLocation location, System.Action configureOptions) { throw null; } diff --git a/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs b/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs index 9a65e04485a..9ac92a99d10 100644 --- a/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs +++ b/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs @@ -1,5 +1,11 @@ +using System.Collections.Concurrent; +using System.Net.Security; +using System.Text; +using Microsoft.AspNetCore.Connections; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; +using Orleans.Configuration; +using Orleans.Runtime.Messaging; using Orleans.TestingHost; using TestExtensions; using Xunit; @@ -35,6 +41,10 @@ public class TlsConnectionTests private const string CertificateSubjectName = "fakedomain.faketld"; private const string CertificateConfigKey = "certificate"; private const string ClientCertificateModeKey = "CertificateMode"; + private const string ProtocolRecorderKey = "ProtocolRecorder"; + private const string AuthenticatedSiloProtocol = "orleans-auth-test"; + private const string OrleansProtocol = "Orleans1"; + private static readonly ConcurrentDictionary ProtocolRecorders = new(); /// /// Tests the certificate utility functions for creating self-signed certificates. @@ -199,6 +209,162 @@ public async Task TlsEndToEnd(string[]? oids, RemoteCertificateMode certificateM } } } + + [Fact] + public async Task SeparateSiloAndGatewayTls_NegotiateConfiguredApplicationProtocols() + { + var recorderId = Guid.NewGuid().ToString(); + var recorder = new ProtocolRecorder(); + Assert.True(ProtocolRecorders.TryAdd(recorderId, recorder)); + + TestCluster? testCluster = default; + try + { + var certificate = TestCertificateHelper.CreateSelfSignedCertificate( + CertificateSubjectName, + [TestCertificateHelper.ClientAuthenticationOid, TestCertificateHelper.ServerAuthenticationOid]); + var builder = new TestClusterBuilder() + .AddSiloBuilderConfigurator() + .AddClientBuilderConfigurator(); + builder.Options.InitialSilosCount = 2; + builder.Properties[CertificateConfigKey] = TestCertificateHelper.ConvertToBase64(certificate); + builder.Properties[ProtocolRecorderKey] = recorderId; + + testCluster = builder.Build(); + await testCluster.DeployAsync(); + + var grain = testCluster.Client.GetGrain("alpn"); + Assert.Equal("ping", await grain.Echo("ping")); + + Assert.Contains(AuthenticatedSiloProtocol, recorder.GetProtocols(ConnectionPath.SiloInbound)); + Assert.Contains(AuthenticatedSiloProtocol, recorder.GetProtocols(ConnectionPath.SiloOutbound)); + Assert.Contains(OrleansProtocol, recorder.GetProtocols(ConnectionPath.GatewayInbound)); + Assert.Contains(OrleansProtocol, recorder.GetProtocols(ConnectionPath.ClientOutbound)); + Assert.DoesNotContain(AuthenticatedSiloProtocol, recorder.GetProtocols(ConnectionPath.GatewayInbound)); + Assert.DoesNotContain(AuthenticatedSiloProtocol, recorder.GetProtocols(ConnectionPath.ClientOutbound)); + } + finally + { + ProtocolRecorders.TryRemove(recorderId, out _); + if (testCluster is not null) + { + await testCluster.StopAllSilosAsync(); + testCluster.Dispose(); + } + } + } + + private sealed class AlpnTlsServerConfigurator : IHostConfigurator + { + public void Configure(IHostBuilder hostBuilder) + { + var configuration = hostBuilder.GetConfiguration(); + var certificate = TestCertificateHelper.ConvertFromBase64(configuration[CertificateConfigKey]!); + var recorder = ProtocolRecorders[configuration[ProtocolRecorderKey]!]; + + hostBuilder.UseOrleans((_, siloBuilder) => + { + siloBuilder.UseSiloTls(options => + { + ConfigureTls(options, certificate); + options.OnAuthenticateAsClient = (_, authenticationOptions) => + { + authenticationOptions.TargetHost = CertificateSubjectName; + authenticationOptions.ApplicationProtocols = + [ + new SslApplicationProtocol(AuthenticatedSiloProtocol), + new SslApplicationProtocol(OrleansProtocol) + ]; + }; + options.OnAuthenticateAsServer = (_, authenticationOptions) => + { + authenticationOptions.ApplicationProtocols = + [ + new SslApplicationProtocol(AuthenticatedSiloProtocol), + new SslApplicationProtocol(OrleansProtocol) + ]; + }; + }); + + siloBuilder.UseGatewayTls(options => + { + ConfigureTls(options, certificate); + options.RemoteCertificateMode = RemoteCertificateMode.NoCertificate; + }); + + siloBuilder.Configure(options => + { + options.ConfigureSiloInboundConnection( + builder => builder.UseMiddleware(new ProtocolRecordingMiddleware(recorder, ConnectionPath.SiloInbound))); + options.ConfigureSiloOutboundConnection( + builder => builder.UseMiddleware(new ProtocolRecordingMiddleware(recorder, ConnectionPath.SiloOutbound))); + options.ConfigureGatewayInboundConnection( + builder => builder.UseMiddleware(new ProtocolRecordingMiddleware(recorder, ConnectionPath.GatewayInbound))); + }); + }); + } + } + + private sealed class AlpnTlsClientConfigurator : IClientBuilderConfigurator + { + public void Configure(IConfiguration configuration, IClientBuilder clientBuilder) + { + var recorder = ProtocolRecorders[configuration[ProtocolRecorderKey]!]; + clientBuilder.UseTls(options => + { + options.AllowAnyRemoteCertificate(); + options.OnAuthenticateAsClient = (_, authenticationOptions) => + { + authenticationOptions.TargetHost = CertificateSubjectName; + }; + }); + + clientBuilder.Configure(options => + options.ConfigureConnection( + builder => builder.UseMiddleware(new ProtocolRecordingMiddleware(recorder, ConnectionPath.ClientOutbound)))); + } + } + + private static void ConfigureTls(TlsOptions options, System.Security.Cryptography.X509Certificates.X509Certificate2 certificate) + { + options.LocalCertificate = certificate; + options.SslProtocols = System.Security.Authentication.SslProtocols.Tls12; + options.AllowAnyRemoteCertificate(); + options.RemoteCertificateMode = RemoteCertificateMode.AllowCertificate; + } + + private sealed class ProtocolRecordingMiddleware(ProtocolRecorder recorder, ConnectionPath path) : IConnectionMiddleware + { + public async Task OnConnectionAsync(ConnectionContext context, ConnectionDelegate next) + { + var feature = context.Features.Get(); + recorder.Record(path, feature is null ? null : Encoding.ASCII.GetString(feature.ApplicationProtocol.Span)); + await next(context); + } + } + + private sealed class ProtocolRecorder + { + private readonly ConcurrentDictionary> _protocols = new(); + + public void Record(ConnectionPath path, string? protocol) + { + _protocols.GetOrAdd(path, static _ => []).Add(protocol ?? ""); + } + + public string[] GetProtocols(ConnectionPath path) + { + return _protocols.TryGetValue(path, out var protocols) ? protocols.ToArray() : []; + } + } + + private enum ConnectionPath + { + SiloInbound, + SiloOutbound, + GatewayInbound, + ClientOutbound + } } /// From 07ef4b42895b0eeeefb1657644d10a13ade3ba05 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Fri, 7 Aug 2026 15:53:07 -0700 Subject: [PATCH 02/22] feat(security): authenticate silo connections Add a bounded bearer-token handshake for silo-to-silo connections and an Entra workload identity provider with strict JWT and metadata validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e71c4ddd-5362-4204-910f-9a742ddd63de --- Directory.Packages.props | 2 + Orleans.slnx | 2 + .../EntraAuthenticationException.cs | 33 + .../EntraCredentialRegistration.cs | 11 + .../EntraJwtValidator.cs | 493 +++++++++++ .../EntraOpenIdConfigurationProvider.cs | 258 ++++++ .../EntraSigningKey.cs | 43 + .../EntraSiloConnectionOptions.cs | 174 ++++ .../EntraSiloConnectionOptionsValidator.cs | 246 ++++++ .../EntraSiloConnectionTokenProvider.cs | 31 + .../EntraSiloConnectionTokenValidator.cs | 39 + .../EntraTokenProvider.cs | 36 + .../HostingExtensions.cs | 63 ++ .../Orleans.Connections.Security.Entra.csproj | 28 + .../README.md | 18 + .../StrictHttpDocumentRetriever.cs | 114 +++ .../AuthenticationAbstractions.cs | 185 ++++ .../AuthenticationWorkLimiter.cs | 67 ++ .../SiloConnectionAuthenticationBuilder.cs | 156 ++++ .../SiloConnectionAuthenticationFeature.cs | 74 ++ .../SiloConnectionAuthenticationMiddleware.cs | 797 ++++++++++++++++++ .../SiloConnectionAuthenticationOptions.cs | 48 ++ ...onnectionAuthenticationOptionsValidator.cs | 111 +++ .../SiloConnectionAuthenticationProtocol.cs | 12 + ...iloConnectionAuthenticationRegistration.cs | 98 +++ .../SiloConnectionAuthenticationTelemetry.cs | 121 +++ .../Hosting/HostingExtensions.ISiloBuilder.cs | 18 + .../HostingExtensions.SiloAuthentication.cs | 93 ++ .../Orleans.Connections.Security.csproj | 10 +- .../Security/OrleansApplicationProtocol.cs | 1 + .../Orleans.Connections.Security.Entra.cs | 77 ++ .../Orleans.Connections.Security.cs | 203 +++++ .../EntraJwtValidatorTests.cs | 311 +++++++ .../EntraMetadataTests.cs | 266 ++++++ .../EntraOptionsTests.cs | 95 +++ .../EntraTestInfrastructure.cs | 261 ++++++ .../EntraTokenProviderTests.cs | 39 + ...ns.Connections.Security.Entra.Tests.csproj | 18 + .../Usings.cs | 1 + ...oConnectionAuthenticationContractsTests.cs | 153 ++++ .../TlsConnectionTests.cs | 17 + 41 files changed, 4820 insertions(+), 3 deletions(-) create mode 100644 src/Orleans.Connections.Security.Entra/EntraAuthenticationException.cs create mode 100644 src/Orleans.Connections.Security.Entra/EntraCredentialRegistration.cs create mode 100644 src/Orleans.Connections.Security.Entra/EntraJwtValidator.cs create mode 100644 src/Orleans.Connections.Security.Entra/EntraOpenIdConfigurationProvider.cs create mode 100644 src/Orleans.Connections.Security.Entra/EntraSigningKey.cs create mode 100644 src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptions.cs create mode 100644 src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptionsValidator.cs create mode 100644 src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenProvider.cs create mode 100644 src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenValidator.cs create mode 100644 src/Orleans.Connections.Security.Entra/EntraTokenProvider.cs create mode 100644 src/Orleans.Connections.Security.Entra/HostingExtensions.cs create mode 100644 src/Orleans.Connections.Security.Entra/Orleans.Connections.Security.Entra.csproj create mode 100644 src/Orleans.Connections.Security.Entra/README.md create mode 100644 src/Orleans.Connections.Security.Entra/StrictHttpDocumentRetriever.cs create mode 100644 src/Orleans.Connections.Security/Authentication/AuthenticationAbstractions.cs create mode 100644 src/Orleans.Connections.Security/Authentication/AuthenticationWorkLimiter.cs create mode 100644 src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationBuilder.cs create mode 100644 src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationFeature.cs create mode 100644 src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationMiddleware.cs create mode 100644 src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptions.cs create mode 100644 src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptionsValidator.cs create mode 100644 src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationProtocol.cs create mode 100644 src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationRegistration.cs create mode 100644 src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationTelemetry.cs create mode 100644 src/Orleans.Connections.Security/Hosting/HostingExtensions.SiloAuthentication.cs create mode 100644 src/api/Orleans.Connections.Security.Entra/Orleans.Connections.Security.Entra.cs create mode 100644 test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs create mode 100644 test/Orleans.Connections.Security.Entra.Tests/EntraMetadataTests.cs create mode 100644 test/Orleans.Connections.Security.Entra.Tests/EntraOptionsTests.cs create mode 100644 test/Orleans.Connections.Security.Entra.Tests/EntraTestInfrastructure.cs create mode 100644 test/Orleans.Connections.Security.Entra.Tests/EntraTokenProviderTests.cs create mode 100644 test/Orleans.Connections.Security.Entra.Tests/Orleans.Connections.Security.Entra.Tests.csproj create mode 100644 test/Orleans.Connections.Security.Entra.Tests/Usings.cs create mode 100644 test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 386ab049b9a..0baf50c5131 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -73,6 +73,8 @@ + + diff --git a/Orleans.slnx b/Orleans.slnx index d2395ae9857..aab3a13ac23 100644 --- a/Orleans.slnx +++ b/Orleans.slnx @@ -70,6 +70,7 @@ + @@ -133,6 +134,7 @@ + diff --git a/src/Orleans.Connections.Security.Entra/EntraAuthenticationException.cs b/src/Orleans.Connections.Security.Entra/EntraAuthenticationException.cs new file mode 100644 index 00000000000..c49da4b49cd --- /dev/null +++ b/src/Orleans.Connections.Security.Entra/EntraAuthenticationException.cs @@ -0,0 +1,33 @@ +using System; + +namespace Orleans.Connections.Security.Entra; + +internal enum EntraAuthenticationError +{ + InvalidToken, + ExpiredToken, + UnauthorizedCaller, + ProviderUnavailable, + TokenAcquisitionFailed, +} + +internal sealed class EntraAuthenticationException : Exception +{ + public EntraAuthenticationException(EntraAuthenticationError error) + : base(GetMessage(error)) + { + Error = error; + } + + public EntraAuthenticationError Error { get; } + + private static string GetMessage(EntraAuthenticationError error) => error switch + { + EntraAuthenticationError.InvalidToken => "The Entra token is invalid.", + EntraAuthenticationError.ExpiredToken => "The Entra token lifetime is invalid.", + EntraAuthenticationError.UnauthorizedCaller => "The Entra caller is not authorized.", + EntraAuthenticationError.ProviderUnavailable => "Entra metadata is unavailable.", + EntraAuthenticationError.TokenAcquisitionFailed => "An Entra token could not be acquired.", + _ => "Entra authentication failed.", + }; +} diff --git a/src/Orleans.Connections.Security.Entra/EntraCredentialRegistration.cs b/src/Orleans.Connections.Security.Entra/EntraCredentialRegistration.cs new file mode 100644 index 00000000000..9e26a23e4a2 --- /dev/null +++ b/src/Orleans.Connections.Security.Entra/EntraCredentialRegistration.cs @@ -0,0 +1,11 @@ +using System; +using Azure.Core; + +namespace Orleans.Connections.Security.Entra; + +internal sealed record EntraCredentialRegistration(TokenCredential Credential); + +internal sealed class EntraTimeProviderAccessor(Func getTimeProvider) +{ + public TimeProvider Value => getTimeProvider(); +} diff --git a/src/Orleans.Connections.Security.Entra/EntraJwtValidator.cs b/src/Orleans.Connections.Security.Entra/EntraJwtValidator.cs new file mode 100644 index 00000000000..3b48fc08434 --- /dev/null +++ b/src/Orleans.Connections.Security.Entra/EntraJwtValidator.cs @@ -0,0 +1,493 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Orleans.Configuration; + +namespace Orleans.Connections.Security.Entra; + +internal readonly record struct EntraTokenValidationOutcome( + ClaimsPrincipal Principal, + DateTimeOffset ExpiresAt); + +internal sealed class EntraJwtValidator +{ + private const string ApplicationIdentityType = "app"; + private readonly EntraSiloConnectionOptions _options; + private readonly EntraOpenIdConfigurationProvider _configurationProvider; + private readonly TimeProvider _timeProvider; + private readonly JsonWebTokenHandler _handler; + + public EntraJwtValidator( + EntraSiloConnectionOptions options, + EntraOpenIdConfigurationProvider configurationProvider, + TimeProvider timeProvider) + { + _options = options; + _configurationProvider = configurationProvider; + _timeProvider = timeProvider; + _handler = new JsonWebTokenHandler + { + MapInboundClaims = false, + MaximumTokenSizeInBytes = options.MaximumTokenSize, + }; + } + + public async ValueTask ValidateAsync( + string token, + string clusterId, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(token) + || string.IsNullOrEmpty(clusterId) + || Encoding.UTF8.GetByteCount(token) > _options.MaximumTokenSize) + { + throw new EntraAuthenticationException(EntraAuthenticationError.InvalidToken); + } + + JwtDocument document; + try + { + document = JwtDocument.Parse(token); + } + catch (Exception exception) when (exception is FormatException or ArgumentException or JsonException) + { + throw new EntraAuthenticationException(EntraAuthenticationError.InvalidToken); + } + + if (!_options.AllowedAlgorithms.Contains(document.Algorithm) + || string.Equals(document.Algorithm, SecurityAlgorithms.None, StringComparison.Ordinal) + || string.IsNullOrEmpty(document.KeyId)) + { + throw new EntraAuthenticationException(EntraAuthenticationError.InvalidToken); + } + + ValidateUntrustedClaims(document, clusterId); + var snapshot = await _configurationProvider.GetConfigurationAsync(cancellationToken).ConfigureAwait(false); + var result = await ValidateSignatureAndStandardClaimsAsync(token, clusterId, snapshot).ConfigureAwait(false); + + if (!result.IsValid && result.Exception is SecurityTokenSignatureKeyNotFoundException) + { + snapshot = await _configurationProvider.RefreshForUnknownSigningKeyAsync( + snapshot.Generation, + cancellationToken).ConfigureAwait(false); + result = await ValidateSignatureAndStandardClaimsAsync(token, clusterId, snapshot).ConfigureAwait(false); + } + + if (!result.IsValid) + { + throw new EntraAuthenticationException(ClassifyValidationFailure(result.Exception)); + } + + ValidateTrustedClaims(document, snapshot.Configuration.Issuer, clusterId); + + var claimsIdentity = result.ClaimsIdentity + ?? throw new EntraAuthenticationException(EntraAuthenticationError.InvalidToken); + var principal = new ClaimsPrincipal( + new ClaimsIdentity(claimsIdentity.Claims.Select(static claim => claim.Clone()), "Entra", "name", "roles")); + return new EntraTokenValidationOutcome(principal, DateTimeOffset.FromUnixTimeSeconds(document.ExpiresAt)); + } + + private Task ValidateSignatureAndStandardClaimsAsync( + string token, + string clusterId, + EntraOpenIdConfigurationSnapshot snapshot) + { + var validAudiences = new HashSet(_options.ValidAudiences, StringComparer.Ordinal); + if (!string.IsNullOrWhiteSpace(_options.ClusterAudienceFormat)) + { + // The cluster-specific audience was already checked against the raw payload and is + // also included in the cryptographically validated audience set. + validAudiences.Add(JwtDocument.FormatClusterValue(_options.ClusterAudienceFormat, clusterId)); + } + + var parameters = new TokenValidationParameters + { + ClockSkew = _options.ClockSkew, + IssuerSigningKeys = snapshot.Configuration.SigningKeys.Where( + key => EntraSigningKey.IsUsable(key, snapshot.Configuration, _options)), + LifetimeValidator = ValidateLifetime, + RequireExpirationTime = true, + RequireSignedTokens = true, + ValidateAudience = true, + ValidateIssuer = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + ValidAlgorithms = _options.AllowedAlgorithms, + ValidAudiences = validAudiences, + ValidIssuer = snapshot.Configuration.Issuer, + }; + + return _handler.ValidateTokenAsync(token, parameters); + } + + private bool ValidateLifetime( + DateTime? notBefore, + DateTime? expires, + SecurityToken securityToken, + TokenValidationParameters validationParameters) + { + if (notBefore is null || expires is null || expires <= notBefore) + { + return false; + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + return notBefore <= now + _options.ClockSkew + && expires >= now - _options.ClockSkew; + } + + private void ValidateUntrustedClaims(JwtDocument document, string clusterId) + { + if (!_options.SupportedTokenVersions.Contains(document.Version) + || !_options.ValidTenantIds.Contains(document.TenantId) + || document.ExpiresAt <= document.NotBefore + || DateTimeOffset.FromUnixTimeSeconds(document.ExpiresAt) + - DateTimeOffset.FromUnixTimeSeconds(document.NotBefore) > _options.MaximumTokenLifetime) + { + throw new EntraAuthenticationException(EntraAuthenticationError.InvalidToken); + } + + if (DateTimeOffset.FromUnixTimeSeconds(document.ExpiresAt) - _timeProvider.GetUtcNow() + < _options.MinimumRemainingTokenLifetime) + { + throw new EntraAuthenticationException(EntraAuthenticationError.ExpiredToken); + } + + var isDelegated = document.Scopes.Count > 0; + if (isDelegated && !_options.AllowDelegatedTokens) + { + throw new EntraAuthenticationException(EntraAuthenticationError.UnauthorizedCaller); + } + + if (!isDelegated && !string.Equals(document.IdentityType, ApplicationIdentityType, StringComparison.Ordinal)) + { + throw new EntraAuthenticationException(EntraAuthenticationError.UnauthorizedCaller); + } + + var callerId = document.Version switch + { + "1.0" when document.AuthorizedParty is null && document.ApplicationId is not null => document.ApplicationId, + "2.0" when document.ApplicationId is null && document.AuthorizedParty is not null => document.AuthorizedParty, + _ => null, + }; + + if (callerId is null) + { + throw new EntraAuthenticationException(EntraAuthenticationError.UnauthorizedCaller); + } + + if (!_options.AllowAnyApplicationInTenant) + { + if (_options.AllowedClientIds.Count > 0 && !_options.AllowedClientIds.Contains(callerId)) + { + throw new EntraAuthenticationException(EntraAuthenticationError.UnauthorizedCaller); + } + + if (_options.AllowedServicePrincipalObjectIds.Count > 0 + && (document.ObjectId is null || !_options.AllowedServicePrincipalObjectIds.Contains(document.ObjectId))) + { + throw new EntraAuthenticationException(EntraAuthenticationError.UnauthorizedCaller); + } + } + + if (_options.RequiredRoles.Count > 0 && !_options.RequiredRoles.Overlaps(document.Roles)) + { + throw new EntraAuthenticationException(EntraAuthenticationError.UnauthorizedCaller); + } + + if (!string.IsNullOrWhiteSpace(_options.ClusterClaimType) + && (!document.Claims.TryGetValue(_options.ClusterClaimType, out var clusterClaim) + || !string.Equals(clusterClaim, clusterId, StringComparison.Ordinal))) + { + throw new EntraAuthenticationException(EntraAuthenticationError.UnauthorizedCaller); + } + + if (!string.IsNullOrWhiteSpace(_options.ClusterRoleFormat) + && !document.Roles.Contains( + JwtDocument.FormatClusterValue(_options.ClusterRoleFormat, clusterId), + StringComparer.Ordinal)) + { + throw new EntraAuthenticationException(EntraAuthenticationError.UnauthorizedCaller); + } + + if (!string.IsNullOrWhiteSpace(_options.ClusterAudienceFormat) + && !document.Audiences.Contains( + JwtDocument.FormatClusterValue(_options.ClusterAudienceFormat, clusterId), + StringComparer.Ordinal)) + { + throw new EntraAuthenticationException(EntraAuthenticationError.UnauthorizedCaller); + } + } + + private void ValidateTrustedClaims(JwtDocument document, string issuer, string clusterId) + { + if (!string.Equals(document.Issuer, issuer, StringComparison.Ordinal) + || !new Uri(issuer).AbsolutePath + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Contains(document.TenantId, StringComparer.OrdinalIgnoreCase)) + { + throw new EntraAuthenticationException(EntraAuthenticationError.InvalidToken); + } + + ValidateUntrustedClaims(document, clusterId); + } + + private static EntraAuthenticationError ClassifyValidationFailure(Exception? exception) => exception switch + { + SecurityTokenExpiredException => EntraAuthenticationError.ExpiredToken, + SecurityTokenNotYetValidException => EntraAuthenticationError.ExpiredToken, + SecurityTokenNoExpirationException => EntraAuthenticationError.ExpiredToken, + SecurityTokenInvalidLifetimeException => EntraAuthenticationError.ExpiredToken, + _ => EntraAuthenticationError.InvalidToken, + }; + + private sealed class JwtDocument + { + private JwtDocument( + string algorithm, + string keyId, + Dictionary claims, + HashSet audiences, + HashSet roles, + HashSet scopes, + long notBefore, + long expiresAt) + { + Algorithm = algorithm; + KeyId = keyId; + Claims = claims; + Audiences = audiences; + Roles = roles; + Scopes = scopes; + NotBefore = notBefore; + ExpiresAt = expiresAt; + } + + public string Algorithm { get; } + + public string KeyId { get; } + + public Dictionary Claims { get; } + + public HashSet Audiences { get; } + + public HashSet Roles { get; } + + public HashSet Scopes { get; } + + public long NotBefore { get; } + + public long ExpiresAt { get; } + + public string Issuer => GetRequired("iss"); + + public string TenantId => GetRequired("tid"); + + public string Version => GetRequired("ver"); + + public string? AuthorizedParty => GetOptional("azp"); + + public string? ApplicationId => GetOptional("appid"); + + public string? ObjectId => GetOptional("oid"); + + public string? IdentityType => GetOptional("idtyp"); + + public static JwtDocument Parse(string token) + { + var segments = token.Split('.'); + if (segments.Length != 3) + { + throw new FormatException(); + } + + using var header = ParseObject(segments[0]); + var payload = ParsePayload(segments[1]); + var algorithm = ReadRequiredString(header, "alg"); + var keyId = ReadRequiredString(header, "kid"); + return new JwtDocument( + algorithm, + keyId, + payload.Claims, + payload.Audiences, + payload.Roles, + payload.Scopes, + payload.NotBefore, + payload.ExpiresAt); + } + + public static string FormatClusterValue(string format, string clusterId) + => string.Format(CultureInfo.InvariantCulture, format, clusterId); + + private static Payload ParsePayload(string segment) + { + using var document = ParseObject(segment); + var claims = new Dictionary(StringComparer.Ordinal); + var audiences = new HashSet(StringComparer.Ordinal); + var roles = new HashSet(StringComparer.Ordinal); + var scopes = new HashSet(StringComparer.Ordinal); + long? notBefore = null; + long? expiresAt = null; + var propertyNames = new HashSet(StringComparer.Ordinal); + + foreach (var property in document.RootElement.EnumerateObject()) + { + if (!propertyNames.Add(property.Name)) + { + throw new FormatException(); + } + + switch (property.Name) + { + case "aud": + ReadStringSet(property.Value, audiences); + break; + case "roles": + ReadStringSet(property.Value, roles); + break; + case "scp": + var scopeValue = ReadString(property.Value); + foreach (var scope in scopeValue.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + if (!scopes.Add(scope)) + { + throw new FormatException(); + } + } + + claims.Add(property.Name, scopeValue); + break; + case "nbf": + notBefore = ReadUnixTime(property.Value); + break; + case "exp": + expiresAt = ReadUnixTime(property.Value); + break; + default: + if (property.Value.ValueKind == JsonValueKind.String) + { + claims.Add(property.Name, ReadString(property.Value)); + } + + break; + } + } + + if (audiences.Count == 0 || notBefore is null || expiresAt is null) + { + throw new FormatException(); + } + + var result = new Payload(claims, audiences, roles, scopes, notBefore.Value, expiresAt.Value); + _ = GetRequiredClaim(result.Claims, "iss"); + _ = GetRequiredClaim(result.Claims, "tid"); + _ = GetRequiredClaim(result.Claims, "ver"); + return result; + } + + private static JsonDocument ParseObject(string segment) + { + var bytes = Base64UrlEncoder.DecodeBytes(segment); + var document = JsonDocument.Parse(bytes, new JsonDocumentOptions { MaxDepth = 16 }); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + document.Dispose(); + throw new FormatException(); + } + + var propertyNames = new HashSet(StringComparer.Ordinal); + foreach (var property in document.RootElement.EnumerateObject()) + { + if (!propertyNames.Add(property.Name)) + { + document.Dispose(); + throw new FormatException(); + } + } + + return document; + } + + private static string ReadRequiredString(JsonDocument document, string propertyName) + { + if (!document.RootElement.TryGetProperty(propertyName, out var value)) + { + throw new FormatException(); + } + + return ReadString(value); + } + + private static string ReadString(JsonElement value) + { + if (value.ValueKind != JsonValueKind.String || string.IsNullOrEmpty(value.GetString())) + { + throw new FormatException(); + } + + return value.GetString()!; + } + + private static long ReadUnixTime(JsonElement value) + { + if (value.ValueKind != JsonValueKind.Number || !value.TryGetInt64(out var result)) + { + throw new FormatException(); + } + + _ = DateTimeOffset.FromUnixTimeSeconds(result); + return result; + } + + private static void ReadStringSet(JsonElement value, HashSet destination) + { + if (value.ValueKind == JsonValueKind.String) + { + if (!destination.Add(ReadString(value))) + { + throw new FormatException(); + } + + return; + } + + if (value.ValueKind != JsonValueKind.Array) + { + throw new FormatException(); + } + + foreach (var element in value.EnumerateArray()) + { + if (!destination.Add(ReadString(element))) + { + throw new FormatException(); + } + } + } + + private string GetRequired(string name) + => GetRequiredClaim(Claims, name); + + private string? GetOptional(string name) + => Claims.TryGetValue(name, out var value) ? value : null; + + private static string GetRequiredClaim(Dictionary claims, string name) + => claims.TryGetValue(name, out var value) ? value : throw new FormatException(); + + private readonly record struct Payload( + Dictionary Claims, + HashSet Audiences, + HashSet Roles, + HashSet Scopes, + long NotBefore, + long ExpiresAt); + } +} diff --git a/src/Orleans.Connections.Security.Entra/EntraOpenIdConfigurationProvider.cs b/src/Orleans.Connections.Security.Entra/EntraOpenIdConfigurationProvider.cs new file mode 100644 index 00000000000..bd56cea9f11 --- /dev/null +++ b/src/Orleans.Connections.Security.Entra/EntraOpenIdConfigurationProvider.cs @@ -0,0 +1,258 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Orleans.Configuration; + +namespace Orleans.Connections.Security.Entra; + +internal readonly record struct EntraOpenIdConfigurationSnapshot( + OpenIdConnectConfiguration Configuration, + long Generation); + +internal sealed class EntraOpenIdConfigurationProvider : IDisposable +{ + private readonly EntraSiloConnectionOptions _options; + private readonly IDocumentRetriever _documentRetriever; + private readonly TimeProvider _timeProvider; + private readonly Func _nextJitter; + private readonly SemaphoreSlim _refreshLock = new(1, 1); + private readonly object _stateLock = new(); + private ConfigurationManager _configurationManager; + private OpenIdConnectConfiguration? _lastKnownGood; + private DateTimeOffset _lastKnownGoodAt; + private DateTimeOffset _nextAutomaticRefresh; + private DateTimeOffset _nextRefreshAllowed; + private DateTimeOffset _lastUnknownKeyRefresh; + private long _generation; + private int _consecutiveFailures; + private int _queuedRefreshes; + + public EntraOpenIdConfigurationProvider(EntraSiloConnectionOptions options, TimeProvider timeProvider) + : this(options, new StrictHttpDocumentRetriever(options), timeProvider, Random.Shared.NextDouble) + { + } + + internal EntraOpenIdConfigurationProvider( + EntraSiloConnectionOptions options, + IDocumentRetriever documentRetriever, + TimeProvider timeProvider, + Func nextJitter) + { + _options = options; + _documentRetriever = documentRetriever; + _timeProvider = timeProvider; + _nextJitter = nextJitter; + _configurationManager = CreateConfigurationManager(); + } + + public ValueTask GetConfigurationAsync(CancellationToken cancellationToken) + { + var now = _timeProvider.GetUtcNow(); + lock (_stateLock) + { + if (_lastKnownGood is not null + && now < _nextAutomaticRefresh + && now - _lastKnownGoodAt <= _options.LastKnownGoodLifetime) + { + return ValueTask.FromResult(new EntraOpenIdConfigurationSnapshot(_lastKnownGood, _generation)); + } + } + + return RefreshAsync(RefreshReason.Automatic, observedGeneration: -1, cancellationToken); + } + + public ValueTask RefreshForUnknownSigningKeyAsync( + long observedGeneration, + CancellationToken cancellationToken) + => RefreshAsync(RefreshReason.UnknownSigningKey, observedGeneration, cancellationToken); + + public void Dispose() + { + _refreshLock.Dispose(); + if (_documentRetriever is IDisposable disposable) + { + disposable.Dispose(); + } + } + + private async ValueTask RefreshAsync( + RefreshReason reason, + long observedGeneration, + CancellationToken cancellationToken) + { + if (Interlocked.Increment(ref _queuedRefreshes) > _options.MaximumMetadataRefreshQueueSize) + { + Interlocked.Decrement(ref _queuedRefreshes); + throw new EntraAuthenticationException(EntraAuthenticationError.ProviderUnavailable); + } + + try + { + await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + Interlocked.Decrement(ref _queuedRefreshes); + } + + try + { + var now = _timeProvider.GetUtcNow(); + lock (_stateLock) + { + if (_lastKnownGood is not null) + { + if (reason == RefreshReason.Automatic && now < _nextAutomaticRefresh) + { + return new EntraOpenIdConfigurationSnapshot(_lastKnownGood, _generation); + } + + if (reason == RefreshReason.UnknownSigningKey + && (observedGeneration != _generation + || now - _lastUnknownKeyRefresh < _options.UnknownSigningKeyRefreshInterval)) + { + return new EntraOpenIdConfigurationSnapshot(_lastKnownGood, _generation); + } + } + + if (now < _nextRefreshAllowed) + { + return GetLastKnownGoodOrThrow(now); + } + + if (reason == RefreshReason.UnknownSigningKey) + { + _lastUnknownKeyRefresh = now; + } + + // A fresh manager guarantees that an allowed refresh is not delayed by a second, + // wall-clock-based throttle inside ConfigurationManager. + _configurationManager = CreateConfigurationManager(); + } + + try + { + var configuration = await _configurationManager.GetConfigurationAsync(cancellationToken).ConfigureAwait(false); + ValidateConfiguration(configuration); + + lock (_stateLock) + { + now = _timeProvider.GetUtcNow(); + _lastKnownGood = configuration; + _lastKnownGoodAt = now; + _nextAutomaticRefresh = now + Min( + _options.AutomaticMetadataRefreshInterval, + _options.LastKnownGoodLifetime); + _nextRefreshAllowed = DateTimeOffset.MinValue; + _consecutiveFailures = 0; + _generation++; + return new EntraOpenIdConfigurationSnapshot(configuration, _generation); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) when (exception is + EntraAuthenticationException + or IOException + or HttpRequestException + or InvalidOperationException + or OperationCanceledException + or ArgumentException + or TimeoutException + or SecurityTokenException + or JsonException) + { + lock (_stateLock) + { + now = _timeProvider.GetUtcNow(); + _consecutiveFailures = Math.Min(_consecutiveFailures + 1, 30); + _nextRefreshAllowed = now + GetBackoffDelay(_consecutiveFailures); + return GetLastKnownGoodOrThrow(now); + } + } + } + finally + { + _refreshLock.Release(); + } + } + + private EntraOpenIdConfigurationSnapshot GetLastKnownGoodOrThrow(DateTimeOffset now) + { + if (_lastKnownGood is not null && now - _lastKnownGoodAt <= _options.LastKnownGoodLifetime) + { + return new EntraOpenIdConfigurationSnapshot(_lastKnownGood, _generation); + } + + throw new EntraAuthenticationException(EntraAuthenticationError.ProviderUnavailable); + } + + private TimeSpan GetBackoffDelay(int failureCount) + { + var exponent = Math.Min(failureCount - 1, 30); + var baseMilliseconds = Math.Min( + _options.MetadataRefreshBackoff.TotalMilliseconds * Math.Pow(2, exponent), + _options.MaximumMetadataRefreshBackoff.TotalMilliseconds); + var jitter = baseMilliseconds * _options.MetadataRefreshJitterRatio * Math.Clamp(_nextJitter(), 0, 1); + return TimeSpan.FromMilliseconds(Math.Min( + baseMilliseconds + jitter, + _options.MaximumMetadataRefreshBackoff.TotalMilliseconds)); + } + + private static TimeSpan Min(TimeSpan left, TimeSpan right) => left <= right ? left : right; + + private ConfigurationManager CreateConfigurationManager() + { + var authority = _options.Authority!.AbsoluteUri.TrimEnd('/'); + return new ConfigurationManager( + $"{authority}/.well-known/openid-configuration", + new OpenIdConnectConfigurationRetriever(), + _documentRetriever); + } + + private void ValidateConfiguration(OpenIdConnectConfiguration configuration) + { + if (!TryGetTrustedUri(configuration.Issuer, out var issuer) + || !TryGetTrustedUri(configuration.JwksUri, out _) + || !IssuerMatchesTenant(issuer) + || configuration.JsonWebKeySet?.Keys.Any(key => EntraSigningKey.IsUsable(key, _options)) != true + || !configuration.SigningKeys.Any(key => EntraSigningKey.IsUsable(key, configuration, _options))) + { + throw new EntraAuthenticationException(EntraAuthenticationError.ProviderUnavailable); + } + } + + private bool TryGetTrustedUri(string? value, out Uri uri) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out uri!) + || !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + || !string.IsNullOrEmpty(uri.UserInfo)) + { + return false; + } + + return string.Equals(uri.IdnHost, _options.Authority!.IdnHost, StringComparison.OrdinalIgnoreCase) + || _options.AdditionalTrustedMetadataHosts.Contains(uri.IdnHost); + } + + private bool IssuerMatchesTenant(Uri issuer) + { + var segments = issuer.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + return segments.Any(segment => _options.ValidTenantIds.Contains(segment)); + } + + private enum RefreshReason + { + Automatic, + UnknownSigningKey, + } +} diff --git a/src/Orleans.Connections.Security.Entra/EntraSigningKey.cs b/src/Orleans.Connections.Security.Entra/EntraSigningKey.cs new file mode 100644 index 00000000000..e2b68e29461 --- /dev/null +++ b/src/Orleans.Connections.Security.Entra/EntraSigningKey.cs @@ -0,0 +1,43 @@ +using System; +using System.Linq; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Orleans.Configuration; + +namespace Orleans.Connections.Security.Entra; + +internal static class EntraSigningKey +{ + public static bool IsUsable( + SecurityKey key, + OpenIdConnectConfiguration configuration, + EntraSiloConnectionOptions options) + { + if (key is not AsymmetricSecurityKey || string.IsNullOrEmpty(key.KeyId)) + { + return false; + } + + return configuration.JsonWebKeySet?.Keys.Any( + jsonWebKey => string.Equals(jsonWebKey.Kid, key.KeyId, StringComparison.Ordinal) + && IsUsable(jsonWebKey, options)) == true; + } + + public static bool IsUsable(JsonWebKey jsonWebKey, EntraSiloConnectionOptions options) + { + if (!string.Equals(jsonWebKey.Use, JsonWebKeyUseNames.Sig, StringComparison.Ordinal) + || (jsonWebKey.KeyOps is { Count: > 0 } + && !jsonWebKey.KeyOps.Contains("verify", StringComparer.Ordinal))) + { + return false; + } + + if (!string.Equals(jsonWebKey.Kty, JsonWebAlgorithmsKeyTypes.RSA, StringComparison.Ordinal) + && !string.Equals(jsonWebKey.Kty, JsonWebAlgorithmsKeyTypes.EllipticCurve, StringComparison.Ordinal)) + { + return false; + } + + return string.IsNullOrEmpty(jsonWebKey.Alg) || options.AllowedAlgorithms.Contains(jsonWebKey.Alg); + } +} diff --git a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptions.cs b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptions.cs new file mode 100644 index 00000000000..83dda0a61f8 --- /dev/null +++ b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptions.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; + +namespace Orleans.Configuration; + +/// +/// Configures Microsoft Entra authentication for Orleans silo connections. +/// +public sealed class EntraSiloConnectionOptions +{ + /// + /// Gets or sets the tenant-specific OpenID Connect authority. + /// + /// + /// The authority must use HTTPS and must not use a tenant-independent endpoint such as + /// common, organizations, or consumers. + /// + public Uri? Authority { get; set; } + + /// + /// Gets or sets the scope requested from the configured . + /// + public string? TokenScope { get; set; } + + /// + /// Gets the exact token audiences which are accepted. + /// + public ISet ValidAudiences { get; } = new HashSet(StringComparer.Ordinal); + + /// + /// Gets the tenant identifiers which are accepted. + /// + public ISet ValidTenantIds { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets the client application identifiers which are authorized to connect. + /// + public ISet AllowedClientIds { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets the service-principal object identifiers which are authorized to connect. + /// + public ISet AllowedServicePrincipalObjectIds { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets the application roles, at least one of which must be present. + /// + public ISet RequiredRoles { get; } = new HashSet(StringComparer.Ordinal); + + /// + /// Gets the asymmetric signing algorithms which are accepted. + /// + public ISet AllowedAlgorithms { get; } = new HashSet(StringComparer.Ordinal) + { + Microsoft.IdentityModel.Tokens.SecurityAlgorithms.RsaSha256, + }; + + /// + /// Gets the token versions which are accepted. + /// + public ISet SupportedTokenVersions { get; } = new HashSet(StringComparer.Ordinal) + { + "1.0", + "2.0", + }; + + /// + /// Gets the additional hosts from which metadata or signing keys can be retrieved. + /// + /// + /// The authority host is always trusted. Additions should only be used when an identity + /// provider's documented metadata endpoint uses another host in the same trusted cloud. + /// Redirect responses are always rejected. + /// + public ISet AdditionalTrustedMetadataHosts { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets or sets a value indicating whether any application in a valid tenant is authorized. + /// + /// The default is . + public bool AllowAnyApplicationInTenant { get; set; } + + /// + /// Gets or sets a value indicating whether delegated tokens can be accepted. + /// + /// The default is . Application tokens are required by default. + public bool AllowDelegatedTokens { get; set; } + + /// + /// Gets or sets the claim whose value must exactly match the local Orleans cluster identifier. + /// + public string? ClusterClaimType { get; set; } + + /// + /// Gets or sets a composite-format string used to construct a required cluster role. + /// + /// {0} is replaced with the local Orleans cluster identifier. + public string? ClusterRoleFormat { get; set; } + + /// + /// Gets or sets a composite-format string used to construct a required cluster audience. + /// + /// {0} is replaced with the local Orleans cluster identifier. + public string? ClusterAudienceFormat { get; set; } + + /// + /// Gets or sets the minimum remaining lifetime required for acquired and validated tokens. + /// + public TimeSpan MinimumRemainingTokenLifetime { get; set; } = TimeSpan.FromMinutes(2); + + /// + /// Gets or sets the maximum accepted token lifetime, measured from nbf to exp. + /// + public TimeSpan MaximumTokenLifetime { get; set; } = TimeSpan.FromHours(2); + + /// + /// Gets or sets the clock skew applied to nbf and exp validation. + /// + public TimeSpan ClockSkew { get; set; } = TimeSpan.FromMinutes(2); + + /// + /// Gets or sets the maximum encoded JWT size. + /// + public int MaximumTokenSize { get; set; } = 16 * 1024; + + /// + /// Gets or sets how often cached OpenID Connect metadata is automatically refreshed. + /// + public TimeSpan AutomaticMetadataRefreshInterval { get; set; } = TimeSpan.FromHours(12); + + /// + /// Gets or sets the minimum interval between refreshes caused by unknown signing keys. + /// + public TimeSpan UnknownSigningKeyRefreshInterval { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets the initial metadata refresh retry delay. + /// + public TimeSpan MetadataRefreshBackoff { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Gets or sets the maximum metadata refresh retry delay. + /// + public TimeSpan MaximumMetadataRefreshBackoff { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// Gets or sets the maximum proportional jitter added to metadata refresh retry delays. + /// + public double MetadataRefreshJitterRatio { get; set; } = 0.2; + + /// + /// Gets or sets the metadata retrieval timeout. + /// + public TimeSpan MetadataRetrievalTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// Gets or sets how long successfully validated metadata can be used during a metadata outage. + /// + /// + /// Last-known-good metadata is never used beyond this interval. This bounds how long a signing + /// key which has been removed by the authority can continue to be trusted during an outage. + /// + public TimeSpan LastKnownGoodLifetime { get; set; } = TimeSpan.FromHours(24); + + /// + /// Gets or sets the maximum metadata document size in bytes. + /// + public int MaximumMetadataSize { get; set; } = 1024 * 1024; + + /// + /// Gets or sets the maximum number of callers which can wait for the single metadata refresh. + /// + public int MaximumMetadataRefreshQueueSize { get; set; } = 64; +} diff --git a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptionsValidator.cs b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptionsValidator.cs new file mode 100644 index 00000000000..cf8559226d3 --- /dev/null +++ b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptionsValidator.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.Extensions.Options; +using Orleans.Connections.Security.Entra; + +namespace Orleans.Configuration; + +internal sealed class EntraSiloConnectionOptionsValidator : IValidateOptions +{ + private static readonly TimeSpan MaximumLongDuration = TimeSpan.FromDays(7); + private static readonly TimeSpan MaximumTokenDuration = TimeSpan.FromDays(1); + private static readonly TimeSpan MaximumClockSkew = TimeSpan.FromMinutes(15); + private static readonly TimeSpan MaximumRetrievalTimeout = TimeSpan.FromMinutes(5); + private readonly IEnumerable? _credentialRegistrations; + + public EntraSiloConnectionOptionsValidator() + { + } + + public EntraSiloConnectionOptionsValidator(IEnumerable credentialRegistrations) + { + _credentialRegistrations = credentialRegistrations; + } + + public ValidateOptionsResult Validate(string? name, EntraSiloConnectionOptions options) + { + ArgumentNullException.ThrowIfNull(options); + var errors = new List(); + + if (_credentialRegistrations is not null && _credentialRegistrations.Count() != 1) + { + errors.Add("Exactly one caller-supplied TokenCredential must be configured."); + } + + if (options.Authority is not { IsAbsoluteUri: true } authority + || !string.Equals(authority.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + || !string.IsNullOrEmpty(authority.UserInfo) + || !string.IsNullOrEmpty(authority.Query) + || !string.IsNullOrEmpty(authority.Fragment)) + { + errors.Add($"{nameof(options.Authority)} must be an absolute HTTPS URI without user information, a query, or a fragment."); + } + else + { + ValidateAuthority(authority, errors); + } + + RequireValue(options.TokenScope, nameof(options.TokenScope), errors); + RequireNonEmpty(options.ValidAudiences, nameof(options.ValidAudiences), errors); + RequireNonEmpty(options.ValidTenantIds, nameof(options.ValidTenantIds), errors); + RequireNonEmpty(options.AllowedAlgorithms, nameof(options.AllowedAlgorithms), errors); + RequireNonEmpty(options.SupportedTokenVersions, nameof(options.SupportedTokenVersions), errors); + ValidateEntries(options.ValidAudiences, nameof(options.ValidAudiences), errors); + ValidateEntries(options.ValidTenantIds, nameof(options.ValidTenantIds), errors); + ValidateEntries(options.AllowedClientIds, nameof(options.AllowedClientIds), errors); + ValidateEntries(options.AllowedServicePrincipalObjectIds, nameof(options.AllowedServicePrincipalObjectIds), errors); + ValidateEntries(options.RequiredRoles, nameof(options.RequiredRoles), errors); + ValidateEntries(options.AllowedAlgorithms, nameof(options.AllowedAlgorithms), errors); + ValidateEntries(options.SupportedTokenVersions, nameof(options.SupportedTokenVersions), errors); + ValidateEntries(options.AdditionalTrustedMetadataHosts, nameof(options.AdditionalTrustedMetadataHosts), errors); + + if (options.Authority is { IsAbsoluteUri: true } configuredAuthority + && TryGetAuthorityTenant(configuredAuthority, out var authorityTenant) + && !options.ValidTenantIds.Contains(authorityTenant)) + { + errors.Add($"{nameof(options.ValidTenantIds)} must include the tenant from {nameof(options.Authority)}."); + } + + if (!options.AllowAnyApplicationInTenant + && options.AllowedClientIds.Count == 0 + && options.AllowedServicePrincipalObjectIds.Count == 0 + && options.RequiredRoles.Count == 0) + { + errors.Add( + $"At least one of {nameof(options.AllowedClientIds)}, {nameof(options.AllowedServicePrincipalObjectIds)}, " + + $"or {nameof(options.RequiredRoles)} must be configured unless {nameof(options.AllowAnyApplicationInTenant)} is enabled."); + } + + if (string.IsNullOrWhiteSpace(options.ClusterClaimType) + && string.IsNullOrWhiteSpace(options.ClusterRoleFormat) + && string.IsNullOrWhiteSpace(options.ClusterAudienceFormat)) + { + errors.Add( + $"At least one of {nameof(options.ClusterClaimType)}, {nameof(options.ClusterRoleFormat)}, " + + $"or {nameof(options.ClusterAudienceFormat)} must bind credentials to the local cluster."); + } + + ValidateFormat(options.ClusterRoleFormat, nameof(options.ClusterRoleFormat), errors); + ValidateFormat(options.ClusterAudienceFormat, nameof(options.ClusterAudienceFormat), errors); + ValidatePositive(options.MinimumRemainingTokenLifetime, nameof(options.MinimumRemainingTokenLifetime), MaximumTokenDuration, errors); + ValidatePositive(options.MaximumTokenLifetime, nameof(options.MaximumTokenLifetime), MaximumTokenDuration, errors); + ValidateNonNegative(options.ClockSkew, nameof(options.ClockSkew), errors); + ValidatePositive(options.AutomaticMetadataRefreshInterval, nameof(options.AutomaticMetadataRefreshInterval), MaximumLongDuration, errors); + ValidatePositive(options.UnknownSigningKeyRefreshInterval, nameof(options.UnknownSigningKeyRefreshInterval), MaximumLongDuration, errors); + ValidatePositive(options.MetadataRefreshBackoff, nameof(options.MetadataRefreshBackoff), MaximumLongDuration, errors); + ValidatePositive(options.MaximumMetadataRefreshBackoff, nameof(options.MaximumMetadataRefreshBackoff), MaximumLongDuration, errors); + ValidatePositive(options.MetadataRetrievalTimeout, nameof(options.MetadataRetrievalTimeout), MaximumRetrievalTimeout, errors); + ValidatePositive(options.LastKnownGoodLifetime, nameof(options.LastKnownGoodLifetime), MaximumLongDuration, errors); + + if (options.MinimumRemainingTokenLifetime > options.MaximumTokenLifetime) + { + errors.Add($"{nameof(options.MinimumRemainingTokenLifetime)} must not exceed {nameof(options.MaximumTokenLifetime)}."); + } + + if (options.ClockSkew > MaximumClockSkew) + { + errors.Add($"{nameof(options.ClockSkew)} cannot exceed {MaximumClockSkew}."); + } + + if (options.MaximumMetadataRefreshBackoff < options.MetadataRefreshBackoff) + { + errors.Add($"{nameof(options.MaximumMetadataRefreshBackoff)} must not be less than {nameof(options.MetadataRefreshBackoff)}."); + } + + if (!double.IsFinite(options.MetadataRefreshJitterRatio) + || options.MetadataRefreshJitterRatio is < 0 or > 1) + { + errors.Add($"{nameof(options.MetadataRefreshJitterRatio)} must be between 0 and 1."); + } + + if (options.MaximumTokenSize <= 0) + { + errors.Add($"{nameof(options.MaximumTokenSize)} must be positive."); + } + else if (options.MaximumTokenSize > 1024 * 1024) + { + errors.Add($"{nameof(options.MaximumTokenSize)} cannot exceed 1 MiB."); + } + + if (options.MaximumMetadataSize <= 0) + { + errors.Add($"{nameof(options.MaximumMetadataSize)} must be positive."); + } + else if (options.MaximumMetadataSize > 16 * 1024 * 1024) + { + errors.Add($"{nameof(options.MaximumMetadataSize)} cannot exceed 16 MiB."); + } + + if (options.MaximumMetadataRefreshQueueSize is <= 0 or > 65_536) + { + errors.Add($"{nameof(options.MaximumMetadataRefreshQueueSize)} must be between 1 and 65536."); + } + + return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(errors); + } + + private static void ValidateAuthority(Uri authority, List errors) + { + if (!TryGetAuthorityTenant(authority, out var tenant)) + { + errors.Add($"{nameof(EntraSiloConnectionOptions.Authority)} must contain a tenant-specific path."); + return; + } + + if (tenant is "common" or "organizations" or "consumers") + { + errors.Add($"{nameof(EntraSiloConnectionOptions.Authority)} must identify a specific tenant."); + } + } + + private static bool TryGetAuthorityTenant(Uri authority, out string tenant) + { + var segments = authority.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + { + tenant = string.Empty; + return false; + } + + tenant = string.Equals(segments[^1], "v2.0", StringComparison.OrdinalIgnoreCase) && segments.Length > 1 + ? segments[^2] + : segments[^1]; + return !string.IsNullOrWhiteSpace(tenant); + } + + private static void RequireValue(string? value, string propertyName, List errors) + { + if (string.IsNullOrWhiteSpace(value)) + { + errors.Add($"{propertyName} must be configured."); + } + } + + private static void RequireNonEmpty(ISet values, string propertyName, List errors) + { + if (values.Count == 0) + { + errors.Add($"{propertyName} must contain at least one value."); + } + } + + private static void ValidateEntries(ISet values, string propertyName, List errors) + { + foreach (var value in values) + { + if (string.IsNullOrWhiteSpace(value)) + { + errors.Add($"{propertyName} cannot contain null, empty, or whitespace values."); + break; + } + } + } + + private static void ValidateFormat(string? value, string propertyName, List errors) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + try + { + var marker = Guid.NewGuid().ToString("N"); + if (!string.Format(CultureInfo.InvariantCulture, value, marker).Contains(marker, StringComparison.Ordinal)) + { + errors.Add($"{propertyName} must contain the '{{0}}' cluster identifier placeholder."); + } + } + catch (FormatException) + { + errors.Add($"{propertyName} must be a valid composite format string."); + } + } + + private static void ValidatePositive( + TimeSpan value, + string propertyName, + TimeSpan maximum, + List errors) + { + if (value <= TimeSpan.Zero || value > maximum) + { + errors.Add($"{propertyName} must be positive and no greater than {maximum}."); + } + } + + private static void ValidateNonNegative(TimeSpan value, string propertyName, List errors) + { + if (value < TimeSpan.Zero) + { + errors.Add($"{propertyName} cannot be negative."); + } + } +} diff --git a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenProvider.cs b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenProvider.cs new file mode 100644 index 00000000000..febdf8f4942 --- /dev/null +++ b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenProvider.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Orleans.Configuration; + +namespace Orleans.Connections.Security.Entra; + +internal sealed class EntraSiloConnectionTokenProvider : ISiloConnectionTokenProvider +{ + private readonly EntraTokenProvider _provider; + + public EntraSiloConnectionTokenProvider( + IEnumerable credentialRegistrations, + IOptions options, + EntraTimeProviderAccessor timeProvider) + { + var registration = credentialRegistrations.Single(); + _provider = new EntraTokenProvider(registration.Credential, options.Value, timeProvider.Value); + } + + public async ValueTask GetTokenAsync( + SiloConnectionTokenRequestContext context, + CancellationToken cancellationToken) + { + var token = await _provider.GetTokenAsync(cancellationToken).ConfigureAwait(false); + return new SiloConnectionToken(token.Token, token.ExpiresOn); + } +} diff --git a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenValidator.cs b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenValidator.cs new file mode 100644 index 00000000000..68e0dcff564 --- /dev/null +++ b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenValidator.cs @@ -0,0 +1,39 @@ +using System.Threading; +using System.Threading.Tasks; +using Orleans.Configuration; + +namespace Orleans.Connections.Security.Entra; + +internal sealed class EntraSiloConnectionTokenValidator : ISiloConnectionTokenValidator +{ + private readonly EntraJwtValidator _validator; + + public EntraSiloConnectionTokenValidator(EntraJwtValidator validator) + { + _validator = validator; + } + + public async ValueTask ValidateTokenAsync( + string token, + SiloConnectionTokenValidationContext context, + CancellationToken cancellationToken) + { + try + { + var result = await _validator.ValidateAsync(token, context.ClusterId, cancellationToken).ConfigureAwait(false); + return SiloConnectionTokenValidationResult.Success(result.Principal, result.ExpiresAt); + } + catch (EntraAuthenticationException exception) + { + return SiloConnectionTokenValidationResult.Fail(MapFailure(exception.Error)); + } + } + + private static SiloConnectionAuthenticationFailure MapFailure(EntraAuthenticationError error) => error switch + { + EntraAuthenticationError.ExpiredToken => SiloConnectionAuthenticationFailure.ExpiredToken, + EntraAuthenticationError.UnauthorizedCaller => SiloConnectionAuthenticationFailure.UnauthorizedCaller, + EntraAuthenticationError.ProviderUnavailable => SiloConnectionAuthenticationFailure.ProviderUnavailable, + _ => SiloConnectionAuthenticationFailure.InvalidToken, + }; +} diff --git a/src/Orleans.Connections.Security.Entra/EntraTokenProvider.cs b/src/Orleans.Connections.Security.Entra/EntraTokenProvider.cs new file mode 100644 index 00000000000..3a2039056de --- /dev/null +++ b/src/Orleans.Connections.Security.Entra/EntraTokenProvider.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Orleans.Configuration; + +namespace Orleans.Connections.Security.Entra; + +internal sealed class EntraTokenProvider +{ + private readonly TokenCredential _credential; + private readonly EntraSiloConnectionOptions _options; + private readonly TimeProvider _timeProvider; + + public EntraTokenProvider(TokenCredential credential, EntraSiloConnectionOptions options, TimeProvider timeProvider) + { + _credential = credential; + _options = options; + _timeProvider = timeProvider; + } + + public async ValueTask GetTokenAsync(CancellationToken cancellationToken) + { + var token = await _credential.GetTokenAsync( + new TokenRequestContext([_options.TokenScope!]), + cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrEmpty(token.Token) + || token.ExpiresOn - _timeProvider.GetUtcNow() < _options.MinimumRemainingTokenLifetime) + { + throw new EntraAuthenticationException(EntraAuthenticationError.TokenAcquisitionFailed); + } + + return token; + } +} diff --git a/src/Orleans.Connections.Security.Entra/HostingExtensions.cs b/src/Orleans.Connections.Security.Entra/HostingExtensions.cs new file mode 100644 index 00000000000..0c8b114645a --- /dev/null +++ b/src/Orleans.Connections.Security.Entra/HostingExtensions.cs @@ -0,0 +1,63 @@ +using System; +using Azure.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Orleans.Configuration; +using Orleans.Connections.Security; +using Orleans.Connections.Security.Entra; + +namespace Orleans.Hosting; + +/// +/// Extension methods for configuring Microsoft Entra silo connection authentication. +/// +public static class EntraSiloConnectionAuthenticationExtensions +{ + /// + /// Configures Microsoft Entra token acquisition and validation for authenticated silo connections. + /// + /// The authenticated silo connection builder. + /// The caller-supplied credential used to acquire tokens. + /// Configures Microsoft Entra token acquisition and validation. + /// The builder. + public static SiloConnectionAuthenticationBuilder UseEntra( + this SiloConnectionAuthenticationBuilder builder, + TokenCredential credential, + Action configureOptions) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(credential); + ArgumentNullException.ThrowIfNull(configureOptions); + + var services = builder.Services; + services.AddSingleton(new EntraCredentialRegistration(credential)); + services.AddSingleton(new EntraTimeProviderAccessor(() => builder.TimeProvider)); + services.TryAddEnumerable( + ServiceDescriptor.Singleton, EntraSiloConnectionOptionsValidator>()); + services.AddOptions() + .Configure(configureOptions) + .ValidateOnStart(); + services.TryAddSingleton( + static serviceProvider => + { + var options = serviceProvider.GetRequiredService>().Value; + return new EntraOpenIdConfigurationProvider( + options, + serviceProvider.GetRequiredService().Value); + }); + services.TryAddSingleton( + static serviceProvider => + { + var options = serviceProvider.GetRequiredService>().Value; + return new EntraJwtValidator( + options, + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService().Value); + }); + + return builder + .UseTokenProvider() + .UseTokenValidator(); + } +} diff --git a/src/Orleans.Connections.Security.Entra/Orleans.Connections.Security.Entra.csproj b/src/Orleans.Connections.Security.Entra/Orleans.Connections.Security.Entra.csproj new file mode 100644 index 00000000000..867fd36024b --- /dev/null +++ b/src/Orleans.Connections.Security.Entra/Orleans.Connections.Security.Entra.csproj @@ -0,0 +1,28 @@ + + + + Microsoft.Orleans.Connections.Security.Entra + Microsoft Orleans Entra silo connection authentication + Preview support for authenticating Microsoft Orleans silo connections using Microsoft Entra workload identities. + $(PackageTags) Azure Entra Security Authentication Preview + $(DefaultTargetFrameworks) + true + enable + + + + + + + + + + + + + + + + + + diff --git a/src/Orleans.Connections.Security.Entra/README.md b/src/Orleans.Connections.Security.Entra/README.md new file mode 100644 index 00000000000..0b72a6f47c7 --- /dev/null +++ b/src/Orleans.Connections.Security.Entra/README.md @@ -0,0 +1,18 @@ +# Microsoft Orleans Entra silo connection authentication + +This preview package integrates Microsoft Entra workload identities with authenticated Orleans +silo connections. Applications supply an `Azure.Core.TokenCredential`; the package does not create +credentials and does not depend on `Azure.Identity`. + +The validator is fail-closed. Configure a tenant-specific HTTPS authority, exact audiences, tenant +identifiers, explicit caller authorization, and a cluster binding. Delegated tokens are rejected by +default. Metadata redirects and untrusted metadata hosts are rejected. + +OpenID Connect metadata refresh is single-flight per configured authority. Unknown signing keys can +trigger at most one refresh per `UnknownSigningKeyRefreshInterval`. Failed refreshes use exponential +backoff with bounded jitter. Previously validated metadata remains usable during an outage for no +longer than `LastKnownGoodLifetime`; after that interval authentication fails. This intentionally +bounds how long a key removed by the authority can remain trusted during an outage. + +The supplied credential remains responsible for token caching. Orleans requests a token for every +outbound authentication attempt and does not add another token cache. diff --git a/src/Orleans.Connections.Security.Entra/StrictHttpDocumentRetriever.cs b/src/Orleans.Connections.Security.Entra/StrictHttpDocumentRetriever.cs new file mode 100644 index 00000000000..45306182a8f --- /dev/null +++ b/src/Orleans.Connections.Security.Entra/StrictHttpDocumentRetriever.cs @@ -0,0 +1,114 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.IdentityModel.Protocols; +using Orleans.Configuration; + +namespace Orleans.Connections.Security.Entra; + +internal sealed class StrictHttpDocumentRetriever : IDocumentRetriever, IDisposable +{ + private static readonly Encoding StrictUtf8 = new UTF8Encoding( + encoderShouldEmitUTF8Identifier: false, + throwOnInvalidBytes: true); + private readonly HttpClient _httpClient; + private readonly HashSet _trustedHosts; + private readonly EntraSiloConnectionOptions _options; + + public StrictHttpDocumentRetriever(EntraSiloConnectionOptions options) + : this(options, new SocketsHttpHandler { AllowAutoRedirect = false }) + { + } + + internal StrictHttpDocumentRetriever(EntraSiloConnectionOptions options, HttpMessageHandler handler) + { + _options = options; + _trustedHosts = new HashSet(options.AdditionalTrustedMetadataHosts, StringComparer.OrdinalIgnoreCase) + { + options.Authority!.IdnHost, + }; + + _httpClient = new HttpClient(handler, disposeHandler: true) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } + + public async Task GetDocumentAsync(string address, CancellationToken cancel) + { + if (!Uri.TryCreate(address, UriKind.Absolute, out var uri) + || !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + || !_trustedHosts.Contains(uri.IdnHost) + || !string.IsNullOrEmpty(uri.UserInfo)) + { + throw new EntraAuthenticationException(EntraAuthenticationError.ProviderUnavailable); + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancel); + timeout.CancelAfter(_options.MetadataRetrievalTimeout); + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + using var response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + timeout.Token).ConfigureAwait(false); + + if (IsRedirect(response.StatusCode) || response.StatusCode != HttpStatusCode.OK) + { + throw new EntraAuthenticationException(EntraAuthenticationError.ProviderUnavailable); + } + + if (response.Content.Headers.ContentLength is > 0 and var contentLength + && contentLength > _options.MaximumMetadataSize) + { + throw new EntraAuthenticationException(EntraAuthenticationError.ProviderUnavailable); + } + + await using var stream = await response.Content.ReadAsStreamAsync(timeout.Token).ConfigureAwait(false); + using var buffer = new MemoryStream(); + var rented = ArrayPool.Shared.Rent(Math.Min(_options.MaximumMetadataSize, 16 * 1024)); + try + { + while (true) + { + var read = await stream.ReadAsync(rented.AsMemory(0, rented.Length), timeout.Token).ConfigureAwait(false); + if (read == 0) + { + break; + } + + if (buffer.Length + read > _options.MaximumMetadataSize) + { + throw new EntraAuthenticationException(EntraAuthenticationError.ProviderUnavailable); + } + + buffer.Write(rented, 0, read); + } + + try + { + return StrictUtf8.GetString(buffer.GetBuffer(), 0, checked((int)buffer.Length)); + } + catch (DecoderFallbackException) + { + throw new EntraAuthenticationException(EntraAuthenticationError.ProviderUnavailable); + } + } + finally + { + ArrayPool.Shared.Return(rented, clearArray: true); + } + } + + public void Dispose() => _httpClient.Dispose(); + + private static bool IsRedirect(HttpStatusCode statusCode) + => (int)statusCode is >= 300 and <= 399; +} diff --git a/src/Orleans.Connections.Security/Authentication/AuthenticationAbstractions.cs b/src/Orleans.Connections.Security/Authentication/AuthenticationAbstractions.cs new file mode 100644 index 00000000000..8900886de70 --- /dev/null +++ b/src/Orleans.Connections.Security/Authentication/AuthenticationAbstractions.cs @@ -0,0 +1,185 @@ +using System; +using System.Net; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; + +namespace Orleans.Connections.Security; + +/// +/// Controls enforcement of silo-to-silo connection authentication. +/// +public enum SiloConnectionAuthenticationMode +{ + /// Disables connection authentication. + Disabled, + + /// Attempts authentication when supported and records failures without rejecting policy failures. + Audit, + + /// Requires every silo connection to be authenticated. + Required, +} +/// +/// Identifies the direction of a silo connection. +/// +public enum SiloConnectionAuthenticationDirection +{ + /// An inbound connection. + Inbound, + + /// An outbound connection. + Outbound, +} + +/// +/// Identifies a bounded connection-authentication failure category. +/// +public enum SiloConnectionAuthenticationFailure +{ + /// No failure occurred. + None, + + /// No token was supplied. + MissingToken, + + /// The token was invalid. + InvalidToken, + + /// The token was expired or did not have sufficient remaining lifetime. + ExpiredToken, + + /// The caller was not authorized for this cluster. + UnauthorizedCaller, + + /// The authentication provider was unavailable. + ProviderUnavailable, + + /// Token validation failed unexpectedly. + ValidationError, +} + +/// +/// A bearer token used to authenticate an outbound silo connection. +/// +/// The token value. +/// The token expiration time. +public readonly record struct SiloConnectionToken(string Value, DateTimeOffset? ExpiresAt); + +/// +/// Supplies bearer tokens for outbound silo connections. +/// +public interface ISiloConnectionTokenProvider +{ + /// Gets a token for an outbound silo connection. + ValueTask GetTokenAsync( + SiloConnectionTokenRequestContext context, + CancellationToken cancellationToken); +} + +/// +/// Validates bearer tokens received on inbound silo connections. +/// +public interface ISiloConnectionTokenValidator +{ + /// Validates a token for an inbound silo connection. + ValueTask ValidateTokenAsync( + string token, + SiloConnectionTokenValidationContext context, + CancellationToken cancellationToken); +} + +/// +/// Describes an outbound token request. +/// +public sealed class SiloConnectionTokenRequestContext +{ + internal SiloConnectionTokenRequestContext(string clusterId, EndPoint? localEndPoint, EndPoint? remoteEndPoint) + { + ClusterId = clusterId; + LocalEndPoint = localEndPoint; + RemoteEndPoint = remoteEndPoint; + } + + /// Gets the expected Orleans cluster identifier. + public string ClusterId { get; } + + /// Gets the connection direction. + public SiloConnectionAuthenticationDirection Direction => SiloConnectionAuthenticationDirection.Outbound; + + /// Gets the local endpoint, if available. + public EndPoint? LocalEndPoint { get; } + + /// Gets the remote endpoint, if available. + public EndPoint? RemoteEndPoint { get; } +} + +/// +/// Describes the policy context for an inbound token validation. +/// +public sealed class SiloConnectionTokenValidationContext +{ + internal SiloConnectionTokenValidationContext(string clusterId, EndPoint? localEndPoint, EndPoint? remoteEndPoint) + { + ClusterId = clusterId; + LocalEndPoint = localEndPoint; + RemoteEndPoint = remoteEndPoint; + } + + /// Gets the expected Orleans cluster identifier. + public string ClusterId { get; } + + /// Gets the connection direction. + public SiloConnectionAuthenticationDirection Direction => SiloConnectionAuthenticationDirection.Inbound; + + /// Gets the local endpoint, if available. + public EndPoint? LocalEndPoint { get; } + + /// Gets the remote endpoint, if available. + public EndPoint? RemoteEndPoint { get; } +} + +/// +/// The structured result of validating a silo connection token. +/// +public sealed class SiloConnectionTokenValidationResult +{ + private SiloConnectionTokenValidationResult( + bool succeeded, + ClaimsPrincipal? principal, + DateTimeOffset? expiresAt, + SiloConnectionAuthenticationFailure failure) + { + Succeeded = succeeded; + Principal = principal; + ExpiresAt = expiresAt; + Failure = failure; + } + + /// Gets whether validation succeeded. + public bool Succeeded { get; } + + /// Gets the validated principal. + public ClaimsPrincipal? Principal { get; } + + /// Gets the validated credential expiration time. + public DateTimeOffset? ExpiresAt { get; } + + /// Gets the failure category. + public SiloConnectionAuthenticationFailure Failure { get; } + + /// Creates a successful validation result. + public static SiloConnectionTokenValidationResult Success(ClaimsPrincipal principal, DateTimeOffset? expiresAt) => + new(true, principal ?? throw new ArgumentNullException(nameof(principal)), expiresAt, SiloConnectionAuthenticationFailure.None); + + /// Creates a failed validation result. + public static SiloConnectionTokenValidationResult Fail(SiloConnectionAuthenticationFailure failure) + { + if (failure == SiloConnectionAuthenticationFailure.None) + { + throw new ArgumentOutOfRangeException(nameof(failure)); + } + + return new(false, null, null, failure); + } +} diff --git a/src/Orleans.Connections.Security/Authentication/AuthenticationWorkLimiter.cs b/src/Orleans.Connections.Security/Authentication/AuthenticationWorkLimiter.cs new file mode 100644 index 00000000000..1d3811c9002 --- /dev/null +++ b/src/Orleans.Connections.Security/Authentication/AuthenticationWorkLimiter.cs @@ -0,0 +1,67 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Orleans.Connections.Security; + +internal sealed class AuthenticationWorkLimiter +{ + private readonly QueueLimiter _inbound; + private readonly QueueLimiter _outbound; + + public AuthenticationWorkLimiter(SiloConnectionAuthenticationOptions options) + { + _inbound = new(options.MaxConcurrentInboundAuthentications, options.MaxPendingInboundAuthentications); + _outbound = new(options.MaxConcurrentOutboundAuthentications, options.MaxPendingOutboundAuthentications); + } + public ValueTask TryAcquireAsync( + SiloConnectionAuthenticationDirection direction, + CancellationToken cancellationToken) => + (direction == SiloConnectionAuthenticationDirection.Inbound ? _inbound : _outbound).TryAcquireAsync(cancellationToken); + + private sealed class QueueLimiter + { + private readonly SemaphoreSlim _semaphore; + private readonly int _maxPending; + private int _pending; + + public QueueLimiter(int concurrency, int maxPending) + { + _semaphore = new SemaphoreSlim(concurrency, concurrency); + _maxPending = maxPending; + } + + public async ValueTask TryAcquireAsync(CancellationToken cancellationToken) + { + if (_semaphore.Wait(0)) + { + return new Releaser(_semaphore); + } + + if (Interlocked.Increment(ref _pending) > _maxPending) + { + Interlocked.Decrement(ref _pending); + return null; + } + + try + { + await _semaphore.WaitAsync(cancellationToken); + return new Releaser(_semaphore); + } + finally + { + Interlocked.Decrement(ref _pending); + } + } + } + + private sealed class Releaser : IDisposable + { + private SemaphoreSlim? _semaphore; + + public Releaser(SemaphoreSlim semaphore) => _semaphore = semaphore; + + public void Dispose() => Interlocked.Exchange(ref _semaphore, null)?.Release(); + } +} diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationBuilder.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationBuilder.cs new file mode 100644 index 00000000000..d159e9906a7 --- /dev/null +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationBuilder.cs @@ -0,0 +1,156 @@ +using System; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; + +namespace Orleans.Connections.Security; + +/// +/// Configures providers and policy for authenticated silo connections. +/// +public sealed class SiloConnectionAuthenticationBuilder +{ + private readonly SiloConnectionAuthenticationOptions _options; + private readonly IServiceCollection _services; + + internal SiloConnectionAuthenticationBuilder( + SiloConnectionAuthenticationOptions options, + IServiceCollection services) + { + _options = options; + _services = services; + } + + internal bool HasTokenProvider { get; private set; } + + internal bool HasTokenValidator { get; private set; } + + /// Gets the service collection used to configure authentication providers. + public IServiceCollection Services => _services; + + /// Gets or sets the authentication enforcement mode. + public SiloConnectionAuthenticationMode Mode { get => _options.Mode; set => _options.Mode = value; } + + /// Gets or sets the total token exchange timeout. + public TimeSpan TokenExchangeTimeout { get => _options.TokenExchangeTimeout; set => _options.TokenExchangeTimeout = value; } + + /// Gets or sets the maximum UTF-8 token size in bytes. + public int MaxTokenSize { get => _options.MaxTokenSize; set => _options.MaxTokenSize = value; } + + /// Gets or sets the maximum concurrent inbound authentication operations. + public int MaxConcurrentInboundAuthentications + { + get => _options.MaxConcurrentInboundAuthentications; + set => _options.MaxConcurrentInboundAuthentications = value; + } + + /// Gets or sets the maximum concurrent outbound authentication operations. + public int MaxConcurrentOutboundAuthentications + { + get => _options.MaxConcurrentOutboundAuthentications; + set => _options.MaxConcurrentOutboundAuthentications = value; + } + + /// Gets or sets the maximum queued inbound authentication operations. + public int MaxPendingInboundAuthentications + { + get => _options.MaxPendingInboundAuthentications; + set => _options.MaxPendingInboundAuthentications = value; + } + + /// Gets or sets the maximum queued outbound authentication operations. + public int MaxPendingOutboundAuthentications + { + get => _options.MaxPendingOutboundAuthentications; + set => _options.MaxPendingOutboundAuthentications = value; + } + + /// Gets or sets the minimum acceptable remaining credential lifetime. + public TimeSpan MinimumRemainingTokenLifetime + { + get => _options.MinimumRemainingTokenLifetime; + set => _options.MinimumRemainingTokenLifetime = value; + } + + /// Gets or sets how long before credential expiration an authenticated connection is closed. + public TimeSpan ExpirationSafetyMargin + { + get => _options.ExpirationSafetyMargin; + set => _options.ExpirationSafetyMargin = value; + } + + /// Gets or sets the maximum deterministic per-connection expiration jitter. + public TimeSpan ExpirationJitter + { + get => _options.ExpirationJitter; + set => _options.ExpirationJitter = value; + } + + /// Gets or sets whether credentials without a finite expiration are accepted. + public bool AllowNonExpiringCredentials + { + get => _options.AllowNonExpiringCredentials; + set => _options.AllowNonExpiringCredentials = value; + } + + /// Gets or sets the expected TLS server DNS identity and SNI name. + public string? TargetHost { get => _options.TargetHost; set => _options.TargetHost = value; } + + /// Gets or sets the time provider used for timeouts and expiration. + public TimeProvider TimeProvider { get => _options.TimeProvider; set => _options.TimeProvider = value; } + + /// Registers a singleton token provider. + public SiloConnectionAuthenticationBuilder UseTokenProvider() + where TProvider : class, ISiloConnectionTokenProvider + { + EnsureProviderCanBeRegistered(); + _services.AddSingleton(); + HasTokenProvider = true; + return this; + } + + /// Registers a token provider instance. + public SiloConnectionAuthenticationBuilder UseTokenProvider(ISiloConnectionTokenProvider provider) + { + ArgumentNullException.ThrowIfNull(provider); + EnsureProviderCanBeRegistered(); + _services.AddSingleton(provider); + HasTokenProvider = true; + return this; + } + + /// Registers a singleton token validator. + public SiloConnectionAuthenticationBuilder UseTokenValidator() + where TValidator : class, ISiloConnectionTokenValidator + { + EnsureValidatorCanBeRegistered(); + _services.AddSingleton(); + HasTokenValidator = true; + return this; + } + + /// Registers a token validator instance. + public SiloConnectionAuthenticationBuilder UseTokenValidator(ISiloConnectionTokenValidator validator) + { + ArgumentNullException.ThrowIfNull(validator); + EnsureValidatorCanBeRegistered(); + _services.AddSingleton(validator); + HasTokenValidator = true; + return this; + } + + private void EnsureProviderCanBeRegistered() + { + if (HasTokenProvider || _services.Any(descriptor => descriptor.ServiceType == typeof(ISiloConnectionTokenProvider))) + { + throw new InvalidOperationException("A silo connection token provider is already registered."); + } + } + + private void EnsureValidatorCanBeRegistered() + { + if (HasTokenValidator || _services.Any(descriptor => descriptor.ServiceType == typeof(ISiloConnectionTokenValidator))) + { + throw new InvalidOperationException("A silo connection token validator is already registered."); + } + } +} diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationFeature.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationFeature.cs new file mode 100644 index 00000000000..8fad49095fa --- /dev/null +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationFeature.cs @@ -0,0 +1,74 @@ +using System; +using System.Linq; +using System.Security.Claims; + +namespace Orleans.Connections.Security; + +/// +/// Describes the authentication state of a silo connection. +/// +public interface ISiloConnectionAuthenticationFeature +{ + /// Gets whether authentication was attempted. + bool AuthenticationAttempted { get; } + + /// Gets whether the connection was authenticated. + bool IsAuthenticated { get; } + + /// Gets an isolated copy of the authenticated principal. + ClaimsPrincipal? Principal { get; } + + /// Gets the credential expiration time. + DateTimeOffset? ExpiresAt { get; } + + /// Gets the authentication failure category. + SiloConnectionAuthenticationFailure Failure { get; } + + /// Gets the negotiated authentication protocol. + string Protocol { get; } +} + +internal sealed class SiloConnectionAuthenticationFeature : ISiloConnectionAuthenticationFeature +{ + private readonly ClaimsPrincipal? _principal; + + public SiloConnectionAuthenticationFeature( + bool authenticationAttempted, + bool isAuthenticated, + ClaimsPrincipal? principal, + DateTimeOffset? expiresAt, + SiloConnectionAuthenticationFailure failure, + string protocol) + { + AuthenticationAttempted = authenticationAttempted; + IsAuthenticated = isAuthenticated; + _principal = principal is null ? null : ClonePrincipal(principal); + ExpiresAt = expiresAt; + Failure = failure; + Protocol = protocol; + } + + public bool AuthenticationAttempted { get; } + + public bool IsAuthenticated { get; } + + public ClaimsPrincipal? Principal => _principal is null ? null : ClonePrincipal(_principal); + + public DateTimeOffset? ExpiresAt { get; } + + public SiloConnectionAuthenticationFailure Failure { get; } + + public string Protocol { get; } + + private static ClaimsPrincipal ClonePrincipal(ClaimsPrincipal principal) + { + var identities = new ClaimsIdentity[principal.Identities.Count()]; + var index = 0; + foreach (var identity in principal.Identities) + { + identities[index++] = identity.Clone(); + } + + return new ClaimsPrincipal(identities); + } +} diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationMiddleware.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationMiddleware.cs new file mode 100644 index 00000000000..65d4cd28fd4 --- /dev/null +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationMiddleware.cs @@ -0,0 +1,797 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Connections; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.Configuration; +using Orleans.Runtime.Messaging; + +namespace Orleans.Connections.Security; + +internal enum SiloConnectionAuthenticationState +{ + Created, + ProtocolSelected, + WorkAdmitted, + TokenTransferred, + ResultTransferred, + Accepted, + Rejected, +} + +internal sealed class SiloConnectionAuthenticationStateMachine +{ + public SiloConnectionAuthenticationState State { get; private set; } + + public void Move(SiloConnectionAuthenticationState expected, SiloConnectionAuthenticationState next) + { + if (State != expected) + { + throw new InvalidOperationException("Invalid silo connection authentication state transition."); + } + + State = next; + } +} + +internal abstract class SiloConnectionAuthenticationMiddleware +{ + private static readonly TimeSpan MaxTimerDueTime = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + protected const byte TokenFrameType = 0x01; + protected const byte ResultFrameType = 0x02; + protected const byte AuthenticatedResult = 0x01; + protected const byte AcceptedUnauthenticatedResult = 0x02; + protected const byte RejectedResult = 0x03; + + private static readonly byte[] BaselineProtocol = Encoding.ASCII.GetBytes("Orleans1"); + private static readonly byte[] AuthenticationProtocol = Encoding.ASCII.GetBytes(SiloConnectionAuthenticationProtocol.Version2); + private readonly IHostApplicationLifetime _applicationLifetime; + + protected SiloConnectionAuthenticationMiddleware( + IOptions options, + IOptions clusterOptions, + AuthenticationWorkLimiter workLimiter, + IHostApplicationLifetime applicationLifetime, + ILogger logger) + { + Options = CloneOptions(options.Value); + ClusterId = clusterOptions.Value.ClusterId; + WorkLimiter = workLimiter; + _applicationLifetime = applicationLifetime; + Logger = logger; + } + + protected SiloConnectionAuthenticationOptions Options { get; } + + protected string ClusterId { get; } + + protected AuthenticationWorkLimiter WorkLimiter { get; } + + protected ILogger Logger { get; } + + protected CancellationTokenSource CreateExchangeCancellation(ConnectionContext context, out CancellationTokenSource timeout) + { + timeout = new CancellationTokenSource(Options.TokenExchangeTimeout, Options.TimeProvider); + return CancellationTokenSource.CreateLinkedTokenSource( + timeout.Token, + context.ConnectionClosed, + _applicationLifetime.ApplicationStopping); + } + + protected ProtocolSelection SelectProtocol(ConnectionContext context) + { + if (Options.Mode == SiloConnectionAuthenticationMode.Disabled) + { + return ProtocolSelection.Disabled; + } + + var applicationProtocol = context.Features.Get()?.ApplicationProtocol; + if (applicationProtocol is null) + { + return ProtocolSelection.Missing; + } + + if (applicationProtocol.Value.Span.SequenceEqual(AuthenticationProtocol)) + { + return ProtocolSelection.Authentication; + } + + if (applicationProtocol.Value.Span.SequenceEqual(BaselineProtocol)) + { + return ProtocolSelection.Baseline; + } + + return ProtocolSelection.Unknown; + } + + protected async Task RunAcceptedAsync( + ConnectionContext context, + ConnectionDelegate next, + SiloConnectionAuthenticationDirection direction, + SiloConnectionAuthenticationFeature feature, + long started, + AuthenticationResultCategory result) + { + context.Features.Set(feature); + SiloConnectionAuthenticationTelemetry.RecordAttempt( + started, + direction, + Options.Mode, + feature.Protocol, + result); + SiloConnectionAuthenticationTelemetry.LogCompleted( + Logger, + GetDirectionName(direction), + Options.Mode.ToString(), + SiloConnectionAuthenticationTelemetry.GetResultName(result)); + + if (!feature.IsAuthenticated) + { + await next(context); + return; + } + + SiloConnectionAuthenticationTelemetry.AddActive(1, direction, Options.Mode, feature.Protocol); + try + { + if (feature.ExpiresAt is not { } expiresAt) + { + await next(context); + return; + } + + var dueTime = GetExpirationDueTime(context.ConnectionId, expiresAt); + if (dueTime <= TimeSpan.Zero) + { + Abort(context, direction, AuthenticationResultCategory.Expiration); + return; + } + + var expirationState = new ExpirationState(context, this, direction, expiresAt); + using var timer = Options.TimeProvider.CreateTimer( + static state => ((ExpirationState)state!).Expire(), + expirationState, + ClampTimerDueTime(dueTime), + Timeout.InfiniteTimeSpan); + expirationState.SetTimer(timer); + await next(context); + } + finally + { + SiloConnectionAuthenticationTelemetry.AddActive(-1, direction, Options.Mode, feature.Protocol); + } + } + + protected void Abort( + ConnectionContext context, + SiloConnectionAuthenticationDirection direction, + AuthenticationResultCategory category, + long? started = null) + { + if (started is { } start) + { + SiloConnectionAuthenticationTelemetry.RecordAttempt( + start, + direction, + Options.Mode, + SiloConnectionAuthenticationProtocol.Version2, + category); + } + else + { + SiloConnectionAuthenticationTelemetry.RecordEvent( + direction, + Options.Mode, + SiloConnectionAuthenticationProtocol.Version2, + category); + } + + SiloConnectionAuthenticationTelemetry.LogFailure( + Logger, + GetDirectionName(direction), + Options.Mode.ToString(), + SiloConnectionAuthenticationTelemetry.GetResultName(category)); + context.Abort(new ConnectionAbortedException( + $"Silo connection authentication failed ({SiloConnectionAuthenticationTelemetry.GetResultName(category)}).")); + } + + protected static string GetDirectionName(SiloConnectionAuthenticationDirection direction) => + direction == SiloConnectionAuthenticationDirection.Inbound ? "inbound" : "outbound"; + + private static SiloConnectionAuthenticationOptions CloneOptions(SiloConnectionAuthenticationOptions source) => new() + { + Mode = source.Mode, + TokenExchangeTimeout = source.TokenExchangeTimeout, + MaxTokenSize = source.MaxTokenSize, + MaxConcurrentInboundAuthentications = source.MaxConcurrentInboundAuthentications, + MaxConcurrentOutboundAuthentications = source.MaxConcurrentOutboundAuthentications, + MaxPendingInboundAuthentications = source.MaxPendingInboundAuthentications, + MaxPendingOutboundAuthentications = source.MaxPendingOutboundAuthentications, + MinimumRemainingTokenLifetime = source.MinimumRemainingTokenLifetime, + ExpirationSafetyMargin = source.ExpirationSafetyMargin, + ExpirationJitter = source.ExpirationJitter, + AllowNonExpiringCredentials = source.AllowNonExpiringCredentials, + TargetHost = source.TargetHost, + TimeProvider = source.TimeProvider, + }; + + private TimeSpan GetExpirationDueTime(string connectionId, DateTimeOffset expiresAt) + { + var jitter = GetDeterministicJitter(connectionId, Options.ExpirationJitter); + return expiresAt - Options.TimeProvider.GetUtcNow() - Options.ExpirationSafetyMargin - jitter; + } + + private static TimeSpan ClampTimerDueTime(TimeSpan dueTime) => + dueTime > MaxTimerDueTime ? MaxTimerDueTime : dueTime; + + private static TimeSpan GetDeterministicJitter(string value, TimeSpan maximum) + { + if (maximum <= TimeSpan.Zero) + { + return TimeSpan.Zero; + } + + ulong hash = 14_695_981_039_346_656_037; + foreach (var character in value) + { + hash ^= character; + hash *= 1_099_511_628_211; + } + + return TimeSpan.FromTicks((long)(hash % ((ulong)maximum.Ticks + 1))); + } + + protected enum ProtocolSelection + { + Disabled, + Missing, + Baseline, + Authentication, + Unknown, + } + + private sealed class ExpirationState + { + private readonly ConnectionContext _context; + private readonly SiloConnectionAuthenticationMiddleware _middleware; + private readonly SiloConnectionAuthenticationDirection _direction; + private readonly DateTimeOffset _expiresAt; + private readonly object _lock = new(); + private ITimer? _timer; + private bool _rescheduleRequested; + private int _expired; + + public ExpirationState( + ConnectionContext context, + SiloConnectionAuthenticationMiddleware middleware, + SiloConnectionAuthenticationDirection direction, + DateTimeOffset expiresAt) + { + _context = context; + _middleware = middleware; + _direction = direction; + _expiresAt = expiresAt; + } + + public void SetTimer(ITimer timer) + { + lock (_lock) + { + _timer = timer; + if (_rescheduleRequested) + { + _rescheduleRequested = false; + RearmOrExpire(); + } + } + } + + public void Expire() + { + lock (_lock) + { + if (_timer is null) + { + _rescheduleRequested = true; + return; + } + + RearmOrExpire(); + } + } + + private void RearmOrExpire() + { + var dueTime = _middleware.GetExpirationDueTime(_context.ConnectionId, _expiresAt); + if (dueTime > TimeSpan.Zero) + { + _timer!.Change(ClampTimerDueTime(dueTime), Timeout.InfiniteTimeSpan); + return; + } + + if (Interlocked.Exchange(ref _expired, 1) == 0) + { + _middleware.Abort(_context, _direction, AuthenticationResultCategory.Expiration); + } + } + } +} + +internal sealed class InboundSiloConnectionAuthenticationMiddleware : SiloConnectionAuthenticationMiddleware, IConnectionMiddleware +{ + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + private readonly ISiloConnectionTokenValidator? _validator; + + public InboundSiloConnectionAuthenticationMiddleware( + IEnumerable validators, + IOptions options, + IOptions clusterOptions, + AuthenticationWorkLimiter workLimiter, + IHostApplicationLifetime applicationLifetime, + ILogger logger) + : base(options, clusterOptions, workLimiter, applicationLifetime, logger) + { + _validator = validators.SingleOrDefault(); + } + + public async Task OnConnectionAsync(ConnectionContext context, ConnectionDelegate next) + { + var direction = SiloConnectionAuthenticationDirection.Inbound; + var selection = SelectProtocol(context); + if (selection == ProtocolSelection.Disabled) + { + await next(context); + return; + } + + if (selection == ProtocolSelection.Baseline && Options.Mode == SiloConnectionAuthenticationMode.Audit) + { + context.Features.Set(new SiloConnectionAuthenticationFeature( + false, + false, + null, + null, + SiloConnectionAuthenticationFailure.None, + "Orleans1")); + SiloConnectionAuthenticationTelemetry.RecordFallback(direction, Options.Mode); + SiloConnectionAuthenticationTelemetry.LogFallback(Logger, GetDirectionName(direction)); + await next(context); + return; + } + + var started = SiloConnectionAuthenticationTelemetry.Start(); + if (selection != ProtocolSelection.Authentication) + { + Abort(context, direction, AuthenticationResultCategory.TlsPolicyError, started); + return; + } + + var state = new SiloConnectionAuthenticationStateMachine(); + state.Move(SiloConnectionAuthenticationState.Created, SiloConnectionAuthenticationState.ProtocolSelected); + + using var linked = CreateExchangeCancellation(context, out var timeout); + using (timeout) + { + IDisposable? admission = null; + try + { + admission = await WorkLimiter.TryAcquireAsync(direction, linked.Token); + if (admission is null) + { + Abort(context, direction, AuthenticationResultCategory.Overload, started); + return; + } + + state.Move(SiloConnectionAuthenticationState.ProtocolSelected, SiloConnectionAuthenticationState.WorkAdmitted); + using (admission) + { + var (frameType, tokenBytes) = await ConnectionFrameHelper.ReadFrameAsync( + context, + linked.Token, + Options.MaxTokenSize + 1); + if (frameType != TokenFrameType) + { + Abort(context, direction, AuthenticationResultCategory.ProtocolError, started); + return; + } + + string token; + try + { + token = StrictUtf8.GetString(tokenBytes); + } + catch (DecoderFallbackException) + { + Abort(context, direction, AuthenticationResultCategory.ProtocolError, started); + return; + } + + state.Move(SiloConnectionAuthenticationState.WorkAdmitted, SiloConnectionAuthenticationState.TokenTransferred); + var validation = await ValidateAsync(context, token, linked.Token); + var isAuthenticated = TryNormalizeValidation(validation, out var principal, out var expiresAt, out var failure); + var resultCode = isAuthenticated + ? AuthenticatedResult + : Options.Mode == SiloConnectionAuthenticationMode.Audit + ? AcceptedUnauthenticatedResult + : RejectedResult; + + await ConnectionFrameHelper.WriteFrameAsync( + context, + ResultFrameType, + [resultCode], + linked.Token); + state.Move(SiloConnectionAuthenticationState.TokenTransferred, SiloConnectionAuthenticationState.ResultTransferred); + + if (resultCode == RejectedResult) + { + state.Move(SiloConnectionAuthenticationState.ResultTransferred, SiloConnectionAuthenticationState.Rejected); + Abort(context, direction, GetValidationCategory(failure), started); + return; + } + + state.Move(SiloConnectionAuthenticationState.ResultTransferred, SiloConnectionAuthenticationState.Accepted); + var feature = new SiloConnectionAuthenticationFeature( + true, + isAuthenticated, + principal, + expiresAt, + failure, + SiloConnectionAuthenticationProtocol.Version2); + admission.Dispose(); + linked.Dispose(); + timeout.Dispose(); + await RunAcceptedAsync( + context, + next, + direction, + feature, + started, + isAuthenticated ? AuthenticationResultCategory.Authenticated : AuthenticationResultCategory.AcceptedUnauthenticated); + } + } + catch (OperationCanceledException) when (state.State != SiloConnectionAuthenticationState.Accepted) + { + Abort( + context, + direction, + timeout.IsCancellationRequested ? AuthenticationResultCategory.Timeout : AuthenticationResultCategory.ProtocolError, + started); + } + catch (InvalidOperationException) when (state.State != SiloConnectionAuthenticationState.Accepted) + { + Abort(context, direction, AuthenticationResultCategory.ProtocolError, started); + } + } + } + + private async ValueTask ValidateAsync( + ConnectionContext context, + string token, + CancellationToken cancellationToken) + { + if (token.Length == 0) + { + return SiloConnectionTokenValidationResult.Fail(SiloConnectionAuthenticationFailure.MissingToken); + } + + if (_validator is null) + { + return SiloConnectionTokenValidationResult.Fail(SiloConnectionAuthenticationFailure.ProviderUnavailable); + } + + try + { + return await _validator.ValidateTokenAsync( + token, + new SiloConnectionTokenValidationContext(ClusterId, context.LocalEndPoint, context.RemoteEndPoint), + cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return SiloConnectionTokenValidationResult.Fail(SiloConnectionAuthenticationFailure.ValidationError); + } + } + + private bool TryNormalizeValidation( + SiloConnectionTokenValidationResult validation, + out ClaimsPrincipal? principal, + out DateTimeOffset? expiresAt, + out SiloConnectionAuthenticationFailure failure) + { + principal = null; + expiresAt = null; + failure = validation.Failure; + if (!validation.Succeeded) + { + if (failure == SiloConnectionAuthenticationFailure.None) + { + failure = SiloConnectionAuthenticationFailure.ValidationError; + } + + return false; + } + + principal = validation.Principal; + expiresAt = validation.ExpiresAt; + if (principal is null) + { + failure = SiloConnectionAuthenticationFailure.ValidationError; + return false; + } + + if (expiresAt is null) + { + if (Options.AllowNonExpiringCredentials) + { + failure = SiloConnectionAuthenticationFailure.None; + return true; + } + + failure = SiloConnectionAuthenticationFailure.ValidationError; + return false; + } + + if (expiresAt <= Options.TimeProvider.GetUtcNow() + Options.MinimumRemainingTokenLifetime) + { + principal = null; + expiresAt = null; + failure = SiloConnectionAuthenticationFailure.ExpiredToken; + return false; + } + + failure = SiloConnectionAuthenticationFailure.None; + return true; + } + + private static AuthenticationResultCategory GetValidationCategory(SiloConnectionAuthenticationFailure failure) => failure switch + { + SiloConnectionAuthenticationFailure.UnauthorizedCaller => AuthenticationResultCategory.AuthorizationFailure, + SiloConnectionAuthenticationFailure.ProviderUnavailable or + SiloConnectionAuthenticationFailure.ValidationError => AuthenticationResultCategory.ValidationFailure, + SiloConnectionAuthenticationFailure.ExpiredToken => AuthenticationResultCategory.Expiration, + _ => AuthenticationResultCategory.Rejected, + }; +} + +internal sealed class OutboundSiloConnectionAuthenticationMiddleware : SiloConnectionAuthenticationMiddleware, IConnectionMiddleware +{ + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + private readonly ISiloConnectionTokenProvider? _provider; + + public OutboundSiloConnectionAuthenticationMiddleware( + IEnumerable providers, + IOptions options, + IOptions clusterOptions, + AuthenticationWorkLimiter workLimiter, + IHostApplicationLifetime applicationLifetime, + ILogger logger) + : base(options, clusterOptions, workLimiter, applicationLifetime, logger) + { + _provider = providers.SingleOrDefault(); + } + + public async Task OnConnectionAsync(ConnectionContext context, ConnectionDelegate next) + { + var direction = SiloConnectionAuthenticationDirection.Outbound; + var selection = SelectProtocol(context); + if (selection == ProtocolSelection.Disabled) + { + await next(context); + return; + } + + if (selection == ProtocolSelection.Baseline && Options.Mode == SiloConnectionAuthenticationMode.Audit) + { + context.Features.Set(new SiloConnectionAuthenticationFeature( + false, + false, + null, + null, + SiloConnectionAuthenticationFailure.None, + "Orleans1")); + SiloConnectionAuthenticationTelemetry.RecordFallback(direction, Options.Mode); + SiloConnectionAuthenticationTelemetry.LogFallback(Logger, GetDirectionName(direction)); + await next(context); + return; + } + + var started = SiloConnectionAuthenticationTelemetry.Start(); + if (selection != ProtocolSelection.Authentication) + { + Abort(context, direction, AuthenticationResultCategory.TlsPolicyError, started); + return; + } + + var state = new SiloConnectionAuthenticationStateMachine(); + state.Move(SiloConnectionAuthenticationState.Created, SiloConnectionAuthenticationState.ProtocolSelected); + + using var linked = CreateExchangeCancellation(context, out var timeout); + using (timeout) + { + try + { + var admission = await WorkLimiter.TryAcquireAsync(direction, linked.Token); + if (admission is null) + { + Abort(context, direction, AuthenticationResultCategory.Overload, started); + return; + } + + state.Move(SiloConnectionAuthenticationState.ProtocolSelected, SiloConnectionAuthenticationState.WorkAdmitted); + using (admission) + { + var (payload, expiresAt, localFailure) = await GetTokenPayloadAsync(context, linked.Token); + if (payload is null) + { + Abort(context, direction, GetAcquisitionCategory(localFailure), started); + return; + } + + await ConnectionFrameHelper.WriteFrameAsync(context, TokenFrameType, payload, linked.Token); + state.Move(SiloConnectionAuthenticationState.WorkAdmitted, SiloConnectionAuthenticationState.TokenTransferred); + + var (frameType, resultPayload) = await ConnectionFrameHelper.ReadFrameAsync(context, linked.Token, 2); + if (frameType != ResultFrameType || resultPayload.Length != 1) + { + Abort(context, direction, AuthenticationResultCategory.ProtocolError, started); + return; + } + + state.Move(SiloConnectionAuthenticationState.TokenTransferred, SiloConnectionAuthenticationState.ResultTransferred); + switch (resultPayload[0]) + { + case AuthenticatedResult: + state.Move(SiloConnectionAuthenticationState.ResultTransferred, SiloConnectionAuthenticationState.Accepted); + admission.Dispose(); + linked.Dispose(); + timeout.Dispose(); + await RunAcceptedAsync( + context, + next, + direction, + new SiloConnectionAuthenticationFeature( + true, + true, + null, + expiresAt, + SiloConnectionAuthenticationFailure.None, + SiloConnectionAuthenticationProtocol.Version2), + started, + AuthenticationResultCategory.Authenticated); + return; + case AcceptedUnauthenticatedResult when Options.Mode == SiloConnectionAuthenticationMode.Audit: + state.Move(SiloConnectionAuthenticationState.ResultTransferred, SiloConnectionAuthenticationState.Accepted); + admission.Dispose(); + linked.Dispose(); + timeout.Dispose(); + await RunAcceptedAsync( + context, + next, + direction, + new SiloConnectionAuthenticationFeature( + true, + false, + null, + null, + localFailure == SiloConnectionAuthenticationFailure.None + ? SiloConnectionAuthenticationFailure.InvalidToken + : localFailure, + SiloConnectionAuthenticationProtocol.Version2), + started, + AuthenticationResultCategory.AcceptedUnauthenticated); + return; + case RejectedResult: + case AcceptedUnauthenticatedResult: + state.Move(SiloConnectionAuthenticationState.ResultTransferred, SiloConnectionAuthenticationState.Rejected); + Abort(context, direction, AuthenticationResultCategory.Rejected, started); + return; + default: + Abort(context, direction, AuthenticationResultCategory.ProtocolError, started); + return; + } + } + } + catch (OperationCanceledException) when (state.State != SiloConnectionAuthenticationState.Accepted) + { + Abort( + context, + direction, + timeout.IsCancellationRequested ? AuthenticationResultCategory.Timeout : AuthenticationResultCategory.ProtocolError, + started); + } + catch (InvalidOperationException) when (state.State != SiloConnectionAuthenticationState.Accepted) + { + Abort(context, direction, AuthenticationResultCategory.ProtocolError, started); + } + } + } + + private async ValueTask<(byte[]? Payload, DateTimeOffset? ExpiresAt, SiloConnectionAuthenticationFailure Failure)> GetTokenPayloadAsync( + ConnectionContext context, + CancellationToken cancellationToken) + { + if (_provider is null) + { + return Options.Mode == SiloConnectionAuthenticationMode.Audit + ? ([], null, SiloConnectionAuthenticationFailure.ProviderUnavailable) + : (null, null, SiloConnectionAuthenticationFailure.ProviderUnavailable); + } + + SiloConnectionToken token; + try + { + token = await _provider.GetTokenAsync( + new SiloConnectionTokenRequestContext(ClusterId, context.LocalEndPoint, context.RemoteEndPoint), + cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return Options.Mode == SiloConnectionAuthenticationMode.Audit + ? ([], null, SiloConnectionAuthenticationFailure.ProviderUnavailable) + : (null, null, SiloConnectionAuthenticationFailure.ProviderUnavailable); + } + + var value = token.Value ?? string.Empty; + byte[] payload; + try + { + if (StrictUtf8.GetByteCount(value) > Options.MaxTokenSize) + { + return Options.Mode == SiloConnectionAuthenticationMode.Audit + ? ([], null, SiloConnectionAuthenticationFailure.InvalidToken) + : (null, null, SiloConnectionAuthenticationFailure.InvalidToken); + } + + payload = StrictUtf8.GetBytes(value); + } + catch (EncoderFallbackException) + { + return Options.Mode == SiloConnectionAuthenticationMode.Audit + ? ([], null, SiloConnectionAuthenticationFailure.InvalidToken) + : (null, null, SiloConnectionAuthenticationFailure.InvalidToken); + } + + if (payload.Length == 0) + { + return Options.Mode == SiloConnectionAuthenticationMode.Audit + ? ([], null, SiloConnectionAuthenticationFailure.MissingToken) + : (null, null, SiloConnectionAuthenticationFailure.MissingToken); + } + + if (token.ExpiresAt is null && !Options.AllowNonExpiringCredentials) + { + return Options.Mode == SiloConnectionAuthenticationMode.Audit + ? ([], null, SiloConnectionAuthenticationFailure.ValidationError) + : (null, null, SiloConnectionAuthenticationFailure.ValidationError); + } + + if (token.ExpiresAt is { } expiresAt + && expiresAt <= Options.TimeProvider.GetUtcNow() + Options.MinimumRemainingTokenLifetime) + { + return Options.Mode == SiloConnectionAuthenticationMode.Audit + ? ([], null, SiloConnectionAuthenticationFailure.ExpiredToken) + : (null, null, SiloConnectionAuthenticationFailure.ExpiredToken); + } + + return (payload, token.ExpiresAt, SiloConnectionAuthenticationFailure.None); + } + + private static AuthenticationResultCategory GetAcquisitionCategory(SiloConnectionAuthenticationFailure failure) => + failure == SiloConnectionAuthenticationFailure.ExpiredToken + ? AuthenticationResultCategory.Expiration + : AuthenticationResultCategory.AcquisitionFailure; +} diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptions.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptions.cs new file mode 100644 index 00000000000..c98d5a90d3e --- /dev/null +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptions.cs @@ -0,0 +1,48 @@ +using System; + +namespace Orleans.Connections.Security; + +/// +/// Configures silo-to-silo connection authentication. +/// +public sealed class SiloConnectionAuthenticationOptions +{ + /// Gets or sets the authentication enforcement mode. + public SiloConnectionAuthenticationMode Mode { get; set; } = SiloConnectionAuthenticationMode.Required; + + /// Gets or sets the total token exchange timeout. + public TimeSpan TokenExchangeTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// Gets or sets the maximum UTF-8 token size in bytes. + public int MaxTokenSize { get; set; } = 16 * 1024; + + /// Gets or sets the maximum concurrent inbound authentication operations. + public int MaxConcurrentInboundAuthentications { get; set; } = 256; + + /// Gets or sets the maximum concurrent outbound authentication operations. + public int MaxConcurrentOutboundAuthentications { get; set; } = 256; + + /// Gets or sets the maximum queued inbound authentication operations. + public int MaxPendingInboundAuthentications { get; set; } = 256; + + /// Gets or sets the maximum queued outbound authentication operations. + public int MaxPendingOutboundAuthentications { get; set; } = 256; + + /// Gets or sets the minimum acceptable remaining credential lifetime. + public TimeSpan MinimumRemainingTokenLifetime { get; set; } = TimeSpan.FromMinutes(2); + + /// Gets or sets how long before credential expiration an authenticated connection is closed. + public TimeSpan ExpirationSafetyMargin { get; set; } = TimeSpan.FromSeconds(30); + + /// Gets or sets the maximum deterministic per-connection expiration jitter. + public TimeSpan ExpirationJitter { get; set; } = TimeSpan.FromSeconds(10); + + /// Gets or sets whether credentials without a finite expiration are accepted. + public bool AllowNonExpiringCredentials { get; set; } + + /// Gets or sets the expected TLS server DNS identity and SNI name. + public string? TargetHost { get; set; } + + /// Gets or sets the time provider used for timeouts and expiration. + public TimeProvider TimeProvider { get; set; } = TimeProvider.System; +} diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptionsValidator.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptionsValidator.cs new file mode 100644 index 00000000000..e2f98e49c5d --- /dev/null +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptionsValidator.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Options; + +namespace Orleans.Connections.Security; + +internal sealed class SiloConnectionAuthenticationOptionsValidator : IValidateOptions +{ + private static readonly TimeSpan MaxDuration = TimeSpan.FromDays(1); + private readonly SiloConnectionAuthenticationRegistration _registration; + + public SiloConnectionAuthenticationOptionsValidator(SiloConnectionAuthenticationRegistration registration) + { + _registration = registration; + } + + public ValidateOptionsResult Validate(string? name, SiloConnectionAuthenticationOptions options) + { + var failures = new List(); + + if (!Enum.IsDefined(options.Mode)) + { + failures.Add($"{nameof(options.Mode)} is invalid."); + } + + ValidatePositiveDuration(options.TokenExchangeTimeout, nameof(options.TokenExchangeTimeout), failures); + ValidatePositiveDuration(options.MinimumRemainingTokenLifetime, nameof(options.MinimumRemainingTokenLifetime), failures); + ValidateNonNegativeDuration(options.ExpirationSafetyMargin, nameof(options.ExpirationSafetyMargin), failures); + ValidateNonNegativeDuration(options.ExpirationJitter, nameof(options.ExpirationJitter), failures); + ValidatePositiveBounded(options.MaxTokenSize, nameof(options.MaxTokenSize), 1024 * 1024, failures); + ValidatePositiveBounded(options.MaxConcurrentInboundAuthentications, nameof(options.MaxConcurrentInboundAuthentications), 65_536, failures); + ValidatePositiveBounded(options.MaxConcurrentOutboundAuthentications, nameof(options.MaxConcurrentOutboundAuthentications), 65_536, failures); + ValidateNonNegativeBounded(options.MaxPendingInboundAuthentications, nameof(options.MaxPendingInboundAuthentications), 65_536, failures); + ValidateNonNegativeBounded(options.MaxPendingOutboundAuthentications, nameof(options.MaxPendingOutboundAuthentications), 65_536, failures); + + if (options.TimeProvider is null) + { + failures.Add($"{nameof(options.TimeProvider)} is required."); + } + + if (options.Mode == SiloConnectionAuthenticationMode.Required) + { + if (!_registration.HasTokenProvider) + { + failures.Add("Required mode needs exactly one token provider."); + } + + if (!_registration.HasTokenValidator) + { + failures.Add("Required mode needs exactly one token validator."); + } + + if (_registration.TlsOptions.RemoteCertificateValidation is not null) + { + failures.Add("Required mode does not permit custom remote-certificate validation callbacks."); + } + + if (string.IsNullOrWhiteSpace(options.TargetHost)) + { + failures.Add($"Required mode needs a non-empty {nameof(options.TargetHost)} for TLS endpoint-identity validation."); + } + + var allowedProtocols = System.Security.Authentication.SslProtocols.None + | System.Security.Authentication.SslProtocols.Tls12 + | System.Security.Authentication.SslProtocols.Tls13; + if ((_registration.TlsOptions.SslProtocols & ~allowedProtocols) != 0) + { + failures.Add("Required mode permits only TLS 1.2 or later."); + } + } + + if (options.MinimumRemainingTokenLifetime < options.ExpirationSafetyMargin + options.ExpirationJitter) + { + failures.Add($"{nameof(options.MinimumRemainingTokenLifetime)} must cover the expiration safety margin and jitter."); + } + + return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); + } + + private static void ValidatePositiveDuration(TimeSpan value, string name, List failures) + { + if (value <= TimeSpan.Zero || value > MaxDuration) + { + failures.Add($"{name} must be positive and no greater than one day."); + } + } + + private static void ValidateNonNegativeDuration(TimeSpan value, string name, List failures) + { + if (value < TimeSpan.Zero || value > MaxDuration) + { + failures.Add($"{name} must be non-negative and no greater than one day."); + } + } + + private static void ValidatePositiveBounded(int value, string name, int maximum, List failures) + { + if (value <= 0 || value > maximum) + { + failures.Add($"{name} must be between 1 and {maximum}."); + } + } + + private static void ValidateNonNegativeBounded(int value, string name, int maximum, List failures) + { + if (value < 0 || value > maximum) + { + failures.Add($"{name} must be between 0 and {maximum}."); + } + } +} diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationProtocol.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationProtocol.cs new file mode 100644 index 00000000000..0719ea8b832 --- /dev/null +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationProtocol.cs @@ -0,0 +1,12 @@ +namespace Orleans.Connections.Security; + +/// +/// Defines the versioned silo connection authentication wire protocol. +/// +public static class SiloConnectionAuthenticationProtocol +{ + /// + /// The ALPN identifier for the token-frame and acknowledgment protocol. + /// + public const string Version2 = "Orleans1+TokenAuth2"; +} diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationRegistration.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationRegistration.cs new file mode 100644 index 00000000000..65de80b78db --- /dev/null +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationRegistration.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using System.Net.Security; + +namespace Orleans.Connections.Security; + +internal sealed class SiloConnectionAuthenticationRegistration +{ + public required SiloConnectionAuthenticationOptions Options { get; init; } + + public required TlsOptions TlsOptions { get; init; } + + public required bool HasTokenProvider { get; init; } + + public required bool HasTokenValidator { get; init; } + + public static SiloConnectionAuthenticationOptions CloneOptions(SiloConnectionAuthenticationOptions source) + { + var result = new SiloConnectionAuthenticationOptions(); + CopyOptions(source, result); + return result; + } + + public static TlsOptions CloneTlsOptions(TlsOptions source) => new() + { + LocalCertificate = source.LocalCertificate, + LocalServerCertificateSelector = source.LocalServerCertificateSelector, + LocalClientCertificateSelector = source.LocalClientCertificateSelector, + RemoteCertificateMode = source.RemoteCertificateMode, + ClientCertificateMode = source.ClientCertificateMode, + RemoteCertificateValidation = source.RemoteCertificateValidation, + SslProtocols = source.SslProtocols, + CheckCertificateRevocation = source.CheckCertificateRevocation, + OnAuthenticateAsServer = source.OnAuthenticateAsServer, + OnAuthenticateAsClient = source.OnAuthenticateAsClient, + HandshakeTimeout = source.HandshakeTimeout, + }; + + public void CopyOptionsTo(SiloConnectionAuthenticationOptions options) + { + CopyOptions(Options, options); + } + + private static void CopyOptions( + SiloConnectionAuthenticationOptions source, + SiloConnectionAuthenticationOptions destination) + { + destination.Mode = source.Mode; + destination.TokenExchangeTimeout = source.TokenExchangeTimeout; + destination.MaxTokenSize = source.MaxTokenSize; + destination.MaxConcurrentInboundAuthentications = source.MaxConcurrentInboundAuthentications; + destination.MaxConcurrentOutboundAuthentications = source.MaxConcurrentOutboundAuthentications; + destination.MaxPendingInboundAuthentications = source.MaxPendingInboundAuthentications; + destination.MaxPendingOutboundAuthentications = source.MaxPendingOutboundAuthentications; + destination.MinimumRemainingTokenLifetime = source.MinimumRemainingTokenLifetime; + destination.ExpirationSafetyMargin = source.ExpirationSafetyMargin; + destination.ExpirationJitter = source.ExpirationJitter; + destination.AllowNonExpiringCredentials = source.AllowNonExpiringCredentials; + destination.TargetHost = source.TargetHost; + destination.TimeProvider = source.TimeProvider; + } + + public static void ConfigureApplicationProtocols( + TlsOptions tlsOptions, + SiloConnectionAuthenticationOptions authenticationOptions) + { + var serverCallback = tlsOptions.OnAuthenticateAsServer; + tlsOptions.OnAuthenticateAsServer = (context, options) => + { + serverCallback?.Invoke(context, options); + var sslOptions = (SslServerAuthenticationOptions)options.SslServerAuthenticationOptions; + sslOptions.ApplicationProtocols = CreateApplicationProtocols(authenticationOptions.Mode); + }; + + var clientCallback = tlsOptions.OnAuthenticateAsClient; + tlsOptions.OnAuthenticateAsClient = (context, options) => + { + clientCallback?.Invoke(context, options); + var sslOptions = (SslClientAuthenticationOptions)options.SslClientAuthenticationOptions; + sslOptions.ApplicationProtocols = CreateApplicationProtocols(authenticationOptions.Mode); + if (!string.IsNullOrWhiteSpace(authenticationOptions.TargetHost)) + { + sslOptions.TargetHost = authenticationOptions.TargetHost; + } + }; + } + + private static List CreateApplicationProtocols(SiloConnectionAuthenticationMode mode) => mode switch + { + SiloConnectionAuthenticationMode.Disabled => [OrleansApplicationProtocol.Orleans1], + SiloConnectionAuthenticationMode.Audit => [OrleansApplicationProtocol.Orleans1TokenAuth2, OrleansApplicationProtocol.Orleans1], + SiloConnectionAuthenticationMode.Required => [OrleansApplicationProtocol.Orleans1TokenAuth2], + _ => [], + }; +} + +internal sealed class SiloTlsRegistrationMarker; + +internal sealed class GatewayTlsRegistrationMarker; diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationTelemetry.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationTelemetry.cs new file mode 100644 index 00000000000..0afff36baf9 --- /dev/null +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationTelemetry.cs @@ -0,0 +1,121 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.Extensions.Logging; + +namespace Orleans.Connections.Security; + +internal enum AuthenticationResultCategory +{ + Authenticated, + AcceptedUnauthenticated, + BaselineFallback, + Rejected, + Overload, + Timeout, + ProtocolError, + TlsPolicyError, + AcquisitionFailure, + ValidationFailure, + AuthorizationFailure, + Expiration, +} + +internal static partial class SiloConnectionAuthenticationTelemetry +{ + private static readonly Meter Meter = new("Microsoft.Orleans.Connections.Security"); + private static readonly Counter Attempts = Meter.CreateCounter("orleans.connections.authentication.attempts"); + private static readonly Histogram Duration = Meter.CreateHistogram("orleans.connections.authentication.duration", "ms"); + private static readonly UpDownCounter Active = Meter.CreateUpDownCounter("orleans.connections.authentication.active"); + private static readonly Counter ProtocolFallbacks = Meter.CreateCounter("orleans.connections.authentication.protocol_fallbacks"); + + public static long Start() => Stopwatch.GetTimestamp(); + + public static void RecordAttempt( + long started, + SiloConnectionAuthenticationDirection direction, + SiloConnectionAuthenticationMode mode, + string protocol, + AuthenticationResultCategory result) + { + var tags = CreateTags(direction, mode, protocol, result); + Attempts.Add(1, tags); + Duration.Record(Stopwatch.GetElapsedTime(started).TotalMilliseconds, tags); + } + + public static void RecordFallback( + SiloConnectionAuthenticationDirection direction, + SiloConnectionAuthenticationMode mode) + { + var tags = CreateTags(direction, mode, "Orleans1", AuthenticationResultCategory.BaselineFallback); + ProtocolFallbacks.Add(1, tags); + } + + public static void RecordEvent( + SiloConnectionAuthenticationDirection direction, + SiloConnectionAuthenticationMode mode, + string protocol, + AuthenticationResultCategory result) + { + Attempts.Add(1, CreateTags(direction, mode, protocol, result)); + } + + public static void AddActive( + long value, + SiloConnectionAuthenticationDirection direction, + SiloConnectionAuthenticationMode mode, + string protocol) + { + Active.Add(value, CreateTags(direction, mode, protocol, AuthenticationResultCategory.Authenticated)); + } + + private static TagList CreateTags( + SiloConnectionAuthenticationDirection direction, + SiloConnectionAuthenticationMode mode, + string protocol, + AuthenticationResultCategory result) + { + return new TagList + { + { "direction", direction == SiloConnectionAuthenticationDirection.Inbound ? "inbound" : "outbound" }, + { "mode", mode.ToString() }, + { "protocol.version", protocol }, + { "result", GetResultName(result) }, + }; + } + + public static string GetResultName(AuthenticationResultCategory result) => result switch + { + AuthenticationResultCategory.Authenticated => "authenticated", + AuthenticationResultCategory.AcceptedUnauthenticated => "accepted_unauthenticated", + AuthenticationResultCategory.BaselineFallback => "baseline_fallback", + AuthenticationResultCategory.Rejected => "rejected", + AuthenticationResultCategory.Overload => "overload", + AuthenticationResultCategory.Timeout => "timeout", + AuthenticationResultCategory.ProtocolError => "protocol_error", + AuthenticationResultCategory.TlsPolicyError => "tls_policy_error", + AuthenticationResultCategory.AcquisitionFailure => "acquisition_failure", + AuthenticationResultCategory.ValidationFailure => "validation_failure", + AuthenticationResultCategory.AuthorizationFailure => "authorization_failure", + AuthenticationResultCategory.Expiration => "expiration", + _ => "protocol_error", + }; + + [LoggerMessage( + EventId = 9200, + Level = LogLevel.Warning, + Message = "Silo connection authentication failed. Direction: {Direction}; Mode: {Mode}; Category: {Category}.")] + public static partial void LogFailure(ILogger logger, string direction, string mode, string category); + + [LoggerMessage( + EventId = 9201, + Level = LogLevel.Information, + Message = "Silo connection authentication completed. Direction: {Direction}; Mode: {Mode}; Result: {Result}.")] + public static partial void LogCompleted(ILogger logger, string direction, string mode, string result); + + [LoggerMessage( + EventId = 9202, + Level = LogLevel.Information, + Message = "Silo connection authentication used the baseline protocol in Audit mode. Direction: {Direction}.")] + public static partial void LogFallback(ILogger logger, string direction); +} diff --git a/src/Orleans.Connections.Security/Hosting/HostingExtensions.ISiloBuilder.cs b/src/Orleans.Connections.Security/Hosting/HostingExtensions.ISiloBuilder.cs index 3ffc2e24c6e..ab82cbfd979 100644 --- a/src/Orleans.Connections.Security/Hosting/HostingExtensions.ISiloBuilder.cs +++ b/src/Orleans.Connections.Security/Hosting/HostingExtensions.ISiloBuilder.cs @@ -1,5 +1,7 @@ using System; +using System.Linq; using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.DependencyInjection; using Orleans.Configuration; using Orleans.Connections.Security; @@ -139,6 +141,15 @@ public static ISiloBuilder UseGatewayTls( private static ISiloBuilder UseSiloTls(this ISiloBuilder builder, TlsOptions options) { + if (builder.Services.Any(descriptor => + descriptor.ServiceType == typeof(SiloConnectionAuthenticationRegistration) + || descriptor.ServiceType == typeof(SiloTlsRegistrationMarker))) + { + throw new InvalidOperationException("Silo TLS or connection authentication has already been configured."); + } + + builder.Services.AddSingleton(); + return builder.Configure(connectionOptions => { connectionOptions.ConfigureSiloInboundConnection(connectionBuilder => @@ -155,6 +166,13 @@ private static ISiloBuilder UseSiloTls(this ISiloBuilder builder, TlsOptions opt private static ISiloBuilder UseGatewayTls(this ISiloBuilder builder, TlsOptions options) { + if (builder.Services.Any(descriptor => descriptor.ServiceType == typeof(GatewayTlsRegistrationMarker))) + { + throw new InvalidOperationException("Gateway TLS has already been configured."); + } + + builder.Services.AddSingleton(); + return builder.Configure(connectionOptions => { connectionOptions.ConfigureGatewayInboundConnection(connectionBuilder => diff --git a/src/Orleans.Connections.Security/Hosting/HostingExtensions.SiloAuthentication.cs b/src/Orleans.Connections.Security/Hosting/HostingExtensions.SiloAuthentication.cs new file mode 100644 index 00000000000..8efc7538c13 --- /dev/null +++ b/src/Orleans.Connections.Security/Hosting/HostingExtensions.SiloAuthentication.cs @@ -0,0 +1,93 @@ +using System; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Orleans.Configuration; +using Orleans.Connections.Security; +using Orleans.Runtime.Messaging; + +namespace Orleans.Hosting; + +public static partial class OrleansConnectionSecurityHostingExtensions +{ + /// + /// Configures TLS and provider-neutral bearer-token authentication for silo-to-silo connections. + /// Gateway connections are not modified. + /// + /// The silo builder. + /// Configures TLS for silo connections. + /// Configures authentication policy and providers. + /// The silo builder. + public static ISiloBuilder UseAuthenticatedSiloConnections( + this ISiloBuilder builder, + Action configureTls, + Action configureAuthentication) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configureTls); + ArgumentNullException.ThrowIfNull(configureAuthentication); + + if (builder.Services.Any(descriptor => + descriptor.ServiceType == typeof(SiloConnectionAuthenticationRegistration) + || descriptor.ServiceType == typeof(SiloTlsRegistrationMarker))) + { + throw new InvalidOperationException("Silo TLS or connection authentication has already been configured."); + } + + var tlsOptions = new TlsOptions(); + configureTls(tlsOptions); + if (tlsOptions.LocalCertificate is null && tlsOptions.LocalServerCertificateSelector is null) + { + throw new InvalidOperationException("No silo TLS certificate was specified."); + } + + if (tlsOptions.LocalCertificate is { } certificate && !certificate.HasPrivateKey) + { + TlsConnectionBuilderExtensions.ThrowNoPrivateKey( + certificate, + $"{nameof(TlsOptions)}.{nameof(TlsOptions.LocalCertificate)}"); + } + + var authenticationOptions = new SiloConnectionAuthenticationOptions(); + var authenticationBuilder = new SiloConnectionAuthenticationBuilder(authenticationOptions, builder.Services); + configureAuthentication(authenticationBuilder); + var tlsSnapshot = SiloConnectionAuthenticationRegistration.CloneTlsOptions(tlsOptions); + var authenticationSnapshot = SiloConnectionAuthenticationRegistration.CloneOptions(authenticationOptions); + SiloConnectionAuthenticationRegistration.ConfigureApplicationProtocols(tlsSnapshot, authenticationSnapshot); + + var registration = new SiloConnectionAuthenticationRegistration + { + Options = authenticationSnapshot, + TlsOptions = tlsSnapshot, + HasTokenProvider = authenticationBuilder.HasTokenProvider, + HasTokenValidator = authenticationBuilder.HasTokenValidator, + }; + + builder.Services.AddSingleton(registration); + builder.Services.AddSingleton>( + new SiloConnectionAuthenticationOptionsValidator(registration)); + builder.Services + .AddOptions() + .Configure(registration.CopyOptionsTo) + .ValidateOnStart(); + builder.Services.AddSingleton(serviceProvider => + new AuthenticationWorkLimiter(serviceProvider.GetRequiredService>().Value)); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + return builder.Configure(connectionOptions => + { + connectionOptions.ConfigureSiloInboundConnection(connectionBuilder => + { + connectionBuilder.UseServerTls(tlsSnapshot); + connectionBuilder.UseMiddleware(); + }); + + connectionOptions.ConfigureSiloOutboundConnection(connectionBuilder => + { + connectionBuilder.UseClientTls(tlsSnapshot); + connectionBuilder.UseMiddleware(); + }); + }); + } +} diff --git a/src/Orleans.Connections.Security/Orleans.Connections.Security.csproj b/src/Orleans.Connections.Security/Orleans.Connections.Security.csproj index d60c05ecfaf..3bf41cb2ade 100644 --- a/src/Orleans.Connections.Security/Orleans.Connections.Security.csproj +++ b/src/Orleans.Connections.Security/Orleans.Connections.Security.csproj @@ -2,9 +2,9 @@ Microsoft.Orleans.Connections.Security - Microsoft Orleans TLS support - Support for security communication using TLS in Microsoft Orleans. - $(PackageTags) TLS SSL + Microsoft Orleans connection security + Support for secure communication using TLS and authenticated silo connections in Microsoft Orleans. + $(PackageTags) TLS SSL authentication $(DefaultTargetFrameworks) true @@ -12,4 +12,8 @@ + + + + diff --git a/src/Orleans.Connections.Security/Security/OrleansApplicationProtocol.cs b/src/Orleans.Connections.Security/Security/OrleansApplicationProtocol.cs index 14ab24ceeee..7b957acb832 100644 --- a/src/Orleans.Connections.Security/Security/OrleansApplicationProtocol.cs +++ b/src/Orleans.Connections.Security/Security/OrleansApplicationProtocol.cs @@ -5,5 +5,6 @@ namespace Orleans.Connections.Security internal static class OrleansApplicationProtocol { public static readonly SslApplicationProtocol Orleans1 = new SslApplicationProtocol("Orleans1"); + public static readonly SslApplicationProtocol Orleans1TokenAuth2 = new SslApplicationProtocol(SiloConnectionAuthenticationProtocol.Version2); } } diff --git a/src/api/Orleans.Connections.Security.Entra/Orleans.Connections.Security.Entra.cs b/src/api/Orleans.Connections.Security.Entra/Orleans.Connections.Security.Entra.cs new file mode 100644 index 00000000000..5a44471325c --- /dev/null +++ b/src/api/Orleans.Connections.Security.Entra/Orleans.Connections.Security.Entra.cs @@ -0,0 +1,77 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +namespace Orleans.Configuration +{ + public sealed partial class EntraSiloConnectionOptions + { + public System.Collections.Generic.ISet AdditionalTrustedMetadataHosts { get { throw null; } } + + public bool AllowAnyApplicationInTenant { get { throw null; } set { } } + + public bool AllowDelegatedTokens { get { throw null; } set { } } + + public System.Collections.Generic.ISet AllowedAlgorithms { get { throw null; } } + + public System.Collections.Generic.ISet AllowedClientIds { get { throw null; } } + + public System.Collections.Generic.ISet AllowedServicePrincipalObjectIds { get { throw null; } } + + public System.Uri? Authority { get { throw null; } set { } } + + public System.TimeSpan AutomaticMetadataRefreshInterval { get { throw null; } set { } } + + public System.TimeSpan ClockSkew { get { throw null; } set { } } + + public string? ClusterAudienceFormat { get { throw null; } set { } } + + public string? ClusterClaimType { get { throw null; } set { } } + + public string? ClusterRoleFormat { get { throw null; } set { } } + + public System.TimeSpan LastKnownGoodLifetime { get { throw null; } set { } } + + public System.TimeSpan MaximumMetadataRefreshBackoff { get { throw null; } set { } } + + public int MaximumMetadataRefreshQueueSize { get { throw null; } set { } } + + public int MaximumMetadataSize { get { throw null; } set { } } + + public System.TimeSpan MaximumTokenLifetime { get { throw null; } set { } } + + public int MaximumTokenSize { get { throw null; } set { } } + + public System.TimeSpan MetadataRefreshBackoff { get { throw null; } set { } } + + public double MetadataRefreshJitterRatio { get { throw null; } set { } } + + public System.TimeSpan MetadataRetrievalTimeout { get { throw null; } set { } } + + public System.TimeSpan MinimumRemainingTokenLifetime { get { throw null; } set { } } + + public System.Collections.Generic.ISet RequiredRoles { get { throw null; } } + + public System.Collections.Generic.ISet SupportedTokenVersions { get { throw null; } } + + public string? TokenScope { get { throw null; } set { } } + + public System.TimeSpan UnknownSigningKeyRefreshInterval { get { throw null; } set { } } + + public System.Collections.Generic.ISet ValidAudiences { get { throw null; } } + + public System.Collections.Generic.ISet ValidTenantIds { get { throw null; } } + } +} + +namespace Orleans.Hosting +{ + public static partial class EntraSiloConnectionAuthenticationExtensions + { + public static SiloConnectionAuthenticationBuilder UseEntra(this SiloConnectionAuthenticationBuilder builder, Azure.Core.TokenCredential credential, System.Action configureOptions) { throw null; } + } +} \ No newline at end of file diff --git a/src/api/Orleans.Connections.Security/Orleans.Connections.Security.cs b/src/api/Orleans.Connections.Security/Orleans.Connections.Security.cs index ca3108a33ce..4c2efc5d7dd 100644 --- a/src/api/Orleans.Connections.Security/Orleans.Connections.Security.cs +++ b/src/api/Orleans.Connections.Security/Orleans.Connections.Security.cs @@ -24,6 +24,31 @@ public static partial class CertificateLoader } public delegate System.Security.Cryptography.X509Certificates.X509Certificate? ClientCertificateSelectionCallback(object sender, string targetHost, System.Security.Cryptography.X509Certificates.X509CertificateCollection localCertificates, System.Security.Cryptography.X509Certificates.X509Certificate? remoteCertificate, string[] acceptableIssuers); + public partial interface ISiloConnectionAuthenticationFeature + { + bool AuthenticationAttempted { get; } + + System.DateTimeOffset? ExpiresAt { get; } + + SiloConnectionAuthenticationFailure Failure { get; } + + bool IsAuthenticated { get; } + + System.Security.Claims.ClaimsPrincipal? Principal { get; } + + string Protocol { get; } + } + + public partial interface ISiloConnectionTokenProvider + { + System.Threading.Tasks.ValueTask GetTokenAsync(SiloConnectionTokenRequestContext context, System.Threading.CancellationToken cancellationToken); + } + + public partial interface ISiloConnectionTokenValidator + { + System.Threading.Tasks.ValueTask ValidateTokenAsync(string token, SiloConnectionTokenValidationContext context, System.Threading.CancellationToken cancellationToken); + } + public partial interface ITlsApplicationProtocolFeature { System.ReadOnlyMemory ApplicationProtocol { get; } @@ -66,6 +91,182 @@ public enum RemoteCertificateMode public delegate bool RemoteCertificateValidator(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, System.Security.Cryptography.X509Certificates.X509Chain? chain, System.Net.Security.SslPolicyErrors policyErrors); public delegate System.Security.Cryptography.X509Certificates.X509Certificate ServerCertificateSelectionCallback(object sender, string? hostName); + public sealed partial class SiloConnectionAuthenticationBuilder + { + internal SiloConnectionAuthenticationBuilder() { } + + public bool AllowNonExpiringCredentials { get { throw null; } set { } } + + public System.TimeSpan ExpirationJitter { get { throw null; } set { } } + + public System.TimeSpan ExpirationSafetyMargin { get { throw null; } set { } } + + public int MaxConcurrentInboundAuthentications { get { throw null; } set { } } + + public int MaxConcurrentOutboundAuthentications { get { throw null; } set { } } + + public int MaxPendingInboundAuthentications { get { throw null; } set { } } + + public int MaxPendingOutboundAuthentications { get { throw null; } set { } } + + public int MaxTokenSize { get { throw null; } set { } } + + public System.TimeSpan MinimumRemainingTokenLifetime { get { throw null; } set { } } + + public SiloConnectionAuthenticationMode Mode { get { throw null; } set { } } + + public Microsoft.Extensions.DependencyInjection.IServiceCollection Services { get { throw null; } } + + public string? TargetHost { get { throw null; } set { } } + + public System.TimeProvider TimeProvider { get { throw null; } set { } } + + public System.TimeSpan TokenExchangeTimeout { get { throw null; } set { } } + + public SiloConnectionAuthenticationBuilder UseTokenProvider(ISiloConnectionTokenProvider provider) { throw null; } + + public SiloConnectionAuthenticationBuilder UseTokenProvider() + where TProvider : class, ISiloConnectionTokenProvider { throw null; } + + public SiloConnectionAuthenticationBuilder UseTokenValidator(ISiloConnectionTokenValidator validator) { throw null; } + + public SiloConnectionAuthenticationBuilder UseTokenValidator() + where TValidator : class, ISiloConnectionTokenValidator { throw null; } + } + + public enum SiloConnectionAuthenticationDirection + { + Inbound = 0, + Outbound = 1 + } + + public enum SiloConnectionAuthenticationFailure + { + None = 0, + MissingToken = 1, + InvalidToken = 2, + ExpiredToken = 3, + UnauthorizedCaller = 4, + ProviderUnavailable = 5, + ValidationError = 6 + } + + public enum SiloConnectionAuthenticationMode + { + Disabled = 0, + Audit = 1, + Required = 2 + } + + public sealed partial class SiloConnectionAuthenticationOptions + { + public bool AllowNonExpiringCredentials { get { throw null; } set { } } + + public System.TimeSpan ExpirationJitter { get { throw null; } set { } } + + public System.TimeSpan ExpirationSafetyMargin { get { throw null; } set { } } + + public int MaxConcurrentInboundAuthentications { get { throw null; } set { } } + + public int MaxConcurrentOutboundAuthentications { get { throw null; } set { } } + + public int MaxPendingInboundAuthentications { get { throw null; } set { } } + + public int MaxPendingOutboundAuthentications { get { throw null; } set { } } + + public int MaxTokenSize { get { throw null; } set { } } + + public System.TimeSpan MinimumRemainingTokenLifetime { get { throw null; } set { } } + + public SiloConnectionAuthenticationMode Mode { get { throw null; } set { } } + + public string? TargetHost { get { throw null; } set { } } + + public System.TimeProvider TimeProvider { get { throw null; } set { } } + + public System.TimeSpan TokenExchangeTimeout { get { throw null; } set { } } + } + + public static partial class SiloConnectionAuthenticationProtocol + { + public const string Version2 = "Orleans1+TokenAuth2"; + } + + public readonly partial struct SiloConnectionToken : System.IEquatable + { + private readonly object _dummy; + private readonly int _dummyPrimitive; + public SiloConnectionToken(string Value, System.DateTimeOffset? ExpiresAt) { } + + public System.DateTimeOffset? ExpiresAt { get { throw null; } init { } } + + public string Value { get { throw null; } init { } } + + [System.Runtime.CompilerServices.CompilerGenerated] + public readonly void Deconstruct(out string Value, out System.DateTimeOffset? ExpiresAt) { throw null; } + + [System.Runtime.CompilerServices.CompilerGenerated] + public readonly bool Equals(SiloConnectionToken other) { throw null; } + + [System.Runtime.CompilerServices.CompilerGenerated] + public override readonly bool Equals(object obj) { throw null; } + + [System.Runtime.CompilerServices.CompilerGenerated] + public override readonly int GetHashCode() { throw null; } + + [System.Runtime.CompilerServices.CompilerGenerated] + public static bool operator ==(SiloConnectionToken left, SiloConnectionToken right) { throw null; } + + [System.Runtime.CompilerServices.CompilerGenerated] + public static bool operator !=(SiloConnectionToken left, SiloConnectionToken right) { throw null; } + + [System.Runtime.CompilerServices.CompilerGenerated] + public override readonly string ToString() { throw null; } + } + + public sealed partial class SiloConnectionTokenRequestContext + { + internal SiloConnectionTokenRequestContext() { } + + public string ClusterId { get { throw null; } } + + public SiloConnectionAuthenticationDirection Direction { get { throw null; } } + + public System.Net.EndPoint? LocalEndPoint { get { throw null; } } + + public System.Net.EndPoint? RemoteEndPoint { get { throw null; } } + } + + public sealed partial class SiloConnectionTokenValidationContext + { + internal SiloConnectionTokenValidationContext() { } + + public string ClusterId { get { throw null; } } + + public SiloConnectionAuthenticationDirection Direction { get { throw null; } } + + public System.Net.EndPoint? LocalEndPoint { get { throw null; } } + + public System.Net.EndPoint? RemoteEndPoint { get { throw null; } } + } + + public sealed partial class SiloConnectionTokenValidationResult + { + internal SiloConnectionTokenValidationResult() { } + + public System.DateTimeOffset? ExpiresAt { get { throw null; } } + + public SiloConnectionAuthenticationFailure Failure { get { throw null; } } + + public System.Security.Claims.ClaimsPrincipal? Principal { get { throw null; } } + + public bool Succeeded { get { throw null; } } + + public static SiloConnectionTokenValidationResult Fail(SiloConnectionAuthenticationFailure failure) { throw null; } + + public static SiloConnectionTokenValidationResult Success(System.Security.Claims.ClaimsPrincipal principal, System.DateTimeOffset? expiresAt) { throw null; } + } + public partial class TlsClientAuthenticationOptions { public System.Collections.Generic.List? ApplicationProtocols { get { throw null; } set { } } @@ -136,6 +337,8 @@ public static partial class OrleansConnectionSecurityHostingExtensions public static ISiloBuilder UseSiloTls(this ISiloBuilder builder, System.Action configureOptions) { throw null; } + public static ISiloBuilder UseAuthenticatedSiloConnections(this ISiloBuilder builder, System.Action configureTls, System.Action configureAuthentication) { throw null; } + public static IClientBuilder UseTls(this IClientBuilder builder, System.Action configureOptions) { throw null; } public static IClientBuilder UseTls(this IClientBuilder builder, System.Security.Cryptography.X509Certificates.StoreName storeName, string subject, bool allowInvalid, System.Security.Cryptography.X509Certificates.StoreLocation location, System.Action configureOptions) { throw null; } diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs new file mode 100644 index 00000000000..f9a868dd6b7 --- /dev/null +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs @@ -0,0 +1,311 @@ +using System.Security.Cryptography; +using Microsoft.IdentityModel.Tokens; +using Orleans.Connections.Security.Entra; + +namespace Orleans.Connections.Security.Entra.Tests; + +public sealed class EntraJwtValidatorTests +{ + [Theory] + [InlineData("1.0")] + [InlineData("2.0")] + public async Task AcceptsValidApplicationToken(string version) + { + using var fixture = new EntraTestFixture(); + var validator = fixture.CreateValidator(); + var token = fixture.CreateToken(version); + + var result = await validator.ValidateAsync(token, EntraTestFixture.ClusterId, CancellationToken.None); + + Assert.True(result.Principal.Identity?.IsAuthenticated); + Assert.Equal(EntraTestFixture.ClientId, result.Principal.FindFirst(version == "1.0" ? "appid" : "azp")?.Value); + Assert.Equal(fixture.TimeProvider.GetUtcNow().AddMinutes(30), result.ExpiresAt); + } + + [Fact] + public async Task AcceptsRealisticV1IssuerWhenExplicitlyTrusted() + { + const string authority = "https://login.microsoftonline.com/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + const string issuer = "https://sts.windows.net/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/"; + using var fixture = new EntraTestFixture(); + var options = EntraTestFixture.CreateOptions(authority); + options.AdditionalTrustedMetadataHosts.Add("sts.windows.net"); + var metadata = new TestDocumentRetriever(options.Authority!); + metadata.SetConfiguration(issuer, fixture.CurrentKey); + using var provider = new EntraOpenIdConfigurationProvider(options, metadata, fixture.TimeProvider, static () => 0); + var validator = new EntraJwtValidator(options, provider, fixture.TimeProvider); + var token = fixture.CreateToken(version: "1.0", issuer: issuer); + + var result = await validator.ValidateAsync(token, EntraTestFixture.ClusterId, CancellationToken.None); + + Assert.True(result.Principal.Identity?.IsAuthenticated); + } + + [Fact] + public async Task RejectsExpiredToken() + { + using var fixture = new EntraTestFixture(); + var token = fixture.CreateToken( + notBefore: fixture.TimeProvider.GetUtcNow().AddMinutes(-30), + expires: fixture.TimeProvider.GetUtcNow().AddMinutes(-5)); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.ExpiredToken); + } + + [Fact] + public async Task AcceptsNotBeforeWithinClockSkew() + { + using var fixture = new EntraTestFixture(); + var token = fixture.CreateToken(notBefore: fixture.TimeProvider.GetUtcNow().AddMinutes(1)); + + var result = await fixture.CreateValidator().ValidateAsync( + token, + EntraTestFixture.ClusterId, + CancellationToken.None); + + Assert.True(result.Principal.Identity?.IsAuthenticated); + } + + [Fact] + public async Task RejectsNotBeforeOutsideClockSkew() + { + using var fixture = new EntraTestFixture(); + var token = fixture.CreateToken(notBefore: fixture.TimeProvider.GetUtcNow().AddMinutes(3)); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.ExpiredToken); + } + + [Fact] + public async Task RejectsExcessiveTokenLifetime() + { + using var fixture = new EntraTestFixture(); + var token = fixture.CreateToken( + notBefore: fixture.TimeProvider.GetUtcNow().AddMinutes(-1), + expires: fixture.TimeProvider.GetUtcNow().AddHours(3)); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.InvalidToken); + } + + [Theory] + [InlineData("https://login.microsoftonline.com/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/v2.0", EntraTestFixture.TenantId, EntraTestFixture.Audience)] + [InlineData(EntraTestFixture.Issuer, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", EntraTestFixture.Audience)] + [InlineData(EntraTestFixture.Issuer, EntraTestFixture.TenantId, "orleans-silos")] + public async Task RejectsWrongIssuerTenantOrExactAudience(string issuer, string tenant, string audience) + { + using var fixture = new EntraTestFixture(); + var token = fixture.CreateToken(issuer: issuer, tenantId: tenant, audience: audience); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.InvalidToken); + } + + [Fact] + public async Task RejectsWrongCluster() + { + using var fixture = new EntraTestFixture(); + var token = fixture.CreateToken(clusterId: "cluster-b"); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.UnauthorizedCaller); + } + + [Fact] + public async Task SupportsClusterSpecificRoleBinding() + { + using var fixture = new EntraTestFixture(); + fixture.Options.ClusterClaimType = null; + fixture.Options.ClusterRoleFormat = "Orleans.Silo.Connect.{0}"; + var token = fixture.CreateToken(roles: [EntraTestFixture.Role, "Orleans.Silo.Connect.cluster-a"]); + + var result = await fixture.CreateValidator().ValidateAsync( + token, + EntraTestFixture.ClusterId, + CancellationToken.None); + + Assert.True(result.Principal.Identity?.IsAuthenticated); + } + + [Fact] + public async Task SupportsClusterSpecificAudienceBinding() + { + using var fixture = new EntraTestFixture(); + fixture.Options.ClusterClaimType = null; + fixture.Options.ClusterAudienceFormat = "api://orleans-silos/{0}"; + var token = fixture.CreateToken(audience: "api://orleans-silos/cluster-a"); + + var result = await fixture.CreateValidator().ValidateAsync( + token, + EntraTestFixture.ClusterId, + CancellationToken.None); + + Assert.True(result.Principal.Identity?.IsAuthenticated); + } + + [Fact] + public async Task RejectsDelegatedTokenByDefault() + { + using var fixture = new EntraTestFixture(); + var token = fixture.CreateToken(scopes: "user.read"); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.UnauthorizedCaller); + } + + [Fact] + public async Task AllowsDelegatedTokenOnlyWhenExplicitlyConfigured() + { + using var fixture = new EntraTestFixture(); + fixture.Options.AllowDelegatedTokens = true; + var token = fixture.CreateToken(identityType: "user", scopes: "user.read"); + + var result = await fixture.CreateValidator().ValidateAsync( + token, + EntraTestFixture.ClusterId, + CancellationToken.None); + + Assert.True(result.Principal.Identity?.IsAuthenticated); + } + + [Theory] + [InlineData("33333333-3333-3333-3333-333333333333", EntraTestFixture.Role)] + [InlineData(EntraTestFixture.ClientId, "Wrong.Role")] + public async Task RejectsWrongCallerOrApplicationRole(string clientId, string role) + { + using var fixture = new EntraTestFixture(); + var token = fixture.CreateToken(clientId: clientId, roles: [role]); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.UnauthorizedCaller); + } + + [Fact] + public async Task AuthorizesConfiguredServicePrincipalObjectId() + { + using var fixture = new EntraTestFixture(); + fixture.Options.AllowedServicePrincipalObjectIds.Add(EntraTestFixture.ObjectId); + var token = fixture.CreateToken(); + + var result = await fixture.CreateValidator().ValidateAsync( + token, + EntraTestFixture.ClusterId, + CancellationToken.None); + + Assert.True(result.Principal.Identity?.IsAuthenticated); + } + + [Fact] + public async Task RejectsWrongServicePrincipalObjectId() + { + using var fixture = new EntraTestFixture(); + fixture.Options.AllowedServicePrincipalObjectIds.Add("33333333-3333-3333-3333-333333333333"); + var token = fixture.CreateToken(); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.UnauthorizedCaller); + } + + [Theory] + [InlineData("1.0", "azp")] + [InlineData("2.0", "appid")] + public async Task RejectsAmbiguousCallerIdentity(string version, string conflictingClaim) + { + using var fixture = new EntraTestFixture(); + var token = fixture.CreateToken( + version: version, + additionalClaims: new Dictionary { [conflictingClaim] = EntraTestFixture.ClientId }); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.UnauthorizedCaller); + } + + [Fact] + public async Task RejectsDuplicateIdentityClaim() + { + using var fixture = new EntraTestFixture(); + var token = EntraTestFixture.CreateDuplicateClaimToken(); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.InvalidToken); + } + + [Fact] + public async Task RejectsDuplicateAuthorizationClaim() + { + using var fixture = new EntraTestFixture(); + var token = EntraTestFixture.CreateMalformedToken( + """{"iss":"x","tid":"x","ver":"2.0","azp":"x","idtyp":"app","roles":["a"],"roles":["b"],"nbf":1,"exp":2,"aud":"x"}"""); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.InvalidToken); + } + + [Theory] + [InlineData("""{"iss":"x","tid":"x","ver":"2.0","azp":"x","idtyp":"app","roles":["a"],"exp":2,"aud":"x"}""")] + [InlineData("""{"iss":"x","tid":"x","ver":"2.0","azp":"x","idtyp":"app","roles":["a"],"nbf":1,"aud":"x"}""")] + public async Task RejectsTokenWithoutFiniteLifetime(string payload) + { + using var fixture = new EntraTestFixture(); + var token = EntraTestFixture.CreateMalformedToken(payload); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.InvalidToken); + } + + [Fact] + public async Task RejectsUnsupportedTokenVersion() + { + using var fixture = new EntraTestFixture(); + var token = fixture.CreateToken(version: "3.0"); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.InvalidToken); + } + + [Fact] + public async Task RejectsUnsignedToken() + { + using var fixture = new EntraTestFixture(); + var token = fixture.CreateToken(signingCredentials: null); + var segments = token.Split('.'); + var unsignedHeader = Base64UrlEncoder.Encode("""{"alg":"none","kid":"key-1"}"""); + token = $"{unsignedHeader}.{segments[1]}."; + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.InvalidToken); + } + + [Fact] + public async Task RejectsSymmetricAlgorithmEvenWhenAddedToAllowlist() + { + using var fixture = new EntraTestFixture(); + fixture.Options.AllowedAlgorithms.Add(SecurityAlgorithms.HmacSha256); + var key = new SymmetricSecurityKey(RandomNumberGenerator.GetBytes(32)) { KeyId = "symmetric" }; + var token = fixture.CreateToken(signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.InvalidToken); + } + + [Fact] + public async Task RejectsDisallowedAsymmetricAlgorithm() + { + using var fixture = new EntraTestFixture(); + var key = fixture.CreateKey("rsa-384", SecurityAlgorithms.RsaSha384); + var token = fixture.CreateToken(signingCredentials: key); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.InvalidToken); + } + + [Fact] + public async Task NeverIncludesTokenInFailure() + { + using var fixture = new EntraTestFixture(); + var token = fixture.CreateToken(clientId: "not-authorized"); + var validator = fixture.CreateValidator(); + + var exception = await Assert.ThrowsAsync( + () => validator.ValidateAsync(token, EntraTestFixture.ClusterId, CancellationToken.None).AsTask()); + + Assert.DoesNotContain(token, exception.ToString(), StringComparison.Ordinal); + } + + private static async Task AssertErrorAsync( + EntraTestFixture fixture, + string token, + EntraAuthenticationError expected) + { + var exception = await Assert.ThrowsAsync( + () => fixture.CreateValidator() + .ValidateAsync(token, EntraTestFixture.ClusterId, CancellationToken.None) + .AsTask()); + Assert.Equal(expected, exception.Error); + } +} diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraMetadataTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraMetadataTests.cs new file mode 100644 index 00000000000..1397f2145f9 --- /dev/null +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraMetadataTests.cs @@ -0,0 +1,266 @@ +using System.Net; +using System.Net.Http; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Tokens; +using Orleans.Connections.Security.Entra; + +namespace Orleans.Connections.Security.Entra.Tests; + +public sealed class EntraMetadataTests +{ + [Fact] + public async Task RefreshesMetadataForSigningKeyRollover() + { + using var fixture = new EntraTestFixture(); + using var provider = CreateProvider(fixture); + var validator = new EntraJwtValidator(fixture.Options, provider, fixture.TimeProvider); + await validator.ValidateAsync( + fixture.CreateToken(), + EntraTestFixture.ClusterId, + CancellationToken.None); + var nextKey = fixture.CreateKey("key-2"); + fixture.RollMetadataTo(nextKey); + + var result = await validator.ValidateAsync( + fixture.CreateToken(signingCredentials: nextKey), + EntraTestFixture.ClusterId, + CancellationToken.None); + + Assert.True(result.Principal.Identity?.IsAuthenticated); + Assert.Equal(4, fixture.Metadata.RequestCount); + } + + [Fact] + public async Task ThrottlesUnknownSigningKeyRefresh() + { + using var fixture = new EntraTestFixture(); + using var provider = CreateProvider(fixture); + var validator = new EntraJwtValidator(fixture.Options, provider, fixture.TimeProvider); + await validator.ValidateAsync( + fixture.CreateToken(), + EntraTestFixture.ClusterId, + CancellationToken.None); + var unknownKey = fixture.CreateKey("unknown"); + var token = fixture.CreateToken(signingCredentials: unknownKey); + + await Assert.ThrowsAsync( + () => validator.ValidateAsync(token, EntraTestFixture.ClusterId, CancellationToken.None).AsTask()); + var afterFirstFailure = fixture.Metadata.RequestCount; + await Assert.ThrowsAsync( + () => validator.ValidateAsync(token, EntraTestFixture.ClusterId, CancellationToken.None).AsTask()); + + Assert.Equal(4, afterFirstFailure); + Assert.Equal(afterFirstFailure, fixture.Metadata.RequestCount); + } + + [Fact] + public async Task UsesLastKnownGoodMetadataDuringBoundedOutage() + { + using var fixture = new EntraTestFixture(); + fixture.Options.AutomaticMetadataRefreshInterval = TimeSpan.FromMinutes(1); + fixture.Options.LastKnownGoodLifetime = TimeSpan.FromMinutes(10); + using var provider = CreateProvider(fixture); + var validator = new EntraJwtValidator(fixture.Options, provider, fixture.TimeProvider); + await validator.ValidateAsync( + fixture.CreateToken(), + EntraTestFixture.ClusterId, + CancellationToken.None); + fixture.TimeProvider.Advance(TimeSpan.FromMinutes(2)); + fixture.Metadata.FailRequests = true; + + var result = await validator.ValidateAsync( + fixture.CreateToken(), + EntraTestFixture.ClusterId, + CancellationToken.None); + + Assert.True(result.Principal.Identity?.IsAuthenticated); + Assert.Equal(3, fixture.Metadata.RequestCount); + } + + [Fact] + public async Task RejectsLastKnownGoodMetadataAfterBoundedLifetime() + { + using var fixture = new EntraTestFixture(); + fixture.Options.AutomaticMetadataRefreshInterval = TimeSpan.FromMinutes(1); + fixture.Options.LastKnownGoodLifetime = TimeSpan.FromMinutes(5); + using var provider = CreateProvider(fixture); + var validator = new EntraJwtValidator(fixture.Options, provider, fixture.TimeProvider); + await validator.ValidateAsync( + fixture.CreateToken(), + EntraTestFixture.ClusterId, + CancellationToken.None); + fixture.TimeProvider.Advance(TimeSpan.FromMinutes(6)); + fixture.Metadata.FailRequests = true; + + var exception = await Assert.ThrowsAsync( + () => validator.ValidateAsync( + fixture.CreateToken(), + EntraTestFixture.ClusterId, + CancellationToken.None).AsTask()); + + Assert.Equal(EntraAuthenticationError.ProviderUnavailable, exception.Error); + } + + [Fact] + public async Task AppliesBackoffAfterMetadataFailure() + { + using var fixture = new EntraTestFixture(); + fixture.Options.AutomaticMetadataRefreshInterval = TimeSpan.FromMinutes(1); + fixture.Options.MetadataRefreshBackoff = TimeSpan.FromSeconds(10); + fixture.Options.MaximumMetadataRefreshBackoff = TimeSpan.FromSeconds(10); + using var provider = CreateProvider(fixture); + var validator = new EntraJwtValidator(fixture.Options, provider, fixture.TimeProvider); + await validator.ValidateAsync( + fixture.CreateToken(), + EntraTestFixture.ClusterId, + CancellationToken.None); + fixture.TimeProvider.Advance(TimeSpan.FromMinutes(2)); + fixture.Metadata.FailRequests = true; + await validator.ValidateAsync( + fixture.CreateToken(), + EntraTestFixture.ClusterId, + CancellationToken.None); + var requestCount = fixture.Metadata.RequestCount; + + await validator.ValidateAsync( + fixture.CreateToken(), + EntraTestFixture.ClusterId, + CancellationToken.None); + + Assert.Equal(requestCount, fixture.Metadata.RequestCount); + } + + [Fact] + public async Task MetadataRefreshIsSingleFlight() + { + using var fixture = new EntraTestFixture(); + fixture.Metadata.ResponseDelay = TimeSpan.FromMilliseconds(50); + using var provider = CreateProvider(fixture); + var validator = new EntraJwtValidator(fixture.Options, provider, fixture.TimeProvider); + var token = fixture.CreateToken(); + + await Task.WhenAll( + Enumerable.Range(0, 20).Select( + _ => validator.ValidateAsync(token, EntraTestFixture.ClusterId, CancellationToken.None).AsTask())); + + Assert.Equal(2, fixture.Metadata.RequestCount); + } + + [Fact] + public async Task MetadataRefreshQueueIsBounded() + { + using var fixture = new EntraTestFixture(); + fixture.Options.MaximumMetadataRefreshQueueSize = 2; + fixture.Metadata.ResponseDelay = TimeSpan.FromMilliseconds(100); + using var provider = CreateProvider(fixture); + + var operations = Enumerable.Range(0, 8) + .Select(_ => provider.GetConfigurationAsync(CancellationToken.None).AsTask()) + .ToArray(); + var results = await Task.WhenAll( + operations.Select(async operation => + { + try + { + await operation; + return EntraAuthenticationError.InvalidToken; + } + catch (EntraAuthenticationException exception) + { + return exception.Error; + } + })); + + Assert.Contains(EntraAuthenticationError.ProviderUnavailable, results); + Assert.Equal(2, fixture.Metadata.RequestCount); + } + + [Theory] + [InlineData("enc", null)] + [InlineData("sig", "sign")] + public async Task RejectsSigningKeysWithoutVerificationUsage(string use, string? keyOperation) + { + using var fixture = new EntraTestFixture(); + fixture.Metadata.SetConfiguration( + EntraTestFixture.Issuer, + fixture.CurrentKey, + use, + keyOperation is null ? null : [keyOperation]); + using var provider = CreateProvider(fixture); + + var exception = await Assert.ThrowsAsync( + () => provider.GetConfigurationAsync(CancellationToken.None).AsTask()); + + Assert.Equal(EntraAuthenticationError.ProviderUnavailable, exception.Error); + } + + [Theory] + [InlineData("https://login.microsoftonline.us/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/v2.0", null)] + [InlineData(EntraTestFixture.Issuer, "https://login.microsoftonline.us/keys")] + public async Task RejectsCrossCloudIssuerOrSigningKeySubstitution(string issuer, string? jwksUri) + { + using var fixture = new EntraTestFixture(); + fixture.Metadata.SetConfiguration(issuer, fixture.CurrentKey, jwksUri: jwksUri); + using var provider = CreateProvider(fixture); + + var exception = await Assert.ThrowsAsync( + () => provider.GetConfigurationAsync(CancellationToken.None).AsTask()); + + Assert.Equal(EntraAuthenticationError.ProviderUnavailable, exception.Error); + } + + [Fact] + public async Task SupportsExplicitSovereignCloudAuthority() + { + const string issuer = "https://login.microsoftonline.us/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/v2.0"; + using var fixture = new EntraTestFixture(); + var options = EntraTestFixture.CreateOptions(issuer); + var metadata = new TestDocumentRetriever(options.Authority!); + metadata.SetConfiguration(issuer, fixture.CurrentKey); + using var provider = new EntraOpenIdConfigurationProvider(options, metadata, fixture.TimeProvider, static () => 0); + var validator = new EntraJwtValidator(options, provider, fixture.TimeProvider); + + var result = await validator.ValidateAsync( + fixture.CreateToken(issuer: issuer), + EntraTestFixture.ClusterId, + CancellationToken.None); + + Assert.True(result.Principal.Identity?.IsAuthenticated); + } + + [Fact] + public async Task RejectsRedirectedMetadata() + { + var options = EntraTestFixture.CreateOptions(); + var handler = new TestHttpMessageHandler( + _ => new HttpResponseMessage(HttpStatusCode.Redirect) + { + Headers = { Location = new Uri("https://evil.example/metadata") }, + }); + using var retriever = new StrictHttpDocumentRetriever(options, handler); + + var exception = await Assert.ThrowsAsync( + () => retriever.GetDocumentAsync( + $"{options.Authority!.AbsoluteUri.TrimEnd('/')}/.well-known/openid-configuration", + CancellationToken.None)); + + Assert.Equal(EntraAuthenticationError.ProviderUnavailable, exception.Error); + } + + [Fact] + public async Task RejectsUntrustedMetadataHostBeforeSendingRequest() + { + var options = EntraTestFixture.CreateOptions(); + var handler = new TestHttpMessageHandler( + _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); + using var retriever = new StrictHttpDocumentRetriever(options, handler); + + await Assert.ThrowsAsync( + () => retriever.GetDocumentAsync("https://evil.example/metadata", CancellationToken.None)); + + Assert.Equal(0, handler.RequestCount); + } + + private static EntraOpenIdConfigurationProvider CreateProvider(EntraTestFixture fixture) + => new(fixture.Options, fixture.Metadata, fixture.TimeProvider, static () => 0); +} diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraOptionsTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraOptionsTests.cs new file mode 100644 index 00000000000..1fecd121140 --- /dev/null +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraOptionsTests.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.Options; +using Orleans.Configuration; + +namespace Orleans.Connections.Security.Entra.Tests; + +public sealed class EntraOptionsTests +{ + [Fact] + public void SecureConfigurationIsValid() + { + var result = new EntraSiloConnectionOptionsValidator().Validate( + Options.DefaultName, + EntraTestFixture.CreateOptions()); + + Assert.True(result.Succeeded); + } + + [Theory] + [InlineData("http://login.microsoftonline.com/tenant/v2.0")] + [InlineData("https://login.microsoftonline.com/common/v2.0")] + [InlineData("https://login.microsoftonline.com/organizations/v2.0")] + [InlineData("https://login.microsoftonline.com/consumers/v2.0")] + public void RejectsUntrustedOrTenantIndependentAuthority(string authority) + { + var options = EntraTestFixture.CreateOptions(); + options.Authority = new Uri(authority); + + var result = new EntraSiloConnectionOptionsValidator().Validate(Options.DefaultName, options); + + Assert.False(result.Succeeded); + } + + [Fact] + public void RequiresExplicitCallerAuthorization() + { + var options = EntraTestFixture.CreateOptions(); + options.AllowedClientIds.Clear(); + options.RequiredRoles.Clear(); + + var result = new EntraSiloConnectionOptionsValidator().Validate(Options.DefaultName, options); + + Assert.False(result.Succeeded); + } + + [Fact] + public void RequiresExplicitClusterBinding() + { + var options = EntraTestFixture.CreateOptions(); + options.ClusterClaimType = null; + + var result = new EntraSiloConnectionOptionsValidator().Validate(Options.DefaultName, options); + + Assert.False(result.Succeeded); + } + + [Theory] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + public void RejectsNonFiniteMetadataRefreshJitter(double jitter) + { + var options = EntraTestFixture.CreateOptions(); + options.MetadataRefreshJitterRatio = jitter; + + var result = new EntraSiloConnectionOptionsValidator().Validate(Options.DefaultName, options); + + Assert.False(result.Succeeded); + } + + [Fact] + public void RejectsEffectivelyUnboundedMetadataWork() + { + var options = EntraTestFixture.CreateOptions(); + options.AutomaticMetadataRefreshInterval = TimeSpan.MaxValue; + options.MaximumMetadataRefreshQueueSize = int.MaxValue; + + var result = new EntraSiloConnectionOptionsValidator().Validate(Options.DefaultName, options); + + Assert.False(result.Succeeded); + } + + [Fact] + public void TimeProviderAccessorReadsCurrentAuthenticationClock() + { + TimeProvider current = TimeProvider.System; + var accessor = new EntraTimeProviderAccessor(() => current); + var expected = new TestTimeProvider(); + + current = expected; + + Assert.Same(expected, accessor.Value); + } + + private sealed class TestTimeProvider : TimeProvider; +} diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraTestInfrastructure.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraTestInfrastructure.cs new file mode 100644 index 00000000000..fe2ececbfc1 --- /dev/null +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraTestInfrastructure.cs @@ -0,0 +1,261 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using Azure.Core; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Tokens; +using Orleans.Configuration; +using Orleans.Connections.Security.Entra; + +namespace Orleans.Connections.Security.Entra.Tests; + +internal sealed class EntraTestFixture : IDisposable +{ + public const string Audience = "api://orleans-silos"; + public const string ClientId = "11111111-1111-1111-1111-111111111111"; + public const string ClusterId = "cluster-a"; + public const string Issuer = "https://login.microsoftonline.com/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/v2.0"; + public const string ObjectId = "22222222-2222-2222-2222-222222222222"; + public const string Role = "Orleans.Silo.Connect"; + public const string TenantId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + private readonly List _keys = []; + + public EntraTestFixture() + { + TimeProvider = new TestTimeProvider(new DateTimeOffset(2026, 8, 7, 12, 0, 0, TimeSpan.Zero)); + Options = CreateOptions(); + Metadata = new TestDocumentRetriever(Options.Authority!); + CurrentKey = CreateKey("key-1"); + Metadata.SetConfiguration(Issuer, CurrentKey); + } + + public SigningCredentials CurrentKey { get; private set; } + + public TestDocumentRetriever Metadata { get; } + + public EntraSiloConnectionOptions Options { get; } + + public TestTimeProvider TimeProvider { get; } + + public static EntraSiloConnectionOptions CreateOptions( + string authority = Issuer, + string audience = Audience) + { + var options = new EntraSiloConnectionOptions + { + Authority = new Uri(authority), + TokenScope = $"{audience}/.default", + ClusterClaimType = "orleans_cluster", + MetadataRefreshJitterRatio = 0, + }; + options.ValidAudiences.Add(audience); + options.ValidTenantIds.Add(TenantId); + options.AllowedClientIds.Add(ClientId); + options.RequiredRoles.Add(Role); + return options; + } + + public SigningCredentials CreateKey(string keyId, string algorithm = SecurityAlgorithms.RsaSha256) + { + var rsa = RSA.Create(2048); + _keys.Add(rsa); + return new SigningCredentials(new RsaSecurityKey(rsa) { KeyId = keyId }, algorithm); + } + + public EntraJwtValidator CreateValidator() + { + var provider = new EntraOpenIdConfigurationProvider(Options, Metadata, TimeProvider, static () => 0); + return new EntraJwtValidator(Options, provider, TimeProvider); + } + + public string CreateToken( + string version = "2.0", + SigningCredentials? signingCredentials = null, + string issuer = Issuer, + string tenantId = TenantId, + string audience = Audience, + string clusterId = ClusterId, + string clientId = ClientId, + string objectId = ObjectId, + string identityType = "app", + string[]? roles = null, + string? scopes = null, + DateTimeOffset? notBefore = null, + DateTimeOffset? expires = null, + IDictionary? additionalClaims = null) + { + var claims = new Dictionary + { + ["tid"] = tenantId, + ["ver"] = version, + ["oid"] = objectId, + ["idtyp"] = identityType, + ["orleans_cluster"] = clusterId, + ["roles"] = roles ?? [Role], + }; + claims[version == "1.0" ? "appid" : "azp"] = clientId; + if (scopes is not null) + { + claims["scp"] = scopes; + } + + if (additionalClaims is not null) + { + foreach (var pair in additionalClaims) + { + claims[pair.Key] = pair.Value; + } + } + + var now = TimeProvider.GetUtcNow(); + var descriptor = new SecurityTokenDescriptor + { + Audience = audience, + Claims = claims, + Expires = (expires ?? now.AddMinutes(30)).UtcDateTime, + Issuer = issuer, + NotBefore = (notBefore ?? now.AddMinutes(-1)).UtcDateTime, + SigningCredentials = signingCredentials ?? CurrentKey, + }; + return new JsonWebTokenHandler().CreateToken(descriptor); + } + + public void RollMetadataTo(SigningCredentials key, string issuer = Issuer, string use = "sig", string[]? keyOperations = null) + { + CurrentKey = key; + Metadata.SetConfiguration(issuer, key, use, keyOperations); + } + + public void Dispose() + { + foreach (var key in _keys) + { + key.Dispose(); + } + } + + public static string CreateDuplicateClaimToken() + { + const string header = """{"alg":"RS256","kid":"key-1"}"""; + const string payload = """{"iss":"x","tid":"x","tid":"y","ver":"2.0","nbf":1,"exp":2,"aud":"x"}"""; + return $"{Base64UrlEncoder.Encode(header)}.{Base64UrlEncoder.Encode(payload)}.invalid"; + } + + public static string CreateMalformedToken(string payload) + { + const string header = """{"alg":"RS256","kid":"key-1"}"""; + return $"{Base64UrlEncoder.Encode(header)}.{Base64UrlEncoder.Encode(payload)}.invalid"; + } +} + +internal sealed class TestDocumentRetriever : IDocumentRetriever +{ + private readonly Uri _authority; + private readonly ConcurrentDictionary _documents = new(StringComparer.Ordinal); + private int _requestCount; + + public TestDocumentRetriever(Uri authority) + { + _authority = authority; + } + + public bool FailRequests { get; set; } + + public TimeSpan ResponseDelay { get; set; } + + public int RequestCount => Volatile.Read(ref _requestCount); + + public Task GetDocumentAsync(string address, CancellationToken cancel) + { + Interlocked.Increment(ref _requestCount); + if (FailRequests) + { + throw new InvalidOperationException("simulated outage"); + } + + return GetCoreAsync(address, cancel); + } + + public void SetConfiguration( + string issuer, + SigningCredentials signingCredentials, + string use = "sig", + string[]? keyOperations = null, + string? jwksUri = null) + { + var authority = _authority.AbsoluteUri.TrimEnd('/'); + var keysAddress = jwksUri ?? $"{authority}/keys"; + _documents[$"{authority}/.well-known/openid-configuration"] = + $$"""{"issuer":"{{issuer}}","jwks_uri":"{{keysAddress}}"}"""; + _documents[keysAddress] = CreateJwks(signingCredentials, use, keyOperations); + } + + private async Task GetCoreAsync(string address, CancellationToken cancellationToken) + { + if (ResponseDelay > TimeSpan.Zero) + { + await Task.Delay(ResponseDelay, cancellationToken); + } + + return _documents.TryGetValue(address, out var document) + ? document + : throw new InvalidOperationException("unknown metadata address"); + } + + private static string CreateJwks( + SigningCredentials signingCredentials, + string use, + string[]? keyOperations) + { + var key = (RsaSecurityKey)signingCredentials.Key; + var parameters = key.Rsa?.ExportParameters(includePrivateParameters: false) ?? key.Parameters; + var operations = keyOperations is null + ? string.Empty + : $$""","key_ops":{{System.Text.Json.JsonSerializer.Serialize(keyOperations)}}"""; + return $$""" + {"keys":[{"kty":"RSA","use":"{{use}}","kid":"{{key.KeyId}}","alg":"{{signingCredentials.Algorithm}}","n":"{{Base64UrlEncoder.Encode(parameters.Modulus)}}","e":"{{Base64UrlEncoder.Encode(parameters.Exponent)}}"{{operations}}}]} + """; + } +} + +internal sealed class TestTimeProvider(DateTimeOffset utcNow) : TimeProvider +{ + public DateTimeOffset UtcNow { get; private set; } = utcNow; + + public override DateTimeOffset GetUtcNow() => UtcNow; + + public void Advance(TimeSpan amount) => UtcNow += amount; +} + +internal sealed class TestTokenCredential(Func> getToken) + : TokenCredential +{ + public int CallCount { get; private set; } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => GetTokenAsync(requestContext, cancellationToken).AsTask().GetAwaiter().GetResult(); + + public override ValueTask GetTokenAsync( + TokenRequestContext requestContext, + CancellationToken cancellationToken) + { + CallCount++; + return getToken(requestContext, cancellationToken); + } +} + +internal sealed class TestHttpMessageHandler(Func send) : HttpMessageHandler +{ + public int RequestCount { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestCount++; + return Task.FromResult(send(request)); + } +} diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraTokenProviderTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraTokenProviderTests.cs new file mode 100644 index 00000000000..30f052a7608 --- /dev/null +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraTokenProviderTests.cs @@ -0,0 +1,39 @@ +using Azure.Core; +using Orleans.Connections.Security.Entra; + +namespace Orleans.Connections.Security.Entra.Tests; + +public sealed class EntraTokenProviderTests +{ + [Fact] + public async Task RequestsEachTokenFromCallerSuppliedCredential() + { + var options = EntraTestFixture.CreateOptions(); + var timeProvider = new TestTimeProvider(DateTimeOffset.UtcNow); + var credential = new TestTokenCredential( + (_, _) => ValueTask.FromResult(new AccessToken("token", timeProvider.GetUtcNow().AddMinutes(10)))); + var provider = new EntraTokenProvider(credential, options, timeProvider); + + await provider.GetTokenAsync(CancellationToken.None); + await provider.GetTokenAsync(CancellationToken.None); + + Assert.Equal(2, credential.CallCount); + } + + [Fact] + public async Task RejectsTokenWithInsufficientRemainingLifetimeWithoutLeakingIt() + { + const string token = "secret-bearer-token"; + var options = EntraTestFixture.CreateOptions(); + var timeProvider = new TestTimeProvider(DateTimeOffset.UtcNow); + var credential = new TestTokenCredential( + (_, _) => ValueTask.FromResult(new AccessToken(token, timeProvider.GetUtcNow().AddSeconds(30)))); + var provider = new EntraTokenProvider(credential, options, timeProvider); + + var exception = await Assert.ThrowsAsync( + () => provider.GetTokenAsync(CancellationToken.None).AsTask()); + + Assert.Equal(EntraAuthenticationError.TokenAcquisitionFailed, exception.Error); + Assert.DoesNotContain(token, exception.ToString(), StringComparison.Ordinal); + } +} diff --git a/test/Orleans.Connections.Security.Entra.Tests/Orleans.Connections.Security.Entra.Tests.csproj b/test/Orleans.Connections.Security.Entra.Tests/Orleans.Connections.Security.Entra.Tests.csproj new file mode 100644 index 00000000000..9c733f4be7c --- /dev/null +++ b/test/Orleans.Connections.Security.Entra.Tests/Orleans.Connections.Security.Entra.Tests.csproj @@ -0,0 +1,18 @@ + + + + $(TestTargetFrameworks) + true + enable + + + + + + + + + + + + diff --git a/test/Orleans.Connections.Security.Entra.Tests/Usings.cs b/test/Orleans.Connections.Security.Entra.Tests/Usings.cs new file mode 100644 index 00000000000..c802f4480b1 --- /dev/null +++ b/test/Orleans.Connections.Security.Entra.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs b/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs new file mode 100644 index 00000000000..13100377306 --- /dev/null +++ b/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs @@ -0,0 +1,153 @@ +using System.Security.Claims; +using Xunit; + +namespace Orleans.Connections.Security.Tests; + +public class SiloConnectionAuthenticationContractsTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ValidationResult_Success_PreservesPrincipalExpirationAndInvariants(bool hasExpiration) + { + var principal = new ClaimsPrincipal( + new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "silo-17")], "test-token")); + DateTimeOffset? expiration = hasExpiration + ? new DateTimeOffset(2031, 4, 5, 6, 7, 8, TimeSpan.Zero) + : null; + + var result = SiloConnectionTokenValidationResult.Success(principal, expiration); + + Assert.True(result.Succeeded); + Assert.Same(principal, result.Principal); + Assert.Equal(expiration, result.ExpiresAt); + Assert.Equal(SiloConnectionAuthenticationFailure.None, result.Failure); + } + + [Fact] + public void ValidationResult_Success_NullPrincipal_Throws() + { + var expiration = new DateTimeOffset(2031, 4, 5, 6, 7, 8, TimeSpan.Zero); + + var exception = Assert.Throws( + () => SiloConnectionTokenValidationResult.Success(null!, expiration)); + + Assert.Equal("principal", exception.ParamName); + } + + [Theory] + [InlineData(SiloConnectionAuthenticationFailure.MissingToken)] + [InlineData(SiloConnectionAuthenticationFailure.InvalidToken)] + [InlineData(SiloConnectionAuthenticationFailure.ExpiredToken)] + [InlineData(SiloConnectionAuthenticationFailure.UnauthorizedCaller)] + [InlineData(SiloConnectionAuthenticationFailure.ProviderUnavailable)] + [InlineData(SiloConnectionAuthenticationFailure.ValidationError)] + public void ValidationResult_Fail_MapsEveryBoundedFailure(SiloConnectionAuthenticationFailure failure) + { + var result = SiloConnectionTokenValidationResult.Fail(failure); + + Assert.False(result.Succeeded); + Assert.Null(result.Principal); + Assert.Null(result.ExpiresAt); + Assert.Equal(failure, result.Failure); + } + + [Fact] + public void ValidationResult_Fail_None_Throws() + { + var exception = Assert.Throws( + () => SiloConnectionTokenValidationResult.Fail(SiloConnectionAuthenticationFailure.None)); + + Assert.Equal("failure", exception.ParamName); + } + + [Fact] + public void Token_Record_PreservesValueAndExpiration() + { + var expiration = new DateTimeOffset(2032, 8, 9, 10, 11, 12, TimeSpan.Zero); + + var finite = new SiloConnectionToken("finite-token-value", expiration); + var nonExpiring = new SiloConnectionToken("non-expiring-token-value", null); + + Assert.Equal("finite-token-value", finite.Value); + Assert.Equal(expiration, finite.ExpiresAt); + Assert.Equal("non-expiring-token-value", nonExpiring.Value); + Assert.Null(nonExpiring.ExpiresAt); + Assert.NotEqual(finite, nonExpiring); + } +} + +public class SiloConnectionAuthenticationOptionsTests +{ + [Fact] + public void Defaults_AreSecureAndBounded() + { + var options = new SiloConnectionAuthenticationOptions(); + + Assert.Equal(SiloConnectionAuthenticationMode.Required, options.Mode); + Assert.Equal(TimeSpan.FromSeconds(10), options.TokenExchangeTimeout); + Assert.Equal(16 * 1024, options.MaxTokenSize); + Assert.Equal(256, options.MaxConcurrentInboundAuthentications); + Assert.Equal(256, options.MaxConcurrentOutboundAuthentications); + Assert.Equal(256, options.MaxPendingInboundAuthentications); + Assert.Equal(256, options.MaxPendingOutboundAuthentications); + Assert.Equal(TimeSpan.FromMinutes(2), options.MinimumRemainingTokenLifetime); + Assert.Equal(TimeSpan.FromSeconds(30), options.ExpirationSafetyMargin); + Assert.Equal(TimeSpan.FromSeconds(10), options.ExpirationJitter); + Assert.False(options.AllowNonExpiringCredentials); + Assert.Null(options.TargetHost); + Assert.Same(TimeProvider.System, options.TimeProvider); + } + + [Fact] + public void Properties_AreMutable() + { + var timeProvider = new TestTimeProvider(); + var options = new SiloConnectionAuthenticationOptions + { + Mode = SiloConnectionAuthenticationMode.Audit, + TokenExchangeTimeout = TimeSpan.FromSeconds(17), + MaxTokenSize = 32 * 1024, + MaxConcurrentInboundAuthentications = 37, + MaxConcurrentOutboundAuthentications = 41, + MaxPendingInboundAuthentications = 43, + MaxPendingOutboundAuthentications = 47, + MinimumRemainingTokenLifetime = TimeSpan.FromMinutes(7), + ExpirationSafetyMargin = TimeSpan.FromSeconds(53), + ExpirationJitter = TimeSpan.FromSeconds(11), + AllowNonExpiringCredentials = true, + TargetHost = "silo.internal.example", + TimeProvider = timeProvider, + }; + + Assert.Equal(SiloConnectionAuthenticationMode.Audit, options.Mode); + Assert.Equal(TimeSpan.FromSeconds(17), options.TokenExchangeTimeout); + Assert.Equal(32 * 1024, options.MaxTokenSize); + Assert.Equal(37, options.MaxConcurrentInboundAuthentications); + Assert.Equal(41, options.MaxConcurrentOutboundAuthentications); + Assert.Equal(43, options.MaxPendingInboundAuthentications); + Assert.Equal(47, options.MaxPendingOutboundAuthentications); + Assert.Equal(TimeSpan.FromMinutes(7), options.MinimumRemainingTokenLifetime); + Assert.Equal(TimeSpan.FromSeconds(53), options.ExpirationSafetyMargin); + Assert.Equal(TimeSpan.FromSeconds(11), options.ExpirationJitter); + Assert.True(options.AllowNonExpiringCredentials); + Assert.Equal("silo.internal.example", options.TargetHost); + Assert.Same(timeProvider, options.TimeProvider); + } + + private sealed class TestTimeProvider : TimeProvider + { + } +} + +public class SiloConnectionAuthenticationProtocolTests +{ + [Fact] + public void Version2_IsExpectedAlpnIdentifier() + { + Assert.Equal( + "Orleans1+TokenAuth2", + SiloConnectionAuthenticationProtocol.Version2, + StringComparer.Ordinal); + } +} diff --git a/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs b/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs index 9ac92a99d10..4b5be6fe01b 100644 --- a/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs +++ b/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs @@ -38,6 +38,23 @@ namespace Orleans.Connections.Security.Tests [TestArea("Security")] public class TlsConnectionTests { + [Fact] + public void UseGatewayTls_ThrowsWhenConfiguredMoreThanOnce() + { + var builder = Host.CreateApplicationBuilder(); + + builder.UseOrleans(siloBuilder => + { + siloBuilder.UseGatewayTls(options => options.LocalServerCertificateSelector = static (_, _) => null!); + + var exception = Assert.Throws( + () => siloBuilder.UseGatewayTls( + options => options.LocalServerCertificateSelector = static (_, _) => null!)); + + Assert.Equal("Gateway TLS has already been configured.", exception.Message); + }); + } + private const string CertificateSubjectName = "fakedomain.faketld"; private const string CertificateConfigKey = "certificate"; private const string ClientCertificateModeKey = "CertificateMode"; From 9be029f47e648e5f695b9aae55f4d08b168da0d9 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Fri, 7 Aug 2026 15:35:56 -0700 Subject: [PATCH 03/22] docs: add authenticated silo connection sample Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1a69ae4d-a036-4ccd-9a33-1e97fd1378cf --- .../host/authenticated-silo-connections.md | 216 ++++++++++++++++++ ...thenticatedSiloConnections.Snippets.csproj | 27 +++ .../docs/host/transport-layer-security.md | 1 + docs/site/src/content/docs/toc.yml | 2 + .../docs/tutorials-and-samples/index.md | 1 + .../AuthenticatedSiloConnections.csproj | 24 ++ .../AuthenticatedSiloConnections/Program.cs | 67 ++++++ .../AuthenticatedSiloConnections/README.md | 62 +++++ .../SampleOptions.cs | 168 ++++++++++++++ .../SiloAuthentication.cs | 125 ++++++++++ .../appsettings.json | 27 +++ samples/README.md | 1 + samples/Samples.slnx | 3 + samples/gallery.json | 11 + 14 files changed, 735 insertions(+) create mode 100644 docs/site/src/content/docs/host/authenticated-silo-connections.md create mode 100644 docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/AuthenticatedSiloConnections.Snippets.csproj create mode 100644 samples/AuthenticatedSiloConnections/AuthenticatedSiloConnections.csproj create mode 100644 samples/AuthenticatedSiloConnections/Program.cs create mode 100644 samples/AuthenticatedSiloConnections/README.md create mode 100644 samples/AuthenticatedSiloConnections/SampleOptions.cs create mode 100644 samples/AuthenticatedSiloConnections/SiloAuthentication.cs create mode 100644 samples/AuthenticatedSiloConnections/appsettings.json diff --git a/docs/site/src/content/docs/host/authenticated-silo-connections.md b/docs/site/src/content/docs/host/authenticated-silo-connections.md new file mode 100644 index 00000000000..968decf8ce9 --- /dev/null +++ b/docs/site/src/content/docs/host/authenticated-silo-connections.md @@ -0,0 +1,216 @@ +--- +title: Authenticate Orleans silo connections +description: Authenticate silo-to-silo connections with TLS and Microsoft Entra workload identities. +ms.date: 08/07/2026 +ms.topic: how-to +--- + +# Authenticate Orleans silo connections + +Authenticated silo connections verify the workload identity of a connecting +silo before Orleans reads its connection preamble or application messages. Use + +to configure TLS and bearer-token authentication as one ordered policy. + +> [!IMPORTANT] +> This feature applies only to silo-to-silo connections. Client-to-gateway +> behavior is unchanged. Secure gateway traffic with the existing +> [TLS](transport-layer-security.md) and application authentication mechanisms. + +Install `Microsoft.Orleans.Connections.Security` and +`Microsoft.Orleans.Connections.Security.Entra` in every silo. The Entra package +acquires and validates tokens, including metadata and signing-key rollover. +Don't copy JWT parsing or validation logic into the application. + +## Understand the security boundary + +The connection pipeline is: + +```text +TCP + -> TLS handshake and ALPN negotiation + -> bearer-token exchange + -> Orleans connection preamble + -> Orleans messages +``` + +TLS protects the bearer token in transit and authenticates the TLS server. The +token authenticates and authorizes the connecting workload. Membership still +determines which silos make up the cluster; connection authentication doesn't +replace membership, authorize individual grain calls, propagate end-user +identity, or prove that a workload owns the exact `SiloAddress` it claims. + +The design protects against network peers without an authorized workload +credential, unauthenticated downgrade in enforcement mode, cross-cluster token +reuse, and malformed or excessively concurrent authentication exchanges. It +doesn't protect against compromise of an authorized silo, theft and replay of a +bearer token before expiration, or compromise of a trusted CA, identity +provider, signing key, or host. Use short-lived tokens, workload isolation, +network policy, and optionally mTLS to reduce the remaining risk. + +## Bind authorization to one cluster and environment + +Audience validation alone isn't caller authorization. Configure all of the +following: + +1. A tenant-specific authority. +2. A dedicated audience for one cluster and deployment environment, such as + `api:///contoso-prod-westus`. +3. The application role `Orleans.Silo.Connect`. +4. An explicit allowlist of caller application IDs. + +The audience must exactly match the resource identifier registered in Microsoft +Entra. Don't remove the `api://` prefix or share a general-purpose silo audience +across environments. If the audience must be shared, require a separate +cluster-specific claim or role and compare it exactly to the local `ClusterId`. + +Prefer an explicit appropriate to the hosting +environment. The maintained sample supplies a `WorkloadIdentityCredential`; it +doesn't silently use a developer or unrelated cached identity: + +:::code language="csharp" source="../../../../../../samples/AuthenticatedSiloConnections/Program.cs" id="ExplicitCredential"::: + +Create the credential once and reuse it. The credential implementation owns its +token cache. + +## Configure TLS and Entra authentication + +The sample configures mTLS, platform chain and DNS-name validation, online +revocation checking, and an additional private-root pin. The root allowlist +supports overlap during CA rotation. Each silo certificate therefore needs +both the Server Authentication and Client Authentication EKUs. + +:::code language="csharp" source="../../../../../../samples/AuthenticatedSiloConnections/SiloAuthentication.cs" id="AuthenticatedSiloConnections"::: + +The certificate callback accepts only `SslPolicyErrors.None`, so the configured +`TargetHost` must match a DNS SAN and the chain must be valid and trusted. It +then narrows trust to an explicitly configured root. Never replace this policy +with +or an unconditional callback. `Required` mode rejects permissive certificate +validation during startup. + +The example deliberately bounds token bytes, exchange duration, concurrent +handshakes, and minimum remaining token lifetime. Keep all size, duration, +queue, concurrency, metadata-refresh, and token-lifetime limits finite. +Configuration is validated at startup; invalid middleware ordering, missing +TLS/provider registrations, and conflicting TLS policies fail closed. + +## Choose an enforcement mode + + is +snapshotted at startup. Changing it requires a silo restart. + +| Mode | Negotiation and acceptance behavior | +|---|---| +| `Disabled` | Advertises only the baseline Orleans protocol and doesn't exchange authentication frames. | +| `Audit` | Prefers authentication, permits baseline negotiation with an older or disabled peer, and accepts measured authentication failures. | +| `Required` | Advertises only the authentication protocol and accepts only a successful authenticated result with a principal and finite expiration. | + +`Required` has no unauthenticated fallback. A `Required` silo and an old or +disabled silo have no common ALPN protocol, so TLS negotiation fails. A +`Required` outbound peer also rejects an Audit result which was accepted but +isn't authenticated. + +After peers negotiate the authentication ALPN, framing, token acquisition, +validation, authorization, acknowledgment, timeout, or provider failures abort +the connection in every mode. `Audit` can fall back only when TLS negotiated +the baseline ALPN with a peer which doesn't support authentication; it can't +reinterpret a failed authentication exchange as baseline Orleans traffic. + +## Plan for token expiration + +Authentication occurs once per connection, but a silo connection can otherwise +outlive its access token. In `Required` mode, Orleans uses the validator's +finite expiration and recycles the connection before expiry using a safety +margin and bounded jitter. Reconnection acquires a new token through the +caller-supplied credential. + +The provider's expiration is advisory; it can't extend the expiration validated +by the receiving silo. Tokens without a finite expiration are rejected in +`Required` unless non-expiring credentials were explicitly enabled. Monitor +recycling before enforcement so a credential, metadata, or network problem +doesn't surface only when many connections approach expiration. + +## Roll out safely + +Define gates and ownership before changing modes: + +1. Deploy the code everywhere with `Disabled` and restart the fleet. +2. Restart by failure domain with `Audit`. Retain canaries and monitor baseline + fallback, acquisition and validation failures, authorization denials, + provider availability, latency, concurrency saturation, and metadata + refresh. +3. Remain in `Audit` until every expected silo pair has negotiated + authentication, unexpected fallback and failure rates are zero for at least + one configured maximum connection lifetime, and representative canaries + have recycled connections at token expiry. +4. Restart `Required` canaries. Verify connectivity, membership stability, + token renewal, and provider health before proceeding through each failure + domain. + +Use rates and denominators rather than raw cumulative counts for promotion +decisions. An identity-provider or metadata outage must not automatically +downgrade the cluster. + +### Roll back + +Don't roll directly from `Required` to `Disabled` one silo at a time; those +modes have no common ALPN. Use this fleet-wide, restart-based sequence: + +```text +Required -> Audit across the fleet -> Disabled across the fleet +``` + +`Required` and `Audit` share the authentication ALPN, so the first step remains +wire-compatible. Document who may authorize the downgrade, how restarts are +coordinated, and the maximum accepted exposure window in `Audit`. + +## Monitor authentication + +Export the `Microsoft.Orleans` meter. The maintained sample enables an OTLP +exporter when `OTEL_EXPORTER_OTLP_ENDPOINT` is set: + +:::code language="csharp" source="../../../../../../samples/AuthenticatedSiloConnections/Program.cs" id="FixedDiagnostics"::: + +Alert on rates and latency for these instruments: + +| Instrument | Operational use | +|---|---| +| `orleans.connections.authentication.attempts` | Count outcomes by fixed result category. | +| `orleans.connections.authentication.duration` | Detect token-provider, metadata, validation, or network latency. | +| `orleans.connections.authentication.active` | Detect handshake concurrency saturation. | +| `orleans.connections.authentication.protocol_fallbacks` | Identify peers which haven't negotiated authentication in `Audit`. | + +Keep dimensions bounded to direction, mode, protocol version, and fixed result +category. Never add token, tenant, client, object, issuer, endpoint, or arbitrary +exception values as metric tags. + +Authentication logs use fixed event IDs and bounded categories such as +overload, timeout, protocol error, TLS policy error, acquisition failure, +validation failure, authorization failure, and expiration. Preserve event ID, +category, direction, and mode in the log pipeline. Tokens must never appear in +logs, traces, metrics, activities, exceptions, or connection features. + +## Production checklist + +- Use an explicit workload credential and keep its federated token or secret + material out of source and ordinary configuration. +- Give each cluster/environment an exact audience and require both a role and + caller allowlist. +- Keep TLS 1.2 or later, certificate chain/name checks, revocation policy, and + narrow trust roots enabled. +- Bound token, timeout, concurrency, queue, metadata refresh, and token lifetime + settings. +- Restrict silo and gateway ports with network policy. +- Synchronize clocks and exercise certificate, key, and identity rotation. +- Treat unexpected baseline fallback in `Audit` and every authentication + failure in `Required` as an operational event. + +## See also + +- [Authenticated silo connections sample](https://github.com/dotnet/orleans/tree/main/samples/AuthenticatedSiloConnections) +- [Secure Orleans connections with TLS](transport-layer-security.md) +- [Monitor an Orleans application](monitoring/index.md) +- +- [Azure Identity client library for .NET](https://learn.microsoft.com/dotnet/azure/sdk/authentication/) +- [Application roles in Microsoft Entra ID](https://learn.microsoft.com/entra/identity-platform/howto-add-app-roles-in-apps) diff --git a/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/AuthenticatedSiloConnections.Snippets.csproj b/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/AuthenticatedSiloConnections.Snippets.csproj new file mode 100644 index 00000000000..c85d2104df8 --- /dev/null +++ b/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/AuthenticatedSiloConnections.Snippets.csproj @@ -0,0 +1,27 @@ + + + Exe + net10.0 + enable + enable + false + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\..\..\..\..\..\..\..\')) + + + + + + + + + + + + + + + + + + + diff --git a/docs/site/src/content/docs/host/transport-layer-security.md b/docs/site/src/content/docs/host/transport-layer-security.md index fe8fb9cb0a7..a44d356c019 100644 --- a/docs/site/src/content/docs/host/transport-layer-security.md +++ b/docs/site/src/content/docs/host/transport-layer-security.md @@ -125,6 +125,7 @@ Certificate selectors are called during authentication, but certificate loading, - [Network hardening](../security/networking.md) - - +- [Authenticate Orleans silo connections](authenticated-silo-connections.md) - [Client configuration](configuration-guide/client-configuration.md) - [Server configuration](configuration-guide/server-configuration.md) - [.NET TLS/SSL best practices](https://learn.microsoft.com/dotnet/core/extensions/sslstream-best-practices) diff --git a/docs/site/src/content/docs/toc.yml b/docs/site/src/content/docs/toc.yml index ac0f03b8883..27b9d496b8c 100644 --- a/docs/site/src/content/docs/toc.yml +++ b/docs/site/src/content/docs/toc.yml @@ -220,6 +220,8 @@ items: href: host/transport-layer-security.md - name: Connection middleware href: host/connection-middleware.md + - name: Authenticate Orleans connections + href: host/authenticated-silo-connections.md - name: Configuration items: - name: Overview diff --git a/docs/site/src/content/docs/tutorials-and-samples/index.md b/docs/site/src/content/docs/tutorials-and-samples/index.md index 6c1ee34cb44..c065f7d5e68 100644 --- a/docs/site/src/content/docs/tutorials-and-samples/index.md +++ b/docs/site/src/content/docs/tutorials-and-samples/index.md @@ -79,6 +79,7 @@ The Azure Blob JSON sample uses the experimental `Microsoft.Orleans.Journaling` | --- | --- | | [Azure Container Apps](https://github.com/dotnet/orleans/tree/main/samples/Deployment/AzureContainerApps) | A cluster, clients, dashboard, scaler, and Bicep deployment. | | [Azure App Service](https://github.com/dotnet/orleans/tree/main/samples/Deployment/AzureAppService) | A multi-instance Orleans application on App Service. | +| [Authenticated Silo Connections](https://github.com/dotnet/orleans/tree/main/samples/AuthenticatedSiloConnections) | TLS and Microsoft Entra workload authentication for silo connections. | | [Transport Layer Security](https://github.com/dotnet/orleans/tree/main/samples/TransportLayerSecurity) | Mutual TLS for Orleans network communication. | | [Voting](https://github.com/dotnet/orleans/tree/main/samples/Voting) | Kubernetes-oriented deployment and the Orleans Dashboard. | diff --git a/samples/AuthenticatedSiloConnections/AuthenticatedSiloConnections.csproj b/samples/AuthenticatedSiloConnections/AuthenticatedSiloConnections.csproj new file mode 100644 index 00000000000..c03a6f9c780 --- /dev/null +++ b/samples/AuthenticatedSiloConnections/AuthenticatedSiloConnections.csproj @@ -0,0 +1,24 @@ + + + Exe + net10.0 + enable + enable + true + + + + + + + + + + + + + + + + + diff --git a/samples/AuthenticatedSiloConnections/Program.cs b/samples/AuthenticatedSiloConnections/Program.cs new file mode 100644 index 00000000000..3f1503db4be --- /dev/null +++ b/samples/AuthenticatedSiloConnections/Program.cs @@ -0,0 +1,67 @@ +using AuthenticatedSiloConnections; +using Azure.Core; +using Azure.Identity; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; + +var builder = Host.CreateApplicationBuilder(args); +var options = SampleOptions.Load(builder.Configuration); +var exportToOtlp = !string.IsNullOrWhiteSpace( + builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + +// +builder.Logging.ClearProviders(); +builder.Logging.AddJsonConsole(console => +{ + console.TimestampFormat = "O"; + console.JsonWriterOptions = new() { Indented = false }; +}); +builder.Logging.AddFilter("Orleans.Connections.Security", LogLevel.Information); +builder.Logging.AddFilter("Azure.Identity", LogLevel.Warning); + +builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService( + serviceName: "authenticated-orleans-silo", + serviceInstanceId: Environment.MachineName)) + .WithMetrics(metrics => + { + metrics.AddMeter("Microsoft.Orleans"); + + if (exportToOtlp) + { + metrics.AddOtlpExporter(); + } + }); +// + +// +TokenCredential credential = new WorkloadIdentityCredential( + new WorkloadIdentityCredentialOptions + { + TenantId = options.Entra.TenantId, + ClientId = options.Entra.WorkloadClientId, + TokenFilePath = options.Entra.FederatedTokenFile, + }); +// + +using var siloCertificate = CertificatePolicy.LoadSiloCertificate( + options.Certificate.Path, + options.Certificate.Password); + +builder.UseOrleans(siloBuilder => +{ + siloBuilder.UseLocalhostClustering( + siloPort: options.SiloPort, + gatewayPort: options.GatewayPort, + primarySiloEndpoint: options.PrimarySiloEndpoint, + serviceId: options.ServiceId, + clusterId: options.ClusterId); + + SiloAuthentication.Configure( + siloBuilder, + options, + credential, + siloCertificate); +}); + +await builder.Build().RunAsync(); diff --git a/samples/AuthenticatedSiloConnections/README.md b/samples/AuthenticatedSiloConnections/README.md new file mode 100644 index 00000000000..74eb4550eb3 --- /dev/null +++ b/samples/AuthenticatedSiloConnections/README.md @@ -0,0 +1,62 @@ +# Authenticated silo connections + +This sample configures mutual TLS (mTLS) and Microsoft Entra workload +authentication for silo-to-silo connections. It uses an explicit +`WorkloadIdentityCredential`; it doesn't construct `DefaultAzureCredential` or +copy JWT validation logic into the application. + +The sample is a two-process localhost cluster. Start one process with the +default ports, then start another with +`OrleansSecurity__SiloPort=11112` and +`OrleansSecurity__GatewayPort=30001`. Both processes use the primary silo port +`11111`. + +## Configure Microsoft Entra + +1. Register a resource application for the cluster security boundary. +2. Configure the identifier URI + `api:///`. The cluster ID includes the + deployment environment, for example `contoso-prod-westus`. +3. Define the application role `Orleans.Silo.Connect` and allow applications + as members. +4. Assign the role to each authorized silo workload identity. +5. Configure a federated identity credential for each workload and set its + application ID in `AllowedCallerClientIds`. + +The exact audience, tenant, application-token classification, caller +application ID, and application role are validated by +`Microsoft.Orleans.Connections.Security.Entra`. Don't replace that package with +sample-owned JWT parsing or validation. + +## Configure TLS + +Provide a PFX whose certificate has the Server Authentication and Client +Authentication EKUs and a DNS SAN matching `Certificate:TargetHost`. Install +the issuing private root in the operating-system trust store, then configure +its SHA-256 fingerprint. The sample requires successful platform chain, +validity, EKU, and revocation checks in both directions, requires the outbound +DNS-name check, and additionally pins the expected private root. Add the old +and new root fingerprints during CA rotation. + +Supply the PFX password through a secret provider or the environment variable +`OrleansSecurity__Certificate__Password`; don't store it in `appsettings.json`. + +## Run and observe + +Use environment variables or a secret-aware configuration provider to replace +every placeholder in `appsettings.json`. Set `OTEL_EXPORTER_OTLP_ENDPOINT` to +export the `Microsoft.Orleans` meter. Structured console logs preserve the +runtime's fixed event IDs and bounded authentication result categories. + +Start in `Audit` mode. Proceed to `Required` only after every expected silo pair +has used the authentication protocol, baseline fallback and unexpected failure +rates remain zero for at least one maximum connection lifetime, and token +expiry recycling succeeds. Changing modes requires a restart. + +`Required` has no unauthenticated fallback. Roll back fleet-wide from +`Required` to `Audit`, and only then from `Audit` to `Disabled`. Never +automatically weaken the mode because Microsoft Entra or metadata is +unavailable. + +Client-to-gateway authentication is unchanged. Secure gateway traffic +separately with the existing TLS and application authentication mechanisms. diff --git a/samples/AuthenticatedSiloConnections/SampleOptions.cs b/samples/AuthenticatedSiloConnections/SampleOptions.cs new file mode 100644 index 00000000000..c149fb1c5cc --- /dev/null +++ b/samples/AuthenticatedSiloConnections/SampleOptions.cs @@ -0,0 +1,168 @@ +using System.Net; +using Microsoft.Extensions.Configuration; +using Orleans.Connections.Security; + +namespace AuthenticatedSiloConnections; + +internal sealed class SampleOptions +{ + public const string SectionName = "OrleansSecurity"; + + public string ServiceId { get; set; } = "authenticated-silo-sample"; + + public string ClusterId { get; set; } = ""; + + public int SiloPort { get; set; } = 11111; + + public int GatewayPort { get; set; } = 30000; + + public int PrimarySiloPort { get; set; } = 11111; + + public SiloConnectionAuthenticationMode AuthenticationMode { get; set; } + = SiloConnectionAuthenticationMode.Audit; + + public CertificateOptions Certificate { get; set; } = new(); + + public EntraOptions Entra { get; set; } = new(); + + public IPEndPoint PrimarySiloEndpoint + => new(IPAddress.Loopback, PrimarySiloPort); + + public static SampleOptions Load(IConfiguration configuration) + { + var result = configuration + .GetRequiredSection(SectionName) + .Get() + ?? throw new InvalidOperationException( + $"Configuration section '{SectionName}' is required."); + + result.Validate(); + return result; + } + + private void Validate() + { + RequireValue(ServiceId, nameof(ServiceId)); + RequireValue(ClusterId, nameof(ClusterId)); + ValidatePort(SiloPort, nameof(SiloPort)); + ValidatePort(GatewayPort, nameof(GatewayPort)); + ValidatePort(PrimarySiloPort, nameof(PrimarySiloPort)); + Certificate.Validate(); + Entra.Validate(ClusterId); + } + + private static void ValidatePort(int value, string name) + { + if (value is < IPEndPoint.MinPort or > IPEndPoint.MaxPort) + { + throw new InvalidOperationException($"{name} is outside the valid port range."); + } + } + + internal static void RequireValue(string? value, string name) + { + if (string.IsNullOrWhiteSpace(value) + || value.Contains('<') + || value.Contains('>')) + { + throw new InvalidOperationException( + $"{SectionName}:{name} must be explicitly configured."); + } + } +} + +internal sealed class CertificateOptions +{ + public string Path { get; set; } = ""; + + public string? Password { get; set; } + + public string TargetHost { get; set; } = ""; + + public string[] TrustedRootSha256Fingerprints { get; set; } = []; + + public void Validate() + { + SampleOptions.RequireValue(Path, "Certificate:Path"); + SampleOptions.RequireValue(TargetHost, "Certificate:TargetHost"); + + if (!File.Exists(Path)) + { + throw new InvalidOperationException( + "The configured silo certificate file does not exist."); + } + + if (TrustedRootSha256Fingerprints.Length == 0) + { + throw new InvalidOperationException( + "At least one trusted root SHA-256 fingerprint is required."); + } + + _ = CertificatePolicy.ParseSha256Fingerprints( + TrustedRootSha256Fingerprints); + } +} + +internal sealed class EntraOptions +{ + public string TenantId { get; set; } = ""; + + public string ResourceApplicationId { get; set; } = ""; + + public string WorkloadClientId { get; set; } = ""; + + public string FederatedTokenFile { get; set; } = ""; + + public string[] AllowedCallerClientIds { get; set; } = []; + + public Uri Authority + => new($"https://login.microsoftonline.com/{TenantId}/v2.0"); + + public string Audience + => $"api://{ResourceApplicationId}/{_clusterId}"; + + private string _clusterId = ""; + + public void Validate(string clusterId) + { + _clusterId = clusterId; + RequireGuid(TenantId, nameof(TenantId)); + RequireGuid(ResourceApplicationId, nameof(ResourceApplicationId)); + RequireGuid(WorkloadClientId, nameof(WorkloadClientId)); + SampleOptions.RequireValue(FederatedTokenFile, nameof(FederatedTokenFile)); + + if (!File.Exists(FederatedTokenFile)) + { + throw new InvalidOperationException( + "The configured workload identity token file does not exist."); + } + + if (AllowedCallerClientIds.Length == 0) + { + throw new InvalidOperationException( + "At least one allowed caller application ID is required."); + } + + foreach (var clientId in AllowedCallerClientIds) + { + RequireGuid(clientId, nameof(AllowedCallerClientIds)); + } + + if (!AllowedCallerClientIds.Contains( + WorkloadClientId, + StringComparer.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "This silo's workload client ID must be in the allowed caller list."); + } + } + + private static void RequireGuid(string value, string name) + { + if (!Guid.TryParseExact(value, "D", out _)) + { + throw new InvalidOperationException( + $"{SampleOptions.SectionName}:Entra:{name} must be a GUID."); + } + } +} diff --git a/samples/AuthenticatedSiloConnections/SiloAuthentication.cs b/samples/AuthenticatedSiloConnections/SiloAuthentication.cs new file mode 100644 index 00000000000..3a9429d0b7f --- /dev/null +++ b/samples/AuthenticatedSiloConnections/SiloAuthentication.cs @@ -0,0 +1,125 @@ +using System.Net.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Azure.Core; +using Orleans.Connections.Security; +using Orleans.Connections.Security.Entra; +using Orleans.Hosting; + +namespace AuthenticatedSiloConnections; + +internal static class SiloAuthentication +{ + public static void Configure( + ISiloBuilder siloBuilder, + SampleOptions options, + TokenCredential credential, + X509Certificate2 siloCertificate) + { + var trustedRoots = CertificatePolicy.ParseSha256Fingerprints( + options.Certificate.TrustedRootSha256Fingerprints); + + // + siloBuilder.UseAuthenticatedSiloConnections( + tls => + { + tls.LocalCertificate = siloCertificate; + tls.RemoteCertificateMode = RemoteCertificateMode.RequireCertificate; + tls.ClientCertificateMode = RemoteCertificateMode.RequireCertificate; + tls.CheckCertificateRevocation = true; + tls.OnAuthenticateAsClient = (_, sslOptions) => + { + sslOptions.TargetHost = options.Certificate.TargetHost; + sslOptions.CertificateRevocationCheckMode = + X509RevocationMode.Online; + }; + tls.RemoteCertificateValidation = (certificate, chain, errors) => + CertificatePolicy.ValidateRemoteCertificate( + certificate, + chain, + errors, + trustedRoots); + }, + authentication => + { + authentication.Mode = options.AuthenticationMode; + authentication.TokenExchangeTimeout = TimeSpan.FromSeconds(10); + authentication.MaxTokenSize = 16 * 1024; + authentication.MaxConcurrentHandshakes = 256; + authentication.MinimumRemainingTokenLifetime = + TimeSpan.FromMinutes(2); + + authentication.UseEntra( + credential, + entra => + { + entra.Authority = options.Entra.Authority; + entra.TokenScope = $"{options.Entra.Audience}/.default"; + entra.ValidAudiences.Add(options.Entra.Audience); + entra.ValidTenantIds.Add(options.Entra.TenantId); + + foreach (var clientId in options.Entra.AllowedCallerClientIds) + { + entra.AllowedClientIds.Add(clientId); + } + + entra.RequiredRoles.Add("Orleans.Silo.Connect"); + }); + }); + // + } +} + +internal static class CertificatePolicy +{ + public static X509Certificate2 LoadSiloCertificate( + string path, + string? password) + => X509CertificateLoader.LoadPkcs12FromFile( + path, + password, + X509KeyStorageFlags.EphemeralKeySet); + + public static byte[][] ParseSha256Fingerprints(IEnumerable values) + => values.Select(value => + { + var normalized = value.Replace(":", "", StringComparison.Ordinal); + if (normalized.Length != 64) + { + throw new InvalidOperationException( + "Every trusted root fingerprint must contain 32 SHA-256 bytes."); + } + + try + { + return Convert.FromHexString(normalized); + } + catch (FormatException exception) + { + throw new InvalidOperationException( + "A trusted root fingerprint is not hexadecimal.", + exception); + } + }).ToArray(); + + public static bool ValidateRemoteCertificate( + X509Certificate2 certificate, + X509Chain? chain, + SslPolicyErrors errors, + IReadOnlyList trustedRootFingerprints) + { + if (errors != SslPolicyErrors.None + || chain is null + || chain.ChainElements.Count == 0) + { + return false; + } + + var root = chain.ChainElements[^1].Certificate; + var rootFingerprint = root.GetCertHash(HashAlgorithmName.SHA256); + return trustedRootFingerprints.Any( + expected => CryptographicOperations.FixedTimeEquals( + expected, + rootFingerprint)); + } +} diff --git a/samples/AuthenticatedSiloConnections/appsettings.json b/samples/AuthenticatedSiloConnections/appsettings.json new file mode 100644 index 00000000000..bd7c7d81ccf --- /dev/null +++ b/samples/AuthenticatedSiloConnections/appsettings.json @@ -0,0 +1,27 @@ +{ + "OrleansSecurity": { + "ServiceId": "authenticated-silo-sample", + "ClusterId": "contoso-prod-westus", + "SiloPort": 11111, + "GatewayPort": 30000, + "PrimarySiloPort": 11111, + "AuthenticationMode": "Audit", + "Certificate": { + "Path": "", + "Password": "", + "TargetHost": "orleans-silo.contoso.internal", + "TrustedRootSha256Fingerprints": [ + "" + ] + }, + "Entra": { + "TenantId": "", + "ResourceApplicationId": "", + "WorkloadClientId": "", + "FederatedTokenFile": "", + "AllowedCallerClientIds": [ + "" + ] + } + } +} diff --git a/samples/README.md b/samples/README.md index d0323eb8f5c..5101e45d296 100644 --- a/samples/README.md +++ b/samples/README.md @@ -36,6 +36,7 @@ The command checks the gallery manifest and builds every project in `Samples.sln | --- | --- | --- | --- | --- | | [Adventure](Adventure) | A text adventure game demonstrating grains, external clients, and application modeling. | C# | games, clients, grains | [dotnet/samples](https://github.com/dotnet/samples) | | [AWS Kinesis and DynamoDB](AWS/KinesisDynamoDB) | An AWS-hosted Orleans application using DynamoDB for clustering, persistence, reminders, and Kinesis checkpoints. | C# | aws, kinesis, dynamodb, streaming | [dotnet/orleans](https://github.com/dotnet/orleans) | +| [Authenticated Silo Connections](AuthenticatedSiloConnections) | A silo cluster using TLS and Microsoft Entra workload authentication for silo connections. | C# | security, tls, entra, networking | [dotnet/orleans](https://github.com/dotnet/orleans) | | [Bank Account](BankAccount) | A bank transfer simulation demonstrating ACID transactions across stateful grains. | C# | transactions, persistence | [dotnet/samples](https://github.com/dotnet/samples) | | [Basic Clustering](BasicClustering) | A minimal Aspire-hosted Orleans cluster with two silo replicas and Redis membership. | C# | clustering, aspire, redis, getting-started | [dotnet/orleans](https://github.com/dotnet/orleans) | | [Blazor Server](Blazor/BlazorServer) | An interactive Blazor Server application backed by Orleans grains. | C#, Razor | blazor, aspnet-core, web | [dotnet/samples](https://github.com/dotnet/samples) | diff --git a/samples/Samples.slnx b/samples/Samples.slnx index f37c069acea..de7a5c571b8 100644 --- a/samples/Samples.slnx +++ b/samples/Samples.slnx @@ -8,6 +8,9 @@ + + + diff --git a/samples/gallery.json b/samples/gallery.json index be6c5f2639a..57a6cd1eb4b 100644 --- a/samples/gallery.json +++ b/samples/gallery.json @@ -21,6 +21,17 @@ "tags": ["aws", "kinesis", "dynamodb", "streaming"], "featured": false }, + { + "slug": "authenticated-silo-connections", + "title": "Authenticated Silo Connections", + "description": "A silo cluster using TLS and Microsoft Entra workload authentication for silo connections.", + "path": "AuthenticatedSiloConnections", + "sourceRepository": "https://github.com/dotnet/orleans", + "image": null, + "languages": ["C#"], + "tags": ["security", "tls", "entra", "networking"], + "featured": false + }, { "slug": "bank-account", "title": "Bank Account", From be3011e2d01cd8a8354d312a81f33bd0f723ba80 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Fri, 7 Aug 2026 16:10:51 -0700 Subject: [PATCH 04/22] fix(docs): align authenticated silo sample Use the finalized bounded authentication options, configure cluster audience binding and the security meter, and keep Required mode on built-in certificate validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e71c4ddd-5362-4204-910f-9a742ddd63de --- .../host/authenticated-silo-connections.md | 45 ++++++------ .../AuthenticatedSiloConnections/Program.cs | 6 +- .../AuthenticatedSiloConnections/README.md | 25 +++---- .../SampleOptions.cs | 11 --- .../SiloAuthentication.cs | 68 ++----------------- .../appsettings.json | 5 +- 6 files changed, 50 insertions(+), 110 deletions(-) diff --git a/docs/site/src/content/docs/host/authenticated-silo-connections.md b/docs/site/src/content/docs/host/authenticated-silo-connections.md index 968decf8ce9..e0352b81acd 100644 --- a/docs/site/src/content/docs/host/authenticated-silo-connections.md +++ b/docs/site/src/content/docs/host/authenticated-silo-connections.md @@ -75,19 +75,19 @@ token cache. ## Configure TLS and Entra authentication -The sample configures mTLS, platform chain and DNS-name validation, online -revocation checking, and an additional private-root pin. The root allowlist -supports overlap during CA rotation. Each silo certificate therefore needs -both the Server Authentication and Client Authentication EKUs. +The sample configures mTLS, platform chain and DNS-name validation, and online +revocation checking. Install only the expected public or private roots in the +platform trust store and overlap old and new roots there during CA rotation. +Each silo certificate therefore needs both the Server Authentication and +Client Authentication EKUs. :::code language="csharp" source="../../../../../../samples/AuthenticatedSiloConnections/SiloAuthentication.cs" id="AuthenticatedSiloConnections"::: -The certificate callback accepts only `SslPolicyErrors.None`, so the configured -`TargetHost` must match a DNS SAN and the chain must be valid and trusted. It -then narrows trust to an explicitly configured root. Never replace this policy -with -or an unconditional callback. `Required` mode rejects permissive certificate -validation during startup. +The configured `TargetHost` must match a DNS SAN and the chain must be valid +and trusted. Never replace this policy with + or a +custom certificate-validation callback. `Required` mode rejects custom +certificate-validation callbacks during startup. The example deliberately bounds token bytes, exchange duration, concurrent handshakes, and minimum remaining token lifetime. Keep all size, duration, @@ -104,18 +104,20 @@ snapshotted at startup. Changing it requires a silo restart. |---|---| | `Disabled` | Advertises only the baseline Orleans protocol and doesn't exchange authentication frames. | | `Audit` | Prefers authentication, permits baseline negotiation with an older or disabled peer, and accepts measured authentication failures. | -| `Required` | Advertises only the authentication protocol and accepts only a successful authenticated result with a principal and finite expiration. | +| `Required` | Advertises only the authentication protocol and accepts only a successful authenticated result with a principal and, by default, a finite expiration. | `Required` has no unauthenticated fallback. A `Required` silo and an old or disabled silo have no common ALPN protocol, so TLS negotiation fails. A `Required` outbound peer also rejects an Audit result which was accepted but isn't authenticated. -After peers negotiate the authentication ALPN, framing, token acquisition, -validation, authorization, acknowledgment, timeout, or provider failures abort -the connection in every mode. `Audit` can fall back only when TLS negotiated -the baseline ALPN with a peer which doesn't support authentication; it can't -reinterpret a failed authentication exchange as baseline Orleans traffic. +After peers negotiate the authentication ALPN, malformed framing, +acknowledgment, timeout, or overload failures abort the connection in every +mode. `Audit` can explicitly accept token acquisition, validation, +authorization, or provider failures as unauthenticated, but it cannot +reinterpret them as baseline Orleans traffic. Baseline fallback is permitted +only when TLS negotiated the baseline ALPN with a peer which doesn't support +authentication. ## Plan for token expiration @@ -141,9 +143,10 @@ Define gates and ownership before changing modes: provider availability, latency, concurrency saturation, and metadata refresh. 3. Remain in `Audit` until every expected silo pair has negotiated - authentication, unexpected fallback and failure rates are zero for at least - one configured maximum connection lifetime, and representative canaries - have recycled connections at token expiry. + authentication. Deliberately reconnect every expected peer pair and verify + each new connection authenticates, unexpected fallback and failure rates + remain zero, and representative authenticated connections recycle at token + expiry. 4. Restart `Required` canaries. Verify connectivity, membership stability, token renewal, and provider health before proceeding through each failure domain. @@ -167,8 +170,8 @@ coordinated, and the maximum accepted exposure window in `Audit`. ## Monitor authentication -Export the `Microsoft.Orleans` meter. The maintained sample enables an OTLP -exporter when `OTEL_EXPORTER_OTLP_ENDPOINT` is set: +Export the `Microsoft.Orleans.Connections.Security` meter. The maintained +sample enables an OTLP exporter when `OTEL_EXPORTER_OTLP_ENDPOINT` is set: :::code language="csharp" source="../../../../../../samples/AuthenticatedSiloConnections/Program.cs" id="FixedDiagnostics"::: diff --git a/samples/AuthenticatedSiloConnections/Program.cs b/samples/AuthenticatedSiloConnections/Program.cs index 3f1503db4be..2def0d2a802 100644 --- a/samples/AuthenticatedSiloConnections/Program.cs +++ b/samples/AuthenticatedSiloConnections/Program.cs @@ -1,8 +1,12 @@ using AuthenticatedSiloConnections; using Azure.Core; using Azure.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; +using Orleans.Hosting; var builder = Host.CreateApplicationBuilder(args); var options = SampleOptions.Load(builder.Configuration); @@ -25,7 +29,7 @@ serviceInstanceId: Environment.MachineName)) .WithMetrics(metrics => { - metrics.AddMeter("Microsoft.Orleans"); + metrics.AddMeter("Microsoft.Orleans.Connections.Security"); if (exportToOtlp) { diff --git a/samples/AuthenticatedSiloConnections/README.md b/samples/AuthenticatedSiloConnections/README.md index 74eb4550eb3..405b60f8a01 100644 --- a/samples/AuthenticatedSiloConnections/README.md +++ b/samples/AuthenticatedSiloConnections/README.md @@ -32,11 +32,10 @@ sample-owned JWT parsing or validation. Provide a PFX whose certificate has the Server Authentication and Client Authentication EKUs and a DNS SAN matching `Certificate:TargetHost`. Install -the issuing private root in the operating-system trust store, then configure -its SHA-256 fingerprint. The sample requires successful platform chain, -validity, EKU, and revocation checks in both directions, requires the outbound -DNS-name check, and additionally pins the expected private root. Add the old -and new root fingerprints during CA rotation. +the issuing private root in the operating-system trust store. The sample +requires successful platform chain, validity, EKU, and revocation checks in +both directions and explicitly configures the outbound DNS-name check. Overlap +the old and new roots in the platform trust store during CA rotation. Supply the PFX password through a secret provider or the environment variable `OrleansSecurity__Certificate__Password`; don't store it in `appsettings.json`. @@ -45,13 +44,15 @@ Supply the PFX password through a secret provider or the environment variable Use environment variables or a secret-aware configuration provider to replace every placeholder in `appsettings.json`. Set `OTEL_EXPORTER_OTLP_ENDPOINT` to -export the `Microsoft.Orleans` meter. Structured console logs preserve the -runtime's fixed event IDs and bounded authentication result categories. - -Start in `Audit` mode. Proceed to `Required` only after every expected silo pair -has used the authentication protocol, baseline fallback and unexpected failure -rates remain zero for at least one maximum connection lifetime, and token -expiry recycling succeeds. Changing modes requires a restart. +export the `Microsoft.Orleans.Connections.Security` meter. Structured console +logs preserve the runtime's fixed event IDs and bounded authentication result +categories. + +Start in `Audit` mode. Before proceeding to `Required`, deliberately reconnect +every expected silo pair and verify that each new connection authenticates, +baseline fallback and unexpected failure rates remain zero, and token-expiry +recycling succeeds for authenticated connections. Changing modes requires a +restart. `Required` has no unauthenticated fallback. Roll back fleet-wide from `Required` to `Audit`, and only then from `Audit` to `Disabled`. Never diff --git a/samples/AuthenticatedSiloConnections/SampleOptions.cs b/samples/AuthenticatedSiloConnections/SampleOptions.cs index c149fb1c5cc..e11edcab262 100644 --- a/samples/AuthenticatedSiloConnections/SampleOptions.cs +++ b/samples/AuthenticatedSiloConnections/SampleOptions.cs @@ -79,8 +79,6 @@ internal sealed class CertificateOptions public string TargetHost { get; set; } = ""; - public string[] TrustedRootSha256Fingerprints { get; set; } = []; - public void Validate() { SampleOptions.RequireValue(Path, "Certificate:Path"); @@ -91,15 +89,6 @@ public void Validate() throw new InvalidOperationException( "The configured silo certificate file does not exist."); } - - if (TrustedRootSha256Fingerprints.Length == 0) - { - throw new InvalidOperationException( - "At least one trusted root SHA-256 fingerprint is required."); - } - - _ = CertificatePolicy.ParseSha256Fingerprints( - TrustedRootSha256Fingerprints); } } diff --git a/samples/AuthenticatedSiloConnections/SiloAuthentication.cs b/samples/AuthenticatedSiloConnections/SiloAuthentication.cs index 3a9429d0b7f..244597ccefd 100644 --- a/samples/AuthenticatedSiloConnections/SiloAuthentication.cs +++ b/samples/AuthenticatedSiloConnections/SiloAuthentication.cs @@ -1,5 +1,3 @@ -using System.Net.Security; -using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Azure.Core; using Orleans.Connections.Security; @@ -16,9 +14,6 @@ public static void Configure( TokenCredential credential, X509Certificate2 siloCertificate) { - var trustedRoots = CertificatePolicy.ParseSha256Fingerprints( - options.Certificate.TrustedRootSha256Fingerprints); - // siloBuilder.UseAuthenticatedSiloConnections( tls => @@ -27,25 +22,17 @@ public static void Configure( tls.RemoteCertificateMode = RemoteCertificateMode.RequireCertificate; tls.ClientCertificateMode = RemoteCertificateMode.RequireCertificate; tls.CheckCertificateRevocation = true; - tls.OnAuthenticateAsClient = (_, sslOptions) => - { - sslOptions.TargetHost = options.Certificate.TargetHost; - sslOptions.CertificateRevocationCheckMode = - X509RevocationMode.Online; - }; - tls.RemoteCertificateValidation = (certificate, chain, errors) => - CertificatePolicy.ValidateRemoteCertificate( - certificate, - chain, - errors, - trustedRoots); }, authentication => { authentication.Mode = options.AuthenticationMode; + authentication.TargetHost = options.Certificate.TargetHost; authentication.TokenExchangeTimeout = TimeSpan.FromSeconds(10); authentication.MaxTokenSize = 16 * 1024; - authentication.MaxConcurrentHandshakes = 256; + authentication.MaxConcurrentInboundAuthentications = 256; + authentication.MaxConcurrentOutboundAuthentications = 256; + authentication.MaxPendingInboundAuthentications = 256; + authentication.MaxPendingOutboundAuthentications = 256; authentication.MinimumRemainingTokenLifetime = TimeSpan.FromMinutes(2); @@ -57,6 +44,8 @@ public static void Configure( entra.TokenScope = $"{options.Entra.Audience}/.default"; entra.ValidAudiences.Add(options.Entra.Audience); entra.ValidTenantIds.Add(options.Entra.TenantId); + entra.ClusterAudienceFormat = + $"api://{options.Entra.ResourceApplicationId}/{{0}}"; foreach (var clientId in options.Entra.AllowedCallerClientIds) { @@ -79,47 +68,4 @@ public static X509Certificate2 LoadSiloCertificate( path, password, X509KeyStorageFlags.EphemeralKeySet); - - public static byte[][] ParseSha256Fingerprints(IEnumerable values) - => values.Select(value => - { - var normalized = value.Replace(":", "", StringComparison.Ordinal); - if (normalized.Length != 64) - { - throw new InvalidOperationException( - "Every trusted root fingerprint must contain 32 SHA-256 bytes."); - } - - try - { - return Convert.FromHexString(normalized); - } - catch (FormatException exception) - { - throw new InvalidOperationException( - "A trusted root fingerprint is not hexadecimal.", - exception); - } - }).ToArray(); - - public static bool ValidateRemoteCertificate( - X509Certificate2 certificate, - X509Chain? chain, - SslPolicyErrors errors, - IReadOnlyList trustedRootFingerprints) - { - if (errors != SslPolicyErrors.None - || chain is null - || chain.ChainElements.Count == 0) - { - return false; - } - - var root = chain.ChainElements[^1].Certificate; - var rootFingerprint = root.GetCertHash(HashAlgorithmName.SHA256); - return trustedRootFingerprints.Any( - expected => CryptographicOperations.FixedTimeEquals( - expected, - rootFingerprint)); - } } diff --git a/samples/AuthenticatedSiloConnections/appsettings.json b/samples/AuthenticatedSiloConnections/appsettings.json index bd7c7d81ccf..7bb8c695b0c 100644 --- a/samples/AuthenticatedSiloConnections/appsettings.json +++ b/samples/AuthenticatedSiloConnections/appsettings.json @@ -9,10 +9,7 @@ "Certificate": { "Path": "", "Password": "", - "TargetHost": "orleans-silo.contoso.internal", - "TrustedRootSha256Fingerprints": [ - "" - ] + "TargetHost": "orleans-silo.contoso.internal" }, "Entra": { "TenantId": "", From beba37c6dd0c6128a8d4a74ab415e42357f3096e Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Fri, 7 Aug 2026 16:13:38 -0700 Subject: [PATCH 05/22] docs: register authenticated silo snippet Include the source-backed authenticated silo example in the aggregate host snippets solution. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e71c4ddd-5362-4204-910f-9a742ddd63de --- docs/site/src/content/docs/host/snippets/snippets.sln | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/site/src/content/docs/host/snippets/snippets.sln b/docs/site/src/content/docs/host/snippets/snippets.sln index 097f03c223d..4588759ce95 100644 --- a/docs/site/src/content/docs/host/snippets/snippets.sln +++ b/docs/site/src/content/docs/host/snippets/snippets.sln @@ -16,6 +16,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharedContracts", "aspire\S EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hosting", "hosting\Hosting.csproj", "{A1B2C3D4-1111-2222-3333-444455556671}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AuthenticatedSiloConnections.Snippets", "authenticated-silo-connections\csharp\AuthenticatedSiloConnections.Snippets.csproj", "{A1B2C3D4-1111-2222-3333-444455556672}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -50,5 +52,9 @@ Global {A1B2C3D4-1111-2222-3333-444455556671}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1B2C3D4-1111-2222-3333-444455556671}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-1111-2222-3333-444455556671}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455556672}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455556672}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455556672}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455556672}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal From 79d358b060b3e01eabebc8c2a95eac02348efb7e Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Fri, 7 Aug 2026 18:23:35 -0700 Subject: [PATCH 06/22] feat(security): authenticate client connections Add independent gateway and external-client TLS plus bearer-token authentication using the existing generic and Entra mechanisms. Preserve silo registration compatibility, isolate client policies, and extend samples, docs, telemetry, API surfaces, and integration coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e71c4ddd-5362-4204-910f-9a742ddd63de --- .../host/authenticated-silo-connections.md | 72 +++++-- ...thenticatedSiloConnections.Snippets.csproj | 1 + .../ClientAuthentication.cs | 30 +++ .../AuthenticatedSiloConnections/README.md | 19 +- .../SampleOptions.cs | 26 ++- .../SiloAuthentication.cs | 84 +++++--- .../appsettings.json | 5 +- .../EntraCredentialRegistration.cs | 11 - .../EntraSiloConnectionOptionsValidator.cs | 18 -- .../EntraSiloConnectionTokenProvider.cs | 12 +- .../EntraSiloConnectionTokenValidator.cs | 14 +- .../HostingExtensions.cs | 39 ++-- .../AuthenticationAbstractions.cs | 46 ++++- .../SiloConnectionAuthenticationBuilder.cs | 108 +++++++++- .../SiloConnectionAuthenticationMiddleware.cs | 148 +++++++++++--- .../SiloConnectionAuthenticationOptions.cs | 2 +- ...onnectionAuthenticationOptionsValidator.cs | 15 +- ...iloConnectionAuthenticationRegistration.cs | 117 ++++++++++- .../SiloConnectionAuthenticationTelemetry.cs | 36 +++- .../HostingExtensions.ClientAuthentication.cs | 193 ++++++++++++++++++ .../HostingExtensions.IClientBuilder.cs | 11 + .../Hosting/HostingExtensions.ISiloBuilder.cs | 5 + .../HostingExtensions.SiloAuthentication.cs | 31 ++- .../Orleans.Connections.Security.csproj | 2 +- .../Orleans.Connections.Security.cs | 20 ++ .../EntraOptionsTests.cs | 13 -- .../ClientConnectionAuthenticationTests.cs | 142 +++++++++++++ ...oConnectionAuthenticationContractsTests.cs | 126 ++++++++++++ 28 files changed, 1128 insertions(+), 218 deletions(-) create mode 100644 samples/AuthenticatedSiloConnections/ClientAuthentication.cs delete mode 100644 src/Orleans.Connections.Security.Entra/EntraCredentialRegistration.cs create mode 100644 src/Orleans.Connections.Security/Hosting/HostingExtensions.ClientAuthentication.cs create mode 100644 test/Orleans.Connections.Security.Tests/ClientConnectionAuthenticationTests.cs diff --git a/docs/site/src/content/docs/host/authenticated-silo-connections.md b/docs/site/src/content/docs/host/authenticated-silo-connections.md index e0352b81acd..b99671bcc1f 100644 --- a/docs/site/src/content/docs/host/authenticated-silo-connections.md +++ b/docs/site/src/content/docs/host/authenticated-silo-connections.md @@ -1,21 +1,24 @@ --- -title: Authenticate Orleans silo connections -description: Authenticate silo-to-silo connections with TLS and Microsoft Entra workload identities. +title: Authenticate Orleans connections +description: Authenticate silo and external client connections with TLS and Microsoft Entra workload identities. ms.date: 08/07/2026 ms.topic: how-to --- -# Authenticate Orleans silo connections +# Authenticate Orleans connections -Authenticated silo connections verify the workload identity of a connecting -silo before Orleans reads its connection preamble or application messages. Use +Authenticated connections verify the workload identity of a connecting silo or +external Orleans client before Orleans reads its connection preamble or +application messages. Use -to configure TLS and bearer-token authentication as one ordered policy. +for silo traffic and + +on gateways and external clients. Each method configures TLS and bearer-token +authentication as one ordered policy. > [!IMPORTANT] -> This feature applies only to silo-to-silo connections. Client-to-gateway -> behavior is unchanged. Secure gateway traffic with the existing -> [TLS](transport-layer-security.md) and application authentication mechanisms. +> Silo and client connections are configured independently. Enabling +> authentication for one path doesn't silently change the other. Install `Microsoft.Orleans.Connections.Security` and `Microsoft.Orleans.Connections.Security.Entra` in every silo. The Entra package @@ -56,8 +59,10 @@ following: 1. A tenant-specific authority. 2. A dedicated audience for one cluster and deployment environment, such as `api:///contoso-prod-westus`. -3. The application role `Orleans.Silo.Connect`. -4. An explicit allowlist of caller application IDs. +3. A path-specific application role, such as `Orleans.Silo.Connect` or + `Orleans.Client.Connect`. +4. A separate explicit caller application-ID allowlist for silos and external + clients. The audience must exactly match the resource identifier registered in Microsoft Entra. Don't remove the `api://` prefix or share a general-purpose silo audience @@ -87,7 +92,8 @@ The configured `TargetHost` must match a DNS SAN and the chain must be valid and trusted. Never replace this policy with or a custom certificate-validation callback. `Required` mode rejects custom -certificate-validation callbacks during startup. +certificate-validation callbacks and direct per-connection TLS authentication +callbacks during startup. The example deliberately bounds token bytes, exchange duration, concurrent handshakes, and minimum remaining token lifetime. Keep all size, duration, @@ -95,10 +101,31 @@ queue, concurrency, metadata-refresh, and token-lifetime limits finite. Configuration is validated at startup; invalid middleware ordering, missing TLS/provider registrations, and conflicting TLS policies fail closed. +### Authenticate external clients + +Configure the gateway side on every silo. It validates client tokens before the +gateway reads the Orleans connection preamble: + +:::code language="csharp" source="../../../../../../samples/AuthenticatedSiloConnections/SiloAuthentication.cs" id="AuthenticatedClientGateway"::: + +Configure each external Orleans client with the corresponding outbound policy: + +:::code language="csharp" source="../../../../../../samples/AuthenticatedSiloConnections/ClientAuthentication.cs" id="AuthenticatedClient"::: + +The client and gateway must use compatible enforcement modes and the same Entra +audience, tenant, cluster binding, client role, and caller authorization. Keep +the external-client role and allowlist separate from the silo policy. The + and + +properties distinguish +client-to-gateway traffic from silo-to-silo traffic for custom providers. +Gateway authentication does not authorize individual grain calls or propagate +the authenticated principal into grain requests. + ## Choose an enforcement mode is -snapshotted at startup. Changing it requires a silo restart. +snapshotted at startup. Changing it requires a silo or client process restart. | Mode | Negotiation and acceptance behavior | |---|---| @@ -121,7 +148,7 @@ authentication. ## Plan for token expiration -Authentication occurs once per connection, but a silo connection can otherwise +Authentication occurs once per connection, but an Orleans connection can otherwise outlive its access token. In `Required` mode, Orleans uses the validator's finite expiration and recycles the connection before expiry using a safety margin and bounded jitter. Reconnection acquires a new token through the @@ -138,15 +165,15 @@ doesn't surface only when many connections approach expiration. Define gates and ownership before changing modes: 1. Deploy the code everywhere with `Disabled` and restart the fleet. -2. Restart by failure domain with `Audit`. Retain canaries and monitor baseline +2. Restart silos and clients by failure domain with `Audit`. Retain canaries and monitor baseline fallback, acquisition and validation failures, authorization denials, provider availability, latency, concurrency saturation, and metadata refresh. -3. Remain in `Audit` until every expected silo pair has negotiated - authentication. Deliberately reconnect every expected peer pair and verify - each new connection authenticates, unexpected fallback and failure rates - remain zero, and representative authenticated connections recycle at token - expiry. + 3. Remain in `Audit` until every expected silo pair and external client path + has negotiated authentication. Deliberately reconnect expected peers and + representative clients, then verify each new connection authenticates, + unexpected fallback and failure rates remain zero, and representative + authenticated connections recycle at token expiry. 4. Restart `Required` canaries. Verify connectivity, membership stability, token renewal, and provider health before proceeding through each failure domain. @@ -185,13 +212,14 @@ Alert on rates and latency for these instruments: | `orleans.connections.authentication.protocol_fallbacks` | Identify peers which haven't negotiated authentication in `Audit`. | Keep dimensions bounded to direction, mode, protocol version, and fixed result -category. Never add token, tenant, client, object, issuer, endpoint, or arbitrary +category. The `connection.type` dimension distinguishes `silo` and `client` +connections. Never add token, tenant, client, object, issuer, endpoint, or arbitrary exception values as metric tags. Authentication logs use fixed event IDs and bounded categories such as overload, timeout, protocol error, TLS policy error, acquisition failure, validation failure, authorization failure, and expiration. Preserve event ID, -category, direction, and mode in the log pipeline. Tokens must never appear in +connection type, category, direction, and mode in the log pipeline. Tokens must never appear in logs, traces, metrics, activities, exceptions, or connection features. ## Production checklist diff --git a/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/AuthenticatedSiloConnections.Snippets.csproj b/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/AuthenticatedSiloConnections.Snippets.csproj index c85d2104df8..3c54f17d4e7 100644 --- a/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/AuthenticatedSiloConnections.Snippets.csproj +++ b/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/AuthenticatedSiloConnections.Snippets.csproj @@ -10,6 +10,7 @@ + diff --git a/samples/AuthenticatedSiloConnections/ClientAuthentication.cs b/samples/AuthenticatedSiloConnections/ClientAuthentication.cs new file mode 100644 index 00000000000..a9b9a373f96 --- /dev/null +++ b/samples/AuthenticatedSiloConnections/ClientAuthentication.cs @@ -0,0 +1,30 @@ +using Azure.Core; +using Orleans.Hosting; + +namespace AuthenticatedSiloConnections; + +internal static class ClientAuthentication +{ + public static void Configure( + IClientBuilder clientBuilder, + SampleOptions options, + TokenCredential credential) + { + // + clientBuilder.UseAuthenticatedClientConnections( + tls => + { + tls.CheckCertificateRevocation = true; + }, + authentication => + { + SiloAuthentication.ConfigureAuthentication( + authentication, + options, + credential, + options.Entra.AllowedClientCallerClientIds, + "Orleans.Client.Connect"); + }); + // + } +} diff --git a/samples/AuthenticatedSiloConnections/README.md b/samples/AuthenticatedSiloConnections/README.md index 405b60f8a01..1b4a6b5f515 100644 --- a/samples/AuthenticatedSiloConnections/README.md +++ b/samples/AuthenticatedSiloConnections/README.md @@ -1,7 +1,7 @@ # Authenticated silo connections This sample configures mutual TLS (mTLS) and Microsoft Entra workload -authentication for silo-to-silo connections. It uses an explicit +authentication for silo-to-silo and external client-to-gateway connections. It uses an explicit `WorkloadIdentityCredential`; it doesn't construct `DefaultAzureCredential` or copy JWT validation logic into the application. @@ -17,11 +17,12 @@ default ports, then start another with 2. Configure the identifier URI `api:///`. The cluster ID includes the deployment environment, for example `contoso-prod-westus`. -3. Define the application role `Orleans.Silo.Connect` and allow applications - as members. -4. Assign the role to each authorized silo workload identity. -5. Configure a federated identity credential for each workload and set its - application ID in `AllowedCallerClientIds`. +3. Define the application roles `Orleans.Silo.Connect` and + `Orleans.Client.Connect`, and allow applications as members. +4. Assign only the matching role to each authorized silo or client workload + identity. +5. Configure a federated identity credential for each workload and place its + application ID in the matching silo or external-client allowlist. The exact audience, tenant, application-token classification, caller application ID, and application role are validated by @@ -59,5 +60,7 @@ restart. automatically weaken the mode because Microsoft Entra or metadata is unavailable. -Client-to-gateway authentication is unchanged. Secure gateway traffic -separately with the existing TLS and application authentication mechanisms. +The gateway validates external client bearer tokens using a distinct +`Orleans.Client.Connect` role and caller allowlist. External clients must call +`UseAuthenticatedClientConnections` with a token provider and the same exact +audience, tenant, client role, and cluster binding. diff --git a/samples/AuthenticatedSiloConnections/SampleOptions.cs b/samples/AuthenticatedSiloConnections/SampleOptions.cs index e11edcab262..1561664e841 100644 --- a/samples/AuthenticatedSiloConnections/SampleOptions.cs +++ b/samples/AuthenticatedSiloConnections/SampleOptions.cs @@ -102,7 +102,9 @@ internal sealed class EntraOptions public string FederatedTokenFile { get; set; } = ""; - public string[] AllowedCallerClientIds { get; set; } = []; + public string[] AllowedSiloCallerClientIds { get; set; } = []; + + public string[] AllowedClientCallerClientIds { get; set; } = []; public Uri Authority => new($"https://login.microsoftonline.com/{TenantId}/v2.0"); @@ -126,23 +128,29 @@ public void Validate(string clusterId) "The configured workload identity token file does not exist."); } - if (AllowedCallerClientIds.Length == 0) + if (AllowedSiloCallerClientIds.Length == 0) + { + throw new InvalidOperationException( + "At least one allowed silo caller application ID is required."); + } + + if (AllowedClientCallerClientIds.Length == 0) { throw new InvalidOperationException( - "At least one allowed caller application ID is required."); + "At least one allowed external client application ID is required."); } - foreach (var clientId in AllowedCallerClientIds) + foreach (var clientId in AllowedSiloCallerClientIds.Concat(AllowedClientCallerClientIds)) { - RequireGuid(clientId, nameof(AllowedCallerClientIds)); + RequireGuid(clientId, "AllowedCallerClientIds"); } - if (!AllowedCallerClientIds.Contains( - WorkloadClientId, - StringComparer.OrdinalIgnoreCase)) + if (!AllowedSiloCallerClientIds + .Concat(AllowedClientCallerClientIds) + .Contains(WorkloadClientId, StringComparer.OrdinalIgnoreCase)) { throw new InvalidOperationException( - "This silo's workload client ID must be in the allowed caller list."); + "This process's workload client ID must be in an allowed caller list."); } } diff --git a/samples/AuthenticatedSiloConnections/SiloAuthentication.cs b/samples/AuthenticatedSiloConnections/SiloAuthentication.cs index 244597ccefd..ff3465773ec 100644 --- a/samples/AuthenticatedSiloConnections/SiloAuthentication.cs +++ b/samples/AuthenticatedSiloConnections/SiloAuthentication.cs @@ -25,37 +25,69 @@ public static void Configure( }, authentication => { - authentication.Mode = options.AuthenticationMode; - authentication.TargetHost = options.Certificate.TargetHost; - authentication.TokenExchangeTimeout = TimeSpan.FromSeconds(10); - authentication.MaxTokenSize = 16 * 1024; - authentication.MaxConcurrentInboundAuthentications = 256; - authentication.MaxConcurrentOutboundAuthentications = 256; - authentication.MaxPendingInboundAuthentications = 256; - authentication.MaxPendingOutboundAuthentications = 256; - authentication.MinimumRemainingTokenLifetime = - TimeSpan.FromMinutes(2); + ConfigureAuthentication( + authentication, + options, + credential, + options.Entra.AllowedSiloCallerClientIds, + "Orleans.Silo.Connect"); + }); + // - authentication.UseEntra( + // + siloBuilder.UseAuthenticatedClientConnections( + tls => + { + tls.LocalCertificate = siloCertificate; + tls.RemoteCertificateMode = RemoteCertificateMode.NoCertificate; + }, + authentication => + { + ConfigureAuthentication( + authentication, + options, credential, - entra => - { - entra.Authority = options.Entra.Authority; - entra.TokenScope = $"{options.Entra.Audience}/.default"; - entra.ValidAudiences.Add(options.Entra.Audience); - entra.ValidTenantIds.Add(options.Entra.TenantId); - entra.ClusterAudienceFormat = - $"api://{options.Entra.ResourceApplicationId}/{{0}}"; + options.Entra.AllowedClientCallerClientIds, + "Orleans.Client.Connect"); + }); + // + } - foreach (var clientId in options.Entra.AllowedCallerClientIds) - { - entra.AllowedClientIds.Add(clientId); - } + internal static void ConfigureAuthentication( + SiloConnectionAuthenticationBuilder authentication, + SampleOptions options, + TokenCredential credential, + IEnumerable allowedCallerClientIds, + string requiredRole) + { + authentication.Mode = options.AuthenticationMode; + authentication.TargetHost = options.Certificate.TargetHost; + authentication.TokenExchangeTimeout = TimeSpan.FromSeconds(10); + authentication.MaxTokenSize = 16 * 1024; + authentication.MaxConcurrentInboundAuthentications = 256; + authentication.MaxConcurrentOutboundAuthentications = 256; + authentication.MaxPendingInboundAuthentications = 256; + authentication.MaxPendingOutboundAuthentications = 256; + authentication.MinimumRemainingTokenLifetime = TimeSpan.FromMinutes(2); - entra.RequiredRoles.Add("Orleans.Silo.Connect"); - }); + authentication.UseEntra( + credential, + entra => + { + entra.Authority = options.Entra.Authority; + entra.TokenScope = $"{options.Entra.Audience}/.default"; + entra.ValidAudiences.Add(options.Entra.Audience); + entra.ValidTenantIds.Add(options.Entra.TenantId); + entra.ClusterAudienceFormat = + $"api://{options.Entra.ResourceApplicationId}/{{0}}"; + + foreach (var clientId in allowedCallerClientIds) + { + entra.AllowedClientIds.Add(clientId); + } + + entra.RequiredRoles.Add(requiredRole); }); - // } } diff --git a/samples/AuthenticatedSiloConnections/appsettings.json b/samples/AuthenticatedSiloConnections/appsettings.json index 7bb8c695b0c..ee61fa826b6 100644 --- a/samples/AuthenticatedSiloConnections/appsettings.json +++ b/samples/AuthenticatedSiloConnections/appsettings.json @@ -16,8 +16,11 @@ "ResourceApplicationId": "", "WorkloadClientId": "", "FederatedTokenFile": "", - "AllowedCallerClientIds": [ + "AllowedSiloCallerClientIds": [ "" + ], + "AllowedClientCallerClientIds": [ + "" ] } } diff --git a/src/Orleans.Connections.Security.Entra/EntraCredentialRegistration.cs b/src/Orleans.Connections.Security.Entra/EntraCredentialRegistration.cs deleted file mode 100644 index 9e26a23e4a2..00000000000 --- a/src/Orleans.Connections.Security.Entra/EntraCredentialRegistration.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; -using Azure.Core; - -namespace Orleans.Connections.Security.Entra; - -internal sealed record EntraCredentialRegistration(TokenCredential Credential); - -internal sealed class EntraTimeProviderAccessor(Func getTimeProvider) -{ - public TimeProvider Value => getTimeProvider(); -} diff --git a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptionsValidator.cs b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptionsValidator.cs index cf8559226d3..d9647936ae9 100644 --- a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptionsValidator.cs +++ b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptionsValidator.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.Linq; using Microsoft.Extensions.Options; -using Orleans.Connections.Security.Entra; namespace Orleans.Configuration; @@ -13,27 +11,11 @@ internal sealed class EntraSiloConnectionOptionsValidator : IValidateOptions? _credentialRegistrations; - - public EntraSiloConnectionOptionsValidator() - { - } - - public EntraSiloConnectionOptionsValidator(IEnumerable credentialRegistrations) - { - _credentialRegistrations = credentialRegistrations; - } - public ValidateOptionsResult Validate(string? name, EntraSiloConnectionOptions options) { ArgumentNullException.ThrowIfNull(options); var errors = new List(); - if (_credentialRegistrations is not null && _credentialRegistrations.Count() != 1) - { - errors.Add("Exactly one caller-supplied TokenCredential must be configured."); - } - if (options.Authority is not { IsAbsoluteUri: true } authority || !string.Equals(authority.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || !string.IsNullOrEmpty(authority.UserInfo) diff --git a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenProvider.cs b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenProvider.cs index febdf8f4942..56c58636186 100644 --- a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenProvider.cs +++ b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenProvider.cs @@ -1,9 +1,6 @@ using System; -using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.Options; using Orleans.Configuration; namespace Orleans.Connections.Security.Entra; @@ -13,12 +10,11 @@ internal sealed class EntraSiloConnectionTokenProvider : ISiloConnectionTokenPro private readonly EntraTokenProvider _provider; public EntraSiloConnectionTokenProvider( - IEnumerable credentialRegistrations, - IOptions options, - EntraTimeProviderAccessor timeProvider) + Azure.Core.TokenCredential credential, + EntraSiloConnectionOptions options, + TimeProvider timeProvider) { - var registration = credentialRegistrations.Single(); - _provider = new EntraTokenProvider(registration.Credential, options.Value, timeProvider.Value); + _provider = new EntraTokenProvider(credential, options, timeProvider); } public async ValueTask GetTokenAsync( diff --git a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenValidator.cs b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenValidator.cs index 68e0dcff564..26bc69a4750 100644 --- a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenValidator.cs +++ b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenValidator.cs @@ -1,18 +1,30 @@ +using System; using System.Threading; using System.Threading.Tasks; using Orleans.Configuration; namespace Orleans.Connections.Security.Entra; -internal sealed class EntraSiloConnectionTokenValidator : ISiloConnectionTokenValidator +internal sealed class EntraSiloConnectionTokenValidator : ISiloConnectionTokenValidator, IDisposable { private readonly EntraJwtValidator _validator; + private readonly EntraOpenIdConfigurationProvider? _metadata; public EntraSiloConnectionTokenValidator(EntraJwtValidator validator) + : this(validator, metadata: null) + { + } + + public EntraSiloConnectionTokenValidator( + EntraJwtValidator validator, + EntraOpenIdConfigurationProvider? metadata) { _validator = validator; + _metadata = metadata; } + public void Dispose() => _metadata?.Dispose(); + public async ValueTask ValidateTokenAsync( string token, SiloConnectionTokenValidationContext context, diff --git a/src/Orleans.Connections.Security.Entra/HostingExtensions.cs b/src/Orleans.Connections.Security.Entra/HostingExtensions.cs index 0c8b114645a..f9d58ed46b3 100644 --- a/src/Orleans.Connections.Security.Entra/HostingExtensions.cs +++ b/src/Orleans.Connections.Security.Entra/HostingExtensions.cs @@ -31,33 +31,34 @@ public static SiloConnectionAuthenticationBuilder UseEntra( ArgumentNullException.ThrowIfNull(configureOptions); var services = builder.Services; - services.AddSingleton(new EntraCredentialRegistration(credential)); - services.AddSingleton(new EntraTimeProviderAccessor(() => builder.TimeProvider)); + var optionsName = builder.Name; services.TryAddEnumerable( ServiceDescriptor.Singleton, EntraSiloConnectionOptionsValidator>()); - services.AddOptions() + services.AddOptions(optionsName) .Configure(configureOptions) .ValidateOnStart(); - services.TryAddSingleton( - static serviceProvider => + return builder + .UseTokenProvider(serviceProvider => { - var options = serviceProvider.GetRequiredService>().Value; - return new EntraOpenIdConfigurationProvider( + var options = serviceProvider + .GetRequiredService>() + .Get(optionsName); + return new EntraSiloConnectionTokenProvider( + credential, options, - serviceProvider.GetRequiredService().Value); - }); - services.TryAddSingleton( - static serviceProvider => + builder.TimeProvider); + }) + .UseTokenValidator(serviceProvider => { - var options = serviceProvider.GetRequiredService>().Value; - return new EntraJwtValidator( + var options = serviceProvider + .GetRequiredService>() + .Get(optionsName); + var metadata = new EntraOpenIdConfigurationProvider(options, builder.TimeProvider); + var validator = new EntraJwtValidator( options, - serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService().Value); + metadata, + builder.TimeProvider); + return new EntraSiloConnectionTokenValidator(validator, metadata); }); - - return builder - .UseTokenProvider() - .UseTokenValidator(); } } diff --git a/src/Orleans.Connections.Security/Authentication/AuthenticationAbstractions.cs b/src/Orleans.Connections.Security/Authentication/AuthenticationAbstractions.cs index 8900886de70..ad7e15498d2 100644 --- a/src/Orleans.Connections.Security/Authentication/AuthenticationAbstractions.cs +++ b/src/Orleans.Connections.Security/Authentication/AuthenticationAbstractions.cs @@ -7,7 +7,7 @@ namespace Orleans.Connections.Security; /// -/// Controls enforcement of silo-to-silo connection authentication. +/// Controls enforcement of Orleans connection authentication. /// public enum SiloConnectionAuthenticationMode { @@ -17,9 +17,21 @@ public enum SiloConnectionAuthenticationMode /// Attempts authentication when supported and records failures without rejecting policy failures. Audit, - /// Requires every silo connection to be authenticated. + /// Requires every configured connection to be authenticated. Required, } + +/// +/// Identifies the kind of Orleans connection being authenticated. +/// +public enum SiloConnectionAuthenticationTarget +{ + /// A connection between silos. + Silo, + + /// A connection between an external Orleans client and a silo gateway. + Client, +} /// /// Identifies the direction of a silo connection. /// @@ -60,29 +72,29 @@ public enum SiloConnectionAuthenticationFailure } /// -/// A bearer token used to authenticate an outbound silo connection. +/// A bearer token used to authenticate an outbound Orleans connection. /// /// The token value. /// The token expiration time. public readonly record struct SiloConnectionToken(string Value, DateTimeOffset? ExpiresAt); /// -/// Supplies bearer tokens for outbound silo connections. +/// Supplies bearer tokens for outbound Orleans connections. /// public interface ISiloConnectionTokenProvider { - /// Gets a token for an outbound silo connection. + /// Gets a token for an outbound Orleans connection. ValueTask GetTokenAsync( SiloConnectionTokenRequestContext context, CancellationToken cancellationToken); } /// -/// Validates bearer tokens received on inbound silo connections. +/// Validates bearer tokens received on inbound Orleans connections. /// public interface ISiloConnectionTokenValidator { - /// Validates a token for an inbound silo connection. + /// Validates a token for an inbound Orleans connection. ValueTask ValidateTokenAsync( string token, SiloConnectionTokenValidationContext context, @@ -94,9 +106,14 @@ ValueTask ValidateTokenAsync( /// public sealed class SiloConnectionTokenRequestContext { - internal SiloConnectionTokenRequestContext(string clusterId, EndPoint? localEndPoint, EndPoint? remoteEndPoint) + internal SiloConnectionTokenRequestContext( + string clusterId, + SiloConnectionAuthenticationTarget target, + EndPoint? localEndPoint, + EndPoint? remoteEndPoint) { ClusterId = clusterId; + Target = target; LocalEndPoint = localEndPoint; RemoteEndPoint = remoteEndPoint; } @@ -104,6 +121,9 @@ internal SiloConnectionTokenRequestContext(string clusterId, EndPoint? localEndP /// Gets the expected Orleans cluster identifier. public string ClusterId { get; } + /// Gets the kind of connection being authenticated. + public SiloConnectionAuthenticationTarget Target { get; } + /// Gets the connection direction. public SiloConnectionAuthenticationDirection Direction => SiloConnectionAuthenticationDirection.Outbound; @@ -119,9 +139,14 @@ internal SiloConnectionTokenRequestContext(string clusterId, EndPoint? localEndP /// public sealed class SiloConnectionTokenValidationContext { - internal SiloConnectionTokenValidationContext(string clusterId, EndPoint? localEndPoint, EndPoint? remoteEndPoint) + internal SiloConnectionTokenValidationContext( + string clusterId, + SiloConnectionAuthenticationTarget target, + EndPoint? localEndPoint, + EndPoint? remoteEndPoint) { ClusterId = clusterId; + Target = target; LocalEndPoint = localEndPoint; RemoteEndPoint = remoteEndPoint; } @@ -129,6 +154,9 @@ internal SiloConnectionTokenValidationContext(string clusterId, EndPoint? localE /// Gets the expected Orleans cluster identifier. public string ClusterId { get; } + /// Gets the kind of connection being authenticated. + public SiloConnectionAuthenticationTarget Target { get; } + /// Gets the connection direction. public SiloConnectionAuthenticationDirection Direction => SiloConnectionAuthenticationDirection.Inbound; diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationBuilder.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationBuilder.cs index d159e9906a7..d1e45fb05eb 100644 --- a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationBuilder.cs +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationBuilder.cs @@ -1,21 +1,25 @@ using System; -using System.Linq; using Microsoft.Extensions.DependencyInjection; namespace Orleans.Connections.Security; /// -/// Configures providers and policy for authenticated silo connections. +/// Configures providers and policy for authenticated Orleans connections. /// public sealed class SiloConnectionAuthenticationBuilder { private readonly SiloConnectionAuthenticationOptions _options; private readonly IServiceCollection _services; + private readonly object _serviceKey; internal SiloConnectionAuthenticationBuilder( + string name, + object serviceKey, SiloConnectionAuthenticationOptions options, IServiceCollection services) { + Name = name; + _serviceKey = serviceKey; _options = options; _services = services; } @@ -27,6 +31,9 @@ internal SiloConnectionAuthenticationBuilder( /// Gets the service collection used to configure authentication providers. public IServiceCollection Services => _services; + /// Gets the unique name of this connection authentication registration. + public string Name { get; } + /// Gets or sets the authentication enforcement mode. public SiloConnectionAuthenticationMode Mode { get => _options.Mode; set => _options.Mode = value; } @@ -103,7 +110,15 @@ public SiloConnectionAuthenticationBuilder UseTokenProvider() where TProvider : class, ISiloConnectionTokenProvider { EnsureProviderCanBeRegistered(); - _services.AddSingleton(); + if (PreserveUnkeyedRegistrations) + { + _services.AddSingleton(); + } + else + { + _services.AddKeyedSingleton(_serviceKey); + } + HasTokenProvider = true; return this; } @@ -113,7 +128,36 @@ public SiloConnectionAuthenticationBuilder UseTokenProvider(ISiloConnectionToken { ArgumentNullException.ThrowIfNull(provider); EnsureProviderCanBeRegistered(); - _services.AddSingleton(provider); + if (PreserveUnkeyedRegistrations) + { + _services.AddSingleton(provider); + } + else + { + _services.AddKeyedSingleton(_serviceKey, provider); + } + + HasTokenProvider = true; + return this; + } + + /// Registers a singleton token provider factory. + public SiloConnectionAuthenticationBuilder UseTokenProvider( + Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + EnsureProviderCanBeRegistered(); + if (PreserveUnkeyedRegistrations) + { + _services.AddSingleton(factory); + } + else + { + _services.AddKeyedSingleton( + _serviceKey, + (serviceProvider, _) => factory(serviceProvider)); + } + HasTokenProvider = true; return this; } @@ -123,7 +167,15 @@ public SiloConnectionAuthenticationBuilder UseTokenValidator() where TValidator : class, ISiloConnectionTokenValidator { EnsureValidatorCanBeRegistered(); - _services.AddSingleton(); + if (PreserveUnkeyedRegistrations) + { + _services.AddSingleton(); + } + else + { + _services.AddKeyedSingleton(_serviceKey); + } + HasTokenValidator = true; return this; } @@ -133,14 +185,50 @@ public SiloConnectionAuthenticationBuilder UseTokenValidator(ISiloConnectionToke { ArgumentNullException.ThrowIfNull(validator); EnsureValidatorCanBeRegistered(); - _services.AddSingleton(validator); + if (PreserveUnkeyedRegistrations) + { + _services.AddSingleton(validator); + } + else + { + _services.AddKeyedSingleton(_serviceKey, validator); + } + + HasTokenValidator = true; + return this; + } + + /// Registers a singleton token validator factory. + public SiloConnectionAuthenticationBuilder UseTokenValidator( + Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + EnsureValidatorCanBeRegistered(); + if (PreserveUnkeyedRegistrations) + { + _services.AddSingleton(factory); + } + else + { + _services.AddKeyedSingleton( + _serviceKey, + (serviceProvider, _) => factory(serviceProvider)); + } + HasTokenValidator = true; return this; } + private bool PreserveUnkeyedRegistrations => + ReferenceEquals(_serviceKey, ConnectionAuthenticationServiceKeys.Silo); + private void EnsureProviderCanBeRegistered() { - if (HasTokenProvider || _services.Any(descriptor => descriptor.ServiceType == typeof(ISiloConnectionTokenProvider))) + if (HasTokenProvider + || (PreserveUnkeyedRegistrations + && _services.Any(descriptor => + descriptor.ServiceType == typeof(ISiloConnectionTokenProvider) + && !descriptor.IsKeyedService))) { throw new InvalidOperationException("A silo connection token provider is already registered."); } @@ -148,7 +236,11 @@ private void EnsureProviderCanBeRegistered() private void EnsureValidatorCanBeRegistered() { - if (HasTokenValidator || _services.Any(descriptor => descriptor.ServiceType == typeof(ISiloConnectionTokenValidator))) + if (HasTokenValidator + || (PreserveUnkeyedRegistrations + && _services.Any(descriptor => + descriptor.ServiceType == typeof(ISiloConnectionTokenValidator) + && !descriptor.IsKeyedService))) { throw new InvalidOperationException("A silo connection token validator is already registered."); } diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationMiddleware.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationMiddleware.cs index 65d4cd28fd4..20dab121fa0 100644 --- a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationMiddleware.cs +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationMiddleware.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Connections; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -54,14 +55,16 @@ internal abstract class SiloConnectionAuthenticationMiddleware private readonly IHostApplicationLifetime _applicationLifetime; protected SiloConnectionAuthenticationMiddleware( - IOptions options, - IOptions clusterOptions, + SiloConnectionAuthenticationOptions options, + string clusterId, + SiloConnectionAuthenticationTarget target, AuthenticationWorkLimiter workLimiter, IHostApplicationLifetime applicationLifetime, ILogger logger) { - Options = CloneOptions(options.Value); - ClusterId = clusterOptions.Value.ClusterId; + Options = CloneOptions(options); + ClusterId = clusterId; + Target = target; WorkLimiter = workLimiter; _applicationLifetime = applicationLifetime; Logger = logger; @@ -71,6 +74,8 @@ protected SiloConnectionAuthenticationMiddleware( protected string ClusterId { get; } + protected SiloConnectionAuthenticationTarget Target { get; } + protected AuthenticationWorkLimiter WorkLimiter { get; } protected ILogger Logger { get; } @@ -121,12 +126,14 @@ protected async Task RunAcceptedAsync( context.Features.Set(feature); SiloConnectionAuthenticationTelemetry.RecordAttempt( started, + Target, direction, Options.Mode, feature.Protocol, result); SiloConnectionAuthenticationTelemetry.LogCompleted( Logger, + GetTargetName(Target), GetDirectionName(direction), Options.Mode.ToString(), SiloConnectionAuthenticationTelemetry.GetResultName(result)); @@ -137,7 +144,7 @@ protected async Task RunAcceptedAsync( return; } - SiloConnectionAuthenticationTelemetry.AddActive(1, direction, Options.Mode, feature.Protocol); + SiloConnectionAuthenticationTelemetry.AddActive(1, Target, direction, Options.Mode, feature.Protocol); try { if (feature.ExpiresAt is not { } expiresAt) @@ -164,7 +171,7 @@ protected async Task RunAcceptedAsync( } finally { - SiloConnectionAuthenticationTelemetry.AddActive(-1, direction, Options.Mode, feature.Protocol); + SiloConnectionAuthenticationTelemetry.AddActive(-1, Target, direction, Options.Mode, feature.Protocol); } } @@ -178,6 +185,7 @@ protected void Abort( { SiloConnectionAuthenticationTelemetry.RecordAttempt( start, + Target, direction, Options.Mode, SiloConnectionAuthenticationProtocol.Version2, @@ -186,6 +194,7 @@ protected void Abort( else { SiloConnectionAuthenticationTelemetry.RecordEvent( + Target, direction, Options.Mode, SiloConnectionAuthenticationProtocol.Version2, @@ -194,16 +203,20 @@ protected void Abort( SiloConnectionAuthenticationTelemetry.LogFailure( Logger, + GetTargetName(Target), GetDirectionName(direction), Options.Mode.ToString(), SiloConnectionAuthenticationTelemetry.GetResultName(category)); context.Abort(new ConnectionAbortedException( - $"Silo connection authentication failed ({SiloConnectionAuthenticationTelemetry.GetResultName(category)}).")); + $"Orleans connection authentication failed ({SiloConnectionAuthenticationTelemetry.GetResultName(category)}).")); } protected static string GetDirectionName(SiloConnectionAuthenticationDirection direction) => direction == SiloConnectionAuthenticationDirection.Inbound ? "inbound" : "outbound"; + protected static string GetTargetName(SiloConnectionAuthenticationTarget target) => + target == SiloConnectionAuthenticationTarget.Silo ? "silo" : "client"; + private static SiloConnectionAuthenticationOptions CloneOptions(SiloConnectionAuthenticationOptions source) => new() { Mode = source.Mode, @@ -323,21 +336,41 @@ private void RearmOrExpire() } } -internal sealed class InboundSiloConnectionAuthenticationMiddleware : SiloConnectionAuthenticationMiddleware, IConnectionMiddleware +internal class InboundSiloConnectionAuthenticationMiddleware : SiloConnectionAuthenticationMiddleware, IConnectionMiddleware { private static readonly UTF8Encoding StrictUtf8 = new(false, true); private readonly ISiloConnectionTokenValidator? _validator; public InboundSiloConnectionAuthenticationMiddleware( - IEnumerable validators, - IOptions options, + IServiceProvider serviceProvider, + SiloConnectionAuthenticationRegistration registration, IOptions clusterOptions, - AuthenticationWorkLimiter workLimiter, IHostApplicationLifetime applicationLifetime, ILogger logger) - : base(options, clusterOptions, workLimiter, applicationLifetime, logger) + : this( + serviceProvider.GetServices().SingleOrDefault(), + registration, + clusterOptions.Value.ClusterId, + applicationLifetime, + logger) { - _validator = validators.SingleOrDefault(); + } + + protected InboundSiloConnectionAuthenticationMiddleware( + ISiloConnectionTokenValidator? validator, + ConnectionAuthenticationRegistration registration, + string clusterId, + IHostApplicationLifetime applicationLifetime, + ILogger logger) + : base( + registration.Options, + clusterId, + registration.Target, + registration.WorkLimiter, + applicationLifetime, + logger) + { + _validator = validator; } public async Task OnConnectionAsync(ConnectionContext context, ConnectionDelegate next) @@ -359,8 +392,8 @@ public async Task OnConnectionAsync(ConnectionContext context, ConnectionDelegat null, SiloConnectionAuthenticationFailure.None, "Orleans1")); - SiloConnectionAuthenticationTelemetry.RecordFallback(direction, Options.Mode); - SiloConnectionAuthenticationTelemetry.LogFallback(Logger, GetDirectionName(direction)); + SiloConnectionAuthenticationTelemetry.RecordFallback(Target, direction, Options.Mode); + SiloConnectionAuthenticationTelemetry.LogFallback(Logger, GetTargetName(Target), GetDirectionName(direction)); await next(context); return; } @@ -489,7 +522,11 @@ private async ValueTask ValidateAsync( { return await _validator.ValidateTokenAsync( token, - new SiloConnectionTokenValidationContext(ClusterId, context.LocalEndPoint, context.RemoteEndPoint), + new SiloConnectionTokenValidationContext( + ClusterId, + Target, + context.LocalEndPoint, + context.RemoteEndPoint), cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -563,21 +600,59 @@ SiloConnectionAuthenticationFailure.ProviderUnavailable or }; } -internal sealed class OutboundSiloConnectionAuthenticationMiddleware : SiloConnectionAuthenticationMiddleware, IConnectionMiddleware +internal sealed class InboundGatewayConnectionAuthenticationMiddleware : InboundSiloConnectionAuthenticationMiddleware +{ + public InboundGatewayConnectionAuthenticationMiddleware( + IServiceProvider serviceProvider, + GatewayConnectionAuthenticationRegistration registration, + IOptions clusterOptions, + IHostApplicationLifetime applicationLifetime, + ILogger logger) + : base( + serviceProvider.GetKeyedService(registration.ServiceKey), + registration, + clusterOptions.Value.ClusterId, + applicationLifetime, + logger) + { + } +} + +internal class OutboundSiloConnectionAuthenticationMiddleware : SiloConnectionAuthenticationMiddleware, IConnectionMiddleware { private static readonly UTF8Encoding StrictUtf8 = new(false, true); private readonly ISiloConnectionTokenProvider? _provider; public OutboundSiloConnectionAuthenticationMiddleware( - IEnumerable providers, - IOptions options, + IServiceProvider serviceProvider, + SiloConnectionAuthenticationRegistration registration, IOptions clusterOptions, - AuthenticationWorkLimiter workLimiter, IHostApplicationLifetime applicationLifetime, ILogger logger) - : base(options, clusterOptions, workLimiter, applicationLifetime, logger) + : this( + serviceProvider.GetServices().SingleOrDefault(), + registration, + clusterOptions.Value.ClusterId, + applicationLifetime, + logger) + { + } + + protected OutboundSiloConnectionAuthenticationMiddleware( + ISiloConnectionTokenProvider? provider, + ConnectionAuthenticationRegistration registration, + string clusterId, + IHostApplicationLifetime applicationLifetime, + ILogger logger) + : base( + registration.Options, + clusterId, + registration.Target, + registration.WorkLimiter, + applicationLifetime, + logger) { - _provider = providers.SingleOrDefault(); + _provider = provider; } public async Task OnConnectionAsync(ConnectionContext context, ConnectionDelegate next) @@ -599,8 +674,8 @@ public async Task OnConnectionAsync(ConnectionContext context, ConnectionDelegat null, SiloConnectionAuthenticationFailure.None, "Orleans1")); - SiloConnectionAuthenticationTelemetry.RecordFallback(direction, Options.Mode); - SiloConnectionAuthenticationTelemetry.LogFallback(Logger, GetDirectionName(direction)); + SiloConnectionAuthenticationTelemetry.RecordFallback(Target, direction, Options.Mode); + SiloConnectionAuthenticationTelemetry.LogFallback(Logger, GetTargetName(Target), GetDirectionName(direction)); await next(context); return; } @@ -731,9 +806,14 @@ await RunAcceptedAsync( try { token = await _provider.GetTokenAsync( - new SiloConnectionTokenRequestContext(ClusterId, context.LocalEndPoint, context.RemoteEndPoint), + new SiloConnectionTokenRequestContext( + ClusterId, + Target, + context.LocalEndPoint, + context.RemoteEndPoint), cancellationToken); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; @@ -795,3 +875,21 @@ private static AuthenticationResultCategory GetAcquisitionCategory(SiloConnectio ? AuthenticationResultCategory.Expiration : AuthenticationResultCategory.AcquisitionFailure; } + +internal sealed class OutboundClientConnectionAuthenticationMiddleware : OutboundSiloConnectionAuthenticationMiddleware +{ + public OutboundClientConnectionAuthenticationMiddleware( + IServiceProvider serviceProvider, + ClientConnectionAuthenticationRegistration registration, + IOptions clusterOptions, + IHostApplicationLifetime applicationLifetime, + ILogger logger) + : base( + serviceProvider.GetKeyedService(registration.ServiceKey), + registration, + clusterOptions.Value.ClusterId, + applicationLifetime, + logger) + { + } +} diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptions.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptions.cs index c98d5a90d3e..c1242d5a39d 100644 --- a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptions.cs +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptions.cs @@ -3,7 +3,7 @@ namespace Orleans.Connections.Security; /// -/// Configures silo-to-silo connection authentication. +/// Configures Orleans connection authentication. /// public sealed class SiloConnectionAuthenticationOptions { diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptionsValidator.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptionsValidator.cs index e2f98e49c5d..95352da87d9 100644 --- a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptionsValidator.cs +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptionsValidator.cs @@ -7,15 +7,20 @@ namespace Orleans.Connections.Security; internal sealed class SiloConnectionAuthenticationOptionsValidator : IValidateOptions { private static readonly TimeSpan MaxDuration = TimeSpan.FromDays(1); - private readonly SiloConnectionAuthenticationRegistration _registration; + private readonly ConnectionAuthenticationRegistration _registration; - public SiloConnectionAuthenticationOptionsValidator(SiloConnectionAuthenticationRegistration registration) + public SiloConnectionAuthenticationOptionsValidator(ConnectionAuthenticationRegistration registration) { _registration = registration; } public ValidateOptionsResult Validate(string? name, SiloConnectionAuthenticationOptions options) { + if (!string.Equals(name, _registration.Name, StringComparison.Ordinal)) + { + return ValidateOptionsResult.Skip; + } + var failures = new List(); if (!Enum.IsDefined(options.Mode)) @@ -40,12 +45,12 @@ public ValidateOptionsResult Validate(string? name, SiloConnectionAuthentication if (options.Mode == SiloConnectionAuthenticationMode.Required) { - if (!_registration.HasTokenProvider) + if (_registration.RequiresTokenProvider && !_registration.HasTokenProvider) { failures.Add("Required mode needs exactly one token provider."); } - if (!_registration.HasTokenValidator) + if (_registration.RequiresTokenValidator && !_registration.HasTokenValidator) { failures.Add("Required mode needs exactly one token validator."); } @@ -55,7 +60,7 @@ public ValidateOptionsResult Validate(string? name, SiloConnectionAuthentication failures.Add("Required mode does not permit custom remote-certificate validation callbacks."); } - if (string.IsNullOrWhiteSpace(options.TargetHost)) + if (_registration.RequiresTokenProvider && string.IsNullOrWhiteSpace(options.TargetHost)) { failures.Add($"Required mode needs a non-empty {nameof(options.TargetHost)} for TLS endpoint-identity validation."); } diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationRegistration.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationRegistration.cs index 65de80b78db..4cb0c7ee956 100644 --- a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationRegistration.cs +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationRegistration.cs @@ -3,15 +3,50 @@ namespace Orleans.Connections.Security; -internal sealed class SiloConnectionAuthenticationRegistration +internal abstract class ConnectionAuthenticationRegistration { - public required SiloConnectionAuthenticationOptions Options { get; init; } + protected ConnectionAuthenticationRegistration( + string name, + object serviceKey, + SiloConnectionAuthenticationTarget target, + SiloConnectionAuthenticationOptions options, + TlsOptions tlsOptions, + bool hasTokenProvider, + bool hasTokenValidator, + bool requiresTokenProvider, + bool requiresTokenValidator) + { + Name = name; + ServiceKey = serviceKey; + Target = target; + Options = options; + TlsOptions = tlsOptions; + HasTokenProvider = hasTokenProvider; + HasTokenValidator = hasTokenValidator; + RequiresTokenProvider = requiresTokenProvider; + RequiresTokenValidator = requiresTokenValidator; + WorkLimiter = new AuthenticationWorkLimiter(options); + } + + public string Name { get; } + + public object ServiceKey { get; } + + public SiloConnectionAuthenticationTarget Target { get; } - public required TlsOptions TlsOptions { get; init; } + public SiloConnectionAuthenticationOptions Options { get; } - public required bool HasTokenProvider { get; init; } + public TlsOptions TlsOptions { get; } - public required bool HasTokenValidator { get; init; } + public bool HasTokenProvider { get; } + + public bool HasTokenValidator { get; } + + public bool RequiresTokenProvider { get; } + + public bool RequiresTokenValidator { get; } + + public AuthenticationWorkLimiter WorkLimiter { get; } public static SiloConnectionAuthenticationOptions CloneOptions(SiloConnectionAuthenticationOptions source) { @@ -64,6 +99,14 @@ public static void ConfigureApplicationProtocols( SiloConnectionAuthenticationOptions authenticationOptions) { var serverCallback = tlsOptions.OnAuthenticateAsServer; + var clientCallback = tlsOptions.OnAuthenticateAsClient; + if (authenticationOptions.Mode == SiloConnectionAuthenticationMode.Required + && (serverCallback is not null || clientCallback is not null)) + { + throw new InvalidOperationException( + "Required mode does not permit direct per-connection TLS authentication callbacks."); + } + tlsOptions.OnAuthenticateAsServer = (context, options) => { serverCallback?.Invoke(context, options); @@ -71,7 +114,6 @@ public static void ConfigureApplicationProtocols( sslOptions.ApplicationProtocols = CreateApplicationProtocols(authenticationOptions.Mode); }; - var clientCallback = tlsOptions.OnAuthenticateAsClient; tlsOptions.OnAuthenticateAsClient = (context, options) => { clientCallback?.Invoke(context, options); @@ -93,6 +135,69 @@ public static void ConfigureApplicationProtocols( }; } +internal sealed class SiloConnectionAuthenticationRegistration( + string name, + object serviceKey, + SiloConnectionAuthenticationOptions options, + TlsOptions tlsOptions, + bool hasTokenProvider, + bool hasTokenValidator) + : ConnectionAuthenticationRegistration( + name, + serviceKey, + SiloConnectionAuthenticationTarget.Silo, + options, + tlsOptions, + hasTokenProvider, + hasTokenValidator, + requiresTokenProvider: true, + requiresTokenValidator: true); + +internal sealed class GatewayConnectionAuthenticationRegistration( + string name, + object serviceKey, + SiloConnectionAuthenticationOptions options, + TlsOptions tlsOptions, + bool hasTokenProvider, + bool hasTokenValidator) + : ConnectionAuthenticationRegistration( + name, + serviceKey, + SiloConnectionAuthenticationTarget.Client, + options, + tlsOptions, + hasTokenProvider, + hasTokenValidator, + requiresTokenProvider: false, + requiresTokenValidator: true); + +internal sealed class ClientConnectionAuthenticationRegistration( + string name, + object serviceKey, + SiloConnectionAuthenticationOptions options, + TlsOptions tlsOptions, + bool hasTokenProvider, + bool hasTokenValidator) + : ConnectionAuthenticationRegistration( + name, + serviceKey, + SiloConnectionAuthenticationTarget.Client, + options, + tlsOptions, + hasTokenProvider, + hasTokenValidator, + requiresTokenProvider: true, + requiresTokenValidator: false); + +internal static class ConnectionAuthenticationServiceKeys +{ + public static readonly object Silo = new(); + public static readonly object Gateway = new(); + public static readonly object Client = new(); +} + internal sealed class SiloTlsRegistrationMarker; internal sealed class GatewayTlsRegistrationMarker; + +internal sealed class ClientTlsRegistrationMarker; diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationTelemetry.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationTelemetry.cs index 0afff36baf9..88561d1ea8f 100644 --- a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationTelemetry.cs +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationTelemetry.cs @@ -33,43 +33,48 @@ internal static partial class SiloConnectionAuthenticationTelemetry public static void RecordAttempt( long started, + SiloConnectionAuthenticationTarget target, SiloConnectionAuthenticationDirection direction, SiloConnectionAuthenticationMode mode, string protocol, AuthenticationResultCategory result) { - var tags = CreateTags(direction, mode, protocol, result); + var tags = CreateTags(target, direction, mode, protocol, result); Attempts.Add(1, tags); Duration.Record(Stopwatch.GetElapsedTime(started).TotalMilliseconds, tags); } public static void RecordFallback( + SiloConnectionAuthenticationTarget target, SiloConnectionAuthenticationDirection direction, SiloConnectionAuthenticationMode mode) { - var tags = CreateTags(direction, mode, "Orleans1", AuthenticationResultCategory.BaselineFallback); + var tags = CreateTags(target, direction, mode, "Orleans1", AuthenticationResultCategory.BaselineFallback); ProtocolFallbacks.Add(1, tags); } public static void RecordEvent( + SiloConnectionAuthenticationTarget target, SiloConnectionAuthenticationDirection direction, SiloConnectionAuthenticationMode mode, string protocol, AuthenticationResultCategory result) { - Attempts.Add(1, CreateTags(direction, mode, protocol, result)); + Attempts.Add(1, CreateTags(target, direction, mode, protocol, result)); } public static void AddActive( long value, + SiloConnectionAuthenticationTarget target, SiloConnectionAuthenticationDirection direction, SiloConnectionAuthenticationMode mode, string protocol) { - Active.Add(value, CreateTags(direction, mode, protocol, AuthenticationResultCategory.Authenticated)); + Active.Add(value, CreateTags(target, direction, mode, protocol, AuthenticationResultCategory.Authenticated)); } private static TagList CreateTags( + SiloConnectionAuthenticationTarget target, SiloConnectionAuthenticationDirection direction, SiloConnectionAuthenticationMode mode, string protocol, @@ -77,6 +82,7 @@ private static TagList CreateTags( { return new TagList { + { "connection.type", target == SiloConnectionAuthenticationTarget.Silo ? "silo" : "client" }, { "direction", direction == SiloConnectionAuthenticationDirection.Inbound ? "inbound" : "outbound" }, { "mode", mode.ToString() }, { "protocol.version", protocol }, @@ -104,18 +110,28 @@ private static TagList CreateTags( [LoggerMessage( EventId = 9200, Level = LogLevel.Warning, - Message = "Silo connection authentication failed. Direction: {Direction}; Mode: {Mode}; Category: {Category}.")] - public static partial void LogFailure(ILogger logger, string direction, string mode, string category); + Message = "Orleans connection authentication failed. Connection type: {ConnectionType}; Direction: {Direction}; Mode: {Mode}; Category: {Category}.")] + public static partial void LogFailure( + ILogger logger, + string connectionType, + string direction, + string mode, + string category); [LoggerMessage( EventId = 9201, Level = LogLevel.Information, - Message = "Silo connection authentication completed. Direction: {Direction}; Mode: {Mode}; Result: {Result}.")] - public static partial void LogCompleted(ILogger logger, string direction, string mode, string result); + Message = "Orleans connection authentication completed. Connection type: {ConnectionType}; Direction: {Direction}; Mode: {Mode}; Result: {Result}.")] + public static partial void LogCompleted( + ILogger logger, + string connectionType, + string direction, + string mode, + string result); [LoggerMessage( EventId = 9202, Level = LogLevel.Information, - Message = "Silo connection authentication used the baseline protocol in Audit mode. Direction: {Direction}.")] - public static partial void LogFallback(ILogger logger, string direction); + Message = "Orleans connection authentication used the baseline protocol in Audit mode. Connection type: {ConnectionType}; Direction: {Direction}.")] + public static partial void LogFallback(ILogger logger, string connectionType, string direction); } diff --git a/src/Orleans.Connections.Security/Hosting/HostingExtensions.ClientAuthentication.cs b/src/Orleans.Connections.Security/Hosting/HostingExtensions.ClientAuthentication.cs new file mode 100644 index 00000000000..5785a430249 --- /dev/null +++ b/src/Orleans.Connections.Security/Hosting/HostingExtensions.ClientAuthentication.cs @@ -0,0 +1,193 @@ +using System; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Orleans.Configuration; +using Orleans.Connections.Security; +using Orleans.Runtime.Messaging; + +namespace Orleans.Hosting; + +public static partial class OrleansConnectionSecurityHostingExtensions +{ + /// + /// Configures TLS and provider-neutral bearer-token authentication for connections from Orleans clients. + /// Silo-to-silo connections are not modified. + /// + /// The silo builder. + /// Configures TLS for gateway connections. + /// Configures authentication policy and token validation. + /// The silo builder. + public static ISiloBuilder UseAuthenticatedClientConnections( + this ISiloBuilder builder, + Action configureTls, + Action configureAuthentication) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configureTls); + ArgumentNullException.ThrowIfNull(configureAuthentication); + + if (builder.Services.Any(descriptor => + descriptor.ServiceType == typeof(GatewayConnectionAuthenticationRegistration) + || descriptor.ServiceType == typeof(GatewayTlsRegistrationMarker))) + { + throw new InvalidOperationException("Gateway TLS or client connection authentication has already been configured."); + } + + var tlsOptions = new TlsOptions(); + configureTls(tlsOptions); + ValidateServerTlsOptions(tlsOptions, "gateway"); + + const string registrationName = "Orleans.GatewayConnections"; + var authenticationOptions = new SiloConnectionAuthenticationOptions(); + var authenticationBuilder = new SiloConnectionAuthenticationBuilder( + registrationName, + ConnectionAuthenticationServiceKeys.Gateway, + authenticationOptions, + builder.Services); + configureAuthentication(authenticationBuilder); + + var tlsSnapshot = ConnectionAuthenticationRegistration.CloneTlsOptions(tlsOptions); + var authenticationSnapshot = ConnectionAuthenticationRegistration.CloneOptions(authenticationOptions); + ConnectionAuthenticationRegistration.ConfigureApplicationProtocols(tlsSnapshot, authenticationSnapshot); + var registration = new GatewayConnectionAuthenticationRegistration( + registrationName, + ConnectionAuthenticationServiceKeys.Gateway, + authenticationSnapshot, + tlsSnapshot, + authenticationBuilder.HasTokenProvider, + authenticationBuilder.HasTokenValidator); + + RegisterAuthentication(builder.Services, registration); + builder.Services.AddSingleton(); + + return builder.Configure(connectionOptions => + connectionOptions.ConfigureGatewayInboundConnection(connectionBuilder => + { + connectionBuilder.UseServerTls(tlsSnapshot); + connectionBuilder.UseMiddleware(); + })); + } + + /// + /// Configures TLS and provider-neutral bearer-token authentication for connections to Orleans gateways. + /// + /// The client builder. + /// Configures TLS for gateway connections. + /// Configures authentication policy and token acquisition. + /// The client builder. + public static IClientBuilder UseAuthenticatedClientConnections( + this IClientBuilder builder, + Action configureTls, + Action configureAuthentication) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configureTls); + ArgumentNullException.ThrowIfNull(configureAuthentication); + + if (builder.Services.Any(descriptor => + descriptor.ServiceType == typeof(ClientConnectionAuthenticationRegistration) + || descriptor.ServiceType == typeof(ClientTlsRegistrationMarker))) + { + throw new InvalidOperationException("Client TLS or connection authentication has already been configured."); + } + + var tlsOptions = new TlsOptions(); + configureTls(tlsOptions); + ValidateClientTlsOptions(tlsOptions); + + const string registrationName = "Orleans.ClientConnections"; + var authenticationOptions = new SiloConnectionAuthenticationOptions(); + var authenticationBuilder = new SiloConnectionAuthenticationBuilder( + registrationName, + ConnectionAuthenticationServiceKeys.Client, + authenticationOptions, + builder.Services); + configureAuthentication(authenticationBuilder); + + var tlsSnapshot = ConnectionAuthenticationRegistration.CloneTlsOptions(tlsOptions); + var authenticationSnapshot = ConnectionAuthenticationRegistration.CloneOptions(authenticationOptions); + ConnectionAuthenticationRegistration.ConfigureApplicationProtocols(tlsSnapshot, authenticationSnapshot); + var registration = new ClientConnectionAuthenticationRegistration( + registrationName, + ConnectionAuthenticationServiceKeys.Client, + authenticationSnapshot, + tlsSnapshot, + authenticationBuilder.HasTokenProvider, + authenticationBuilder.HasTokenValidator); + + RegisterAuthentication(builder.Services, registration); + builder.Services.AddSingleton(); + + return builder.Configure(connectionOptions => + connectionOptions.ConfigureConnection(connectionBuilder => + { + connectionBuilder.UseClientTls(tlsSnapshot); + connectionBuilder.UseMiddleware(); + })); + } + + private static void RegisterAuthentication( + IServiceCollection services, + ConnectionAuthenticationRegistration registration) + { + switch (registration) + { + case SiloConnectionAuthenticationRegistration silo: + services.AddSingleton(silo); + break; + case GatewayConnectionAuthenticationRegistration gateway: + services.AddSingleton(gateway); + break; + case ClientConnectionAuthenticationRegistration client: + services.AddSingleton(client); + break; + default: + throw new ArgumentOutOfRangeException(nameof(registration)); + } + + services.AddSingleton>( + new SiloConnectionAuthenticationOptionsValidator(registration)); + services + .AddOptions(registration.Name) + .Configure(registration.CopyOptionsTo) + .ValidateOnStart(); + + if (registration is SiloConnectionAuthenticationRegistration) + { + services + .AddOptions() + .Configure(registration.CopyOptionsTo); + } + } + + private static void ValidateServerTlsOptions(TlsOptions options, string connectionKind) + { + if (options.LocalCertificate is null && options.LocalServerCertificateSelector is null) + { + throw new InvalidOperationException($"No {connectionKind} TLS certificate was specified."); + } + + if (options.LocalCertificate is { } certificate && !certificate.HasPrivateKey) + { + TlsConnectionBuilderExtensions.ThrowNoPrivateKey( + certificate, + $"{nameof(TlsOptions)}.{nameof(TlsOptions.LocalCertificate)}"); + } + } + + private static void ValidateClientTlsOptions(TlsOptions options) + { + if (options.LocalCertificate is null && options.ClientCertificateMode == RemoteCertificateMode.RequireCertificate) + { + throw new InvalidOperationException("No client TLS certificate was specified."); + } + + if (options.LocalCertificate is { } certificate && !certificate.HasPrivateKey) + { + TlsConnectionBuilderExtensions.ThrowNoPrivateKey( + certificate, + $"{nameof(TlsOptions)}.{nameof(TlsOptions.LocalCertificate)}"); + } + } +} diff --git a/src/Orleans.Connections.Security/Hosting/HostingExtensions.IClientBuilder.cs b/src/Orleans.Connections.Security/Hosting/HostingExtensions.IClientBuilder.cs index 2020564e8f0..d4cfd22f2bc 100644 --- a/src/Orleans.Connections.Security/Hosting/HostingExtensions.IClientBuilder.cs +++ b/src/Orleans.Connections.Security/Hosting/HostingExtensions.IClientBuilder.cs @@ -1,5 +1,7 @@ using System; +using System.Linq; using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.DependencyInjection; using Orleans.Configuration; using Orleans.Connections.Security; @@ -122,6 +124,15 @@ public static IClientBuilder UseTls( TlsConnectionBuilderExtensions.ThrowNoPrivateKey(certificate, $"{nameof(TlsOptions)}.{nameof(TlsOptions.LocalCertificate)}"); } + if (builder.Services.Any(descriptor => + descriptor.ServiceType == typeof(ClientTlsRegistrationMarker) + || descriptor.ServiceType == typeof(ClientConnectionAuthenticationRegistration))) + { + throw new InvalidOperationException("Client TLS or connection authentication has already been configured."); + } + + builder.Services.AddSingleton(); + return builder.Configure(connectionOptions => { connectionOptions.ConfigureConnection(connectionBuilder => diff --git a/src/Orleans.Connections.Security/Hosting/HostingExtensions.ISiloBuilder.cs b/src/Orleans.Connections.Security/Hosting/HostingExtensions.ISiloBuilder.cs index ab82cbfd979..17bad1a7f9e 100644 --- a/src/Orleans.Connections.Security/Hosting/HostingExtensions.ISiloBuilder.cs +++ b/src/Orleans.Connections.Security/Hosting/HostingExtensions.ISiloBuilder.cs @@ -171,6 +171,11 @@ private static ISiloBuilder UseGatewayTls(this ISiloBuilder builder, TlsOptions throw new InvalidOperationException("Gateway TLS has already been configured."); } + if (builder.Services.Any(descriptor => descriptor.ServiceType == typeof(GatewayConnectionAuthenticationRegistration))) + { + throw new InvalidOperationException("Gateway TLS or client connection authentication has already been configured."); + } + builder.Services.AddSingleton(); return builder.Configure(connectionOptions => diff --git a/src/Orleans.Connections.Security/Hosting/HostingExtensions.SiloAuthentication.cs b/src/Orleans.Connections.Security/Hosting/HostingExtensions.SiloAuthentication.cs index 8efc7538c13..3098bc39954 100644 --- a/src/Orleans.Connections.Security/Hosting/HostingExtensions.SiloAuthentication.cs +++ b/src/Orleans.Connections.Security/Hosting/HostingExtensions.SiloAuthentication.cs @@ -48,30 +48,27 @@ public static ISiloBuilder UseAuthenticatedSiloConnections( $"{nameof(TlsOptions)}.{nameof(TlsOptions.LocalCertificate)}"); } + const string registrationName = "Orleans.SiloConnections"; var authenticationOptions = new SiloConnectionAuthenticationOptions(); - var authenticationBuilder = new SiloConnectionAuthenticationBuilder(authenticationOptions, builder.Services); + var authenticationBuilder = new SiloConnectionAuthenticationBuilder( + registrationName, + ConnectionAuthenticationServiceKeys.Silo, + authenticationOptions, + builder.Services); configureAuthentication(authenticationBuilder); var tlsSnapshot = SiloConnectionAuthenticationRegistration.CloneTlsOptions(tlsOptions); var authenticationSnapshot = SiloConnectionAuthenticationRegistration.CloneOptions(authenticationOptions); SiloConnectionAuthenticationRegistration.ConfigureApplicationProtocols(tlsSnapshot, authenticationSnapshot); - var registration = new SiloConnectionAuthenticationRegistration - { - Options = authenticationSnapshot, - TlsOptions = tlsSnapshot, - HasTokenProvider = authenticationBuilder.HasTokenProvider, - HasTokenValidator = authenticationBuilder.HasTokenValidator, - }; + var registration = new SiloConnectionAuthenticationRegistration( + registrationName, + ConnectionAuthenticationServiceKeys.Silo, + authenticationSnapshot, + tlsSnapshot, + authenticationBuilder.HasTokenProvider, + authenticationBuilder.HasTokenValidator); - builder.Services.AddSingleton(registration); - builder.Services.AddSingleton>( - new SiloConnectionAuthenticationOptionsValidator(registration)); - builder.Services - .AddOptions() - .Configure(registration.CopyOptionsTo) - .ValidateOnStart(); - builder.Services.AddSingleton(serviceProvider => - new AuthenticationWorkLimiter(serviceProvider.GetRequiredService>().Value)); + RegisterAuthentication(builder.Services, registration); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Orleans.Connections.Security/Orleans.Connections.Security.csproj b/src/Orleans.Connections.Security/Orleans.Connections.Security.csproj index 3bf41cb2ade..b2b5b546ebd 100644 --- a/src/Orleans.Connections.Security/Orleans.Connections.Security.csproj +++ b/src/Orleans.Connections.Security/Orleans.Connections.Security.csproj @@ -3,7 +3,7 @@ Microsoft.Orleans.Connections.Security Microsoft Orleans connection security - Support for secure communication using TLS and authenticated silo connections in Microsoft Orleans. + Support for secure communication using TLS and authenticated silo and client connections in Microsoft Orleans. $(PackageTags) TLS SSL authentication $(DefaultTargetFrameworks) true diff --git a/src/api/Orleans.Connections.Security/Orleans.Connections.Security.cs b/src/api/Orleans.Connections.Security/Orleans.Connections.Security.cs index 4c2efc5d7dd..c2d67f39e35 100644 --- a/src/api/Orleans.Connections.Security/Orleans.Connections.Security.cs +++ b/src/api/Orleans.Connections.Security/Orleans.Connections.Security.cs @@ -115,6 +115,8 @@ internal SiloConnectionAuthenticationBuilder() { } public SiloConnectionAuthenticationMode Mode { get { throw null; } set { } } + public string Name { get { throw null; } } + public Microsoft.Extensions.DependencyInjection.IServiceCollection Services { get { throw null; } } public string? TargetHost { get { throw null; } set { } } @@ -125,11 +127,15 @@ internal SiloConnectionAuthenticationBuilder() { } public SiloConnectionAuthenticationBuilder UseTokenProvider(ISiloConnectionTokenProvider provider) { throw null; } + public SiloConnectionAuthenticationBuilder UseTokenProvider(System.Func factory) { throw null; } + public SiloConnectionAuthenticationBuilder UseTokenProvider() where TProvider : class, ISiloConnectionTokenProvider { throw null; } public SiloConnectionAuthenticationBuilder UseTokenValidator(ISiloConnectionTokenValidator validator) { throw null; } + public SiloConnectionAuthenticationBuilder UseTokenValidator(System.Func factory) { throw null; } + public SiloConnectionAuthenticationBuilder UseTokenValidator() where TValidator : class, ISiloConnectionTokenValidator { throw null; } } @@ -158,6 +164,12 @@ public enum SiloConnectionAuthenticationMode Required = 2 } + public enum SiloConnectionAuthenticationTarget + { + Silo = 0, + Client = 1 + } + public sealed partial class SiloConnectionAuthenticationOptions { public bool AllowNonExpiringCredentials { get { throw null; } set { } } @@ -235,6 +247,8 @@ internal SiloConnectionTokenRequestContext() { } public System.Net.EndPoint? LocalEndPoint { get { throw null; } } public System.Net.EndPoint? RemoteEndPoint { get { throw null; } } + + public SiloConnectionAuthenticationTarget Target { get { throw null; } } } public sealed partial class SiloConnectionTokenValidationContext @@ -248,6 +262,8 @@ internal SiloConnectionTokenValidationContext() { } public System.Net.EndPoint? LocalEndPoint { get { throw null; } } public System.Net.EndPoint? RemoteEndPoint { get { throw null; } } + + public SiloConnectionAuthenticationTarget Target { get { throw null; } } } public sealed partial class SiloConnectionTokenValidationResult @@ -339,6 +355,10 @@ public static partial class OrleansConnectionSecurityHostingExtensions public static ISiloBuilder UseAuthenticatedSiloConnections(this ISiloBuilder builder, System.Action configureTls, System.Action configureAuthentication) { throw null; } + public static IClientBuilder UseAuthenticatedClientConnections(this IClientBuilder builder, System.Action configureTls, System.Action configureAuthentication) { throw null; } + + public static ISiloBuilder UseAuthenticatedClientConnections(this ISiloBuilder builder, System.Action configureTls, System.Action configureAuthentication) { throw null; } + public static IClientBuilder UseTls(this IClientBuilder builder, System.Action configureOptions) { throw null; } public static IClientBuilder UseTls(this IClientBuilder builder, System.Security.Cryptography.X509Certificates.StoreName storeName, string subject, bool allowInvalid, System.Security.Cryptography.X509Certificates.StoreLocation location, System.Action configureOptions) { throw null; } diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraOptionsTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraOptionsTests.cs index 1fecd121140..e45dafa2339 100644 --- a/test/Orleans.Connections.Security.Entra.Tests/EntraOptionsTests.cs +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraOptionsTests.cs @@ -79,17 +79,4 @@ public void RejectsEffectivelyUnboundedMetadataWork() Assert.False(result.Succeeded); } - [Fact] - public void TimeProviderAccessorReadsCurrentAuthenticationClock() - { - TimeProvider current = TimeProvider.System; - var accessor = new EntraTimeProviderAccessor(() => current); - var expected = new TestTimeProvider(); - - current = expected; - - Assert.Same(expected, accessor.Value); - } - - private sealed class TestTimeProvider : TimeProvider; } diff --git a/test/Orleans.Connections.Security.Tests/ClientConnectionAuthenticationTests.cs b/test/Orleans.Connections.Security.Tests/ClientConnectionAuthenticationTests.cs new file mode 100644 index 00000000000..fdaddd1470d --- /dev/null +++ b/test/Orleans.Connections.Security.Tests/ClientConnectionAuthenticationTests.cs @@ -0,0 +1,142 @@ +using System.Collections.Concurrent; +using System.Security.Claims; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Orleans.Hosting; +using Orleans.TestingHost; +using Xunit; + +namespace Orleans.Connections.Security.Tests; + +[Trait("Category", "BVT")] +public sealed class ClientConnectionAuthenticationTests +{ + private const string CertificateConfigKey = "ClientAuthenticationCertificate"; + private const string RecorderConfigKey = "ClientAuthenticationRecorder"; + private const string Token = "client-authentication-test-token"; + private const string TargetHost = "client-authentication.test"; + private static readonly ConcurrentDictionary Recorders = new(); + + [Fact] + public async Task AuthenticatedClientConnection_CanCallGrain() + { + var recorderId = Guid.NewGuid().ToString(); + var recorder = new ValidationRecorder(); + Assert.True(Recorders.TryAdd(recorderId, recorder)); + + TestCluster? cluster = null; + try + { + var certificate = TestCertificateHelper.CreateSelfSignedCertificate( + TargetHost, + [TestCertificateHelper.ServerAuthenticationOid]); + var builder = new TestClusterBuilder() + .AddSiloBuilderConfigurator() + .AddClientBuilderConfigurator(); + builder.Options.InitialSilosCount = 2; + builder.Properties[CertificateConfigKey] = TestCertificateHelper.ConvertToBase64(certificate); + builder.Properties[RecorderConfigKey] = recorderId; + + cluster = builder.Build(); + await cluster.DeployAsync(); + + var grain = cluster.Client.GetGrain("authenticated-client"); + Assert.Equal("authenticated", await grain.Echo("authenticated")); + Assert.True(recorder.ValidationCount > 0); + Assert.Equal(SiloConnectionAuthenticationTarget.Client, recorder.LastTarget); + Assert.Equal(cluster.Options.ClusterId, recorder.LastClusterId); + } + finally + { + Recorders.TryRemove(recorderId, out _); + if (cluster is not null) + { + await cluster.StopAllSilosAsync(); + cluster.Dispose(); + } + } + } + + private sealed class AuthenticatedGatewayConfigurator : IHostConfigurator + { + public void Configure(IHostBuilder hostBuilder) + { + var configuration = hostBuilder.GetConfiguration(); + var certificate = TestCertificateHelper.ConvertFromBase64(configuration[CertificateConfigKey]!); + var recorder = Recorders[configuration[RecorderConfigKey]!]; + + hostBuilder.UseOrleans((_, siloBuilder) => + siloBuilder.UseAuthenticatedClientConnections( + tls => + { + tls.LocalCertificate = certificate; + tls.RemoteCertificateMode = RemoteCertificateMode.NoCertificate; + }, + authentication => + { + authentication.Mode = SiloConnectionAuthenticationMode.Audit; + authentication.UseTokenValidator(new RecordingTokenValidator(recorder)); + })); + } + } + + private sealed class AuthenticatedClientConfigurator : IClientBuilderConfigurator + { + public void Configure(IConfiguration configuration, IClientBuilder clientBuilder) + { + clientBuilder.UseAuthenticatedClientConnections( + tls => tls.AllowAnyRemoteCertificate(), + authentication => + { + authentication.Mode = SiloConnectionAuthenticationMode.Audit; + authentication.UseTokenProvider(new FixedTokenProvider()); + }); + } + } + + private sealed class FixedTokenProvider : ISiloConnectionTokenProvider + { + public ValueTask GetTokenAsync( + SiloConnectionTokenRequestContext context, + CancellationToken cancellationToken) + { + Assert.Equal(SiloConnectionAuthenticationTarget.Client, context.Target); + return ValueTask.FromResult( + new SiloConnectionToken(Token, DateTimeOffset.UtcNow.AddMinutes(10))); + } + } + + private sealed class RecordingTokenValidator(ValidationRecorder recorder) : ISiloConnectionTokenValidator + { + public ValueTask ValidateTokenAsync( + string token, + SiloConnectionTokenValidationContext context, + CancellationToken cancellationToken) + { + Assert.Equal(Token, token); + recorder.Record(context); + var principal = new ClaimsPrincipal( + new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "test-client")], "test-token")); + return ValueTask.FromResult( + SiloConnectionTokenValidationResult.Success(principal, DateTimeOffset.UtcNow.AddMinutes(10))); + } + } + + private sealed class ValidationRecorder + { + private int _validationCount; + + public int ValidationCount => Volatile.Read(ref _validationCount); + + public string? LastClusterId { get; private set; } + + public SiloConnectionAuthenticationTarget LastTarget { get; private set; } + + public void Record(SiloConnectionTokenValidationContext context) + { + LastClusterId = context.ClusterId; + LastTarget = context.Target; + Interlocked.Increment(ref _validationCount); + } + } +} diff --git a/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs b/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs index 13100377306..9281c4bb56e 100644 --- a/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs +++ b/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace Orleans.Connections.Security.Tests; @@ -150,4 +151,129 @@ public void Version2_IsExpectedAlpnIdentifier() SiloConnectionAuthenticationProtocol.Version2, StringComparer.Ordinal); } + + public class SiloConnectionAuthenticationContextTests + { + [Theory] + [InlineData(SiloConnectionAuthenticationTarget.Silo)] + [InlineData(SiloConnectionAuthenticationTarget.Client)] + public void Contexts_PreserveConnectionTarget(SiloConnectionAuthenticationTarget target) + { + var request = new SiloConnectionTokenRequestContext("cluster", target, null, null); + var validation = new SiloConnectionTokenValidationContext("cluster", target, null, null); + + Assert.Equal(target, request.Target); + Assert.Equal(target, validation.Target); + } + + public class SiloConnectionAuthenticationRegistrationTests + { + [Fact] + public void Providers_AreIsolatedByConnectionPath() + { + var services = new ServiceCollection(); + var siloKey = ConnectionAuthenticationServiceKeys.Silo; + var clientKey = new object(); + var siloProvider = new TestTokenProvider("silo"); + var clientProvider = new TestTokenProvider("client"); + + new SiloConnectionAuthenticationBuilder( + "silo", + siloKey, + new SiloConnectionAuthenticationOptions(), + services) + .UseTokenProvider(siloProvider); + new SiloConnectionAuthenticationBuilder( + "client", + clientKey, + new SiloConnectionAuthenticationOptions(), + services) + .UseTokenProvider(clientProvider); + + using var serviceProvider = services.BuildServiceProvider(); + Assert.Same(clientProvider, serviceProvider.GetRequiredKeyedService(clientKey)); + Assert.Same(siloProvider, serviceProvider.GetRequiredService()); + Assert.Null(serviceProvider.GetKeyedService(siloKey)); + } + + [Fact] + public void SiloProviderRegistration_RejectsExistingUnkeyedProvider() + { + var services = new ServiceCollection(); + services.AddSingleton(new TestTokenProvider("existing")); + var builder = new SiloConnectionAuthenticationBuilder( + "silo", + ConnectionAuthenticationServiceKeys.Silo, + new SiloConnectionAuthenticationOptions(), + services); + + Assert.Throws( + () => builder.UseTokenProvider(new TestTokenProvider("replacement"))); + } + + [Theory] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void RequiredMode_RejectsDirectTlsAuthenticationCallbacks( + bool configureClientCallback, + bool configureServerCallback) + { + var tlsOptions = new TlsOptions(); + if (configureClientCallback) + { + tlsOptions.OnAuthenticateAsClient = static (_, _) => { }; + } + + if (configureServerCallback) + { + tlsOptions.OnAuthenticateAsServer = static (_, _) => { }; + } + + var exception = Assert.Throws(() => + ConnectionAuthenticationRegistration.ConfigureApplicationProtocols( + tlsOptions, + new SiloConnectionAuthenticationOptions + { + Mode = SiloConnectionAuthenticationMode.Required, + })); + + Assert.Contains("does not permit", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void RequiredMode_RequiresOnlyServicesUsedByConnectionDirection() + { + var clientOptions = new SiloConnectionAuthenticationOptions { TargetHost = "gateway.test" }; + var clientRegistration = new ClientConnectionAuthenticationRegistration( + "client", + new object(), + clientOptions, + new TlsOptions(), + hasTokenProvider: true, + hasTokenValidator: false); + var gatewayOptions = new SiloConnectionAuthenticationOptions(); + var gatewayRegistration = new GatewayConnectionAuthenticationRegistration( + "gateway", + new object(), + gatewayOptions, + new TlsOptions(), + hasTokenProvider: false, + hasTokenValidator: true); + + Assert.True(new SiloConnectionAuthenticationOptionsValidator(clientRegistration) + .Validate("client", clientOptions).Succeeded); + Assert.True(new SiloConnectionAuthenticationOptionsValidator(gatewayRegistration) + .Validate("gateway", gatewayOptions).Succeeded); + } + + private sealed class TestTokenProvider(string value) : ISiloConnectionTokenProvider + { + public ValueTask GetTokenAsync( + SiloConnectionTokenRequestContext context, + CancellationToken cancellationToken) => + ValueTask.FromResult(new SiloConnectionToken(value, DateTimeOffset.UtcNow.AddMinutes(5))); + } + } + } } From f5fd8862a0f78710980e27b1f03afeba2de81b74 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Fri, 7 Aug 2026 19:35:39 -0700 Subject: [PATCH 07/22] fix(security): check outbound certificate revocation Apply CheckCertificateRevocation consistently to TLS client and server authentication, cover both silo and external-client outbound paths, and align the TLS guidance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e71c4ddd-5362-4204-910f-9a742ddd63de --- .../docs/host/transport-layer-security.md | 8 +++-- .../Security/TlsClientConnectionMiddleware.cs | 3 ++ .../Security/TlsOptions.cs | 2 +- .../TlsConnectionTests.cs | 34 +++++++++++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/docs/site/src/content/docs/host/transport-layer-security.md b/docs/site/src/content/docs/host/transport-layer-security.md index a44d356c019..f718c48f9f8 100644 --- a/docs/site/src/content/docs/host/transport-layer-security.md +++ b/docs/site/src/content/docs/host/transport-layer-security.md @@ -93,7 +93,11 @@ Keep trust stores narrow. Don't place unrelated public or corporate roots in a w defaults to TLS 1.2 and TLS 1.3. Retain those defaults unless an interoperability or policy requirement calls for a narrower set. Orleans doesn't enable TLS 1.0 or TLS 1.1 by default. -Set to check remote certificates on inbound silo connections. For outbound connections, set in . Before enabling revocation checks, verify that every workload can reach the certificate revocation list (CRL) or Online Certificate Status Protocol (OCSP) service and decide how outages should affect availability. +Set to +check remote certificates on both inbound and outbound connections. Before +enabling it, verify that every workload can reach the certificate revocation +list (CRL) or Online Certificate Status Protocol (OCSP) service and decide how +outages should affect availability. ## Rotate certificates @@ -125,7 +129,7 @@ Certificate selectors are called during authentication, but certificate loading, - [Network hardening](../security/networking.md) - - -- [Authenticate Orleans silo connections](authenticated-silo-connections.md) +- [Authenticate Orleans connections](authenticated-silo-connections.md) - [Client configuration](configuration-guide/client-configuration.md) - [Server configuration](configuration-guide/server-configuration.md) - [.NET TLS/SSL best practices](https://learn.microsoft.com/dotnet/core/extensions/sslstream-best-practices) diff --git a/src/Orleans.Connections.Security/Security/TlsClientConnectionMiddleware.cs b/src/Orleans.Connections.Security/Security/TlsClientConnectionMiddleware.cs index e85be5b1446..069c2389a69 100644 --- a/src/Orleans.Connections.Security/Security/TlsClientConnectionMiddleware.cs +++ b/src/Orleans.Connections.Security/Security/TlsClientConnectionMiddleware.cs @@ -137,6 +137,9 @@ private async Task InnerOnConnectionAsync(ConnectionContext context, ConnectionD ClientCertificates = _certificate == null || _certificateSelector != null ? null : new X509CertificateCollection { _certificate }, LocalCertificateSelectionCallback = selector, EnabledSslProtocols = _options.SslProtocols, + CertificateRevocationCheckMode = _options.CheckCertificateRevocation + ? X509RevocationMode.Online + : X509RevocationMode.NoCheck, }; _options.OnAuthenticateAsClient?.Invoke(context, sslOptions); diff --git a/src/Orleans.Connections.Security/Security/TlsOptions.cs b/src/Orleans.Connections.Security/Security/TlsOptions.cs index b83f577a942..7e0199fe86f 100644 --- a/src/Orleans.Connections.Security/Security/TlsOptions.cs +++ b/src/Orleans.Connections.Security/Security/TlsOptions.cs @@ -72,7 +72,7 @@ public class TlsOptions public SslProtocols SslProtocols { get; set; } = SslProtocols.Tls13 | SslProtocols.Tls12; /// - /// Specifies whether the certificate revocation list is checked during authentication. + /// Specifies whether remote certificate revocation is checked during client and server authentication. /// public bool CheckCertificateRevocation { get; set; } diff --git a/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs b/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs index 4b5be6fe01b..4c550d6cd97 100644 --- a/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs +++ b/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs @@ -259,6 +259,12 @@ public async Task SeparateSiloAndGatewayTls_NegotiateConfiguredApplicationProtoc Assert.Contains(OrleansProtocol, recorder.GetProtocols(ConnectionPath.ClientOutbound)); Assert.DoesNotContain(AuthenticatedSiloProtocol, recorder.GetProtocols(ConnectionPath.GatewayInbound)); Assert.DoesNotContain(AuthenticatedSiloProtocol, recorder.GetProtocols(ConnectionPath.ClientOutbound)); + Assert.Contains( + System.Security.Cryptography.X509Certificates.X509RevocationMode.Online, + recorder.GetRevocationModes(ConnectionPath.SiloOutbound)); + Assert.Contains( + System.Security.Cryptography.X509Certificates.X509RevocationMode.Online, + recorder.GetRevocationModes(ConnectionPath.ClientOutbound)); } finally { @@ -286,6 +292,11 @@ public void Configure(IHostBuilder hostBuilder) ConfigureTls(options, certificate); options.OnAuthenticateAsClient = (_, authenticationOptions) => { + recorder.RecordRevocationMode( + ConnectionPath.SiloOutbound, + authenticationOptions.CertificateRevocationCheckMode); + authenticationOptions.CertificateRevocationCheckMode = + System.Security.Cryptography.X509Certificates.X509RevocationMode.NoCheck; authenticationOptions.TargetHost = CertificateSubjectName; authenticationOptions.ApplicationProtocols = [ @@ -330,8 +341,14 @@ public void Configure(IConfiguration configuration, IClientBuilder clientBuilder clientBuilder.UseTls(options => { options.AllowAnyRemoteCertificate(); + options.CheckCertificateRevocation = true; options.OnAuthenticateAsClient = (_, authenticationOptions) => { + recorder.RecordRevocationMode( + ConnectionPath.ClientOutbound, + authenticationOptions.CertificateRevocationCheckMode); + authenticationOptions.CertificateRevocationCheckMode = + System.Security.Cryptography.X509Certificates.X509RevocationMode.NoCheck; authenticationOptions.TargetHost = CertificateSubjectName; }; }); @@ -348,6 +365,7 @@ private static void ConfigureTls(TlsOptions options, System.Security.Cryptograph options.SslProtocols = System.Security.Authentication.SslProtocols.Tls12; options.AllowAnyRemoteCertificate(); options.RemoteCertificateMode = RemoteCertificateMode.AllowCertificate; + options.CheckCertificateRevocation = true; } private sealed class ProtocolRecordingMiddleware(ProtocolRecorder recorder, ConnectionPath path) : IConnectionMiddleware @@ -363,6 +381,9 @@ public async Task OnConnectionAsync(ConnectionContext context, ConnectionDelegat private sealed class ProtocolRecorder { private readonly ConcurrentDictionary> _protocols = new(); + private readonly ConcurrentDictionary< + ConnectionPath, + ConcurrentBag> _revocationModes = new(); public void Record(ConnectionPath path, string? protocol) { @@ -373,6 +394,19 @@ public string[] GetProtocols(ConnectionPath path) { return _protocols.TryGetValue(path, out var protocols) ? protocols.ToArray() : []; } + + public void RecordRevocationMode( + ConnectionPath path, + System.Security.Cryptography.X509Certificates.X509RevocationMode mode) + { + _revocationModes.GetOrAdd(path, static _ => []).Add(mode); + } + + public System.Security.Cryptography.X509Certificates.X509RevocationMode[] GetRevocationModes( + ConnectionPath path) + { + return _revocationModes.TryGetValue(path, out var modes) ? modes.ToArray() : []; + } } private enum ConnectionPath From d0c76785d06b4681d1a1d69afcd305dae090f807 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Fri, 7 Aug 2026 19:35:46 -0700 Subject: [PATCH 08/22] docs(security): add production setup guidance Document secure topology planning, Entra and certificate provisioning, silo and client configuration, fail-closed test cases, staged rollout, monitoring, rotation, incident recovery, and production readiness. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e71c4ddd-5362-4204-910f-9a742ddd63de --- .../src/content/docs/deployment/networking.md | 5 +- .../docs/deployment/production-readiness.md | 7 +- .../host/authenticated-silo-connections.md | 190 +++++++++++++++--- .../AuthenticatedSiloConnections/README.md | 23 ++- 4 files changed, 194 insertions(+), 31 deletions(-) diff --git a/docs/site/src/content/docs/deployment/networking.md b/docs/site/src/content/docs/deployment/networking.md index 24b367804c2..47ba0b9b098 100644 --- a/docs/site/src/content/docs/deployment/networking.md +++ b/docs/site/src/content/docs/deployment/networking.md @@ -84,7 +84,10 @@ Allow only the required paths: - Application ingress: the application's HTTP, gRPC, or other public protocol. - Provider endpoints: the identities and destinations required by each configured provider. -Don't expose the silo port or gateway port to the public internet. If clients cross an untrusted network, use [Orleans TLS](../host/transport-layer-security.md) and enforce workload identity at the surrounding network boundary. +Don't expose the silo port or gateway port to the public internet. Protect +Orleans traffic with [TLS](../host/transport-layer-security.md), and use +[authenticated Orleans connections](../host/authenticated-silo-connections.md) +when silos or clients must prove workload identity at the transport boundary. ## Validate connectivity diff --git a/docs/site/src/content/docs/deployment/production-readiness.md b/docs/site/src/content/docs/deployment/production-readiness.md index 4d481600e4b..df44181a4f8 100644 --- a/docs/site/src/content/docs/deployment/production-readiness.md +++ b/docs/site/src/content/docs/deployment/production-readiness.md @@ -1,7 +1,7 @@ --- title: Production-readiness checklist description: Review an Orleans deployment before it receives production traffic. -ms.date: 08/02/2026 +ms.date: 08/07/2026 ms.topic: checklist --- @@ -45,12 +45,15 @@ Complete this checklist for each production environment. Record owners, expected - [ ] The [Orleans trust boundaries](../security/index.md) and application-owned security controls are documented. - [ ] Only trusted workloads can reach silo and gateway ports. -- [ ] Orleans transport security is configured when the network isn't already a trusted, isolated boundary. See [Orleans Transport Layer Security](../host/transport-layer-security.md). +- [ ] TLS protects silo-to-silo and client-to-gateway traffic, with platform chain, DNS-name, EKU, and revocation validation. See [Secure Orleans connections with TLS](../host/transport-layer-security.md). +- [ ] Workload authentication uses cluster-specific audiences, separate silo and client roles, explicit caller allowlists, and fail-closed enforcement. See [Authenticate Orleans connections](../host/authenticated-silo-connections.md). - [ ] Grain calls enforce [application authentication and authorization](../security/authentication-authorization.md), and validated credentials establish the identity carried through request context. - [ ] Serializer type-name resolution follows the [least-privilege type policy](../security/serialization.md). +- [ ] Membership, storage, reminder, and stream providers independently use encrypted transport, workload identity, and least-privilege permissions. - [ ] Administrative endpoints, health details, metrics, and logs don't expose secrets or tenant data. - [ ] Provider identities have least privilege for membership, state, reminders, and streams. - [ ] Certificates and credentials have rotation and expiry alerts. +- [ ] Negative connection tests prove that wrong certificates, tenants, audiences, roles, caller IDs, and baseline-only peers are rejected. ## Observability and operations diff --git a/docs/site/src/content/docs/host/authenticated-silo-connections.md b/docs/site/src/content/docs/host/authenticated-silo-connections.md index b99671bcc1f..9de99f14808 100644 --- a/docs/site/src/content/docs/host/authenticated-silo-connections.md +++ b/docs/site/src/content/docs/host/authenticated-silo-connections.md @@ -20,10 +20,38 @@ authentication as one ordered policy. > Silo and client connections are configured independently. Enabling > authentication for one path doesn't silently change the other. -Install `Microsoft.Orleans.Connections.Security` and -`Microsoft.Orleans.Connections.Security.Entra` in every silo. The Entra package -acquires and validates tokens, including metadata and signing-key rollover. -Don't copy JWT parsing or validation logic into the application. +## Plan the secure topology + +Start with a path-by-path policy. Don't treat "inside the cluster network" as a +single trust decision. + +| Path | Listener | Allowed callers | Recommended controls | +|---|---|---|---| +| Silo to silo | Silo port | Workload identities for this cluster only | Private network policy, TLS or mTLS, `Orleans.Silo.Connect`, cluster-specific audience, silo caller allowlist | +| External client to gateway | Gateway port | Explicit application workloads | Private network policy, server-authenticated TLS or mTLS, `Orleans.Client.Connect`, client caller allowlist | +| Public user traffic | Application ingress | End users or upstream services | Application protocol authentication and authorization; don't expose an Orleans port as public ingress | +| Membership, storage, reminders, and streams | Provider endpoints | Silo provider identity | Provider-native TLS, workload identity, and least-privilege data-plane permissions | + +Connection authentication protects the first two paths only. It doesn't secure +provider traffic or replace authorization at your application's public API. +Don't expose the silo or gateway port to the public internet. + +Install `Microsoft.Orleans.Connections.Security` in every silo and external +Orleans client. Install `Microsoft.Orleans.Connections.Security.Entra` in every +process which acquires or validates an Entra token. The Entra package handles +token acquisition and strict validation, including metadata and signing-key +rollover. Don't copy JWT parsing or validation logic into the application. + +Before implementation, record the owner and expected value for each of these +items: + +- `ServiceId` and environment-specific `ClusterId`. +- Silo and gateway DNS names, ports, and permitted network sources. +- Certificate issuers, SANs, EKUs, trust stores, and revocation endpoints. +- Entra tenant, resource application, exact audience, roles, and caller + application IDs. +- Credential and certificate rotation owners, alert thresholds, and emergency + revocation procedure. ## Understand the security boundary @@ -51,7 +79,7 @@ bearer token before expiration, or compromise of a trusted CA, identity provider, signing key, or host. Use short-lived tokens, workload isolation, network policy, and optionally mTLS to reduce the remaining risk. -## Bind authorization to one cluster and environment +## Provision Entra authorization Audience validation alone isn't caller authorization. Configure all of the following: @@ -64,11 +92,59 @@ following: 4. A separate explicit caller application-ID allowlist for silos and external clients. +Create the identity boundary in this order: + +1. Create a resource application for the Orleans cluster security boundary. +2. Give each environment a distinct identifier URI which includes the + `ClusterId`, for example + `api:///contoso-prod-westus`. +3. Define application roles `Orleans.Silo.Connect` and + `Orleans.Client.Connect`, with applications as allowed member types. +4. Create or select one workload identity for each independently deployable + silo and client workload. Don't share a client secret or exported + certificate across the fleet. +5. Assign only the matching application role. A client identity doesn't need + the silo role. +6. Put each application ID in the matching caller allowlist. Role assignment + and allowlisting are separate checks; require both. +7. Configure a managed identity, workload identity federation, or another + non-interactive credential. Grant no Microsoft Graph permission merely to + establish an Orleans connection. + The audience must exactly match the resource identifier registered in Microsoft Entra. Don't remove the `api://` prefix or share a general-purpose silo audience across environments. If the audience must be shared, require a separate cluster-specific claim or role and compare it exactly to the local `ClusterId`. +Use a tenant-specific authority. Don't use `common`, `organizations`, or +`consumers`. Permit only application tokens issued to the expected tenant, +audience, caller application, role, and cluster binding. Keep access tokens +short-lived and keep every host's clock synchronized. + +## Issue and deploy certificates + +Use a CA and trust-store design which identifies the workload boundary, not +merely any machine with a publicly trusted certificate. + +| Endpoint | Certificate requirements | +|---|---| +| Silo | Server Authentication EKU, DNS SAN matching the configured target host, and a protected private key | +| Silo when using mTLS between silos | Both Server Authentication and Client Authentication EKUs | +| External client when using bearer authentication with server-authenticated TLS | No client certificate; it still validates the gateway certificate | +| External client when using mTLS | A distinct certificate with Client Authentication EKU | + +Install the issuing roots before deploying leaf certificates. Keep workload +trust stores narrow, and don't accept an arbitrary certificate from a broad +corporate or public root as proof of cluster membership. Restrict private-key +access to the process identity and load passwords from a secret provider rather +than ordinary configuration. + +The gateway's DNS SAN must match the target host used by external clients. If +silo and gateway traffic use different DNS names or certificate policies, +configure their TLS policies independently instead of relying on the shared + +convenience API. + Prefer an explicit appropriate to the hosting environment. The maintained sample supplies a `WorkloadIdentityCredential`; it doesn't silently use a developer or unrelated cached identity: @@ -78,13 +154,14 @@ doesn't silently use a developer or unrelated cached identity: Create the credential once and reuse it. The credential implementation owns its token cache. -## Configure TLS and Entra authentication +## Configure silos and clients -The sample configures mTLS, platform chain and DNS-name validation, and online -revocation checking. Install only the expected public or private roots in the -platform trust store and overlap old and new roots there during CA rotation. -Each silo certificate therefore needs both the Server Authentication and -Client Authentication EKUs. +The sample configures mTLS between silos and server-authenticated TLS plus +bearer authentication for external clients. Both paths use platform chain and +DNS-name validation with online revocation checking. Install only the expected +public or private roots in the platform trust store and overlap old and new +roots there during CA rotation. Each silo certificate therefore needs both the +Server Authentication and Client Authentication EKUs. :::code language="csharp" source="../../../../../../samples/AuthenticatedSiloConnections/SiloAuthentication.cs" id="AuthenticatedSiloConnections"::: @@ -93,7 +170,7 @@ and trusted. Never replace this policy with or a custom certificate-validation callback. `Required` mode rejects custom certificate-validation callbacks and direct per-connection TLS authentication -callbacks during startup. +callbacks during configuration. The example deliberately bounds token bytes, exchange duration, concurrent handshakes, and minimum remaining token lifetime. Keep all size, duration, @@ -101,6 +178,11 @@ queue, concurrency, metadata-refresh, and token-lifetime limits finite. Configuration is validated at startup; invalid middleware ordering, missing TLS/provider registrations, and conflicting TLS policies fail closed. +Call + +once on every silo. A silo both validates inbound silo tokens and acquires a +token for outbound silo connections, so it needs both a provider and validator. + ### Authenticate external clients Configure the gateway side on every silo. It validates client tokens before the @@ -122,10 +204,19 @@ client-to-gateway traffic from silo-to-silo traffic for custom providers. Gateway authentication does not authorize individual grain calls or propagate the authenticated principal into grain requests. +Call + +once on every gateway-hosting silo and once on every external client. The +gateway needs a validator; the external client needs a provider and an exact +TLS target host. When both policies run in one silo, Orleans keeps their Entra +options and token services isolated. + ## Choose an enforcement mode is snapshotted at startup. Changing it requires a silo or client process restart. +Choose the mode independently for silo and client paths, but keep both ends of +each path rollout-compatible. | Mode | Negotiation and acceptance behavior | |---|---| @@ -165,15 +256,15 @@ doesn't surface only when many connections approach expiration. Define gates and ownership before changing modes: 1. Deploy the code everywhere with `Disabled` and restart the fleet. -2. Restart silos and clients by failure domain with `Audit`. Retain canaries and monitor baseline - fallback, acquisition and validation failures, authorization denials, - provider availability, latency, concurrency saturation, and metadata - refresh. - 3. Remain in `Audit` until every expected silo pair and external client path - has negotiated authentication. Deliberately reconnect expected peers and - representative clients, then verify each new connection authenticates, - unexpected fallback and failure rates remain zero, and representative - authenticated connections recycle at token expiry. +2. Restart silos and clients by failure domain with `Audit`. Retain canaries and + monitor baseline fallback, acquisition and validation failures, + authorization denials, provider availability, latency, concurrency + saturation, and metadata refresh. +3. Remain in `Audit` until every expected silo pair and external client path has + negotiated authentication. Deliberately reconnect expected peers and + representative clients, then verify each new connection authenticates, + unexpected fallback and failure rates remain zero, and representative + authenticated connections recycle at token expiry. 4. Restart `Required` canaries. Verify connectivity, membership stability, token renewal, and provider health before proceeding through each failure domain. @@ -195,6 +286,27 @@ Required -> Audit across the fleet -> Disabled across the fleet wire-compatible. Document who may authorize the downgrade, how restarts are coordinated, and the maximum accepted exposure window in `Audit`. +## Prove the policy fails closed + +Run positive and negative connection tests in a non-production environment +before enabling `Required`. A successful happy-path connection alone doesn't +prove the boundary. + +| Test | Expected result in `Required` | +|---|---| +| Authorized silo and authorized external client | Connect and make representative grain calls | +| Missing token, malformed token, or user-delegated token | Connection rejected before the Orleans preamble | +| Wrong tenant, audience, cluster binding, role, or caller application ID | Connection rejected | +| Expired token or token below the minimum remaining lifetime | Connection rejected | +| Untrusted issuer, wrong DNS SAN, missing EKU, expired certificate, or revoked certificate | TLS handshake rejected | +| Peer using baseline Orleans ALPN only | TLS negotiation fails; no unauthenticated fallback | +| Token provider, metadata endpoint, or signing-key refresh unavailable | New connection fails; mode remains `Required` | +| Handshake concurrency or queue limit exceeded | Excess work is rejected without unbounded growth | + +Repeat the tests after certificate, federated-credential, app-role, audience, +and signing-key rotation. Include reconnects: an already open connection can +hide a broken credential until token-expiry recycling or a process restart. + ## Monitor authentication Export the `Microsoft.Orleans.Connections.Security` meter. The maintained @@ -219,15 +331,41 @@ exception values as metric tags. Authentication logs use fixed event IDs and bounded categories such as overload, timeout, protocol error, TLS policy error, acquisition failure, validation failure, authorization failure, and expiration. Preserve event ID, -connection type, category, direction, and mode in the log pipeline. Tokens must never appear in -logs, traces, metrics, activities, exceptions, or connection features. +connection type, category, direction, and mode in the log pipeline. Tokens must +never appear in logs, traces, metrics, activities, exceptions, or connection +features. + +## Operate and recover + +Maintain runbooks for these events: + +- **Certificate rotation:** Trust the replacement issuer first, overlap old and + new chains, deploy new leaves, force representative reconnects, and only then + remove the old trust root. +- **Workload credential rotation:** Create the replacement credential or + federation before removing the old one. Verify a newly opened connection, + not only an existing connection. +- **Signing-key rollover:** Keep metadata refresh healthy and alert on repeated + unknown-key refresh failures. Don't pin one issuer signing key in + application code. +- **Caller removal:** Remove the role assignment and allowlist entry, then + recycle active connections if access must end before their validated token + expiration. +- **Credential or private-key compromise:** Block the workload at the network + boundary, revoke or disable the credential, remove authorization, rotate + affected keys and certificates, and restart or recycle connections. Don't + automatically change enforcement to `Audit` or `Disabled`. +- **Identity-provider outage:** Existing authenticated connections can + continue until recycled. New connections fail closed in `Required`. Use + capacity and availability planning rather than an automatic security + downgrade. ## Production checklist - Use an explicit workload credential and keep its federated token or secret material out of source and ordinary configuration. -- Give each cluster/environment an exact audience and require both a role and - caller allowlist. +- Give each cluster/environment an exact audience, use separate silo and client + roles, and require both the matching role and caller allowlist. - Keep TLS 1.2 or later, certificate chain/name checks, revocation policy, and narrow trust roots enabled. - Bound token, timeout, concurrency, queue, metadata refresh, and token lifetime @@ -236,6 +374,8 @@ logs, traces, metrics, activities, exceptions, or connection features. - Synchronize clocks and exercise certificate, key, and identity rotation. - Treat unexpected baseline fallback in `Audit` and every authentication failure in `Required` as an operational event. +- Test wrong-certificate, wrong-identity, provider-outage, overload, rotation, + reconnect, and rollback scenarios before production. ## See also diff --git a/samples/AuthenticatedSiloConnections/README.md b/samples/AuthenticatedSiloConnections/README.md index 1b4a6b5f515..fb291fb4e8e 100644 --- a/samples/AuthenticatedSiloConnections/README.md +++ b/samples/AuthenticatedSiloConnections/README.md @@ -1,9 +1,10 @@ # Authenticated silo connections This sample configures mutual TLS (mTLS) and Microsoft Entra workload -authentication for silo-to-silo and external client-to-gateway connections. It uses an explicit -`WorkloadIdentityCredential`; it doesn't construct `DefaultAzureCredential` or -copy JWT validation logic into the application. +authentication for silo-to-silo connections, plus server-authenticated TLS and +Entra authentication for external client-to-gateway connections. It uses an +explicit `WorkloadIdentityCredential`; it doesn't construct +`DefaultAzureCredential` or copy JWT validation logic into the application. The sample is a two-process localhost cluster. Start one process with the default ports, then start another with @@ -29,6 +30,11 @@ application ID, and application role are validated by `Microsoft.Orleans.Connections.Security.Entra`. Don't replace that package with sample-owned JWT parsing or validation. +The sample uses one resource application but separate application roles and +caller allowlists for silo and external-client traffic. Use distinct resource +applications or audiences as well if those paths have different administrators +or compromise boundaries. + ## Configure TLS Provide a PFX whose certificate has the Server Authentication and Client @@ -55,6 +61,12 @@ baseline fallback and unexpected failure rates remain zero, and token-expiry recycling succeeds for authenticated connections. Changing modes requires a restart. +Before production, also verify that connections fail for an untrusted +certificate, wrong DNS SAN, wrong tenant or audience, missing role, unlisted +caller application ID, expired token, and a peer which supports only the +baseline Orleans ALPN. Repeat the checks after certificate and identity +rotation. + `Required` has no unauthenticated fallback. Roll back fleet-wide from `Required` to `Audit`, and only then from `Audit` to `Disabled`. Never automatically weaken the mode because Microsoft Entra or metadata is @@ -64,3 +76,8 @@ The gateway validates external client bearer tokens using a distinct `Orleans.Client.Connect` role and caller allowlist. External clients must call `UseAuthenticatedClientConnections` with a token provider and the same exact audience, tenant, client role, and cluster binding. + +See the maintained [authenticated Orleans connections +guide](../../docs/site/src/content/docs/host/authenticated-silo-connections.md) +for the complete production setup, rollout, validation, monitoring, rotation, +and incident-response guidance. From 22da62fb286a0cc472a5cf647c01b90d3e3025fc Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Sat, 8 Aug 2026 07:31:09 -0700 Subject: [PATCH 09/22] docs(security): clarify Orleans trust boundary State that authenticated silos and clients are admitted into the same coarse-grained trust boundary, that Orleans does not sandbox clients per grain call, and that configured storage and providers are trusted cluster infrastructure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e71c4ddd-5362-4204-910f-9a742ddd63de --- .../docs/deployment/production-readiness.md | 2 + .../host/authenticated-silo-connections.md | 60 +++++++++++++------ .../docs/host/transport-layer-security.md | 9 ++- .../AuthenticatedSiloConnections/README.md | 6 ++ 4 files changed, 57 insertions(+), 20 deletions(-) diff --git a/docs/site/src/content/docs/deployment/production-readiness.md b/docs/site/src/content/docs/deployment/production-readiness.md index df44181a4f8..b046c46f234 100644 --- a/docs/site/src/content/docs/deployment/production-readiness.md +++ b/docs/site/src/content/docs/deployment/production-readiness.md @@ -47,9 +47,11 @@ Complete this checklist for each production environment. Record owners, expected - [ ] Only trusted workloads can reach silo and gateway ports. - [ ] TLS protects silo-to-silo and client-to-gateway traffic, with platform chain, DNS-name, EKU, and revocation validation. See [Secure Orleans connections with TLS](../host/transport-layer-security.md). - [ ] Workload authentication uses cluster-specific audiences, separate silo and client roles, explicit caller allowlists, and fail-closed enforcement. See [Authenticate Orleans connections](../host/authenticated-silo-connections.md). +- [ ] Every silo and external Orleans client admitted by these policies is trusted to access the cluster; untrusted users are authenticated and authorized at application ingress. - [ ] Grain calls enforce [application authentication and authorization](../security/authentication-authorization.md), and validated credentials establish the identity carried through request context. - [ ] Serializer type-name resolution follows the [least-privilege type policy](../security/serialization.md). - [ ] Membership, storage, reminder, and stream providers independently use encrypted transport, workload identity, and least-privilege permissions. +- [ ] Configured providers and persisted data are treated as trusted cluster infrastructure, with administrative access restricted accordingly. - [ ] Administrative endpoints, health details, metrics, and logs don't expose secrets or tenant data. - [ ] Provider identities have least privilege for membership, state, reminders, and streams. - [ ] Certificates and credentials have rotation and expiry alerts. diff --git a/docs/site/src/content/docs/host/authenticated-silo-connections.md b/docs/site/src/content/docs/host/authenticated-silo-connections.md index 9de99f14808..db0296bb56d 100644 --- a/docs/site/src/content/docs/host/authenticated-silo-connections.md +++ b/docs/site/src/content/docs/host/authenticated-silo-connections.md @@ -25,15 +25,28 @@ authentication as one ordered policy. Start with a path-by-path policy. Don't treat "inside the cluster network" as a single trust decision. -| Path | Listener | Allowed callers | Recommended controls | -|---|---|---|---| -| Silo to silo | Silo port | Workload identities for this cluster only | Private network policy, TLS or mTLS, `Orleans.Silo.Connect`, cluster-specific audience, silo caller allowlist | -| External client to gateway | Gateway port | Explicit application workloads | Private network policy, server-authenticated TLS or mTLS, `Orleans.Client.Connect`, client caller allowlist | -| Public user traffic | Application ingress | End users or upstream services | Application protocol authentication and authorization; don't expose an Orleans port as public ingress | -| Membership, storage, reminders, and streams | Provider endpoints | Silo provider identity | Provider-native TLS, workload identity, and least-privilege data-plane permissions | - -Connection authentication protects the first two paths only. It doesn't secure -provider traffic or replace authorization at your application's public API. +| Component or path | Trust requirement | Recommended controls | +|---|---|---| +| Silo-to-silo connection | Every admitted silo is trusted as part of this cluster | Private network policy, TLS or mTLS, `Orleans.Silo.Connect`, cluster-specific audience, silo caller allowlist | +| External client-to-gateway connection | Every admitted client is trusted to access the Orleans cluster | Private network policy, server-authenticated TLS or mTLS, `Orleans.Client.Connect`, client caller allowlist | +| Public user traffic | End users and arbitrary upstream callers aren't inside the Orleans trust boundary | Authenticate and authorize at application ingress; don't expose an Orleans port as public ingress | +| Membership, storage, reminders, and streams | Configured providers and the data they return are trusted cluster infrastructure | Provider-native TLS, workload identity, least-privilege data-plane permissions, and administrative access controls | + +Orleans has a coarse-grained trust boundary. If a silo or external Orleans +client can connect and authenticate, Orleans treats it as trusted. An admitted +client can invoke any grain interface available to it; Orleans connection +authentication isn't a per-grain or per-method authorization system. Therefore, +only admit application workloads which belong inside the same trust boundary as +the cluster. Authenticate and authorize untrusted end users before they reach an +Orleans client, and expose only application-specific operations through that +trusted client. + +Configured storage, membership, reminder, and stream providers are trusted too. +Orleans assumes that provider responses and persisted data are authentic and +authorized for the cluster. Protect provider credentials, transport, data, and +administrative access accordingly. A malicious or compromised provider is +outside this connection-authentication threat model. + Don't expose the silo or gateway port to the public internet. Install `Microsoft.Orleans.Connections.Security` in every silo and external @@ -66,18 +79,20 @@ TCP ``` TLS protects the bearer token in transit and authenticates the TLS server. The -token authenticates and authorizes the connecting workload. Membership still -determines which silos make up the cluster; connection authentication doesn't -replace membership, authorize individual grain calls, propagate end-user -identity, or prove that a workload owns the exact `SiloAddress` it claims. +token decides whether the connecting workload is admitted to the Orleans trust +boundary. Membership still determines which silos make up the cluster. +Connection authentication doesn't propagate end-user identity, enforce +per-grain or per-method authorization, or prove that a workload owns the exact +`SiloAddress` it claims. The design protects against network peers without an authorized workload credential, unauthenticated downgrade in enforcement mode, cross-cluster token reuse, and malformed or excessively concurrent authentication exchanges. It -doesn't protect against compromise of an authorized silo, theft and replay of a -bearer token before expiration, or compromise of a trusted CA, identity -provider, signing key, or host. Use short-lived tokens, workload isolation, -network policy, and optionally mTLS to reduce the remaining risk. +doesn't protect against compromise of an admitted silo or client, malicious or +corrupted trusted storage, theft and replay of a bearer token before expiration, +or compromise of a trusted CA, identity provider, signing key, or host. Use +short-lived tokens, workload isolation, network policy, and optionally mTLS to +reduce the remaining risk. ## Provision Entra authorization @@ -201,8 +216,11 @@ the external-client role and allowlist separate from the silo policy. The properties distinguish client-to-gateway traffic from silo-to-silo traffic for custom providers. -Gateway authentication does not authorize individual grain calls or propagate -the authenticated principal into grain requests. +After gateway authentication succeeds, Orleans trusts that client connection. +The authenticated principal isn't propagated into grain requests, and Orleans +doesn't apply per-grain or per-method authorization. If callers require +different permissions, enforce them before they enter the Orleans client or +implement an explicit application-level authorization design. Call @@ -371,6 +389,10 @@ Maintain runbooks for these events: - Bound token, timeout, concurrency, queue, metadata refresh, and token lifetime settings. - Restrict silo and gateway ports with network policy. +- Admit only silos and clients which belong inside the cluster trust boundary; + don't use a direct Orleans client as an untrusted public endpoint. +- Treat configured storage and providers as trusted infrastructure, and protect + their credentials, transport, data, and administrative access. - Synchronize clocks and exercise certificate, key, and identity rotation. - Treat unexpected baseline fallback in `Audit` and every authentication failure in `Required` as an operational event. diff --git a/docs/site/src/content/docs/host/transport-layer-security.md b/docs/site/src/content/docs/host/transport-layer-security.md index f718c48f9f8..10ece8fa099 100644 --- a/docs/site/src/content/docs/host/transport-layer-security.md +++ b/docs/site/src/content/docs/host/transport-layer-security.md @@ -30,7 +30,14 @@ The two similarly named options apply at different stages: Every silo both accepts and initiates connections. For mTLS, a silo certificate therefore needs the Server Authentication extended key usage (EKU) for inbound connections and the Client Authentication EKU for outbound connections. For server-authenticated TLS, the silo certificate only needs Server Authentication. An Orleans client certificate used for mTLS needs Client Authentication. Certificate identity, issuance, and trust should reflect workload roles rather than reusing one certificate and private key across the cluster. -TLS provides confidentiality, integrity, and certificate-based peer authentication for the Orleans transport. It doesn't authorize grain calls, isolate tenants, protect data after either process receives it, or secure membership/storage provider traffic unless those providers are separately configured. Compromise of a trusted certificate or private key can let an attacker impersonate that workload. +TLS provides confidentiality, integrity, and certificate-based peer +authentication for the Orleans transport. Orleans trusts a silo or client after +it is admitted; TLS doesn't provide per-grain authorization, isolate tenants, or +protect data after either process receives it. Configured membership and storage +providers and their data are trusted cluster infrastructure, and their transport +and access controls must be secured separately. Compromise of a trusted +certificate or private key can let an attacker enter the Orleans trust boundary +as that workload. ## Load the local certificate diff --git a/samples/AuthenticatedSiloConnections/README.md b/samples/AuthenticatedSiloConnections/README.md index fb291fb4e8e..e5a4785372c 100644 --- a/samples/AuthenticatedSiloConnections/README.md +++ b/samples/AuthenticatedSiloConnections/README.md @@ -30,6 +30,12 @@ application ID, and application role are validated by `Microsoft.Orleans.Connections.Security.Entra`. Don't replace that package with sample-owned JWT parsing or validation. +An authenticated silo or external Orleans client is inside the Orleans trust +boundary. Orleans doesn't apply per-grain or per-method authorization to that +connection. Admit only trusted application workloads, and authenticate and +authorize untrusted end users before their requests reach an Orleans client. +Configured storage and other providers are trusted cluster infrastructure. + The sample uses one resource application but separate application roles and caller allowlists for silo and external-client traffic. Use distinct resource applications or audiences as well if those paths have different administrators From 84b64f98d60758bd8c7dee5c6999431fedf484ff Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Tue, 11 Aug 2026 12:08:57 -0700 Subject: [PATCH 10/22] fix(samples): make authenticated connections standalone Use package references and sample-local central package versions so the authenticated connections sample satisfies the standalone sample validation introduced on main. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e71c4ddd-5362-4204-910f-9a742ddd63de --- .../AuthenticatedSiloConnections.csproj | 6 +++--- .../Directory.Packages.props | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 samples/AuthenticatedSiloConnections/Directory.Packages.props diff --git a/samples/AuthenticatedSiloConnections/AuthenticatedSiloConnections.csproj b/samples/AuthenticatedSiloConnections/AuthenticatedSiloConnections.csproj index c03a6f9c780..2ff3157e4c3 100644 --- a/samples/AuthenticatedSiloConnections/AuthenticatedSiloConnections.csproj +++ b/samples/AuthenticatedSiloConnections/AuthenticatedSiloConnections.csproj @@ -8,12 +8,12 @@ - - - + + + diff --git a/samples/AuthenticatedSiloConnections/Directory.Packages.props b/samples/AuthenticatedSiloConnections/Directory.Packages.props new file mode 100644 index 00000000000..9a94ce35b13 --- /dev/null +++ b/samples/AuthenticatedSiloConnections/Directory.Packages.props @@ -0,0 +1,17 @@ + + + true + true + + + + + + + + + + + + + From 622a5e045b7f3553ba588c3884c774932e638107 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Wed, 19 Aug 2026 02:32:29 -0700 Subject: [PATCH 11/22] fix(docs): align connection security guidance Register and localize the compiled connection-security snippets, document the Entra package, and account for sample and package URLs which become available after publication. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e71c4ddd-5362-4204-910f-9a742ddd63de --- docs/Docs.slnx | 4 + .../host/authenticated-silo-connections.md | 10 +- ...thenticatedSiloConnections.Snippets.csproj | 9 - .../ConnectionAuthenticationExamples.cs | 199 ++++++++++++++++++ .../content/docs/resources/nuget-packages.md | 1 + .../src/data/external-link-allowlist.json | 2 + .../src/data/unpublished-api-packages.json | 4 +- 7 files changed, 214 insertions(+), 15 deletions(-) create mode 100644 docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs diff --git a/docs/Docs.slnx b/docs/Docs.slnx index a5ca91fdae3..ac17e3c0192 100644 --- a/docs/Docs.slnx +++ b/docs/Docs.slnx @@ -69,6 +69,10 @@ + + + + diff --git a/docs/site/src/content/docs/host/authenticated-silo-connections.md b/docs/site/src/content/docs/host/authenticated-silo-connections.md index db0296bb56d..24e3ece747f 100644 --- a/docs/site/src/content/docs/host/authenticated-silo-connections.md +++ b/docs/site/src/content/docs/host/authenticated-silo-connections.md @@ -164,7 +164,7 @@ Prefer an explicit appropriate to the hosting environment. The maintained sample supplies a `WorkloadIdentityCredential`; it doesn't silently use a developer or unrelated cached identity: -:::code language="csharp" source="../../../../../../samples/AuthenticatedSiloConnections/Program.cs" id="ExplicitCredential"::: +:::code language="csharp" source="snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs" id="ExplicitCredential"::: Create the credential once and reuse it. The credential implementation owns its token cache. @@ -178,7 +178,7 @@ public or private roots in the platform trust store and overlap old and new roots there during CA rotation. Each silo certificate therefore needs both the Server Authentication and Client Authentication EKUs. -:::code language="csharp" source="../../../../../../samples/AuthenticatedSiloConnections/SiloAuthentication.cs" id="AuthenticatedSiloConnections"::: +:::code language="csharp" source="snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs" id="AuthenticatedSiloConnections"::: The configured `TargetHost` must match a DNS SAN and the chain must be valid and trusted. Never replace this policy with @@ -203,11 +203,11 @@ token for outbound silo connections, so it needs both a provider and validator. Configure the gateway side on every silo. It validates client tokens before the gateway reads the Orleans connection preamble: -:::code language="csharp" source="../../../../../../samples/AuthenticatedSiloConnections/SiloAuthentication.cs" id="AuthenticatedClientGateway"::: +:::code language="csharp" source="snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs" id="AuthenticatedClientGateway"::: Configure each external Orleans client with the corresponding outbound policy: -:::code language="csharp" source="../../../../../../samples/AuthenticatedSiloConnections/ClientAuthentication.cs" id="AuthenticatedClient"::: +:::code language="csharp" source="snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs" id="AuthenticatedClient"::: The client and gateway must use compatible enforcement modes and the same Entra audience, tenant, cluster binding, client role, and caller authorization. Keep @@ -330,7 +330,7 @@ hide a broken credential until token-expiry recycling or a process restart. Export the `Microsoft.Orleans.Connections.Security` meter. The maintained sample enables an OTLP exporter when `OTEL_EXPORTER_OTLP_ENDPOINT` is set: -:::code language="csharp" source="../../../../../../samples/AuthenticatedSiloConnections/Program.cs" id="FixedDiagnostics"::: +:::code language="csharp" source="snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs" id="FixedDiagnostics"::: Alert on rates and latency for these instruments: diff --git a/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/AuthenticatedSiloConnections.Snippets.csproj b/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/AuthenticatedSiloConnections.Snippets.csproj index 3c54f17d4e7..db01d6ed977 100644 --- a/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/AuthenticatedSiloConnections.Snippets.csproj +++ b/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/AuthenticatedSiloConnections.Snippets.csproj @@ -1,20 +1,11 @@ - Exe net10.0 enable enable - false $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\..\..\..\..\..\..\..\')) - - - - - - - diff --git a/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs b/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs new file mode 100644 index 00000000000..1273bf56eef --- /dev/null +++ b/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs @@ -0,0 +1,199 @@ +using System.Security.Cryptography.X509Certificates; +using Azure.Core; +using Azure.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using Orleans.Connections.Security; +using Orleans.Connections.Security.Entra; +using Orleans.Hosting; + +namespace Orleans.Docs.ConnectionSecurity; + +internal static class ConnectionAuthenticationExamples +{ + public static TokenCredential CreateCredential(ConnectionSecurityOptions options) + { + // + TokenCredential credential = new WorkloadIdentityCredential( + new WorkloadIdentityCredentialOptions + { + TenantId = options.Entra.TenantId, + ClientId = options.Entra.WorkloadClientId, + TokenFilePath = options.Entra.FederatedTokenFile, + }); + // + + return credential; + } + + public static void ConfigureSilo( + ISiloBuilder siloBuilder, + ConnectionSecurityOptions options, + TokenCredential credential, + X509Certificate2 siloCertificate) + { + // + siloBuilder.UseAuthenticatedSiloConnections( + tls => + { + tls.LocalCertificate = siloCertificate; + tls.RemoteCertificateMode = RemoteCertificateMode.RequireCertificate; + tls.ClientCertificateMode = RemoteCertificateMode.RequireCertificate; + tls.CheckCertificateRevocation = true; + }, + authentication => + { + ConfigureAuthentication( + authentication, + options, + credential, + options.Entra.AllowedSiloCallerClientIds, + "Orleans.Silo.Connect"); + }); + // + + // + siloBuilder.UseAuthenticatedClientConnections( + tls => + { + tls.LocalCertificate = siloCertificate; + tls.RemoteCertificateMode = RemoteCertificateMode.NoCertificate; + }, + authentication => + { + ConfigureAuthentication( + authentication, + options, + credential, + options.Entra.AllowedClientCallerClientIds, + "Orleans.Client.Connect"); + }); + // + } + + public static void ConfigureClient( + IClientBuilder clientBuilder, + ConnectionSecurityOptions options, + TokenCredential credential) + { + // + clientBuilder.UseAuthenticatedClientConnections( + tls => + { + tls.CheckCertificateRevocation = true; + }, + authentication => + { + ConfigureAuthentication( + authentication, + options, + credential, + options.Entra.AllowedClientCallerClientIds, + "Orleans.Client.Connect"); + }); + // + } + + public static void ConfigureDiagnostics( + HostApplicationBuilder builder, + bool exportToOtlp) + { + // + builder.Logging.ClearProviders(); + builder.Logging.AddJsonConsole(console => + { + console.TimestampFormat = "O"; + console.JsonWriterOptions = new() { Indented = false }; + }); + builder.Logging.AddFilter("Orleans.Connections.Security", LogLevel.Information); + builder.Logging.AddFilter("Azure.Identity", LogLevel.Warning); + + builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService( + serviceName: "authenticated-orleans-silo", + serviceInstanceId: Environment.MachineName)) + .WithMetrics(metrics => + { + metrics.AddMeter("Microsoft.Orleans.Connections.Security"); + + if (exportToOtlp) + { + metrics.AddOtlpExporter(); + } + }); + // + } + + private static void ConfigureAuthentication( + SiloConnectionAuthenticationBuilder authentication, + ConnectionSecurityOptions options, + TokenCredential credential, + IEnumerable allowedCallerClientIds, + string requiredRole) + { + authentication.Mode = options.AuthenticationMode; + authentication.TargetHost = options.Certificate.TargetHost; + authentication.TokenExchangeTimeout = TimeSpan.FromSeconds(10); + authentication.MaxTokenSize = 16 * 1024; + authentication.MaxConcurrentInboundAuthentications = 256; + authentication.MaxConcurrentOutboundAuthentications = 256; + authentication.MaxPendingInboundAuthentications = 256; + authentication.MaxPendingOutboundAuthentications = 256; + authentication.MinimumRemainingTokenLifetime = TimeSpan.FromMinutes(2); + + authentication.UseEntra( + credential, + entra => + { + entra.Authority = options.Entra.Authority; + entra.TokenScope = $"{options.Entra.Audience}/.default"; + entra.ValidAudiences.Add(options.Entra.Audience); + entra.ValidTenantIds.Add(options.Entra.TenantId); + entra.ClusterAudienceFormat = + $"api://{options.Entra.ResourceApplicationId}/{{0}}"; + + foreach (var clientId in allowedCallerClientIds) + { + entra.AllowedClientIds.Add(clientId); + } + + entra.RequiredRoles.Add(requiredRole); + }); + } +} + +internal sealed class ConnectionSecurityOptions +{ + public SiloConnectionAuthenticationMode AuthenticationMode { get; init; } + + public CertificateOptions Certificate { get; init; } = new(); + + public EntraOptions Entra { get; init; } = new(); +} + +internal sealed class CertificateOptions +{ + public string TargetHost { get; init; } = ""; +} + +internal sealed class EntraOptions +{ + public string TenantId { get; init; } = ""; + + public string ResourceApplicationId { get; init; } = ""; + + public string WorkloadClientId { get; init; } = ""; + + public string FederatedTokenFile { get; init; } = ""; + + public string Audience { get; init; } = ""; + + public Uri Authority { get; init; } = null!; + + public string[] AllowedSiloCallerClientIds { get; init; } = []; + + public string[] AllowedClientCallerClientIds { get; init; } = []; +} diff --git a/docs/site/src/content/docs/resources/nuget-packages.md b/docs/site/src/content/docs/resources/nuget-packages.md index 30dca3704f7..34abe23d7fe 100644 --- a/docs/site/src/content/docs/resources/nuget-packages.md +++ b/docs/site/src/content/docs/resources/nuget-packages.md @@ -29,6 +29,7 @@ For installation guidance, see [`dotnet package add`](https://learn.microsoft.co | [Microsoft.Orleans.Dashboard](https://www.nuget.org/packages/Microsoft.Orleans.Dashboard) | Built-in Orleans Dashboard server and UI. | | [Microsoft.Orleans.Dashboard.Abstractions](https://www.nuget.org/packages/Microsoft.Orleans.Dashboard.Abstractions) | Dashboard contracts for components which don't host the UI. | | [Microsoft.Orleans.Connections.Security](https://www.nuget.org/packages/Microsoft.Orleans.Connections.Security) | TLS support for Orleans connections. | +| [Microsoft.Orleans.Connections.Security.Entra](https://www.nuget.org/packages/Microsoft.Orleans.Connections.Security.Entra) | Microsoft Entra workload authentication for Orleans connections. | `Microsoft.Orleans.Runtime`, `Microsoft.Orleans.Core`, and the abstractions packages are lower-level dependencies of the metapackages. Reference them directly only when building a library with a narrower dependency requirement. diff --git a/docs/site/src/data/external-link-allowlist.json b/docs/site/src/data/external-link-allowlist.json index e691c21bb91..e9b13910cc2 100644 --- a/docs/site/src/data/external-link-allowlist.json +++ b/docs/site/src/data/external-link-allowlist.json @@ -9,6 +9,8 @@ "https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html": "AWS serves this public CLI configuration page to browsers but rejects the bounded automated HEAD/GET probe with HTTP 403.", "https://docs.aws.amazon.com/sdk-for-net/v4/developer-guide/creds-assign.html": "AWS serves this public SDK credential-resolution page to browsers but rejects the bounded automated HEAD/GET probe with HTTP 403.", "https://docs.aws.amazon.com/streams/latest/dev/introduction.html": "AWS serves this public Kinesis overview to browsers but rejects the bounded automated HEAD/GET probe with HTTP 403.", + "https://github.com/dotnet/orleans/tree/main/samples/AuthenticatedSiloConnections": "The sample is introduced by this pull request, so its permanent main-branch URL becomes available when the pull request merges.", + "https://www.nuget.org/packages/Microsoft.Orleans.Connections.Security.Entra": "The new connection authentication package is documented but not yet published; remove this entry and its unpublished API-package entry after publication.", "https://en.wikipedia.org/wiki/Kalman_filter": "Wikipedia serves this public article to browsers but rejects the bounded automated HEAD/GET probe with HTTP 403.", "https://www.f5.com/company/blog/nginx/nginx-power-of-two-choices-load-balancing-algorithm": "The F5 site serves this canonical NGINX article to browsers but rejects the bounded automated HEAD/GET probe with HTTP 403." } diff --git a/docs/site/src/data/unpublished-api-packages.json b/docs/site/src/data/unpublished-api-packages.json index 64d185c77e2..7bc932d2bd2 100644 --- a/docs/site/src/data/unpublished-api-packages.json +++ b/docs/site/src/data/unpublished-api-packages.json @@ -1,4 +1,6 @@ { "description": "Generated API assemblies which are not currently published as standalone NuGet packages.", - "packages": {} + "packages": { + "Microsoft.Orleans.Connections.Security.Entra": "The new connection authentication provider is awaiting its first NuGet publication." + } } From d7fd9428203e15467a74c865997e5b658c5d93f4 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Wed, 19 Aug 2026 02:42:48 -0700 Subject: [PATCH 12/22] test(security): classify Entra authentication tests Reference the shared test infrastructure and assign the Entra test classes to the standard BVT, provider, category, and security-area traits used by filtered CI runs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e71c4ddd-5362-4204-910f-9a742ddd63de --- .../EntraJwtValidatorTests.cs | 4 ++++ .../EntraMetadataTests.cs | 4 ++++ .../EntraOptionsTests.cs | 4 ++++ .../EntraTokenProviderTests.cs | 4 ++++ .../Orleans.Connections.Security.Entra.Tests.csproj | 1 + 5 files changed, 17 insertions(+) diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs index f9a868dd6b7..535c75f9df0 100644 --- a/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs @@ -4,6 +4,10 @@ namespace Orleans.Connections.Security.Entra.Tests; +[TestCategory("BVT")] +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Security")] public sealed class EntraJwtValidatorTests { [Theory] diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraMetadataTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraMetadataTests.cs index 1397f2145f9..c2abafe82bf 100644 --- a/test/Orleans.Connections.Security.Entra.Tests/EntraMetadataTests.cs +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraMetadataTests.cs @@ -6,6 +6,10 @@ namespace Orleans.Connections.Security.Entra.Tests; +[TestCategory("BVT")] +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Security")] public sealed class EntraMetadataTests { [Fact] diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraOptionsTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraOptionsTests.cs index e45dafa2339..7a3e9c48761 100644 --- a/test/Orleans.Connections.Security.Entra.Tests/EntraOptionsTests.cs +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraOptionsTests.cs @@ -3,6 +3,10 @@ namespace Orleans.Connections.Security.Entra.Tests; +[TestCategory("BVT")] +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Security")] public sealed class EntraOptionsTests { [Fact] diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraTokenProviderTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraTokenProviderTests.cs index 30f052a7608..78acff60a3a 100644 --- a/test/Orleans.Connections.Security.Entra.Tests/EntraTokenProviderTests.cs +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraTokenProviderTests.cs @@ -3,6 +3,10 @@ namespace Orleans.Connections.Security.Entra.Tests; +[TestCategory("BVT")] +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Security")] public sealed class EntraTokenProviderTests { [Fact] diff --git a/test/Orleans.Connections.Security.Entra.Tests/Orleans.Connections.Security.Entra.Tests.csproj b/test/Orleans.Connections.Security.Entra.Tests/Orleans.Connections.Security.Entra.Tests.csproj index 9c733f4be7c..1ef6ac77cff 100644 --- a/test/Orleans.Connections.Security.Entra.Tests/Orleans.Connections.Security.Entra.Tests.csproj +++ b/test/Orleans.Connections.Security.Entra.Tests/Orleans.Connections.Security.Entra.Tests.csproj @@ -14,5 +14,6 @@ + From 26c38dd66b0baed5b2feab5ae8b1f26a26eb3280 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Fri, 21 Aug 2026 05:36:54 -0700 Subject: [PATCH 13/22] test(security): migrate authentication tests to MTP --- ...ns.Connections.Security.Entra.Tests.csproj | 6 ------ .../ClientConnectionAuthenticationTests.cs | 5 ++++- ...oConnectionAuthenticationContractsTests.cs | 20 +++++++++++++++++++ .../TlsConnectionTests.cs | 1 - 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/test/Orleans.Connections.Security.Entra.Tests/Orleans.Connections.Security.Entra.Tests.csproj b/test/Orleans.Connections.Security.Entra.Tests/Orleans.Connections.Security.Entra.Tests.csproj index 1ef6ac77cff..e90ebba9918 100644 --- a/test/Orleans.Connections.Security.Entra.Tests/Orleans.Connections.Security.Entra.Tests.csproj +++ b/test/Orleans.Connections.Security.Entra.Tests/Orleans.Connections.Security.Entra.Tests.csproj @@ -6,12 +6,6 @@ enable - - - - - - diff --git a/test/Orleans.Connections.Security.Tests/ClientConnectionAuthenticationTests.cs b/test/Orleans.Connections.Security.Tests/ClientConnectionAuthenticationTests.cs index fdaddd1470d..96fac134f77 100644 --- a/test/Orleans.Connections.Security.Tests/ClientConnectionAuthenticationTests.cs +++ b/test/Orleans.Connections.Security.Tests/ClientConnectionAuthenticationTests.cs @@ -8,7 +8,10 @@ namespace Orleans.Connections.Security.Tests; -[Trait("Category", "BVT")] +[TestCategory("BVT")] +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Security")] public sealed class ClientConnectionAuthenticationTests { private const string CertificateConfigKey = "ClientAuthenticationCertificate"; diff --git a/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs b/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs index 9281c4bb56e..1f46bae9221 100644 --- a/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs +++ b/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs @@ -4,6 +4,10 @@ namespace Orleans.Connections.Security.Tests; +[TestCategory("BVT")] +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Security")] public class SiloConnectionAuthenticationContractsTests { [Theory] @@ -78,6 +82,10 @@ public void Token_Record_PreservesValueAndExpiration() } } +[TestCategory("BVT")] +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Security")] public class SiloConnectionAuthenticationOptionsTests { [Fact] @@ -141,6 +149,10 @@ private sealed class TestTimeProvider : TimeProvider } } +[TestCategory("BVT")] +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Security")] public class SiloConnectionAuthenticationProtocolTests { [Fact] @@ -152,6 +164,10 @@ public void Version2_IsExpectedAlpnIdentifier() StringComparer.Ordinal); } + [TestCategory("BVT")] + [TestSuite("BVT")] + [TestProvider("None")] + [TestArea("Security")] public class SiloConnectionAuthenticationContextTests { [Theory] @@ -166,6 +182,10 @@ public void Contexts_PreserveConnectionTarget(SiloConnectionAuthenticationTarget Assert.Equal(target, validation.Target); } + [TestCategory("BVT")] + [TestSuite("BVT")] + [TestProvider("None")] + [TestArea("Security")] public class SiloConnectionAuthenticationRegistrationTests { [Fact] diff --git a/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs b/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs index 4c550d6cd97..708d698bd26 100644 --- a/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs +++ b/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs @@ -32,7 +32,6 @@ namespace Orleans.Connections.Security.Tests /// - Authenticating clients and silos /// [TestCategory("BVT")] - [Trait("Category", "BVT")] [TestSuite("BVT")] [TestProvider("None")] [TestArea("Security")] From 88b83f075819d7c03ba3bf77277771ccfb723b28 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Fri, 21 Aug 2026 05:55:36 -0700 Subject: [PATCH 14/22] docs(security): correct Entra setup and metrics --- .../docs/host/authenticated-silo-connections.md | 14 ++++++++------ samples/AuthenticatedSiloConnections/README.md | 6 ++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/site/src/content/docs/host/authenticated-silo-connections.md b/docs/site/src/content/docs/host/authenticated-silo-connections.md index 24e3ece747f..bbc2339f2f9 100644 --- a/docs/site/src/content/docs/host/authenticated-silo-connections.md +++ b/docs/site/src/content/docs/host/authenticated-silo-connections.md @@ -115,14 +115,16 @@ Create the identity boundary in this order: `api:///contoso-prod-westus`. 3. Define application roles `Orleans.Silo.Connect` and `Orleans.Client.Connect`, with applications as allowed member types. -4. Create or select one workload identity for each independently deployable +4. Configure `idtyp` as an optional access-token claim so that application + tokens include `idtyp: "app"`. +5. Create or select one workload identity for each independently deployable silo and client workload. Don't share a client secret or exported certificate across the fleet. -5. Assign only the matching application role. A client identity doesn't need +6. Assign only the matching application role. A client identity doesn't need the silo role. -6. Put each application ID in the matching caller allowlist. Role assignment +7. Put each application ID in the matching caller allowlist. Role assignment and allowlisting are separate checks; require both. -7. Configure a managed identity, workload identity federation, or another +8. Configure a managed identity, workload identity federation, or another non-interactive credential. Grant no Microsoft Graph permission merely to establish an Orleans connection. @@ -336,9 +338,9 @@ Alert on rates and latency for these instruments: | Instrument | Operational use | |---|---| -| `orleans.connections.authentication.attempts` | Count outcomes by fixed result category. | +| `orleans.connections.authentication.attempts` | Count outcomes by fixed result category; `result=overload` identifies authentication capacity exhaustion after both the concurrency and pending-queue limits are reached. | | `orleans.connections.authentication.duration` | Detect token-provider, metadata, validation, or network latency. | -| `orleans.connections.authentication.active` | Detect handshake concurrency saturation. | +| `orleans.connections.authentication.active` | Track established authenticated connections by connection type and direction. | | `orleans.connections.authentication.protocol_fallbacks` | Identify peers which haven't negotiated authentication in `Audit`. | Keep dimensions bounded to direction, mode, protocol version, and fixed result diff --git a/samples/AuthenticatedSiloConnections/README.md b/samples/AuthenticatedSiloConnections/README.md index e5a4785372c..8f621f93deb 100644 --- a/samples/AuthenticatedSiloConnections/README.md +++ b/samples/AuthenticatedSiloConnections/README.md @@ -20,9 +20,11 @@ default ports, then start another with deployment environment, for example `contoso-prod-westus`. 3. Define the application roles `Orleans.Silo.Connect` and `Orleans.Client.Connect`, and allow applications as members. -4. Assign only the matching role to each authorized silo or client workload +4. Configure `idtyp` as an optional access-token claim so that application + tokens include `idtyp: "app"`. +5. Assign only the matching role to each authorized silo or client workload identity. -5. Configure a federated identity credential for each workload and place its +6. Configure a federated identity credential for each workload and place its application ID in the matching silo or external-client allowlist. The exact audience, tenant, application-token classification, caller From c505b44ab8858701086a8e061687b68904c04e34 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Fri, 21 Aug 2026 06:23:42 -0700 Subject: [PATCH 15/22] fix(security): redact connection tokens --- .../AuthenticationAbstractions.cs | 10 ++++++++- ...oConnectionAuthenticationContractsTests.cs | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Orleans.Connections.Security/Authentication/AuthenticationAbstractions.cs b/src/Orleans.Connections.Security/Authentication/AuthenticationAbstractions.cs index ad7e15498d2..426aacc2936 100644 --- a/src/Orleans.Connections.Security/Authentication/AuthenticationAbstractions.cs +++ b/src/Orleans.Connections.Security/Authentication/AuthenticationAbstractions.cs @@ -76,7 +76,15 @@ public enum SiloConnectionAuthenticationFailure /// /// The token value. /// The token expiration time. -public readonly record struct SiloConnectionToken(string Value, DateTimeOffset? ExpiresAt); +public readonly record struct SiloConnectionToken(string Value, DateTimeOffset? ExpiresAt) +{ + /// + public override string ToString() + { + var expiration = ExpiresAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture) ?? "null"; + return $"{nameof(SiloConnectionToken)} {{ Value = [REDACTED], ExpiresAt = {expiration} }}"; + } +} /// /// Supplies bearer tokens for outbound Orleans connections. diff --git a/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs b/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs index 1f46bae9221..c9b91964888 100644 --- a/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs +++ b/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs @@ -80,6 +80,27 @@ public void Token_Record_PreservesValueAndExpiration() Assert.Null(nonExpiring.ExpiresAt); Assert.NotEqual(finite, nonExpiring); } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Token_ToString_RedactsValue(bool hasExpiration) + { + const string tokenValue = "secret-bearer-token"; + DateTimeOffset? expiration = hasExpiration + ? new DateTimeOffset(2032, 8, 9, 10, 11, 12, TimeSpan.Zero) + : null; + var token = new SiloConnectionToken(tokenValue, expiration); + + var result = token.ToString(); + + Assert.DoesNotContain(tokenValue, result, StringComparison.Ordinal); + Assert.Equal( + hasExpiration + ? "SiloConnectionToken { Value = [REDACTED], ExpiresAt = 2032-08-09T10:11:12.0000000+00:00 }" + : "SiloConnectionToken { Value = [REDACTED], ExpiresAt = null }", + result); + } } [TestCategory("BVT")] From de9c10d7e721b92a026ceb6e2a08ab32080c4b0b Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Sun, 23 Aug 2026 15:39:08 -0700 Subject: [PATCH 16/22] fix(security): correct Entra v2 audience binding --- .../host/authenticated-silo-connections.md | 159 ++-- .../ConnectionAuthenticationExamples.cs | 39 +- .../ClientAuthentication.cs | 2 +- .../AuthenticatedSiloConnections/README.md | 110 ++- .../SampleOptions.cs | 34 +- .../SiloAuthentication.cs | 13 +- .../appsettings.json | 5 +- .../EntraJwtValidator.cs | 25 +- .../EntraSiloConnectionOptions.cs | 44 +- .../EntraSiloConnectionOptionsValidator.cs | 33 +- .../EntraSiloConnectionTokenProvider.cs | 10 +- .../EntraTokenProvider.cs | 19 +- .../README.md | 95 +++ .../AuthenticationAbstractions.cs | 14 +- .../SiloConnectionAuthenticationMiddleware.cs | 57 +- .../SiloConnectionAuthenticationOptions.cs | 5 +- .../SiloConnectionAuthenticationTelemetry.cs | 2 - .../Orleans.Connections.Security.Entra.cs | 5 + .../EntraJwtValidatorTests.cs | 167 +++- .../EntraOptionsTests.cs | 120 +++ .../EntraSiloConnectionTokenProviderTests.cs | 84 ++ .../EntraSiloConnectionTokenValidatorTests.cs | 124 +++ .../EntraTestInfrastructure.cs | 8 +- .../EntraTokenProviderTests.cs | 66 ++ ...ConnectionAuthenticationMiddlewareTests.cs | 778 ++++++++++++++++++ 25 files changed, 1828 insertions(+), 190 deletions(-) create mode 100644 test/Orleans.Connections.Security.Entra.Tests/EntraSiloConnectionTokenProviderTests.cs create mode 100644 test/Orleans.Connections.Security.Entra.Tests/EntraSiloConnectionTokenValidatorTests.cs create mode 100644 test/Orleans.Connections.Security.Tests/Authentication/SiloConnectionAuthenticationMiddlewareTests.cs diff --git a/docs/site/src/content/docs/host/authenticated-silo-connections.md b/docs/site/src/content/docs/host/authenticated-silo-connections.md index bbc2339f2f9..6aae5638d5b 100644 --- a/docs/site/src/content/docs/host/authenticated-silo-connections.md +++ b/docs/site/src/content/docs/host/authenticated-silo-connections.md @@ -1,7 +1,7 @@ --- title: Authenticate Orleans connections description: Authenticate silo and external client connections with TLS and Microsoft Entra workload identities. -ms.date: 08/07/2026 +ms.date: 08/23/2026 ms.topic: how-to --- @@ -27,8 +27,8 @@ single trust decision. | Component or path | Trust requirement | Recommended controls | |---|---|---| -| Silo-to-silo connection | Every admitted silo is trusted as part of this cluster | Private network policy, TLS or mTLS, `Orleans.Silo.Connect`, cluster-specific audience, silo caller allowlist | -| External client-to-gateway connection | Every admitted client is trusted to access the Orleans cluster | Private network policy, server-authenticated TLS or mTLS, `Orleans.Client.Connect`, client caller allowlist | +| Silo-to-silo connection | Every admitted silo is trusted as part of this cluster | Private network policy, TLS or mTLS, resource-application GUID audience, exact cluster-specific silo role, silo caller allowlist | +| External client-to-gateway connection | Every admitted client is trusted to access the Orleans cluster | Private network policy, server-authenticated TLS or mTLS, resource-application GUID audience, exact cluster-specific client role, client caller allowlist | | Public user traffic | End users and arbitrary upstream callers aren't inside the Orleans trust boundary | Authenticate and authorize at application ingress; don't expose an Orleans port as public ingress | | Membership, storage, reminders, and streams | Configured providers and the data they return are trusted cluster infrastructure | Provider-native TLS, workload identity, least-privilege data-plane permissions, and administrative access controls | @@ -61,8 +61,8 @@ items: - `ServiceId` and environment-specific `ClusterId`. - Silo and gateway DNS names, ports, and permitted network sources. - Certificate issuers, SANs, EKUs, trust stores, and revocation endpoints. -- Entra tenant, resource application, exact audience, roles, and caller - application IDs. +- Entra tenant, cluster-qualified token request scope, resource application + client-ID GUID, exact cluster roles, and caller application IDs. - Credential and certificate rotation owners, alert thresholds, and emergency revocation procedure. @@ -96,25 +96,29 @@ reduce the remaining risk. ## Provision Entra authorization -Audience validation alone isn't caller authorization. Configure all of the -following: +Audience validation alone isn't caller authorization. Keep these three values +separate: -1. A tenant-specific authority. -2. A dedicated audience for one cluster and deployment environment, such as - `api:///contoso-prod-westus`. -3. A path-specific application role, such as `Orleans.Silo.Connect` or - `Orleans.Client.Connect`. -4. A separate explicit caller application-ID allowlist for silos and external - clients. +| Token request scope | Entra v2 JWT audience | Exact cluster authorization | +|---|---|---| +| Register a cluster-qualified resource identifier such as `api:///contoso-prod-westus`. Set it as `TokenScope` without `/.default`; Orleans appends that suffix only when acquiring a token. | Set `ResourceApplicationId` to the resource application's client-ID GUID. Microsoft Entra emits this GUID as `aud` in a v2 access token. | Require an exact role such as `Orleans.Silo.Connect.contoso-prod-westus` or `Orleans.Client.Connect.contoso-prod-westus`. Alternatively, require one explicit custom claim whose value exactly equals the local `ClusterId`. | + +The cluster-qualified URI is never a successful JWT audience. For example, the +credential requests +`api:///contoso-prod-westus/.default`, +but the resulting v2 JWT must contain +`"aud": ""`. Create the identity boundary in this order: 1. Create a resource application for the Orleans cluster security boundary. -2. Give each environment a distinct identifier URI which includes the - `ClusterId`, for example - `api:///contoso-prod-westus`. -3. Define application roles `Orleans.Silo.Connect` and - `Orleans.Client.Connect`, with applications as allowed member types. +2. Note its client-ID GUID, request v2 access tokens, and give each environment + a distinct identifier URI which includes the `ClusterId`, for example + `api:///contoso-prod-westus`. +3. Define exact cluster-specific application roles + `Orleans.Silo.Connect.contoso-prod-westus` and + `Orleans.Client.Connect.contoso-prod-westus`, with applications as allowed + member types. 4. Configure `idtyp` as an optional access-token claim so that application tokens include `idtyp: "app"`. 5. Create or select one workload identity for each independently deployable @@ -128,15 +132,63 @@ Create the identity boundary in this order: non-interactive credential. Grant no Microsoft Graph permission merely to establish an Orleans connection. -The audience must exactly match the resource identifier registered in Microsoft -Entra. Don't remove the `api://` prefix or share a general-purpose silo audience -across environments. If the audience must be shared, require a separate -cluster-specific claim or role and compare it exactly to the local `ClusterId`. +This bounded manifest excerpt contains identifiers only. Generate stable GUIDs +for each `appRoles[].id`; they aren't credentials. App-role assignments are +made to workload service principals separately from this resource-application +manifest. + +```json +{ + "appId": "", + "identifierUris": [ + "api:///contoso-prod-westus" + ], + "api": { + "requestedAccessTokenVersion": 2 + }, + "appRoles": [ + { + "allowedMemberTypes": [ "Application" ], + "description": "Connect a silo to contoso-prod-westus.", + "displayName": "Orleans silo connect: contoso-prod-westus", + "id": "", + "isEnabled": true, + "value": "Orleans.Silo.Connect.contoso-prod-westus" + }, + { + "allowedMemberTypes": [ "Application" ], + "description": "Connect an Orleans client to contoso-prod-westus.", + "displayName": "Orleans client connect: contoso-prod-westus", + "id": "", + "isEnabled": true, + "value": "Orleans.Client.Connect.contoso-prod-westus" + } + ], + "optionalClaims": { + "accessToken": [ + { + "name": "idtyp", + "essential": true, + "additionalProperties": [] + } + ] + } +} +``` + +As an alternative to app-role cluster binding, a trusted issuer claims-mapping +policy can emit a signed custom claim such as +`orleans_cluster: "contoso-prod-westus"`. Set `ClusterClaimType` to +`orleans_cluster` instead of setting `ClusterRole`; don't configure both. The +validator compares the claim value to the local `ClusterId` using exact ordinal +matching. A general caller role doesn't replace either exact cluster-binding +mechanism. Use a tenant-specific authority. Don't use `common`, `organizations`, or `consumers`. Permit only application tokens issued to the expected tenant, -audience, caller application, role, and cluster binding. Keep access tokens -short-lived and keep every host's clock synchronized. +resource-application GUID audience, caller application, exact cluster role or +claim, and cluster binding. Keep access tokens short-lived and keep every +host's clock synchronized. ## Issue and deploy certificates @@ -195,6 +247,11 @@ queue, concurrency, metadata-refresh, and token-lifetime limits finite. Configuration is validated at startup; invalid middleware ordering, missing TLS/provider registrations, and conflicting TLS policies fail closed. +The compiled configuration separates token acquisition, GUID audience +validation, and exact cluster authorization: + +:::code language="csharp" source="snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs" id="EntraAuthenticationOptions"::: + Call once on every silo. A silo both validates inbound silo tokens and acquires a @@ -212,8 +269,9 @@ Configure each external Orleans client with the corresponding outbound policy: :::code language="csharp" source="snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs" id="AuthenticatedClient"::: The client and gateway must use compatible enforcement modes and the same Entra -audience, tenant, cluster binding, client role, and caller authorization. Keep -the external-client role and allowlist separate from the silo policy. The +token request scope, resource-application GUID, tenant, exact client cluster +role, and caller authorization. Keep the external-client role and allowlist +separate from the silo policy. The and properties distinguish @@ -241,7 +299,7 @@ each path rollout-compatible. | Mode | Negotiation and acceptance behavior | |---|---| | `Disabled` | Advertises only the baseline Orleans protocol and doesn't exchange authentication frames. | -| `Audit` | Prefers authentication, permits baseline negotiation with an older or disabled peer, and accepts measured authentication failures. | +| `Audit` | Prefers authentication and permits measured, unauthenticated baseline fallback only with a peer which doesn't negotiate authentication. A failed negotiated authentication rejects the connection. | | `Required` | Advertises only the authentication protocol and accepts only a successful authenticated result with a principal and, by default, a finite expiration. | `Required` has no unauthenticated fallback. A `Required` silo and an old or @@ -249,13 +307,12 @@ disabled silo have no common ALPN protocol, so TLS negotiation fails. A `Required` outbound peer also rejects an Audit result which was accepted but isn't authenticated. -After peers negotiate the authentication ALPN, malformed framing, -acknowledgment, timeout, or overload failures abort the connection in every -mode. `Audit` can explicitly accept token acquisition, validation, -authorization, or provider failures as unauthenticated, but it cannot -reinterpret them as baseline Orleans traffic. Baseline fallback is permitted -only when TLS negotiated the baseline ALPN with a peer which doesn't support -authentication. +After peers negotiate the authentication ALPN, every acquisition, validation, +authorization, expiry, malformed framing, acknowledgment, timeout, or overload +failure aborts the connection in both `Audit` and `Required`. `Audit` cannot +reinterpret a failed exchange as baseline Orleans traffic. Its baseline +fallback applies only when TLS negotiated the baseline ALPN with a peer which +doesn't support authentication. ## Plan for token expiration @@ -312,16 +369,17 @@ Run positive and negative connection tests in a non-production environment before enabling `Required`. A successful happy-path connection alone doesn't prove the boundary. -| Test | Expected result in `Required` | -|---|---| -| Authorized silo and authorized external client | Connect and make representative grain calls | -| Missing token, malformed token, or user-delegated token | Connection rejected before the Orleans preamble | -| Wrong tenant, audience, cluster binding, role, or caller application ID | Connection rejected | -| Expired token or token below the minimum remaining lifetime | Connection rejected | -| Untrusted issuer, wrong DNS SAN, missing EKU, expired certificate, or revoked certificate | TLS handshake rejected | -| Peer using baseline Orleans ALPN only | TLS negotiation fails; no unauthenticated fallback | -| Token provider, metadata endpoint, or signing-key refresh unavailable | New connection fails; mode remains `Required` | -| Handshake concurrency or queue limit exceeded | Excess work is rejected without unbounded growth | +| Test | Expected result in `Required` | Expected result in `Audit` | +|---|---|---| +| Authorized silo and authorized external client | Connect and make representative grain calls | Connect, authenticate, and make representative grain calls | +| Missing token, malformed token, or user-delegated token after authentication negotiation | Connection rejected before the Orleans preamble | Connection rejected before the Orleans preamble | +| URI `aud` instead of the resource application GUID | Connection rejected | Connection rejected after authentication negotiation | +| Wrong tenant or issuer, missing or wrong exact cluster role/claim, or unlisted caller application ID | Connection rejected | Connection rejected after authentication negotiation | +| Expired token or token below the minimum remaining lifetime | Connection rejected | Connection rejected after authentication negotiation | +| Untrusted issuer, wrong DNS SAN, missing EKU, expired certificate, or revoked certificate | TLS handshake rejected | TLS handshake rejected | +| Peer using baseline Orleans ALPN only | TLS negotiation fails; no unauthenticated fallback | Baseline connection can continue unauthenticated and is recorded as fallback | +| Token provider, metadata endpoint, or signing-key refresh unavailable after authentication negotiation | New connection fails; mode remains `Required` | New connection fails; mode remains `Audit` | +| Handshake concurrency or queue limit exceeded | Excess work is rejected without unbounded growth | Excess work is rejected without unbounded growth | Repeat the tests after certificate, federated-credential, app-role, audience, and signing-key rotation. Include reconnects: an already open connection can @@ -384,8 +442,10 @@ Maintain runbooks for these events: - Use an explicit workload credential and keep its federated token or secret material out of source and ordinary configuration. -- Give each cluster/environment an exact audience, use separate silo and client - roles, and require both the matching role and caller allowlist. +- Give each cluster/environment a cluster-qualified token request scope, use the + resource application client-ID GUID as the v2 audience, use separate exact + silo and client cluster roles, and require both the matching role and caller + allowlist. - Keep TLS 1.2 or later, certificate chain/name checks, revocation policy, and narrow trust roots enabled. - Bound token, timeout, concurrency, queue, metadata refresh, and token lifetime @@ -396,8 +456,9 @@ Maintain runbooks for these events: - Treat configured storage and providers as trusted infrastructure, and protect their credentials, transport, data, and administrative access. - Synchronize clocks and exercise certificate, key, and identity rotation. -- Treat unexpected baseline fallback in `Audit` and every authentication - failure in `Required` as an operational event. +- Treat unexpected baseline fallback and every failed negotiated + authentication in `Audit`, and every authentication failure in `Required`, + as an operational event. - Test wrong-certificate, wrong-identity, provider-outage, overload, rotation, reconnect, and rollback scenarios before production. diff --git a/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs b/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs index 1273bf56eef..bc8fe4aae6f 100644 --- a/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs +++ b/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs @@ -51,7 +51,7 @@ public static void ConfigureSilo( options, credential, options.Entra.AllowedSiloCallerClientIds, - "Orleans.Silo.Connect"); + options.Entra.SiloClusterRole); }); // @@ -69,7 +69,7 @@ public static void ConfigureSilo( options, credential, options.Entra.AllowedClientCallerClientIds, - "Orleans.Client.Connect"); + options.Entra.ClientClusterRole); }); // } @@ -92,7 +92,7 @@ public static void ConfigureClient( options, credential, options.Entra.AllowedClientCallerClientIds, - "Orleans.Client.Connect"); + options.Entra.ClientClusterRole); }); // } @@ -144,24 +144,23 @@ private static void ConfigureAuthentication( authentication.MaxPendingOutboundAuthentications = 256; authentication.MinimumRemainingTokenLifetime = TimeSpan.FromMinutes(2); + // authentication.UseEntra( credential, entra => { entra.Authority = options.Entra.Authority; - entra.TokenScope = $"{options.Entra.Audience}/.default"; - entra.ValidAudiences.Add(options.Entra.Audience); + entra.TokenScope = options.Entra.TokenScope; + entra.ResourceApplicationId = options.Entra.ResourceApplicationId; entra.ValidTenantIds.Add(options.Entra.TenantId); - entra.ClusterAudienceFormat = - $"api://{options.Entra.ResourceApplicationId}/{{0}}"; + entra.ClusterRole = requiredRole; foreach (var clientId in allowedCallerClientIds) { entra.AllowedClientIds.Add(clientId); } - - entra.RequiredRoles.Add(requiredRole); }); + // } } @@ -181,17 +180,27 @@ internal sealed class CertificateOptions internal sealed class EntraOptions { - public string TenantId { get; init; } = ""; + public string TenantId { get; init; } = "22222222-2222-2222-2222-222222222222"; + + public string TokenScope { get; init; } + = "api://11111111-1111-1111-1111-111111111111/contoso-prod-westus"; + + public string ResourceApplicationId { get; init; } + = "11111111-1111-1111-1111-111111111111"; - public string ResourceApplicationId { get; init; } = ""; + public string SiloClusterRole { get; init; } + = "Orleans.Silo.Connect.contoso-prod-westus"; - public string WorkloadClientId { get; init; } = ""; + public string ClientClusterRole { get; init; } + = "Orleans.Client.Connect.contoso-prod-westus"; - public string FederatedTokenFile { get; init; } = ""; + public string WorkloadClientId { get; init; } + = "33333333-3333-3333-3333-333333333333"; - public string Audience { get; init; } = ""; + public string FederatedTokenFile { get; init; } = ""; - public Uri Authority { get; init; } = null!; + public Uri Authority { get; init; } + = new("https://login.microsoftonline.com/22222222-2222-2222-2222-222222222222/v2.0"); public string[] AllowedSiloCallerClientIds { get; init; } = []; diff --git a/samples/AuthenticatedSiloConnections/ClientAuthentication.cs b/samples/AuthenticatedSiloConnections/ClientAuthentication.cs index a9b9a373f96..67c35b6eb5d 100644 --- a/samples/AuthenticatedSiloConnections/ClientAuthentication.cs +++ b/samples/AuthenticatedSiloConnections/ClientAuthentication.cs @@ -23,7 +23,7 @@ public static void Configure( options, credential, options.Entra.AllowedClientCallerClientIds, - "Orleans.Client.Connect"); + options.Entra.ClientClusterRole); }); // } diff --git a/samples/AuthenticatedSiloConnections/README.md b/samples/AuthenticatedSiloConnections/README.md index 8f621f93deb..2a8b7baaaea 100644 --- a/samples/AuthenticatedSiloConnections/README.md +++ b/samples/AuthenticatedSiloConnections/README.md @@ -14,12 +14,29 @@ default ports, then start another with ## Configure Microsoft Entra -1. Register a resource application for the cluster security boundary. +Keep these three values separate: + +| Purpose | Sample value | +|---|---| +| Cluster-qualified resource identifier used only for token acquisition | `api:///contoso-prod-westus`; configure this as `TokenScope`, without `/.default` | +| Resource application client ID emitted as the Entra v2 JWT `aud` | ``; configure this GUID as `ResourceApplicationId` | +| Exact cluster authorization | `Orleans.Silo.Connect.contoso-prod-westus` for silos or `Orleans.Client.Connect.contoso-prod-westus` for clients | + +The package appends `/.default` when requesting a token, so the credential +requests +`api:///contoso-prod-westus/.default`. +For a v2 access token, Microsoft Entra emits the resource application's +client-ID GUID—not that URI—as `aud`. The cluster-qualified URI is never a +successful JWT audience. + +1. Register a resource application for the cluster security boundary and note + its client-ID GUID. 2. Configure the identifier URI - `api:///`. The cluster ID includes the - deployment environment, for example `contoso-prod-westus`. -3. Define the application roles `Orleans.Silo.Connect` and - `Orleans.Client.Connect`, and allow applications as members. + `api:///contoso-prod-westus`. +3. Define the exact application roles + `Orleans.Silo.Connect.contoso-prod-westus` and + `Orleans.Client.Connect.contoso-prod-westus`, allowing applications as + members. 4. Configure `idtyp` as an optional access-token claim so that application tokens include `idtyp: "app"`. 5. Assign only the matching role to each authorized silo or client workload @@ -27,21 +44,70 @@ default ports, then start another with 6. Configure a federated identity credential for each workload and place its application ID in the matching silo or external-client allowlist. -The exact audience, tenant, application-token classification, caller -application ID, and application role are validated by +This bounded resource-application manifest excerpt uses placeholders only. +Generate and retain stable GUIDs for each `appRoles[].id`; those GUIDs aren't +credentials. + +```json +{ + "appId": "", + "identifierUris": [ + "api:///contoso-prod-westus" + ], + "api": { + "requestedAccessTokenVersion": 2 + }, + "appRoles": [ + { + "allowedMemberTypes": [ "Application" ], + "description": "Connect a silo to contoso-prod-westus.", + "displayName": "Orleans silo connect: contoso-prod-westus", + "id": "", + "isEnabled": true, + "value": "Orleans.Silo.Connect.contoso-prod-westus" + }, + { + "allowedMemberTypes": [ "Application" ], + "description": "Connect an Orleans client to contoso-prod-westus.", + "displayName": "Orleans client connect: contoso-prod-westus", + "id": "", + "isEnabled": true, + "value": "Orleans.Client.Connect.contoso-prod-westus" + } + ], + "optionalClaims": { + "accessToken": [ + { + "name": "idtyp", + "essential": true, + "additionalProperties": [] + } + ] + } +} +``` + +The exact GUID audience, tenant, application-token classification, caller +application ID, and cluster role are validated by `Microsoft.Orleans.Connections.Security.Entra`. Don't replace that package with sample-owned JWT parsing or validation. +As an alternative cluster binding, an issuer-managed claims policy can emit a +signed custom claim such as `orleans_cluster: "contoso-prod-westus"`. Configure +`ClusterClaimType = "orleans_cluster"` instead of `ClusterRole`; don't require +both. The claim value must exactly equal the local `ClusterId`. The maintained +sample uses app roles because their manifest and assignments are explicit. + An authenticated silo or external Orleans client is inside the Orleans trust boundary. Orleans doesn't apply per-grain or per-method authorization to that connection. Admit only trusted application workloads, and authenticate and authorize untrusted end users before their requests reach an Orleans client. Configured storage and other providers are trusted cluster infrastructure. -The sample uses one resource application but separate application roles and -caller allowlists for silo and external-client traffic. Use distinct resource -applications or audiences as well if those paths have different administrators -or compromise boundaries. +The sample uses one resource application but separate cluster-specific +application roles and caller allowlists for silo and external-client traffic. +Use distinct resource applications as well if those paths have different +administrators or compromise boundaries. ## Configure TLS @@ -63,17 +129,24 @@ export the `Microsoft.Orleans.Connections.Security` meter. Structured console logs preserve the runtime's fixed event IDs and bounded authentication result categories. -Start in `Audit` mode. Before proceeding to `Required`, deliberately reconnect +Start in `Audit` mode. `Audit` permits unauthenticated baseline fallback only +when a peer doesn't negotiate the authentication protocol. Once peers negotiate +authentication, `Audit` fails closed just like `Required`: token acquisition, +validation, authorization, expiry, framing, timeout, and overload failures +reject the connection. Before proceeding to `Required`, deliberately reconnect every expected silo pair and verify that each new connection authenticates, baseline fallback and unexpected failure rates remain zero, and token-expiry recycling succeeds for authenticated connections. Changing modes requires a restart. Before production, also verify that connections fail for an untrusted -certificate, wrong DNS SAN, wrong tenant or audience, missing role, unlisted -caller application ID, expired token, and a peer which supports only the -baseline Orleans ALPN. Repeat the checks after certificate and identity -rotation. +certificate, wrong DNS SAN, a URI `aud` instead of the resource application +GUID, wrong tenant or issuer, a missing or wrong exact cluster role, an unlisted +caller application ID, and an expired token. Those failures must reject the +connection in `Required` and after authentication is negotiated in `Audit`. A +peer which supports only the baseline Orleans ALPN is rejected in `Required` +but can use the measured, unauthenticated baseline fallback in `Audit`. Repeat +the checks after certificate and identity rotation. `Required` has no unauthenticated fallback. Roll back fleet-wide from `Required` to `Audit`, and only then from `Audit` to `Disabled`. Never @@ -81,9 +154,10 @@ automatically weaken the mode because Microsoft Entra or metadata is unavailable. The gateway validates external client bearer tokens using a distinct -`Orleans.Client.Connect` role and caller allowlist. External clients must call +cluster-specific `Orleans.Client.Connect.` role and caller +allowlist. External clients must call `UseAuthenticatedClientConnections` with a token provider and the same exact -audience, tenant, client role, and cluster binding. +resource application GUID, token request scope, tenant, and client cluster role. See the maintained [authenticated Orleans connections guide](../../docs/site/src/content/docs/host/authenticated-silo-connections.md) diff --git a/samples/AuthenticatedSiloConnections/SampleOptions.cs b/samples/AuthenticatedSiloConnections/SampleOptions.cs index 1561664e841..55b967dd28d 100644 --- a/samples/AuthenticatedSiloConnections/SampleOptions.cs +++ b/samples/AuthenticatedSiloConnections/SampleOptions.cs @@ -96,8 +96,14 @@ internal sealed class EntraOptions { public string TenantId { get; set; } = ""; + public string TokenScope { get; set; } = ""; + public string ResourceApplicationId { get; set; } = ""; + public string SiloClusterRole { get; set; } = ""; + + public string ClientClusterRole { get; set; } = ""; + public string WorkloadClientId { get; set; } = ""; public string FederatedTokenFile { get; set; } = ""; @@ -109,19 +115,26 @@ internal sealed class EntraOptions public Uri Authority => new($"https://login.microsoftonline.com/{TenantId}/v2.0"); - public string Audience - => $"api://{ResourceApplicationId}/{_clusterId}"; - - private string _clusterId = ""; - public void Validate(string clusterId) { - _clusterId = clusterId; RequireGuid(TenantId, nameof(TenantId)); RequireGuid(ResourceApplicationId, nameof(ResourceApplicationId)); RequireGuid(WorkloadClientId, nameof(WorkloadClientId)); + SampleOptions.RequireValue(TokenScope, nameof(TokenScope)); + SampleOptions.RequireValue(SiloClusterRole, nameof(SiloClusterRole)); + SampleOptions.RequireValue(ClientClusterRole, nameof(ClientClusterRole)); SampleOptions.RequireValue(FederatedTokenFile, nameof(FederatedTokenFile)); + var expectedTokenScope = $"api://{ResourceApplicationId}/{clusterId}"; + if (!string.Equals(TokenScope, expectedTokenScope, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"{SampleOptions.SectionName}:Entra:{nameof(TokenScope)} must be '{expectedTokenScope}'."); + } + + ValidateClusterRole(SiloClusterRole, $"Orleans.Silo.Connect.{clusterId}", nameof(SiloClusterRole)); + ValidateClusterRole(ClientClusterRole, $"Orleans.Client.Connect.{clusterId}", nameof(ClientClusterRole)); + if (!File.Exists(FederatedTokenFile)) { throw new InvalidOperationException( @@ -162,4 +175,13 @@ private static void RequireGuid(string value, string name) $"{SampleOptions.SectionName}:Entra:{name} must be a GUID."); } } + + private static void ValidateClusterRole(string value, string expected, string name) + { + if (!string.Equals(value, expected, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"{SampleOptions.SectionName}:Entra:{name} must be the exact role '{expected}'."); + } + } } diff --git a/samples/AuthenticatedSiloConnections/SiloAuthentication.cs b/samples/AuthenticatedSiloConnections/SiloAuthentication.cs index ff3465773ec..7cd4383b657 100644 --- a/samples/AuthenticatedSiloConnections/SiloAuthentication.cs +++ b/samples/AuthenticatedSiloConnections/SiloAuthentication.cs @@ -30,7 +30,7 @@ public static void Configure( options, credential, options.Entra.AllowedSiloCallerClientIds, - "Orleans.Silo.Connect"); + options.Entra.SiloClusterRole); }); // @@ -48,7 +48,7 @@ public static void Configure( options, credential, options.Entra.AllowedClientCallerClientIds, - "Orleans.Client.Connect"); + options.Entra.ClientClusterRole); }); // } @@ -75,18 +75,15 @@ internal static void ConfigureAuthentication( entra => { entra.Authority = options.Entra.Authority; - entra.TokenScope = $"{options.Entra.Audience}/.default"; - entra.ValidAudiences.Add(options.Entra.Audience); + entra.TokenScope = options.Entra.TokenScope; + entra.ResourceApplicationId = options.Entra.ResourceApplicationId; entra.ValidTenantIds.Add(options.Entra.TenantId); - entra.ClusterAudienceFormat = - $"api://{options.Entra.ResourceApplicationId}/{{0}}"; + entra.ClusterRole = requiredRole; foreach (var clientId in allowedCallerClientIds) { entra.AllowedClientIds.Add(clientId); } - - entra.RequiredRoles.Add(requiredRole); }); } } diff --git a/samples/AuthenticatedSiloConnections/appsettings.json b/samples/AuthenticatedSiloConnections/appsettings.json index ee61fa826b6..fd541890b15 100644 --- a/samples/AuthenticatedSiloConnections/appsettings.json +++ b/samples/AuthenticatedSiloConnections/appsettings.json @@ -13,7 +13,10 @@ }, "Entra": { "TenantId": "", - "ResourceApplicationId": "", + "TokenScope": "api:///contoso-prod-westus", + "ResourceApplicationId": "", + "SiloClusterRole": "Orleans.Silo.Connect.contoso-prod-westus", + "ClientClusterRole": "Orleans.Client.Connect.contoso-prod-westus", "WorkloadClientId": "", "FederatedTokenFile": "", "AllowedSiloCallerClientIds": [ diff --git a/src/Orleans.Connections.Security.Entra/EntraJwtValidator.cs b/src/Orleans.Connections.Security.Entra/EntraJwtValidator.cs index 3b48fc08434..2f35bbf7ea5 100644 --- a/src/Orleans.Connections.Security.Entra/EntraJwtValidator.cs +++ b/src/Orleans.Connections.Security.Entra/EntraJwtValidator.cs @@ -71,14 +71,14 @@ public async ValueTask ValidateAsync( ValidateUntrustedClaims(document, clusterId); var snapshot = await _configurationProvider.GetConfigurationAsync(cancellationToken).ConfigureAwait(false); - var result = await ValidateSignatureAndStandardClaimsAsync(token, clusterId, snapshot).ConfigureAwait(false); + var result = await ValidateSignatureAndStandardClaimsAsync(token, snapshot).ConfigureAwait(false); if (!result.IsValid && result.Exception is SecurityTokenSignatureKeyNotFoundException) { snapshot = await _configurationProvider.RefreshForUnknownSigningKeyAsync( snapshot.Generation, cancellationToken).ConfigureAwait(false); - result = await ValidateSignatureAndStandardClaimsAsync(token, clusterId, snapshot).ConfigureAwait(false); + result = await ValidateSignatureAndStandardClaimsAsync(token, snapshot).ConfigureAwait(false); } if (!result.IsValid) @@ -97,15 +97,12 @@ public async ValueTask ValidateAsync( private Task ValidateSignatureAndStandardClaimsAsync( string token, - string clusterId, EntraOpenIdConfigurationSnapshot snapshot) { var validAudiences = new HashSet(_options.ValidAudiences, StringComparer.Ordinal); - if (!string.IsNullOrWhiteSpace(_options.ClusterAudienceFormat)) + if (!string.IsNullOrWhiteSpace(_options.ResourceApplicationId)) { - // The cluster-specific audience was already checked against the raw payload and is - // also included in the cryptographically validated audience set. - validAudiences.Add(JwtDocument.FormatClusterValue(_options.ClusterAudienceFormat, clusterId)); + validAudiences.Add(_options.ResourceApplicationId); } var parameters = new TokenValidationParameters @@ -218,13 +215,12 @@ private void ValidateUntrustedClaims(JwtDocument document, string clusterId) throw new EntraAuthenticationException(EntraAuthenticationError.UnauthorizedCaller); } - if (!string.IsNullOrWhiteSpace(_options.ClusterAudienceFormat) - && !document.Audiences.Contains( - JwtDocument.FormatClusterValue(_options.ClusterAudienceFormat, clusterId), - StringComparer.Ordinal)) + if (!string.IsNullOrWhiteSpace(_options.ClusterRole) + && !document.Roles.Contains(_options.ClusterRole, StringComparer.Ordinal)) { throw new EntraAuthenticationException(EntraAuthenticationError.UnauthorizedCaller); } + } private void ValidateTrustedClaims(JwtDocument document, string issuer, string clusterId) @@ -237,6 +233,13 @@ private void ValidateTrustedClaims(JwtDocument document, string issuer, string c throw new EntraAuthenticationException(EntraAuthenticationError.InvalidToken); } + if (string.IsNullOrWhiteSpace(_options.ClusterClaimType) + && string.IsNullOrWhiteSpace(_options.ClusterRole) + && string.IsNullOrWhiteSpace(_options.ClusterRoleFormat)) + { + throw new EntraAuthenticationException(EntraAuthenticationError.UnauthorizedCaller); + } + ValidateUntrustedClaims(document, clusterId); } diff --git a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptions.cs b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptions.cs index 83dda0a61f8..b58599c5815 100644 --- a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptions.cs +++ b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptions.cs @@ -18,13 +18,28 @@ public sealed class EntraSiloConnectionOptions public Uri? Authority { get; set; } /// - /// Gets or sets the scope requested from the configured . + /// Gets or sets the cluster-qualified resource or scope identifier used to request a token. /// + /// + /// The /.default suffix is added when requesting a token. This value is not a valid JWT audience. + /// public string? TokenScope { get; set; } /// - /// Gets the exact token audiences which are accepted. + /// Gets or sets the resource application's client-ID GUID. + /// + /// + /// This value is compared with the JWT aud claim. It is not a scope URI. + /// + public string? ResourceApplicationId { get; set; } + + /// + /// Gets additional exact JWT audiences which are accepted. /// + /// + /// is always accepted and is the normal Microsoft Entra v2 audience. + /// A scope or resource identifier URI must not be added for an Entra v2 token. + /// public ISet ValidAudiences { get; } = new HashSet(StringComparer.Ordinal); /// @@ -45,6 +60,10 @@ public sealed class EntraSiloConnectionOptions /// /// Gets the application roles, at least one of which must be present. /// + /// + /// These roles authorize a caller but do not replace the exact cluster binding configured by + /// or . + /// public ISet RequiredRoles { get; } = new HashSet(StringComparer.Ordinal); /// @@ -89,8 +108,18 @@ public sealed class EntraSiloConnectionOptions /// /// Gets or sets the claim whose value must exactly match the local Orleans cluster identifier. /// + /// Claim type and value matching is ordinal and exact. public string? ClusterClaimType { get; set; } + /// + /// Gets or sets the exact application role required to connect to the local Orleans cluster. + /// + /// + /// Matching is ordinal and exact. For example, a silo role can be + /// Orleans.Silo.Connect.<cluster-id>. + /// + public string? ClusterRole { get; set; } + /// /// Gets or sets a composite-format string used to construct a required cluster role. /// @@ -98,9 +127,16 @@ public sealed class EntraSiloConnectionOptions public string? ClusterRoleFormat { get; set; } /// - /// Gets or sets a composite-format string used to construct a required cluster audience. + /// Gets or sets an obsolete composite-format string which formerly constructed a cluster audience. /// - /// {0} is replaced with the local Orleans cluster identifier. + /// + /// This property is retained for source compatibility and is not used to authorize a cluster. + /// Configure for JWT audience validation and use + /// or for exact cluster authorization. + /// + [Obsolete( + $"Use {nameof(ResourceApplicationId)} with {nameof(ClusterRole)} or {nameof(ClusterClaimType)} instead. " + + $"{nameof(TokenScope)} is not a JWT audience.")] public string? ClusterAudienceFormat { get; set; } /// diff --git a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptionsValidator.cs b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptionsValidator.cs index d9647936ae9..64acc2ff153 100644 --- a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptionsValidator.cs +++ b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptionsValidator.cs @@ -30,7 +30,7 @@ public ValidateOptionsResult Validate(string? name, EntraSiloConnectionOptions o } RequireValue(options.TokenScope, nameof(options.TokenScope), errors); - RequireNonEmpty(options.ValidAudiences, nameof(options.ValidAudiences), errors); + RequireValue(options.ResourceApplicationId, nameof(options.ResourceApplicationId), errors); RequireNonEmpty(options.ValidTenantIds, nameof(options.ValidTenantIds), errors); RequireNonEmpty(options.AllowedAlgorithms, nameof(options.AllowedAlgorithms), errors); RequireNonEmpty(options.SupportedTokenVersions, nameof(options.SupportedTokenVersions), errors); @@ -43,6 +43,12 @@ public ValidateOptionsResult Validate(string? name, EntraSiloConnectionOptions o ValidateEntries(options.SupportedTokenVersions, nameof(options.SupportedTokenVersions), errors); ValidateEntries(options.AdditionalTrustedMetadataHosts, nameof(options.AdditionalTrustedMetadataHosts), errors); + if (!string.IsNullOrWhiteSpace(options.ResourceApplicationId) + && !Guid.TryParse(options.ResourceApplicationId, out _)) + { + errors.Add($"{nameof(options.ResourceApplicationId)} must be a GUID."); + } + if (options.Authority is { IsAbsoluteUri: true } configuredAuthority && TryGetAuthorityTenant(configuredAuthority, out var authorityTenant) && !options.ValidTenantIds.Contains(authorityTenant)) @@ -60,17 +66,30 @@ public ValidateOptionsResult Validate(string? name, EntraSiloConnectionOptions o $"or {nameof(options.RequiredRoles)} must be configured unless {nameof(options.AllowAnyApplicationInTenant)} is enabled."); } - if (string.IsNullOrWhiteSpace(options.ClusterClaimType) - && string.IsNullOrWhiteSpace(options.ClusterRoleFormat) - && string.IsNullOrWhiteSpace(options.ClusterAudienceFormat)) + var hasClusterClaim = !string.IsNullOrWhiteSpace(options.ClusterClaimType); + var hasExactClusterRole = !string.IsNullOrWhiteSpace(options.ClusterRole); + var hasFormattedClusterRole = !string.IsNullOrWhiteSpace(options.ClusterRoleFormat); + var hasClusterRole = hasExactClusterRole || hasFormattedClusterRole; + if (hasExactClusterRole && hasFormattedClusterRole) + { + errors.Add( + $"Only one of {nameof(options.ClusterRole)} or {nameof(options.ClusterRoleFormat)} can configure cluster role binding."); + } + + if (!hasClusterClaim && !hasClusterRole) + { + errors.Add( + $"A cluster role ({nameof(options.ClusterRole)} or {nameof(options.ClusterRoleFormat)}) " + + $"or {nameof(options.ClusterClaimType)} must bind credentials to the local cluster."); + } + else if (hasClusterClaim && hasClusterRole) { errors.Add( - $"At least one of {nameof(options.ClusterClaimType)}, {nameof(options.ClusterRoleFormat)}, " + - $"or {nameof(options.ClusterAudienceFormat)} must bind credentials to the local cluster."); + $"Configure either a cluster role ({nameof(options.ClusterRole)} or {nameof(options.ClusterRoleFormat)}) " + + $"or {nameof(options.ClusterClaimType)}, but not both."); } ValidateFormat(options.ClusterRoleFormat, nameof(options.ClusterRoleFormat), errors); - ValidateFormat(options.ClusterAudienceFormat, nameof(options.ClusterAudienceFormat), errors); ValidatePositive(options.MinimumRemainingTokenLifetime, nameof(options.MinimumRemainingTokenLifetime), MaximumTokenDuration, errors); ValidatePositive(options.MaximumTokenLifetime, nameof(options.MaximumTokenLifetime), MaximumTokenDuration, errors); ValidateNonNegative(options.ClockSkew, nameof(options.ClockSkew), errors); diff --git a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenProvider.cs b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenProvider.cs index 56c58636186..0af5b830ef6 100644 --- a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenProvider.cs +++ b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionTokenProvider.cs @@ -7,7 +7,7 @@ namespace Orleans.Connections.Security.Entra; internal sealed class EntraSiloConnectionTokenProvider : ISiloConnectionTokenProvider { - private readonly EntraTokenProvider _provider; + private readonly IEntraTokenProvider _provider; public EntraSiloConnectionTokenProvider( Azure.Core.TokenCredential credential, @@ -17,11 +17,17 @@ public EntraSiloConnectionTokenProvider( _provider = new EntraTokenProvider(credential, options, timeProvider); } + internal EntraSiloConnectionTokenProvider(IEntraTokenProvider provider) + { + ArgumentNullException.ThrowIfNull(provider); + _provider = provider; + } + public async ValueTask GetTokenAsync( SiloConnectionTokenRequestContext context, CancellationToken cancellationToken) { - var token = await _provider.GetTokenAsync(cancellationToken).ConfigureAwait(false); + var token = await _provider.GetTokenAsync(context, cancellationToken).ConfigureAwait(false); return new SiloConnectionToken(token.Token, token.ExpiresOn); } } diff --git a/src/Orleans.Connections.Security.Entra/EntraTokenProvider.cs b/src/Orleans.Connections.Security.Entra/EntraTokenProvider.cs index 3a2039056de..55ab38aa870 100644 --- a/src/Orleans.Connections.Security.Entra/EntraTokenProvider.cs +++ b/src/Orleans.Connections.Security.Entra/EntraTokenProvider.cs @@ -6,7 +6,14 @@ namespace Orleans.Connections.Security.Entra; -internal sealed class EntraTokenProvider +internal interface IEntraTokenProvider +{ + ValueTask GetTokenAsync( + SiloConnectionTokenRequestContext context, + CancellationToken cancellationToken); +} + +internal sealed class EntraTokenProvider : IEntraTokenProvider { private readonly TokenCredential _credential; private readonly EntraSiloConnectionOptions _options; @@ -21,8 +28,12 @@ public EntraTokenProvider(TokenCredential credential, EntraSiloConnectionOptions public async ValueTask GetTokenAsync(CancellationToken cancellationToken) { + var configuredScope = _options.TokenScope!; + var requestScope = configuredScope.EndsWith("/.default", StringComparison.Ordinal) + ? configuredScope + : $"{configuredScope.TrimEnd('/')}/.default"; var token = await _credential.GetTokenAsync( - new TokenRequestContext([_options.TokenScope!]), + new TokenRequestContext([requestScope]), cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(token.Token) @@ -33,4 +44,8 @@ public async ValueTask GetTokenAsync(CancellationToken cancellation return token; } + + ValueTask IEntraTokenProvider.GetTokenAsync( + SiloConnectionTokenRequestContext context, + CancellationToken cancellationToken) => GetTokenAsync(cancellationToken); } diff --git a/src/Orleans.Connections.Security.Entra/README.md b/src/Orleans.Connections.Security.Entra/README.md index 0b72a6f47c7..e4ef999a186 100644 --- a/src/Orleans.Connections.Security.Entra/README.md +++ b/src/Orleans.Connections.Security.Entra/README.md @@ -16,3 +16,98 @@ bounds how long a key removed by the authority can remain trusted during an outa The supplied credential remains responsible for token caching. Orleans requests a token for every outbound authentication attempt and does not add another token cache. + +## Configure a v2 resource application + +Keep token acquisition, JWT audience validation, and cluster authorization as +three separate values: + +| Option | Example | Meaning | +|---|---|---| +| `TokenScope` | `api://11111111-1111-1111-1111-111111111111/contoso-prod-westus` | Cluster-qualified resource identifier used only to acquire a token. Orleans appends `/.default`. | +| `ResourceApplicationId` | `11111111-1111-1111-1111-111111111111` | Resource application's client-ID GUID, emitted as `aud` in an Entra v2 access token. | +| `ClusterRole` | `Orleans.Silo.Connect.contoso-prod-westus` | Exact, ordinal cluster authorization value. Use the corresponding `Orleans.Client.Connect.contoso-prod-westus` value for client connections. | + +`TokenScope` is never validated as `aud`. A cluster-qualified identifier URI +can successfully acquire a token while the resulting v2 JWT contains the +resource application client-ID GUID as its audience. + +```csharp +authentication.UseEntra( + credential, + entra => + { + entra.Authority = new Uri( + "https://login.microsoftonline.com/22222222-2222-2222-2222-222222222222/v2.0"); + entra.TokenScope = + "api://11111111-1111-1111-1111-111111111111/contoso-prod-westus"; + entra.ResourceApplicationId = + "11111111-1111-1111-1111-111111111111"; + entra.ValidTenantIds.Add( + "22222222-2222-2222-2222-222222222222"); + entra.AllowedClientIds.Add( + "33333333-3333-3333-3333-333333333333"); + entra.ClusterRole = + "Orleans.Silo.Connect.contoso-prod-westus"; + }); +``` + +The corresponding resource-application manifest uses the URI for token +requests, requests v2 access tokens, and defines an exact cluster role. Replace +the placeholders with identifiers, not secrets: + +```json +{ + "appId": "", + "identifierUris": [ + "api:///contoso-prod-westus" + ], + "api": { + "requestedAccessTokenVersion": 2 + }, + "appRoles": [ + { + "allowedMemberTypes": [ "Application" ], + "description": "Connect a silo to contoso-prod-westus.", + "displayName": "Orleans silo connect: contoso-prod-westus", + "id": "", + "isEnabled": true, + "value": "Orleans.Silo.Connect.contoso-prod-westus" + } + ], + "optionalClaims": { + "accessToken": [ + { + "name": "idtyp", + "essential": true, + "additionalProperties": [] + } + ] + } +} +``` + +Assign the app role only to authorized workload service principals and also +configure the corresponding caller application-ID or service-principal +allowlist. Role assignment and allowlisting are independent checks. + +As an alternative to `ClusterRole`, configure `ClusterClaimType` and have the +trusted issuer emit that signed custom claim with a value exactly equal to the +local Orleans cluster ID. Don't configure both mechanisms. A general caller +role in `RequiredRoles` doesn't replace this exact cluster binding. + +## Enforcement and migration + +`Required` rejects peers which don't negotiate authentication. `Audit` permits +unauthenticated baseline fallback only for a peer which doesn't negotiate the +authentication protocol. Once authentication is negotiated, acquisition, +validation, authorization, expiry, protocol, timeout, and overload failures +reject the connection in both modes. + +`ClusterAudienceFormat` is obsolete and no longer authorizes a cluster. Migrate +by configuring the resource application's client-ID GUID in +`ResourceApplicationId`, retaining the cluster-qualified resource identifier +in `TokenScope`, and replacing the audience format with either an exact +`ClusterRole` or `ClusterClaimType`. Don't add the identifier URI to +`ValidAudiences` for an Entra v2 token. Advanced additional audiences, when +explicitly required, remain separate from the token request scope. diff --git a/src/Orleans.Connections.Security/Authentication/AuthenticationAbstractions.cs b/src/Orleans.Connections.Security/Authentication/AuthenticationAbstractions.cs index 426aacc2936..1e3589bba0d 100644 --- a/src/Orleans.Connections.Security/Authentication/AuthenticationAbstractions.cs +++ b/src/Orleans.Connections.Security/Authentication/AuthenticationAbstractions.cs @@ -14,7 +14,10 @@ public enum SiloConnectionAuthenticationMode /// Disables connection authentication. Disabled, - /// Attempts authentication when supported and records failures without rejecting policy failures. + /// + /// Allows a peer which did not negotiate token authentication to continue unauthenticated. + /// Once token authentication is negotiated, any authentication failure rejects the connection. + /// Audit, /// Requires every configured connection to be authenticated. @@ -46,6 +49,7 @@ public enum SiloConnectionAuthenticationDirection /// /// Identifies a bounded connection-authentication failure category. +/// Categories do not contain token, claim, or other peer-controlled values. /// public enum SiloConnectionAuthenticationFailure { @@ -201,14 +205,18 @@ private SiloConnectionTokenValidationResult( /// Gets the validated credential expiration time. public DateTimeOffset? ExpiresAt { get; } - /// Gets the failure category. + /// + /// Gets the bounded failure category. This value does not expose token or claim content. + /// public SiloConnectionAuthenticationFailure Failure { get; } /// Creates a successful validation result. public static SiloConnectionTokenValidationResult Success(ClaimsPrincipal principal, DateTimeOffset? expiresAt) => new(true, principal ?? throw new ArgumentNullException(nameof(principal)), expiresAt, SiloConnectionAuthenticationFailure.None); - /// Creates a failed validation result. + /// + /// Creates a failed validation result using a bounded category which does not contain remote token or claim content. + /// public static SiloConnectionTokenValidationResult Fail(SiloConnectionAuthenticationFailure failure) { if (failure == SiloConnectionAuthenticationFailure.None) diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationMiddleware.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationMiddleware.cs index 20dab121fa0..2010cd7608d 100644 --- a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationMiddleware.cs +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationMiddleware.cs @@ -448,11 +448,7 @@ public async Task OnConnectionAsync(ConnectionContext context, ConnectionDelegat state.Move(SiloConnectionAuthenticationState.WorkAdmitted, SiloConnectionAuthenticationState.TokenTransferred); var validation = await ValidateAsync(context, token, linked.Token); var isAuthenticated = TryNormalizeValidation(validation, out var principal, out var expiresAt, out var failure); - var resultCode = isAuthenticated - ? AuthenticatedResult - : Options.Mode == SiloConnectionAuthenticationMode.Audit - ? AcceptedUnauthenticatedResult - : RejectedResult; + var resultCode = isAuthenticated ? AuthenticatedResult : RejectedResult; await ConnectionFrameHelper.WriteFrameAsync( context, @@ -485,7 +481,7 @@ await RunAcceptedAsync( direction, feature, started, - isAuthenticated ? AuthenticationResultCategory.Authenticated : AuthenticationResultCategory.AcceptedUnauthenticated); + AuthenticationResultCategory.Authenticated); } } catch (OperationCanceledException) when (state.State != SiloConnectionAuthenticationState.Accepted) @@ -744,27 +740,6 @@ await RunAcceptedAsync( started, AuthenticationResultCategory.Authenticated); return; - case AcceptedUnauthenticatedResult when Options.Mode == SiloConnectionAuthenticationMode.Audit: - state.Move(SiloConnectionAuthenticationState.ResultTransferred, SiloConnectionAuthenticationState.Accepted); - admission.Dispose(); - linked.Dispose(); - timeout.Dispose(); - await RunAcceptedAsync( - context, - next, - direction, - new SiloConnectionAuthenticationFeature( - true, - false, - null, - null, - localFailure == SiloConnectionAuthenticationFailure.None - ? SiloConnectionAuthenticationFailure.InvalidToken - : localFailure, - SiloConnectionAuthenticationProtocol.Version2), - started, - AuthenticationResultCategory.AcceptedUnauthenticated); - return; case RejectedResult: case AcceptedUnauthenticatedResult: state.Move(SiloConnectionAuthenticationState.ResultTransferred, SiloConnectionAuthenticationState.Rejected); @@ -797,9 +772,7 @@ await RunAcceptedAsync( { if (_provider is null) { - return Options.Mode == SiloConnectionAuthenticationMode.Audit - ? ([], null, SiloConnectionAuthenticationFailure.ProviderUnavailable) - : (null, null, SiloConnectionAuthenticationFailure.ProviderUnavailable); + return (null, null, SiloConnectionAuthenticationFailure.ProviderUnavailable); } SiloConnectionToken token; @@ -820,9 +793,7 @@ await RunAcceptedAsync( } catch { - return Options.Mode == SiloConnectionAuthenticationMode.Audit - ? ([], null, SiloConnectionAuthenticationFailure.ProviderUnavailable) - : (null, null, SiloConnectionAuthenticationFailure.ProviderUnavailable); + return (null, null, SiloConnectionAuthenticationFailure.ProviderUnavailable); } var value = token.Value ?? string.Empty; @@ -831,40 +802,30 @@ await RunAcceptedAsync( { if (StrictUtf8.GetByteCount(value) > Options.MaxTokenSize) { - return Options.Mode == SiloConnectionAuthenticationMode.Audit - ? ([], null, SiloConnectionAuthenticationFailure.InvalidToken) - : (null, null, SiloConnectionAuthenticationFailure.InvalidToken); + return (null, null, SiloConnectionAuthenticationFailure.InvalidToken); } payload = StrictUtf8.GetBytes(value); } catch (EncoderFallbackException) { - return Options.Mode == SiloConnectionAuthenticationMode.Audit - ? ([], null, SiloConnectionAuthenticationFailure.InvalidToken) - : (null, null, SiloConnectionAuthenticationFailure.InvalidToken); + return (null, null, SiloConnectionAuthenticationFailure.InvalidToken); } if (payload.Length == 0) { - return Options.Mode == SiloConnectionAuthenticationMode.Audit - ? ([], null, SiloConnectionAuthenticationFailure.MissingToken) - : (null, null, SiloConnectionAuthenticationFailure.MissingToken); + return (null, null, SiloConnectionAuthenticationFailure.MissingToken); } if (token.ExpiresAt is null && !Options.AllowNonExpiringCredentials) { - return Options.Mode == SiloConnectionAuthenticationMode.Audit - ? ([], null, SiloConnectionAuthenticationFailure.ValidationError) - : (null, null, SiloConnectionAuthenticationFailure.ValidationError); + return (null, null, SiloConnectionAuthenticationFailure.ValidationError); } if (token.ExpiresAt is { } expiresAt && expiresAt <= Options.TimeProvider.GetUtcNow() + Options.MinimumRemainingTokenLifetime) { - return Options.Mode == SiloConnectionAuthenticationMode.Audit - ? ([], null, SiloConnectionAuthenticationFailure.ExpiredToken) - : (null, null, SiloConnectionAuthenticationFailure.ExpiredToken); + return (null, null, SiloConnectionAuthenticationFailure.ExpiredToken); } return (payload, token.ExpiresAt, SiloConnectionAuthenticationFailure.None); diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptions.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptions.cs index c1242d5a39d..cb7ad8cbc9f 100644 --- a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptions.cs +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptions.cs @@ -7,7 +7,10 @@ namespace Orleans.Connections.Security; /// public sealed class SiloConnectionAuthenticationOptions { - /// Gets or sets the authentication enforcement mode. + /// + /// Gets or sets the authentication enforcement mode. Audit mode permits baseline protocol fallback, + /// but does not permit a failed negotiated authentication exchange to continue. + /// public SiloConnectionAuthenticationMode Mode { get; set; } = SiloConnectionAuthenticationMode.Required; /// Gets or sets the total token exchange timeout. diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationTelemetry.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationTelemetry.cs index 88561d1ea8f..cc32ee0312e 100644 --- a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationTelemetry.cs +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationTelemetry.cs @@ -8,7 +8,6 @@ namespace Orleans.Connections.Security; internal enum AuthenticationResultCategory { Authenticated, - AcceptedUnauthenticated, BaselineFallback, Rejected, Overload, @@ -93,7 +92,6 @@ private static TagList CreateTags( public static string GetResultName(AuthenticationResultCategory result) => result switch { AuthenticationResultCategory.Authenticated => "authenticated", - AuthenticationResultCategory.AcceptedUnauthenticated => "accepted_unauthenticated", AuthenticationResultCategory.BaselineFallback => "baseline_fallback", AuthenticationResultCategory.Rejected => "rejected", AuthenticationResultCategory.Overload => "overload", diff --git a/src/api/Orleans.Connections.Security.Entra/Orleans.Connections.Security.Entra.cs b/src/api/Orleans.Connections.Security.Entra/Orleans.Connections.Security.Entra.cs index 5a44471325c..a19eec9ea06 100644 --- a/src/api/Orleans.Connections.Security.Entra/Orleans.Connections.Security.Entra.cs +++ b/src/api/Orleans.Connections.Security.Entra/Orleans.Connections.Security.Entra.cs @@ -28,10 +28,13 @@ public sealed partial class EntraSiloConnectionOptions public System.TimeSpan ClockSkew { get { throw null; } set { } } + [System.Obsolete("Use ResourceApplicationId with ClusterRole or ClusterClaimType instead. TokenScope is not a JWT audience.")] public string? ClusterAudienceFormat { get { throw null; } set { } } public string? ClusterClaimType { get { throw null; } set { } } + public string? ClusterRole { get { throw null; } set { } } + public string? ClusterRoleFormat { get { throw null; } set { } } public System.TimeSpan LastKnownGoodLifetime { get { throw null; } set { } } @@ -56,6 +59,8 @@ public sealed partial class EntraSiloConnectionOptions public System.Collections.Generic.ISet RequiredRoles { get { throw null; } } + public string? ResourceApplicationId { get { throw null; } set { } } + public System.Collections.Generic.ISet SupportedTokenVersions { get { throw null; } } public string? TokenScope { get { throw null; } set { } } diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs index 535c75f9df0..7fb8ad57eb7 100644 --- a/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs @@ -127,20 +127,29 @@ public async Task SupportsClusterSpecificRoleBinding() Assert.True(result.Principal.Identity?.IsAuthenticated); } - [Fact] - public async Task SupportsClusterSpecificAudienceBinding() + [Theory] + [InlineData("legacy-format", "InvalidToken")] + [InlineData("token-scope", "InvalidToken")] + [InlineData("resource-application-id", "UnauthorizedCaller")] + public async Task RejectsLegacyClusterAudienceBinding( + string audienceSource, + string expectedError) { using var fixture = new EntraTestFixture(); fixture.Options.ClusterClaimType = null; +#pragma warning disable CS0618 fixture.Options.ClusterAudienceFormat = "api://orleans-silos/{0}"; - var token = fixture.CreateToken(audience: "api://orleans-silos/cluster-a"); - - var result = await fixture.CreateValidator().ValidateAsync( - token, - EntraTestFixture.ClusterId, - CancellationToken.None); - - Assert.True(result.Principal.Identity?.IsAuthenticated); +#pragma warning restore CS0618 + var audience = audienceSource switch + { + "legacy-format" => "api://orleans-silos/cluster-a", + "token-scope" => fixture.Options.TokenScope!, + "resource-application-id" => fixture.Options.ResourceApplicationId!, + _ => throw new ArgumentOutOfRangeException(nameof(audienceSource)), + }; + var token = fixture.CreateToken(audience: audience); + + await AssertErrorAsync(fixture, token, Enum.Parse(expectedError)); } [Fact] @@ -312,4 +321,142 @@ private static async Task AssertErrorAsync( .AsTask()); Assert.Equal(expected, exception.Error); } + + [Fact] + public async Task AcceptsV2TokenWithGuidAudienceAndExactClusterRole() + { + using var fixture = new EntraTestFixture(); + ConfigureExactClusterRole(fixture); + fixture.Options.ValidAudiences.Clear(); + var token = fixture.CreateToken(roles: [EntraTestFixture.Role, ExactClusterRole]); + + var result = await fixture.CreateValidator().ValidateAsync( + token, + EntraTestFixture.ClusterId, + CancellationToken.None); + + var identity = Assert.IsType(result.Principal.Identity); + Assert.True(identity.IsAuthenticated); + Assert.Equal("Entra", identity.AuthenticationType); + Assert.Equal(EntraTestFixture.ClientId, result.Principal.FindFirst("azp")?.Value); + Assert.Equal(EntraTestFixture.Audience, result.Principal.FindFirst("aud")?.Value); + Assert.Contains(result.Principal.FindAll("roles"), claim => claim.Value == ExactClusterRole); + Assert.Equal(fixture.TimeProvider.GetUtcNow().AddMinutes(30), result.ExpiresAt); + } + + [Fact] + public async Task RejectsV2TokenWithUriAudience() + { + using var fixture = new EntraTestFixture(); + ConfigureExactClusterRole(fixture); + fixture.Options.ValidAudiences.Clear(); +#pragma warning disable CS0618 + fixture.Options.ClusterAudienceFormat = "api://11111111-1111-1111-1111-111111111111/{0}"; +#pragma warning restore CS0618 + var token = fixture.CreateToken( + audience: fixture.Options.TokenScope!, + roles: [EntraTestFixture.Role, ExactClusterRole]); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.InvalidToken); + } + + [Theory] + [InlineData("missing")] + [InlineData("unrelated")] + [InlineData("prefix")] + [InlineData("other-cluster")] + [InlineData("case-mismatch")] + public async Task RejectsMissingOrWrongClusterRole(string roleCase) + { + using var fixture = new EntraTestFixture(); + ConfigureExactClusterRole(fixture); + string[] roles = roleCase switch + { + "missing" => [EntraTestFixture.Role], + "unrelated" => [EntraTestFixture.Role, "Unrelated.Role"], + "prefix" => [EntraTestFixture.Role, "Orleans.Silo.Connect.cluster"], + "other-cluster" => [EntraTestFixture.Role, "Orleans.Silo.Connect.cluster-b"], + "case-mismatch" => [EntraTestFixture.Role, "orleans.silo.connect.cluster-a"], + _ => throw new ArgumentOutOfRangeException(nameof(roleCase)), + }; + var token = fixture.CreateToken(roles: roles); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.UnauthorizedCaller); + } + + [Fact] + public async Task AcceptsMultipleRolesIncludingExactClusterRole() + { + using var fixture = new EntraTestFixture(); + ConfigureExactClusterRole(fixture); + var token = fixture.CreateToken( + roles: [EntraTestFixture.Role, "Unrelated.Before", ExactClusterRole, "Unrelated.After"]); + + var result = await fixture.CreateValidator().ValidateAsync( + token, + EntraTestFixture.ClusterId, + CancellationToken.None); + + Assert.True(result.Principal.Identity?.IsAuthenticated); + Assert.Equal( + [EntraTestFixture.Role, "Unrelated.Before", ExactClusterRole, "Unrelated.After"], + result.Principal.FindAll("roles").Select(claim => claim.Value)); + Assert.Equal(fixture.TimeProvider.GetUtcNow().AddMinutes(30), result.ExpiresAt); + } + + [Theory] + [InlineData("wrong-tenant")] + [InlineData("issuer-mismatch")] + public async Task RejectsWrongTenantOrIssuer(string failureCase) + { + using var fixture = new EntraTestFixture(); + ConfigureExactClusterRole(fixture); + var token = failureCase switch + { + "wrong-tenant" => fixture.CreateToken( + tenantId: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + roles: [EntraTestFixture.Role, ExactClusterRole]), + "issuer-mismatch" => fixture.CreateToken( + issuer: "https://login.microsoftonline.com/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/v2.0", + roles: [EntraTestFixture.Role, ExactClusterRole]), + _ => throw new ArgumentOutOfRangeException(nameof(failureCase)), + }; + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.InvalidToken); + } + + [Theory] + [InlineData(EntraTestFixture.ClusterId, true)] + [InlineData("Cluster-A", false)] + [InlineData("cluster", false)] + [InlineData("cluster-a-suffix", false)] + public async Task ConfiguredCustomClusterClaimRequiresExactOrdinalValue(string claimValue, bool succeeds) + { + using var fixture = new EntraTestFixture(); + var token = fixture.CreateToken(clusterId: claimValue); + + if (succeeds) + { + var result = await fixture.CreateValidator().ValidateAsync( + token, + EntraTestFixture.ClusterId, + CancellationToken.None); + + Assert.True(result.Principal.Identity?.IsAuthenticated); + Assert.Equal(claimValue, result.Principal.FindFirst("orleans_cluster")?.Value); + Assert.Equal(fixture.TimeProvider.GetUtcNow().AddMinutes(30), result.ExpiresAt); + } + else + { + await AssertErrorAsync(fixture, token, EntraAuthenticationError.UnauthorizedCaller); + } + } + + private const string ExactClusterRole = "Orleans.Silo.Connect.cluster-a"; + + private static void ConfigureExactClusterRole(EntraTestFixture fixture) + { + fixture.Options.ClusterClaimType = null; + fixture.Options.ClusterRole = ExactClusterRole; + } } diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraOptionsTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraOptionsTests.cs index 7a3e9c48761..54793b32612 100644 --- a/test/Orleans.Connections.Security.Entra.Tests/EntraOptionsTests.cs +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraOptionsTests.cs @@ -83,4 +83,124 @@ public void RejectsEffectivelyUnboundedMetadataWork() Assert.False(result.Succeeded); } + [Fact] + public void Validate_AcceptsSeparateTokenScopeResourceApplicationIdAndClusterRole() + { + var options = EntraTestFixture.CreateOptions(); + options.TokenScope = "api://11111111-1111-1111-1111-111111111111/cluster-a"; + options.ResourceApplicationId = "44444444-4444-4444-4444-444444444444"; + options.ValidAudiences.Clear(); + options.ClusterClaimType = null; + options.ClusterRole = "Orleans.Silo.Connect.cluster-a"; + + var result = new EntraSiloConnectionOptionsValidator().Validate(Options.DefaultName, options); + + Assert.True(result.Succeeded); + } + + [Fact] + public void Validate_AcceptsExplicitClusterClaimBinding() + { + var options = EntraTestFixture.CreateOptions(); + options.ResourceApplicationId = "44444444-4444-4444-4444-444444444444"; + options.ClusterRole = null; + options.ClusterClaimType = "orleans_cluster"; + + var result = new EntraSiloConnectionOptionsValidator().Validate(Options.DefaultName, options); + + Assert.True(result.Succeeded); + } + + [Fact] + public void Validate_RejectsMissingResourceApplicationId() + { + var options = EntraTestFixture.CreateOptions(); + options.ResourceApplicationId = null; + + var result = new EntraSiloConnectionOptionsValidator().Validate(Options.DefaultName, options); + + AssertValidationFailure(result, nameof(options.ResourceApplicationId), "must be configured"); + } + + [Theory] + [InlineData("api://11111111-1111-1111-1111-111111111111")] + [InlineData("not-an-application-id")] + public void Validate_RejectsNonGuidResourceApplicationId(string resourceApplicationId) + { + var options = EntraTestFixture.CreateOptions(); + options.ResourceApplicationId = resourceApplicationId; + + var result = new EntraSiloConnectionOptionsValidator().Validate(Options.DefaultName, options); + + AssertValidationFailure(result, nameof(options.ResourceApplicationId), "must be a GUID"); + } + + [Fact] + public void Validate_RejectsMissingClusterBinding() + { + var options = EntraTestFixture.CreateOptions(); + options.ClusterClaimType = null; + options.ClusterRole = null; + + var result = new EntraSiloConnectionOptionsValidator().Validate(Options.DefaultName, options); + + AssertValidationFailure(result, nameof(options.ClusterRole), "A cluster role"); + Assert.Contains(nameof(options.ClusterClaimType), result.FailureMessage, StringComparison.Ordinal); + } + + [Fact] + public void Validate_RejectsClusterAudienceAuthorization() + { + var options = EntraTestFixture.CreateOptions(); + options.ClusterClaimType = null; + options.ClusterRole = null; +#pragma warning disable CS0618 + options.ClusterAudienceFormat = "api://orleans-silos/{0}"; +#pragma warning restore CS0618 + + var result = new EntraSiloConnectionOptionsValidator().Validate(Options.DefaultName, options); + + AssertValidationFailure(result, nameof(options.ClusterRole), "A cluster role"); + Assert.Contains(nameof(options.ClusterClaimType), result.FailureMessage, StringComparison.Ordinal); + } + + [Fact] + public void Validate_RejectsAmbiguousRoleAndClaimBinding() + { + var options = EntraTestFixture.CreateOptions(); + options.ClusterRole = "Orleans.Silo.Connect.cluster-a"; + options.ClusterClaimType = "orleans_cluster"; + + var result = new EntraSiloConnectionOptionsValidator().Validate(Options.DefaultName, options); + + AssertValidationFailure(result, nameof(options.ClusterRole), "either a cluster role"); + Assert.Contains(nameof(options.ClusterClaimType), result.FailureMessage, StringComparison.Ordinal); + } + + [Fact] + public void Validate_RejectsExactAndFormattedClusterRolesTogether() + { + var options = EntraTestFixture.CreateOptions(); + options.ClusterClaimType = null; + options.ClusterRole = "Orleans.Silo.Connect.cluster-a"; + options.ClusterRoleFormat = "Orleans.Silo.Connect.{0}"; + + var result = new EntraSiloConnectionOptionsValidator().Validate(Options.DefaultName, options); + + AssertValidationFailure(result, nameof(options.ClusterRole), "Only one"); + Assert.Contains(nameof(options.ClusterRoleFormat), result.FailureMessage, StringComparison.Ordinal); + } + + private static void AssertValidationFailure( + Microsoft.Extensions.Options.ValidateOptionsResult result, + string memberName, + string reason) + { + Assert.False(result.Succeeded); + Assert.NotNull(result.Failures); + var failure = Assert.Single(result.Failures); + Assert.Contains(memberName, failure, StringComparison.Ordinal); + Assert.Contains(reason, failure, StringComparison.Ordinal); + } + } diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraSiloConnectionTokenProviderTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraSiloConnectionTokenProviderTests.cs new file mode 100644 index 00000000000..262735efa08 --- /dev/null +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraSiloConnectionTokenProviderTests.cs @@ -0,0 +1,84 @@ +using Azure.Core; +using Orleans.Connections.Security.Entra; +using System.Runtime.CompilerServices; + +namespace Orleans.Connections.Security.Entra.Tests; + +[TestCategory("BVT")] +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Security")] +public sealed class EntraSiloConnectionTokenProviderTests +{ + [Fact] + public async Task GetTokenAsync_ForwardsConnectionContextAndReturnsAcquiredToken() + { + var expiresAt = new DateTimeOffset(2026, 8, 23, 12, 30, 0, TimeSpan.Zero); + var expectedToken = new AccessToken("acquired-token", expiresAt); + SiloConnectionTokenRequestContext? capturedContext = null; + var underlyingProvider = new TestEntraTokenProvider((context, _) => + { + capturedContext = context; + return ValueTask.FromResult(expectedToken); + }); + var provider = new EntraSiloConnectionTokenProvider(underlyingProvider); + var requestContext = (SiloConnectionTokenRequestContext)RuntimeHelpers.GetUninitializedObject( + typeof(SiloConnectionTokenRequestContext)); + + var result = await provider.GetTokenAsync(requestContext, CancellationToken.None); + + Assert.Same(requestContext, capturedContext); + Assert.Equal(1, underlyingProvider.CallCount); + Assert.Equal(expectedToken.Token, result.Value); + Assert.Equal(expectedToken.ExpiresOn, result.ExpiresAt); + } + + [Fact] + public async Task GetTokenAsync_ForwardsCancellationToken() + { + using var cancellation = new CancellationTokenSource(); + var expectedToken = new AccessToken( + "cancellation-token", + new DateTimeOffset(2026, 8, 23, 12, 30, 0, TimeSpan.Zero)); + CancellationToken capturedCancellationToken = default; + var underlyingProvider = new TestEntraTokenProvider((_, cancellationToken) => + { + capturedCancellationToken = cancellationToken; + return ValueTask.FromResult(expectedToken); + }); + var provider = new EntraSiloConnectionTokenProvider(underlyingProvider); + var requestContext = (SiloConnectionTokenRequestContext)RuntimeHelpers.GetUninitializedObject( + typeof(SiloConnectionTokenRequestContext)); + + var result = await provider.GetTokenAsync(requestContext, cancellation.Token); + + Assert.Equal(cancellation.Token, capturedCancellationToken); + Assert.Equal(1, underlyingProvider.CallCount); + Assert.Equal(expectedToken.Token, result.Value); + Assert.Equal(expectedToken.ExpiresOn, result.ExpiresAt); + } + + [Fact] + public void Constructor_NullProvider_ThrowsArgumentNullException() + { + var exception = Assert.Throws( + () => new EntraSiloConnectionTokenProvider(null!)); + + Assert.Equal("provider", exception.ParamName); + } + + private sealed class TestEntraTokenProvider( + Func> getToken) + : IEntraTokenProvider + { + public int CallCount { get; private set; } + + public ValueTask GetTokenAsync( + SiloConnectionTokenRequestContext context, + CancellationToken cancellationToken) + { + CallCount++; + return getToken(context, cancellationToken); + } + } +} diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraSiloConnectionTokenValidatorTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraSiloConnectionTokenValidatorTests.cs new file mode 100644 index 00000000000..932b306f7fc --- /dev/null +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraSiloConnectionTokenValidatorTests.cs @@ -0,0 +1,124 @@ +using System.Reflection; +using Orleans.Connections.Security.Entra; + +namespace Orleans.Connections.Security.Entra.Tests; + +[TestCategory("BVT")] +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Security")] +public sealed class EntraSiloConnectionTokenValidatorTests +{ + private const string ExactClusterRole = "Orleans.Silo.Connect.cluster-a"; + + [Theory] + [InlineData("invalid-audience", SiloConnectionAuthenticationFailure.InvalidToken)] + [InlineData("missing-role", SiloConnectionAuthenticationFailure.UnauthorizedCaller)] + [InlineData("wrong-role", SiloConnectionAuthenticationFailure.UnauthorizedCaller)] + [InlineData("wrong-tenant", SiloConnectionAuthenticationFailure.InvalidToken)] + [InlineData("issuer-mismatch", SiloConnectionAuthenticationFailure.InvalidToken)] + [InlineData("expired", SiloConnectionAuthenticationFailure.ExpiredToken)] + public async Task ValidateTokenAsync_MapsJwtFailureToBoundedCategory( + string failureCase, + SiloConnectionAuthenticationFailure expectedFailure) + { + using var fixture = new EntraTestFixture(); + ConfigureExactClusterRole(fixture); + var token = failureCase switch + { + "invalid-audience" => fixture.CreateToken( + audience: fixture.Options.TokenScope!, + roles: [EntraTestFixture.Role, ExactClusterRole]), + "missing-role" => fixture.CreateToken(roles: [EntraTestFixture.Role]), + "wrong-role" => fixture.CreateToken( + roles: [EntraTestFixture.Role, "Orleans.Silo.Connect.cluster-b"]), + "wrong-tenant" => fixture.CreateToken( + tenantId: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + roles: [EntraTestFixture.Role, ExactClusterRole]), + "issuer-mismatch" => fixture.CreateToken( + issuer: "https://login.microsoftonline.com/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/v2.0", + roles: [EntraTestFixture.Role, ExactClusterRole]), + "expired" => fixture.CreateToken( + roles: [EntraTestFixture.Role, ExactClusterRole], + notBefore: fixture.TimeProvider.GetUtcNow().AddMinutes(-30), + expires: fixture.TimeProvider.GetUtcNow().AddMinutes(-5)), + _ => throw new ArgumentOutOfRangeException(nameof(failureCase)), + }; + using var validator = new EntraSiloConnectionTokenValidator(fixture.CreateValidator()); + + var result = await validator.ValidateTokenAsync(token, CreateContext(), CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal(expectedFailure, result.Failure); + Assert.Null(result.Principal); + Assert.Null(result.ExpiresAt); + } + + [Fact] + public async Task ValidateTokenAsync_DoesNotExposeTokenOrClaimValuesInFailure() + { + const string tenant = "tenant-secret"; + const string issuer = "https://issuer-secret.example/v2.0"; + const string audience = "audience-secret"; + const string role = "role-secret"; + using var fixture = new EntraTestFixture(); + ConfigureExactClusterRole(fixture); + var token = fixture.CreateToken( + tenantId: tenant, + issuer: issuer, + audience: audience, + roles: [role]); + using var validator = new EntraSiloConnectionTokenValidator(fixture.CreateValidator()); + + var result = await validator.ValidateTokenAsync(token, CreateContext(), CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal(SiloConnectionAuthenticationFailure.InvalidToken, result.Failure); + Assert.Null(result.Principal); + Assert.Null(result.ExpiresAt); + var publicDiagnostic = $"{result.Succeeded}|{result.Failure}|{result.Principal}|{result.ExpiresAt}|{result}"; + Assert.DoesNotContain(token, publicDiagnostic, StringComparison.Ordinal); + Assert.DoesNotContain(tenant, publicDiagnostic, StringComparison.Ordinal); + Assert.DoesNotContain(issuer, publicDiagnostic, StringComparison.Ordinal); + Assert.DoesNotContain(audience, publicDiagnostic, StringComparison.Ordinal); + Assert.DoesNotContain(role, publicDiagnostic, StringComparison.Ordinal); + } + + [Fact] + public async Task ValidateTokenAsync_ReturnsPrincipalAndExpirationOnSuccess() + { + using var fixture = new EntraTestFixture(); + ConfigureExactClusterRole(fixture); + fixture.Options.ValidAudiences.Clear(); + var token = fixture.CreateToken( + roles: [EntraTestFixture.Role, "Unrelated.Before", ExactClusterRole, "Unrelated.After"]); + using var validator = new EntraSiloConnectionTokenValidator(fixture.CreateValidator()); + + var result = await validator.ValidateTokenAsync(token, CreateContext(), CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Equal(SiloConnectionAuthenticationFailure.None, result.Failure); + var principal = Assert.IsType(result.Principal); + Assert.True(principal.Identity?.IsAuthenticated); + Assert.Equal("Entra", principal.Identity?.AuthenticationType); + Assert.Equal(EntraTestFixture.ClientId, principal.FindFirst("azp")?.Value); + Assert.Equal( + [EntraTestFixture.Role, "Unrelated.Before", ExactClusterRole, "Unrelated.After"], + principal.FindAll("roles").Select(claim => claim.Value)); + Assert.Equal(fixture.TimeProvider.GetUtcNow().AddMinutes(30), result.ExpiresAt); + } + + private static void ConfigureExactClusterRole(EntraTestFixture fixture) + { + fixture.Options.ClusterClaimType = null; + fixture.Options.ClusterRole = ExactClusterRole; + } + + private static SiloConnectionTokenValidationContext CreateContext() + => (SiloConnectionTokenValidationContext)Activator.CreateInstance( + typeof(SiloConnectionTokenValidationContext), + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + args: [EntraTestFixture.ClusterId, SiloConnectionAuthenticationTarget.Silo, null, null], + culture: null)!; +} diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraTestInfrastructure.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraTestInfrastructure.cs index fe2ececbfc1..29ca7a72a6c 100644 --- a/test/Orleans.Connections.Security.Entra.Tests/EntraTestInfrastructure.cs +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraTestInfrastructure.cs @@ -14,7 +14,7 @@ namespace Orleans.Connections.Security.Entra.Tests; internal sealed class EntraTestFixture : IDisposable { - public const string Audience = "api://orleans-silos"; + public const string Audience = "44444444-4444-4444-4444-444444444444"; public const string ClientId = "11111111-1111-1111-1111-111111111111"; public const string ClusterId = "cluster-a"; public const string Issuer = "https://login.microsoftonline.com/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/v2.0"; @@ -47,7 +47,8 @@ public static EntraSiloConnectionOptions CreateOptions( var options = new EntraSiloConnectionOptions { Authority = new Uri(authority), - TokenScope = $"{audience}/.default", + TokenScope = $"api://11111111-1111-1111-1111-111111111111/{ClusterId}", + ResourceApplicationId = audience, ClusterClaimType = "orleans_cluster", MetadataRefreshJitterRatio = 0, }; @@ -235,6 +236,8 @@ internal sealed class TestTokenCredential(Func GetTokenAsync(requestContext, cancellationToken).AsTask().GetAwaiter().GetResult(); @@ -243,6 +246,7 @@ public override ValueTask GetTokenAsync( CancellationToken cancellationToken) { CallCount++; + LastRequestContext = requestContext; return getToken(requestContext, cancellationToken); } } diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraTokenProviderTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraTokenProviderTests.cs index 78acff60a3a..f9892d6d1b7 100644 --- a/test/Orleans.Connections.Security.Entra.Tests/EntraTokenProviderTests.cs +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraTokenProviderTests.cs @@ -40,4 +40,70 @@ public async Task RejectsTokenWithInsufficientRemainingLifetimeWithoutLeakingIt( Assert.Equal(EntraAuthenticationError.TokenAcquisitionFailed, exception.Error); Assert.DoesNotContain(token, exception.ToString(), StringComparison.Ordinal); } + + [Fact] + public async Task RequestsConfiguredClusterScope() + { + var options = EntraTestFixture.CreateOptions(); + options.TokenScope = "api://11111111-1111-1111-1111-111111111111/cluster-a"; + options.ResourceApplicationId = "44444444-4444-4444-4444-444444444444"; + options.ClusterRole = "Orleans.Silo.Connect.cluster-a"; + var timeProvider = new TestTimeProvider(new DateTimeOffset(2026, 8, 23, 12, 0, 0, TimeSpan.Zero)); + var credential = new TestTokenCredential( + (_, _) => ValueTask.FromResult(new AccessToken("acquired-token", timeProvider.GetUtcNow().AddMinutes(10)))); + var provider = new EntraTokenProvider(credential, options, timeProvider); + + var token = await provider.GetTokenAsync(CancellationToken.None); + + var requestContext = credential.LastRequestContext; + Assert.True(requestContext.HasValue); + var scopes = requestContext.Value.Scopes; + Assert.Equal(["api://11111111-1111-1111-1111-111111111111/cluster-a/.default"], scopes); + Assert.DoesNotContain(options.ResourceApplicationId, scopes); + Assert.DoesNotContain(options.ClusterRole, scopes); + Assert.Equal(1, credential.CallCount); + Assert.Equal("acquired-token", token.Token); + } + + [Fact] + public async Task RequestsConfiguredClusterScope_WhenAlreadySuffixed_DoesNotDuplicateDefaultSuffix() + { + var options = EntraTestFixture.CreateOptions(); + options.TokenScope = "api://11111111-1111-1111-1111-111111111111/cluster-a/.default"; + var timeProvider = new TestTimeProvider(new DateTimeOffset(2026, 8, 23, 12, 0, 0, TimeSpan.Zero)); + var credential = new TestTokenCredential( + (_, _) => ValueTask.FromResult(new AccessToken("already-suffixed-token", timeProvider.GetUtcNow().AddMinutes(10)))); + var provider = new EntraTokenProvider(credential, options, timeProvider); + + var token = await provider.GetTokenAsync(CancellationToken.None); + + var requestContext = credential.LastRequestContext; + Assert.True(requestContext.HasValue); + Assert.Equal( + ["api://11111111-1111-1111-1111-111111111111/cluster-a/.default"], + requestContext.Value.Scopes); + Assert.Equal(1, credential.CallCount); + Assert.Equal("already-suffixed-token", token.Token); + } + + [Fact] + public async Task RequestsConfiguredClusterScope_WhenTrailingSlash_NormalizesBeforeDefaultSuffix() + { + var options = EntraTestFixture.CreateOptions(); + options.TokenScope = "api://11111111-1111-1111-1111-111111111111/cluster-a/"; + var timeProvider = new TestTimeProvider(new DateTimeOffset(2026, 8, 23, 12, 0, 0, TimeSpan.Zero)); + var credential = new TestTokenCredential( + (_, _) => ValueTask.FromResult(new AccessToken("trailing-slash-token", timeProvider.GetUtcNow().AddMinutes(10)))); + var provider = new EntraTokenProvider(credential, options, timeProvider); + + var token = await provider.GetTokenAsync(CancellationToken.None); + + var requestContext = credential.LastRequestContext; + Assert.True(requestContext.HasValue); + Assert.Equal( + ["api://11111111-1111-1111-1111-111111111111/cluster-a/.default"], + requestContext.Value.Scopes); + Assert.Equal(1, credential.CallCount); + Assert.Equal("trailing-slash-token", token.Token); + } } diff --git a/test/Orleans.Connections.Security.Tests/Authentication/SiloConnectionAuthenticationMiddlewareTests.cs b/test/Orleans.Connections.Security.Tests/Authentication/SiloConnectionAuthenticationMiddlewareTests.cs new file mode 100644 index 00000000000..fbc334a534d --- /dev/null +++ b/test/Orleans.Connections.Security.Tests/Authentication/SiloConnectionAuthenticationMiddlewareTests.cs @@ -0,0 +1,778 @@ +using System.Diagnostics.Metrics; +using System.IO.Pipelines; +using System.Net; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Orleans.Runtime.Messaging; +using Xunit; + +namespace Orleans.Connections.Security.Tests; + +[TestCategory("BVT")] +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Security")] +public class SiloConnectionAuthenticationMiddlewareTests +{ + private const byte TokenFrameType = 0x01; + private const byte ResultFrameType = 0x02; + private const byte AuthenticatedResult = 0x01; + private const byte AcceptedUnauthenticatedResult = 0x02; + private const byte RejectedResult = 0x03; + private static readonly DateTimeOffset Now = new(2031, 2, 3, 4, 5, 6, TimeSpan.Zero); + private static readonly DateTimeOffset Expiration = Now.AddMinutes(30); + + [Theory] + [InlineData(SiloConnectionAuthenticationDirection.Inbound)] + [InlineData(SiloConnectionAuthenticationDirection.Outbound)] + public async Task NegotiatedAuthenticationFailure_AbortsInRequiredAndAuditAndReportsSameCategory( + SiloConnectionAuthenticationDirection direction) + { + var results = new List(); + + foreach (var mode in new[] { SiloConnectionAuthenticationMode.Required, SiloConnectionAuthenticationMode.Audit }) + { + results.Add(await RunNegotiatedFailureAsync(mode, direction)); + } + + var expectedCategory = direction == SiloConnectionAuthenticationDirection.Inbound + ? "authorization_failure" + : "rejected"; + Assert.All(results, result => + { + Assert.Equal(0, result.DownstreamCalls); + Assert.NotNull(result.AbortReason); + Assert.Equal( + $"Orleans connection authentication failed ({expectedCategory}).", + result.AbortReason.Message); + Assert.Null(result.Feature); + Assert.Equal(expectedCategory, Assert.Single(result.FailureLogs).Properties["Category"]); + Assert.Equal(expectedCategory, Assert.Single(result.AttemptMetrics).Tags["result"]); + Assert.DoesNotContain(result.Logs, entry => entry.EventId == 9201); + Assert.DoesNotContain( + result.AttemptMetrics, + measurement => measurement.Tags["result"] is "authenticated" or "accepted_unauthenticated"); + }); + Assert.Equal( + results[0].FailureLogs.Single().Properties["Category"], + results[1].FailureLogs.Single().Properties["Category"]); + + if (direction == SiloConnectionAuthenticationDirection.Inbound) + { + Assert.All(results, result => + { + Assert.Equal(ResultFrameType, result.PeerFrameType); + Assert.Equal([RejectedResult], result.PeerPayload); + }); + } + else + { + Assert.All(results, result => + { + Assert.Equal(TokenFrameType, result.PeerFrameType); + Assert.Equal("fixed-outbound-token", Encoding.UTF8.GetString(result.PeerPayload)); + }); + } + } + + [Fact] + public async Task TokenAcquisitionFailure_AbortsInRequiredAndAuditAndReportsSameCategory() + { + var results = new List(); + + foreach (var mode in new[] { SiloConnectionAuthenticationMode.Required, SiloConnectionAuthenticationMode.Audit }) + { + var provider = new DelegateTokenProvider((_, _) => + throw new InvalidOperationException("provider-sensitive-sentinel")); + await using var pair = ConnectionPair.Create(SiloConnectionAuthenticationProtocol.Version2); + await WriteFrameAsync( + pair.Peer, + ResultFrameType, + [RejectedResult]); + var logger = new CaptureLogger(); + using var telemetry = new TelemetryCapture(); + var middleware = CreateOutboundMiddleware(mode, provider, logger); + var downstreamCalls = 0; + + await middleware.OnConnectionAsync(pair.Connection, _ => + { + downstreamCalls++; + return Task.CompletedTask; + }); + + var peerReceivedFrame = TryReadAvailableFrame(pair.Peer); + results.Add(CaptureResult(pair.Connection, downstreamCalls, logger, telemetry, peerReceivedFrame)); + Assert.Equal(1, provider.CallCount); + } + + Assert.All(results, result => + { + Assert.Equal(0, result.DownstreamCalls); + Assert.NotNull(result.AbortReason); + Assert.Equal( + "Orleans connection authentication failed (acquisition_failure).", + result.AbortReason.Message); + Assert.False(result.PeerReceivedFrame); + Assert.Null(result.Feature); + Assert.Equal("acquisition_failure", Assert.Single(result.FailureLogs).Properties["Category"]); + Assert.Equal("acquisition_failure", Assert.Single(result.AttemptMetrics).Tags["result"]); + Assert.DoesNotContain("provider-sensitive-sentinel", result.AllLogText, StringComparison.Ordinal); + }); + Assert.Equal( + results[0].FailureLogs.Single().Properties["Category"], + results[1].FailureLogs.Single().Properties["Category"]); + } + + [Theory] + [InlineData("missing-provider", "acquisition_failure")] + [InlineData("empty-token", "acquisition_failure")] + [InlineData("oversized-token", "acquisition_failure")] + [InlineData("invalid-utf8-token", "acquisition_failure")] + [InlineData("missing-expiration", "acquisition_failure")] + [InlineData("expired-token", "expiration")] + public async Task InvalidOutboundCredential_AbortsInRequiredAndAuditWithoutSendingToken( + string failureCase, + string expectedCategory) + { + var results = new List(); + + foreach (var mode in new[] { SiloConnectionAuthenticationMode.Required, SiloConnectionAuthenticationMode.Audit }) + { + DelegateTokenProvider? provider = failureCase switch + { + "missing-provider" => null, + "empty-token" => new DelegateTokenProvider((_, _) => + ValueTask.FromResult(new SiloConnectionToken(string.Empty, Expiration))), + "oversized-token" => new DelegateTokenProvider((_, _) => + ValueTask.FromResult(new SiloConnectionToken(new string('x', (16 * 1024) + 1), Expiration))), + "invalid-utf8-token" => new DelegateTokenProvider((_, _) => + ValueTask.FromResult(new SiloConnectionToken("\uD800", Expiration))), + "missing-expiration" => new DelegateTokenProvider((_, _) => + ValueTask.FromResult(new SiloConnectionToken("missing-expiration", null))), + "expired-token" => new DelegateTokenProvider((_, _) => + ValueTask.FromResult(new SiloConnectionToken("insufficient-lifetime", Now.AddMinutes(1)))), + _ => throw new ArgumentOutOfRangeException(nameof(failureCase)), + }; + await using var pair = ConnectionPair.Create(SiloConnectionAuthenticationProtocol.Version2); + await WriteFrameAsync(pair.Peer, ResultFrameType, [RejectedResult]); + var logger = new CaptureLogger(); + using var telemetry = new TelemetryCapture(); + var middleware = CreateOutboundMiddleware(mode, provider, logger); + var downstreamCalls = 0; + + await middleware.OnConnectionAsync(pair.Connection, _ => + { + downstreamCalls++; + return Task.CompletedTask; + }); + + results.Add(CaptureResult( + pair.Connection, + downstreamCalls, + logger, + telemetry, + TryReadAvailableFrame(pair.Peer))); + if (provider is not null) + { + Assert.Equal(1, provider.CallCount); + } + } + + Assert.All(results, result => + { + Assert.Equal(0, result.DownstreamCalls); + Assert.Equal( + $"Orleans connection authentication failed ({expectedCategory}).", + result.AbortReason?.Message); + Assert.False(result.PeerReceivedFrame); + Assert.Null(result.Feature); + Assert.Equal(expectedCategory, Assert.Single(result.FailureLogs).Properties["Category"]); + Assert.Equal(expectedCategory, Assert.Single(result.AttemptMetrics).Tags["result"]); + Assert.DoesNotContain(result.Logs, entry => entry.EventId == 9201); + }); + Assert.Equal( + results[0].FailureLogs.Single().Properties["Category"], + results[1].FailureLogs.Single().Properties["Category"]); + } + + [Fact] + public async Task NegotiatedAuthenticationFailure_DiagnosticsAreBounded() + { + const string tokenSentinel = "token-SENTINEL-2c65"; + const string tenantSentinel = "tenant-SENTINEL-a764"; + const string issuerSentinel = "issuer-SENTINEL-c639"; + const string audienceSentinel = "audience-SENTINEL-d184"; + const string roleSentinel = "role-SENTINEL-f502"; + var token = string.Join('.', tokenSentinel, tenantSentinel, issuerSentinel, audienceSentinel, roleSentinel); + + foreach (var mode in new[] { SiloConnectionAuthenticationMode.Required, SiloConnectionAuthenticationMode.Audit }) + { + await using var pair = ConnectionPair.Create(SiloConnectionAuthenticationProtocol.Version2); + await WriteFrameAsync( + pair.Peer, + TokenFrameType, + Encoding.UTF8.GetBytes(token)); + var logger = new CaptureLogger(); + using var telemetry = new TelemetryCapture(); + var validator = new DelegateTokenValidator((actualToken, _, _) => + { + Assert.Equal(token, actualToken); + return ValueTask.FromResult( + SiloConnectionTokenValidationResult.Fail(SiloConnectionAuthenticationFailure.UnauthorizedCaller)); + }); + var middleware = CreateInboundMiddleware(mode, validator, logger); + + await middleware.OnConnectionAsync(pair.Connection, _ => Task.CompletedTask); + + var failure = Assert.Single(logger.Entries, entry => entry.EventId == 9200); + Assert.Equal(LogLevel.Warning, failure.Level); + Assert.Equal("authorization_failure", failure.Properties["Category"]); + Assert.Equal("authorization_failure", Assert.Single(telemetry.Attempts).Tags["result"]); + foreach (var sentinel in new[] + { + tokenSentinel, + tenantSentinel, + issuerSentinel, + audienceSentinel, + roleSentinel, + }) + { + Assert.DoesNotContain(sentinel, logger.AllText, StringComparison.Ordinal); + Assert.DoesNotContain(sentinel, telemetry.AllText, StringComparison.Ordinal); + Assert.DoesNotContain(sentinel, pair.Connection.AbortReason!.Message, StringComparison.Ordinal); + } + } + } + + [Theory] + [InlineData(SiloConnectionAuthenticationDirection.Inbound)] + [InlineData(SiloConnectionAuthenticationDirection.Outbound)] + public async Task Audit_BaselinePeer_FallsBackWithoutClaimingAuthentication( + SiloConnectionAuthenticationDirection direction) + { + await using var pair = ConnectionPair.Create("Orleans1"); + var logger = new CaptureLogger(); + using var telemetry = new TelemetryCapture(); + var middleware = CreateMiddleware( + SiloConnectionAuthenticationMode.Audit, + direction, + provider: null, + validator: null, + logger); + ISiloConnectionAuthenticationFeature? observedFeature = null; + var downstreamCalls = 0; + + await middleware.OnConnectionAsync(pair.Connection, context => + { + downstreamCalls++; + observedFeature = context.Features.Get(); + return Task.CompletedTask; + }); + + Assert.Equal(1, downstreamCalls); + Assert.Null(pair.Connection.AbortReason); + var feature = Assert.IsAssignableFrom(observedFeature); + Assert.False(feature.AuthenticationAttempted); + Assert.False(feature.IsAuthenticated); + Assert.Null(feature.Principal); + Assert.Null(feature.ExpiresAt); + Assert.Equal(SiloConnectionAuthenticationFailure.None, feature.Failure); + Assert.Equal("Orleans1", feature.Protocol); + var fallback = Assert.Single(logger.Entries, entry => entry.EventId == 9202); + Assert.Equal(LogLevel.Information, fallback.Level); + Assert.Empty(telemetry.Attempts); + Assert.Single(telemetry.ProtocolFallbacks); + Assert.DoesNotContain(logger.Entries, entry => entry.EventId == 9201); + } + + [Theory] + [InlineData(SiloConnectionAuthenticationDirection.Inbound)] + [InlineData(SiloConnectionAuthenticationDirection.Outbound)] + public async Task Required_BaselinePeer_IsRejected(SiloConnectionAuthenticationDirection direction) + { + await using var pair = ConnectionPair.Create("Orleans1"); + var logger = new CaptureLogger(); + using var telemetry = new TelemetryCapture(); + var middleware = CreateMiddleware( + SiloConnectionAuthenticationMode.Required, + direction, + provider: null, + validator: null, + logger); + var downstreamCalls = 0; + + await middleware.OnConnectionAsync(pair.Connection, _ => + { + downstreamCalls++; + return Task.CompletedTask; + }); + + Assert.Equal(0, downstreamCalls); + Assert.Equal( + "Orleans connection authentication failed (tls_policy_error).", + pair.Connection.AbortReason?.Message); + Assert.Null(pair.Connection.Features.Get()); + Assert.Equal("tls_policy_error", Assert.Single(logger.Entries, entry => entry.EventId == 9200).Properties["Category"]); + Assert.Equal("tls_policy_error", Assert.Single(telemetry.Attempts).Tags["result"]); + Assert.Empty(telemetry.ProtocolFallbacks); + } + + [Fact] + public async Task NegotiatedAuthenticationSuccess_InvokesPipelineAndPreservesPrincipalAndExpiration() + { + const string token = "successful-fixed-token"; + const string subject = "silo-success-17"; + + foreach (var mode in new[] { SiloConnectionAuthenticationMode.Required, SiloConnectionAuthenticationMode.Audit }) + { + await using var pair = ConnectionPair.Create(SiloConnectionAuthenticationProtocol.Version2); + await WriteFrameAsync( + pair.Peer, + TokenFrameType, + Encoding.UTF8.GetBytes(token)); + var principal = new ClaimsPrincipal( + new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, subject)], "fixed-token")); + SiloConnectionTokenValidationContext? validationContext = null; + var validator = new DelegateTokenValidator((actualToken, context, _) => + { + Assert.Equal(token, actualToken); + validationContext = context; + return ValueTask.FromResult(SiloConnectionTokenValidationResult.Success(principal, Expiration)); + }); + var logger = new CaptureLogger(); + using var telemetry = new TelemetryCapture(); + var middleware = CreateInboundMiddleware(mode, validator, logger); + ISiloConnectionAuthenticationFeature? observedFeature = null; + var downstreamCalls = 0; + + await middleware.OnConnectionAsync(pair.Connection, context => + { + downstreamCalls++; + observedFeature = context.Features.Get(); + return Task.CompletedTask; + }); + var (frameType, payload) = await ReadFrameAsync(pair.Peer); + + Assert.Equal(1, downstreamCalls); + Assert.Null(pair.Connection.AbortReason); + Assert.Equal(ResultFrameType, frameType); + Assert.Equal([AuthenticatedResult], payload); + var feature = Assert.IsAssignableFrom(observedFeature); + Assert.True(feature.AuthenticationAttempted); + Assert.True(feature.IsAuthenticated); + Assert.Equal(subject, feature.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value); + Assert.Equal("fixed-token", feature.Principal?.Identity?.AuthenticationType); + Assert.NotSame(principal, feature.Principal); + Assert.Equal(Expiration, feature.ExpiresAt); + Assert.Equal(SiloConnectionAuthenticationFailure.None, feature.Failure); + Assert.Equal(SiloConnectionAuthenticationProtocol.Version2, feature.Protocol); + Assert.Equal("phase-3-cluster", validationContext?.ClusterId); + Assert.Equal(pair.Connection.LocalEndPoint, validationContext?.LocalEndPoint); + Assert.Equal(pair.Connection.RemoteEndPoint, validationContext?.RemoteEndPoint); + Assert.Equal("authenticated", Assert.Single(logger.Entries, entry => entry.EventId == 9201).Properties["Result"]); + Assert.Equal("authenticated", Assert.Single(telemetry.Attempts).Tags["result"]); + } + } + + private static async Task RunNegotiatedFailureAsync( + SiloConnectionAuthenticationMode mode, + SiloConnectionAuthenticationDirection direction) + { + await using var pair = ConnectionPair.Create(SiloConnectionAuthenticationProtocol.Version2); + var logger = new CaptureLogger(); + using var telemetry = new TelemetryCapture(); + IConnectionMiddleware middleware; + + if (direction == SiloConnectionAuthenticationDirection.Inbound) + { + await WriteFrameAsync( + pair.Peer, + TokenFrameType, + Encoding.UTF8.GetBytes("inbound-sensitive-token")); + middleware = CreateInboundMiddleware( + mode, + new DelegateTokenValidator((_, _, _) => ValueTask.FromResult( + SiloConnectionTokenValidationResult.Fail(SiloConnectionAuthenticationFailure.UnauthorizedCaller))), + logger); + } + else + { + await WriteFrameAsync( + pair.Peer, + ResultFrameType, + [AcceptedUnauthenticatedResult]); + middleware = CreateOutboundMiddleware( + mode, + new DelegateTokenProvider((_, _) => + ValueTask.FromResult(new SiloConnectionToken("fixed-outbound-token", Expiration))), + logger); + } + + var downstreamCalls = 0; + await middleware.OnConnectionAsync(pair.Connection, _ => + { + downstreamCalls++; + return Task.CompletedTask; + }); + var (peerFrameType, peerPayload) = await ReadFrameAsync(pair.Peer); + + return CaptureResult( + pair.Connection, + downstreamCalls, + logger, + telemetry, + peerReceivedFrame: true, + peerFrameType, + peerPayload); + } + + private static IConnectionMiddleware CreateMiddleware( + SiloConnectionAuthenticationMode mode, + SiloConnectionAuthenticationDirection direction, + ISiloConnectionTokenProvider? provider, + ISiloConnectionTokenValidator? validator, + CaptureLogger logger) => + direction == SiloConnectionAuthenticationDirection.Inbound + ? CreateInboundMiddleware(mode, validator, logger) + : CreateOutboundMiddleware(mode, provider, logger); + + private static TestInboundMiddleware CreateInboundMiddleware( + SiloConnectionAuthenticationMode mode, + ISiloConnectionTokenValidator? validator, + CaptureLogger logger) + { + var options = CreateOptions(mode); + return new TestInboundMiddleware(validator, CreateRegistration(options), logger); + } + + private static TestOutboundMiddleware CreateOutboundMiddleware( + SiloConnectionAuthenticationMode mode, + ISiloConnectionTokenProvider? provider, + CaptureLogger logger) + { + var options = CreateOptions(mode); + return new TestOutboundMiddleware(provider, CreateRegistration(options), logger); + } + + private static SiloConnectionAuthenticationRegistration CreateRegistration( + SiloConnectionAuthenticationOptions options) => + new( + "phase-3", + ConnectionAuthenticationServiceKeys.Silo, + options, + new TlsOptions(), + hasTokenProvider: true, + hasTokenValidator: true); + + private static SiloConnectionAuthenticationOptions CreateOptions(SiloConnectionAuthenticationMode mode) => new() + { + Mode = mode, + TimeProvider = new FixedTimeProvider(Now), + TokenExchangeTimeout = TimeSpan.FromMinutes(5), + MinimumRemainingTokenLifetime = TimeSpan.FromMinutes(2), + ExpirationSafetyMargin = TimeSpan.FromSeconds(30), + ExpirationJitter = TimeSpan.Zero, + }; + + private static ScenarioResult CaptureResult( + TestConnectionContext connection, + int downstreamCalls, + CaptureLogger logger, + TelemetryCapture telemetry, + bool peerReceivedFrame, + byte? peerFrameType = null, + byte[]? peerPayload = null) => + new( + downstreamCalls, + connection.AbortReason, + connection.Features.Get(), + logger.Entries.ToArray(), + telemetry.Measurements.ToArray(), + peerReceivedFrame, + peerFrameType, + peerPayload ?? []); + + private static async ValueTask WriteFrameAsync(ConnectionContext context, byte frameType, byte[] payload) => + await ConnectionFrameHelper.WriteFrameAsync(context, frameType, payload, CancellationToken.None); + + private static async ValueTask<(byte FrameType, byte[] Payload)> ReadFrameAsync(ConnectionContext context) => + await ConnectionFrameHelper.ReadFrameAsync(context, CancellationToken.None); + + private static bool TryReadAvailableFrame(ConnectionContext context) + { + if (!context.Transport.Input.TryRead(out var result)) + { + return false; + } + + var hasData = !result.Buffer.IsEmpty; + context.Transport.Input.AdvanceTo(result.Buffer.End); + return hasData; + } + + private sealed class TestInboundMiddleware( + ISiloConnectionTokenValidator? validator, + ConnectionAuthenticationRegistration registration, + ILogger logger) + : InboundSiloConnectionAuthenticationMiddleware( + validator, + registration, + "phase-3-cluster", + TestHostApplicationLifetime.Instance, + logger); + + private sealed class TestOutboundMiddleware( + ISiloConnectionTokenProvider? provider, + ConnectionAuthenticationRegistration registration, + ILogger logger) + : OutboundSiloConnectionAuthenticationMiddleware( + provider, + registration, + "phase-3-cluster", + TestHostApplicationLifetime.Instance, + logger); + + private sealed class DelegateTokenProvider( + Func> callback) + : ISiloConnectionTokenProvider + { + public int CallCount { get; private set; } + + public ValueTask GetTokenAsync( + SiloConnectionTokenRequestContext context, + CancellationToken cancellationToken) + { + CallCount++; + return callback(context, cancellationToken); + } + } + + private sealed class DelegateTokenValidator( + Func> callback) + : ISiloConnectionTokenValidator + { + public ValueTask ValidateTokenAsync( + string token, + SiloConnectionTokenValidationContext context, + CancellationToken cancellationToken) => + callback(token, context, cancellationToken); + } + + private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => utcNow; + + public override ITimer CreateTimer( + TimerCallback callback, + object? state, + TimeSpan dueTime, + TimeSpan period) => + NoOpTimer.Instance; + + private sealed class NoOpTimer : ITimer + { + public static readonly NoOpTimer Instance = new(); + + public bool Change(TimeSpan dueTime, TimeSpan period) => true; + + public void Dispose() + { + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + } + + private sealed class TestHostApplicationLifetime : IHostApplicationLifetime + { + public static readonly TestHostApplicationLifetime Instance = new(); + + public CancellationToken ApplicationStarted => CancellationToken.None; + + public CancellationToken ApplicationStopping => CancellationToken.None; + + public CancellationToken ApplicationStopped => CancellationToken.None; + + public void StopApplication() + { + } + } + + private sealed class TestTlsApplicationProtocolFeature(string protocol) : ITlsApplicationProtocolFeature + { + public ReadOnlyMemory ApplicationProtocol { get; } = Encoding.ASCII.GetBytes(protocol); + } + + private sealed class ConnectionPair : IAsyncDisposable + { + private readonly Pipe _connectionToPeer; + private readonly Pipe _peerToConnection; + + private ConnectionPair(string protocol) + { + _connectionToPeer = new Pipe(); + _peerToConnection = new Pipe(); + Connection = new TestConnectionContext( + new DuplexPipe(_peerToConnection.Reader, _connectionToPeer.Writer), + "connection"); + Peer = new TestConnectionContext( + new DuplexPipe(_connectionToPeer.Reader, _peerToConnection.Writer), + "peer"); + Connection.Features.Set(new TestTlsApplicationProtocolFeature(protocol)); + } + + public TestConnectionContext Connection { get; } + + public TestConnectionContext Peer { get; } + + public static ConnectionPair Create(string protocol) => new(protocol); + + public async ValueTask DisposeAsync() + { + await _connectionToPeer.Reader.CompleteAsync(); + await _connectionToPeer.Writer.CompleteAsync(); + await _peerToConnection.Reader.CompleteAsync(); + await _peerToConnection.Writer.CompleteAsync(); + } + } + + private sealed class TestConnectionContext(IDuplexPipe transport, string connectionId) : ConnectionContext + { + public override string ConnectionId { get; set; } = connectionId; + + public override IDuplexPipe Transport { get; set; } = transport; + + public override IFeatureCollection Features { get; } = new FeatureCollection(); + + public override IDictionary Items { get; set; } = new Dictionary(); + + public override EndPoint? LocalEndPoint { get; set; } = new IPEndPoint(IPAddress.Loopback, 11111); + + public override EndPoint? RemoteEndPoint { get; set; } = new IPEndPoint(IPAddress.Loopback, 22222); + + public ConnectionAbortedException? AbortReason { get; private set; } + + public override void Abort(ConnectionAbortedException abortReason) => AbortReason = abortReason; + } + + private sealed class DuplexPipe(PipeReader input, PipeWriter output) : IDuplexPipe + { + public PipeReader Input { get; } = input; + + public PipeWriter Output { get; } = output; + } + + private sealed class CaptureLogger : ILogger + { + public List Entries { get; } = []; + + public string AllText => string.Join( + Environment.NewLine, + Entries.SelectMany(entry => entry.Properties.Values.Prepend(entry.Message))); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + var properties = new Dictionary(StringComparer.Ordinal); + if (state is IEnumerable> values) + { + foreach (var pair in values) + { + properties[pair.Key] = pair.Value?.ToString() ?? string.Empty; + } + } + + Entries.Add(new LogEntry(eventId.Id, logLevel, formatter(state, exception), properties)); + } + } + + private sealed class TelemetryCapture : IDisposable + { + private readonly MeterListener _listener = new(); + + public TelemetryCapture() + { + _listener.InstrumentPublished = static (instrument, listener) => + { + if (instrument.Meter.Name == "Microsoft.Orleans.Connections.Security" + && instrument.Name is "orleans.connections.authentication.attempts" + or "orleans.connections.authentication.protocol_fallbacks") + { + listener.EnableMeasurementEvents(instrument); + } + }; + _listener.SetMeasurementEventCallback((instrument, value, tags, _) => + { + var capturedTags = new Dictionary(StringComparer.Ordinal); + foreach (var tag in tags) + { + capturedTags[tag.Key] = tag.Value?.ToString() ?? string.Empty; + } + + Measurements.Add(new MetricMeasurement(instrument.Name, value, capturedTags)); + }); + _listener.Start(); + } + + public List Measurements { get; } = []; + + public IEnumerable Attempts => + Measurements.Where(measurement => + measurement.InstrumentName == "orleans.connections.authentication.attempts"); + + public IEnumerable ProtocolFallbacks => + Measurements.Where(measurement => + measurement.InstrumentName == "orleans.connections.authentication.protocol_fallbacks"); + + public string AllText => string.Join( + Environment.NewLine, + Measurements.SelectMany(measurement => + measurement.Tags.Select(tag => $"{tag.Key}={tag.Value}"))); + + public void Dispose() => _listener.Dispose(); + } + + private sealed record LogEntry( + int EventId, + LogLevel Level, + string Message, + IReadOnlyDictionary Properties); + + private sealed record MetricMeasurement( + string InstrumentName, + long Value, + IReadOnlyDictionary Tags); + + private sealed record ScenarioResult( + int DownstreamCalls, + ConnectionAbortedException? AbortReason, + ISiloConnectionAuthenticationFeature? Feature, + IReadOnlyList Logs, + IReadOnlyList Metrics, + bool PeerReceivedFrame, + byte? PeerFrameType, + byte[] PeerPayload) + { + public IEnumerable FailureLogs => Logs.Where(entry => entry.EventId == 9200); + + public IEnumerable AttemptMetrics => + Metrics.Where(measurement => + measurement.InstrumentName == "orleans.connections.authentication.attempts"); + + public string AllLogText => string.Join( + Environment.NewLine, + Logs.SelectMany(entry => entry.Properties.Values.Prepend(entry.Message))); + } +} From b3dee1a2dad6bf0c58e6a5e7a04edeb9005b6315 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Sun, 23 Aug 2026 16:15:26 -0700 Subject: [PATCH 17/22] fix(security): close authentication config gaps --- .../SampleOptions.cs | 6 +-- .../EntraTokenProvider.cs | 4 +- ...onnectionAuthenticationOptionsValidator.cs | 9 ++-- .../EntraTokenProviderTests.cs | 21 ++++++++ ...oConnectionAuthenticationContractsTests.cs | 53 +++++++++++++++++-- 5 files changed, 80 insertions(+), 13 deletions(-) diff --git a/samples/AuthenticatedSiloConnections/SampleOptions.cs b/samples/AuthenticatedSiloConnections/SampleOptions.cs index 55b967dd28d..90297f61ba8 100644 --- a/samples/AuthenticatedSiloConnections/SampleOptions.cs +++ b/samples/AuthenticatedSiloConnections/SampleOptions.cs @@ -158,12 +158,10 @@ public void Validate(string clusterId) RequireGuid(clientId, "AllowedCallerClientIds"); } - if (!AllowedSiloCallerClientIds - .Concat(AllowedClientCallerClientIds) - .Contains(WorkloadClientId, StringComparer.OrdinalIgnoreCase)) + if (!AllowedSiloCallerClientIds.Contains(WorkloadClientId, StringComparer.OrdinalIgnoreCase)) { throw new InvalidOperationException( - "This process's workload client ID must be in an allowed caller list."); + "This silo process's workload client ID must be in the allowed silo caller list."); } } diff --git a/src/Orleans.Connections.Security.Entra/EntraTokenProvider.cs b/src/Orleans.Connections.Security.Entra/EntraTokenProvider.cs index 55ab38aa870..860bc1f8cd4 100644 --- a/src/Orleans.Connections.Security.Entra/EntraTokenProvider.cs +++ b/src/Orleans.Connections.Security.Entra/EntraTokenProvider.cs @@ -28,10 +28,10 @@ public EntraTokenProvider(TokenCredential credential, EntraSiloConnectionOptions public async ValueTask GetTokenAsync(CancellationToken cancellationToken) { - var configuredScope = _options.TokenScope!; + var configuredScope = _options.TokenScope!.TrimEnd('/'); var requestScope = configuredScope.EndsWith("/.default", StringComparison.Ordinal) ? configuredScope - : $"{configuredScope.TrimEnd('/')}/.default"; + : $"{configuredScope}/.default"; var token = await _credential.GetTokenAsync( new TokenRequestContext([requestScope]), cancellationToken).ConfigureAwait(false); diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptionsValidator.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptionsValidator.cs index 95352da87d9..181fdd06d5f 100644 --- a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptionsValidator.cs +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationOptionsValidator.cs @@ -43,18 +43,21 @@ public ValidateOptionsResult Validate(string? name, SiloConnectionAuthentication failures.Add($"{nameof(options.TimeProvider)} is required."); } - if (options.Mode == SiloConnectionAuthenticationMode.Required) + if (options.Mode != SiloConnectionAuthenticationMode.Disabled) { if (_registration.RequiresTokenProvider && !_registration.HasTokenProvider) { - failures.Add("Required mode needs exactly one token provider."); + failures.Add($"{options.Mode} mode needs exactly one token provider."); } if (_registration.RequiresTokenValidator && !_registration.HasTokenValidator) { - failures.Add("Required mode needs exactly one token validator."); + failures.Add($"{options.Mode} mode needs exactly one token validator."); } + } + if (options.Mode == SiloConnectionAuthenticationMode.Required) + { if (_registration.TlsOptions.RemoteCertificateValidation is not null) { failures.Add("Required mode does not permit custom remote-certificate validation callbacks."); diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraTokenProviderTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraTokenProviderTests.cs index f9892d6d1b7..72d810abc91 100644 --- a/test/Orleans.Connections.Security.Entra.Tests/EntraTokenProviderTests.cs +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraTokenProviderTests.cs @@ -86,6 +86,27 @@ public async Task RequestsConfiguredClusterScope_WhenAlreadySuffixed_DoesNotDupl Assert.Equal("already-suffixed-token", token.Token); } + [Fact] + public async Task RequestsConfiguredClusterScope_WhenDefaultSuffixHasTrailingSlash_NormalizesBeforeCheckingSuffix() + { + var options = EntraTestFixture.CreateOptions(); + options.TokenScope = "api://11111111-1111-1111-1111-111111111111/cluster-a/.default/"; + var timeProvider = new TestTimeProvider(new DateTimeOffset(2026, 8, 23, 12, 0, 0, TimeSpan.Zero)); + var credential = new TestTokenCredential( + (_, _) => ValueTask.FromResult(new AccessToken("normalized-suffix-token", timeProvider.GetUtcNow().AddMinutes(10)))); + var provider = new EntraTokenProvider(credential, options, timeProvider); + + var token = await provider.GetTokenAsync(CancellationToken.None); + + var requestContext = credential.LastRequestContext; + Assert.True(requestContext.HasValue); + Assert.Equal( + ["api://11111111-1111-1111-1111-111111111111/cluster-a/.default"], + requestContext.Value.Scopes); + Assert.Equal(1, credential.CallCount); + Assert.Equal("normalized-suffix-token", token.Token); + } + [Fact] public async Task RequestsConfiguredClusterScope_WhenTrailingSlash_NormalizesBeforeDefaultSuffix() { diff --git a/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs b/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs index c9b91964888..a3c330ee911 100644 --- a/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs +++ b/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs @@ -282,10 +282,17 @@ public void RequiredMode_RejectsDirectTlsAuthenticationCallbacks( Assert.Contains("does not permit", exception.Message, StringComparison.Ordinal); } - [Fact] - public void RequiredMode_RequiresOnlyServicesUsedByConnectionDirection() + [Theory] + [InlineData(SiloConnectionAuthenticationMode.Audit)] + [InlineData(SiloConnectionAuthenticationMode.Required)] + public void EnabledModes_RequireOnlyServicesUsedByConnectionDirection( + SiloConnectionAuthenticationMode mode) { - var clientOptions = new SiloConnectionAuthenticationOptions { TargetHost = "gateway.test" }; + var clientOptions = new SiloConnectionAuthenticationOptions + { + Mode = mode, + TargetHost = "gateway.test", + }; var clientRegistration = new ClientConnectionAuthenticationRegistration( "client", new object(), @@ -293,7 +300,7 @@ public void RequiredMode_RequiresOnlyServicesUsedByConnectionDirection() new TlsOptions(), hasTokenProvider: true, hasTokenValidator: false); - var gatewayOptions = new SiloConnectionAuthenticationOptions(); + var gatewayOptions = new SiloConnectionAuthenticationOptions { Mode = mode }; var gatewayRegistration = new GatewayConnectionAuthenticationRegistration( "gateway", new object(), @@ -308,6 +315,44 @@ public void RequiredMode_RequiresOnlyServicesUsedByConnectionDirection() .Validate("gateway", gatewayOptions).Succeeded); } + [Theory] + [InlineData(SiloConnectionAuthenticationMode.Audit)] + [InlineData(SiloConnectionAuthenticationMode.Required)] + public void EnabledModes_RejectMissingDirectionalServices( + SiloConnectionAuthenticationMode mode) + { + var clientOptions = new SiloConnectionAuthenticationOptions + { + Mode = mode, + TargetHost = "gateway.test", + }; + var clientRegistration = new ClientConnectionAuthenticationRegistration( + "client", + new object(), + clientOptions, + new TlsOptions(), + hasTokenProvider: false, + hasTokenValidator: false); + var gatewayOptions = new SiloConnectionAuthenticationOptions { Mode = mode }; + var gatewayRegistration = new GatewayConnectionAuthenticationRegistration( + "gateway", + new object(), + gatewayOptions, + new TlsOptions(), + hasTokenProvider: false, + hasTokenValidator: false); + + var clientResult = new SiloConnectionAuthenticationOptionsValidator(clientRegistration) + .Validate("client", clientOptions); + var gatewayResult = new SiloConnectionAuthenticationOptionsValidator(gatewayRegistration) + .Validate("gateway", gatewayOptions); + + Assert.False(clientResult.Succeeded); + Assert.Contains($"{mode} mode needs exactly one token provider.", clientResult.FailureMessage); + Assert.False(gatewayResult.Succeeded); + Assert.Contains($"{mode} mode needs exactly one token validator.", gatewayResult.FailureMessage); + } + private sealed class TestTokenProvider(string value) : ISiloConnectionTokenProvider { public ValueTask GetTokenAsync( From e5ce6ce5345aa0803171d61bed8c6cf89eaae11d Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Sun, 23 Aug 2026 16:28:14 -0700 Subject: [PATCH 18/22] perf(security): tighten authentication runtime paths --- .../Authentication/AuthenticationWorkLimiter.cs | 2 +- .../SiloConnectionAuthenticationMiddleware.cs | 4 ++-- .../SiloConnectionAuthenticationTelemetry.cs | 10 +++++++++- .../SiloConnectionAuthenticationContractsTests.cs | 15 +++++++++++++++ 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/Orleans.Connections.Security/Authentication/AuthenticationWorkLimiter.cs b/src/Orleans.Connections.Security/Authentication/AuthenticationWorkLimiter.cs index 1d3811c9002..5dd725b0686 100644 --- a/src/Orleans.Connections.Security/Authentication/AuthenticationWorkLimiter.cs +++ b/src/Orleans.Connections.Security/Authentication/AuthenticationWorkLimiter.cs @@ -46,7 +46,7 @@ public QueueLimiter(int concurrency, int maxPending) try { - await _semaphore.WaitAsync(cancellationToken); + await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); return new Releaser(_semaphore); } finally diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationMiddleware.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationMiddleware.cs index 2010cd7608d..51b56ca4caa 100644 --- a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationMiddleware.cs +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationMiddleware.cs @@ -135,7 +135,7 @@ protected async Task RunAcceptedAsync( Logger, GetTargetName(Target), GetDirectionName(direction), - Options.Mode.ToString(), + SiloConnectionAuthenticationTelemetry.GetModeName(Options.Mode), SiloConnectionAuthenticationTelemetry.GetResultName(result)); if (!feature.IsAuthenticated) @@ -205,7 +205,7 @@ protected void Abort( Logger, GetTargetName(Target), GetDirectionName(direction), - Options.Mode.ToString(), + SiloConnectionAuthenticationTelemetry.GetModeName(Options.Mode), SiloConnectionAuthenticationTelemetry.GetResultName(category)); context.Abort(new ConnectionAbortedException( $"Orleans connection authentication failed ({SiloConnectionAuthenticationTelemetry.GetResultName(category)}).")); diff --git a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationTelemetry.cs b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationTelemetry.cs index cc32ee0312e..1ddb1a3343b 100644 --- a/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationTelemetry.cs +++ b/src/Orleans.Connections.Security/Authentication/SiloConnectionAuthenticationTelemetry.cs @@ -83,12 +83,20 @@ private static TagList CreateTags( { { "connection.type", target == SiloConnectionAuthenticationTarget.Silo ? "silo" : "client" }, { "direction", direction == SiloConnectionAuthenticationDirection.Inbound ? "inbound" : "outbound" }, - { "mode", mode.ToString() }, + { "mode", GetModeName(mode) }, { "protocol.version", protocol }, { "result", GetResultName(result) }, }; } + public static string GetModeName(SiloConnectionAuthenticationMode mode) => mode switch + { + SiloConnectionAuthenticationMode.Disabled => "Disabled", + SiloConnectionAuthenticationMode.Audit => "Audit", + SiloConnectionAuthenticationMode.Required => "Required", + _ => "Unknown", + }; + public static string GetResultName(AuthenticationResultCategory result) => result switch { AuthenticationResultCategory.Authenticated => "authenticated", diff --git a/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs b/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs index a3c330ee911..a70ac883a06 100644 --- a/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs +++ b/test/Orleans.Connections.Security.Tests/SiloConnectionAuthenticationContractsTests.cs @@ -185,6 +185,21 @@ public void Version2_IsExpectedAlpnIdentifier() StringComparer.Ordinal); } + [Theory] + [InlineData(SiloConnectionAuthenticationMode.Disabled, "Disabled")] + [InlineData(SiloConnectionAuthenticationMode.Audit, "Audit")] + [InlineData(SiloConnectionAuthenticationMode.Required, "Required")] + [InlineData((SiloConnectionAuthenticationMode)int.MaxValue, "Unknown")] + public void TelemetryModeName_ReturnsBoundedConstants( + SiloConnectionAuthenticationMode mode, + string expected) + { + var actual = SiloConnectionAuthenticationTelemetry.GetModeName(mode); + + Assert.Equal(expected, actual); + Assert.Same(actual, SiloConnectionAuthenticationTelemetry.GetModeName(mode)); + } + [TestCategory("BVT")] [TestSuite("BVT")] [TestProvider("None")] From a978193b1caaa49bb8a65368541fc1c65c16689d Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Mon, 24 Aug 2026 08:23:36 -0700 Subject: [PATCH 19/22] fix(security): remove unused Entra imports Remove redundant namespace imports from the authenticated connection sample and its compiled documentation snippet. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e71c4ddd-5362-4204-910f-9a742ddd63de --- .../csharp/ConnectionAuthenticationExamples.cs | 1 - samples/AuthenticatedSiloConnections/SiloAuthentication.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs b/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs index bc8fe4aae6f..80ac68360d5 100644 --- a/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs +++ b/docs/site/src/content/docs/host/snippets/authenticated-silo-connections/csharp/ConnectionAuthenticationExamples.cs @@ -7,7 +7,6 @@ using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using Orleans.Connections.Security; -using Orleans.Connections.Security.Entra; using Orleans.Hosting; namespace Orleans.Docs.ConnectionSecurity; diff --git a/samples/AuthenticatedSiloConnections/SiloAuthentication.cs b/samples/AuthenticatedSiloConnections/SiloAuthentication.cs index 7cd4383b657..e8403eff626 100644 --- a/samples/AuthenticatedSiloConnections/SiloAuthentication.cs +++ b/samples/AuthenticatedSiloConnections/SiloAuthentication.cs @@ -1,7 +1,6 @@ using System.Security.Cryptography.X509Certificates; using Azure.Core; using Orleans.Connections.Security; -using Orleans.Connections.Security.Entra; using Orleans.Hosting; namespace AuthenticatedSiloConnections; From 0585e720c1404dd5faa8137bc278f00f1f58228d Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Tue, 25 Aug 2026 02:48:43 -0700 Subject: [PATCH 20/22] fix(security): align Entra caller allowlists --- .../EntraJwtValidator.cs | 13 +++---- .../EntraSiloConnectionOptions.cs | 8 ++++ .../README.md | 3 +- .../EntraJwtValidatorTests.cs | 38 +++++++++++++++++++ 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/Orleans.Connections.Security.Entra/EntraJwtValidator.cs b/src/Orleans.Connections.Security.Entra/EntraJwtValidator.cs index 2f35bbf7ea5..38b949b0115 100644 --- a/src/Orleans.Connections.Security.Entra/EntraJwtValidator.cs +++ b/src/Orleans.Connections.Security.Entra/EntraJwtValidator.cs @@ -183,13 +183,12 @@ private void ValidateUntrustedClaims(JwtDocument document, string clusterId) if (!_options.AllowAnyApplicationInTenant) { - if (_options.AllowedClientIds.Count > 0 && !_options.AllowedClientIds.Contains(callerId)) - { - throw new EntraAuthenticationException(EntraAuthenticationError.UnauthorizedCaller); - } - - if (_options.AllowedServicePrincipalObjectIds.Count > 0 - && (document.ObjectId is null || !_options.AllowedServicePrincipalObjectIds.Contains(document.ObjectId))) + var hasCallerAllowlist = _options.AllowedClientIds.Count > 0 + || _options.AllowedServicePrincipalObjectIds.Count > 0; + var callerIdAllowed = _options.AllowedClientIds.Contains(callerId); + var objectIdAllowed = document.ObjectId is not null + && _options.AllowedServicePrincipalObjectIds.Contains(document.ObjectId); + if (hasCallerAllowlist && !callerIdAllowed && !objectIdAllowed) { throw new EntraAuthenticationException(EntraAuthenticationError.UnauthorizedCaller); } diff --git a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptions.cs b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptions.cs index b58599c5815..a8c2f913f52 100644 --- a/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptions.cs +++ b/src/Orleans.Connections.Security.Entra/EntraSiloConnectionOptions.cs @@ -50,11 +50,19 @@ public sealed class EntraSiloConnectionOptions /// /// Gets the client application identifiers which are authorized to connect. /// + /// + /// When this allowlist and are both configured, + /// a caller is authorized when either identity matches. + /// public ISet AllowedClientIds { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); /// /// Gets the service-principal object identifiers which are authorized to connect. /// + /// + /// When this allowlist and are both configured, + /// a caller is authorized when either identity matches. + /// public ISet AllowedServicePrincipalObjectIds { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); /// diff --git a/src/Orleans.Connections.Security.Entra/README.md b/src/Orleans.Connections.Security.Entra/README.md index e4ef999a186..9b78e82fdf5 100644 --- a/src/Orleans.Connections.Security.Entra/README.md +++ b/src/Orleans.Connections.Security.Entra/README.md @@ -89,7 +89,8 @@ the placeholders with identifiers, not secrets: Assign the app role only to authorized workload service principals and also configure the corresponding caller application-ID or service-principal -allowlist. Role assignment and allowlisting are independent checks. +allowlist. When both allowlists are configured, matching either identity +authorizes the caller. Role assignment and allowlisting are independent checks. As an alternative to `ClusterRole`, configure `ClusterClaimType` and have the trusted issuer emit that signed custom claim with a value exactly equal to the diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs index 7fb8ad57eb7..d48f99ea01f 100644 --- a/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs @@ -191,6 +191,7 @@ public async Task RejectsWrongCallerOrApplicationRole(string clientId, string ro public async Task AuthorizesConfiguredServicePrincipalObjectId() { using var fixture = new EntraTestFixture(); + fixture.Options.AllowedClientIds.Clear(); fixture.Options.AllowedServicePrincipalObjectIds.Add(EntraTestFixture.ObjectId); var token = fixture.CreateToken(); @@ -206,12 +207,49 @@ public async Task AuthorizesConfiguredServicePrincipalObjectId() public async Task RejectsWrongServicePrincipalObjectId() { using var fixture = new EntraTestFixture(); + fixture.Options.AllowedClientIds.Clear(); fixture.Options.AllowedServicePrincipalObjectIds.Add("33333333-3333-3333-3333-333333333333"); var token = fixture.CreateToken(); await AssertErrorAsync(fixture, token, EntraAuthenticationError.UnauthorizedCaller); } + [Theory] + [InlineData(true, false, true)] + [InlineData(false, true, true)] + [InlineData(true, true, true)] + [InlineData(false, false, false)] + public async Task CombinedCallerAllowlists_AuthorizeWhenEitherIdentityMatches( + bool clientIdMatches, + bool objectIdMatches, + bool succeeds) + { + const string otherClientId = "33333333-3333-3333-3333-333333333333"; + const string otherObjectId = "44444444-4444-4444-4444-444444444444"; + using var fixture = new EntraTestFixture(); + fixture.Options.AllowedClientIds.Clear(); + fixture.Options.AllowedClientIds.Add(clientIdMatches ? EntraTestFixture.ClientId : otherClientId); + fixture.Options.AllowedServicePrincipalObjectIds.Add( + objectIdMatches ? EntraTestFixture.ObjectId : otherObjectId); + var token = fixture.CreateToken(); + + if (succeeds) + { + var result = await fixture.CreateValidator().ValidateAsync( + token, + EntraTestFixture.ClusterId, + CancellationToken.None); + + Assert.True(result.Principal.Identity?.IsAuthenticated); + Assert.Equal(EntraTestFixture.ClientId, result.Principal.FindFirst("azp")?.Value); + Assert.Equal(EntraTestFixture.ObjectId, result.Principal.FindFirst("oid")?.Value); + } + else + { + await AssertErrorAsync(fixture, token, EntraAuthenticationError.UnauthorizedCaller); + } + } + [Theory] [InlineData("1.0", "azp")] [InlineData("2.0", "appid")] From a8928118608bf1ba73f6192f24127781a6f99928 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Wed, 26 Aug 2026 03:58:47 -0700 Subject: [PATCH 21/22] fix(test): import Orleans hosting extensions --- test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs b/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs index 708d698bd26..c7acc16d020 100644 --- a/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs +++ b/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Orleans.Configuration; +using Orleans.Hosting; using Orleans.Runtime.Messaging; using Orleans.TestingHost; using TestExtensions; From 3d1ebc35c9fabd4863ac5b43820549315790dfcf Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Tue, 1 Sep 2026 23:38:20 -0700 Subject: [PATCH 22/22] fix(security): harden connection authentication validation --- .../EntraJwtValidator.cs | 12 +- .../EntraSigningKey.cs | 114 ++++++++++++++- .../HostingExtensions.ClientAuthentication.cs | 6 +- .../HostingExtensions.IClientBuilder.cs | 6 +- .../Security/TlsClientConnectionMiddleware.cs | 34 ++++- .../EntraJwtValidatorTests.cs | 96 +++++++++++++ .../EntraMetadataTests.cs | 26 ++++ .../EntraTestInfrastructure.cs | 132 ++++++++++++++++-- .../ClientConnectionAuthenticationTests.cs | 18 ++- .../TlsConnectionTests.cs | 81 +++++++++-- 10 files changed, 477 insertions(+), 48 deletions(-) diff --git a/src/Orleans.Connections.Security.Entra/EntraJwtValidator.cs b/src/Orleans.Connections.Security.Entra/EntraJwtValidator.cs index 38b949b0115..6d953a2652e 100644 --- a/src/Orleans.Connections.Security.Entra/EntraJwtValidator.cs +++ b/src/Orleans.Connections.Security.Entra/EntraJwtValidator.cs @@ -71,14 +71,14 @@ public async ValueTask ValidateAsync( ValidateUntrustedClaims(document, clusterId); var snapshot = await _configurationProvider.GetConfigurationAsync(cancellationToken).ConfigureAwait(false); - var result = await ValidateSignatureAndStandardClaimsAsync(token, snapshot).ConfigureAwait(false); + var result = await ValidateSignatureAndStandardClaimsAsync(token, document, snapshot).ConfigureAwait(false); if (!result.IsValid && result.Exception is SecurityTokenSignatureKeyNotFoundException) { snapshot = await _configurationProvider.RefreshForUnknownSigningKeyAsync( snapshot.Generation, cancellationToken).ConfigureAwait(false); - result = await ValidateSignatureAndStandardClaimsAsync(token, snapshot).ConfigureAwait(false); + result = await ValidateSignatureAndStandardClaimsAsync(token, document, snapshot).ConfigureAwait(false); } if (!result.IsValid) @@ -97,6 +97,7 @@ public async ValueTask ValidateAsync( private Task ValidateSignatureAndStandardClaimsAsync( string token, + JwtDocument document, EntraOpenIdConfigurationSnapshot snapshot) { var validAudiences = new HashSet(_options.ValidAudiences, StringComparer.Ordinal); @@ -109,7 +110,12 @@ private Task ValidateSignatureAndStandardClaimsAsync( { ClockSkew = _options.ClockSkew, IssuerSigningKeys = snapshot.Configuration.SigningKeys.Where( - key => EntraSigningKey.IsUsable(key, snapshot.Configuration, _options)), + key => EntraSigningKey.IsUsable( + key, + snapshot.Configuration, + _options, + document.Issuer, + document.TenantId)), LifetimeValidator = ValidateLifetime, RequireExpirationTime = true, RequireSignedTokens = true, diff --git a/src/Orleans.Connections.Security.Entra/EntraSigningKey.cs b/src/Orleans.Connections.Security.Entra/EntraSigningKey.cs index e2b68e29461..80aeaa49c14 100644 --- a/src/Orleans.Connections.Security.Entra/EntraSigningKey.cs +++ b/src/Orleans.Connections.Security.Entra/EntraSigningKey.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Security.Cryptography; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using Orleans.Configuration; @@ -8,19 +9,62 @@ namespace Orleans.Connections.Security.Entra; internal static class EntraSigningKey { + private const string CloudInstanceName = "cloud_instance_name"; + private const string Issuer = "issuer"; + private const string TenantIdTemplate = "{tenantid}"; + public static bool IsUsable( SecurityKey key, OpenIdConnectConfiguration configuration, EntraSiloConnectionOptions options) { - if (key is not AsymmetricSecurityKey || string.IsNullOrEmpty(key.KeyId)) + return HasUsableKeyMaterial(key) + && configuration.JsonWebKeySet?.Keys.Any( + jsonWebKey => string.Equals(jsonWebKey.Kid, key.KeyId, StringComparison.Ordinal) + && HasMatchingKeyMaterial(key, jsonWebKey) + && IsUsable(jsonWebKey, options)) == true; + } + + public static bool IsUsable( + SecurityKey key, + OpenIdConnectConfiguration configuration, + EntraSiloConnectionOptions options, + string tokenIssuer, + string tenantId) + { + return HasUsableKeyMaterial(key) + && configuration.JsonWebKeySet?.Keys.Any( + jsonWebKey => string.Equals(jsonWebKey.Kid, key.KeyId, StringComparison.Ordinal) + && HasMatchingKeyMaterial(key, jsonWebKey) + && IsUsable(jsonWebKey, options) + && HasCompatibleIssuer(jsonWebKey, configuration, tokenIssuer, tenantId) + && HasCompatibleCloudInstance(jsonWebKey, configuration)) == true; + } + + private static bool HasUsableKeyMaterial(SecurityKey key) => + key is AsymmetricSecurityKey && !string.IsNullOrEmpty(key.KeyId); + + private static bool HasMatchingKeyMaterial(SecurityKey key, JsonWebKey jsonWebKey) + { + if (key is X509SecurityKey x509SecurityKey && jsonWebKey.X5c is { Count: > 0 }) { - return false; + try + { + return CryptographicOperations.FixedTimeEquals( + x509SecurityKey.Certificate.RawData, + Convert.FromBase64String(jsonWebKey.X5c[0])); + } + catch (FormatException) + { + return false; + } } - return configuration.JsonWebKeySet?.Keys.Any( - jsonWebKey => string.Equals(jsonWebKey.Kid, key.KeyId, StringComparison.Ordinal) - && IsUsable(jsonWebKey, options)) == true; + return key.CanComputeJwkThumbprint() + && jsonWebKey.CanComputeJwkThumbprint() + && CryptographicOperations.FixedTimeEquals( + key.ComputeJwkThumbprint(), + jsonWebKey.ComputeJwkThumbprint()); } public static bool IsUsable(JsonWebKey jsonWebKey, EntraSiloConnectionOptions options) @@ -40,4 +84,64 @@ public static bool IsUsable(JsonWebKey jsonWebKey, EntraSiloConnectionOptions op return string.IsNullOrEmpty(jsonWebKey.Alg) || options.AllowedAlgorithms.Contains(jsonWebKey.Alg); } + + private static bool HasCompatibleIssuer( + JsonWebKey jsonWebKey, + OpenIdConnectConfiguration configuration, + string tokenIssuer, + string tenantId) + { + if (!TryGetMetadataValue(jsonWebKey.AdditionalData, Issuer, out var signingKeyIssuer)) + { + return true; + } + + if (!tokenIssuer.Contains(tenantId, StringComparison.Ordinal)) + { + return false; + } + + var effectiveSigningKeyIssuer = signingKeyIssuer.Replace( + TenantIdTemplate, + tenantId, + StringComparison.Ordinal); + var effectiveConfigurationIssuer = configuration.Issuer?.Replace( + TenantIdTemplate, + tenantId, + StringComparison.Ordinal); + return string.Equals(effectiveSigningKeyIssuer, tokenIssuer, StringComparison.Ordinal) + || string.Equals(effectiveSigningKeyIssuer, effectiveConfigurationIssuer, StringComparison.Ordinal); + } + + private static bool HasCompatibleCloudInstance( + JsonWebKey jsonWebKey, + OpenIdConnectConfiguration configuration) + { + return !TryGetMetadataValue(jsonWebKey.AdditionalData, CloudInstanceName, out var signingKeyCloudInstance) + || !TryGetMetadataValue( + configuration.AdditionalData, + CloudInstanceName, + out var configurationCloudInstance) + || string.Equals( + signingKeyCloudInstance, + configurationCloudInstance, + StringComparison.Ordinal); + } + + private static bool TryGetMetadataValue( + IDictionary metadata, + string name, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? value) + { + if (metadata.TryGetValue(name, out var rawValue) + && rawValue is string candidate + && !string.IsNullOrWhiteSpace(candidate)) + { + value = candidate; + return true; + } + + value = null; + return false; + } } diff --git a/src/Orleans.Connections.Security/Hosting/HostingExtensions.ClientAuthentication.cs b/src/Orleans.Connections.Security/Hosting/HostingExtensions.ClientAuthentication.cs index 5785a430249..790a4361376 100644 --- a/src/Orleans.Connections.Security/Hosting/HostingExtensions.ClientAuthentication.cs +++ b/src/Orleans.Connections.Security/Hosting/HostingExtensions.ClientAuthentication.cs @@ -178,9 +178,11 @@ private static void ValidateServerTlsOptions(TlsOptions options, string connecti private static void ValidateClientTlsOptions(TlsOptions options) { - if (options.LocalCertificate is null && options.ClientCertificateMode == RemoteCertificateMode.RequireCertificate) + if (options.LocalCertificate is null + && options.LocalClientCertificateSelector is null + && options.ClientCertificateMode == RemoteCertificateMode.RequireCertificate) { - throw new InvalidOperationException("No client TLS certificate was specified."); + throw new InvalidOperationException("No client TLS certificate or certificate selector was specified."); } if (options.LocalCertificate is { } certificate && !certificate.HasPrivateKey) diff --git a/src/Orleans.Connections.Security/Hosting/HostingExtensions.IClientBuilder.cs b/src/Orleans.Connections.Security/Hosting/HostingExtensions.IClientBuilder.cs index d4cfd22f2bc..5e8a361ee0c 100644 --- a/src/Orleans.Connections.Security/Hosting/HostingExtensions.IClientBuilder.cs +++ b/src/Orleans.Connections.Security/Hosting/HostingExtensions.IClientBuilder.cs @@ -114,9 +114,11 @@ public static IClientBuilder UseTls( var options = new TlsOptions(); configureOptions(options); - if (options.LocalCertificate is null && options.ClientCertificateMode == RemoteCertificateMode.RequireCertificate) + if (options.LocalCertificate is null + && options.LocalClientCertificateSelector is null + && options.ClientCertificateMode == RemoteCertificateMode.RequireCertificate) { - throw new InvalidOperationException("No certificate specified"); + throw new InvalidOperationException("No certificate or certificate selector specified"); } if (options.LocalCertificate is X509Certificate2 certificate && !certificate.HasPrivateKey) diff --git a/src/Orleans.Connections.Security/Security/TlsClientConnectionMiddleware.cs b/src/Orleans.Connections.Security/Security/TlsClientConnectionMiddleware.cs index 069c2389a69..1c61f9b2449 100644 --- a/src/Orleans.Connections.Security/Security/TlsClientConnectionMiddleware.cs +++ b/src/Orleans.Connections.Security/Security/TlsClientConnectionMiddleware.cs @@ -26,10 +26,17 @@ public TlsClientConnectionMiddleware(TlsOptions options, ILoggerFactory? loggerF throw new ArgumentNullException(nameof(options)); } - // capture the certificate now so it can't be switched after validation - _certificate = ValidateCertificate(options.LocalCertificate, options.ClientCertificateMode); + // Capture a fixed certificate now; selector results are validated on every invocation to support rotation. _certificateSelector = options.LocalClientCertificateSelector; - + if (options.LocalCertificate is { } certificate) + { + _certificate = ValidateCertificate(certificate, options.ClientCertificateMode); + } + else if (options.ClientCertificateMode == RemoteCertificateMode.RequireCertificate + && _certificateSelector is null) + { + EnsureCertificateIsAllowedForClientAuth(certificate: null); + } _options = options; _logger = loggerFactory?.CreateLogger(); @@ -119,10 +126,7 @@ private async Task InnerOnConnectionAsync(ConnectionContext context, ConnectionD selector = (sender, targetHost, localCertificates, remoteCertificate, acceptableIssuers) => { var cert = _certificateSelector(sender, targetHost, localCertificates, remoteCertificate, acceptableIssuers); - if (cert != null) - { - EnsureCertificateIsAllowedForClientAuth(cert); - } + ValidateSelectedCertificate(cert, _options.ClientCertificateMode); #if NET10_0_OR_GREATER return cert; @@ -208,6 +212,16 @@ private async Task InnerOnConnectionAsync(ConnectionContext context, ConnectionD } } + internal static void ValidateSelectedCertificate( + X509Certificate2? certificate, + RemoteCertificateMode mode) + { + if (certificate is not null || mode == RemoteCertificateMode.RequireCertificate) + { + EnsureCertificateIsAllowedForClientAuth(certificate); + } + } + private static X509Certificate2? ValidateCertificate(X509Certificate2? certificate, RemoteCertificateMode mode) { switch (mode) @@ -238,6 +252,12 @@ protected static void EnsureCertificateIsAllowedForClientAuth([NotNull] X509Cert { throw new InvalidOperationException($"Invalid client certificate for client authentication: {certificate.Thumbprint}"); } + + if (!certificate.HasPrivateKey) + { + throw new InvalidOperationException( + $"Client certificate does not have an accessible private key: {certificate.Thumbprint}"); + } } private static X509Certificate2? ConvertToX509Certificate2(X509Certificate? certificate) diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs index d48f99ea01f..f2fe85c15f8 100644 --- a/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraJwtValidatorTests.cs @@ -335,6 +335,102 @@ public async Task RejectsDisallowedAsymmetricAlgorithm() await AssertErrorAsync(fixture, token, EntraAuthenticationError.InvalidToken); } + [Fact] + public async Task RejectsMismatchedSigningKeyIssuer() + { + using var fixture = new EntraTestFixture(); + fixture.Metadata.SetConfiguration( + EntraTestFixture.Issuer, + fixture.CurrentKey, + keyIssuer: "https://login.microsoftonline.com/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/v2.0"); + var token = fixture.CreateToken(); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.InvalidToken); + } + + [Fact] + public async Task RejectsMismatchedIssuerForDuplicateSigningKeyId() + { + using var fixture = new EntraTestFixture(); + var untrustedKey = fixture.CreateKey(fixture.CurrentKey.Key.KeyId); + fixture.Metadata.SetConfigurationWithDuplicateKeyId( + EntraTestFixture.Issuer, + fixture.CurrentKey, + untrustedKey, + "https://login.microsoftonline.com/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/v2.0"); + var token = fixture.CreateToken(signingCredentials: untrustedKey); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.InvalidToken); + } + + [Fact] + public async Task AcceptsTemplatedSigningKeyIssuer() + { + using var fixture = new EntraTestFixture(); + const string templatedIssuer = "https://login.microsoftonline.com/{tenantid}/v2.0"; + fixture.Metadata.SetConfiguration( + EntraTestFixture.Issuer, + fixture.CurrentKey, + keyIssuer: templatedIssuer); + var token = fixture.CreateToken(); + + var result = await fixture.CreateValidator().ValidateAsync( + token, + EntraTestFixture.ClusterId, + CancellationToken.None); + + Assert.True(result.Principal.Identity?.IsAuthenticated); + } + + [Fact] + public async Task RejectsMismatchedSigningKeyCloudInstance() + { + using var fixture = new EntraTestFixture(); + fixture.Metadata.SetConfiguration( + EntraTestFixture.Issuer, + fixture.CurrentKey, + keyCloudInstanceName: "microsoftonline.us", + configurationCloudInstanceName: "microsoftonline.com"); + var token = fixture.CreateToken(); + + await AssertErrorAsync(fixture, token, EntraAuthenticationError.InvalidToken); + } + + [Fact] + public async Task AcceptsMatchingSigningKeyCloudInstance() + { + using var fixture = new EntraTestFixture(); + fixture.Metadata.SetConfiguration( + EntraTestFixture.Issuer, + fixture.CurrentKey, + keyCloudInstanceName: "microsoftonline.com", + configurationCloudInstanceName: "microsoftonline.com"); + var token = fixture.CreateToken(); + + var result = await fixture.CreateValidator().ValidateAsync( + token, + EntraTestFixture.ClusterId, + CancellationToken.None); + + Assert.True(result.Principal.Identity?.IsAuthenticated); + } + + [Fact] + public async Task AcceptsX5cOnlySigningKey() + { + using var fixture = new EntraTestFixture(); + var signingCredentials = fixture.CreateCertificateKey("x5c-key"); + fixture.Metadata.SetConfiguration(EntraTestFixture.Issuer, signingCredentials); + var token = fixture.CreateToken(signingCredentials: signingCredentials); + + var result = await fixture.CreateValidator().ValidateAsync( + token, + EntraTestFixture.ClusterId, + CancellationToken.None); + + Assert.True(result.Principal.Identity?.IsAuthenticated); + } + [Fact] public async Task NeverIncludesTokenInFailure() { diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraMetadataTests.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraMetadataTests.cs index c2abafe82bf..6fd5ff12d38 100644 --- a/test/Orleans.Connections.Security.Entra.Tests/EntraMetadataTests.cs +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraMetadataTests.cs @@ -34,6 +34,32 @@ await validator.ValidateAsync( Assert.Equal(4, fixture.Metadata.RequestCount); } + [Fact] + public async Task RejectsOutOfScopeSigningKeyDuringRollover() + { + using var fixture = new EntraTestFixture(); + using var provider = CreateProvider(fixture); + var validator = new EntraJwtValidator(fixture.Options, provider, fixture.TimeProvider); + await validator.ValidateAsync( + fixture.CreateToken(), + EntraTestFixture.ClusterId, + CancellationToken.None); + var nextKey = fixture.CreateKey("key-2"); + fixture.Metadata.SetConfiguration( + EntraTestFixture.Issuer, + nextKey, + keyCloudInstanceName: "microsoftonline.us", + configurationCloudInstanceName: "microsoftonline.com"); + + var exception = await Assert.ThrowsAsync( + () => validator.ValidateAsync( + fixture.CreateToken(signingCredentials: nextKey), + EntraTestFixture.ClusterId, + CancellationToken.None).AsTask()); + + Assert.Equal(EntraAuthenticationError.InvalidToken, exception.Error); + } + [Fact] public async Task ThrottlesUnknownSigningKeyRefresh() { diff --git a/test/Orleans.Connections.Security.Entra.Tests/EntraTestInfrastructure.cs b/test/Orleans.Connections.Security.Entra.Tests/EntraTestInfrastructure.cs index 29ca7a72a6c..c68bc0d3143 100644 --- a/test/Orleans.Connections.Security.Entra.Tests/EntraTestInfrastructure.cs +++ b/test/Orleans.Connections.Security.Entra.Tests/EntraTestInfrastructure.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http; using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text; using Azure.Core; using Microsoft.IdentityModel.JsonWebTokens; @@ -22,6 +23,7 @@ internal sealed class EntraTestFixture : IDisposable public const string Role = "Orleans.Silo.Connect"; public const string TenantId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; private readonly List _keys = []; + private readonly List _certificates = []; public EntraTestFixture() { @@ -66,6 +68,24 @@ public SigningCredentials CreateKey(string keyId, string algorithm = SecurityAlg return new SigningCredentials(new RsaSecurityKey(rsa) { KeyId = keyId }, algorithm); } + public SigningCredentials CreateCertificateKey(string keyId) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Entra signing test", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + var now = System.TimeProvider.System.GetUtcNow(); + var certificate = request.CreateSelfSigned( + now.AddDays(-1), + now.AddDays(1)); + _certificates.Add(certificate); + return new SigningCredentials( + new X509SecurityKey(certificate) { KeyId = keyId }, + SecurityAlgorithms.RsaSha256); + } + public EntraJwtValidator CreateValidator() { var provider = new EntraOpenIdConfigurationProvider(Options, Metadata, TimeProvider, static () => 0); @@ -136,6 +156,11 @@ public void Dispose() { key.Dispose(); } + + foreach (var certificate in _certificates) + { + certificate.Dispose(); + } } public static string CreateDuplicateClaimToken() @@ -185,13 +210,61 @@ public void SetConfiguration( SigningCredentials signingCredentials, string use = "sig", string[]? keyOperations = null, - string? jwksUri = null) + string? jwksUri = null, + string? keyIssuer = null, + string? keyCloudInstanceName = null, + string? configurationCloudInstanceName = null) { var authority = _authority.AbsoluteUri.TrimEnd('/'); var keysAddress = jwksUri ?? $"{authority}/keys"; + var configuration = new Dictionary + { + ["issuer"] = issuer, + ["jwks_uri"] = keysAddress, + }; + if (!string.IsNullOrEmpty(configurationCloudInstanceName)) + { + configuration["cloud_instance_name"] = configurationCloudInstanceName; + } + + _documents[$"{authority}/.well-known/openid-configuration"] = + System.Text.Json.JsonSerializer.Serialize(configuration); + _documents[keysAddress] = System.Text.Json.JsonSerializer.Serialize(new + { + keys = new[] + { + CreateJwk( + signingCredentials, + use, + keyOperations, + keyIssuer, + keyCloudInstanceName), + }, + }); + } + + public void SetConfigurationWithDuplicateKeyId( + string issuer, + SigningCredentials trustedSigningCredentials, + SigningCredentials untrustedSigningCredentials, + string untrustedKeyIssuer) + { + var authority = _authority.AbsoluteUri.TrimEnd('/'); + var keysAddress = $"{authority}/keys"; _documents[$"{authority}/.well-known/openid-configuration"] = - $$"""{"issuer":"{{issuer}}","jwks_uri":"{{keysAddress}}"}"""; - _documents[keysAddress] = CreateJwks(signingCredentials, use, keyOperations); + System.Text.Json.JsonSerializer.Serialize(new + { + issuer, + jwks_uri = keysAddress, + }); + _documents[keysAddress] = System.Text.Json.JsonSerializer.Serialize(new + { + keys = new[] + { + CreateJwk(trustedSigningCredentials, "sig", null, issuer, null), + CreateJwk(untrustedSigningCredentials, "sig", null, untrustedKeyIssuer, null), + }, + }); } private async Task GetCoreAsync(string address, CancellationToken cancellationToken) @@ -206,19 +279,52 @@ private async Task GetCoreAsync(string address, CancellationToken cancel : throw new InvalidOperationException("unknown metadata address"); } - private static string CreateJwks( + private static Dictionary CreateJwk( SigningCredentials signingCredentials, string use, - string[]? keyOperations) + string[]? keyOperations, + string? keyIssuer, + string? keyCloudInstanceName) { - var key = (RsaSecurityKey)signingCredentials.Key; - var parameters = key.Rsa?.ExportParameters(includePrivateParameters: false) ?? key.Parameters; - var operations = keyOperations is null - ? string.Empty - : $$""","key_ops":{{System.Text.Json.JsonSerializer.Serialize(keyOperations)}}"""; - return $$""" - {"keys":[{"kty":"RSA","use":"{{use}}","kid":"{{key.KeyId}}","alg":"{{signingCredentials.Algorithm}}","n":"{{Base64UrlEncoder.Encode(parameters.Modulus)}}","e":"{{Base64UrlEncoder.Encode(parameters.Exponent)}}"{{operations}}}]} - """; + var key = new Dictionary + { + ["kty"] = "RSA", + ["use"] = use, + ["kid"] = signingCredentials.Key.KeyId, + ["alg"] = signingCredentials.Algorithm, + }; + switch (signingCredentials.Key) + { + case RsaSecurityKey rsaSecurityKey: + var parameters = rsaSecurityKey.Rsa?.ExportParameters(includePrivateParameters: false) + ?? rsaSecurityKey.Parameters; + key["n"] = Base64UrlEncoder.Encode(parameters.Modulus); + key["e"] = Base64UrlEncoder.Encode(parameters.Exponent); + break; + case X509SecurityKey x509SecurityKey: + key["x5c"] = new[] { Convert.ToBase64String(x509SecurityKey.Certificate.RawData) }; + break; + default: + throw new InvalidOperationException( + $"Unsupported test signing key type: {signingCredentials.Key.GetType()}"); + } + + if (keyOperations is not null) + { + key["key_ops"] = keyOperations; + } + + if (!string.IsNullOrEmpty(keyIssuer)) + { + key["issuer"] = keyIssuer; + } + + if (!string.IsNullOrEmpty(keyCloudInstanceName)) + { + key["cloud_instance_name"] = keyCloudInstanceName; + } + + return key; } } diff --git a/test/Orleans.Connections.Security.Tests/ClientConnectionAuthenticationTests.cs b/test/Orleans.Connections.Security.Tests/ClientConnectionAuthenticationTests.cs index 96fac134f77..377bb0d1cd5 100644 --- a/test/Orleans.Connections.Security.Tests/ClientConnectionAuthenticationTests.cs +++ b/test/Orleans.Connections.Security.Tests/ClientConnectionAuthenticationTests.cs @@ -23,6 +23,7 @@ public sealed class ClientConnectionAuthenticationTests [Fact] public async Task AuthenticatedClientConnection_CanCallGrain() { + var cancellationToken = TestContext.Current.CancellationToken; var recorderId = Guid.NewGuid().ToString(); var recorder = new ValidationRecorder(); Assert.True(Recorders.TryAdd(recorderId, recorder)); @@ -32,7 +33,7 @@ public async Task AuthenticatedClientConnection_CanCallGrain() { var certificate = TestCertificateHelper.CreateSelfSignedCertificate( TargetHost, - [TestCertificateHelper.ServerAuthenticationOid]); + [TestCertificateHelper.ClientAuthenticationOid, TestCertificateHelper.ServerAuthenticationOid]); var builder = new TestClusterBuilder() .AddSiloBuilderConfigurator() .AddClientBuilderConfigurator(); @@ -41,7 +42,7 @@ public async Task AuthenticatedClientConnection_CanCallGrain() builder.Properties[RecorderConfigKey] = recorderId; cluster = builder.Build(); - await cluster.DeployAsync(); + await cluster.DeployAsync(cancellationToken); var grain = cluster.Client.GetGrain("authenticated-client"); Assert.Equal("authenticated", await grain.Echo("authenticated")); @@ -54,7 +55,7 @@ public async Task AuthenticatedClientConnection_CanCallGrain() Recorders.TryRemove(recorderId, out _); if (cluster is not null) { - await cluster.StopAllSilosAsync(); + await cluster.StopAllSilosAsync(cancellationToken); cluster.Dispose(); } } @@ -73,7 +74,8 @@ public void Configure(IHostBuilder hostBuilder) tls => { tls.LocalCertificate = certificate; - tls.RemoteCertificateMode = RemoteCertificateMode.NoCertificate; + tls.RemoteCertificateMode = RemoteCertificateMode.RequireCertificate; + tls.AllowAnyRemoteCertificate(); }, authentication => { @@ -87,8 +89,14 @@ private sealed class AuthenticatedClientConfigurator : IClientBuilderConfigurato { public void Configure(IConfiguration configuration, IClientBuilder clientBuilder) { + var certificate = TestCertificateHelper.ConvertFromBase64(configuration[CertificateConfigKey]!); clientBuilder.UseAuthenticatedClientConnections( - tls => tls.AllowAnyRemoteCertificate(), + tls => + { + tls.AllowAnyRemoteCertificate(); + tls.ClientCertificateMode = RemoteCertificateMode.RequireCertificate; + tls.LocalClientCertificateSelector = (_, _, _, _, _) => certificate; + }, authentication => { authentication.Mode = SiloConnectionAuthenticationMode.Audit; diff --git a/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs b/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs index c7acc16d020..26845b9a45d 100644 --- a/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs +++ b/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs @@ -58,6 +58,7 @@ public void UseGatewayTls_ThrowsWhenConfiguredMoreThanOnce() private const string CertificateSubjectName = "fakedomain.faketld"; private const string CertificateConfigKey = "certificate"; private const string ClientCertificateModeKey = "CertificateMode"; + private const string ClientCertificateSelectorKey = "ClientCertificateSelector"; private const string ProtocolRecorderKey = "ProtocolRecorder"; private const string AuthenticatedSiloProtocol = "orleans-auth-test"; private const string OrleansProtocol = "Orleans1"; @@ -80,6 +81,50 @@ public void CanCreateCertificates() var decoded = TestCertificateHelper.ConvertFromBase64(encoded); Assert.Equal(original, decoded); } + + [Fact] + public void RequiredClientCertificateSelector_RejectsNullCertificate() + { + Assert.Throws( + () => TlsClientConnectionMiddleware.ValidateSelectedCertificate( + certificate: null, + RemoteCertificateMode.RequireCertificate)); + } + + [Fact] + public void RequiredClientCertificateSelector_RejectsCertificateWithoutClientAuthenticationEku() + { + using var certificate = TestCertificateHelper.CreateSelfSignedCertificate( + CertificateSubjectName, + [TestCertificateHelper.ServerAuthenticationOid]); + + Assert.Throws( + () => TlsClientConnectionMiddleware.ValidateSelectedCertificate( + certificate, + RemoteCertificateMode.RequireCertificate)); + } + + [Fact] + public void RequiredClientCertificateSelector_RejectsCertificateWithoutPrivateKey() + { + using var certificate = TestCertificateHelper.CreateSelfSignedCertificate( + CertificateSubjectName, + [TestCertificateHelper.ClientAuthenticationOid]); +#if NET9_0_OR_GREATER + using var publicCertificate = + System.Security.Cryptography.X509Certificates.X509CertificateLoader.LoadCertificate(certificate.RawData); +#else +#pragma warning disable SYSLIB0057 + using var publicCertificate = + new System.Security.Cryptography.X509Certificates.X509Certificate2(certificate.RawData); +#pragma warning restore SYSLIB0057 +#endif + + Assert.Throws( + () => TlsClientConnectionMiddleware.ValidateSelectedCertificate( + publicCertificate, + RemoteCertificateMode.RequireCertificate)); + } /// /// Configures TLS for Orleans clients in the test cluster. @@ -98,6 +143,7 @@ public void Configure(IConfiguration configuration, IClientBuilder clientBuilder var certificateModeString = configuration[ClientCertificateModeKey]; var certificateMode = (RemoteCertificateMode)Enum.Parse(typeof(RemoteCertificateMode), certificateModeString!); + var useCertificateSelector = bool.Parse(configuration[ClientCertificateSelectorKey]!); clientBuilder.UseTls(options => { @@ -106,7 +152,14 @@ public void Configure(IConfiguration configuration, IClientBuilder clientBuilder // Allow any certificate for testing (in production, validate properly) options.AllowAnyRemoteCertificate(); // Client's certificate for mutual TLS - options.LocalCertificate = localCertificate; + if (useCertificateSelector) + { + options.LocalClientCertificateSelector = (_, _, _, _, _) => localCertificate; + } + else + { + options.LocalCertificate = localCertificate; + } // Require server to present a certificate options.RemoteCertificateMode = RemoteCertificateMode.RequireCertificate; // Configure whether server requires client certificate @@ -174,14 +227,18 @@ public void Configure(IHostBuilder hostBuilder) /// - Data integrity is maintained (echo test) /// [Theory] - [InlineData(null, RemoteCertificateMode.AllowCertificate)] - [InlineData(null, RemoteCertificateMode.NoCertificate)] - [InlineData(new[] { TestCertificateHelper.ServerAuthenticationOid }, RemoteCertificateMode.AllowCertificate)] - [InlineData(new[] { TestCertificateHelper.ServerAuthenticationOid }, RemoteCertificateMode.NoCertificate)] - [InlineData(new[] { TestCertificateHelper.ClientAuthenticationOid, TestCertificateHelper.ServerAuthenticationOid }, RemoteCertificateMode.NoCertificate)] - [InlineData(new[] { TestCertificateHelper.ClientAuthenticationOid, TestCertificateHelper.ServerAuthenticationOid }, RemoteCertificateMode.AllowCertificate)] - [InlineData(new[] { TestCertificateHelper.ClientAuthenticationOid, TestCertificateHelper.ServerAuthenticationOid }, RemoteCertificateMode.RequireCertificate)] - public async Task TlsEndToEnd(string[]? oids, RemoteCertificateMode certificateMode) + [InlineData(null, RemoteCertificateMode.AllowCertificate, false)] + [InlineData(null, RemoteCertificateMode.NoCertificate, false)] + [InlineData(new[] { TestCertificateHelper.ServerAuthenticationOid }, RemoteCertificateMode.AllowCertificate, false)] + [InlineData(new[] { TestCertificateHelper.ServerAuthenticationOid }, RemoteCertificateMode.NoCertificate, false)] + [InlineData(new[] { TestCertificateHelper.ClientAuthenticationOid, TestCertificateHelper.ServerAuthenticationOid }, RemoteCertificateMode.NoCertificate, false)] + [InlineData(new[] { TestCertificateHelper.ClientAuthenticationOid, TestCertificateHelper.ServerAuthenticationOid }, RemoteCertificateMode.AllowCertificate, false)] + [InlineData(new[] { TestCertificateHelper.ClientAuthenticationOid, TestCertificateHelper.ServerAuthenticationOid }, RemoteCertificateMode.RequireCertificate, false)] + [InlineData(new[] { TestCertificateHelper.ClientAuthenticationOid, TestCertificateHelper.ServerAuthenticationOid }, RemoteCertificateMode.RequireCertificate, true)] + public async Task TlsEndToEnd( + string[]? oids, + RemoteCertificateMode certificateMode, + bool useCertificateSelector) { var cancellationToken = TestContext.Current.CancellationToken; TestCluster? testCluster = default; @@ -199,6 +256,7 @@ public async Task TlsEndToEnd(string[]? oids, RemoteCertificateMode certificateM var encodedCertificate = TestCertificateHelper.ConvertToBase64(certificate); builder.Properties[CertificateConfigKey] = encodedCertificate; builder.Properties[ClientCertificateModeKey] = certificateMode.ToString(); + builder.Properties[ClientCertificateSelectorKey] = useCertificateSelector.ToString(); testCluster = builder.Build(); await testCluster.DeployAsync(cancellationToken); @@ -230,6 +288,7 @@ public async Task TlsEndToEnd(string[]? oids, RemoteCertificateMode certificateM [Fact] public async Task SeparateSiloAndGatewayTls_NegotiateConfiguredApplicationProtocols() { + var cancellationToken = TestContext.Current.CancellationToken; var recorderId = Guid.NewGuid().ToString(); var recorder = new ProtocolRecorder(); Assert.True(ProtocolRecorders.TryAdd(recorderId, recorder)); @@ -248,7 +307,7 @@ public async Task SeparateSiloAndGatewayTls_NegotiateConfiguredApplicationProtoc builder.Properties[ProtocolRecorderKey] = recorderId; testCluster = builder.Build(); - await testCluster.DeployAsync(); + await testCluster.DeployAsync(cancellationToken); var grain = testCluster.Client.GetGrain("alpn"); Assert.Equal("ping", await grain.Echo("ping")); @@ -271,7 +330,7 @@ public async Task SeparateSiloAndGatewayTls_NegotiateConfiguredApplicationProtoc ProtocolRecorders.TryRemove(recorderId, out _); if (testCluster is not null) { - await testCluster.StopAllSilosAsync(); + await testCluster.StopAllSilosAsync(cancellationToken); testCluster.Dispose(); } }