< Summary

Information
Class: Elsa.ExternalAuthentication.Services.PreviewSignInService
Assembly: Elsa.ExternalAuthentication
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Services/PreviewSignInService.cs
Line coverage
41%
Covered lines: 42
Uncovered lines: 59
Coverable lines: 101
Total lines: 152
Line coverage: 41.5%
Branch coverage
19%
Covered branches: 13
Total branches: 66
Branch coverage: 19.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
InitiateAsync()0%4260%
AuthorizeAsync()70%101094.11%
CompleteAsync()0%420200%
TakeResultAsync()16.66%8660%
DescribePolicyAsync()0%110100%
ResolveSecretsAsync()25%4475%
ActorId(...)50%66100%
Opaque()100%11100%
Mask(...)0%620%
SecretFingerprint(...)100%11100%
Dispose(...)50%22100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Services/PreviewSignInService.cs

#LineLine coverage
 1using System.Security.Claims;
 2using System.Security.Cryptography;
 3using System.Text;
 4using Elsa.Common;
 5using Elsa.ExternalAuthentication.Contracts;
 6using Elsa.ExternalAuthentication.Models;
 7using Elsa.ExternalAuthentication.Notifications;
 8using Elsa.ExternalAuthentication.Options;
 9using Microsoft.AspNetCore.DataProtection;
 10using Microsoft.Extensions.Options;
 11
 12namespace Elsa.ExternalAuthentication.Services;
 13
 14/// <summary>Executes an administrator-bound adapter flow without resolving links, provisioning users, sessions, or cred
 215public sealed class PreviewSignInService(
 216    IdentityProviderConnectionManagementService management,
 217    IExternalAuthenticationAdapterRegistry adapters,
 218    IEnumerable<ISecretBindingResolver> secretBindingResolvers,
 219    IEnumerable<IUnlinkedIdentityPolicy> policies,
 220    IExternalIdentityProvisioner provisioner,
 221    IPermissionGrantResolver permissionGrants,
 222    IExternalAuthenticationStateStore stateStore,
 223    IPreviewResultStore results,
 224    IExternalAuthenticationHandleHasher handles,
 225    IDataProtectionProvider dataProtection,
 226    ISystemClock clock,
 227    IOptions<ExternalAuthenticationOptions> options,
 228    ExternalAuthenticationSecurityNotifier notifier)
 29{
 30    private const string StartPurpose = "PreviewStart";
 231    private readonly IReadOnlyDictionary<string, ISecretBindingResolver> _resolvers = secretBindingResolvers.ToDictionar
 232    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    {
 036        var lookup = await management.FindAsync(connectionId, tenantId, cancellationToken);
 037        if (lookup is not ManagementConnectionLookupResult.Found(var connection)) return new PreviewInitiationResult.Not
 038        if (connection.Connection.Revision != expectedRevision) return new PreviewInitiationResult.PreconditionFailed(co
 039        var adminId = ActorId(administrator);
 040        if (adminId is null) return new PreviewInitiationResult.Forbidden();
 041        var handle = Opaque();
 042        var transaction = new BrokerTransaction
 043        {
 044            HandleHash = handles.Hash(handle), Purpose = BrokerTransactionPurpose.Preview, ClientId = adminId,
 045            CallbackUri = new Uri($"/external-authentication/previews/{Uri.EscapeDataString(handle)}/authorize", UriKind
 046            ReturnPath = "/", TenantId = tenantId, ConnectionId = connection.Connection.Id,
 047            ConnectionMaterialRevision = connection.Connection.MaterialRevision, PkceChallenge = string.Empty,
 048            ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.PreviewLifetime)
 049        };
 050        await stateStore.PutAsync(StartPurpose, transaction.HandleHash, transaction, transaction.ExpiresAt, cancellation
 051        return new PreviewInitiationResult.Started(handle, transaction.ExpiresAt);
 052    }
 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    {
 260        var taken = await stateStore.TryTakeAsync<BrokerTransaction>(StartPurpose, handles.Hash(previewHandle), cancella
 261        if (taken is not TakeResult<BrokerTransaction>.Taken { Value: var transaction } || string.IsNullOrWhiteSpace(tra
 162            return new PreviewAuthorizeResult.Invalid();
 163        var lookup = await management.FindAsync(transaction.ConnectionId!, transaction.TenantId, cancellationToken);
 164        if (lookup is not ManagementConnectionLookupResult.Found(var connection) || !string.Equals(connection.Connection
 065            return new PreviewAuthorizeResult.Invalid();
 66
 167        var state = Opaque();
 168        transaction.HandleHash = handles.Hash(state);
 169        transaction.ClientState = previewHandle;
 170        var secrets = await ResolveSecretsAsync(connection.Connection.SecretBindings, cancellationToken);
 71        try
 72        {
 173            transaction.SecretGenerationFingerprint = SecretFingerprint(secrets);
 174            var request = await adapter.CreateAuthorizationRequestAsync(new ExternalAuthorizationContext(connection, sec
 175            transaction.ProtectedPayload = dataProtection.CreateProtector("Elsa.ExternalAuthentication.AdapterPayload.v1
 176            await stateStore.PutAsync(BrokerTransactionPurpose.Preview.ToString(), transaction.HandleHash, transaction, 
 177            return new PreviewAuthorizeResult.Redirect(request.NavigationUri);
 78        }
 179        finally { Dispose(secrets); }
 280    }
 81
 82    public async ValueTask<PreviewCallbackResult> CompleteAsync(string connectionId, string state, IReadOnlyDictionary<s
 83    {
 084        var taken = await stateStore.TryTakeAsync<BrokerTransaction>(BrokerTransactionPurpose.Preview.ToString(), handle
 085        if (taken is not TakeResult<BrokerTransaction>.Taken { Value: var transaction } || !string.Equals(transaction.Co
 086            return new PreviewCallbackResult.Invalid();
 087        var lookup = await management.FindAsync(connectionId, transaction.TenantId, cancellationToken);
 088        if (lookup is not ManagementConnectionLookupResult.Found(var connection) || !string.Equals(connection.Connection
 089            return new PreviewCallbackResult.Invalid();
 090        var secrets = await ResolveSecretsAsync(connection.Connection.SecretBindings, cancellationToken);
 91        try
 92        {
 093            if (!string.Equals(transaction.SecretGenerationFingerprint, SecretFingerprint(secrets), StringComparison.Ord
 094            var protectedPayload = transaction.ProtectedPayload;
 095            transaction.ProtectedPayload = dataProtection.CreateProtector("Elsa.ExternalAuthentication.AdapterPayload.v1
 96            ExternalAuthenticationResult authentication;
 097            try { authentication = await adapter.AuthenticateCallbackAsync(new ExternalCallbackContext(connection, secre
 098            finally { transaction.ProtectedPayload = protectedPayload; }
 099            var existingLink = await provisioner.FindLinkAsync(transaction.TenantId, ConnectionRevisionCalculator.Normal
 0100            var decision = existingLink is null ? await DescribePolicyAsync(connection, authentication, cancellationToke
 0101            var grants = existingLink is null
 0102                ? new PermissionGrantResult([], [new PermissionGrantWarning("user_resolution_required", "Permission gran
 0103                : await permissionGrants.ResolveAsync(new PermissionGrantResolutionContext(transaction.TenantId, existin
 0104            var result = new PreviewResult(handles.Hash(transaction.ClientState), transaction.ClientId, transaction.Tena
 0105                authentication.Identity.Issuer, Mask(authentication.Identity.Subject), ExternalAuthenticationRedactor.Re
 0106            await results.SaveAsync(result, cancellationToken);
 0107            await notifier.PublishAsync(new IdentityProviderConnectionPreviewed(ExternalAuthenticationSecurityNotifier.C
 0108            return new PreviewCallbackResult.Completed();
 109        }
 0110        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
 0111        catch { return new PreviewCallbackResult.Invalid(); }
 0112        finally { Dispose(secrets); }
 0113    }
 114
 115    public async ValueTask<TakeResult<PreviewResult>> TakeResultAsync(string previewHandle, string tenantId, ClaimsPrinc
 116    {
 1117        var adminId = ActorId(administrator);
 2118        if (adminId is null) return new TakeResult<PreviewResult>.NotFound();
 0119        var result = await results.TryTakeAsync(handles.Hash(previewHandle), adminId, cancellationToken);
 0120        return result is TakeResult<PreviewResult>.Taken { Value: var preview } && !string.Equals(preview.TenantId, tena
 1121    }
 122
 123    private async ValueTask<string> DescribePolicyAsync(EffectiveIdentityProviderConnection connection, ExternalAuthenti
 124    {
 0125        var selection = connection.Connection.UnlinkedPolicy ?? new PolicySelection(options.Value.UnlinkedIdentityPolicy
 0126        if (!_policies.TryGetValue(selection.Type, out var policy)) return "unlinked_policy_unavailable";
 0127        var decision = await policy.EvaluateAsync(new UnlinkedIdentityContext(connection.Connection.TenantId, connection
 0128        return decision switch
 0129        {
 0130            UnlinkedIdentityDecision.CreateUser => "would_create_user_and_link",
 0131            UnlinkedIdentityDecision.LinkExistingUser => "would_link_existing_user",
 0132            UnlinkedIdentityDecision.Reject => "would_reject_unlinked_identity",
 0133            _ => "unlinked_policy_unknown"
 0134        };
 0135    }
 136
 137    private async ValueTask<IReadOnlyDictionary<string, ResolvedSecretBinding>> ResolveSecretsAsync(IDictionary<string, 
 138    {
 1139        var values = new Dictionary<string, ResolvedSecretBinding>(StringComparer.Ordinal);
 3140        try { foreach (var (name, binding) in bindings) values[name] = await (_resolvers.TryGetValue(binding.ResolverTyp
 0141        catch { Dispose(values); throw; }
 1142    }
 1143    private static string? ActorId(ClaimsPrincipal user) => user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? user.Fin
 1144    private static string Opaque() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+
 0145    private static string Mask(string subject) => subject.Length <= 4 ? "****" : subject[..2] + "***" + subject[^2..];
 1146    private static string SecretFingerprint(IReadOnlyDictionary<string, ResolvedSecretBinding> values) => Convert.ToHexS
 3147    private static void Dispose(IReadOnlyDictionary<string, ResolvedSecretBinding> values) { foreach (var value in value
 148}
 149
 150public abstract record PreviewInitiationResult { private PreviewInitiationResult() { } public sealed record Started(stri
 151public abstract record PreviewAuthorizeResult { private PreviewAuthorizeResult() { } public sealed record Redirect(Uri N
 152public abstract record PreviewCallbackResult { private PreviewCallbackResult() { } public sealed record Completed : Prev