* 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>
238 lines
14 KiB
C#
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();
|
|
}
|
|
}
|