From 1b74bb94c08e092a557e62edf2867345e709877f Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 2 Aug 2026 02:47:28 +0200 Subject: [PATCH] fix: validate external login methods before discovery Use the same structural and secret-binding assessment for management, discovery, and initiation so incomplete overrides are never advertised as available sign-in methods. --- .../ExternalAuthenticationContracts.cs | 7 ++ .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Services/ExternalAuthenticationBroker.cs | 16 ++- ...tityProviderConnectionManagementService.cs | 45 +------ ...ntityProviderConnectionValidityAssessor.cs | 99 +++++++++++++++ .../Broker/BrokerContractTests.cs | 36 ++++++ .../Broker/BrokerSecurityTests.cs | 20 ++- .../Connections/ConnectionManagementTests.cs | 1 + .../PreviewEndpointContractTests.cs | 4 + .../ExternalAuthenticationBenchmarks.cs | 10 ++ ...ProviderConnectionValidityAssessorTests.cs | 115 ++++++++++++++++++ 11 files changed, 307 insertions(+), 47 deletions(-) create mode 100644 src/modules/Elsa.ExternalAuthentication/Services/IdentityProviderConnectionValidityAssessor.cs create mode 100644 test/unit/Elsa.ExternalAuthentication.UnitTests/Foundational/IdentityProviderConnectionValidityAssessorTests.cs diff --git a/src/modules/Elsa.ExternalAuthentication/Contracts/ExternalAuthenticationContracts.cs b/src/modules/Elsa.ExternalAuthentication/Contracts/ExternalAuthenticationContracts.cs index ae949df08..e8f3ae7f6 100644 --- a/src/modules/Elsa.ExternalAuthentication/Contracts/ExternalAuthenticationContracts.cs +++ b/src/modules/Elsa.ExternalAuthentication/Contracts/ExternalAuthenticationContracts.cs @@ -72,6 +72,13 @@ public interface IIdentityProviderConnectionRegistry ValueTask FindByIdAsync(string targetTenantId, string connectionId, CancellationToken cancellationToken = default); } +public interface IIdentityProviderConnectionValidityAssessor +{ + ValueTask AssessAsync( + EffectiveIdentityProviderConnection connection, + CancellationToken cancellationToken = default); +} + public interface IIdentityProviderConnectionStore { ValueTask> FindAsync(ConnectionFilter filter, CancellationToken cancellationToken = default); diff --git a/src/modules/Elsa.ExternalAuthentication/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.ExternalAuthentication/Extensions/ServiceCollectionExtensions.cs index 902bcf1cf..2ca5e07bd 100644 --- a/src/modules/Elsa.ExternalAuthentication/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.ExternalAuthentication/Extensions/ServiceCollectionExtensions.cs @@ -59,6 +59,7 @@ public static class ServiceCollectionExtensions services.TryAddSingleton(); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs b/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs index d0ff405b1..9c8d1519e 100644 --- a/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs +++ b/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs @@ -18,6 +18,7 @@ namespace Elsa.ExternalAuthentication.Services; public sealed class ExternalAuthenticationBroker( IIdentityProviderConnectionRegistry connectionRegistry, + IIdentityProviderConnectionValidityAssessor validityAssessor, IEnumerable adapters, IEnumerable secretBindingResolvers, IExternalAuthenticationHandleHasher handleHasher, @@ -67,7 +68,16 @@ public sealed class ExternalAuthenticationBroker( public async ValueTask> DiscoverAsync(string targetTenantId, string clientId, CancellationToken cancellationToken = default) { EnsureClient(clientId); - var externalMethods = (await connectionRegistry.GetAsync(targetTenantId, cancellationToken)).LoginMethods; + var registry = await connectionRegistry.GetAsync(targetTenantId, cancellationToken); + var advertisedIds = registry.LoginMethods.Select(method => method.Id).ToHashSet(StringComparer.Ordinal); + var assessments = await Task.WhenAll(registry.Connections + .Where(connection => advertisedIds.Contains(connection.Connection.Id)) + .Select(connection => validityAssessor.AssessAsync(connection, cancellationToken).AsTask())); + var availableIds = assessments + .Where(connection => connection.Validity == ConnectionValidity.Valid) + .Select(connection => connection.Connection.Id) + .ToHashSet(StringComparer.Ordinal); + var externalMethods = registry.LoginMethods.Where(method => availableIds.Contains(method.Id)).ToArray(); var localOptions = options.Value.LocalLogin; if (!localOptions.IsEnabled) return externalMethods; @@ -91,7 +101,9 @@ public sealed class ExternalAuthenticationBroker( return BrokerInitiationResult.Fail(error); } var connection = await connectionRegistry.FindByKeyAsync(targetTenantId, request.ConnectionKey, cancellationToken); - if (connection is null || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection.ArchivedAt != null) + if (connection is not null) + connection = await validityAssessor.AssessAsync(connection, cancellationToken); + if (connection is null || connection.Validity != ConnectionValidity.Valid || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection.ArchivedAt != null) { var error = BrokerErrorFactory.Create(BrokerErrorCategory.MethodUnavailable); await RecordOutcomeAsync("external", "initiate", SecurityEventOutcome.Rejected, BrokerErrorCategory.MethodUnavailable, targetTenantId, null, null, cancellationToken); diff --git a/src/modules/Elsa.ExternalAuthentication/Services/IdentityProviderConnectionManagementService.cs b/src/modules/Elsa.ExternalAuthentication/Services/IdentityProviderConnectionManagementService.cs index 4eaa6b8ad..add1eb340 100644 --- a/src/modules/Elsa.ExternalAuthentication/Services/IdentityProviderConnectionManagementService.cs +++ b/src/modules/Elsa.ExternalAuthentication/Services/IdentityProviderConnectionManagementService.cs @@ -22,6 +22,7 @@ namespace Elsa.ExternalAuthentication.Services; public sealed partial class IdentityProviderConnectionManagementService( IIdentityProviderConnectionStore store, IIdentityProviderConnectionRegistry registry, + IIdentityProviderConnectionValidityAssessor validityAssessor, IConnectionRegistryVersionStore registryVersions, IExternalAuthenticationAdapterRegistry adapters, IAdapterSettingsMigrationService settingsMigrations, @@ -46,13 +47,13 @@ public sealed partial class IdentityProviderConnectionManagementService( { var effective = await registry.FindByIdAsync(targetTenantId, id, cancellationToken); if (effective is not null && effective.Scope == ConnectionScope.Host) - return new ManagementConnectionLookupResult.Found(await AssessValidityAsync(effective, cancellationToken)); + return new ManagementConnectionLookupResult.Found(await validityAssessor.AssessAsync(effective, cancellationToken)); var connection = await store.FindByIdAsync(id, cancellationToken); if (connection is null || connection.TenantId != ConnectionScope.HostTenantId) return new ManagementConnectionLookupResult.NotFound(); - return new ManagementConnectionLookupResult.Found(await AssessValidityAsync(ToEffective(connection), cancellationToken)); + return new ManagementConnectionLookupResult.Found(await validityAssessor.AssessAsync(ToEffective(connection), cancellationToken)); } /// Returns the deployment-derived read-only upstream callback URI for management display. @@ -76,7 +77,7 @@ public sealed partial class IdentityProviderConnectionManagementService( .ThenBy(x => x.Connection.Key, StringComparer.Ordinal) .ThenBy(x => x.Connection.Id, StringComparer.Ordinal) .ToArray(); - return await Task.WhenAll(matches.Select(x => AssessValidityAsync(x, cancellationToken).AsTask())); + return await Task.WhenAll(matches.Select(x => validityAssessor.AssessAsync(x, cancellationToken).AsTask())); } public async ValueTask CreateAsync(IdentityProviderConnection connection, ClaimsPrincipal actor, string targetTenantId, bool confirmUnsafeSettings, CancellationToken cancellationToken = default) @@ -432,44 +433,6 @@ public sealed partial class IdentityProviderConnectionManagementService( return states; } - // Registry composition deliberately leaves adapter-specific structural validity as Unknown. - // Management reads resolve that state without invoking provider test endpoints or persisting migrations. - private async ValueTask AssessValidityAsync(EffectiveIdentityProviderConnection effective, CancellationToken cancellationToken) - { - if (effective.Validity == ConnectionValidity.Invalid) - return effective; - - var connection = IdentityProviderConnectionCloner.Clone(effective.Connection); - if (!adapters.TryGet(connection.AdapterType, out var adapter)) - return effective with { Validity = ConnectionValidity.Invalid }; - - try - { - var migration = await settingsMigrations.MigrateAsync(connection.AdapterType, connection.AdapterSettingsVersion, connection.AdapterSettings, cancellationToken); - connection.AdapterSettingsVersion = migration.SettingsVersion; - connection.AdapterSettings = migration.Settings; - } - catch (InvalidOperationException) - { - return effective with { Validity = ConnectionValidity.Invalid }; - } - - var descriptor = adapter.Describe(); - var declaredSecrets = descriptor.Fields.Where(x => x.IsSecretBinding).ToDictionary(x => x.Name, StringComparer.Ordinal); - if (connection.SecretBindings.Keys.Any(x => !declaredSecrets.ContainsKey(x))) - return effective with { Validity = ConnectionValidity.Invalid }; - - var states = await GetSecretStatesAsync(connection, cancellationToken); - if (declaredSecrets.Values.Where(x => x.IsRequired).Any(field => !states.TryGetValue(field.Name, out var state) || !state.IsConfigured || !state.IsResolvable)) - return effective with { Validity = ConnectionValidity.Invalid }; - - var validation = await adapter.ValidateAsync(new ConnectionValidationContext( - new EffectiveIdentityProviderConnection(connection, effective.Ownership, effective.Scope, ConnectionValidity.Unknown, effective.IsShadowed, effective.SourceName), - new Dictionary(), - clock), cancellationToken); - return effective with { Validity = validation.IsValid ? ConnectionValidity.Valid : ConnectionValidity.Invalid }; - } - private async ValueTask ApplySettingsMigrationAsync(IdentityProviderConnection connection, ICollection errors, CancellationToken cancellationToken) { try diff --git a/src/modules/Elsa.ExternalAuthentication/Services/IdentityProviderConnectionValidityAssessor.cs b/src/modules/Elsa.ExternalAuthentication/Services/IdentityProviderConnectionValidityAssessor.cs new file mode 100644 index 000000000..5926e02c7 --- /dev/null +++ b/src/modules/Elsa.ExternalAuthentication/Services/IdentityProviderConnectionValidityAssessor.cs @@ -0,0 +1,99 @@ +using Elsa.Common; +using Elsa.ExternalAuthentication.Contracts; +using Elsa.ExternalAuthentication.Models; + +namespace Elsa.ExternalAuthentication.Services; + +/// +/// Resolves adapter-specific structural validity without invoking provider test endpoints. +/// +public sealed class IdentityProviderConnectionValidityAssessor( + IExternalAuthenticationAdapterRegistry adapters, + IAdapterSettingsMigrationService settingsMigrations, + IEnumerable secretBindingResolvers, + ISystemClock clock) : IIdentityProviderConnectionValidityAssessor +{ + private readonly IReadOnlyDictionary _secretBindingResolvers = + secretBindingResolvers.ToDictionary(x => x.Type, StringComparer.Ordinal); + + public async ValueTask AssessAsync( + EffectiveIdentityProviderConnection effective, + CancellationToken cancellationToken = default) + { + if (effective.Validity == ConnectionValidity.Invalid) + return effective; + + try + { + return await AssessCoreAsync(effective, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + // A broken adapter or secret backend must not prevent every other sign-in method from being discovered. + return effective with { Validity = ConnectionValidity.Invalid }; + } + } + + private async ValueTask AssessCoreAsync( + EffectiveIdentityProviderConnection effective, + CancellationToken cancellationToken) + { + + var connection = IdentityProviderConnectionCloner.Clone(effective.Connection); + if (!adapters.TryGet(connection.AdapterType, out var adapter)) + return effective with { Validity = ConnectionValidity.Invalid }; + + try + { + var migration = await settingsMigrations.MigrateAsync( + connection.AdapterType, + connection.AdapterSettingsVersion, + connection.AdapterSettings, + cancellationToken); + connection.AdapterSettingsVersion = migration.SettingsVersion; + connection.AdapterSettings = migration.Settings; + } + catch (InvalidOperationException) + { + return effective with { Validity = ConnectionValidity.Invalid }; + } + + var descriptor = adapter.Describe(); + var declaredSecrets = descriptor.Fields + .Where(x => x.IsSecretBinding) + .ToDictionary(x => x.Name, StringComparer.Ordinal); + if (connection.SecretBindings.Keys.Any(x => !declaredSecrets.ContainsKey(x))) + return effective with { Validity = ConnectionValidity.Invalid }; + + var secretStates = await GetSecretStatesAsync(connection, cancellationToken); + if (declaredSecrets.Values + .Where(x => x.IsRequired) + .Any(field => !secretStates.TryGetValue(field.Name, out var state) || !state.IsConfigured || !state.IsResolvable)) + return effective with { Validity = ConnectionValidity.Invalid }; + + var validation = await adapter.ValidateAsync(new ConnectionValidationContext( + effective with { Connection = connection, Validity = ConnectionValidity.Unknown }, + new Dictionary(), + clock), cancellationToken); + return effective with { Validity = validation.IsValid ? ConnectionValidity.Valid : ConnectionValidity.Invalid }; + } + + private async ValueTask> GetSecretStatesAsync( + IdentityProviderConnection connection, + CancellationToken cancellationToken) + { + var states = new Dictionary(StringComparer.Ordinal); + foreach (var (name, binding) in connection.SecretBindings) + { + states[name] = _secretBindingResolvers.TryGetValue(binding.ResolverType, out var resolver) + ? await resolver.GetStateAsync(binding, cancellationToken) + : new SecretBindingState(false, false); + } + + return states; + } +} diff --git a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerContractTests.cs b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerContractTests.cs index 7d94b69f7..25b86c1f6 100644 --- a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerContractTests.cs +++ b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerContractTests.cs @@ -35,6 +35,42 @@ public class BrokerContractTests await Assert.ThrowsAsync(() => broker.DiscoverAsync("tenant-b", "unknown").AsTask()); } + + [Fact] + public async Task DiscoveryDoesNotAdvertiseAnInvalidConnection() + { + var broker = BrokerSecurityTests.CreateBroker( + new BrokerSecurityTests.RecordingAdapter(), + connectionValidity: ConnectionValidity.Unknown, + assessedValidity: ConnectionValidity.Invalid, + includeLoginMethod: true); + + var methods = await broker.DiscoverAsync("tenant-a", "studio"); + + Assert.DoesNotContain(methods, method => method.Id == "connection-a"); + } + + [Fact] + public async Task InitiationRejectsAConnectionThatFailsRuntimeValidityAssessment() + { + var adapter = new BrokerSecurityTests.RecordingAdapter(); + var broker = BrokerSecurityTests.CreateBroker( + adapter, + connectionValidity: ConnectionValidity.Unknown, + assessedValidity: ConnectionValidity.Invalid); + + var result = await broker.InitiateExternalAsync(new BrokerAuthorizationRequest( + "studio", + new Uri("https://studio.example/authentication/external/callback"), + "code", + "challenge", + "S256", + "/workflows", + "contoso"), "tenant-a"); + + Assert.Equal("method_unavailable", result.Error?.Error); + Assert.Null(adapter.Connection); + } } public class BrokerDiscoveryEndpointContractTests : IAsyncLifetime diff --git a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerSecurityTests.cs b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerSecurityTests.cs index 54f3a66ff..ac3a6abdf 100644 --- a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerSecurityTests.cs +++ b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerSecurityTests.cs @@ -385,7 +385,10 @@ public class BrokerSecurityTests IElsaTokenService? tokenService = null, IIdentityRefreshTokenService? identityRefreshTokenService = null, ITenantAccessor? tenantAccessor = null, - ExternalAuthenticationSecurityNotifier? notifier = null) + ExternalAuthenticationSecurityNotifier? notifier = null, + ConnectionValidity connectionValidity = ConnectionValidity.Valid, + ConnectionValidity? assessedValidity = null, + bool includeLoginMethod = false) { var connection = new IdentityProviderConnection { @@ -393,18 +396,27 @@ public class BrokerSecurityTests DisplayName = "Contoso", IsEnabled = true, MaterialRevision = "revision-a" }; configureConnection?.Invoke(connection); - var effective = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Configuration, new(ConnectionScopeKind.Tenant, "tenant-a"), ConnectionValidity.Valid, false, "test"); + var effective = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Configuration, new(ConnectionScopeKind.Tenant, "tenant-a"), connectionValidity, false, "test"); var registry = Substitute.For(); registry.FindByKeyAsync("tenant-a", "contoso", Arg.Any()).Returns(ValueTask.FromResult(effective)); registry.FindByIdAsync("tenant-a", "connection-a", Arg.Any()).Returns(ValueTask.FromResult(effective)); - registry.GetAsync("tenant-a", Arg.Any()).Returns(ValueTask.FromResult(new EffectiveConnectionRegistry([effective], [], "v1"))); + IReadOnlyCollection loginMethods = includeLoginMethod + ? [new LoginMethod(connection.Id, connection.Key, LoginMethodKind.External, connection.DisplayName, null, 0, false, new Uri($"/external-authentication/authorize/{connection.Key}", UriKind.Relative))] + : []; + registry.GetAsync("tenant-a", Arg.Any()).Returns(ValueTask.FromResult(new EffectiveConnectionRegistry([effective], loginMethods, "v1"))); + var validityAssessor = Substitute.For(); + validityAssessor.AssessAsync(Arg.Any(), Arg.Any()) + .Returns(call => ValueTask.FromResult(call.Arg() with + { + Validity = assessedValidity ?? call.Arg().Validity + })); var options = Microsoft.Extensions.Options.Options.Create(new ExternalAuthenticationOptions { Clients = clients?.ToList() ?? [new AuthenticationClient("studio", "Studio", AuthenticationClientType.Public, new HashSet { new("https://studio.example/authentication/external/callback") }, new HashSet(), new HashSet { "https://studio.example" }, new HashSet { "/workflows" }, null, true)] }); var clock = new TestClock(); - return new ExternalAuthenticationBroker(registry, [adapter], resolvers ?? [], hasher ?? new HmacExternalAuthenticationHandleHasher(), new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider(), identityResolver ?? Substitute.For(), permissionGrantResolver ?? Substitute.For(), new InMemoryExternalAuthenticationStateStore(clock), grants ?? new InMemoryAuthorizationGrantStore(clock), sessionStore ?? new InMemoryExternalAuthenticationSessionStore(clock), tokenIssuer ?? Substitute.For(), credentialsValidator ?? Substitute.For(), userProvider ?? Substitute.For(), roleProvider ?? Substitute.For(), tokenService ?? Substitute.For(), identityRefreshTokenService ?? Substitute.For(), tenantAccessor ?? new DefaultTenantAccessor(), clock, options, notifier); + return new ExternalAuthenticationBroker(registry, validityAssessor, [adapter], resolvers ?? [], hasher ?? new HmacExternalAuthenticationHandleHasher(), new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider(), identityResolver ?? Substitute.For(), permissionGrantResolver ?? Substitute.For(), new InMemoryExternalAuthenticationStateStore(clock), grants ?? new InMemoryAuthorizationGrantStore(clock), sessionStore ?? new InMemoryExternalAuthenticationSessionStore(clock), tokenIssuer ?? Substitute.For(), credentialsValidator ?? Substitute.For(), userProvider ?? Substitute.For(), roleProvider ?? Substitute.For(), tokenService ?? Substitute.For(), identityRefreshTokenService ?? Substitute.For(), tenantAccessor ?? new DefaultTenantAccessor(), clock, options, notifier); } private static BrokerAuthorizationRequest Request(string returnPath) => new("studio", new Uri("https://studio.example/authentication/external/callback"), "code", "challenge", "S256", returnPath, "contoso"); diff --git a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Connections/ConnectionManagementTests.cs b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Connections/ConnectionManagementTests.cs index fcc51b26a..7d28bd17e 100644 --- a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Connections/ConnectionManagementTests.cs +++ b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Connections/ConnectionManagementTests.cs @@ -79,6 +79,7 @@ public class ConnectionManagementTests : IAsyncLifetime builder.Services.AddSingleton(new TestAdapterRegistry(_adapter)); _settingsMigrations = new TestAdapterSettingsMigrationService(); builder.Services.AddSingleton(_settingsMigrations); + builder.Services.AddSingleton(); builder.Services.AddSingleton(new TestUnlinkedIdentityPolicyRegistry()); builder.Services.AddSingleton(new TestExternalUserMatcherRegistry("allowed-matcher", "disallowed-matcher")); builder.Services.AddScoped(_ => Substitute.For()); diff --git a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Operations/PreviewEndpointContractTests.cs b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Operations/PreviewEndpointContractTests.cs index 945616d98..76c4aa759 100644 --- a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Operations/PreviewEndpointContractTests.cs +++ b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Operations/PreviewEndpointContractTests.cs @@ -46,9 +46,13 @@ public class PreviewEndpointContractTests : IAsyncLifetime var settingsMigrations = Substitute.For(); settingsMigrations.MigrateAsync(adapter.Type, connection.AdapterSettingsVersion, Arg.Any(), Arg.Any()) .Returns(ValueTask.FromResult(new AdapterSettingsMigrationResult(connection.AdapterSettingsVersion, connection.AdapterSettings, false))); + var validityAssessor = Substitute.For(); + validityAssessor.AssessAsync(Arg.Any(), Arg.Any()) + .Returns(call => ValueTask.FromResult(call.Arg())); var management = new IdentityProviderConnectionManagementService( null!, connectionRegistry, + validityAssessor, null!, adapters, settingsMigrations, diff --git a/test/performance/Elsa.Workflows.PerformanceTests/ExternalAuthentication/ExternalAuthenticationBenchmarks.cs b/test/performance/Elsa.Workflows.PerformanceTests/ExternalAuthentication/ExternalAuthenticationBenchmarks.cs index ea416aae4..f5fdf958c 100644 --- a/test/performance/Elsa.Workflows.PerformanceTests/ExternalAuthentication/ExternalAuthenticationBenchmarks.cs +++ b/test/performance/Elsa.Workflows.PerformanceTests/ExternalAuthentication/ExternalAuthenticationBenchmarks.cs @@ -58,8 +58,10 @@ public class ExternalAuthenticationBenchmarks ] }); var unused = new UnusedBrokerDependencies(); + var validityAssessor = new AssumeValidConnectionValidityAssessor(); _broker = new ExternalAuthenticationBroker( _discoveryRegistry, + validityAssessor, [adapter], [], _hasher, @@ -139,6 +141,14 @@ public class ExternalAuthenticationBenchmarks public ValueTask CreateLogoutRequestAsync(ExternalLogoutContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } + private sealed class AssumeValidConnectionValidityAssessor : IIdentityProviderConnectionValidityAssessor + { + public ValueTask AssessAsync( + EffectiveIdentityProviderConnection connection, + CancellationToken cancellationToken = default) => + ValueTask.FromResult(connection with { Validity = ConnectionValidity.Valid }); + } + private sealed class UnusedBrokerDependencies : IExternalIdentityResolver, IPermissionGrantResolver, diff --git a/test/unit/Elsa.ExternalAuthentication.UnitTests/Foundational/IdentityProviderConnectionValidityAssessorTests.cs b/test/unit/Elsa.ExternalAuthentication.UnitTests/Foundational/IdentityProviderConnectionValidityAssessorTests.cs new file mode 100644 index 000000000..88b391807 --- /dev/null +++ b/test/unit/Elsa.ExternalAuthentication.UnitTests/Foundational/IdentityProviderConnectionValidityAssessorTests.cs @@ -0,0 +1,115 @@ +using Elsa.Common; +using Elsa.ExternalAuthentication.Contracts; +using Elsa.ExternalAuthentication.Models; +using Elsa.ExternalAuthentication.Services; + +namespace Elsa.ExternalAuthentication.UnitTests.Foundational; + +public class IdentityProviderConnectionValidityAssessorTests +{ + [Fact] + public async Task MissingRequiredSecretMakesAnEnabledConnectionInvalid() + { + var connection = ExternalAuthenticationTestData.CreateConnection(); + connection.AdapterType = RequiredSecretAdapter.AdapterType; + connection.SecretBindings.Clear(); + var effective = new EffectiveIdentityProviderConnection( + connection, + ConnectionSourceOwnership.Database, + ConnectionScope.Host, + ConnectionValidity.Unknown, + false, + "database"); + var assessor = new IdentityProviderConnectionValidityAssessor( + new TestAdapterRegistry(new RequiredSecretAdapter()), + new PassThroughSettingsMigrationService(), + [], + new FixedClock()); + + var result = await assessor.AssessAsync(effective); + + Assert.Equal(ConnectionValidity.Invalid, result.Validity); + } + + [Fact] + public async Task SecretResolverFailureMakesOnlyThatConnectionInvalid() + { + var connection = ExternalAuthenticationTestData.CreateConnection(); + connection.AdapterType = RequiredSecretAdapter.AdapterType; + connection.SecretBindings["clientSecret"] = new SecretBinding(ThrowingSecretBindingResolver.ResolverType, "client-secret"); + var effective = new EffectiveIdentityProviderConnection( + connection, + ConnectionSourceOwnership.Database, + ConnectionScope.Host, + ConnectionValidity.Unknown, + false, + "database"); + var assessor = new IdentityProviderConnectionValidityAssessor( + new TestAdapterRegistry(new RequiredSecretAdapter()), + new PassThroughSettingsMigrationService(), + [new ThrowingSecretBindingResolver()], + new FixedClock()); + + var result = await assessor.AssessAsync(effective); + + Assert.Equal(ConnectionValidity.Invalid, result.Validity); + } + + private sealed class TestAdapterRegistry(IExternalAuthenticationAdapter adapter) : IExternalAuthenticationAdapterRegistry + { + public IReadOnlyCollection ListDescriptors() => [adapter.Describe()]; + + public bool TryGet(string type, out IExternalAuthenticationAdapter result) + { + result = adapter; + return string.Equals(type, adapter.Type, StringComparison.Ordinal); + } + } + + private sealed class PassThroughSettingsMigrationService : IAdapterSettingsMigrationService + { + public ValueTask MigrateAsync( + string adapterType, + int settingsVersion, + System.Text.Json.JsonElement settings, + CancellationToken cancellationToken = default) => + ValueTask.FromResult(new AdapterSettingsMigrationResult(settingsVersion, settings, false)); + } + + private sealed class RequiredSecretAdapter : IExternalAuthenticationAdapter + { + public const string AdapterType = "required-secret"; + public string Type => AdapterType; + + public ExternalAuthenticationAdapterDescriptor Describe() => new( + Type, + "Required secret", + "Requires a client secret", + 1, + [new SettingFieldDescriptor("clientSecret", "Client secret", "Secret", "secret", true, "secret", null, [], new SettingFieldValidation(), true, false, null, null, true)], + new ExternalAuthenticationAdapterCapabilities(false, false, false), + null); + + public ValueTask ValidateAsync(ConnectionValidationContext context, CancellationToken cancellationToken = default) => + ValueTask.FromResult(new ConnectionValidationResult(true, [], [])); + public ValueTask CreateAuthorizationRequestAsync(ExternalAuthorizationContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public ValueTask AuthenticateCallbackAsync(ExternalCallbackContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public ValueTask TestAsync(ConnectionTestContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public ValueTask CreateLogoutRequestAsync(ExternalLogoutContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + + private sealed class ThrowingSecretBindingResolver : ISecretBindingResolver + { + public const string ResolverType = "throwing"; + public string Type => ResolverType; + public ValueTask GetStateAsync(SecretBinding binding, CancellationToken cancellationToken = default) => + throw new InvalidOperationException("Secret backend unavailable."); + public ValueTask ResolveAsync(SecretBinding binding, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + } + + private sealed class FixedClock : ISystemClock + { + public DateTimeOffset UtcNow => new(2026, 8, 2, 0, 0, 0, TimeSpan.Zero); + } +}