using Elsa.ExternalAuthentication.Models; using Elsa.ExternalAuthentication.Options; using Elsa.ExternalAuthentication.Validation; using Microsoft.Extensions.Logging; namespace Elsa.ExternalAuthentication.UnitTests.Foundational; public class ExternalAuthenticationOptionsValidatorTests { private readonly CapturingLogger _logger = new(); [Theory] // The legacy spelling carries two colons, so it parses as nothing and would silently stop bounding anything. [InlineData("external-authentication:connections:read")] [InlineData("not a permission")] [InlineData("workflows/definitions:")] public void RejectsAGrantBoundaryEntryThatIsNotAWellFormedPermission(string permission) { var allowed = new ExternalAuthenticationOptions(); allowed.PermissionGrants.AllowedPermissions = [permission]; var denied = new ExternalAuthenticationOptions(); denied.PermissionGrants.DeniedPermissions = [permission]; var allowedResult = CreateValidator().Validate(null, allowed); var deniedResult = CreateValidator().Validate(null, denied); Assert.False(allowedResult.Succeeded); Assert.Contains(allowedResult.Failures!, x => x.Contains("AllowedPermissions") && x.Contains("well-formed permission")); Assert.False(deniedResult.Succeeded); Assert.Contains(deniedResult.Failures!, x => x.Contains("DeniedPermissions") && x.Contains("well-formed permission")); } [Theory] // These parse, so they slipped past well-formedness — yet the matcher never satisfies them. In a deny // list that is a silent un-denying, exactly what boundary validation exists to prevent. [InlineData("workflows*:delete")] [InlineData("work*/foo:view")] [InlineData("work*/definitions/*:view")] [InlineData("workflows:del*")] public void RejectsAGrantBoundaryEntryWithAWildcardTheMatcherNeverSatisfies(string permission) { var allowed = new ExternalAuthenticationOptions(); allowed.PermissionGrants.AllowedPermissions = [permission]; var denied = new ExternalAuthenticationOptions(); denied.PermissionGrants.DeniedPermissions = [permission]; var allowedResult = CreateValidator().Validate(null, allowed); var deniedResult = CreateValidator().Validate(null, denied); Assert.False(allowedResult.Succeeded); Assert.Contains(allowedResult.Failures!, x => x.Contains("AllowedPermissions") && x.Contains("would match nothing")); Assert.False(deniedResult.Succeeded); Assert.Contains(deniedResult.Failures!, x => x.Contains("DeniedPermissions") && x.Contains("would match nothing")); } [Fact] public void AcceptsAGrantBoundaryOfWildcardPatterns() { var options = new ExternalAuthenticationOptions(); options.PermissionGrants.AllowedPermissions = ["workflows/*:delete", "*"]; options.PermissionGrants.DeniedPermissions = ["workflows/definitions:*"]; var result = CreateValidator().Validate(null, options); Assert.DoesNotContain(result.Failures ?? [], x => x.Contains("well-formed permission")); } /// /// A deny list is not a failure, but it costs a wildcard grant everything it could have conferred: deny /// matches in both directions, so '*' satisfies every deny entry and an externally-authenticated superuser /// silently loses it while local login keeps working. The warning names that at startup rather than leaving /// it to be diagnosed from an issued token. /// [Fact] public void WarnsThatANonEmptyDenyListRefusesWildcardGrantsWhole() { var options = new ExternalAuthenticationOptions(); options.PermissionGrants.DeniedPermissions = ["workflows/*:delete"]; var result = CreateValidator().Validate(null, options); Assert.True(result.Succeeded); var warning = Assert.Single(_logger.Entries, x => x.Level == LogLevel.Warning); Assert.Contains("DeniedPermissions", warning.Message); Assert.Contains("refused entirely", warning.Message); } [Fact] public void DoesNotWarnWhenNoPermissionsAreDenied() { var options = new ExternalAuthenticationOptions(); options.PermissionGrants.AllowedPermissions = ["workflows/*:delete"]; CreateValidator().Validate(null, options); Assert.DoesNotContain(_logger.Entries, x => x.Level == LogLevel.Warning); } [Fact] public void RejectsDuplicateInstalledAdapterTypes() { var result = CreateValidator([new StubAdapter("oidc"), new StubAdapter("oidc")]).Validate(null, new ExternalAuthenticationOptions()); Assert.False(result.Succeeded); Assert.Contains(result.Failures!, x => x.Contains("registered more than once")); } [Fact] public void RejectsPublicClientWithWildcardOriginAndSecret() { var options = new ExternalAuthenticationOptions { Clients = [ new AuthenticationClient( "studio", "Studio", AuthenticationClientType.Public, new HashSet { new("https://studio.example/callback") }, new HashSet(), new HashSet { "https://*.example" }, new HashSet { "/" }, new SecretBinding("configuration", "studio-secret"), true) ] }; var result = CreateValidator().Validate(null, options); Assert.False(result.Succeeded); Assert.Contains(result.Failures!, x => x.Contains("invalid allowed origin")); Assert.Contains(result.Failures!, x => x.Contains("must not define a client secret")); } [Fact] public void RejectsNonHostConfigurationConnection() { var options = new ExternalAuthenticationOptions { ConfigurationConnections = [ RegistryTestData.Connection("tenant", "tenant-a", "contoso") ] }; var result = CreateValidator().Validate(null, options); Assert.False(result.Succeeded); Assert.Contains(result.Failures!, x => x.Contains("must use the host scope")); } [Fact] public void RejectsMultipleConfiguredPreferredConnections() { var options = new ExternalAuthenticationOptions { ConfigurationConnections = [ RegistryTestData.Connection("first", "*", "first", isPreferred: true), RegistryTestData.Connection("second", "*", "second", isPreferred: true) ] }; var result = CreateValidator().Validate(null, options); Assert.False(result.Succeeded); Assert.Contains(result.Failures!, x => x.Contains("more than one preferred sign-in method")); } [Fact] public void RejectsNonPositiveRateLimitRules() { var options = new ExternalAuthenticationOptions { RateLimits = new ExternalAuthenticationRateLimitOptions { Discovery = new RateLimitRule(0, TimeSpan.Zero) } }; var result = CreateValidator().Validate(null, options); Assert.False(result.Succeeded); Assert.Contains(result.Failures!, x => x.Contains("Discovery") && x.Contains("positive permit limit and window")); } [Theory] [InlineData("http://elsa.example")] [InlineData("https://elsa.example/?unexpected=true")] public void RejectsUnsafeExternalCallbackBaseUri(string callbackBaseUri) { var options = new ExternalAuthenticationOptions { Redirects = new RedirectValidationOptions { ExternalCallbackBaseUri = new Uri(callbackBaseUri) } }; var result = CreateValidator().Validate(null, options); Assert.False(result.Succeeded); Assert.Contains(result.Failures!, failure => failure.Contains("ExternalCallbackBaseUri")); } [Fact] public void AllowsHttpLoopbackExternalCallbackBaseUriOnlyWhenDevelopmentModeIsEnabled() { var options = new ExternalAuthenticationOptions { Redirects = new RedirectValidationOptions { ExternalCallbackBaseUri = new Uri("http://127.0.0.1:5000"), AllowDevelopmentLoopbackCallbacks = true } }; var result = CreateValidator().Validate(null, options); Assert.True(result.Succeeded); } [Fact] public void AcceptsExactPublicClientAndInstalledConfigurationSelections() { var options = new ExternalAuthenticationOptions { AllowedAdapterTypes = ["oidc"], Clients = [ new AuthenticationClient( "studio", "Studio", AuthenticationClientType.Public, new HashSet { new("https://studio.example/callback") }, new HashSet { new("https://studio.example/logout") }, new HashSet { "https://studio.example" }, new HashSet { "/" }, null, true) ], ConfigurationConnections = [RegistryTestData.Connection("connection", "*", "contoso")] }; var result = CreateValidator().Validate(null, options); Assert.True(result.Succeeded); } private ExternalAuthenticationOptionsValidator CreateValidator(IEnumerable? adapters = null) { var extensions = new ExternalAuthenticationExtensionOptions(); foreach (var adapter in adapters ?? [new StubAdapter("oidc")]) extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.Adapter, adapter.Type)); extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.UnlinkedIdentityPolicy, "reject")); extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.UnlinkedIdentityPolicy, "create-user")); extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.PermissionGrantSource, "elsa-roles")); extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.PermissionGrantSource, "claim-mapping")); extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.PermissionGrantSource, "group-mapping")); extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.PermissionGrantSource, "claim-pass-through")); return new(Microsoft.Extensions.Options.Options.Create(extensions), _logger); } private sealed class CapturingLogger : ILogger { public List Entries { get; } = []; public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => Entries.Add(new(logLevel, formatter(state, exception))); } private sealed record LogEntry(LogLevel Level, string Message); private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } }