< Summary

Information
Class: Elsa.ExternalAuthentication.Validation.ExternalAuthenticationOptionsValidator
Assembly: Elsa.ExternalAuthentication
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Validation/ExternalAuthenticationOptionsValidator.cs
Line coverage
80%
Covered lines: 166
Uncovered lines: 41
Coverable lines: 207
Total lines: 359
Line coverage: 80.1%
Branch coverage
70%
Covered branches: 154
Total branches: 220
Branch coverage: 70%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
Validate(...)50%101095%
ValidateExternalCallbackBaseUri(...)83.33%66100%
ValidateHandleHashing(...)87.5%8888.23%
ValidateRateLimits(...)100%88100%
ValidateProviderEgress(...)81.57%393890%
ValidateExtensionTypes(...)83.33%7675%
ValidateAllowedTypes(...)80%111080%
ValidateClients(...)67.64%453478.57%
ValidateUris(...)83.33%6687.5%
ValidateOrigins(...)92.85%141492.85%
ValidateReturnPathPrefixes(...)75%9872.72%
IsExactClientUri(...)57.14%171475%
ValidateConfigurationConnections(...)73.52%413482.14%
ValidatePolicy(...)10%441030%
ValidateGrantSources(...)28.57%891427.27%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Validation/ExternalAuthenticationOptionsValidator.cs

#LineLine coverage
 1using Elsa.ExternalAuthentication.Contracts;
 2using Elsa.ExternalAuthentication.Models;
 3using Elsa.ExternalAuthentication.Options;
 4using Elsa.ExternalAuthentication.Policies;
 5using Microsoft.Extensions.Options;
 6
 7namespace Elsa.ExternalAuthentication.Validation;
 8
 9/// <summary>
 10/// Validates the deployment-owned External Authentication configuration before it is used by the broker.
 11/// </summary>
 1612public sealed class ExternalAuthenticationOptionsValidator(
 1613    IOptions<ExternalAuthenticationExtensionOptions> extensionOptions) : IValidateOptions<ExternalAuthenticationOptions>
 14{
 15    public ValidateOptionsResult Validate(string? name, ExternalAuthenticationOptions options)
 16    {
 1617        var failures = new List<string>();
 1618        var registrations = extensionOptions.Value.Registrations;
 13119        var installedAdapterTypes = ValidateExtensionTypes(registrations.Where(x => x.Kind == ExternalAuthenticationExte
 14320        var installedPolicyTypes = ValidateExtensionTypes(registrations.Where(x => x.Kind == ExternalAuthenticationExten
 16621        var installedGrantSourceTypes = ValidateExtensionTypes(registrations.Where(x => x.Kind == ExternalAuthentication
 11422        var installedMatcherTypes = ValidateExtensionTypes(registrations.Where(x => x.Kind == ExternalAuthenticationExte
 23
 1624        ValidateAllowedTypes(options.AllowedAdapterTypes, installedAdapterTypes, "adapter", failures);
 1625        ValidateAllowedTypes(options.AllowedUnlinkedIdentityPolicyTypes, installedPolicyTypes, "unlinked identity policy
 1626        ValidateAllowedTypes(options.AllowedPermissionGrantSourceTypes, installedGrantSourceTypes, "permission grant sou
 1627        ValidateAllowedTypes(options.AllowedExternalUserMatcherTypes, installedMatcherTypes, "external user matcher", fa
 1628        if (string.Equals(options.UnlinkedIdentityPolicy.DefaultType, MatchExternalUserUnlinkedIdentityPolicy.PolicyType
 1629            (installedMatcherTypes.Count == 0 || options.AllowedExternalUserMatcherTypes.Count > 0 && !installedMatcherT
 030            failures.Add("The default unlinked identity policy 'match-user' requires at least one installed and allowed 
 1631        ValidateExternalCallbackBaseUri(options.Redirects, failures);
 1632        ValidateClients(options, failures);
 1633        ValidateConfigurationConnections(options, installedAdapterTypes, installedPolicyTypes, installedGrantSourceTypes
 1634        ValidateRateLimits(options.RateLimits, failures);
 1635        ValidateProviderEgress(options.ProviderEgress, failures);
 1636        ValidateHandleHashing(options.HandleHashing, failures);
 37
 1638        return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures);
 39    }
 40
 41    private static void ValidateExternalCallbackBaseUri(RedirectValidationOptions? redirects, ICollection<string> failur
 42    {
 1643        if (redirects?.ExternalCallbackBaseUri is null)
 1344            return;
 45
 346        if (!ExternalCallbackBaseUriValidator.IsValid(redirects.ExternalCallbackBaseUri, redirects.AllowDevelopmentLoopb
 247            failures.Add(ExternalCallbackBaseUriValidator.ErrorMessage);
 348    }
 49
 50    private static void ValidateHandleHashing(ExternalAuthenticationHandleHashingOptions? handleHashing, ICollection<str
 51    {
 1652        if (handleHashing is null)
 53        {
 054            failures.Add("External Authentication handle-hashing settings are required.");
 055            return;
 56        }
 57
 1658        if (string.IsNullOrWhiteSpace(handleHashing.SharedKeyBase64))
 1459            return;
 60
 261        byte[]? key = null;
 62        try
 63        {
 264            key = Convert.FromBase64String(handleHashing.SharedKeyBase64);
 165            if (key.Length < 32)
 166                failures.Add("External Authentication HandleHashing:SharedKeyBase64 must contain at least 32 bytes.");
 167        }
 168        catch (FormatException)
 69        {
 170            failures.Add("External Authentication HandleHashing:SharedKeyBase64 must be valid base64.");
 171        }
 72        finally
 73        {
 274            if (key is not null)
 175                System.Security.Cryptography.CryptographicOperations.ZeroMemory(key);
 276        }
 277    }
 78
 79    private static void ValidateRateLimits(ExternalAuthenticationRateLimitOptions rateLimits, ICollection<string> failur
 80    {
 19281        foreach (var (name, rule) in new (string Name, RateLimitRule? Rule)[]
 1682        {
 1683            (nameof(rateLimits.Discovery), rateLimits.Discovery),
 1684            (nameof(rateLimits.ExternalInitiation), rateLimits.ExternalInitiation),
 1685            (nameof(rateLimits.LocalInitiation), rateLimits.LocalInitiation),
 1686            (nameof(rateLimits.ProviderCallback), rateLimits.ProviderCallback),
 1687            (nameof(rateLimits.TokenExchange), rateLimits.TokenExchange)
 1688        })
 89        {
 8090            if (rule is null || rule.PermitLimit <= 0 || rule.Window <= TimeSpan.Zero)
 191                failures.Add($"External Authentication rate limit '{name}' must have a positive permit limit and window.
 92        }
 1693    }
 94
 95    private static void ValidateProviderEgress(ProviderEgressOptions? providerEgress, ICollection<string> failures)
 96    {
 1697        if (providerEgress is null)
 98        {
 099            failures.Add("External Authentication provider egress settings are required.");
 0100            return;
 101        }
 102
 16103        if (providerEgress.MaximumRedirects < 0)
 1104            failures.Add("External Authentication provider egress maximum redirects must not be negative.");
 16105        if (providerEgress.ConnectTimeout <= TimeSpan.Zero || providerEgress.RequestTimeout <= TimeSpan.Zero)
 1106            failures.Add("External Authentication provider egress timeouts must be positive.");
 16107        if (providerEgress.MaximumDiscoveryResponseBytes <= 0 || providerEgress.MaximumTokenResponseBytes <= 0 || provid
 1108            failures.Add("External Authentication provider egress response-size limits must be positive.");
 109
 34110        foreach (var host in providerEgress.AllowedHosts ?? [])
 111        {
 1112            if (string.IsNullOrWhiteSpace(host) || host.Contains('*') || Uri.CheckHostName(host.TrimEnd('.')) == UriHost
 1113                failures.Add($"External Authentication provider egress allowed host '{host}' is invalid.");
 114        }
 115
 16116        if (providerEgress.ProxyUri is { } proxyUri &&
 16117            (!proxyUri.IsAbsoluteUri ||
 16118             !string.Equals(proxyUri.Scheme, "http", StringComparison.OrdinalIgnoreCase) && !string.Equals(proxyUri.Sche
 16119             !string.IsNullOrEmpty(proxyUri.UserInfo) ||
 16120             !string.IsNullOrEmpty(proxyUri.Fragment) ||
 16121             proxyUri.HostNameType == UriHostNameType.Unknown))
 1122            failures.Add("External Authentication provider egress proxy URI must be an absolute HTTP(S) URI without cred
 16123    }
 124
 125    private static HashSet<string> ValidateExtensionTypes(IEnumerable<string> types, string kind, ICollection<string> fa
 126    {
 64127        var result = new HashSet<string>(StringComparer.Ordinal);
 128
 324129        foreach (var type in types)
 130        {
 98131            if (string.IsNullOrWhiteSpace(type))
 132            {
 0133                failures.Add($"Installed {kind} types must not be empty.");
 0134                continue;
 135            }
 136
 98137            if (!result.Add(type))
 2138                failures.Add($"The installed {kind} type '{type}' is registered more than once.");
 139        }
 140
 64141        return result;
 142    }
 143
 144    private static void ValidateAllowedTypes(IEnumerable<string>? allowedTypes, IReadOnlySet<string> installedTypes, str
 145    {
 64146        var seen = new HashSet<string>(StringComparer.Ordinal);
 147
 290148        foreach (var type in allowedTypes ?? [])
 149        {
 81150            if (string.IsNullOrWhiteSpace(type))
 151            {
 0152                failures.Add($"Allowed {kind} types must not be empty.");
 0153                continue;
 154            }
 155
 81156            if (!seen.Add(type))
 1157                failures.Add($"The allowed {kind} type '{type}' is configured more than once.");
 158
 81159            if (!installedTypes.Contains(type))
 1160                failures.Add($"The allowed {kind} type '{type}' is not installed.");
 161        }
 64162    }
 163
 164    private static void ValidateClients(ExternalAuthenticationOptions options, ICollection<string> failures)
 165    {
 16166        var clientIds = new HashSet<string>(StringComparer.Ordinal);
 167
 38168        foreach (var client in options.Clients ?? [])
 169        {
 3170            if (string.IsNullOrWhiteSpace(client.ClientId))
 0171                failures.Add("Authentication client identifiers must not be empty.");
 3172            else if (!clientIds.Add(client.ClientId))
 0173                failures.Add($"The authentication client identifier '{client.ClientId}' is configured more than once.");
 174
 3175            var callbackUris = client.CallbackUris ?? new HashSet<Uri>();
 3176            var logoutCallbackUris = client.LogoutCallbackUris ?? new HashSet<Uri>();
 3177            var allowedOrigins = client.AllowedOrigins ?? new HashSet<string>();
 3178            var allowedReturnPathPrefixes = client.AllowedReturnPathPrefixes ?? new HashSet<string>();
 179
 3180            if (callbackUris.Count == 0)
 0181                failures.Add($"Authentication client '{client.ClientId}' must register at least one callback URI.");
 182
 3183            ValidateUris(client.ClientId, callbackUris, "callback", options.Redirects.AllowDevelopmentLoopbackCallbacks,
 3184            ValidateUris(client.ClientId, logoutCallbackUris, "logout callback", options.Redirects.AllowDevelopmentLoopb
 3185            ValidateOrigins(client.ClientId, allowedOrigins, options.Redirects.AllowDevelopmentLoopbackCallbacks, failur
 3186            ValidateReturnPathPrefixes(client.ClientId, allowedReturnPathPrefixes, failures);
 187
 3188            if (client.ClientType == AuthenticationClientType.Public)
 189            {
 3190                if (client.SecretBinding is not null)
 2191                    failures.Add($"Public authentication client '{client.ClientId}' must not define a client secret bind
 192
 3193                if (allowedOrigins.Count == 0)
 0194                    failures.Add($"Public authentication client '{client.ClientId}' must register at least one allowed o
 195
 3196                var originSet = allowedOrigins.ToHashSet(StringComparer.Ordinal);
 12197                foreach (var callbackUri in callbackUris)
 198                {
 3199                    if (callbackUri.IsAbsoluteUri && !string.IsNullOrWhiteSpace(callbackUri.Host) && !originSet.Contains
 2200                        failures.Add($"Public authentication client '{client.ClientId}' callback URI '{callbackUri}' doe
 201                }
 202            }
 0203            else if (client.SecretBinding is null)
 0204                failures.Add($"Confidential authentication client '{client.ClientId}' must define a client secret bindin
 205        }
 16206    }
 207
 208    private static void ValidateUris(string clientId, IEnumerable<Uri> uris, string registrationKind, bool allowDevelopm
 209    {
 6210        var registeredUris = new HashSet<string>(StringComparer.Ordinal);
 211
 20212        foreach (var uri in uris)
 213        {
 4214            if (!IsExactClientUri(uri, allowDevelopmentLoopback))
 215            {
 1216                failures.Add($"Authentication client '{clientId}' has an invalid {registrationKind} URI '{uri}'. Registr
 1217                continue;
 218            }
 219
 3220            if (!registeredUris.Add(uri.AbsoluteUri))
 0221                failures.Add($"Authentication client '{clientId}' registers {registrationKind} URI '{uri}' more than onc
 222        }
 6223    }
 224
 225    private static void ValidateOrigins(string clientId, IEnumerable<string> origins, bool allowDevelopmentLoopback, ICo
 226    {
 3227        var registeredOrigins = new HashSet<string>(StringComparer.Ordinal);
 228
 12229        foreach (var origin in origins)
 230        {
 3231            if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri) ||
 3232                !IsExactClientUri(uri, allowDevelopmentLoopback) ||
 3233                uri.AbsolutePath != "/" ||
 3234                uri.Query.Length != 0)
 235            {
 1236                failures.Add($"Authentication client '{clientId}' has invalid allowed origin '{origin}'. Origins must be
 1237                continue;
 238            }
 239
 2240            var normalizedOrigin = uri.GetLeftPart(UriPartial.Authority);
 2241            if (!string.Equals(origin, normalizedOrigin, StringComparison.Ordinal))
 1242                failures.Add($"Authentication client '{clientId}' allowed origin '{origin}' must not contain a path, que
 243
 2244            if (!registeredOrigins.Add(normalizedOrigin))
 0245                failures.Add($"Authentication client '{clientId}' registers allowed origin '{origin}' more than once.");
 246        }
 3247    }
 248
 249    private static void ValidateReturnPathPrefixes(string clientId, IReadOnlySet<string> prefixes, ICollection<string> f
 250    {
 3251        if (prefixes.Count == 0)
 252        {
 1253            failures.Add($"Authentication client '{clientId}' must register at least one allowed return-path prefix.");
 1254            return;
 255        }
 256
 2257        var registeredPrefixes = new HashSet<string>(StringComparer.Ordinal);
 8258        foreach (var prefix in prefixes)
 259        {
 2260            if (!ClientReturnPathValidator.TryValidateForClient(prefix, new HashSet<string>(StringComparer.Ordinal) { pr
 261            {
 0262                failures.Add($"Authentication client '{clientId}' has invalid allowed return-path prefix '{prefix}'.");
 0263                continue;
 264            }
 265
 2266            if (!registeredPrefixes.Add(prefix))
 0267                failures.Add($"Authentication client '{clientId}' registers allowed return-path prefix '{prefix}' more t
 268        }
 2269    }
 270
 271    private static bool IsExactClientUri(Uri uri, bool allowDevelopmentLoopback)
 272    {
 6273        if (!uri.IsAbsoluteUri || !string.IsNullOrEmpty(uri.Fragment) || !string.IsNullOrEmpty(uri.UserInfo) || uri.Host
 0274            return false;
 275
 6276        return string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) ||
 6277            allowDevelopmentLoopback && uri.IsLoopback && string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.
 278    }
 279
 280    private static void ValidateConfigurationConnections(
 281        ExternalAuthenticationOptions options,
 282        IReadOnlySet<string> installedAdapterTypes,
 283        IReadOnlySet<string> installedPolicyTypes,
 284        IReadOnlySet<string> installedGrantSourceTypes,
 285        ICollection<string> failures)
 286    {
 16287        var connectionKeys = new HashSet<string>(StringComparer.Ordinal);
 16288        var configuredConnectionIds = new HashSet<string>(StringComparer.Ordinal);
 16289        var hasPreferredConnection = false;
 290
 46291        foreach (var connection in options.ConfigurationConnections ?? [])
 292        {
 7293            var tenantId = string.IsNullOrWhiteSpace(connection.TenantId) ? ConnectionScope.HostTenantId : connection.Te
 7294            if (tenantId != ConnectionScope.HostTenantId)
 3295                failures.Add($"Configuration connection '{connection.Key}' must use the host scope in this version.");
 7296            var key = connection.Key?.Trim().ToLowerInvariant() ?? string.Empty;
 297
 7298            if (string.IsNullOrWhiteSpace(key))
 0299                failures.Add("Configuration connection keys must not be empty.");
 7300            else if (!connectionKeys.Add(key))
 1301                failures.Add($"Configuration connection key '{connection.Key}' is configured more than once.");
 302
 7303            if (!string.IsNullOrWhiteSpace(connection.Id) && !configuredConnectionIds.Add(connection.Id))
 0304                failures.Add($"Configuration connection ID '{connection.Id}' is configured more than once.");
 305
 7306            if (connection.IsPreferred && hasPreferredConnection)
 1307                failures.Add("Configuration connections define more than one preferred sign-in method.");
 7308            hasPreferredConnection |= connection.IsPreferred;
 309
 7310            if (string.IsNullOrWhiteSpace(connection.AdapterType) || !installedAdapterTypes.Contains(connection.AdapterT
 1311                failures.Add($"Configuration connection '{connection.Key}' selects adapter type '{connection.AdapterType
 6312            else if (options.AllowedAdapterTypes.Count > 0 && !options.AllowedAdapterTypes.Contains(connection.AdapterTy
 0313                failures.Add($"Configuration connection '{connection.Key}' selects adapter type '{connection.AdapterType
 314
 7315            if (connection.AdapterSettingsVersion <= 0)
 0316                failures.Add($"Configuration connection '{connection.Key}' must use a positive adapter settings version.
 317
 7318            if (string.IsNullOrWhiteSpace(connection.DisplayName))
 0319                failures.Add($"Configuration connection '{connection.Key}' must define a display name.");
 320
 7321            ValidatePolicy(connection, options, installedPolicyTypes, failures);
 7322            ValidateGrantSources(connection, options, installedGrantSourceTypes, failures);
 323        }
 324
 16325    }
 326
 327    private static void ValidatePolicy(IdentityProviderConnection connection, ExternalAuthenticationOptions options, IRe
 328    {
 7329        var policy = connection.UnlinkedPolicy;
 7330        if (policy is null)
 7331            return;
 332
 0333        if (policy.SettingsVersion <= 0)
 0334            failures.Add($"Configuration connection '{connection.Key}' must use a positive unlinked identity policy sett
 335
 0336        if (!installedPolicyTypes.Contains(policy.Type))
 0337            failures.Add($"Configuration connection '{connection.Key}' selects unlinked identity policy '{policy.Type}',
 0338        else if (options.AllowedUnlinkedIdentityPolicyTypes.Count > 0 && !options.AllowedUnlinkedIdentityPolicyTypes.Con
 0339            failures.Add($"Configuration connection '{connection.Key}' selects unlinked identity policy '{policy.Type}',
 0340    }
 341
 342    private static void ValidateGrantSources(IdentityProviderConnection connection, ExternalAuthenticationOptions option
 343    {
 7344        var orders = new HashSet<int>();
 14345        foreach (var grantSource in connection.PermissionGrantSources ?? [])
 346        {
 0347            if (!orders.Add(grantSource.Order))
 0348                failures.Add($"Configuration connection '{connection.Key}' configures more than one permission grant sou
 349
 0350            if (grantSource.SettingsVersion <= 0)
 0351                failures.Add($"Configuration connection '{connection.Key}' must use a positive permission grant source s
 352
 0353            if (!installedGrantSourceTypes.Contains(grantSource.Type))
 0354                failures.Add($"Configuration connection '{connection.Key}' selects permission grant source '{grantSource
 0355            else if (options.AllowedPermissionGrantSourceTypes.Count > 0 && !options.AllowedPermissionGrantSourceTypes.C
 0356                failures.Add($"Configuration connection '{connection.Key}' selects permission grant source '{grantSource
 357        }
 7358    }
 359}

Methods/Properties

.ctor(Microsoft.Extensions.Options.IOptions`1<Elsa.ExternalAuthentication.Options.ExternalAuthenticationExtensionOptions>)
Validate(System.String,Elsa.ExternalAuthentication.Options.ExternalAuthenticationOptions)
ValidateExternalCallbackBaseUri(Elsa.ExternalAuthentication.Options.RedirectValidationOptions,System.Collections.Generic.ICollection`1<System.String>)
ValidateHandleHashing(Elsa.ExternalAuthentication.Options.ExternalAuthenticationHandleHashingOptions,System.Collections.Generic.ICollection`1<System.String>)
ValidateRateLimits(Elsa.ExternalAuthentication.Options.ExternalAuthenticationRateLimitOptions,System.Collections.Generic.ICollection`1<System.String>)
ValidateProviderEgress(Elsa.ExternalAuthentication.Options.ProviderEgressOptions,System.Collections.Generic.ICollection`1<System.String>)
ValidateExtensionTypes(System.Collections.Generic.IEnumerable`1<System.String>,System.String,System.Collections.Generic.ICollection`1<System.String>)
ValidateAllowedTypes(System.Collections.Generic.IEnumerable`1<System.String>,System.Collections.Generic.IReadOnlySet`1<System.String>,System.String,System.Collections.Generic.ICollection`1<System.String>)
ValidateClients(Elsa.ExternalAuthentication.Options.ExternalAuthenticationOptions,System.Collections.Generic.ICollection`1<System.String>)
ValidateUris(System.String,System.Collections.Generic.IEnumerable`1<System.Uri>,System.String,System.Boolean,System.Collections.Generic.ICollection`1<System.String>)
ValidateOrigins(System.String,System.Collections.Generic.IEnumerable`1<System.String>,System.Boolean,System.Collections.Generic.ICollection`1<System.String>)
ValidateReturnPathPrefixes(System.String,System.Collections.Generic.IReadOnlySet`1<System.String>,System.Collections.Generic.ICollection`1<System.String>)
IsExactClientUri(System.Uri,System.Boolean)
ValidateConfigurationConnections(Elsa.ExternalAuthentication.Options.ExternalAuthenticationOptions,System.Collections.Generic.IReadOnlySet`1<System.String>,System.Collections.Generic.IReadOnlySet`1<System.String>,System.Collections.Generic.IReadOnlySet`1<System.String>,System.Collections.Generic.ICollection`1<System.String>)
ValidatePolicy(Elsa.ExternalAuthentication.Models.IdentityProviderConnection,Elsa.ExternalAuthentication.Options.ExternalAuthenticationOptions,System.Collections.Generic.IReadOnlySet`1<System.String>,System.Collections.Generic.ICollection`1<System.String>)
ValidateGrantSources(Elsa.ExternalAuthentication.Models.IdentityProviderConnection,Elsa.ExternalAuthentication.Options.ExternalAuthenticationOptions,System.Collections.Generic.IReadOnlySet`1<System.String>,System.Collections.Generic.ICollection`1<System.String>)