using Elsa.ExternalAuthentication.Contracts; using Elsa.ExternalAuthentication.Models; using Elsa.ExternalAuthentication.Options; using Elsa.ExternalAuthentication.Validation; using Microsoft.Extensions.Logging.Abstractions; using Elsa.Extensions; using Microsoft.Extensions.Configuration; namespace Elsa.ExternalAuthentication.UnitTests.Foundational; public class ExternalAuthenticationOptionsTests { [Fact] public void BindsAuthenticationClientsFromConfiguration() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["ExternalAuthentication:AuthenticationClients:0:ClientId"] = "elsa-studio-server", ["ExternalAuthentication:AuthenticationClients:0:DisplayName"] = "Elsa Studio Server", ["ExternalAuthentication:AuthenticationClients:0:ClientType"] = "Confidential", ["ExternalAuthentication:AuthenticationClients:0:CallbackUris:0"] = "https://localhost:7113/authentication/external/callback", ["ExternalAuthentication:AuthenticationClients:0:LogoutCallbackUris:0"] = "https://localhost:7113/authentication/external/logout-callback", ["ExternalAuthentication:AuthenticationClients:0:AllowedReturnPathPrefixes:0"] = "/", ["ExternalAuthentication:AuthenticationClients:0:SecretBinding:Ownership"] = "External", ["ExternalAuthentication:AuthenticationClients:0:SecretBinding:ResolverType"] = "configuration", ["ExternalAuthentication:AuthenticationClients:0:SecretBinding:Reference"] = "ExternalAuthentication:Secrets:StudioServerClientSecret", ["ExternalAuthentication:AuthenticationClients:0:IsEnabled"] = "true" }) .Build(); var options = new ExternalAuthenticationOptions(); configuration.GetSection("ExternalAuthentication").Bind(options); var client = Assert.Single(options.Clients); Assert.Equal("elsa-studio-server", client.ClientId); Assert.Equal(AuthenticationClientType.Confidential, client.ClientType); Assert.Contains(new Uri("https://localhost:7113/authentication/external/callback"), client.CallbackUris); Assert.Contains(new Uri("https://localhost:7113/authentication/external/logout-callback"), client.LogoutCallbackUris); Assert.Contains("/", client.AllowedReturnPathPrefixes); Assert.Equal("configuration", client.SecretBinding?.ResolverType); Assert.Equal("ExternalAuthentication:Secrets:StudioServerClientSecret", client.SecretBinding?.Reference); Assert.True(client.IsEnabled); } [Fact] public void BindsConnectionJsonSettingsFromConfiguration() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["ExternalAuthentication:Connections:0:Key"] = "keycloak", ["ExternalAuthentication:Connections:0:AdapterType"] = "openid-connect", ["ExternalAuthentication:Connections:0:AdapterSettings:mode"] = "discovery", ["ExternalAuthentication:Connections:0:AdapterSettings:discoveryUrl"] = "https://localhost:8443/realms/elsa/.well-known/openid-configuration", ["ExternalAuthentication:Connections:0:AdapterSettings:clientId"] = "elsa-studio-idp", ["ExternalAuthentication:Connections:0:AdapterSettings:scopes:0"] = "profile", ["ExternalAuthentication:Connections:0:AdapterSettings:scopes:1"] = "email", ["ExternalAuthentication:Connections:0:UnlinkedPolicy:Type"] = "create-user", ["ExternalAuthentication:Connections:0:UnlinkedPolicy:SettingsVersion"] = "1", ["ExternalAuthentication:Connections:0:UnlinkedPolicy:Settings:defaultRoleIds:0"] = "admin", ["ExternalAuthentication:Connections:0:PermissionGrantSources:0:Type"] = "claim-mapping", ["ExternalAuthentication:Connections:0:PermissionGrantSources:0:SettingsVersion"] = "1", ["ExternalAuthentication:Connections:0:PermissionGrantSources:0:Order"] = "10", ["ExternalAuthentication:Connections:0:PermissionGrantSources:0:Settings:claimType"] = "groups" }) .Build(); var options = new ExternalAuthenticationOptions(); configuration.GetSection("ExternalAuthentication").BindExternalAuthenticationOptions(options); var connection = Assert.Single(options.ConfigurationConnections); Assert.Equal("discovery", connection.AdapterSettings.GetProperty("mode").GetString()); Assert.Equal("https://localhost:8443/realms/elsa/.well-known/openid-configuration", connection.AdapterSettings.GetProperty("discoveryUrl").GetString()); Assert.Equal("elsa-studio-idp", connection.AdapterSettings.GetProperty("clientId").GetString()); Assert.Equal(["profile", "email"], connection.AdapterSettings.GetProperty("scopes").EnumerateArray().Select(x => x.GetString())); Assert.Equal("admin", connection.UnlinkedPolicy?.Settings.GetProperty("defaultRoleIds")[0].GetString()); Assert.Equal("groups", Assert.Single(connection.PermissionGrantSources).Settings.GetProperty("claimType").GetString()); } [Fact] public void DefaultsFavorTheMostRestrictiveOperationalSettings() { var options = new ExternalAuthenticationOptions(); Assert.True(options.EnableDatabaseConnections); Assert.Equal(TimeSpan.FromMinutes(10), options.Lifetimes.BrokerTransactionLifetime); Assert.Equal(TimeSpan.FromMinutes(1), options.Lifetimes.CompletionCodeLifetime); Assert.Equal(64, options.Claims.MaximumClaimCount); Assert.Equal(1_024, options.Claims.MaximumValueLength); Assert.Equal(16 * 1_024, options.Claims.MaximumTotalBytes); Assert.True(options.ProviderEgress.RequireHttps); Assert.False(options.ProviderEgress.AllowPrivateNetworkDestinations); Assert.Equal(3, options.ProviderEgress.MaximumRedirects); Assert.True(options.Redirects.RequirePkceS256); Assert.False(options.Redirects.AllowDevelopmentLoopbackCallbacks); Assert.Equal(BrowserCredentialPersistence.Memory, options.WebAssemblyPersistence.Persistence); Assert.True(options.WebAssemblyPersistence.RequireExplicitPersistentStorageWarning); Assert.True(options.FinalLoginPathGuard.IsEnabled); Assert.True(options.FinalLoginPathGuard.RequireRecoveryMethod); } [Fact] public void ValidatorRejectsDuplicateOrUnavailableExtensionTypes() { var options = new ExternalAuthenticationOptions { AllowedAdapterTypes = ["oidc", "oidc", "saml"], AllowedUnlinkedIdentityPolicyTypes = ["reject"], AllowedPermissionGrantSourceTypes = ["elsa-roles"] }; var validator = CreateValidator(adapters: [new TestAdapter("oidc"), new TestAdapter("oidc")]); var result = validator.Validate(null, options); Assert.False(result.Succeeded); Assert.Contains(result.Failures!, failure => failure.Contains("adapter type 'oidc' is registered more than once", StringComparison.Ordinal)); Assert.Contains(result.Failures!, failure => failure.Contains("allowed adapter type 'oidc' is configured more than once", StringComparison.Ordinal)); Assert.Contains(result.Failures!, failure => failure.Contains("allowed adapter type 'saml' is not installed", StringComparison.Ordinal)); } [Fact] public void ValidatorRejectsInsecureClientRegistrations() { var options = CreateValidOptions(); options.Clients = [ new AuthenticationClient( "studio", "Studio", AuthenticationClientType.Public, new HashSet { new("http://studio.example/callback") }, new HashSet(), new HashSet { "https://studio.example/" }, new HashSet(), new SecretBinding("test", "must-not-be-set"), true) ]; var result = CreateValidator().Validate(null, options); Assert.False(result.Succeeded); Assert.Contains(result.Failures!, failure => failure.Contains("invalid callback URI", StringComparison.Ordinal)); Assert.Contains(result.Failures!, failure => failure.Contains("must not define a client secret binding", StringComparison.Ordinal)); Assert.Contains(result.Failures!, failure => failure.Contains("must not contain a path, query, or trailing slash", StringComparison.Ordinal)); } [Fact] public void ValidatorRejectsUnsafeProviderEgressConfiguration() { var options = CreateValidOptions(); options.ProviderEgress = new ProviderEgressOptions { MaximumRedirects = -1, ConnectTimeout = TimeSpan.Zero, RequestTimeout = TimeSpan.Zero, MaximumDiscoveryResponseBytes = 0, MaximumTokenResponseBytes = 0, MaximumUserInfoResponseBytes = 0, AllowedHosts = ["*.example.test"], ProxyUri = new Uri("ftp://user:password@proxy.example") }; var result = CreateValidator().Validate(null, options); Assert.False(result.Succeeded); Assert.Contains(result.Failures!, failure => failure.Contains("egress maximum redirects", StringComparison.Ordinal)); Assert.Contains(result.Failures!, failure => failure.Contains("egress timeouts", StringComparison.Ordinal)); Assert.Contains(result.Failures!, failure => failure.Contains("response-size limits", StringComparison.Ordinal)); Assert.Contains(result.Failures!, failure => failure.Contains("allowed host", StringComparison.Ordinal)); Assert.Contains(result.Failures!, failure => failure.Contains("proxy URI", StringComparison.Ordinal)); } [Fact] public void ValidatorRejectsConfigurationCollisionsAndUnknownAdapterTypes() { var options = CreateValidOptions(); var unknownAdapter = ExternalAuthenticationTestData.CreateConnection("unknown", "tenant-b", "partner"); unknownAdapter.AdapterType = "saml"; options.ConfigurationConnections = [ ExternalAuthenticationTestData.CreateConnection("host", ConnectionScope.HostTenantId, "corp"), ExternalAuthenticationTestData.CreateConnection("tenant", "tenant-a", " CORP "), unknownAdapter ]; var result = CreateValidator().Validate(null, options); Assert.False(result.Succeeded); Assert.Contains(result.Failures!, failure => failure.Contains("must use the host scope", StringComparison.Ordinal)); Assert.Contains(result.Failures!, failure => failure.Contains("configured more than once", StringComparison.Ordinal)); Assert.Contains(result.Failures!, failure => failure.Contains("selects adapter type 'saml', which is not installed", StringComparison.Ordinal)); } private static ExternalAuthenticationOptions CreateValidOptions() => new() { AllowedAdapterTypes = ["oidc"], AllowedUnlinkedIdentityPolicyTypes = ["reject"], AllowedPermissionGrantSourceTypes = ["elsa-roles"] }; private static ExternalAuthenticationOptionsValidator CreateValidator( IEnumerable? adapters = null) { var extensions = new ExternalAuthenticationExtensionOptions(); foreach (var adapter in adapters ?? [new TestAdapter("oidc")]) extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.Adapter, adapter.Type)); extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.UnlinkedIdentityPolicy, "reject")); extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.PermissionGrantSource, "elsa-roles")); return new(Microsoft.Extensions.Options.Options.Create(extensions), NullLogger.Instance); } private sealed class TestAdapter(string type) : IExternalAuthenticationAdapter { public string Type => type; public ExternalAuthenticationAdapterDescriptor Describe() => throw new NotSupportedException(); public ValueTask ValidateAsync(ConnectionValidationContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); 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 TestUnlinkedIdentityPolicy(string type) : IUnlinkedIdentityPolicy { public string Type => type; public UnlinkedIdentityPolicyDescriptor Describe() => throw new NotSupportedException(); public ValueTask EvaluateAsync(UnlinkedIdentityContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class TestPermissionGrantSource(string type) : IPermissionGrantSource { public string Type => type; public PermissionGrantSourceDescriptor Describe() => throw new NotSupportedException(); public ValueTask GetGrantsAsync(PermissionGrantContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } }