* fix(external-authentication): scope role-deletion impact to the role's tenant ExternalAuthenticationRoleDeletionDependencyContributor scanned every stored connection with an empty ConnectionFilter and every configured connection regardless of its tenant, so a role ID that exists in two tenants could report another tenant's references as its own impact -- and a configuration entry owned by another tenant could block a role deletion outright. Remediation had the same reach: it loaded a dependency's connection by the caller-supplied owner ID without checking which tenant owned it. Impact, prevalidation and remediation now only see connections in the role's tenant context, which is the tenant active on ITenantAccessor while the role-deletion coordinator runs. Host-scoped connections stay in scope for every tenant, because the connection registry resolves the host scope for every signing-in tenant and the provisioner resolves a connection's default role IDs in the signing-in user's tenant, so a host connection naming a role ID really does reference that tenant's role. Configuration entries that leave the tenant blank are host-scoped for the same reason the configuration source materializes them there. A connection carrying another tenant's ID is out of scope in both directions, and a connection loaded for remediation that is not in the role's tenant is treated as absent, which fails the request rather than mutating it. The stored connections are fetched per applicable scope so another tenant's rows are never materialized, and both connection stores already honor ConnectionFilter.Scope; the durable store now has a test pinning that, since the tenant boundary rests on it. Refs #8013 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): scan every tenant when deleting a tenant-agnostic role Role stores expose tenant-agnostic roles (TenantId == "*") from every tenant, but the role-deletion contributor derived its dependency scan boundary from the ambient tenant only, so deleting an agnostic role while tenant A was active left references from other tenants dangling. Resolve the role being deleted once per operation, through the active role store, and scan every connection and configuration entry regardless of tenant when it is agnostic. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(external-authentication): share the active role store lookup Extract the duplicated "active role store is the last registration" resolution into a single ActiveRoleStore accessor and rename ToScope to ToConnectionScope for clarity. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): read one connection snapshot and prefer the agnostic role Reading the host and tenant scopes as two separate store queries let a connection whose TenantId changed mid-flight fall between the reads and escape both, letting role deletion proceed while a reference remained. FindConnectionsInRoleTenantScopeAsync now reads one snapshot and filters it in memory. IsAgnosticRoleAsync resolved a role by an unqualified ID lookup, which could return the ambient tenant's role instead of an agnostic role sharing its ID, silently narrowing impact scanning and leaving JIT-policy references in other tenants dangling; it now checks every role sharing the ID and gives the agnostic scope deterministic precedence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(external-authentication): correct the scope-filter test comment Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): fail closed when a role ID resolves to more than one role A same-ID collision between a tenant-scoped role and an agnostic role can only occur in MemoryRoleStore (durable persistence keys roles by ID alone). In that case the coordinator's own deletion target is already ambiguous, so widening or narrowing the scope by guessing is wrong in either direction; throw instead of picking a side. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): scope role-deletion impact by the resolved role's tenant Replace the isAgnosticRole flag with ResolveRoleTenantIdAsync, which returns the resolved role's own TenantId and falls back to the ambient tenant only when the role cannot be resolved. With multitenancy disabled the EF role store installs no tenant query filter and can resolve a tenant-owned role by ID regardless of the ambient tenant, so scoping by the ambient tenant alone left that role's connection references out of scan while the coordinator deleted it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): require an agnostic replacement when remediating an agnostic role Authorization for a replacement role still resolves through the ambient tenant's role services, so a deletion initiated in tenant A could authorize a tenant-A-only replacement and then write it into tenant B's connection policy, where that role does not exist. When the deletion target is agnostic, require the replacement role to be agnostic too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): require agnostic replacements for host connections and reject ambiguous ones Extend the agnostic-replacement requirement to host-scoped connections, since a host connection is served to every signing-in tenant and a tenant-scoped replacement would resolve in the authorizing tenant but fail to resolve in every other tenant it serves. Recheck the replacement at removal time through the same agnostic-role resolution used at validation, instead of trusting whichever same-ID role a plain FindAsync happens to return, so a replacement collision introduced between validation and mutation is rejected. Resolve IsAgnosticRoleAsync's candidate directly and return true only when exactly one matching role is agnostic, so an ambiguous replacement ID is reported as replacement_role_unavailable_or_unauthorized instead of escaping as an exception. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): keep host-connection replacements allowed for default-tenant roles Revert the host-scope replacement guard added for host-scoped connections. IdentityProviderConnectionManagementService forces every managed connection to host scope, and in a deployment without multitenancy roles are created scoped to the default tenant rather than agnostic, so requiring an agnostic replacement for host-scoped connections would make every replacement remediation impossible in the default deployment. The replacement guard applies only when the deletion target itself is agnostic, as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
970 lines
56 KiB
C#
970 lines
56 KiB
C#
using Elsa.Testing.Shared.Multitenancy;
|
|
using System.Data.Common;
|
|
using System.Text.Json;
|
|
using Elsa.Common;
|
|
using Elsa.Common.Services;
|
|
using Elsa.ExternalAuthentication.Contracts;
|
|
using Elsa.ExternalAuthentication.Models;
|
|
using Elsa.ExternalAuthentication.Persistence.EFCore;
|
|
using Elsa.ExternalAuthentication.Persistence.EFCore.Stores;
|
|
using Elsa.ExternalAuthentication.Services;
|
|
using Elsa.Identity.Contracts;
|
|
using Elsa.Identity.Entities;
|
|
using Elsa.Identity.Models;
|
|
using Elsa.Identity.Providers;
|
|
using Elsa.Identity.Services;
|
|
using Elsa.Persistence.EFCore;
|
|
using Elsa.Workflows;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
|
|
namespace Elsa.ExternalAuthentication.IntegrationTests.Persistence;
|
|
|
|
public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
|
|
{
|
|
private SqliteConnection _connection = null!;
|
|
private ServiceProvider _services = null!;
|
|
private TestDbContextFactory _dbContextFactory = null!;
|
|
private ExternalAuthenticationDbContextLeaseFactory _leaseFactory = null!;
|
|
private ISystemClock _clock = null!;
|
|
private MemoryUserStore _userStore = null!;
|
|
private StoreBasedUserProvider _userProvider = null!;
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
_connection = new SqliteConnection("Data Source=:memory:");
|
|
await _connection.OpenAsync();
|
|
_clock = new SystemClock();
|
|
var optionsBuilder = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>();
|
|
optionsBuilder.UseElsaDbContextOptions(null);
|
|
optionsBuilder.UseSqlite(_connection, sqlite => sqlite.MigrationsAssembly(typeof(Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.ExternalAuthenticationDbContextFactory).Assembly.FullName));
|
|
var options = optionsBuilder.Options;
|
|
_services = new ServiceCollection()
|
|
.AddSingleton<IDbContextFactory<ExternalAuthenticationElsaDbContext>>(serviceProvider => new TestDbContextFactory(options, serviceProvider))
|
|
.BuildServiceProvider();
|
|
_dbContextFactory = _services.GetRequiredService<IDbContextFactory<ExternalAuthenticationElsaDbContext>>() as TestDbContextFactory ?? throw new InvalidOperationException();
|
|
_leaseFactory = new ExternalAuthenticationDbContextLeaseFactory(_services.GetRequiredService<IServiceScopeFactory>());
|
|
_userStore = new MemoryUserStore(new MemoryStore<User>(), new TestTenantAccessor("tenant-a"));
|
|
_userProvider = new StoreBasedUserProvider(_userStore);
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
await dbContext.Database.EnsureCreatedAsync();
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
await _services.DisposeAsync();
|
|
await _connection.DisposeAsync();
|
|
}
|
|
|
|
private EFCoreExternalIdentityProvisioner CreateProvisioner(
|
|
IExternalAuthenticationHandleHasher hasher,
|
|
IDbContextFactory<ExternalAuthenticationElsaDbContext>? dbContextFactory = null,
|
|
IUserStore? userStore = null,
|
|
IUserProvider? userProvider = null) =>
|
|
new(dbContextFactory ?? _dbContextFactory,
|
|
userStore ?? _userStore,
|
|
userProvider ?? new StoreBasedUserProvider(userStore ?? _userStore),
|
|
Substitute.For<IRoleProvider>(),
|
|
hasher,
|
|
new GuidIdentityGenerator(),
|
|
_clock,
|
|
NullLogger<EFCoreExternalIdentityProvisioner>.Instance);
|
|
|
|
[Fact]
|
|
public async Task PersistsEveryDurableExternalAuthenticationAggregateWithTheRequiredIndexes()
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
var model = dbContext.Model;
|
|
|
|
Assert.Contains(dbContext.Database.GetMigrations(), x => x.EndsWith("_Initial", StringComparison.Ordinal));
|
|
|
|
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedIdentityProviderConnection));
|
|
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedExternalIdentityLink));
|
|
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedBrokerTransaction));
|
|
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedAuthorizationGrant));
|
|
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedExternalAuthenticationSession));
|
|
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedExternalAuthenticationRefreshToken));
|
|
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedConnectionObservation));
|
|
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedPreviewResult));
|
|
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(ExternalAuthenticationRegistryVersion));
|
|
|
|
var connection = model.FindEntityType(typeof(PersistedIdentityProviderConnection))!;
|
|
Assert.True(connection.FindProperty(nameof(PersistedIdentityProviderConnection.Revision))!.IsConcurrencyToken);
|
|
Assert.Contains(connection.GetIndexes(), x => x.IsUnique && x.Properties.Select(p => p.Name).SequenceEqual([nameof(PersistedIdentityProviderConnection.TenantId), nameof(PersistedIdentityProviderConnection.Key)]));
|
|
var link = model.FindEntityType(typeof(PersistedExternalIdentityLink))!;
|
|
Assert.Contains(link.GetIndexes(), x => x.IsUnique && x.Properties.Select(p => p.Name).SequenceEqual([nameof(PersistedExternalIdentityLink.TenantId), nameof(PersistedExternalIdentityLink.ConnectionKey), nameof(PersistedExternalIdentityLink.Issuer), nameof(PersistedExternalIdentityLink.SubjectHash)]));
|
|
var refreshToken = model.FindEntityType(typeof(PersistedExternalAuthenticationRefreshToken))!;
|
|
Assert.Contains(refreshToken.GetIndexes(), x => x.IsUnique && x.Properties.Select(p => p.Name).SequenceEqual([nameof(PersistedExternalAuthenticationRefreshToken.Hash)]));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SqliteInitialMigrationCreatesTheOptionalRefreshTokenTable()
|
|
{
|
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
|
await connection.OpenAsync();
|
|
var optionsBuilder = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>();
|
|
optionsBuilder.UseElsaDbContextOptions(null);
|
|
optionsBuilder.UseSqlite(connection, sqlite => sqlite.MigrationsAssembly(typeof(Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.ExternalAuthenticationDbContextFactory).Assembly.FullName));
|
|
var options = optionsBuilder.Options;
|
|
await using var services = new ServiceCollection().BuildServiceProvider();
|
|
await using var dbContext = new ExternalAuthenticationElsaDbContext(options, services);
|
|
|
|
await dbContext.Database.MigrateAsync();
|
|
|
|
Assert.Single(await dbContext.Database.GetAppliedMigrationsAsync());
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'ExternalAuthenticationSessionRefreshTokens'";
|
|
Assert.Equal(1L, (long)(await command.ExecuteScalarAsync())!);
|
|
command.CommandText = "SELECT COUNT(*) FROM pragma_table_info('ExternalAuthenticationSessions') WHERE name = 'CurrentRefreshTokenHash'";
|
|
Assert.Equal(0L, (long)(await command.ExecuteScalarAsync())!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExternalAuthenticationModelDoesNotReachIntoTheIdentityAggregate()
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
var model = dbContext.Model;
|
|
|
|
// External authentication owns its own database context, so the identity aggregate must not leak into it.
|
|
// ExternalIdentityLink.UserId is resolved through IUserProvider/IUserStore instead of a foreign key.
|
|
Assert.DoesNotContain(model.GetEntityTypes(), x => x.ClrType == typeof(User));
|
|
Assert.Empty(model.FindEntityType(typeof(PersistedExternalIdentityLink))!.GetForeignKeys());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConnectionStoreEnforcesUniqueScopeKeysAndOptimisticConcurrency()
|
|
{
|
|
var store = new EFCoreIdentityProviderConnectionStore(_leaseFactory);
|
|
var created = Assert.IsType<ConnectionMutationResult.Created>(await store.CreateAsync(CreateConnection()));
|
|
Assert.Equal(1, created.Connection.Revision);
|
|
Assert.IsType<ConnectionMutationResult.DuplicateKey>(await store.CreateAsync(CreateConnection("connection-b")));
|
|
|
|
created.Connection.DisplayName = "Updated";
|
|
var updated = Assert.IsType<ConnectionMutationResult.Updated>(await store.UpdateAsync(created.Connection, 1));
|
|
Assert.Equal(2, updated.Connection.Revision);
|
|
Assert.Equal("Updated", updated.Connection.DisplayName);
|
|
Assert.Equal(2, Assert.IsType<ConnectionMutationResult.RevisionConflict>(await store.UpdateAsync(created.Connection, 1)).CurrentRevision);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConnectionStoreReturnsOnlyTheRequestedScope()
|
|
{
|
|
var store = new EFCoreIdentityProviderConnectionStore(_leaseFactory);
|
|
Assert.IsType<ConnectionMutationResult.Created>(await store.CreateAsync(CreateConnection()));
|
|
Assert.IsType<ConnectionMutationResult.Created>(await store.CreateAsync(CreateConnection("connection-b", "tenant-b")));
|
|
Assert.IsType<ConnectionMutationResult.Created>(await store.CreateAsync(CreateConnection("connection-host", ConnectionScope.HostTenantId)));
|
|
|
|
var tenantScoped = await store.FindAsync(new() { Scope = new(ConnectionScopeKind.Tenant, "tenant-a") });
|
|
var hostScoped = await store.FindAsync(new() { Scope = ConnectionScope.Host });
|
|
var unscoped = await store.FindAsync(new());
|
|
|
|
// The store honors ConnectionFilter.Scope, so callers that query by scope get only that scope's rows;
|
|
// a store that accepted and ignored the filter would silently widen their reach.
|
|
Assert.Equal(["connection-a"], tenantScoped.Items.Select(x => x.Id).ToArray());
|
|
Assert.Equal(["connection-host"], hostScoped.Items.Select(x => x.Id).ToArray());
|
|
Assert.Equal(3, unscoped.Items.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DurableStateGrantSessionAndRegistryVersionOperationsAreSingleUseOrCompareAndSwap()
|
|
{
|
|
var durableDbContexts = _leaseFactory;
|
|
var stateStore = new EFCoreExternalAuthenticationStateStore(durableDbContexts, _clock);
|
|
var transaction = new BrokerTransaction { HandleHash = "state", Purpose = BrokerTransactionPurpose.ExternalSignIn, ClientId = "studio", CallbackUri = new Uri("https://studio.example/callback"), ReturnPath = "/", TenantId = "tenant-a", PkceChallenge = "challenge", ExpiresAt = _clock.UtcNow.AddMinutes(1) };
|
|
await stateStore.PutAsync("ExternalSignIn", "state", transaction, transaction.ExpiresAt);
|
|
Assert.IsType<TakeResult<BrokerTransaction>.Taken>(await stateStore.TryTakeAsync<BrokerTransaction>("ExternalSignIn", "state"));
|
|
Assert.IsType<TakeResult<BrokerTransaction>.AlreadyConsumed>(await stateStore.TryTakeAsync<BrokerTransaction>("ExternalSignIn", "state"));
|
|
|
|
var grantStore = new EFCoreAuthorizationGrantStore(durableDbContexts, _clock);
|
|
await grantStore.SaveAsync(new AuthorizationGrant { CodeHash = "code", ClientId = "studio", CallbackUri = new Uri("https://studio.example/callback"), TenantId = "tenant-a", UserId = "user-a", PkceChallenge = "challenge", ExpiresAt = _clock.UtcNow.AddMinutes(1) });
|
|
Assert.IsType<TakeResult<AuthorizationGrant>.Taken>(await grantStore.TryTakeAsync("code"));
|
|
Assert.IsType<TakeResult<AuthorizationGrant>.AlreadyConsumed>(await grantStore.TryTakeAsync("code"));
|
|
|
|
var sessionStore = new EFCoreExternalAuthenticationSessionStore(durableDbContexts, _clock);
|
|
await sessionStore.SaveAsync(CreateSession());
|
|
Assert.IsType<ExternalAuthenticationSessionRotationResult.Rotated>(await sessionStore.TryRotateRefreshTokenAsync("session-a", "refresh-a", 0, "refresh-b", _clock.UtcNow));
|
|
Assert.Null(await sessionStore.FindByRefreshTokenHashAsync("refresh-a"));
|
|
Assert.Equal("session-a", (await sessionStore.FindByRefreshTokenHashAsync("refresh-b"))!.Id);
|
|
Assert.IsType<ExternalAuthenticationSessionRotationResult.Reused>(await sessionStore.TryRotateRefreshTokenAsync("session-a", "refresh-a", 0, "refresh-c", _clock.UtcNow));
|
|
|
|
var firstNode = new EFCoreConnectionRegistryVersionStore(durableDbContexts);
|
|
var secondNode = new EFCoreConnectionRegistryVersionStore(durableDbContexts);
|
|
Assert.Equal(1, await firstNode.GetVersionAsync());
|
|
var version = await firstNode.AdvanceAsync();
|
|
Assert.True(await secondNode.IsCurrentAsync(version));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DurableStateStoreRoundTripsRelativePreviewCallbackUris()
|
|
{
|
|
var stateStore = new EFCoreExternalAuthenticationStateStore(_leaseFactory, _clock);
|
|
var transaction = new BrokerTransaction
|
|
{
|
|
HandleHash = "preview-state",
|
|
Purpose = BrokerTransactionPurpose.Preview,
|
|
ClientId = "administrator",
|
|
CallbackUri = new Uri("/external-authentication/previews/preview-handle/authorize", UriKind.Relative),
|
|
ReturnPath = "/",
|
|
TenantId = "tenant-a",
|
|
ConnectionId = "connection-a",
|
|
ConnectionMaterialRevision = "revision-a",
|
|
PkceChallenge = string.Empty,
|
|
ExpiresAt = _clock.UtcNow.AddMinutes(1)
|
|
};
|
|
|
|
await stateStore.PutAsync("PreviewStart", transaction.HandleHash, transaction, transaction.ExpiresAt);
|
|
var stored = Assert.IsType<TakeResult<BrokerTransaction>.Taken>(
|
|
await stateStore.TryTakeAsync<BrokerTransaction>("PreviewStart", transaction.HandleHash));
|
|
|
|
Assert.False(stored.Value.CallbackUri.IsAbsoluteUri);
|
|
Assert.Equal(transaction.CallbackUri, stored.Value.CallbackUri);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DurableSingleUseStoresRejectExpiredEntriesInTheAtomicConsumePredicate()
|
|
{
|
|
var durableDbContexts = _leaseFactory;
|
|
var beforeExpiry = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
var afterExpiry = beforeExpiry.AddMinutes(2);
|
|
var expiresAt = beforeExpiry.AddMinutes(1);
|
|
|
|
var stateStore = new EFCoreExternalAuthenticationStateStore(durableDbContexts, new SteppingSystemClock(afterExpiry));
|
|
await stateStore.PutAsync("state", "state", new BrokerTransaction { HandleHash = "state", Purpose = BrokerTransactionPurpose.ExternalSignIn, ClientId = "studio", CallbackUri = new Uri("https://studio.example/callback"), ReturnPath = "/", TenantId = "tenant-a", PkceChallenge = "challenge", ExpiresAt = expiresAt }, expiresAt);
|
|
Assert.IsType<TakeResult<BrokerTransaction>.Expired>(await stateStore.TryTakeAsync<BrokerTransaction>("state", "state"));
|
|
|
|
var grantStore = new EFCoreAuthorizationGrantStore(durableDbContexts, new SteppingSystemClock(afterExpiry));
|
|
await grantStore.SaveAsync(new AuthorizationGrant { CodeHash = "grant", ClientId = "studio", CallbackUri = new Uri("https://studio.example/callback"), TenantId = "tenant-a", UserId = "user-a", PkceChallenge = "challenge", ExpiresAt = expiresAt });
|
|
Assert.IsType<TakeResult<AuthorizationGrant>.Expired>(await grantStore.TryTakeAsync("grant"));
|
|
|
|
var previewStore = new EFCorePreviewResultStore(durableDbContexts, new SteppingSystemClock(afterExpiry));
|
|
await previewStore.SaveAsync(new PreviewResult("preview", "admin-a", "tenant-a", "connection-a", "revision-a", "https://issuer.example", "subject", new Dictionary<string, IReadOnlyCollection<string>>(), "allowed", [], [], expiresAt, null));
|
|
Assert.IsType<TakeResult<PreviewResult>.Expired>(await previewStore.TryTakeAsync("preview", "admin-a"));
|
|
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
Assert.Null((await dbContext.ExternalAuthenticationBrokerTransactions.SingleAsync(x => x.HandleHash == "state")).ConsumedAt);
|
|
Assert.Null((await dbContext.ExternalAuthenticationAuthorizationGrants.SingleAsync(x => x.CodeHash == "grant")).ConsumedAt);
|
|
Assert.Null((await dbContext.ExternalAuthenticationPreviewResults.SingleAsync(x => x.HandleHash == "preview")).ConsumedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProvisionerCreatesCredentiallessUserAndOneDurableLinkPerIdentityTuple()
|
|
{
|
|
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
|
var provisioner = CreateProvisioner(hasher);
|
|
var request = new ProvisioningRequest("tenant-a", "connection-a", new ExternalIdentity("https://issuer.example", "subject-a", new Dictionary<string, IReadOnlyCollection<string>>()), new UserCreationProposal("external"));
|
|
|
|
var created = await provisioner.CreateLinkOrGetExistingAsync(request);
|
|
var converged = await provisioner.CreateLinkOrGetExistingAsync(request);
|
|
|
|
Assert.True(created.WasCreated);
|
|
Assert.False(converged.WasCreated);
|
|
Assert.Equal(created.Link.Id, converged.Link.Id);
|
|
var user = Assert.Single(await _userStore.FindManyAsync(new UserFilter()));
|
|
Assert.Null(user.HashedPassword);
|
|
Assert.Null(user.HashedPasswordSalt);
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProvisionerPersistsTheLatestSuccessfulSignInTimestamp()
|
|
{
|
|
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
|
var provisioner = CreateProvisioner(hasher);
|
|
var identity = new ExternalIdentity("https://issuer.example", "subject-a", EmptyClaims);
|
|
var request = new ProvisioningRequest("tenant-a", "connection-a", identity, new UserCreationProposal("external"));
|
|
var created = await provisioner.CreateLinkOrGetExistingAsync(request);
|
|
var firstSignInAt = new DateTimeOffset(2026, 7, 26, 10, 0, 0, TimeSpan.Zero);
|
|
var latestSignInAt = firstSignInAt.AddMinutes(1);
|
|
|
|
Assert.True(await provisioner.RecordSuccessfulSignInAsync("tenant-a", "connection-a", identity, created.UserId, firstSignInAt));
|
|
Assert.True(await provisioner.RecordSuccessfulSignInAsync("tenant-a", "connection-a", identity, created.UserId, latestSignInAt));
|
|
Assert.True(await provisioner.RecordSuccessfulSignInAsync("tenant-a", "connection-a", identity, created.UserId, firstSignInAt));
|
|
|
|
var persisted = await CreateProvisioner(hasher).FindLinkAsync("tenant-a", "connection-a", identity);
|
|
Assert.Equal(latestSignInAt, persisted!.LastSignedInAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConcurrentSignInsPreserveTheLatestTimestamp()
|
|
{
|
|
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
|
var provisioner = CreateProvisioner(hasher);
|
|
var identity = new ExternalIdentity("https://issuer.example", "subject-a", EmptyClaims);
|
|
var created = await provisioner.CreateLinkOrGetExistingAsync(
|
|
new ProvisioningRequest("tenant-a", "connection-a", identity, new UserCreationProposal("external")));
|
|
var signInTimes = Enumerable.Range(0, 8)
|
|
.Select(minutes => new DateTimeOffset(2026, 7, 26, 10, minutes, 0, TimeSpan.Zero))
|
|
.ToArray();
|
|
|
|
var results = await Task.WhenAll(signInTimes.Select(async signedInAt =>
|
|
await CreateProvisioner(hasher).RecordSuccessfulSignInAsync("tenant-a", "connection-a", identity, created.UserId, signedInAt)));
|
|
|
|
Assert.All(results, Assert.True);
|
|
var persisted = await CreateProvisioner(hasher).FindLinkAsync("tenant-a", "connection-a", identity);
|
|
Assert.Equal(signInTimes.Max(), persisted!.LastSignedInAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProvisionerRemovesTheJustInTimeUserThatLosesTheLinkRace()
|
|
{
|
|
var databasePath = Path.Combine(Path.GetTempPath(), $"elsa-external-identity-provisioning-{Guid.NewGuid():N}.db");
|
|
await using var services = new ServiceCollection().BuildServiceProvider();
|
|
try
|
|
{
|
|
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
|
|
.UseSqlite($"Data Source={databasePath};Default Timeout=30")
|
|
.Options;
|
|
var factory = new TestDbContextFactory(options, services);
|
|
await using (var dbContext = await factory.CreateDbContextAsync())
|
|
await dbContext.Database.EnsureCreatedAsync();
|
|
|
|
var durableUsers = new MemoryUserStore(new MemoryStore<User>(), new TestTenantAccessor("tenant-a"));
|
|
var coordinatedUsers = new CoordinatedUserStore(durableUsers, 2);
|
|
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
|
var firstNode = CreateProvisioner(hasher, factory, coordinatedUsers);
|
|
var secondNode = CreateProvisioner(hasher, factory, coordinatedUsers);
|
|
var request = new ProvisioningRequest("tenant-a", "connection-a", new ExternalIdentity("https://issuer.example", "subject-race", EmptyClaims), new UserCreationProposal("external"));
|
|
|
|
var results = await Task.WhenAll(
|
|
firstNode.CreateLinkOrGetExistingAsync(request).AsTask(),
|
|
secondNode.CreateLinkOrGetExistingAsync(request).AsTask());
|
|
|
|
Assert.Single(results, x => x.WasCreated);
|
|
Assert.Single(results, x => !x.WasCreated);
|
|
Assert.Single(results.Select(x => x.Link.Id).Distinct(StringComparer.Ordinal));
|
|
var user = Assert.Single(await durableUsers.FindManyAsync(new UserFilter()));
|
|
Assert.Equal(results[0].UserId, user.Id);
|
|
Assert.Equal(results[1].UserId, user.Id);
|
|
await using var verificationContext = await factory.CreateDbContextAsync();
|
|
Assert.Single(await verificationContext.ExternalIdentityLinks.ToListAsync());
|
|
}
|
|
finally
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
File.Delete(databasePath);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProvisionerRemovesTheJustInTimeUserWhenLinkPersistenceFails()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
|
|
.UseSqlite(_connection)
|
|
.AddInterceptors(new FailingLinkSaveInterceptor())
|
|
.Options;
|
|
var provisioner = CreateProvisioner(
|
|
new HmacExternalAuthenticationHandleHasher(),
|
|
new TestDbContextFactory(options, _services));
|
|
var request = new ProvisioningRequest(
|
|
"tenant-a",
|
|
"connection-a",
|
|
new ExternalIdentity("https://issuer.example", "subject-link-failure", EmptyClaims),
|
|
new UserCreationProposal("external"));
|
|
|
|
await Assert.ThrowsAsync<DbUpdateException>(() => provisioner.CreateLinkOrGetExistingAsync(request).AsTask());
|
|
|
|
Assert.Empty(await _userStore.FindManyAsync(new UserFilter()));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProvisionerRemovesTheJustInTimeUserWhenPublicationIsCancelled()
|
|
{
|
|
using var cancellationTokenSource = new CancellationTokenSource();
|
|
var users = new CancelAfterSaveUserStore(new MemoryUserStore(new MemoryStore<User>(), new TestTenantAccessor("tenant-a")), cancellationTokenSource);
|
|
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
|
var provisioner = CreateProvisioner(hasher, userStore: users);
|
|
var request = new ProvisioningRequest(
|
|
"tenant-a",
|
|
"connection-a",
|
|
new ExternalIdentity("https://issuer.example", "subject-cancelled-publication", EmptyClaims),
|
|
new UserCreationProposal("external"));
|
|
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
|
provisioner.CreateLinkOrGetExistingAsync(request, cancellationTokenSource.Token).AsTask());
|
|
|
|
Assert.Empty(await users.FindManyAsync(new UserFilter()));
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
Assert.Empty(await dbContext.ExternalIdentityLinks.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProvisionerFailsWhenAJustInTimeUserCannotBeCompensated()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
|
|
.UseSqlite(_connection)
|
|
.AddInterceptors(new FailingLinkSaveInterceptor())
|
|
.Options;
|
|
var userStore = new DeleteFailingUserStore(new MemoryUserStore(new MemoryStore<User>(), new TestTenantAccessor("tenant-a")));
|
|
var provisioner = CreateProvisioner(
|
|
new HmacExternalAuthenticationHandleHasher(),
|
|
new TestDbContextFactory(options, _services),
|
|
userStore);
|
|
var request = new ProvisioningRequest(
|
|
"tenant-a",
|
|
"connection-a",
|
|
new ExternalIdentity("https://issuer.example", "subject-compensation-failure", EmptyClaims),
|
|
new UserCreationProposal("external"));
|
|
|
|
var exception = await Assert.ThrowsAsync<AggregateException>(() => provisioner.CreateLinkOrGetExistingAsync(request).AsTask());
|
|
|
|
Assert.Contains("No credentials were issued", exception.Message, StringComparison.Ordinal);
|
|
Assert.Single(await userStore.FindManyAsync(new UserFilter()));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProvisionerRemovesTheLinkWhenUserDeletionWinsTheRace()
|
|
{
|
|
var users = new MemoryUserStore(new MemoryStore<User>(), new TestTenantAccessor("tenant-a"));
|
|
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
|
|
.UseSqlite(_connection)
|
|
.AddInterceptors(new DeleteLinkedUserBeforeCommitInterceptor(users))
|
|
.Options;
|
|
var provisioner = CreateProvisioner(
|
|
new HmacExternalAuthenticationHandleHasher(),
|
|
new TestDbContextFactory(options, _services),
|
|
users);
|
|
var request = new ProvisioningRequest(
|
|
"tenant-a",
|
|
"connection-a",
|
|
new ExternalIdentity("https://issuer.example", "subject-user-deletion-race", EmptyClaims),
|
|
new UserCreationProposal("external"));
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => provisioner.CreateLinkOrGetExistingAsync(request).AsTask());
|
|
|
|
Assert.Empty(await users.FindManyAsync(new UserFilter()));
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
Assert.Empty(await dbContext.ExternalIdentityLinks.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProvisionerReconcilesAmbiguousPublicationForExistingUser()
|
|
{
|
|
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
|
|
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
|
|
.UseSqlite(_connection)
|
|
.AddInterceptors(new DeleteLinkedUserAfterSaveAndThrowInterceptor(_userStore))
|
|
.Options;
|
|
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
|
var provisioner = CreateProvisioner(hasher, new TestDbContextFactory(options, _services));
|
|
var request = new ProvisioningRequest(
|
|
"tenant-a",
|
|
"connection-a",
|
|
new ExternalIdentity("https://issuer.example", "subject-ambiguous-existing-user", EmptyClaims),
|
|
null,
|
|
"user-a");
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => provisioner.CreateLinkOrGetExistingAsync(request).AsTask());
|
|
|
|
Assert.Null(await _userStore.FindAsync(new UserFilter { Id = "user-a" }));
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
Assert.Empty(await dbContext.ExternalIdentityLinks.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProvisionerAtomicallyReplacesLinksAndPreservesTheOldLinkOnConflict()
|
|
{
|
|
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
|
var provisioner = CreateProvisioner(hasher);
|
|
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
|
|
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
|
|
|
|
var old = (await provisioner.CreateLinkOrGetExistingAsync(new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
|
|
var conflicting = (await provisioner.CreateLinkOrGetExistingAsync(new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-conflict", EmptyClaims), null, "user-b"))).Link;
|
|
|
|
var conflict = Assert.IsType<ExternalIdentityLinkReplaceResult.Conflict>(await provisioner.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", old.Id, "user-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-conflict", EmptyClaims))));
|
|
Assert.Equal(conflicting.Id, conflict.ConflictingLink.Id);
|
|
Assert.IsType<ExternalIdentityLinkReplaceResult.NotFound>(await provisioner.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-b", old.Id, "user-b", "contoso", new ExternalIdentity("https://issuer.example", "cross-tenant", EmptyClaims))));
|
|
|
|
await using (var dbContext = await _dbContextFactory.CreateDbContextAsync())
|
|
{
|
|
Assert.Contains(await dbContext.ExternalIdentityLinks.ToListAsync(), x => x.Id == old.Id);
|
|
}
|
|
|
|
var sameTupleReplacement = Assert.IsType<ExternalIdentityLinkReplaceResult.Success>(
|
|
await provisioner.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", old.Id, "user-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims))));
|
|
Assert.NotEqual(old.Id, sameTupleReplacement.NewLink.Id);
|
|
|
|
var replaced = Assert.IsType<ExternalIdentityLinkReplaceResult.Success>(await provisioner.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", sameTupleReplacement.NewLink.Id, "user-b", "fabrikam", new ExternalIdentity("https://replacement.example", "subject-new", EmptyClaims))));
|
|
Assert.NotEqual(sameTupleReplacement.NewLink.Id, replaced.NewLink.Id);
|
|
Assert.Equal("user-b", replaced.NewLink.UserId);
|
|
Assert.Equal("fabrikam", replaced.NewLink.ConnectionKey);
|
|
Assert.Null(replaced.NewLink.LastSignedInAt);
|
|
|
|
await using (var dbContext = await _dbContextFactory.CreateDbContextAsync())
|
|
{
|
|
var links = await dbContext.ExternalIdentityLinks.ToListAsync();
|
|
Assert.DoesNotContain(links, x => x.Id == old.Id);
|
|
Assert.DoesNotContain(links, x => x.Id == sameTupleReplacement.NewLink.Id);
|
|
Assert.Contains(links, x => x.Id == replaced.NewLink.Id);
|
|
Assert.Contains(links, x => x.Id == conflicting.Id);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProvisionerPreservesTheOldLinkWhenTargetUserDeletionWinsReplacementRace()
|
|
{
|
|
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
|
|
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
|
|
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
|
var originalProvisioner = CreateProvisioner(hasher);
|
|
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
|
|
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
|
|
var racingProvider = new DeleteOnSelectedFindUserProvider(new StoreBasedUserProvider(_userStore), _userStore, 2);
|
|
var racingProvisioner = CreateProvisioner(hasher, userProvider: racingProvider);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => racingProvisioner.ReplaceAsync(
|
|
new ExternalIdentityLinkReplaceRequest(
|
|
"tenant-a",
|
|
old.Id,
|
|
"user-b",
|
|
"contoso",
|
|
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
|
|
|
|
Assert.Null(await _userStore.FindAsync(new UserFilter { Id = "user-b" }));
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
var durableLink = Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
|
|
Assert.Equal(old.Id, durableLink.Id);
|
|
Assert.Equal("user-a", durableLink.UserId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProvisionerLeavesNoLinkWhenBothReplacementUsersAreDeletedDuringCompensation()
|
|
{
|
|
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
|
|
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
|
|
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
|
var originalProvisioner = CreateProvisioner(hasher);
|
|
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
|
|
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
|
|
var racingProvider = new DeleteOnSelectedFindUserProvider(new StoreBasedUserProvider(_userStore), _userStore, 2, 3);
|
|
var racingProvisioner = CreateProvisioner(hasher, userProvider: racingProvider);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => racingProvisioner.ReplaceAsync(
|
|
new ExternalIdentityLinkReplaceRequest(
|
|
"tenant-a",
|
|
old.Id,
|
|
"user-b",
|
|
"contoso",
|
|
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
|
|
|
|
Assert.Null(await _userStore.FindAsync(new UserFilter { Id = "user-a" }));
|
|
Assert.Null(await _userStore.FindAsync(new UserFilter { Id = "user-b" }));
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
Assert.Empty(await dbContext.ExternalIdentityLinks.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProvisionerPreservesRestoredLinkWhenPreviousUserLookupFails()
|
|
{
|
|
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
|
|
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
|
|
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
|
var originalProvisioner = CreateProvisioner(hasher);
|
|
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
|
|
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
|
|
var racingProvider = new DeleteThenThrowUserProvider(new StoreBasedUserProvider(_userStore), _userStore);
|
|
var racingProvisioner = CreateProvisioner(hasher, userProvider: racingProvider);
|
|
|
|
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => racingProvisioner.ReplaceAsync(
|
|
new ExternalIdentityLinkReplaceRequest(
|
|
"tenant-a",
|
|
old.Id,
|
|
"user-b",
|
|
"contoso",
|
|
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
|
|
|
|
Assert.Contains("lookup failure", exception.Message, StringComparison.Ordinal);
|
|
Assert.NotNull(await _userStore.FindAsync(new UserFilter { Id = "user-a" }));
|
|
Assert.Null(await _userStore.FindAsync(new UserFilter { Id = "user-b" }));
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
var durableLink = Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
|
|
Assert.Equal(old.Id, durableLink.Id);
|
|
Assert.Equal("user-a", durableLink.UserId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProvisionerFallsBackWhenInvalidRestoredLinkCleanupFailsOnce()
|
|
{
|
|
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
|
|
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
|
|
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
|
|
.UseSqlite(_connection)
|
|
.AddInterceptors(new FailSelectedLinkDeleteInterceptor(3))
|
|
.Options;
|
|
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
|
var factory = new TestDbContextFactory(options, _services);
|
|
var originalProvisioner = CreateProvisioner(hasher, factory);
|
|
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
|
|
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
|
|
var racingProvider = new DeleteOnSelectedFindUserProvider(new StoreBasedUserProvider(_userStore), _userStore, 2, 3);
|
|
var racingProvisioner = CreateProvisioner(hasher, factory, userProvider: racingProvider);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => racingProvisioner.ReplaceAsync(
|
|
new ExternalIdentityLinkReplaceRequest(
|
|
"tenant-a",
|
|
old.Id,
|
|
"user-b",
|
|
"contoso",
|
|
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
|
|
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
Assert.Empty(await dbContext.ExternalIdentityLinks.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProvisionerDoesNotMisclassifyPostCommitUserLookupFailureAsConflict()
|
|
{
|
|
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
|
|
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
|
|
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
|
var originalProvisioner = CreateProvisioner(hasher);
|
|
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
|
|
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
|
|
var failingProvider = new ThrowOnSelectedFindUserProvider(new StoreBasedUserProvider(_userStore), 2);
|
|
var failingProvisioner = CreateProvisioner(hasher, userProvider: failingProvider);
|
|
|
|
await Assert.ThrowsAsync<DbUpdateException>(() => failingProvisioner.ReplaceAsync(
|
|
new ExternalIdentityLinkReplaceRequest(
|
|
"tenant-a",
|
|
old.Id,
|
|
"user-b",
|
|
"contoso",
|
|
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
|
|
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
var durableLink = Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
|
|
Assert.NotEqual(old.Id, durableLink.Id);
|
|
Assert.Equal("user-b", durableLink.UserId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProvisionerReconcilesReplacementWhenCommitAcknowledgementIsLost()
|
|
{
|
|
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
|
|
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
|
|
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
|
var originalProvisioner = CreateProvisioner(hasher);
|
|
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
|
|
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
|
|
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
|
|
.UseSqlite(_connection)
|
|
.AddInterceptors(new ThrowAfterSelectedCommitInterceptor(1))
|
|
.Options;
|
|
var provisioner = CreateProvisioner(hasher, new TestDbContextFactory(options, _services));
|
|
|
|
var result = Assert.IsType<ExternalIdentityLinkReplaceResult.Success>(await provisioner.ReplaceAsync(
|
|
new ExternalIdentityLinkReplaceRequest(
|
|
"tenant-a",
|
|
old.Id,
|
|
"user-b",
|
|
"contoso",
|
|
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))));
|
|
|
|
Assert.NotEqual(old.Id, result.NewLink.Id);
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
var durableLink = Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
|
|
Assert.Equal(result.NewLink.Id, durableLink.Id);
|
|
Assert.Equal("user-b", durableLink.UserId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProvisionerPreservesRestoredLinkWhenCompensationCommitAcknowledgementIsLost()
|
|
{
|
|
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
|
|
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
|
|
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
|
var originalProvisioner = CreateProvisioner(hasher);
|
|
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
|
|
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
|
|
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
|
|
.UseSqlite(_connection)
|
|
.AddInterceptors(new ThrowAfterSelectedCommitInterceptor(2))
|
|
.Options;
|
|
var racingProvider = new DeleteOnSelectedFindUserProvider(new StoreBasedUserProvider(_userStore), _userStore, 2);
|
|
var provisioner = CreateProvisioner(hasher, new TestDbContextFactory(options, _services), userProvider: racingProvider);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => provisioner.ReplaceAsync(
|
|
new ExternalIdentityLinkReplaceRequest(
|
|
"tenant-a",
|
|
old.Id,
|
|
"user-b",
|
|
"contoso",
|
|
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
|
|
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
var durableLink = Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
|
|
Assert.Equal(old.Id, durableLink.Id);
|
|
Assert.Equal("user-a", durableLink.UserId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DurableConcurrentReplacementUsesTheOldLinkIdAsAnAtomicGuard()
|
|
{
|
|
var databasePath = Path.Combine(Path.GetTempPath(), $"elsa-external-identity-links-{Guid.NewGuid():N}.db");
|
|
await using var services = new ServiceCollection().BuildServiceProvider();
|
|
try
|
|
{
|
|
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
|
|
.UseSqlite($"Data Source={databasePath};Default Timeout=30")
|
|
.Options;
|
|
var factory = new TestDbContextFactory(options, services);
|
|
await using (var dbContext = await factory.CreateDbContextAsync())
|
|
{
|
|
await dbContext.Database.EnsureCreatedAsync();
|
|
}
|
|
|
|
// Both nodes share one user directory, which is what a multi-node deployment actually looks like.
|
|
var sharedUserStore = new MemoryUserStore(new MemoryStore<User>(), new TestTenantAccessor("tenant-a"));
|
|
await sharedUserStore.SaveAsync(new User { Id = "user-a", Name = "alice-concurrent", TenantId = "tenant-a" });
|
|
|
|
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
|
var firstNode = CreateProvisioner(hasher, factory, sharedUserStore);
|
|
var secondNode = CreateProvisioner(hasher, factory, sharedUserStore);
|
|
var old = (await firstNode.CreateLinkOrGetExistingAsync(
|
|
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
|
|
|
|
var results = await Task.WhenAll(
|
|
firstNode.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", old.Id, "user-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-a", EmptyClaims))).AsTask(),
|
|
secondNode.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", old.Id, "user-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-b", EmptyClaims))).AsTask());
|
|
|
|
Assert.Single(results.OfType<ExternalIdentityLinkReplaceResult.Success>());
|
|
Assert.Single(results.OfType<ExternalIdentityLinkReplaceResult.NotFound>());
|
|
await using var verificationContext = await factory.CreateDbContextAsync();
|
|
Assert.Single(await verificationContext.ExternalIdentityLinks.ToListAsync());
|
|
}
|
|
finally
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
File.Delete(databasePath);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CallbackCompletionPersistsTheSessionBeforeAnyRefreshTokenIsIssued()
|
|
{
|
|
var identityResolver = Substitute.For<IExternalIdentityResolver>();
|
|
identityResolver.ResolveAsync(Arg.Any<ExternalIdentityResolutionContext>(), Arg.Any<CancellationToken>())
|
|
.Returns(ValueTask.FromResult(new ExternalIdentityResolution("user-a", false)));
|
|
var permissionGrantResolver = Substitute.For<IPermissionGrantResolver>();
|
|
permissionGrantResolver.ResolveAsync(Arg.Any<PermissionGrantResolutionContext>(), Arg.Any<CancellationToken>())
|
|
.Returns(ValueTask.FromResult(new PermissionGrantResult([], [])));
|
|
var adapter = new Broker.BrokerSecurityTests.RecordingAdapter
|
|
{
|
|
AuthenticationResult = new ExternalAuthenticationResult(new ExternalIdentity("https://issuer.example", "subject-a", EmptyClaims), EmptyClaims, [])
|
|
};
|
|
var broker = Broker.BrokerSecurityTests.CreateBroker(adapter, identityResolver: identityResolver, permissionGrantResolver: permissionGrantResolver, sessionStore: new EFCoreExternalAuthenticationSessionStore(_leaseFactory, _clock));
|
|
await broker.InitiateExternalAsync(new BrokerAuthorizationRequest("studio", new Uri("https://studio.example/authentication/external/callback"), "code", "challenge", "S256", "/workflows", "contoso"), "tenant-a");
|
|
|
|
var result = await broker.CompleteCallbackAsync("contoso", adapter.CorrelationState!, new Dictionary<string, IReadOnlyCollection<string>> { ["state"] = [adapter.CorrelationState!] });
|
|
|
|
Assert.Null(result.Error);
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
var session = Assert.Single(await dbContext.ExternalAuthenticationSessions.ToListAsync());
|
|
Assert.Equal("user-a", session.UserId);
|
|
Assert.Empty(await dbContext.ExternalAuthenticationRefreshTokens.ToListAsync());
|
|
Assert.Null((await new EFCoreExternalAuthenticationSessionStore(_leaseFactory, _clock).FindByIdAsync(session.Id))!.CurrentRefreshTokenHash);
|
|
Assert.Null(await new EFCoreExternalAuthenticationSessionStore(_leaseFactory, _clock).FindByRefreshTokenHashAsync(null!));
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, IReadOnlyCollection<string>> EmptyClaims { get; } = new Dictionary<string, IReadOnlyCollection<string>>();
|
|
|
|
private static IdentityProviderConnection CreateConnection(string id = "connection-a", string tenantId = "tenant-a") => new()
|
|
{
|
|
Id = id,
|
|
TenantId = tenantId,
|
|
Key = "contoso",
|
|
AdapterType = "openid-connect",
|
|
AdapterSettingsVersion = 1,
|
|
AdapterSettings = JsonDocument.Parse("{}").RootElement.Clone(),
|
|
DisplayName = "Contoso",
|
|
MaterialRevision = "revision-a",
|
|
Revision = 1,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
|
|
private ExternalAuthenticationSession CreateSession() => new()
|
|
{
|
|
Id = "session-a", AuthenticationClientId = "studio", TenantId = "tenant-a", UserId = "user-a", ConnectionKey = "contoso", ConnectionMaterialRevision = "revision-a", Issuer = "https://issuer.example", SubjectHash = "subject", ExternalGrants = [], StartedAt = _clock.UtcNow, LastRefreshedAt = _clock.UtcNow, ExpiresAt = _clock.UtcNow.AddHours(1), RefreshExpiresAt = _clock.UtcNow.AddHours(1), CurrentRefreshTokenHash = "refresh-a"
|
|
};
|
|
|
|
private sealed class TestDbContextFactory(DbContextOptions<ExternalAuthenticationElsaDbContext> options, IServiceProvider serviceProvider) : IDbContextFactory<ExternalAuthenticationElsaDbContext>
|
|
{
|
|
public ExternalAuthenticationElsaDbContext CreateDbContext() => new(options, serviceProvider);
|
|
public Task<ExternalAuthenticationElsaDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) => Task.FromResult(CreateDbContext());
|
|
}
|
|
|
|
private sealed class SteppingSystemClock(params DateTimeOffset[] instants) : ISystemClock
|
|
{
|
|
private int _index;
|
|
public DateTimeOffset UtcNow => instants[Math.Min(_index++, instants.Length - 1)];
|
|
}
|
|
|
|
private sealed class CoordinatedUserStore(IUserStore inner, int participantCount) : IUserStore
|
|
{
|
|
private readonly TaskCompletionSource _participantsReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
private int _participants;
|
|
|
|
public async Task SaveAsync(User user, CancellationToken cancellationToken = default)
|
|
{
|
|
if (Interlocked.Increment(ref _participants) == participantCount)
|
|
_participantsReady.TrySetResult();
|
|
await _participantsReady.Task.WaitAsync(cancellationToken);
|
|
await inner.SaveAsync(user, cancellationToken);
|
|
}
|
|
|
|
public Task DeleteAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.DeleteAsync(filter, cancellationToken);
|
|
public Task<IEnumerable<User>> FindManyAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindManyAsync(filter, cancellationToken);
|
|
public Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindAsync(filter, cancellationToken);
|
|
}
|
|
|
|
private sealed class FailingLinkSaveInterceptor : SaveChangesInterceptor
|
|
{
|
|
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
|
DbContextEventData eventData,
|
|
InterceptionResult<int> result,
|
|
CancellationToken cancellationToken = default) =>
|
|
throw new DbUpdateException("Simulated external identity link persistence failure.");
|
|
}
|
|
|
|
private sealed class DeleteLinkedUserBeforeCommitInterceptor(IUserStore users) : SaveChangesInterceptor
|
|
{
|
|
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
|
DbContextEventData eventData,
|
|
InterceptionResult<int> result,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var userId = eventData.Context!.ChangeTracker.Entries<PersistedExternalIdentityLink>()
|
|
.Single(x => x.State == EntityState.Added).Entity.UserId;
|
|
await users.DeleteAsync(new UserFilter { Id = userId }, cancellationToken);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
private sealed class DeleteLinkedUserAfterSaveAndThrowInterceptor(IUserStore users) : SaveChangesInterceptor
|
|
{
|
|
public override async ValueTask<int> SavedChangesAsync(
|
|
SaveChangesCompletedEventData eventData,
|
|
int result,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var userId = eventData.Context!.ChangeTracker.Entries<PersistedExternalIdentityLink>()
|
|
.Single().Entity.UserId;
|
|
await users.DeleteAsync(new UserFilter { Id = userId }, CancellationToken.None);
|
|
throw new InvalidOperationException("Simulated ambiguous post-save failure.");
|
|
}
|
|
}
|
|
|
|
private sealed class DeleteFailingUserStore(IUserStore inner) : IUserStore
|
|
{
|
|
public Task SaveAsync(User user, CancellationToken cancellationToken = default) => inner.SaveAsync(user, cancellationToken);
|
|
public Task DeleteAsync(UserFilter filter, CancellationToken cancellationToken = default) => throw new InvalidOperationException("Simulated user cleanup failure.");
|
|
public Task<IEnumerable<User>> FindManyAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindManyAsync(filter, cancellationToken);
|
|
public Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindAsync(filter, cancellationToken);
|
|
}
|
|
|
|
private sealed class CancelAfterSaveUserStore(IUserStore inner, CancellationTokenSource cancellationTokenSource) : IUserStore
|
|
{
|
|
public async Task SaveAsync(User user, CancellationToken cancellationToken = default)
|
|
{
|
|
await inner.SaveAsync(user, cancellationToken);
|
|
cancellationTokenSource.Cancel();
|
|
}
|
|
|
|
public Task DeleteAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.DeleteAsync(filter, cancellationToken);
|
|
public Task<IEnumerable<User>> FindManyAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindManyAsync(filter, cancellationToken);
|
|
public Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindAsync(filter, cancellationToken);
|
|
}
|
|
|
|
private sealed class FailSelectedLinkDeleteInterceptor(int failureCount) : DbCommandInterceptor
|
|
{
|
|
private int _deleteCount;
|
|
|
|
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
|
|
DbCommand command,
|
|
CommandEventData eventData,
|
|
InterceptionResult<int> result,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (command.CommandText.Contains("DELETE FROM \"ExternalIdentityLinks\"", StringComparison.Ordinal) &&
|
|
Interlocked.Increment(ref _deleteCount) == failureCount)
|
|
throw new InvalidOperationException("Simulated external identity link cleanup failure.");
|
|
|
|
return ValueTask.FromResult(result);
|
|
}
|
|
}
|
|
|
|
private sealed class ThrowAfterSelectedCommitInterceptor(int failureCount) : DbTransactionInterceptor
|
|
{
|
|
private int _commitCount;
|
|
|
|
public override Task TransactionCommittedAsync(
|
|
DbTransaction transaction,
|
|
TransactionEndEventData eventData,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (Interlocked.Increment(ref _commitCount) == failureCount)
|
|
throw new InvalidOperationException("Simulated lost transaction commit acknowledgement.");
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class DeleteOnSelectedFindUserProvider(IUserProvider inner, IUserStore users, params int[] deletionCounts) : IUserProvider
|
|
{
|
|
private readonly HashSet<int> _deletionCounts = deletionCounts.ToHashSet();
|
|
private int _findCount;
|
|
|
|
public async Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default)
|
|
{
|
|
var user = await inner.FindAsync(filter, cancellationToken);
|
|
if (user is not null && _deletionCounts.Contains(Interlocked.Increment(ref _findCount)))
|
|
{
|
|
await users.DeleteAsync(new UserFilter { Id = user.Id }, cancellationToken);
|
|
return null;
|
|
}
|
|
|
|
return user;
|
|
}
|
|
}
|
|
|
|
private sealed class DeleteThenThrowUserProvider(IUserProvider inner, IUserStore users) : IUserProvider
|
|
{
|
|
private int _findCount;
|
|
|
|
public async Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default)
|
|
{
|
|
var user = await inner.FindAsync(filter, cancellationToken);
|
|
var findCount = Interlocked.Increment(ref _findCount);
|
|
if (user is not null && findCount == 2)
|
|
{
|
|
await users.DeleteAsync(new UserFilter { Id = user.Id }, cancellationToken);
|
|
return null;
|
|
}
|
|
|
|
if (findCount == 3)
|
|
throw new InvalidOperationException("Simulated user-directory lookup failure.");
|
|
|
|
return user;
|
|
}
|
|
}
|
|
|
|
private sealed class ThrowOnSelectedFindUserProvider(IUserProvider inner, int failureCount) : IUserProvider
|
|
{
|
|
private int _findCount;
|
|
|
|
public async Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default)
|
|
{
|
|
var user = await inner.FindAsync(filter, cancellationToken);
|
|
if (Interlocked.Increment(ref _findCount) == failureCount)
|
|
throw new DbUpdateException("Simulated post-commit user-directory failure.");
|
|
return user;
|
|
}
|
|
}
|
|
}
|