Add atomic external identity link replacement

This commit is contained in:
Sipke Schoorstra 2026-07-26 23:40:17 +02:00
parent 97c459f198
commit e97a90d442
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
12 changed files with 529 additions and 7 deletions

View file

@ -19,6 +19,9 @@ public interface IExternalIdentityLinksApi
[Post("/external-authentication/identity-links")]
Task<ExternalIdentityLink> PrelinkAsync([Body] PrelinkExternalIdentityRequest request, CancellationToken cancellationToken = default);
[Post("/external-authentication/identity-links/{linkId}/replace")]
Task<ExternalIdentityLink> ReplaceAsync(string linkId, [Body] ReplaceExternalIdentityLinkRequest request, CancellationToken cancellationToken = default);
[Delete("/external-authentication/identity-links/{linkId}")]
Task UnlinkAsync(string linkId, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,7 @@
namespace Elsa.Api.Client.Resources.ExternalAuthentication.IdentityLinks.Requests;
/// <summary>
/// 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.
/// </summary>
public record ReplaceExternalIdentityLinkRequest(string UserId, string ConnectionKey, string Issuer, string Subject);

View file

@ -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.
/// </summary>
ValueTask<ProvisioningResult> CreateLinkOrGetExistingAsync(ProvisioningRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Atomically removes the tenant-scoped link identified by <see cref="ExternalIdentityLinkReplaceRequest.LinkId"/>
/// and creates its replacement, or returns the conflicting link without changing the original.
/// </summary>
ValueTask<ExternalIdentityLinkReplaceResult> ReplaceAsync(ExternalIdentityLinkReplaceRequest request, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This external identity provisioner does not support atomic link replacement.");
}
/// <summary>

View file

@ -110,6 +110,57 @@ internal sealed class PrelinkIdentityLink(ExternalIdentityLinkManagementService
}
}
internal sealed class ReplaceIdentityLink(ExternalIdentityLinkManagementService management, ITenantAccessor tenantAccessor) : ElsaEndpoint<ReplaceIdentityLinkRequest>
{
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<string>("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<IdentityLinkDocument> Items, string? NextCursor);
internal sealed record FindIdentityLinkUsersResponse(IReadOnlyCollection<IdentityLinkUserDocument> Items, string? NextCursor);
internal sealed record IdentityLinkUserDocument(string Id, string DisplayName);

View file

@ -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);

View file

@ -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;

View file

@ -96,6 +96,69 @@ public sealed class ExternalIdentityLinkManagementService(
return true;
}
public async ValueTask<ExternalIdentityLinkReplaceResult> 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<string, IReadOnlyCollection<string>>())),
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<INotificationSender>();
@ -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<INotificationSender>();
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))

View file

@ -74,6 +74,49 @@ public sealed class InMemoryExternalIdentityProvisioner(
}
}
public async ValueTask<ExternalIdentityLinkReplaceResult> 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<ExternalIdentityKey, ExternalIdentityLink>)))
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<Page<ExternalIdentityLink>> FindAsync(ExternalIdentityLinkFilter filter, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(filter);

View file

@ -65,6 +65,74 @@ public sealed class EFCoreExternalIdentityProvisioner(
}
}
public async ValueTask<ExternalIdentityLinkReplaceResult> 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<Page<ExternalIdentityLink>> FindAsync(ExternalIdentityLinkFilter filter, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(filter);

View file

@ -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<MemoryStore<User>>();
builder.Services.AddSingleton<IIdentityGenerator, GuidIdentityGenerator>();
builder.Services.AddSingleton<ISystemClock, SystemClock>();
builder.Services.AddSingleton<ISystemClock>(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<IExternalAuthenticationHandleHasher, HmacExternalAuthenticationHandleHasher>();
builder.Services.AddSingleton<InMemoryExternalIdentityProvisionerState>();
builder.Services.AddScoped<IUserStore, MemoryUserStore>();
@ -61,6 +69,8 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime
_tenant = Substitute.For<ITenantAccessor>();
_tenant.TenantId.Returns("tenant-a");
builder.Services.AddSingleton(_tenant);
_notifications = Substitute.For<INotificationSender>();
builder.Services.AddSingleton(_notifications);
builder.Services.AddScoped<ExternalIdentityLinkManagementService>();
_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<LinkDocument>();
var response = await ReplaceAsync(prelinked!.Id, "subject-new", "user-c", "fabrikam", "https://replacement.example/path/");
var replacement = await response.Content.ReadFromJsonAsync<LinkDocument>();
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<LinkList>("/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<IExternalIdentityProvisioner>();
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<LinkDocument>();
var conflicting = await (await PrelinkAsync("subject-conflict")).Content.ReadFromJsonAsync<LinkDocument>();
var response = await ReplaceAsync(old!.Id, "subject-conflict");
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
var links = await Client.GetFromJsonAsync<LinkList>("/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<LinkDocument>();
_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<ErrorDocument>())!.Error);
var links = await Client.GetFromJsonAsync<LinkList>("/external-authentication/identity-links");
Assert.Single(links!.Items);
}
[Fact]
public async Task ReplaceAuditsSuccessAndConflictButNotValidationFailures()
{
var old = await (await PrelinkAsync("subject-old")).Content.ReadFromJsonAsync<LinkDocument>();
var conflicting = await (await PrelinkAsync("subject-conflict", "user-c")).Content.ReadFromJsonAsync<LinkDocument>();
_notifications.ClearReceivedCalls();
var successfulResponse = await ReplaceAsync(old!.Id, "subject-new", "user-c", "fabrikam");
var replacement = await successfulResponse.Content.ReadFromJsonAsync<LinkDocument>();
var replacementToConflict = await (await PrelinkAsync("subject-another")).Content.ReadFromJsonAsync<LinkDocument>();
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<ExternalIdentityLinkReplaced>()
.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<HttpResponseMessage> 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<HttpResponseMessage> 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<IUserStore>().SaveAsync(new User { Id = id, Name = name, TenantId = tenantId });
}
private sealed record LinkDocument(string Id, string UserId);
private static IReadOnlyDictionary<string, IReadOnlyCollection<string>> EmptyClaims { get; } = new Dictionary<string, IReadOnlyCollection<string>>();
private sealed record LinkDocument(string Id, string UserId, string ConnectionKey, string Issuer, DateTimeOffset CreatedAt, DateTimeOffset? LastSignedInAt);
private sealed record LinkList(IReadOnlyCollection<LinkDocument> 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<EffectiveIdentityProviderConnection?> FindByKeyAsync(string targetTenantId, string key, CancellationToken cancellationToken = default) => ValueTask.FromResult<EffectiveIdentityProviderConnection?>(string.Equals(targetTenantId, "tenant-a", StringComparison.Ordinal) && string.Equals(key, "contoso", StringComparison.Ordinal) ? CreateConnection() : null);
public ValueTask<EffectiveIdentityProviderConnection?> FindByKeyAsync(string targetTenantId, string key, CancellationToken cancellationToken = default) => ValueTask.FromResult<EffectiveIdentityProviderConnection?>(string.Equals(targetTenantId, "tenant-a", StringComparison.Ordinal) && (string.Equals(key, "contoso", StringComparison.Ordinal) || string.Equals(key, "fabrikam", StringComparison.Ordinal)) ? CreateConnection(key) : null);
public ValueTask<EffectiveIdentityProviderConnection?> FindByIdAsync(string targetTenantId, string connectionId, CancellationToken cancellationToken = default) => ValueTask.FromResult<EffectiveIdentityProviderConnection?>(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)];
}
}

View file

@ -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<Elsa.Identity.Contracts.IRoleProvider>(), 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<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 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<IdentityElsaDbContext>()
.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<Elsa.Identity.Contracts.IRoleProvider>(), hasher, new GuidIdentityGenerator(), _clock);
var secondNode = new EFCoreExternalIdentityProvisioner(factory, Substitute.For<Elsa.Identity.Contracts.IRoleProvider>(), 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<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);
}
}
private static IReadOnlyDictionary<string, IReadOnlyCollection<string>> EmptyClaims { get; } = new Dictionary<string, IReadOnlyCollection<string>>();
private static IdentityProviderConnection CreateConnection(string id = "connection-a") => new()
{
Id = id,

View file

@ -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)
};