using Elsa.Testing.Shared.Multitenancy; 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> { ["email"] = ["person@example.test"] }); var exception = await Assert.ThrowsAsync(() => 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(() => resolver.ResolveAsync(CreateContext()).AsTask()); } [Fact] public async Task InMemoryProvisionerCreatesALinkForAnExistingUserOnlyInTheTargetTenant() { var userStore = new MemoryUserStore(new MemoryStore(), new TestTenantAccessor("tenant-a")); 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(), new TestTenantAccessor("tenant-b")); await userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-b" }); var provisioner = CreateInMemoryProvisioner(userStore); await Assert.ThrowsAsync(() => provisioner.CreateLinkOrGetExistingAsync(CreateProvisioningRequest(existingUserId: "user-b")).AsTask()); } [Fact] public async Task InMemoryProvisionerRetriesAGeneratedUserNameCollision() { var userStore = new MemoryUserStore(new MemoryStore(), new TestTenantAccessor("tenant-a")); 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(), new TestTenantAccessor("tenant-a")); var roleStore = new MemoryRoleStore(new MemoryStore(), new TestTenantAccessor("tenant-a")); 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(), new TestTenantAccessor("tenant-a")); 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>(); services.AddSingleton(new Elsa.Workflows.GuidIdentityGenerator()); services.AddSingleton(new FixedSystemClock(new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero))); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(new TestTenantAccessor("tenant-a")); services.AddScoped(); services.AddScoped(); services.AddSingleton(NSubstitute.Substitute.For()); services.AddScoped(); await using var provider = services.BuildServiceProvider(); var request = CreateProvisioningRequest(); ProvisioningResult created; await using (var firstScope = provider.CreateAsyncScope()) created = await firstScope.ServiceProvider.GetRequiredService().CreateLinkOrGetExistingAsync(request); await using var secondScope = provider.CreateAsyncScope(); var provisioner = secondScope.ServiceProvider.GetRequiredService(); 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(), 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? defaultRoleIds = null) => new( "tenant-a", connectionKey, new ExternalIdentity(issuer, "subject-a", new Dictionary>()), existingUserId is null ? new UserCreationProposal("external", DefaultRoleIds: defaultRoleIds) : null, existingUserId); private static ExternalIdentityResolutionContext CreateContext(string policyType = "create-user", IReadOnlyDictionary>? 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>()); 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 _users = []; public IReadOnlyCollection Users => _users; public ValueTask 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 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 FindAsync(Elsa.Identity.Models.UserFilter filter, CancellationToken cancellationToken = default) => Task.FromResult(filter.Name == user.Name ? user : null); } private sealed class SequenceIdentityGenerator(params string[] ids) : IIdentityGenerator { private readonly Queue _ids = new(ids); public string GenerateId() => _ids.Dequeue(); } private sealed class FixedSystemClock(DateTimeOffset utcNow) : ISystemClock { public DateTimeOffset UtcNow { get; } = utcNow; } }