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.
This commit is contained in:
parent
a24a6fe267
commit
1b74bb94c0
|
|
@ -72,6 +72,13 @@ public interface IIdentityProviderConnectionRegistry
|
|||
ValueTask<EffectiveIdentityProviderConnection?> FindByIdAsync(string targetTenantId, string connectionId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IIdentityProviderConnectionValidityAssessor
|
||||
{
|
||||
ValueTask<EffectiveIdentityProviderConnection> AssessAsync(
|
||||
EffectiveIdentityProviderConnection connection,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IIdentityProviderConnectionStore
|
||||
{
|
||||
ValueTask<Page<IdentityProviderConnection>> FindAsync(ConnectionFilter filter, CancellationToken cancellationToken = default);
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ public static class ServiceCollectionExtensions
|
|||
services.TryAddSingleton<IIdentityProviderConnectionStore, InMemoryIdentityProviderConnectionStore>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IIdentityProviderConnectionSource, DatabaseIdentityProviderConnectionSource>());
|
||||
services.TryAddSingleton<IIdentityProviderConnectionRegistry, DefaultIdentityProviderConnectionRegistry>();
|
||||
services.TryAddSingleton<IIdentityProviderConnectionValidityAssessor, IdentityProviderConnectionValidityAssessor>();
|
||||
services.TryAddSingleton<ExtensionDescriptorValidator>();
|
||||
services.TryAddSingleton<IExternalAuthenticationAdapterRegistry, DefaultExternalAuthenticationAdapterRegistry>();
|
||||
services.TryAddSingleton<IUnlinkedIdentityPolicyRegistry, DefaultUnlinkedIdentityPolicyRegistry>();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ namespace Elsa.ExternalAuthentication.Services;
|
|||
|
||||
public sealed class ExternalAuthenticationBroker(
|
||||
IIdentityProviderConnectionRegistry connectionRegistry,
|
||||
IIdentityProviderConnectionValidityAssessor validityAssessor,
|
||||
IEnumerable<IExternalAuthenticationAdapter> adapters,
|
||||
IEnumerable<ISecretBindingResolver> secretBindingResolvers,
|
||||
IExternalAuthenticationHandleHasher handleHasher,
|
||||
|
|
@ -67,7 +68,16 @@ public sealed class ExternalAuthenticationBroker(
|
|||
public async ValueTask<IReadOnlyCollection<LoginMethod>> 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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
/// <summary>Returns the deployment-derived read-only upstream callback URI for management display.</summary>
|
||||
|
|
@ -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<ManagementConnectionMutationResult> 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<EffectiveIdentityProviderConnection> 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<string, ResolvedSecretBinding>(),
|
||||
clock), cancellationToken);
|
||||
return effective with { Validity = validation.IsValid ? ConnectionValidity.Valid : ConnectionValidity.Invalid };
|
||||
}
|
||||
|
||||
private async ValueTask ApplySettingsMigrationAsync(IdentityProviderConnection connection, ICollection<ConnectionValidationError> errors, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
using Elsa.Common;
|
||||
using Elsa.ExternalAuthentication.Contracts;
|
||||
using Elsa.ExternalAuthentication.Models;
|
||||
|
||||
namespace Elsa.ExternalAuthentication.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves adapter-specific structural validity without invoking provider test endpoints.
|
||||
/// </summary>
|
||||
public sealed class IdentityProviderConnectionValidityAssessor(
|
||||
IExternalAuthenticationAdapterRegistry adapters,
|
||||
IAdapterSettingsMigrationService settingsMigrations,
|
||||
IEnumerable<ISecretBindingResolver> secretBindingResolvers,
|
||||
ISystemClock clock) : IIdentityProviderConnectionValidityAssessor
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, ISecretBindingResolver> _secretBindingResolvers =
|
||||
secretBindingResolvers.ToDictionary(x => x.Type, StringComparer.Ordinal);
|
||||
|
||||
public async ValueTask<EffectiveIdentityProviderConnection> 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<EffectiveIdentityProviderConnection> 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<string, ResolvedSecretBinding>(),
|
||||
clock), cancellationToken);
|
||||
return effective with { Validity = validation.IsValid ? ConnectionValidity.Valid : ConnectionValidity.Invalid };
|
||||
}
|
||||
|
||||
private async ValueTask<IReadOnlyDictionary<string, SecretBindingState>> GetSecretStatesAsync(
|
||||
IdentityProviderConnection connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var states = new Dictionary<string, SecretBindingState>(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;
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,42 @@ public class BrokerContractTests
|
|||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => 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
|
||||
|
|
|
|||
|
|
@ -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<IIdentityProviderConnectionRegistry>();
|
||||
registry.FindByKeyAsync("tenant-a", "contoso", Arg.Any<CancellationToken>()).Returns(ValueTask.FromResult<EffectiveIdentityProviderConnection?>(effective));
|
||||
registry.FindByIdAsync("tenant-a", "connection-a", Arg.Any<CancellationToken>()).Returns(ValueTask.FromResult<EffectiveIdentityProviderConnection?>(effective));
|
||||
registry.GetAsync("tenant-a", Arg.Any<CancellationToken>()).Returns(ValueTask.FromResult(new EffectiveConnectionRegistry([effective], [], "v1")));
|
||||
IReadOnlyCollection<LoginMethod> 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<CancellationToken>()).Returns(ValueTask.FromResult(new EffectiveConnectionRegistry([effective], loginMethods, "v1")));
|
||||
var validityAssessor = Substitute.For<IIdentityProviderConnectionValidityAssessor>();
|
||||
validityAssessor.AssessAsync(Arg.Any<EffectiveIdentityProviderConnection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(call => ValueTask.FromResult(call.Arg<EffectiveIdentityProviderConnection>() with
|
||||
{
|
||||
Validity = assessedValidity ?? call.Arg<EffectiveIdentityProviderConnection>().Validity
|
||||
}));
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new ExternalAuthenticationOptions
|
||||
{
|
||||
Clients = clients?.ToList() ?? [new AuthenticationClient("studio", "Studio", AuthenticationClientType.Public,
|
||||
new HashSet<Uri> { new("https://studio.example/authentication/external/callback") }, new HashSet<Uri>(), new HashSet<string> { "https://studio.example" }, new HashSet<string> { "/workflows" }, null, true)]
|
||||
});
|
||||
var clock = new TestClock();
|
||||
return new ExternalAuthenticationBroker(registry, [adapter], resolvers ?? [], hasher ?? new HmacExternalAuthenticationHandleHasher(), new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider(), identityResolver ?? Substitute.For<IExternalIdentityResolver>(), permissionGrantResolver ?? Substitute.For<IPermissionGrantResolver>(), new InMemoryExternalAuthenticationStateStore(clock), grants ?? new InMemoryAuthorizationGrantStore(clock), sessionStore ?? new InMemoryExternalAuthenticationSessionStore(clock), tokenIssuer ?? Substitute.For<IExternalAuthenticationTokenIssuer>(), credentialsValidator ?? Substitute.For<IUserCredentialsValidator>(), userProvider ?? Substitute.For<IUserProvider>(), roleProvider ?? Substitute.For<IRoleProvider>(), tokenService ?? Substitute.For<IElsaTokenService>(), identityRefreshTokenService ?? Substitute.For<IIdentityRefreshTokenService>(), tenantAccessor ?? new DefaultTenantAccessor(), clock, options, notifier);
|
||||
return new ExternalAuthenticationBroker(registry, validityAssessor, [adapter], resolvers ?? [], hasher ?? new HmacExternalAuthenticationHandleHasher(), new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider(), identityResolver ?? Substitute.For<IExternalIdentityResolver>(), permissionGrantResolver ?? Substitute.For<IPermissionGrantResolver>(), new InMemoryExternalAuthenticationStateStore(clock), grants ?? new InMemoryAuthorizationGrantStore(clock), sessionStore ?? new InMemoryExternalAuthenticationSessionStore(clock), tokenIssuer ?? Substitute.For<IExternalAuthenticationTokenIssuer>(), credentialsValidator ?? Substitute.For<IUserCredentialsValidator>(), userProvider ?? Substitute.For<IUserProvider>(), roleProvider ?? Substitute.For<IRoleProvider>(), tokenService ?? Substitute.For<IElsaTokenService>(), identityRefreshTokenService ?? Substitute.For<IIdentityRefreshTokenService>(), 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");
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ public class ConnectionManagementTests : IAsyncLifetime
|
|||
builder.Services.AddSingleton<IExternalAuthenticationAdapterRegistry>(new TestAdapterRegistry(_adapter));
|
||||
_settingsMigrations = new TestAdapterSettingsMigrationService();
|
||||
builder.Services.AddSingleton<IAdapterSettingsMigrationService>(_settingsMigrations);
|
||||
builder.Services.AddSingleton<IIdentityProviderConnectionValidityAssessor, IdentityProviderConnectionValidityAssessor>();
|
||||
builder.Services.AddSingleton<IUnlinkedIdentityPolicyRegistry>(new TestUnlinkedIdentityPolicyRegistry());
|
||||
builder.Services.AddSingleton<IExternalUserMatcherRegistry>(new TestExternalUserMatcherRegistry("allowed-matcher", "disallowed-matcher"));
|
||||
builder.Services.AddScoped(_ => Substitute.For<IPermissionGrantSourceRegistry>());
|
||||
|
|
|
|||
|
|
@ -46,9 +46,13 @@ public class PreviewEndpointContractTests : IAsyncLifetime
|
|||
var settingsMigrations = Substitute.For<IAdapterSettingsMigrationService>();
|
||||
settingsMigrations.MigrateAsync(adapter.Type, connection.AdapterSettingsVersion, Arg.Any<JsonElement>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(new AdapterSettingsMigrationResult(connection.AdapterSettingsVersion, connection.AdapterSettings, false)));
|
||||
var validityAssessor = Substitute.For<IIdentityProviderConnectionValidityAssessor>();
|
||||
validityAssessor.AssessAsync(Arg.Any<EffectiveIdentityProviderConnection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(call => ValueTask.FromResult(call.Arg<EffectiveIdentityProviderConnection>()));
|
||||
var management = new IdentityProviderConnectionManagementService(
|
||||
null!,
|
||||
connectionRegistry,
|
||||
validityAssessor,
|
||||
null!,
|
||||
adapters,
|
||||
settingsMigrations,
|
||||
|
|
|
|||
|
|
@ -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<ExternalLogoutRequest?> CreateLogoutRequestAsync(ExternalLogoutContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class AssumeValidConnectionValidityAssessor : IIdentityProviderConnectionValidityAssessor
|
||||
{
|
||||
public ValueTask<EffectiveIdentityProviderConnection> AssessAsync(
|
||||
EffectiveIdentityProviderConnection connection,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult(connection with { Validity = ConnectionValidity.Valid });
|
||||
}
|
||||
|
||||
private sealed class UnusedBrokerDependencies :
|
||||
IExternalIdentityResolver,
|
||||
IPermissionGrantResolver,
|
||||
|
|
|
|||
|
|
@ -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<ExternalAuthenticationAdapterDescriptor> 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<AdapterSettingsMigrationResult> 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<ConnectionValidationResult> ValidateAsync(ConnectionValidationContext context, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult(new ConnectionValidationResult(true, [], []));
|
||||
public ValueTask<ExternalAuthorizationRequest> CreateAuthorizationRequestAsync(ExternalAuthorizationContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
public ValueTask<ExternalAuthenticationResult> AuthenticateCallbackAsync(ExternalCallbackContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
public ValueTask<ConnectionTestResult> TestAsync(ConnectionTestContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
public ValueTask<ExternalLogoutRequest?> 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<SecretBindingState> GetStateAsync(SecretBinding binding, CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("Secret backend unavailable.");
|
||||
public ValueTask<ResolvedSecretBinding> 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue