< Summary

Information
Class: Elsa.ExternalAuthentication.Services.ExternalAuthenticationBroker
Assembly: Elsa.ExternalAuthentication
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs
Line coverage
64%
Covered lines: 246
Uncovered lines: 137
Coverable lines: 383
Total lines: 625
Line coverage: 64.2%
Branch coverage
51%
Covered branches: 117
Total branches: 226
Branch coverage: 51.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
RecordOutcomeAsync(...)100%22100%
RecordOutcomeCategoryAsync(...)100%22100%
CallbackOutcomeAsync()100%66100%
TokenOutcomeAsync()100%66100%
LogoutOutcomeAsync()0%4260%
DiscoverAsync()50%4490%
InitiateExternalAsync()93.75%161689.79%
CompleteCallbackAsync()78.12%343287.67%
InitiateLocalAsync()50%8664.7%
ExchangeAsync()45.65%1814660%
RefreshLocalAsync()50%2290.9%
SecondsUntil(...)50%22100%
GetExpiresAt(...)100%11100%
IsJwt(...)100%11100%
LogoutAsync()0%1482380%
CompleteLogoutAsync()0%272160%
ContinueLogoutAsync()0%2040%
EnsureClient(...)100%22100%
ValidateAuthorizationRequest(...)100%88100%
VerifyPkce(...)50%22100%
CreateOpaqueValue()100%11100%
Hash(...)100%11100%
Base64Url(...)100%11100%
AppendCallbackParameters(...)100%22100%
AppendQuery(...)66.66%66100%
FailTrustedCallbackAsync()100%11100%
ResolveSecretsAsync()75%5466.66%
GetSecretFingerprint(...)100%11100%
DisposeSecrets(...)100%22100%
ValidateExchangeClientAsync()72.22%411858.33%

File(s)

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

#LineLine coverage
 1using System.Security.Cryptography;
 2using System.Text;
 3using Elsa.Common;
 4using Elsa.Common.Multitenancy;
 5using Elsa.Extensions;
 6using Elsa.ExternalAuthentication.Contracts;
 7using Elsa.ExternalAuthentication.Models;
 8using Elsa.ExternalAuthentication.Options;
 9using Elsa.ExternalAuthentication.Notifications;
 10using Elsa.ExternalAuthentication.Validation;
 11using Elsa.Identity.Contracts;
 12using Elsa.Identity.Models;
 13using Microsoft.Extensions.Options;
 14using Microsoft.AspNetCore.DataProtection;
 15using Microsoft.IdentityModel.JsonWebTokens;
 16
 17namespace Elsa.ExternalAuthentication.Services;
 18
 2819public sealed class ExternalAuthenticationBroker(
 2820    IIdentityProviderConnectionRegistry connectionRegistry,
 2821    IIdentityProviderConnectionValidityAssessor validityAssessor,
 2822    IEnumerable<IExternalAuthenticationAdapter> adapters,
 2823    IEnumerable<ISecretBindingResolver> secretBindingResolvers,
 2824    IExternalAuthenticationHandleHasher handleHasher,
 2825    IDataProtectionProvider dataProtectionProvider,
 2826    IExternalIdentityResolver identityResolver,
 2827    IPermissionGrantResolver permissionGrantResolver,
 2828    IExternalAuthenticationStateStore stateStore,
 2829    IAuthorizationGrantStore grantStore,
 2830    IExternalAuthenticationSessionStore sessionStore,
 2831    IExternalAuthenticationTokenIssuer tokenIssuer,
 2832    IUserCredentialsValidator credentialsValidator,
 2833    IUserProvider userProvider,
 2834    IRoleProvider roleProvider,
 2835    IElsaTokenService elsaTokenService,
 2836    IIdentityRefreshTokenService identityRefreshTokenService,
 2837    ITenantAccessor tenantAccessor,
 2838    ISystemClock clock,
 2839    IOptions<ExternalAuthenticationOptions> options,
 2840    ExternalAuthenticationSecurityNotifier? notifier = null) : IExternalAuthenticationBroker
 41{
 42    private ValueTask RecordOutcomeAsync(string flow, string stage, SecurityEventOutcome outcome, BrokerErrorCategory? c
 2043        RecordOutcomeCategoryAsync(flow, stage, outcome, category is { } value ? BrokerErrorFactory.Create(value).Error 
 44
 45    private ValueTask RecordOutcomeCategoryAsync(string flow, string stage, SecurityEventOutcome outcome, string categor
 46    {
 4347        return notifier?.PublishAsync(new ExternalAuthenticationOutcomeRecorded(
 4348            ExternalAuthenticationSecurityNotifier.Context(null, tenantId, connectionId, userId, outcome, "External auth
 49    }
 50
 51    private async ValueTask<BrokerCallbackResult> CallbackOutcomeAsync(BrokerCallbackResult result, string flow, string 
 52    {
 1653        await RecordOutcomeCategoryAsync(flow, stage, result.Error is null ? SecurityEventOutcome.Succeeded : SecurityEv
 1654        return result;
 1655    }
 56
 57    private async ValueTask<BrokerTokenResult> TokenOutcomeAsync(BrokerTokenResult result, string flow, string stage, Ca
 58    {
 759        await RecordOutcomeCategoryAsync(flow, stage, result.Error is null ? SecurityEventOutcome.Succeeded : SecurityEv
 760        return result;
 761    }
 62    private async ValueTask<BrokerLogoutResult> LogoutOutcomeAsync(BrokerLogoutResult result, string stage, Cancellation
 63    {
 064        await RecordOutcomeCategoryAsync("logout", stage, result.Error is null ? SecurityEventOutcome.Succeeded : Securi
 065        return result;
 066    }
 67
 68    public async ValueTask<IReadOnlyCollection<LoginMethod>> DiscoverAsync(string targetTenantId, string clientId, Cance
 69    {
 270        EnsureClient(clientId);
 171        var registry = await connectionRegistry.GetAsync(targetTenantId, cancellationToken);
 272        var advertisedIds = registry.LoginMethods.Select(method => method.Id).ToHashSet(StringComparer.Ordinal);
 173        var assessments = await Task.WhenAll(registry.Connections
 174            .Where(connection => advertisedIds.Contains(connection.Connection.Id))
 275            .Select(connection => validityAssessor.AssessAsync(connection, cancellationToken).AsTask()));
 176        var availableIds = assessments
 177            .Where(connection => connection.Validity == ConnectionValidity.Valid)
 078            .Select(connection => connection.Connection.Id)
 179            .ToHashSet(StringComparer.Ordinal);
 280        var externalMethods = registry.LoginMethods.Where(method => availableIds.Contains(method.Id)).ToArray();
 181        var localOptions = options.Value.LocalLogin;
 182        if (!localOptions.IsEnabled)
 083            return externalMethods;
 84
 185        var local = new LoginMethod("local", "local", LoginMethodKind.Local, localOptions.DisplayName, localOptions.Icon
 186        return [local, .. externalMethods];
 187    }
 88
 89    public async ValueTask<BrokerInitiationResult> InitiateExternalAsync(BrokerAuthorizationRequest request, string targ
 90    {
 91        AuthenticationClient client;
 92        try
 93        {
 2094            client = EnsureClient(request.ClientId);
 2095            ValidateAuthorizationRequest(request, client);
 1696        }
 97        catch (InvalidOperationException)
 98        {
 499            var error = BrokerErrorFactory.Create(BrokerErrorCategory.InvalidRequest);
 4100            await RecordOutcomeAsync("external", "initiate", SecurityEventOutcome.Rejected, BrokerErrorCategory.InvalidR
 4101            return BrokerInitiationResult.Fail(error);
 102        }
 16103        var connection = await connectionRegistry.FindByKeyAsync(targetTenantId, request.ConnectionKey, cancellationToke
 16104        if (connection is not null)
 16105            connection = await validityAssessor.AssessAsync(connection, cancellationToken);
 16106        if (connection is null || connection.Validity != ConnectionValidity.Valid || connection.IsShadowed || !connectio
 107        {
 1108            var error = BrokerErrorFactory.Create(BrokerErrorCategory.MethodUnavailable);
 1109            await RecordOutcomeAsync("external", "initiate", SecurityEventOutcome.Rejected, BrokerErrorCategory.MethodUn
 1110            return BrokerInitiationResult.Fail(error);
 111        }
 112
 30113        var adapter = adapters.FirstOrDefault(x => string.Equals(x.Type, connection.Connection.AdapterType, StringCompar
 15114        if (adapter is null)
 115        {
 0116            var error = BrokerErrorFactory.Create(BrokerErrorCategory.MethodUnavailable);
 0117            await RecordOutcomeAsync("external", "initiate", SecurityEventOutcome.Rejected, BrokerErrorCategory.MethodUn
 0118            return BrokerInitiationResult.Fail(error);
 119        }
 120
 15121        var state = CreateOpaqueValue();
 15122        var transaction = new BrokerTransaction
 15123        {
 15124            HandleHash = Hash(state),
 15125            Purpose = BrokerTransactionPurpose.ExternalSignIn,
 15126            ClientId = request.ClientId,
 15127            CallbackUri = request.RedirectUri,
 15128            ReturnPath = request.ReturnPath,
 15129            ClientState = request.ClientState,
 15130            TenantId = targetTenantId,
 15131            ConnectionId = connection.Connection.Id,
 15132            ConnectionKey = ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
 15133            ConnectionMaterialRevision = connection.Connection.MaterialRevision,
 15134            PkceChallenge = request.CodeChallenge,
 15135            ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.BrokerTransactionLifetime)
 15136        };
 15137        IReadOnlyDictionary<string, ResolvedSecretBinding> secrets = new Dictionary<string, ResolvedSecretBinding>();
 138        ExternalAuthorizationRequest adapterRequest;
 139        try
 140        {
 15141            secrets = await ResolveSecretsAsync(connection.Connection.SecretBindings, cancellationToken);
 15142            transaction.SecretGenerationFingerprint = GetSecretFingerprint(secrets);
 15143            adapterRequest = await adapter.CreateAuthorizationRequestAsync(new ExternalAuthorizationContext(connection, 
 14144            transaction.ProtectedPayload = dataProtectionProvider.CreateProtector("Elsa.ExternalAuthentication.AdapterPa
 14145        }
 0146        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 147        {
 0148            throw;
 149        }
 150        catch
 151        {
 1152            var error = BrokerErrorFactory.Create(BrokerErrorCategory.TemporarilyUnavailable);
 1153            await RecordOutcomeAsync("external", "initiate", SecurityEventOutcome.Failed, BrokerErrorCategory.Temporaril
 1154            return BrokerInitiationResult.Fail(error);
 155        }
 156        finally
 157        {
 15158            DisposeSecrets(secrets);
 159        }
 14160        await stateStore.PutAsync(BrokerTransactionPurpose.ExternalSignIn.ToString(), transaction.HandleHash, transactio
 14161        await RecordOutcomeAsync("external", "initiate", SecurityEventOutcome.Succeeded, null, targetTenantId, connectio
 14162        return BrokerInitiationResult.Redirect(adapterRequest.NavigationUri);
 20163    }
 164
 165    public async ValueTask<BrokerCallbackResult> CompleteCallbackAsync(string connectionKey, string state, IReadOnlyDict
 166    {
 14167        var taken = await stateStore.TryTakeAsync<BrokerTransaction>(BrokerTransactionPurpose.ExternalSignIn.ToString(),
 14168        if (taken is not TakeResult<BrokerTransaction>.Taken { Value: var transaction })
 1169            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(taken is TakeResult<Br
 13170        if (!string.Equals(transaction.ConnectionKey, ConnectionRevisionCalculator.NormalizeKey(connectionKey), StringCo
 0171            return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.InvalidRequest, cancellationToken);
 172
 13173        var connection = await connectionRegistry.FindByIdAsync(transaction.TenantId, transaction.ConnectionId!, cancell
 13174        if (connection is null || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection.Arc
 2175            return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.MethodUnavailable, cancellationToken)
 11176        if (!string.Equals(connection.Connection.MaterialRevision, transaction.ConnectionMaterialRevision, StringCompari
 1177            return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.FlowChanged, cancellationToken);
 178
 20179        var adapter = adapters.FirstOrDefault(x => string.Equals(x.Type, connection.Connection.AdapterType, StringCompar
 10180        if (adapter is null)
 0181            return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.MethodUnavailable, cancellationToken)
 182
 183        try
 184        {
 10185            var secrets = await ResolveSecretsAsync(connection.Connection.SecretBindings, cancellationToken);
 186            try
 187            {
 10188                if (!string.Equals(transaction.SecretGenerationFingerprint, GetSecretFingerprint(secrets), StringCompari
 1189                    return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.FlowChanged, cancellationToke
 190
 9191                var originalPayload = transaction.ProtectedPayload;
 9192                transaction.ProtectedPayload = dataProtectionProvider.CreateProtector("Elsa.ExternalAuthentication.Adapt
 193                ExternalAuthenticationResult authentication;
 194                try
 195                {
 9196                    authentication = await adapter.AuthenticateCallbackAsync(new ExternalCallbackContext(connection, sec
 6197                }
 198                finally
 199                {
 9200                    transaction.ProtectedPayload = originalPayload;
 201                }
 202
 6203                using var upstreamLogoutHint = authentication.UpstreamLogoutHint;
 6204                var resolution = await identityResolver.ResolveAsync(new ExternalIdentityResolutionContext(transaction.T
 6205                var grantResult = await permissionGrantResolver.ResolveAsync(new PermissionGrantResolutionContext(
 6206                    transaction.TenantId,
 6207                    resolution.UserId,
 6208                    connection,
 6209                    authentication.Identity,
 6210                    authentication.ProjectedClaims), cancellationToken);
 6211                var session = new ExternalAuthenticationSession
 6212                {
 6213                    Id = CreateOpaqueValue(),
 6214                    AuthenticationClientId = transaction.ClientId,
 6215                    TenantId = transaction.TenantId,
 6216                    UserId = resolution.UserId,
 6217                    ConnectionKey = ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
 6218                    ConnectionMaterialRevision = connection.Connection.MaterialRevision,
 6219                    SecretGenerationFingerprint = transaction.SecretGenerationFingerprint,
 6220                    Issuer = authentication.Identity.Issuer,
 6221                    SubjectHash = Hash(authentication.Identity.Subject),
 6222                    ExternalGrants = grantResult.Grants,
 6223                    ProtectedUpstreamLogoutHint = connection.Connection.UpstreamLogoutMode == UpstreamLogoutMode.Disable
 6224                        ? null
 6225                        : dataProtectionProvider.CreateProtector("Elsa.ExternalAuthentication.UpstreamLogoutHint.v1").Pr
 6226                    StartedAt = clock.UtcNow,
 6227                    LastRefreshedAt = clock.UtcNow,
 6228                    ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.MaximumSessionAge),
 6229                    RefreshExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.MaximumSessionAge)
 6230                };
 6231                await sessionStore.SaveAsync(session, cancellationToken);
 6232                var code = CreateOpaqueValue();
 6233                await grantStore.SaveAsync(new AuthorizationGrant { CodeHash = Hash(code), ClientId = transaction.Client
 6234                if (notifier is not null)
 0235                    await notifier.PublishAsync(new ExternalSignInCompleted(
 0236                    ExternalAuthenticationSecurityNotifier.Context(null, transaction.TenantId, connection.Connection.Id,
 0237                    session.Id,
 0238                    connection.Connection.AdapterType), cancellationToken);
 6239                var callbackResult = await CallbackOutcomeAsync(BrokerCallbackResult.Redirect(AppendCallbackParameters(t
 6240                if (identityResolver is IExternalIdentitySignInTracker signInTracker)
 241                {
 4242                    var signInRecorded = await signInTracker.RecordSuccessfulSignInAsync(
 4243                        transaction.TenantId,
 4244                        connection.Connection.Key,
 4245                        authentication.Identity,
 4246                        resolution.UserId,
 4247                        clock.UtcNow,
 4248                        cancellationToken);
 4249                    if (!signInRecorded)
 0250                        throw new InvalidOperationException("The external identity link changed while sign-in was comple
 251                }
 6252                return callbackResult;
 253            }
 254            finally
 255            {
 10256                DisposeSecrets(secrets);
 257            }
 258        }
 0259        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 260        {
 0261            throw;
 262        }
 263        catch
 264        {
 3265            return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.AuthenticationFailed, cancellationTok
 266        }
 14267    }
 268
 269    public async ValueTask<BrokerCallbackResult> InitiateLocalAsync(LocalBrokerAuthorizationRequest request, string targ
 270    {
 271        AuthenticationClient client;
 272        try
 273        {
 2274            client = EnsureClient(request.ClientId);
 2275            ValidateAuthorizationRequest(request, client);
 2276        }
 277        catch (InvalidOperationException)
 278        {
 0279            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.In
 280        }
 2281        if (!options.Value.LocalLogin.IsEnabled)
 0282            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Me
 283        Elsa.Identity.Entities.User? user;
 284        try
 285        {
 2286            user = await credentialsValidator.ValidateAsync(request.Username.Trim(), request.Password, cancellationToken
 2287        }
 0288        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 289        {
 0290            throw;
 291        }
 292        catch
 293        {
 0294            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Se
 295        }
 2296        if (user is null || !string.Equals(user.TenantId.NormalizeTenantId(), targetTenantId.NormalizeTenantId(), String
 0297            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Au
 298
 2299        var code = CreateOpaqueValue();
 2300        await grantStore.SaveAsync(new AuthorizationGrant { CodeHash = Hash(code), ClientId = request.ClientId, Callback
 2301        return await CallbackOutcomeAsync(BrokerCallbackResult.Redirect(AppendCallbackParameters(request.RedirectUri, co
 2302    }
 303
 304    public async ValueTask<BrokerTokenResult> ExchangeAsync(BrokerTokenRequest request, CancellationToken cancellationTo
 305    {
 306        AuthenticationClient client;
 307        try
 308        {
 7309            client = EnsureClient(request.ClientId);
 7310            await ValidateExchangeClientAsync(client, request, cancellationToken);
 4311        }
 0312        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 313        {
 0314            throw;
 315        }
 316        catch
 317        {
 3318            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidR
 319        }
 320
 4321        if (!string.Equals(request.GrantType, "refresh_token", StringComparison.Ordinal) && !string.Equals(request.Grant
 0322            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidR
 4323        if (string.Equals(request.GrantType, "refresh_token", StringComparison.Ordinal))
 324        {
 2325            using var refreshToken = new SensitiveString(request.RefreshToken ?? string.Empty);
 326            try
 327            {
 2328                var token = refreshToken.Reveal();
 2329                var response = IsJwt(token)
 2330                    ? await RefreshLocalAsync(token, cancellationToken)
 2331                    : await tokenIssuer.RefreshAsync(request.ClientId, refreshToken, cancellationToken);
 2332                return await TokenOutcomeAsync(response is null
 2333                    ? BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.AccessDenied))
 2334                    : BrokerTokenResult.Success(response), "refresh", "exchange", cancellationToken);
 335            }
 0336            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
 0337            catch { return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.
 0338        }
 339
 2340        var grantResult = await grantStore.TryTakeAsync(Hash(request.Code ?? string.Empty), cancellationToken);
 2341        if (grantResult is not TakeResult<AuthorizationGrant>.Taken { Value: var grant })
 0342            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidR
 2343        if (!string.Equals(grant.ClientId, request.ClientId, StringComparison.Ordinal) || request.RedirectUri != grant.C
 1344            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidR
 345
 1346        if (grant.ExternalSessionId is { } sessionId)
 347        {
 0348            var session = await sessionStore.FindByIdAsync(sessionId, cancellationToken);
 0349            if (session is null || session.RevokedAt != null || session.ExpiresAt <= clock.UtcNow || !string.Equals(sess
 0350                return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Acce
 0351            var connection = await connectionRegistry.FindByKeyAsync(session.TenantId, session.ConnectionKey, cancellati
 0352            if (connection is null || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection
 0353                return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Acce
 354            try
 355            {
 0356                return await TokenOutcomeAsync(BrokerTokenResult.Success(await tokenIssuer.IssueAsync(session, cancellat
 357            }
 0358            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 359            {
 0360                throw;
 361            }
 362            catch
 363            {
 0364                return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Serv
 365            }
 0366        }
 367
 1368        using var tenantContext = tenantAccessor.PushContext(new Tenant { Id = grant.TenantId, Name = grant.TenantId });
 1369        var user = await userProvider.FindAsync(new UserFilter { Id = grant.UserId }, cancellationToken);
 1370        if (user is null)
 0371            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.AccessDe
 372        try
 373        {
 1374            var roles = (await roleProvider.FindByIdsAsync(user.Roles, cancellationToken)).ToArray();
 3375            var context = new TokenIssuanceContext(user, roles.Select(x => x.Name).ToArray(), roles.SelectMany(x => x.Pe
 1376            var access = await elsaTokenService.IssueAccessTokenAsync(context, cancellationToken);
 1377            var refresh = await elsaTokenService.IssueRefreshTokenAsync(context, cancellationToken);
 1378            return await TokenOutcomeAsync(BrokerTokenResult.Success(new ExternalTokenResponse(
 1379                access.Token,
 1380                "Bearer",
 1381                SecondsUntil(access.ExpiresAt),
 1382                refresh.Token,
 1383                SecondsUntil(refresh.ExpiresAt),
 1384                0)), "token_exchange", "issue", cancellationToken);
 385        }
 0386        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 387        {
 0388            throw;
 389        }
 390        catch
 391        {
 0392            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.ServerEr
 393        }
 7394    }
 395
 396    private async ValueTask<ExternalTokenResponse?> RefreshLocalAsync(string refreshToken, CancellationToken cancellatio
 397    {
 1398        var tokens = await identityRefreshTokenService.RefreshAsync(refreshToken, cancellationToken);
 399
 1400        if (tokens is null)
 0401            return null;
 402
 1403        return new ExternalTokenResponse(
 1404            tokens.AccessToken,
 1405            "Bearer",
 1406            SecondsUntil(GetExpiresAt(tokens.AccessToken)),
 1407            tokens.RefreshToken,
 1408            SecondsUntil(GetExpiresAt(tokens.RefreshToken)),
 1409            0);
 1410    }
 411
 412    private long SecondsUntil(DateTimeOffset? expiresAt) =>
 4413        expiresAt is null ? 0 : Math.Max(0, (long)(expiresAt.Value - clock.UtcNow).TotalSeconds);
 414
 415    private static DateTimeOffset GetExpiresAt(string token) =>
 2416        new(new JsonWebTokenHandler().ReadJsonWebToken(token).ValidTo, TimeSpan.Zero);
 417
 572418    private static bool IsJwt(string token) => token.Count(x => x == '.') == 2;
 419
 420    public async ValueTask<BrokerLogoutResult> LogoutAsync(BrokerLogoutRequest request, string externalSessionId, Cancel
 421    {
 422        AuthenticationClient client;
 423        try
 424        {
 0425            client = EnsureClient(request.ClientId);
 0426            if (!client.LogoutCallbackUris.Contains(request.PostLogoutRedirectUri))
 0427                throw new InvalidOperationException();
 0428        }
 429        catch (InvalidOperationException)
 430        {
 0431            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Invali
 432        }
 0433        if (!string.Equals(request.Mode, "local", StringComparison.OrdinalIgnoreCase) && !string.Equals(request.Mode, "u
 0434            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Invali
 435
 0436        var session = await sessionStore.FindByIdAsync(externalSessionId, cancellationToken);
 0437        if (session is null || !string.Equals(session.AuthenticationClientId, request.ClientId, StringComparison.Ordinal
 0438            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Access
 439
 0440        var revoked = await sessionStore.RevokeAsync(session.Id, "logout", clock.UtcNow, cancellationToken);
 0441        if (revoked && notifier is not null)
 0442            await notifier.PublishAsync(new ExternalAuthenticationSessionRevoked(
 0443                ExternalAuthenticationSecurityNotifier.Context(null, session.TenantId, session.ConnectionKey, session.Us
 0444                session.Id,
 0445                "logout"), cancellationToken);
 0446        var connection = await connectionRegistry.FindByKeyAsync(session.TenantId, session.ConnectionKey, cancellationTo
 0447        var wantsUpstream = string.Equals(request.Mode, "upstream", StringComparison.OrdinalIgnoreCase)
 0448            || connection?.Connection.UpstreamLogoutMode == UpstreamLogoutMode.Always;
 0449        if (!wantsUpstream)
 0450            return await LogoutOutcomeAsync(BrokerLogoutResult.Complete(request.PostLogoutRedirectUri), "complete", canc
 0451        if (connection is null || connection.Connection.UpstreamLogoutMode == UpstreamLogoutMode.Disabled)
 0452            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Invali
 453
 0454        var adapter = adapters.FirstOrDefault(x => string.Equals(x.Type, connection.Connection.AdapterType, StringCompar
 0455        if (adapter is null)
 0456            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Method
 457
 0458        var state = CreateOpaqueValue();
 0459        var transaction = new BrokerTransaction
 0460        {
 0461            HandleHash = Hash(state),
 0462            Purpose = BrokerTransactionPurpose.UpstreamLogout,
 0463            ClientId = request.ClientId,
 0464            CallbackUri = request.PostLogoutRedirectUri,
 0465            ReturnPath = "/",
 0466            TenantId = session.TenantId,
 0467            ConnectionId = connection.Connection.Id,
 0468            ConnectionKey = ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
 0469            ConnectionMaterialRevision = session.ConnectionMaterialRevision,
 0470            PkceChallenge = string.Empty,
 0471            ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.BrokerTransactionLifetime)
 0472        };
 0473        IReadOnlyDictionary<string, ResolvedSecretBinding> logoutSecrets = new Dictionary<string, ResolvedSecretBinding>
 474        ExternalLogoutRequest? upstream;
 475        try
 476        {
 0477            logoutSecrets = await ResolveSecretsAsync(connection.Connection.SecretBindings, cancellationToken);
 0478            using var upstreamLogoutHint = session.ProtectedUpstreamLogoutHint is { Length: > 0 }
 0479                ? new SensitiveString(Encoding.UTF8.GetString(dataProtectionProvider.CreateProtector("Elsa.ExternalAuthe
 0480                : null;
 0481            upstream = await adapter.CreateLogoutRequestAsync(new ExternalLogoutContext(connection, logoutSecrets, trans
 0482            transaction.ProtectedPayload = dataProtectionProvider.CreateProtector("Elsa.ExternalAuthentication.AdapterPa
 0483        }
 0484        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 485        {
 0486            throw;
 487        }
 488        catch
 489        {
 0490            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Tempor
 491        }
 492        finally
 493        {
 0494            DisposeSecrets(logoutSecrets);
 495        }
 0496        if (upstream is null)
 0497            return await LogoutOutcomeAsync(BrokerLogoutResult.Complete(request.PostLogoutRedirectUri), "upstream", canc
 498
 0499        await stateStore.PutAsync(BrokerTransactionPurpose.UpstreamLogout.ToString(), transaction.HandleHash, transactio
 0500        var continuationHandle = CreateOpaqueValue();
 0501        await stateStore.PutAsync("UpstreamLogoutContinue", Hash(continuationHandle), new BrokerTransaction
 0502        {
 0503            HandleHash = Hash(continuationHandle), Purpose = BrokerTransactionPurpose.UpstreamLogout, ClientId = request
 0504            CallbackUri = request.PostLogoutRedirectUri, ReturnPath = upstream.NavigationUri.AbsoluteUri, TenantId = ses
 0505            ExpiresAt = transaction.ExpiresAt
 0506        }, transaction.ExpiresAt, cancellationToken);
 0507        return await LogoutOutcomeAsync(BrokerLogoutResult.Navigate(new Uri($"/external-authentication/logout/continue/{
 0508    }
 509
 510    public async ValueTask<BrokerCallbackResult> CompleteLogoutAsync(string connectionKey, string state, CancellationTok
 511    {
 0512        var taken = await stateStore.TryTakeAsync<BrokerTransaction>(BrokerTransactionPurpose.UpstreamLogout.ToString(),
 0513        if (taken is not TakeResult<BrokerTransaction>.Taken { Value: var transaction })
 0514            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(taken is TakeResult<Br
 0515        if (!string.Equals(transaction.ConnectionKey, ConnectionRevisionCalculator.NormalizeKey(connectionKey), StringCo
 0516            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.In
 0517        var connection = await connectionRegistry.FindByIdAsync(transaction.TenantId, transaction.ConnectionId!, cancell
 0518        return connection is null || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection.
 0519            ? await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.FlowCha
 0520            : await CallbackOutcomeAsync(BrokerCallbackResult.Redirect(transaction.CallbackUri), "logout", "callback", t
 0521    }
 522
 523    public async ValueTask<BrokerLogoutResult> ContinueLogoutAsync(string handle, CancellationToken cancellationToken = 
 524    {
 0525        var taken = await stateStore.TryTakeAsync<BrokerTransaction>("UpstreamLogoutContinue", Hash(handle), cancellatio
 0526        if (taken is not TakeResult<BrokerTransaction>.Taken { Value: var transaction } || !Uri.TryCreate(transaction.Re
 0527            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Invali
 0528        return await LogoutOutcomeAsync(BrokerLogoutResult.Navigate(navigation), "continue", cancellationToken);
 0529    }
 530
 62531    private AuthenticationClient EnsureClient(string clientId) => options.Value.Clients.FirstOrDefault(x => x.IsEnabled 
 532    private static void ValidateAuthorizationRequest(BrokerAuthorizationRequest request, AuthenticationClient client)
 533    {
 22534        if (!string.Equals(request.ResponseType, "code", StringComparison.Ordinal) || !string.Equals(request.CodeChallen
 4535            throw new InvalidOperationException("The broker authorization request is invalid.");
 18536    }
 2537    private static bool VerifyPkce(string challenge, string? verifier) => !string.IsNullOrWhiteSpace(verifier) && string
 29538    private static string CreateOpaqueValue() => Base64Url(RandomNumberGenerator.GetBytes(32));
 539
 45540    private string Hash(string value) => handleHasher.Hash(value);
 31541    private static string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replac
 542    private static Uri AppendCallbackParameters(Uri uri, string code, string? clientState)
 543    {
 8544        var result = AppendQuery(uri, "code", code);
 8545        return string.IsNullOrWhiteSpace(clientState) ? result : AppendQuery(result, "state", clientState);
 546    }
 46547    private static Uri AppendQuery(Uri uri, string key, string value) { var separator = string.IsNullOrEmpty(uri.Query) 
 548    private async ValueTask<BrokerCallbackResult> FailTrustedCallbackAsync(BrokerTransaction transaction, BrokerErrorCat
 549    {
 7550        var error = BrokerErrorFactory.Create(category);
 7551        return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(error, AppendQuery(AppendQuery(transaction.CallbackU
 7552    }
 553
 554    private async ValueTask<IReadOnlyDictionary<string, ResolvedSecretBinding>> ResolveSecretsAsync(IDictionary<string, 
 555    {
 25556        var resolved = new Dictionary<string, ResolvedSecretBinding>(StringComparer.Ordinal);
 557        try
 558        {
 54559            foreach (var (name, binding) in bindings)
 560            {
 4561                var resolver = secretBindingResolvers.FirstOrDefault(x => string.Equals(x.Type, binding.ResolverType, St
 2562                    ?? throw new InvalidOperationException("A required secret binding resolver is unavailable.");
 2563                resolved[name] = await resolver.ResolveAsync(binding, cancellationToken);
 564            }
 25565            return resolved;
 566        }
 0567        catch
 568        {
 0569            DisposeSecrets(resolved);
 0570            throw;
 571        }
 25572    }
 573
 29574    private static string GetSecretFingerprint(IReadOnlyDictionary<string, ResolvedSecretBinding> secrets) => Convert.To
 575    private static void DisposeSecrets(IReadOnlyDictionary<string, ResolvedSecretBinding> secrets)
 576    {
 54577        foreach (var secret in secrets.Values)
 2578            secret.Value.Dispose();
 25579    }
 580
 581    private async ValueTask ValidateExchangeClientAsync(AuthenticationClient client, BrokerTokenRequest request, Cancell
 582    {
 7583        if (client.ClientType == AuthenticationClientType.Public)
 584        {
 6585            if (string.IsNullOrWhiteSpace(request.Origin) || !client.AllowedOrigins.Contains(request.Origin) || !string.
 2586                throw new InvalidOperationException();
 4587            return;
 588        }
 589
 1590        if (client.SecretBinding is null || string.IsNullOrWhiteSpace(request.ClientSecret) || !string.Equals(client.Cli
 1591            throw new InvalidOperationException();
 0592        var resolver = secretBindingResolvers.FirstOrDefault(x => string.Equals(x.Type, client.SecretBinding.ResolverTyp
 0593            ?? throw new InvalidOperationException();
 0594        using var configured = (await resolver.ResolveAsync(client.SecretBinding, cancellationToken)).Value;
 0595        using var supplied = new SensitiveString(request.ClientSecret);
 0596        if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(configured.Reveal()), Encoding.UTF8.GetBytes
 0597            throw new InvalidOperationException();
 4598    }
 599}
 600
 601public interface IExternalAuthenticationBroker
 602{
 603    ValueTask<IReadOnlyCollection<LoginMethod>> DiscoverAsync(string targetTenantId, string clientId, CancellationToken 
 604    ValueTask<BrokerInitiationResult> InitiateExternalAsync(BrokerAuthorizationRequest request, string targetTenantId, C
 605    ValueTask<BrokerCallbackResult> CompleteCallbackAsync(string connectionKey, string state, IReadOnlyDictionary<string
 606    ValueTask<BrokerCallbackResult> InitiateLocalAsync(LocalBrokerAuthorizationRequest request, string targetTenantId, C
 607    ValueTask<BrokerTokenResult> ExchangeAsync(BrokerTokenRequest request, CancellationToken cancellationToken = default
 608    ValueTask<BrokerLogoutResult> LogoutAsync(BrokerLogoutRequest request, string externalSessionId, CancellationToken c
 609    ValueTask<BrokerLogoutResult> ContinueLogoutAsync(string handle, CancellationToken cancellationToken = default);
 610    ValueTask<BrokerCallbackResult> CompleteLogoutAsync(string connectionKey, string state, CancellationToken cancellati
 611}
 612
 613public record BrokerAuthorizationRequest(string ClientId, Uri RedirectUri, string ResponseType, string CodeChallenge, st
 614public sealed record LocalBrokerAuthorizationRequest(string ClientId, Uri RedirectUri, string ResponseType, string CodeC
 615public sealed record BrokerLogoutRequest(string ClientId, Uri PostLogoutRedirectUri, string Mode);
 616public sealed record BrokerInitiationResult(Uri? NavigationUri, PublicBrokerError? Error) { public static BrokerInitiati
 617public sealed record BrokerCallbackResult(Uri? RedirectUri, PublicBrokerError? Error) { public static BrokerCallbackResu
 618public sealed record BrokerTokenRequest(string GrantType, string ClientId, Uri? RedirectUri, string? Code, string? CodeV
 619public sealed record BrokerTokenResult(ExternalTokenResponse? Token, PublicBrokerError? Error) { public static BrokerTok
 620public sealed record BrokerLogoutResult(bool Completed, Uri? NavigationUri, Uri? RedirectUri, PublicBrokerError? Error)
 621{
 622    public static BrokerLogoutResult Complete(Uri redirectUri) => new(true, null, redirectUri, null);
 623    public static BrokerLogoutResult Navigate(Uri navigationUri) => new(false, navigationUri, null, null);
 624    public static BrokerLogoutResult Fail(PublicBrokerError error) => new(false, null, null, error);
 625}

Methods/Properties

.ctor(Elsa.ExternalAuthentication.Contracts.IIdentityProviderConnectionRegistry,Elsa.ExternalAuthentication.Contracts.IIdentityProviderConnectionValidityAssessor,System.Collections.Generic.IEnumerable`1<Elsa.ExternalAuthentication.Contracts.IExternalAuthenticationAdapter>,System.Collections.Generic.IEnumerable`1<Elsa.ExternalAuthentication.Contracts.ISecretBindingResolver>,Elsa.ExternalAuthentication.Contracts.IExternalAuthenticationHandleHasher,Microsoft.AspNetCore.DataProtection.IDataProtectionProvider,Elsa.ExternalAuthentication.Contracts.IExternalIdentityResolver,Elsa.ExternalAuthentication.Contracts.IPermissionGrantResolver,Elsa.ExternalAuthentication.Contracts.IExternalAuthenticationStateStore,Elsa.ExternalAuthentication.Contracts.IAuthorizationGrantStore,Elsa.ExternalAuthentication.Contracts.IExternalAuthenticationSessionStore,Elsa.ExternalAuthentication.Contracts.IExternalAuthenticationTokenIssuer,Elsa.Identity.Contracts.IUserCredentialsValidator,Elsa.Identity.Contracts.IUserProvider,Elsa.Identity.Contracts.IRoleProvider,Elsa.Identity.Contracts.IElsaTokenService,Elsa.Identity.Contracts.IIdentityRefreshTokenService,Elsa.Common.Multitenancy.ITenantAccessor,Elsa.Common.ISystemClock,Microsoft.Extensions.Options.IOptions`1<Elsa.ExternalAuthentication.Options.ExternalAuthenticationOptions>,Elsa.ExternalAuthentication.Services.ExternalAuthenticationSecurityNotifier)
RecordOutcomeAsync(System.String,System.String,Elsa.ExternalAuthentication.Notifications.SecurityEventOutcome,System.Nullable`1<Elsa.ExternalAuthentication.Models.BrokerErrorCategory>,System.String,System.String,System.String,System.Threading.CancellationToken)
RecordOutcomeCategoryAsync(System.String,System.String,Elsa.ExternalAuthentication.Notifications.SecurityEventOutcome,System.String,System.String,System.String,System.String,System.Threading.CancellationToken)
CallbackOutcomeAsync()
TokenOutcomeAsync()
LogoutOutcomeAsync()
DiscoverAsync()
InitiateExternalAsync()
CompleteCallbackAsync()
InitiateLocalAsync()
ExchangeAsync()
RefreshLocalAsync()
SecondsUntil(System.Nullable`1<System.DateTimeOffset>)
GetExpiresAt(System.String)
IsJwt(System.String)
LogoutAsync()
CompleteLogoutAsync()
ContinueLogoutAsync()
EnsureClient(System.String)
ValidateAuthorizationRequest(Elsa.ExternalAuthentication.Services.BrokerAuthorizationRequest,Elsa.ExternalAuthentication.Models.AuthenticationClient)
VerifyPkce(System.String,System.String)
CreateOpaqueValue()
Hash(System.String)
Base64Url(System.Byte[])
AppendCallbackParameters(System.Uri,System.String,System.String)
AppendQuery(System.Uri,System.String,System.String)
FailTrustedCallbackAsync()
ResolveSecretsAsync()
GetSecretFingerprint(System.Collections.Generic.IReadOnlyDictionary`2<System.String,Elsa.ExternalAuthentication.Contracts.ResolvedSecretBinding>)
DisposeSecrets(System.Collections.Generic.IReadOnlyDictionary`2<System.String,Elsa.ExternalAuthentication.Contracts.ResolvedSecretBinding>)
ValidateExchangeClientAsync()