elsa-core/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Identity/JustInTimeProvisioningTests.cs
2026-07-25 23:16:32 +02:00

282 lines
14 KiB
C#

using System.Text.Json;
using Elsa.Common.Services;
using Elsa.Common;
using Elsa.ExternalAuthentication.Contracts;
using Elsa.ExternalAuthentication.Models;
using Elsa.ExternalAuthentication.Options;
using Elsa.ExternalAuthentication.Policies;
using Elsa.ExternalAuthentication.Services;
using Elsa.Identity.Contracts;
using Elsa.Identity.Entities;
using Elsa.Identity.Services;
using Elsa.Identity.Providers;
using Elsa.Workflows;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.ExternalAuthentication.IntegrationTests.Identity;
public class JustInTimeProvisioningTests
{
[Fact]
public async Task ExistingLinkResolvesWithoutApplyingTheUnlinkedPolicy()
{
var provisioner = new AtomicProvisioner();
provisioner.Seed("tenant-a", "tenant-a", "contoso", "https://issuer.example", "subject-a", "user-a");
var resolver = CreateResolver(provisioner, new RejectUnlinkedIdentityPolicy());
var resolution = await resolver.ResolveAsync(CreateContext());
Assert.Equal("user-a", resolution.UserId);
Assert.False(resolution.WasProvisioned);
}
[Fact]
public async Task RejectPolicyDeniesAnUnknownIdentityWithoutUsingMutableClaims()
{
var resolver = CreateResolver(new AtomicProvisioner(), new RejectUnlinkedIdentityPolicy());
var context = CreateContext("reject", new Dictionary<string, IReadOnlyCollection<string>> { ["email"] = ["person@example.test"] });
var exception = await Assert.ThrowsAsync<ExternalIdentityUnlinkedException>(() => resolver.ResolveAsync(context).AsTask());
Assert.Equal("identity_unlinked", exception.SafeReason);
}
[Fact]
public async Task ConcurrentJitRequestsConvergeOnOneCredentiallessTenantUserAndLink()
{
var provisioner = new AtomicProvisioner();
var resolver = CreateResolver(provisioner, new CreateUserUnlinkedIdentityPolicy());
var context = CreateContext();
var results = await Task.WhenAll(Enumerable.Range(0, 16).Select(_ => resolver.ResolveAsync(context).AsTask()));
Assert.Equal("user-1", Assert.Single(results.Select(x => x.UserId).Distinct()));
Assert.Single(results, x => x.WasProvisioned);
var user = Assert.Single(provisioner.Users);
Assert.Equal("tenant-a", user.TenantId);
Assert.Null(user.HashedPassword);
Assert.Null(user.HashedPasswordSalt);
var validator = new DefaultUserCredentialsValidator(new StaticUserProvider(user), new DefaultSecretHasher());
Assert.Null(await validator.ValidateAsync(user.Name, "any-password"));
}
[Fact]
public async Task ResolverRejectsALinkForAnotherTenant()
{
var provisioner = new AtomicProvisioner();
provisioner.Seed("tenant-a", "tenant-b", "contoso", "https://issuer.example", "subject-a", "user-b");
var resolver = CreateResolver(provisioner, new RejectUnlinkedIdentityPolicy());
await Assert.ThrowsAsync<InvalidOperationException>(() => resolver.ResolveAsync(CreateContext()).AsTask());
}
[Fact]
public async Task InMemoryProvisionerCreatesALinkForAnExistingUserOnlyInTheTargetTenant()
{
var userStore = new MemoryUserStore(new MemoryStore<User>());
var user = new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" };
await userStore.SaveAsync(user);
var provisioner = CreateInMemoryProvisioner(userStore);
var request = CreateProvisioningRequest(existingUserId: user.Id);
var result = await provisioner.CreateLinkOrGetExistingAsync(request);
var replay = await provisioner.CreateLinkOrGetExistingAsync(request);
Assert.Equal(user.Id, result.UserId);
Assert.False(result.WasCreated);
Assert.Equal(result.Link.Id, replay.Link.Id);
Assert.False(replay.WasCreated);
}
[Fact]
public async Task InMemoryProvisionerRejectsLinkingAUserFromAnotherTenant()
{
var userStore = new MemoryUserStore(new MemoryStore<User>());
await userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-b" });
var provisioner = CreateInMemoryProvisioner(userStore);
await Assert.ThrowsAsync<InvalidOperationException>(() => provisioner.CreateLinkOrGetExistingAsync(CreateProvisioningRequest(existingUserId: "user-b")).AsTask());
}
[Fact]
public async Task InMemoryProvisionerRetriesAGeneratedUserNameCollision()
{
var userStore = new MemoryUserStore(new MemoryStore<User>());
await userStore.SaveAsync(new User { Id = "existing", Name = "external-collision", TenantId = "tenant-a" });
var provisioner = CreateInMemoryProvisioner(userStore, new SequenceIdentityGenerator("collision", "available", "user-1", "link-1"));
var result = await provisioner.CreateLinkOrGetExistingAsync(CreateProvisioningRequest());
var user = await userStore.FindAsync(new Elsa.Identity.Models.UserFilter { Id = result.UserId });
Assert.True(result.WasCreated);
Assert.NotNull(user);
Assert.Equal("external-available", user.Name);
Assert.Null(user.HashedPassword);
Assert.Null(user.HashedPasswordSalt);
}
[Fact]
public async Task InMemoryProvisionerAssignsConfiguredDefaultRolesToNewUser()
{
var userStore = new MemoryUserStore(new MemoryStore<User>());
var roleStore = new MemoryRoleStore(new MemoryStore<Role>());
await roleStore.AddAsync(new Role { Id = "admin", Name = "Administrator", TenantId = "tenant-a", Permissions = ["*"] });
var provisioner = CreateInMemoryProvisioner(userStore, roleProvider: new StoreBasedRoleProvider(roleStore));
var result = await provisioner.CreateLinkOrGetExistingAsync(CreateProvisioningRequest(defaultRoleIds: ["admin"]));
var user = await userStore.FindAsync(new Elsa.Identity.Models.UserFilter { Id = result.UserId });
Assert.NotNull(user);
Assert.Equal(["admin"], user.Roles);
}
[Fact]
public async Task InMemoryProvisionerTreatsEachExternalIdentityTupleAsDistinct()
{
var userStore = new MemoryUserStore(new MemoryStore<User>());
var provisioner = CreateInMemoryProvisioner(userStore);
var first = await provisioner.CreateLinkOrGetExistingAsync(CreateProvisioningRequest());
var differentIssuer = await provisioner.CreateLinkOrGetExistingAsync(CreateProvisioningRequest(issuer: "https://issuer-two.example"));
var differentConnection = await provisioner.CreateLinkOrGetExistingAsync(CreateProvisioningRequest(connectionKey: "fabrikam"));
Assert.Equal(3, new[] { first.Link.Id, differentIssuer.Link.Id, differentConnection.Link.Id }.Distinct().Count());
Assert.Equal(3, new[] { first.UserId, differentIssuer.UserId, differentConnection.UserId }.Distinct().Count());
}
[Fact]
public async Task InMemoryProvisionerSharesTupleStateAcrossDependencyInjectionScopes()
{
var services = new ServiceCollection();
services.AddSingleton<MemoryStore<User>>();
services.AddSingleton<IIdentityGenerator>(new Elsa.Workflows.GuidIdentityGenerator());
services.AddSingleton<ISystemClock>(new FixedSystemClock(new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero)));
services.AddSingleton<IExternalAuthenticationHandleHasher, HmacExternalAuthenticationHandleHasher>();
services.AddSingleton<InMemoryExternalIdentityProvisionerState>();
services.AddScoped<IUserStore, MemoryUserStore>();
services.AddScoped<IUserProvider, StoreBasedUserProvider>();
services.AddSingleton<IRoleProvider>(NSubstitute.Substitute.For<IRoleProvider>());
services.AddScoped<IExternalIdentityProvisioner, InMemoryExternalIdentityProvisioner>();
await using var provider = services.BuildServiceProvider();
var request = CreateProvisioningRequest();
ProvisioningResult created;
await using (var firstScope = provider.CreateAsyncScope())
created = await firstScope.ServiceProvider.GetRequiredService<IExternalIdentityProvisioner>().CreateLinkOrGetExistingAsync(request);
await using var secondScope = provider.CreateAsyncScope();
var provisioner = secondScope.ServiceProvider.GetRequiredService<IExternalIdentityProvisioner>();
var converged = await provisioner.CreateLinkOrGetExistingAsync(request);
var resolved = await provisioner.FindLinkAsync(request.TenantId, request.ConnectionKey, request.Identity);
Assert.Equal(created.Link.Id, converged.Link.Id);
Assert.False(converged.WasCreated);
Assert.Equal(created.Link.Id, resolved?.Id);
Assert.Equal(new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero), created.Link.CreatedAt);
}
private static DefaultExternalIdentityResolver CreateResolver(IExternalIdentityProvisioner provisioner, IUnlinkedIdentityPolicy policy) => new(
provisioner,
[policy],
Microsoft.Extensions.Options.Options.Create(new ExternalAuthenticationOptions()));
private static InMemoryExternalIdentityProvisioner CreateInMemoryProvisioner(
IUserStore userStore,
IIdentityGenerator? identityGenerator = null,
IRoleProvider? roleProvider = null) => new(
userStore,
new StoreBasedUserProvider(userStore),
roleProvider ?? NSubstitute.Substitute.For<IRoleProvider>(),
identityGenerator ?? new Elsa.Workflows.GuidIdentityGenerator(),
new Elsa.Common.Services.SystemClock(),
new HmacExternalAuthenticationHandleHasher(),
new InMemoryExternalIdentityProvisionerState());
private static ProvisioningRequest CreateProvisioningRequest(
string? existingUserId = null,
string connectionKey = "contoso",
string issuer = "https://issuer.example",
IReadOnlyCollection<string>? defaultRoleIds = null) => new(
"tenant-a",
connectionKey,
new ExternalIdentity(issuer, "subject-a", new Dictionary<string, IReadOnlyCollection<string>>()),
existingUserId is null ? new UserCreationProposal("external", DefaultRoleIds: defaultRoleIds) : null,
existingUserId);
private static ExternalIdentityResolutionContext CreateContext(string policyType = "create-user", IReadOnlyDictionary<string, IReadOnlyCollection<string>>? claims = null)
{
var connection = new IdentityProviderConnection
{
Id = "connection-a",
TenantId = "tenant-a",
Key = "contoso",
AdapterType = "oidc",
AdapterSettingsVersion = 1,
DisplayName = "Contoso",
IsEnabled = true,
UnlinkedPolicy = new PolicySelection(policyType, 1, JsonSerializer.SerializeToElement(new { }))
};
var effectiveConnection = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Configuration, new ConnectionScope(ConnectionScopeKind.Tenant, "tenant-a"), ConnectionValidity.Valid, false, "test");
var identity = new ExternalIdentity("https://issuer.example", "subject-a", claims ?? new Dictionary<string, IReadOnlyCollection<string>>());
return new ExternalIdentityResolutionContext("tenant-a", effectiveConnection, identity, identity.Claims);
}
private sealed class AtomicProvisioner : IExternalIdentityProvisioner
{
private readonly object _syncRoot = new();
private readonly Dictionary<(string TenantId, string ConnectionKey, string Issuer, string Subject), ProvisioningResult> _links = new();
private readonly List<User> _users = [];
public IReadOnlyCollection<User> Users => _users;
public ValueTask<ExternalIdentityLink?> FindLinkAsync(string tenantId, string connectionKey, ExternalIdentity identity, CancellationToken cancellationToken = default)
{
lock (_syncRoot)
return ValueTask.FromResult(_links.GetValueOrDefault((tenantId, connectionKey, identity.Issuer, identity.Subject))?.Link);
}
public ValueTask<ProvisioningResult> CreateLinkOrGetExistingAsync(ProvisioningRequest request, CancellationToken cancellationToken = default)
{
lock (_syncRoot)
{
var key = (request.TenantId, request.ConnectionKey, request.Identity.Issuer, request.Identity.Subject);
if (_links.TryGetValue(key, out var existing))
return ValueTask.FromResult(existing with { WasCreated = false });
var user = new User { Id = $"user-{_users.Count + 1}", Name = $"external-{_users.Count + 1}", TenantId = request.TenantId };
_users.Add(user);
var link = new ExternalIdentityLink($"link-{_users.Count}", request.TenantId, request.ConnectionKey, request.Identity.Issuer, "subject-hash", null, user.Id, DateTimeOffset.UtcNow, null);
var result = new ProvisioningResult(user.Id, link, true, true);
_links[key] = result;
return ValueTask.FromResult(result);
}
}
public void Seed(string lookupTenantId, string linkTenantId, string connectionKey, string issuer, string subject, string userId)
{
lock (_syncRoot)
{
var link = new ExternalIdentityLink("link-seeded", linkTenantId, connectionKey, issuer, "subject-hash", null, userId, DateTimeOffset.UtcNow, null);
_links[(lookupTenantId, connectionKey, issuer, subject)] = new ProvisioningResult(userId, link, false);
}
}
}
private sealed class StaticUserProvider(User user) : IUserProvider
{
public Task<User?> FindAsync(Elsa.Identity.Models.UserFilter filter, CancellationToken cancellationToken = default) => Task.FromResult<User?>(filter.Name == user.Name ? user : null);
}
private sealed class SequenceIdentityGenerator(params string[] ids) : IIdentityGenerator
{
private readonly Queue<string> _ids = new(ids);
public string GenerateId() => _ids.Dequeue();
}
private sealed class FixedSystemClock(DateTimeOffset utcNow) : ISystemClock
{
public DateTimeOffset UtcNow { get; } = utcNow;
}
}