diff --git a/src/clients/Elsa.Api.Client/Resources/ExternalAuthentication/IdentityLinks/Contracts/IExternalIdentityLinksApi.cs b/src/clients/Elsa.Api.Client/Resources/ExternalAuthentication/IdentityLinks/Contracts/IExternalIdentityLinksApi.cs index 6efa4db4c..c3adffe58 100644 --- a/src/clients/Elsa.Api.Client/Resources/ExternalAuthentication/IdentityLinks/Contracts/IExternalIdentityLinksApi.cs +++ b/src/clients/Elsa.Api.Client/Resources/ExternalAuthentication/IdentityLinks/Contracts/IExternalIdentityLinksApi.cs @@ -19,6 +19,9 @@ public interface IExternalIdentityLinksApi [Post("/external-authentication/identity-links")] Task PrelinkAsync([Body] PrelinkExternalIdentityRequest request, CancellationToken cancellationToken = default); + [Post("/external-authentication/identity-links/{linkId}/replace")] + Task ReplaceAsync(string linkId, [Body] ReplaceExternalIdentityLinkRequest request, CancellationToken cancellationToken = default); + [Delete("/external-authentication/identity-links/{linkId}")] Task UnlinkAsync(string linkId, CancellationToken cancellationToken = default); } diff --git a/src/clients/Elsa.Api.Client/Resources/ExternalAuthentication/IdentityLinks/Requests/ReplaceExternalIdentityLinkRequest.cs b/src/clients/Elsa.Api.Client/Resources/ExternalAuthentication/IdentityLinks/Requests/ReplaceExternalIdentityLinkRequest.cs new file mode 100644 index 000000000..08b4c801d --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/ExternalAuthentication/IdentityLinks/Requests/ReplaceExternalIdentityLinkRequest.cs @@ -0,0 +1,7 @@ +namespace Elsa.Api.Client.Resources.ExternalAuthentication.IdentityLinks.Requests; + +/// +/// Atomically replaces an external identity link with a newly created link. +/// The subject is accepted only for this request and is never returned by the API. +/// +public record ReplaceExternalIdentityLinkRequest(string UserId, string ConnectionKey, string Issuer, string Subject); diff --git a/src/modules/Elsa.ExternalAuthentication/Contracts/ExternalAuthenticationContracts.cs b/src/modules/Elsa.ExternalAuthentication/Contracts/ExternalAuthenticationContracts.cs index 4442c636a..e55f1646e 100644 --- a/src/modules/Elsa.ExternalAuthentication/Contracts/ExternalAuthenticationContracts.cs +++ b/src/modules/Elsa.ExternalAuthentication/Contracts/ExternalAuthenticationContracts.cs @@ -140,6 +140,13 @@ public interface IExternalIdentityProvisioner /// Atomically creates the requested link and, when requested, its credential-less user, or returns the winner of a concurrent operation. /// ValueTask CreateLinkOrGetExistingAsync(ProvisioningRequest request, CancellationToken cancellationToken = default); + + /// + /// Atomically removes the tenant-scoped link identified by + /// and creates its replacement, or returns the conflicting link without changing the original. + /// + ValueTask ReplaceAsync(ExternalIdentityLinkReplaceRequest request, CancellationToken cancellationToken = default) => + throw new NotSupportedException("This external identity provisioner does not support atomic link replacement."); } /// diff --git a/src/modules/Elsa.ExternalAuthentication/Endpoints/IdentityLinks/IdentityLinkEndpoints.cs b/src/modules/Elsa.ExternalAuthentication/Endpoints/IdentityLinks/IdentityLinkEndpoints.cs index c4c8c6781..8c1220e79 100644 --- a/src/modules/Elsa.ExternalAuthentication/Endpoints/IdentityLinks/IdentityLinkEndpoints.cs +++ b/src/modules/Elsa.ExternalAuthentication/Endpoints/IdentityLinks/IdentityLinkEndpoints.cs @@ -110,6 +110,57 @@ internal sealed class PrelinkIdentityLink(ExternalIdentityLinkManagementService } } +internal sealed class ReplaceIdentityLink(ExternalIdentityLinkManagementService management, ITenantAccessor tenantAccessor) : ElsaEndpoint +{ + public override void Configure() + { + Post("/external-authentication/identity-links/{linkId}/replace"); + ConfigurePermissions(ExternalAuthenticationPermissions.LinksManage); + } + + public override async Task HandleAsync(ReplaceIdentityLinkRequest request, CancellationToken cancellationToken) + { + ExternalIdentityLinkReplaceResult result; + try + { + result = await management.ReplaceAsync( + tenantAccessor.TenantId, + Route("linkId")!, + request.UserId, + request.ConnectionKey, + request.Issuer, + request.Subject, + User, + cancellationToken); + } + catch (ArgumentException) + { + await SendErrorAsync(StatusCodes.Status400BadRequest, "validation_failed", "The external identity tuple is invalid.", cancellationToken); + return; + } + + switch (result) + { + case ExternalIdentityLinkReplaceResult.Success success: + HttpContext.Response.StatusCode = StatusCodes.Status201Created; + await HttpContext.Response.WriteAsJsonAsync(IdentityLinkDocument.From(success.NewLink), cancellationToken); + return; + case ExternalIdentityLinkReplaceResult.Conflict: + await SendErrorAsync(StatusCodes.Status409Conflict, "conflict", "The external identity is already linked.", cancellationToken); + return; + default: + await SendErrorAsync(StatusCodes.Status404NotFound, "not_found", "The requested resource was not found.", cancellationToken); + return; + } + } + + private Task SendErrorAsync(int status, string error, string message, CancellationToken cancellationToken) + { + HttpContext.Response.StatusCode = status; + return HttpContext.Response.WriteAsJsonAsync(new IdentityLinkError(error, message), cancellationToken); + } +} + internal sealed class DeleteIdentityLink(ExternalIdentityLinkManagementService management, ITenantAccessor tenantAccessor) : ElsaEndpointWithoutRequest { public override void Configure() @@ -148,6 +199,14 @@ internal sealed class PrelinkIdentityLinkRequest public string Subject { get; set; } = null!; } +internal sealed class ReplaceIdentityLinkRequest +{ + public string UserId { get; set; } = null!; + public string ConnectionKey { get; set; } = null!; + public string Issuer { get; set; } = null!; + public string Subject { get; set; } = null!; +} + internal sealed record IdentityLinkListResponse(IReadOnlyCollection Items, string? NextCursor); internal sealed record FindIdentityLinkUsersResponse(IReadOnlyCollection Items, string? NextCursor); internal sealed record IdentityLinkUserDocument(string Id, string DisplayName); diff --git a/src/modules/Elsa.ExternalAuthentication/Models/ExternalAuthenticationResults.cs b/src/modules/Elsa.ExternalAuthentication/Models/ExternalAuthenticationResults.cs index 609fc3733..42ca5dd00 100644 --- a/src/modules/Elsa.ExternalAuthentication/Models/ExternalAuthenticationResults.cs +++ b/src/modules/Elsa.ExternalAuthentication/Models/ExternalAuthenticationResults.cs @@ -21,6 +21,14 @@ public abstract record ExternalUserMatchResult } public sealed record ProvisioningRequest(string TenantId, string ConnectionKey, ExternalIdentity Identity, UserCreationProposal? Proposal = null, string? ExistingUserId = null); public sealed record ProvisioningResult(string UserId, ExternalIdentityLink Link, bool WasCreated, bool WasLinkCreated = false); +public sealed record ExternalIdentityLinkReplaceRequest(string TenantId, string LinkId, string UserId, string ConnectionKey, ExternalIdentity Identity); +public abstract record ExternalIdentityLinkReplaceResult +{ + private ExternalIdentityLinkReplaceResult() { } + public sealed record Success(ExternalIdentityLink OldLink, ExternalIdentityLink NewLink) : ExternalIdentityLinkReplaceResult; + public sealed record Conflict(ExternalIdentityLink OldLink, ExternalIdentityLink ConflictingLink) : ExternalIdentityLinkReplaceResult; + public sealed record NotFound : ExternalIdentityLinkReplaceResult; +} public sealed record ExternalTokenResponse(string AccessToken, string TokenType, long ExpiresIn, string RefreshToken, long RefreshExpiresIn, long ExternalSessionExpiresIn); public sealed record AdapterSettingsMigrationResult(int SettingsVersion, System.Text.Json.JsonElement Settings, bool WasMigrated); diff --git a/src/modules/Elsa.ExternalAuthentication/Notifications/ExternalAuthenticationSecurityNotifications.cs b/src/modules/Elsa.ExternalAuthentication/Notifications/ExternalAuthenticationSecurityNotifications.cs index 0f8f5c10b..fe8462306 100644 --- a/src/modules/Elsa.ExternalAuthentication/Notifications/ExternalAuthenticationSecurityNotifications.cs +++ b/src/modules/Elsa.ExternalAuthentication/Notifications/ExternalAuthenticationSecurityNotifications.cs @@ -29,6 +29,17 @@ public sealed record IdentityProviderConnectionSecretBindingChanged(SecurityEven public sealed record IdentityProviderConnectionTested(SecurityEventContext Context, string TestedMaterialRevision, string Status, string Category, TimeSpan Duration) : INotification; public sealed record IdentityProviderConnectionPreviewed(SecurityEventContext Context, string MaterialRevision) : INotification; public sealed record ExternalIdentityLinkChanged(SecurityEventContext Context, string Operation, string LinkId) : INotification; +public sealed record ExternalIdentityLinkReplaced( + SecurityEventContext Context, + string OldLinkId, + string? NewLinkId, + string OldUserId, + string NewUserId, + string OldConnectionKey, + string NewConnectionKey, + string? ConflictingLinkId = null, + string? ConflictingUserId = null, + string? ConflictingConnectionKey = null) : INotification; public sealed record ExternalAuthenticationSessionRevoked(SecurityEventContext Context, string SessionId, string Reason) : INotification; public sealed record ExternalAuthenticationConnectionSessionsRevoked(SecurityEventContext Context, int SessionCount, string Reason) : INotification; public sealed record ExternalSignInCompleted(SecurityEventContext Context, string? SessionId, string? AdapterType) : INotification; diff --git a/src/modules/Elsa.ExternalAuthentication/Services/ExternalIdentityLinkManagementService.cs b/src/modules/Elsa.ExternalAuthentication/Services/ExternalIdentityLinkManagementService.cs index e084616cd..529a6ceef 100644 --- a/src/modules/Elsa.ExternalAuthentication/Services/ExternalIdentityLinkManagementService.cs +++ b/src/modules/Elsa.ExternalAuthentication/Services/ExternalIdentityLinkManagementService.cs @@ -96,6 +96,69 @@ public sealed class ExternalIdentityLinkManagementService( return true; } + public async ValueTask ReplaceAsync( + string tenantId, + string linkId, + string userId, + string connectionKey, + string issuer, + string subject, + ClaimsPrincipal actor, + CancellationToken cancellationToken = default) + { + ValidateTargetTenant(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(linkId); + ArgumentException.ThrowIfNullOrWhiteSpace(userId); + ArgumentException.ThrowIfNullOrWhiteSpace(connectionKey); + var normalizedIssuer = NormalizeIssuer(issuer); + var normalizedSubject = NormalizeSubject(subject); + + var user = await users.FindAsync(new UserFilter { Id = userId }, cancellationToken); + if (user is null || !string.Equals(user.TenantId, tenantId, StringComparison.Ordinal)) + return new ExternalIdentityLinkReplaceResult.NotFound(); + + var connection = await connections.FindByKeyAsync(tenantId, connectionKey, cancellationToken); + if (connection is null) + return new ExternalIdentityLinkReplaceResult.NotFound(); + + ExternalIdentityLinkReplaceResult result; + try + { + result = await provisioner.ReplaceAsync( + new ExternalIdentityLinkReplaceRequest( + tenantId, + linkId, + user.Id, + ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key), + new ExternalIdentity(normalizedIssuer, normalizedSubject, new Dictionary>())), + cancellationToken); + } + catch (InvalidOperationException) + { + return new ExternalIdentityLinkReplaceResult.NotFound(); + } + + switch (result) + { + case ExternalIdentityLinkReplaceResult.Success success: + await PublishReplacementAsync(actor, success.OldLink, success.NewLink, null, user.Id, success.NewLink.ConnectionKey, SecurityEventOutcome.Succeeded, cancellationToken); + break; + case ExternalIdentityLinkReplaceResult.Conflict conflict: + await PublishReplacementAsync( + actor, + conflict.OldLink, + null, + conflict.ConflictingLink, + user.Id, + ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key), + SecurityEventOutcome.Failed, + cancellationToken); + break; + } + + return result; + } + private async ValueTask PublishAsync(ClaimsPrincipal actor, ExternalIdentityLink link, string operation, CancellationToken cancellationToken) { var sender = services.GetService(); @@ -114,6 +177,46 @@ public sealed class ExternalIdentityLinkManagementService( await sender.SendAsync(new ExternalIdentityLinkChanged(context, operation, link.Id), cancellationToken); } + private async ValueTask PublishReplacementAsync( + ClaimsPrincipal actor, + ExternalIdentityLink oldLink, + ExternalIdentityLink? newLink, + ExternalIdentityLink? conflictingLink, + string targetUserId, + string targetConnectionKey, + SecurityEventOutcome outcome, + CancellationToken cancellationToken) + { + var sender = services.GetService(); + if (sender is null) + return; + + var context = new SecurityEventContext( + actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? actor.FindFirstValue("sub"), + oldLink.TenantId, + targetConnectionKey, + targetUserId, + clock.UtcNow, + outcome, + Guid.NewGuid().ToString("N"), + outcome == SecurityEventOutcome.Succeeded + ? "External identity link replacement completed." + : "External identity link replacement was rejected because the identity is already linked."); + await sender.SendAsync( + new ExternalIdentityLinkReplaced( + context, + oldLink.Id, + newLink?.Id, + oldLink.UserId, + targetUserId, + oldLink.ConnectionKey, + targetConnectionKey, + conflictingLink?.Id, + conflictingLink?.UserId, + conflictingLink?.ConnectionKey), + cancellationToken); + } + private static string NormalizeIssuer(string issuer) { if (!Uri.TryCreate(issuer?.Trim(), UriKind.Absolute, out var uri) || !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment)) diff --git a/src/modules/Elsa.ExternalAuthentication/Services/InMemoryExternalIdentityProvisioner.cs b/src/modules/Elsa.ExternalAuthentication/Services/InMemoryExternalIdentityProvisioner.cs index 163468cfe..f4fc1e867 100644 --- a/src/modules/Elsa.ExternalAuthentication/Services/InMemoryExternalIdentityProvisioner.cs +++ b/src/modules/Elsa.ExternalAuthentication/Services/InMemoryExternalIdentityProvisioner.cs @@ -74,6 +74,49 @@ public sealed class InMemoryExternalIdentityProvisioner( } } + public async ValueTask ReplaceAsync(ExternalIdentityLinkReplaceRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + cancellationToken.ThrowIfCancellationRequested(); + var normalizedConnectionKey = ConnectionRevisionCalculator.NormalizeKey(request.ConnectionKey); + var replacementKey = new ExternalIdentityKey(request.TenantId, normalizedConnectionKey, request.Identity.Issuer, handleHasher.Hash(request.Identity.Subject)); + + await state.Mutex.WaitAsync(cancellationToken); + try + { + var oldEntry = state.Links.FirstOrDefault(x => + string.Equals(x.Value.Id, request.LinkId, StringComparison.Ordinal) && + string.Equals(x.Value.TenantId, request.TenantId, StringComparison.Ordinal)); + if (oldEntry.Equals(default(KeyValuePair))) + return new ExternalIdentityLinkReplaceResult.NotFound(); + + if (state.Links.TryGetValue(replacementKey, out var conflictingLink) && + !string.Equals(conflictingLink.Id, oldEntry.Value.Id, StringComparison.Ordinal)) + return new ExternalIdentityLinkReplaceResult.Conflict(oldEntry.Value, conflictingLink); + + var (user, _) = await ResolveUserAsync( + new ProvisioningRequest(request.TenantId, normalizedConnectionKey, request.Identity, null, request.UserId), + cancellationToken); + var replacement = new ExternalIdentityLink( + identityGenerator.GenerateId(), + request.TenantId, + normalizedConnectionKey, + request.Identity.Issuer, + replacementKey.SubjectHash, + null, + user.Id, + clock.UtcNow, + null); + state.Links.Remove(oldEntry.Key); + state.Links[replacementKey] = replacement; + return new ExternalIdentityLinkReplaceResult.Success(oldEntry.Value, replacement); + } + finally + { + state.Mutex.Release(); + } + } + public async ValueTask> FindAsync(ExternalIdentityLinkFilter filter, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(filter); diff --git a/src/modules/Elsa.Persistence.EFCore/Modules/ExternalAuthentication/EFCoreExternalIdentityProvisioner.cs b/src/modules/Elsa.Persistence.EFCore/Modules/ExternalAuthentication/EFCoreExternalIdentityProvisioner.cs index 343d3d0ac..2e52e57cb 100644 --- a/src/modules/Elsa.Persistence.EFCore/Modules/ExternalAuthentication/EFCoreExternalIdentityProvisioner.cs +++ b/src/modules/Elsa.Persistence.EFCore/Modules/ExternalAuthentication/EFCoreExternalIdentityProvisioner.cs @@ -65,6 +65,74 @@ public sealed class EFCoreExternalIdentityProvisioner( } } + public async ValueTask ReplaceAsync(ExternalIdentityLinkReplaceRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var normalizedConnectionKey = ConnectionRevisionCalculator.NormalizeKey(request.ConnectionKey); + var subjectHash = handleHasher.Hash(request.Identity.Subject); + ExternalIdentityLink? oldLink = null; + + try + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + var oldEntity = await dbContext.ExternalIdentityLinks.AsNoTracking().SingleOrDefaultAsync( + x => x.Id == request.LinkId && x.TenantId == request.TenantId, + cancellationToken); + if (oldEntity is null) + return new ExternalIdentityLinkReplaceResult.NotFound(); + + oldLink = ToModel(oldEntity); + var conflictingEntity = await dbContext.ExternalIdentityLinks.AsNoTracking().SingleOrDefaultAsync( + x => x.Id != request.LinkId && + x.TenantId == request.TenantId && + x.ConnectionKey == normalizedConnectionKey && + x.Issuer == request.Identity.Issuer && + x.SubjectHash == subjectHash, + cancellationToken); + if (conflictingEntity is not null) + return new ExternalIdentityLinkReplaceResult.Conflict(oldLink, ToModel(conflictingEntity)); + + var user = await dbContext.Users.SingleOrDefaultAsync( + x => x.Id == request.UserId && x.TenantId == request.TenantId, + cancellationToken); + if (user is null) + throw new InvalidOperationException("The requested Elsa user does not exist or is outside the target tenant."); + + var deleted = await dbContext.ExternalIdentityLinks + .Where(x => x.Id == request.LinkId && x.TenantId == request.TenantId) + .ExecuteDeleteAsync(cancellationToken); + if (deleted == 0) + return new ExternalIdentityLinkReplaceResult.NotFound(); + + var replacementEntity = new PersistedExternalIdentityLink + { + Id = identityGenerator.GenerateId(), + TenantId = request.TenantId, + ConnectionKey = normalizedConnectionKey, + Issuer = request.Identity.Issuer, + SubjectHash = subjectHash, + UserId = user.Id, + CreatedAt = clock.UtcNow + }; + dbContext.ExternalIdentityLinks.Add(replacementEntity); + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return new ExternalIdentityLinkReplaceResult.Success(oldLink, ToModel(replacementEntity)); + } + catch (DbUpdateConcurrencyException) + { + return new ExternalIdentityLinkReplaceResult.NotFound(); + } + catch (DbUpdateException) when (oldLink is not null) + { + var conflictingLink = await FindLinkAsync(request.TenantId, normalizedConnectionKey, request.Identity, cancellationToken); + if (conflictingLink is not null && !string.Equals(conflictingLink.Id, request.LinkId, StringComparison.Ordinal)) + return new ExternalIdentityLinkReplaceResult.Conflict(oldLink, conflictingLink); + throw; + } + } + public async ValueTask> FindAsync(ExternalIdentityLinkFilter filter, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(filter); diff --git a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Links/ExternalIdentityLinkTests.cs b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Links/ExternalIdentityLinkTests.cs index 147c1acdf..d57d6b2ad 100644 --- a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Links/ExternalIdentityLinkTests.cs +++ b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Links/ExternalIdentityLinkTests.cs @@ -8,12 +8,14 @@ using Elsa.Common.Services; using Elsa.ExternalAuthentication.Contracts; using Elsa.ExternalAuthentication.Features; using Elsa.ExternalAuthentication.Models; +using Elsa.ExternalAuthentication.Notifications; using Elsa.ExternalAuthentication.Policies; using Elsa.ExternalAuthentication.Services; using Elsa.Identity.Contracts; using Elsa.Identity.Entities; using Elsa.Identity.Providers; using Elsa.Identity.Services; +using Elsa.Mediator.Contracts; using Elsa.Workflows; using FastEndpoints; using Microsoft.AspNetCore.Builder; @@ -29,6 +31,7 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime private HttpClient? _client; private ITenantAccessor _tenant = null!; private TestConnectionRegistry _connections = null!; + private INotificationSender _notifications = null!; private bool _wasSecurityEnabled; protected HttpClient Client => _client!; @@ -47,7 +50,12 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime builder.Services.AddAuthorization(); builder.Services.AddSingleton>(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(new SteppingSystemClock( + new DateTimeOffset(2026, 7, 26, 10, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 7, 26, 10, 1, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 7, 26, 10, 2, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 7, 26, 10, 3, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 7, 26, 10, 4, 0, TimeSpan.Zero))); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); @@ -61,6 +69,8 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime _tenant = Substitute.For(); _tenant.TenantId.Returns("tenant-a"); builder.Services.AddSingleton(_tenant); + _notifications = Substitute.For(); + builder.Services.AddSingleton(_notifications); builder.Services.AddScoped(); _app = builder.Build(); _app.Use(async (context, next) => @@ -174,7 +184,113 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime Assert.Equal(HttpStatusCode.NotFound, (await PrelinkAsync("other-tenant-subject", "user-b")).StatusCode); } + [Fact] + public async Task ReplaceCreatesANewLinkAndResetsLifecycleMetadata() + { + var prelinked = await (await PrelinkAsync("subject-old")).Content.ReadFromJsonAsync(); + + var response = await ReplaceAsync(prelinked!.Id, "subject-new", "user-c", "fabrikam", "https://replacement.example/path/"); + var replacement = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.NotNull(replacement); + Assert.NotEqual(prelinked.Id, replacement!.Id); + Assert.Equal("user-c", replacement.UserId); + Assert.Equal("fabrikam", replacement.ConnectionKey); + Assert.Equal("https://replacement.example/path", replacement.Issuer); + Assert.True(replacement.CreatedAt > prelinked.CreatedAt); + Assert.Null(replacement.LastSignedInAt); + + var links = await Client.GetFromJsonAsync("/external-authentication/identity-links"); + var persisted = Assert.Single(links!.Items); + Assert.Equal(replacement.Id, persisted.Id); + + await using var scope = _app!.Services.CreateAsyncScope(); + var provisioner = scope.ServiceProvider.GetRequiredService(); + Assert.Null(await provisioner.FindLinkAsync("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims))); + Assert.Equal(replacement.Id, (await provisioner.FindLinkAsync("tenant-a", "fabrikam", new ExternalIdentity("https://replacement.example/path", "subject-new", EmptyClaims)))!.Id); + } + + [Fact] + public async Task ReplaceConflictLeavesTheOldLinkUntouchedEvenWhenTheTupleBelongsToTheSameUser() + { + var old = await (await PrelinkAsync("subject-old")).Content.ReadFromJsonAsync(); + var conflicting = await (await PrelinkAsync("subject-conflict")).Content.ReadFromJsonAsync(); + + var response = await ReplaceAsync(old!.Id, "subject-conflict"); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + var links = await Client.GetFromJsonAsync("/external-authentication/identity-links"); + Assert.Equal(2, links!.Items.Count); + Assert.Contains(links.Items, x => x.Id == old.Id && x.UserId == old.UserId && x.ConnectionKey == old.ConnectionKey); + Assert.Contains(links.Items, x => x.Id == conflicting!.Id); + } + + [Fact] + public async Task ReplaceUsesTheOldIdAsATenantBoundConcurrencyGuard() + { + var old = await (await PrelinkAsync("subject-old")).Content.ReadFromJsonAsync(); + + _tenant.TenantId.Returns("tenant-b"); + Assert.Equal(HttpStatusCode.NotFound, (await ReplaceAsync(old!.Id, "cross-tenant-subject", "user-b")).StatusCode); + _tenant.TenantId.Returns("tenant-a"); + + var responses = await Task.WhenAll( + ReplaceAsync(old.Id, "winner-a"), + ReplaceAsync(old.Id, "winner-b")); + + Assert.Single(responses, x => x.StatusCode == HttpStatusCode.Created); + var missing = Assert.Single(responses, x => x.StatusCode == HttpStatusCode.NotFound); + Assert.Equal("not_found", (await missing.Content.ReadFromJsonAsync())!.Error); + var links = await Client.GetFromJsonAsync("/external-authentication/identity-links"); + Assert.Single(links!.Items); + } + + [Fact] + public async Task ReplaceAuditsSuccessAndConflictButNotValidationFailures() + { + var old = await (await PrelinkAsync("subject-old")).Content.ReadFromJsonAsync(); + var conflicting = await (await PrelinkAsync("subject-conflict", "user-c")).Content.ReadFromJsonAsync(); + _notifications.ClearReceivedCalls(); + + var successfulResponse = await ReplaceAsync(old!.Id, "subject-new", "user-c", "fabrikam"); + var replacement = await successfulResponse.Content.ReadFromJsonAsync(); + var replacementToConflict = await (await PrelinkAsync("subject-another")).Content.ReadFromJsonAsync(); + Assert.Equal(HttpStatusCode.Conflict, (await ReplaceAsync(replacementToConflict!.Id, "subject-conflict", "user-c")).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await ReplaceAsync(replacement!.Id, "subject-invalid", issuer: "http://issuer.example")).StatusCode); + + var notifications = _notifications.ReceivedCalls() + .Select(x => x.GetArguments()[0]) + .OfType() + .ToArray(); + Assert.Collection( + notifications, + succeeded => + { + Assert.Equal(SecurityEventOutcome.Succeeded, succeeded.Context.Outcome); + Assert.Equal("admin", succeeded.Context.ActorId); + Assert.Equal("tenant-a", succeeded.Context.TenantId); + Assert.Equal(old.Id, succeeded.OldLinkId); + Assert.Equal(replacement.Id, succeeded.NewLinkId); + Assert.Equal("user-a", succeeded.OldUserId); + Assert.Equal("user-c", succeeded.NewUserId); + Assert.Equal("contoso", succeeded.OldConnectionKey); + Assert.Equal("fabrikam", succeeded.NewConnectionKey); + Assert.Null(succeeded.ConflictingLinkId); + }, + failed => + { + Assert.Equal(SecurityEventOutcome.Failed, failed.Context.Outcome); + Assert.Equal(replacementToConflict.Id, failed.OldLinkId); + Assert.Null(failed.NewLinkId); + Assert.Equal(conflicting!.Id, failed.ConflictingLinkId); + Assert.Equal("user-c", failed.ConflictingUserId); + Assert.Equal("contoso", failed.ConflictingConnectionKey); + }); + } + private async Task PrelinkAsync(string subject, string userId = "user-a") => await _client!.PostAsJsonAsync("/external-authentication/identity-links", new { userId, connectionKey = "contoso", issuer = "https://issuer.example/", subject }); + private async Task ReplaceAsync(string linkId, string subject, string userId = "user-a", string connectionKey = "contoso", string issuer = "https://issuer.example/") => await _client!.PostAsJsonAsync($"/external-authentication/identity-links/{linkId}/replace", new { userId, connectionKey, issuer, subject }); protected async Task SeedUserAsync(string id, string name, string tenantId) { @@ -182,8 +298,11 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime await scope.ServiceProvider.GetRequiredService().SaveAsync(new User { Id = id, Name = name, TenantId = tenantId }); } - private sealed record LinkDocument(string Id, string UserId); + private static IReadOnlyDictionary> EmptyClaims { get; } = new Dictionary>(); + + private sealed record LinkDocument(string Id, string UserId, string ConnectionKey, string Issuer, DateTimeOffset CreatedAt, DateTimeOffset? LastSignedInAt); private sealed record LinkList(IReadOnlyCollection Items, string? NextCursor); + private sealed record ErrorDocument(string Error, string Message); private sealed class TestConnectionRegistry : IIdentityProviderConnectionRegistry { @@ -196,14 +315,14 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime return ValueTask.FromResult(new EffectiveConnectionRegistry([connection], [], "test")); } - public ValueTask FindByKeyAsync(string targetTenantId, string key, CancellationToken cancellationToken = default) => ValueTask.FromResult(string.Equals(targetTenantId, "tenant-a", StringComparison.Ordinal) && string.Equals(key, "contoso", StringComparison.Ordinal) ? CreateConnection() : null); + public ValueTask FindByKeyAsync(string targetTenantId, string key, CancellationToken cancellationToken = default) => ValueTask.FromResult(string.Equals(targetTenantId, "tenant-a", StringComparison.Ordinal) && (string.Equals(key, "contoso", StringComparison.Ordinal) || string.Equals(key, "fabrikam", StringComparison.Ordinal)) ? CreateConnection(key) : null); public ValueTask FindByIdAsync(string targetTenantId, string connectionId, CancellationToken cancellationToken = default) => ValueTask.FromResult(string.Equals(targetTenantId, "tenant-a", StringComparison.Ordinal) && string.Equals(connectionId, "connection-a", StringComparison.Ordinal) ? CreateConnection() : null); - private EffectiveIdentityProviderConnection CreateConnection() => new(new IdentityProviderConnection + private EffectiveIdentityProviderConnection CreateConnection(string key = "contoso") => new(new IdentityProviderConnection { - Id = "connection-a", + Id = $"connection-{key}", TenantId = UseHostConnection ? ConnectionScope.HostTenantId : "tenant-a", - Key = "contoso", + Key = key, AdapterType = "test", AdapterSettingsVersion = 1, DisplayName = "Contoso", @@ -212,4 +331,10 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime ClaimProjection = ClaimProjection.Empty }, ConnectionSourceOwnership.Database, new ConnectionScope(ConnectionScopeKind.Tenant, "tenant-a"), ConnectionValidity.Valid, false, "test"); } + + private sealed class SteppingSystemClock(params DateTimeOffset[] instants) : ISystemClock + { + private int _index; + public DateTimeOffset UtcNow => instants[Math.Min(_index++, instants.Length - 1)]; + } } diff --git a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationPersistenceTests.cs b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationPersistenceTests.cs index 54bdd0903..1554e7f78 100644 --- a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationPersistenceTests.cs +++ b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationPersistenceTests.cs @@ -4,6 +4,7 @@ using Elsa.Common.Services; using Elsa.ExternalAuthentication.Contracts; using Elsa.ExternalAuthentication.Models; using Elsa.ExternalAuthentication.Services; +using Elsa.Identity.Entities; using Elsa.Persistence.EFCore.Modules.ExternalAuthentication; using Elsa.Persistence.EFCore.Modules.Identity; using Elsa.Workflows; @@ -155,6 +156,93 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync()); } + [Fact] + public async Task ProvisionerAtomicallyReplacesLinksAndPreservesTheOldLinkOnConflict() + { + using var hasher = new HmacExternalAuthenticationHandleHasher(); + var provisioner = new EFCoreExternalIdentityProvisioner(_dbContextFactory, Substitute.For(), hasher, new GuidIdentityGenerator(), _clock); + await using (var dbContext = await _dbContextFactory.CreateDbContextAsync()) + { + dbContext.Users.AddRange( + new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" }, + new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" }); + await dbContext.SaveChangesAsync(); + } + + 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(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(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( + 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(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 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() + .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(); + dbContext.Users.Add(new User { Id = "user-a", Name = "alice-concurrent", TenantId = "tenant-a" }); + await dbContext.SaveChangesAsync(); + } + + using var hasher = new HmacExternalAuthenticationHandleHasher(); + var firstNode = new EFCoreExternalIdentityProvisioner(factory, Substitute.For(), hasher, new GuidIdentityGenerator(), _clock); + var secondNode = new EFCoreExternalIdentityProvisioner(factory, Substitute.For(), hasher, new GuidIdentityGenerator(), _clock); + 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()); + Assert.Single(results.OfType()); + await using var verificationContext = await factory.CreateDbContextAsync(); + Assert.Single(await verificationContext.ExternalIdentityLinks.ToListAsync()); + } + finally + { + SqliteConnection.ClearAllPools(); + File.Delete(databasePath); + } + } + + private static IReadOnlyDictionary> EmptyClaims { get; } = new Dictionary>(); + private static IdentityProviderConnection CreateConnection(string id = "connection-a") => new() { Id = id, diff --git a/test/unit/Elsa.ExternalAuthentication.UnitTests/Notifications/SecurityNotificationTests.cs b/test/unit/Elsa.ExternalAuthentication.UnitTests/Notifications/SecurityNotificationTests.cs index ff6798373..e39559b34 100644 --- a/test/unit/Elsa.ExternalAuthentication.UnitTests/Notifications/SecurityNotificationTests.cs +++ b/test/unit/Elsa.ExternalAuthentication.UnitTests/Notifications/SecurityNotificationTests.cs @@ -12,7 +12,7 @@ public class SecurityNotificationTests { typeof(IdentityProviderConnectionChanged), typeof(IdentityProviderConnectionLifecycleChanged), typeof(IdentityProviderConnectionSecretBindingChanged), typeof(IdentityProviderConnectionTested), - typeof(IdentityProviderConnectionPreviewed), typeof(ExternalIdentityLinkChanged), + typeof(IdentityProviderConnectionPreviewed), typeof(ExternalIdentityLinkChanged), typeof(ExternalIdentityLinkReplaced), typeof(ExternalAuthenticationSessionRevoked), typeof(ExternalAuthenticationConnectionSessionsRevoked), typeof(ExternalSignInCompleted), typeof(ExternalAuthenticationOutcomeRecorded) };