| | | 1 | | using Elsa.ExternalAuthentication.Contracts; |
| | | 2 | | using Elsa.ExternalAuthentication.Models; |
| | | 3 | | using Elsa.ExternalAuthentication.Options; |
| | | 4 | | using Microsoft.Extensions.Options; |
| | | 5 | | |
| | | 6 | | namespace Elsa.ExternalAuthentication.Services; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Resolves normalized external identities to Elsa users and delegates tuple uniqueness to the atomic provisioner. |
| | | 10 | | /// </summary> |
| | 9 | 11 | | public sealed class DefaultExternalIdentityResolver( |
| | 9 | 12 | | IExternalIdentityProvisioner provisioner, |
| | 9 | 13 | | IEnumerable<IUnlinkedIdentityPolicy> policies, |
| | 9 | 14 | | IOptions<ExternalAuthenticationOptions> options) : IExternalIdentityResolver, IExternalIdentitySignInTracker |
| | | 15 | | { |
| | 18 | 16 | | private readonly IReadOnlyDictionary<string, IUnlinkedIdentityPolicy> _policies = policies.ToDictionary(x => x.Type, |
| | | 17 | | |
| | | 18 | | public async ValueTask<ExternalIdentityResolution> ResolveAsync(ExternalIdentityResolutionContext context, Cancellat |
| | | 19 | | { |
| | 24 | 20 | | ArgumentNullException.ThrowIfNull(context); |
| | | 21 | | |
| | 24 | 22 | | var connectionKey = ConnectionRevisionCalculator.NormalizeKey(context.Connection.Connection.Key); |
| | 24 | 23 | | var existingLink = await provisioner.FindLinkAsync(context.TargetTenantId, connectionKey, context.Identity, canc |
| | 24 | 24 | | if (existingLink is not null) |
| | | 25 | | { |
| | 20 | 26 | | ValidateLink(existingLink, context); |
| | 19 | 27 | | return new ExternalIdentityResolution(existingLink.UserId, false); |
| | | 28 | | } |
| | | 29 | | |
| | 4 | 30 | | var selection = GetPolicySelection(context.Connection); |
| | 4 | 31 | | var policy = _policies.GetValueOrDefault(selection.Type) |
| | 4 | 32 | | ?? throw new InvalidOperationException($"The unlinked identity policy '{selection.Type}' is not available.") |
| | 4 | 33 | | var decision = await policy.EvaluateAsync(new UnlinkedIdentityContext( |
| | 4 | 34 | | context.TargetTenantId, |
| | 4 | 35 | | context.Connection, |
| | 4 | 36 | | context.Identity, |
| | 4 | 37 | | context.ProjectedClaims, |
| | 4 | 38 | | selection.Settings), cancellationToken); |
| | | 39 | | |
| | 4 | 40 | | var result = decision switch |
| | 4 | 41 | | { |
| | 2 | 42 | | UnlinkedIdentityDecision.Reject reject => throw new ExternalIdentityUnlinkedException(reject.SafeReason), |
| | 2 | 43 | | UnlinkedIdentityDecision.CreateUser createUser => await provisioner.CreateLinkOrGetExistingAsync( |
| | 2 | 44 | | new ProvisioningRequest(context.TargetTenantId, connectionKey, context.Identity, createUser.Proposal), c |
| | 0 | 45 | | UnlinkedIdentityDecision.LinkExistingUser linkExistingUser => await provisioner.CreateLinkOrGetExistingAsync |
| | 0 | 46 | | new ProvisioningRequest(context.TargetTenantId, connectionKey, context.Identity, null, linkExistingUser. |
| | 0 | 47 | | _ => throw new InvalidOperationException("The unlinked identity policy returned an unsupported decision.") |
| | 4 | 48 | | }; |
| | | 49 | | |
| | 2 | 50 | | ValidateLink(result.Link, context); |
| | 2 | 51 | | return new ExternalIdentityResolution(result.UserId, result.WasCreated); |
| | 21 | 52 | | } |
| | | 53 | | |
| | | 54 | | public ValueTask<bool> RecordSuccessfulSignInAsync( |
| | | 55 | | string tenantId, |
| | | 56 | | string connectionKey, |
| | | 57 | | ExternalIdentity identity, |
| | | 58 | | string userId, |
| | | 59 | | DateTimeOffset signedInAt, |
| | | 60 | | CancellationToken cancellationToken = default) => |
| | 4 | 61 | | provisioner is IExternalIdentitySignInTracker tracker |
| | 4 | 62 | | ? tracker.RecordSuccessfulSignInAsync(tenantId, connectionKey, identity, userId, signedInAt, cancellationTok |
| | 4 | 63 | | : ValueTask.FromResult(true); |
| | | 64 | | |
| | | 65 | | private PolicySelection GetPolicySelection(EffectiveIdentityProviderConnection connection) |
| | | 66 | | { |
| | 4 | 67 | | var configuredPolicy = connection.Connection.UnlinkedPolicy; |
| | 4 | 68 | | var mayOverride = connection.Ownership == ConnectionSourceOwnership.Configuration || options.Value.UnlinkedIdent |
| | 4 | 69 | | if (configuredPolicy is not null && mayOverride) |
| | 2 | 70 | | return configuredPolicy; |
| | | 71 | | |
| | 2 | 72 | | return new PolicySelection(options.Value.UnlinkedIdentityPolicy.DefaultType, 1, default); |
| | | 73 | | } |
| | | 74 | | |
| | | 75 | | private static void ValidateLink(ExternalIdentityLink link, ExternalIdentityResolutionContext context) |
| | | 76 | | { |
| | 22 | 77 | | if (!string.Equals(link.TenantId, context.TargetTenantId, StringComparison.Ordinal) || |
| | 22 | 78 | | !string.Equals(link.ConnectionKey, ConnectionRevisionCalculator.NormalizeKey(context.Connection.Connection.K |
| | 22 | 79 | | !string.Equals(link.Issuer, context.Identity.Issuer, StringComparison.Ordinal) || |
| | 22 | 80 | | string.IsNullOrWhiteSpace(link.UserId)) |
| | | 81 | | { |
| | 1 | 82 | | throw new InvalidOperationException("The external identity provisioner returned a link outside the requested |
| | | 83 | | } |
| | 21 | 84 | | } |
| | | 85 | | } |
| | | 86 | | |
| | | 87 | | /// <summary> |
| | | 88 | | /// Signals that a successfully authenticated external identity has no permitted Elsa user link. |
| | | 89 | | /// Endpoint code must translate this exception to the safe <c>identity_unlinked</c> broker category. |
| | | 90 | | /// </summary> |
| | | 91 | | public sealed class ExternalIdentityUnlinkedException(string safeReason) : InvalidOperationException |
| | | 92 | | { |
| | | 93 | | public string SafeReason { get; } = safeReason; |
| | | 94 | | } |