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:
Sipke Schoorstra 2026-08-02 02:47:28 +02:00
parent a24a6fe267
commit 1b74bb94c0
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
11 changed files with 307 additions and 47 deletions

View file

@ -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);

View file

@ -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>();

View file

@ -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);

View file

@ -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

View file

@ -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;
}
}

View file

@ -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

View file

@ -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");

View file

@ -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>());

View file

@ -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,

View file

@ -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,

View file

@ -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);
}
}