| | | 1 | | using System.Security.Claims; |
| | | 2 | | using System.Security.Cryptography; |
| | | 3 | | using System.Text; |
| | | 4 | | using Elsa.Common; |
| | | 5 | | using Elsa.ExternalAuthentication.Contracts; |
| | | 6 | | using Elsa.ExternalAuthentication.Models; |
| | | 7 | | using Elsa.ExternalAuthentication.Notifications; |
| | | 8 | | using Elsa.ExternalAuthentication.Options; |
| | | 9 | | using Microsoft.AspNetCore.DataProtection; |
| | | 10 | | using Microsoft.Extensions.Options; |
| | | 11 | | |
| | | 12 | | namespace Elsa.ExternalAuthentication.Services; |
| | | 13 | | |
| | | 14 | | /// <summary>Executes an administrator-bound adapter flow without resolving links, provisioning users, sessions, or cred |
| | 2 | 15 | | public sealed class PreviewSignInService( |
| | 2 | 16 | | IdentityProviderConnectionManagementService management, |
| | 2 | 17 | | IExternalAuthenticationAdapterRegistry adapters, |
| | 2 | 18 | | IEnumerable<ISecretBindingResolver> secretBindingResolvers, |
| | 2 | 19 | | IEnumerable<IUnlinkedIdentityPolicy> policies, |
| | 2 | 20 | | IExternalIdentityProvisioner provisioner, |
| | 2 | 21 | | IPermissionGrantResolver permissionGrants, |
| | 2 | 22 | | IExternalAuthenticationStateStore stateStore, |
| | 2 | 23 | | IPreviewResultStore results, |
| | 2 | 24 | | IExternalAuthenticationHandleHasher handles, |
| | 2 | 25 | | IDataProtectionProvider dataProtection, |
| | 2 | 26 | | ISystemClock clock, |
| | 2 | 27 | | IOptions<ExternalAuthenticationOptions> options, |
| | 2 | 28 | | ExternalAuthenticationSecurityNotifier notifier) |
| | | 29 | | { |
| | | 30 | | private const string StartPurpose = "PreviewStart"; |
| | 2 | 31 | | private readonly IReadOnlyDictionary<string, ISecretBindingResolver> _resolvers = secretBindingResolvers.ToDictionar |
| | 2 | 32 | | private readonly IReadOnlyDictionary<string, IUnlinkedIdentityPolicy> _policies = policies.ToDictionary(x => x.Type, |
| | | 33 | | |
| | | 34 | | public async ValueTask<PreviewInitiationResult> InitiateAsync(string connectionId, long expectedRevision, string ten |
| | | 35 | | { |
| | 0 | 36 | | var lookup = await management.FindAsync(connectionId, tenantId, cancellationToken); |
| | 0 | 37 | | if (lookup is not ManagementConnectionLookupResult.Found(var connection)) return new PreviewInitiationResult.Not |
| | 0 | 38 | | if (connection.Connection.Revision != expectedRevision) return new PreviewInitiationResult.PreconditionFailed(co |
| | 0 | 39 | | var adminId = ActorId(administrator); |
| | 0 | 40 | | if (adminId is null) return new PreviewInitiationResult.Forbidden(); |
| | 0 | 41 | | var handle = Opaque(); |
| | 0 | 42 | | var transaction = new BrokerTransaction |
| | 0 | 43 | | { |
| | 0 | 44 | | HandleHash = handles.Hash(handle), Purpose = BrokerTransactionPurpose.Preview, ClientId = adminId, |
| | 0 | 45 | | CallbackUri = new Uri($"/external-authentication/previews/{Uri.EscapeDataString(handle)}/authorize", UriKind |
| | 0 | 46 | | ReturnPath = "/", TenantId = tenantId, ConnectionId = connection.Connection.Id, |
| | 0 | 47 | | ConnectionMaterialRevision = connection.Connection.MaterialRevision, PkceChallenge = string.Empty, |
| | 0 | 48 | | ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.PreviewLifetime) |
| | 0 | 49 | | }; |
| | 0 | 50 | | await stateStore.PutAsync(StartPurpose, transaction.HandleHash, transaction, transaction.ExpiresAt, cancellation |
| | 0 | 51 | | return new PreviewInitiationResult.Started(handle, transaction.ExpiresAt); |
| | 0 | 52 | | } |
| | | 53 | | |
| | | 54 | | /// <summary> |
| | | 55 | | /// Continues a preview from the opaque, one-time browser handle created by an authorized administrator. |
| | | 56 | | /// The browser navigation is anonymous because it cannot carry Studio's bearer credential. |
| | | 57 | | /// </summary> |
| | | 58 | | public async ValueTask<PreviewAuthorizeResult> AuthorizeAsync(string previewHandle, CancellationToken cancellationTo |
| | | 59 | | { |
| | 2 | 60 | | var taken = await stateStore.TryTakeAsync<BrokerTransaction>(StartPurpose, handles.Hash(previewHandle), cancella |
| | 2 | 61 | | if (taken is not TakeResult<BrokerTransaction>.Taken { Value: var transaction } || string.IsNullOrWhiteSpace(tra |
| | 1 | 62 | | return new PreviewAuthorizeResult.Invalid(); |
| | 1 | 63 | | var lookup = await management.FindAsync(transaction.ConnectionId!, transaction.TenantId, cancellationToken); |
| | 1 | 64 | | if (lookup is not ManagementConnectionLookupResult.Found(var connection) || !string.Equals(connection.Connection |
| | 0 | 65 | | return new PreviewAuthorizeResult.Invalid(); |
| | | 66 | | |
| | 1 | 67 | | var state = Opaque(); |
| | 1 | 68 | | transaction.HandleHash = handles.Hash(state); |
| | 1 | 69 | | transaction.ClientState = previewHandle; |
| | 1 | 70 | | var secrets = await ResolveSecretsAsync(connection.Connection.SecretBindings, cancellationToken); |
| | | 71 | | try |
| | | 72 | | { |
| | 1 | 73 | | transaction.SecretGenerationFingerprint = SecretFingerprint(secrets); |
| | 1 | 74 | | var request = await adapter.CreateAuthorizationRequestAsync(new ExternalAuthorizationContext(connection, sec |
| | 1 | 75 | | transaction.ProtectedPayload = dataProtection.CreateProtector("Elsa.ExternalAuthentication.AdapterPayload.v1 |
| | 1 | 76 | | await stateStore.PutAsync(BrokerTransactionPurpose.Preview.ToString(), transaction.HandleHash, transaction, |
| | 1 | 77 | | return new PreviewAuthorizeResult.Redirect(request.NavigationUri); |
| | | 78 | | } |
| | 1 | 79 | | finally { Dispose(secrets); } |
| | 2 | 80 | | } |
| | | 81 | | |
| | | 82 | | public async ValueTask<PreviewCallbackResult> CompleteAsync(string connectionId, string state, IReadOnlyDictionary<s |
| | | 83 | | { |
| | 0 | 84 | | var taken = await stateStore.TryTakeAsync<BrokerTransaction>(BrokerTransactionPurpose.Preview.ToString(), handle |
| | 0 | 85 | | if (taken is not TakeResult<BrokerTransaction>.Taken { Value: var transaction } || !string.Equals(transaction.Co |
| | 0 | 86 | | return new PreviewCallbackResult.Invalid(); |
| | 0 | 87 | | var lookup = await management.FindAsync(connectionId, transaction.TenantId, cancellationToken); |
| | 0 | 88 | | if (lookup is not ManagementConnectionLookupResult.Found(var connection) || !string.Equals(connection.Connection |
| | 0 | 89 | | return new PreviewCallbackResult.Invalid(); |
| | 0 | 90 | | var secrets = await ResolveSecretsAsync(connection.Connection.SecretBindings, cancellationToken); |
| | | 91 | | try |
| | | 92 | | { |
| | 0 | 93 | | if (!string.Equals(transaction.SecretGenerationFingerprint, SecretFingerprint(secrets), StringComparison.Ord |
| | 0 | 94 | | var protectedPayload = transaction.ProtectedPayload; |
| | 0 | 95 | | transaction.ProtectedPayload = dataProtection.CreateProtector("Elsa.ExternalAuthentication.AdapterPayload.v1 |
| | | 96 | | ExternalAuthenticationResult authentication; |
| | 0 | 97 | | try { authentication = await adapter.AuthenticateCallbackAsync(new ExternalCallbackContext(connection, secre |
| | 0 | 98 | | finally { transaction.ProtectedPayload = protectedPayload; } |
| | 0 | 99 | | var existingLink = await provisioner.FindLinkAsync(transaction.TenantId, ConnectionRevisionCalculator.Normal |
| | 0 | 100 | | var decision = existingLink is null ? await DescribePolicyAsync(connection, authentication, cancellationToke |
| | 0 | 101 | | var grants = existingLink is null |
| | 0 | 102 | | ? new PermissionGrantResult([], [new PermissionGrantWarning("user_resolution_required", "Permission gran |
| | 0 | 103 | | : await permissionGrants.ResolveAsync(new PermissionGrantResolutionContext(transaction.TenantId, existin |
| | 0 | 104 | | var result = new PreviewResult(handles.Hash(transaction.ClientState), transaction.ClientId, transaction.Tena |
| | 0 | 105 | | authentication.Identity.Issuer, Mask(authentication.Identity.Subject), ExternalAuthenticationRedactor.Re |
| | 0 | 106 | | await results.SaveAsync(result, cancellationToken); |
| | 0 | 107 | | await notifier.PublishAsync(new IdentityProviderConnectionPreviewed(ExternalAuthenticationSecurityNotifier.C |
| | 0 | 108 | | return new PreviewCallbackResult.Completed(); |
| | | 109 | | } |
| | 0 | 110 | | catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } |
| | 0 | 111 | | catch { return new PreviewCallbackResult.Invalid(); } |
| | 0 | 112 | | finally { Dispose(secrets); } |
| | 0 | 113 | | } |
| | | 114 | | |
| | | 115 | | public async ValueTask<TakeResult<PreviewResult>> TakeResultAsync(string previewHandle, string tenantId, ClaimsPrinc |
| | | 116 | | { |
| | 1 | 117 | | var adminId = ActorId(administrator); |
| | 2 | 118 | | if (adminId is null) return new TakeResult<PreviewResult>.NotFound(); |
| | 0 | 119 | | var result = await results.TryTakeAsync(handles.Hash(previewHandle), adminId, cancellationToken); |
| | 0 | 120 | | return result is TakeResult<PreviewResult>.Taken { Value: var preview } && !string.Equals(preview.TenantId, tena |
| | 1 | 121 | | } |
| | | 122 | | |
| | | 123 | | private async ValueTask<string> DescribePolicyAsync(EffectiveIdentityProviderConnection connection, ExternalAuthenti |
| | | 124 | | { |
| | 0 | 125 | | var selection = connection.Connection.UnlinkedPolicy ?? new PolicySelection(options.Value.UnlinkedIdentityPolicy |
| | 0 | 126 | | if (!_policies.TryGetValue(selection.Type, out var policy)) return "unlinked_policy_unavailable"; |
| | 0 | 127 | | var decision = await policy.EvaluateAsync(new UnlinkedIdentityContext(connection.Connection.TenantId, connection |
| | 0 | 128 | | return decision switch |
| | 0 | 129 | | { |
| | 0 | 130 | | UnlinkedIdentityDecision.CreateUser => "would_create_user_and_link", |
| | 0 | 131 | | UnlinkedIdentityDecision.LinkExistingUser => "would_link_existing_user", |
| | 0 | 132 | | UnlinkedIdentityDecision.Reject => "would_reject_unlinked_identity", |
| | 0 | 133 | | _ => "unlinked_policy_unknown" |
| | 0 | 134 | | }; |
| | 0 | 135 | | } |
| | | 136 | | |
| | | 137 | | private async ValueTask<IReadOnlyDictionary<string, ResolvedSecretBinding>> ResolveSecretsAsync(IDictionary<string, |
| | | 138 | | { |
| | 1 | 139 | | var values = new Dictionary<string, ResolvedSecretBinding>(StringComparer.Ordinal); |
| | 3 | 140 | | try { foreach (var (name, binding) in bindings) values[name] = await (_resolvers.TryGetValue(binding.ResolverTyp |
| | 0 | 141 | | catch { Dispose(values); throw; } |
| | 1 | 142 | | } |
| | 1 | 143 | | private static string? ActorId(ClaimsPrincipal user) => user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? user.Fin |
| | 1 | 144 | | private static string Opaque() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+ |
| | 0 | 145 | | private static string Mask(string subject) => subject.Length <= 4 ? "****" : subject[..2] + "***" + subject[^2..]; |
| | 1 | 146 | | private static string SecretFingerprint(IReadOnlyDictionary<string, ResolvedSecretBinding> values) => Convert.ToHexS |
| | 3 | 147 | | private static void Dispose(IReadOnlyDictionary<string, ResolvedSecretBinding> values) { foreach (var value in value |
| | | 148 | | } |
| | | 149 | | |
| | | 150 | | public abstract record PreviewInitiationResult { private PreviewInitiationResult() { } public sealed record Started(stri |
| | | 151 | | public abstract record PreviewAuthorizeResult { private PreviewAuthorizeResult() { } public sealed record Redirect(Uri N |
| | | 152 | | public abstract record PreviewCallbackResult { private PreviewCallbackResult() { } public sealed record Completed : Prev |