elsa-core/test/unit/Elsa.ExternalAuthentication.UnitTests/Foundational/ExternalAuthenticationOptionsTests.cs
Sipke Schoorstra 168a8c76f0
fix(auth): validate wildcard permission patterns and warn on deny-list stripping (#7997)
* fix(auth): validate wildcard permission patterns and warn on deny-list stripping

Permission.IsValidPattern rejects inert wildcard spellings (such as
"workflows*:delete") that parse but can never match. The grant boundary,
stored-permission, and external-authentication options validators reject them
at authoring time, and PermissionGrantValidator applies the same check to
incoming grants.

ExternalAuthenticationOptionsValidator now warns (never fails) when
DeniedPermissions is non-empty, because any non-empty deny list refuses every
wildcard grant that could reach a denied permission -- including the seeded
administrator role's "*". The validator takes an ILogger, and
AddExternalAuthenticationServices registers logging alongside its other
framework dependencies (TryAdd-based, so host logging configuration wins).
The operational consequence is recorded in the authorization-model migration
guide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(auth): report subtree grants whose verb nothing under them supports

'workflows/*:frobnicate' reached a non-empty subtree and was therefore
treated as resolved, so the startup audit stayed silent about a grant
that cannot authorize anything. Require at least one reached descriptor
to support a concrete verb; verb wildcards keep the reach-only check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 11:45:54 +02:00

238 lines
14 KiB
C#

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<string, string?>
{
["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<string, string?>
{
["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<Uri> { new("http://studio.example/callback") },
new HashSet<Uri>(),
new HashSet<string> { "https://studio.example/" },
new HashSet<string>(),
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<IExternalAuthenticationAdapter>? 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<ExternalAuthenticationOptionsValidator>.Instance);
}
private sealed class TestAdapter(string type) : IExternalAuthenticationAdapter
{
public string Type => type;
public ExternalAuthenticationAdapterDescriptor Describe() => throw new NotSupportedException();
public ValueTask<ConnectionValidationResult> ValidateAsync(ConnectionValidationContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
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 TestUnlinkedIdentityPolicy(string type) : IUnlinkedIdentityPolicy
{
public string Type => type;
public UnlinkedIdentityPolicyDescriptor Describe() => throw new NotSupportedException();
public ValueTask<UnlinkedIdentityDecision> 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<PermissionGrantResult> GetGrantsAsync(PermissionGrantContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
}