< 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: 250
Uncovered lines: 137
Coverable lines: 387
Total lines: 629
Line coverage: 64.5%
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.83%
InitiateLocalAsync()50%7666.66%
ExchangeAsync()45.65%1684661.4%
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(connection, secrets, transaction, state, 
 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(connection, secrets, transaction, state
 6197                }
 198                finally
 199                {
 9200                    transaction.ProtectedPayload = originalPayload;
 201                }
 202
 6203                using var upstreamLogoutHint = authentication.UpstreamLogoutHint;
 6204                var resolution = await identityResolver.ResolveAsync(new(transaction.TenantId, connection, authenticatio
 6205                var grantResult = await permissionGrantResolver.ResolveAsync(new(
 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()
 6234                    { CodeHash = Hash(code), ClientId = transaction.ClientId, CallbackUri = transaction.CallbackUri, Ten
 6235                if (notifier is not null)
 0236                    await notifier.PublishAsync(new ExternalSignInCompleted(
 0237                    ExternalAuthenticationSecurityNotifier.Context(null, transaction.TenantId, connection.Connection.Id,
 0238                    session.Id,
 0239                    connection.Connection.AdapterType), cancellationToken);
 6240                var callbackResult = await CallbackOutcomeAsync(BrokerCallbackResult.Redirect(AppendCallbackParameters(t
 6241                if (identityResolver is IExternalIdentitySignInTracker signInTracker)
 242                {
 4243                    var signInRecorded = await signInTracker.RecordSuccessfulSignInAsync(
 4244                        transaction.TenantId,
 4245                        connection.Connection.Key,
 4246                        authentication.Identity,
 4247                        resolution.UserId,
 4248                        clock.UtcNow,
 4249                        cancellationToken);
 4250                    if (!signInRecorded)
 0251                        throw new InvalidOperationException("The external identity link changed while sign-in was comple
 252                }
 6253                return callbackResult;
 254            }
 255            finally
 256            {
 10257                DisposeSecrets(secrets);
 258            }
 259        }
 0260        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 261        {
 0262            throw;
 263        }
 264        catch
 265        {
 3266            return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.AuthenticationFailed, cancellationTok
 267        }
 14268    }
 269
 270    public async ValueTask<BrokerCallbackResult> InitiateLocalAsync(LocalBrokerAuthorizationRequest request, string targ
 271    {
 272        AuthenticationClient client;
 273        try
 274        {
 2275            client = EnsureClient(request.ClientId);
 2276            ValidateAuthorizationRequest(request, client);
 2277        }
 278        catch (InvalidOperationException)
 279        {
 0280            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.In
 281        }
 2282        if (!options.Value.LocalLogin.IsEnabled)
 0283            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Me
 284        Identity.Entities.User? user;
 285        try
 286        {
 2287            user = await credentialsValidator.ValidateAsync(request.Username.Trim(), request.Password, cancellationToken
 2288        }
 0289        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 290        {
 0291            throw;
 292        }
 293        catch
 294        {
 0295            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Se
 296        }
 2297        if (user is null || !string.Equals(user.TenantId.NormalizeTenantId(), targetTenantId.NormalizeTenantId(), String
 0298            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Au
 299
 2300        var code = CreateOpaqueValue();
 2301        await grantStore.SaveAsync(new()
 2302            { CodeHash = Hash(code), ClientId = request.ClientId, CallbackUri = request.RedirectUri, TenantId = targetTe
 2303        return await CallbackOutcomeAsync(BrokerCallbackResult.Redirect(AppendCallbackParameters(request.RedirectUri, co
 2304    }
 305
 306    public async ValueTask<BrokerTokenResult> ExchangeAsync(BrokerTokenRequest request, CancellationToken cancellationTo
 307    {
 308        AuthenticationClient client;
 309        try
 310        {
 7311            client = EnsureClient(request.ClientId);
 7312            await ValidateExchangeClientAsync(client, request, cancellationToken);
 4313        }
 0314        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 315        {
 0316            throw;
 317        }
 318        catch
 319        {
 3320            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidR
 321        }
 322
 4323        if (!string.Equals(request.GrantType, "refresh_token", StringComparison.Ordinal) && !string.Equals(request.Grant
 0324            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidR
 4325        if (string.Equals(request.GrantType, "refresh_token", StringComparison.Ordinal))
 326        {
 2327            using var refreshToken = new SensitiveString(request.RefreshToken ?? string.Empty);
 328            try
 329            {
 2330                var token = refreshToken.Reveal();
 2331                var response = IsJwt(token)
 2332                    ? await RefreshLocalAsync(token, cancellationToken)
 2333                    : await tokenIssuer.RefreshAsync(request.ClientId, refreshToken, cancellationToken);
 2334                return await TokenOutcomeAsync(response is null
 2335                    ? BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.AccessDenied))
 2336                    : BrokerTokenResult.Success(response), "refresh", "exchange", cancellationToken);
 337            }
 0338            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
 0339            catch { return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.
 0340        }
 341
 2342        var grantResult = await grantStore.TryTakeAsync(Hash(request.Code ?? string.Empty), cancellationToken);
 2343        if (grantResult is not TakeResult<AuthorizationGrant>.Taken { Value: var grant })
 0344            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidR
 2345        if (!string.Equals(grant.ClientId, request.ClientId, StringComparison.Ordinal) || request.RedirectUri != grant.C
 1346            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidR
 347
 1348        if (grant.ExternalSessionId is { } sessionId)
 349        {
 0350            var session = await sessionStore.FindByIdAsync(sessionId, cancellationToken);
 0351            if (session is null || session.RevokedAt != null || session.ExpiresAt <= clock.UtcNow || !string.Equals(sess
 0352                return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Acce
 0353            var connection = await connectionRegistry.FindByKeyAsync(session.TenantId, session.ConnectionKey, cancellati
 0354            if (connection is null || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection
 0355                return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Acce
 356            try
 357            {
 0358                return await TokenOutcomeAsync(BrokerTokenResult.Success(await tokenIssuer.IssueAsync(session, cancellat
 359            }
 0360            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 361            {
 0362                throw;
 363            }
 364            catch
 365            {
 0366                return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Serv
 367            }
 0368        }
 369
 1370        using var tenantContext = tenantAccessor.PushContext(new()
 1371            { Id = grant.TenantId, Name = grant.TenantId });
 1372        var user = await userProvider.FindAsync(new()
 1373            { Id = grant.UserId }, cancellationToken);
 1374        if (user is null)
 0375            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.AccessDe
 376        try
 377        {
 1378            var roles = (await roleProvider.FindByIdsAsync(user.Roles, cancellationToken)).ToArray();
 3379            var context = new TokenIssuanceContext(user, roles.Select(x => x.Name).ToArray(), roles.SelectMany(x => x.Pe
 1380            var access = await elsaTokenService.IssueAccessTokenAsync(context, cancellationToken);
 1381            var refresh = await elsaTokenService.IssueRefreshTokenAsync(context, cancellationToken);
 1382            return await TokenOutcomeAsync(BrokerTokenResult.Success(new(
 1383                access.Token,
 1384                "Bearer",
 1385                SecondsUntil(access.ExpiresAt),
 1386                refresh.Token,
 1387                SecondsUntil(refresh.ExpiresAt),
 1388                0)), "token_exchange", "issue", cancellationToken);
 389        }
 0390        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 391        {
 0392            throw;
 393        }
 394        catch
 395        {
 0396            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.ServerEr
 397        }
 7398    }
 399
 400    private async ValueTask<ExternalTokenResponse?> RefreshLocalAsync(string refreshToken, CancellationToken cancellatio
 401    {
 1402        var tokens = await identityRefreshTokenService.RefreshAsync(refreshToken, cancellationToken);
 403
 1404        if (tokens is null)
 0405            return null;
 406
 1407        return new(
 1408            tokens.AccessToken,
 1409            "Bearer",
 1410            SecondsUntil(GetExpiresAt(tokens.AccessToken)),
 1411            tokens.RefreshToken,
 1412            SecondsUntil(GetExpiresAt(tokens.RefreshToken)),
 1413            0);
 1414    }
 415
 416    private long SecondsUntil(DateTimeOffset? expiresAt) =>
 4417        expiresAt is null ? 0 : Math.Max(0, (long)(expiresAt.Value - clock.UtcNow).TotalSeconds);
 418
 419    private static DateTimeOffset GetExpiresAt(string token) =>
 2420        new(new JsonWebTokenHandler().ReadJsonWebToken(token).ValidTo, TimeSpan.Zero);
 421
 572422    private static bool IsJwt(string token) => token.Count(x => x == '.') == 2;
 423
 424    public async ValueTask<BrokerLogoutResult> LogoutAsync(BrokerLogoutRequest request, string externalSessionId, Cancel
 425    {
 426        AuthenticationClient client;
 427        try
 428        {
 0429            client = EnsureClient(request.ClientId);
 0430            if (!client.LogoutCallbackUris.Contains(request.PostLogoutRedirectUri))
 0431                throw new InvalidOperationException();
 0432        }
 433        catch (InvalidOperationException)
 434        {
 0435            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Invali
 436        }
 0437        if (!string.Equals(request.Mode, "local", StringComparison.OrdinalIgnoreCase) && !string.Equals(request.Mode, "u
 0438            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Invali
 439
 0440        var session = await sessionStore.FindByIdAsync(externalSessionId, cancellationToken);
 0441        if (session is null || !string.Equals(session.AuthenticationClientId, request.ClientId, StringComparison.Ordinal
 0442            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Access
 443
 0444        var revoked = await sessionStore.RevokeAsync(session.Id, "logout", clock.UtcNow, cancellationToken);
 0445        if (revoked && notifier is not null)
 0446            await notifier.PublishAsync(new ExternalAuthenticationSessionRevoked(
 0447                ExternalAuthenticationSecurityNotifier.Context(null, session.TenantId, session.ConnectionKey, session.Us
 0448                session.Id,
 0449                "logout"), cancellationToken);
 0450        var connection = await connectionRegistry.FindByKeyAsync(session.TenantId, session.ConnectionKey, cancellationTo
 0451        var wantsUpstream = string.Equals(request.Mode, "upstream", StringComparison.OrdinalIgnoreCase)
 0452            || connection?.Connection.UpstreamLogoutMode == UpstreamLogoutMode.Always;
 0453        if (!wantsUpstream)
 0454            return await LogoutOutcomeAsync(BrokerLogoutResult.Complete(request.PostLogoutRedirectUri), "complete", canc
 0455        if (connection is null || connection.Connection.UpstreamLogoutMode == UpstreamLogoutMode.Disabled)
 0456            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Invali
 457
 0458        var adapter = adapters.FirstOrDefault(x => string.Equals(x.Type, connection.Connection.AdapterType, StringCompar
 0459        if (adapter is null)
 0460            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Method
 461
 0462        var state = CreateOpaqueValue();
 0463        var transaction = new BrokerTransaction
 0464        {
 0465            HandleHash = Hash(state),
 0466            Purpose = BrokerTransactionPurpose.UpstreamLogout,
 0467            ClientId = request.ClientId,
 0468            CallbackUri = request.PostLogoutRedirectUri,
 0469            ReturnPath = "/",
 0470            TenantId = session.TenantId,
 0471            ConnectionId = connection.Connection.Id,
 0472            ConnectionKey = ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
 0473            ConnectionMaterialRevision = session.ConnectionMaterialRevision,
 0474            PkceChallenge = string.Empty,
 0475            ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.BrokerTransactionLifetime)
 0476        };
 0477        IReadOnlyDictionary<string, ResolvedSecretBinding> logoutSecrets = new Dictionary<string, ResolvedSecretBinding>
 478        ExternalLogoutRequest? upstream;
 479        try
 480        {
 0481            logoutSecrets = await ResolveSecretsAsync(connection.Connection.SecretBindings, cancellationToken);
 0482            using var upstreamLogoutHint = session.ProtectedUpstreamLogoutHint is { Length: > 0 }
 0483                ? new SensitiveString(Encoding.UTF8.GetString(dataProtectionProvider.CreateProtector("Elsa.ExternalAuthe
 0484                : null;
 0485            upstream = await adapter.CreateLogoutRequestAsync(new(connection, logoutSecrets, transaction, state, clock, 
 0486            transaction.ProtectedPayload = dataProtectionProvider.CreateProtector("Elsa.ExternalAuthentication.AdapterPa
 0487        }
 0488        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 489        {
 0490            throw;
 491        }
 492        catch
 493        {
 0494            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Tempor
 495        }
 496        finally
 497        {
 0498            DisposeSecrets(logoutSecrets);
 499        }
 0500        if (upstream is null)
 0501            return await LogoutOutcomeAsync(BrokerLogoutResult.Complete(request.PostLogoutRedirectUri), "upstream", canc
 502
 0503        await stateStore.PutAsync(BrokerTransactionPurpose.UpstreamLogout.ToString(), transaction.HandleHash, transactio
 0504        var continuationHandle = CreateOpaqueValue();
 0505        await stateStore.PutAsync("UpstreamLogoutContinue", Hash(continuationHandle), new BrokerTransaction
 0506        {
 0507            HandleHash = Hash(continuationHandle), Purpose = BrokerTransactionPurpose.UpstreamLogout, ClientId = request
 0508            CallbackUri = request.PostLogoutRedirectUri, ReturnPath = upstream.NavigationUri.AbsoluteUri, TenantId = ses
 0509            ExpiresAt = transaction.ExpiresAt
 0510        }, transaction.ExpiresAt, cancellationToken);
 0511        return await LogoutOutcomeAsync(BrokerLogoutResult.Navigate(new($"/external-authentication/logout/continue/{Uri.
 0512    }
 513
 514    public async ValueTask<BrokerCallbackResult> CompleteLogoutAsync(string connectionKey, string state, CancellationTok
 515    {
 0516        var taken = await stateStore.TryTakeAsync<BrokerTransaction>(BrokerTransactionPurpose.UpstreamLogout.ToString(),
 0517        if (taken is not TakeResult<BrokerTransaction>.Taken { Value: var transaction })
 0518            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(taken is TakeResult<Br
 0519        if (!string.Equals(transaction.ConnectionKey, ConnectionRevisionCalculator.NormalizeKey(connectionKey), StringCo
 0520            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.In
 0521        var connection = await connectionRegistry.FindByIdAsync(transaction.TenantId, transaction.ConnectionId!, cancell
 0522        return connection is null || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection.
 0523            ? await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.FlowCha
 0524            : await CallbackOutcomeAsync(BrokerCallbackResult.Redirect(transaction.CallbackUri), "logout", "callback", t
 0525    }
 526
 527    public async ValueTask<BrokerLogoutResult> ContinueLogoutAsync(string handle, CancellationToken cancellationToken = 
 528    {
 0529        var taken = await stateStore.TryTakeAsync<BrokerTransaction>("UpstreamLogoutContinue", Hash(handle), cancellatio
 0530        if (taken is not TakeResult<BrokerTransaction>.Taken { Value: var transaction } || !Uri.TryCreate(transaction.Re
 0531            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Invali
 0532        return await LogoutOutcomeAsync(BrokerLogoutResult.Navigate(navigation), "continue", cancellationToken);
 0533    }
 534
 62535    private AuthenticationClient EnsureClient(string clientId) => options.Value.Clients.FirstOrDefault(x => x.IsEnabled 
 536    private static void ValidateAuthorizationRequest(BrokerAuthorizationRequest request, AuthenticationClient client)
 537    {
 22538        if (!string.Equals(request.ResponseType, "code", StringComparison.Ordinal) || !string.Equals(request.CodeChallen
 4539            throw new InvalidOperationException("The broker authorization request is invalid.");
 18540    }
 2541    private static bool VerifyPkce(string challenge, string? verifier) => !string.IsNullOrWhiteSpace(verifier) && string
 29542    private static string CreateOpaqueValue() => Base64Url(RandomNumberGenerator.GetBytes(32));
 543
 45544    private string Hash(string value) => handleHasher.Hash(value);
 31545    private static string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replac
 546    private static Uri AppendCallbackParameters(Uri uri, string code, string? clientState)
 547    {
 8548        var result = AppendQuery(uri, "code", code);
 8549        return string.IsNullOrWhiteSpace(clientState) ? result : AppendQuery(result, "state", clientState);
 550    }
 46551    private static Uri AppendQuery(Uri uri, string key, string value) { var separator = string.IsNullOrEmpty(uri.Query) 
 552    private async ValueTask<BrokerCallbackResult> FailTrustedCallbackAsync(BrokerTransaction transaction, BrokerErrorCat
 553    {
 7554        var error = BrokerErrorFactory.Create(category);
 7555        return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(error, AppendQuery(AppendQuery(transaction.CallbackU
 7556    }
 557
 558    private async ValueTask<IReadOnlyDictionary<string, ResolvedSecretBinding>> ResolveSecretsAsync(IDictionary<string, 
 559    {
 25560        var resolved = new Dictionary<string, ResolvedSecretBinding>(StringComparer.Ordinal);
 561        try
 562        {
 54563            foreach (var (name, binding) in bindings)
 564            {
 4565                var resolver = secretBindingResolvers.FirstOrDefault(x => string.Equals(x.Type, binding.ResolverType, St
 2566                    ?? throw new InvalidOperationException("A required secret binding resolver is unavailable.");
 2567                resolved[name] = await resolver.ResolveAsync(binding, cancellationToken);
 568            }
 25569            return resolved;
 570        }
 0571        catch
 572        {
 0573            DisposeSecrets(resolved);
 0574            throw;
 575        }
 25576    }
 577
 29578    private static string GetSecretFingerprint(IReadOnlyDictionary<string, ResolvedSecretBinding> secrets) => Convert.To
 579    private static void DisposeSecrets(IReadOnlyDictionary<string, ResolvedSecretBinding> secrets)
 580    {
 54581        foreach (var secret in secrets.Values)
 2582            secret.Value.Dispose();
 25583    }
 584
 585    private async ValueTask ValidateExchangeClientAsync(AuthenticationClient client, BrokerTokenRequest request, Cancell
 586    {
 7587        if (client.ClientType == AuthenticationClientType.Public)
 588        {
 6589            if (string.IsNullOrWhiteSpace(request.Origin) || !client.AllowedOrigins.Contains(request.Origin) || !string.
 2590                throw new InvalidOperationException();
 4591            return;
 592        }
 593
 1594        if (client.SecretBinding is null || string.IsNullOrWhiteSpace(request.ClientSecret) || !string.Equals(client.Cli
 1595            throw new InvalidOperationException();
 0596        var resolver = secretBindingResolvers.FirstOrDefault(x => string.Equals(x.Type, client.SecretBinding.ResolverTyp
 0597            ?? throw new InvalidOperationException();
 0598        using var configured = (await resolver.ResolveAsync(client.SecretBinding, cancellationToken)).Value;
 0599        using var supplied = new SensitiveString(request.ClientSecret);
 0600        if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(configured.Reveal()), Encoding.UTF8.GetBytes
 0601            throw new InvalidOperationException();
 4602    }
 603}
 604
 605public interface IExternalAuthenticationBroker
 606{
 607    ValueTask<IReadOnlyCollection<LoginMethod>> DiscoverAsync(string targetTenantId, string clientId, CancellationToken 
 608    ValueTask<BrokerInitiationResult> InitiateExternalAsync(BrokerAuthorizationRequest request, string targetTenantId, C
 609    ValueTask<BrokerCallbackResult> CompleteCallbackAsync(string connectionKey, string state, IReadOnlyDictionary<string
 610    ValueTask<BrokerCallbackResult> InitiateLocalAsync(LocalBrokerAuthorizationRequest request, string targetTenantId, C
 611    ValueTask<BrokerTokenResult> ExchangeAsync(BrokerTokenRequest request, CancellationToken cancellationToken = default
 612    ValueTask<BrokerLogoutResult> LogoutAsync(BrokerLogoutRequest request, string externalSessionId, CancellationToken c
 613    ValueTask<BrokerLogoutResult> ContinueLogoutAsync(string handle, CancellationToken cancellationToken = default);
 614    ValueTask<BrokerCallbackResult> CompleteLogoutAsync(string connectionKey, string state, CancellationToken cancellati
 615}
 616
 617public record BrokerAuthorizationRequest(string ClientId, Uri RedirectUri, string ResponseType, string CodeChallenge, st
 618public sealed record LocalBrokerAuthorizationRequest(string ClientId, Uri RedirectUri, string ResponseType, string CodeC
 619public sealed record BrokerLogoutRequest(string ClientId, Uri PostLogoutRedirectUri, string Mode);
 620public sealed record BrokerInitiationResult(Uri? NavigationUri, PublicBrokerError? Error) { public static BrokerInitiati
 621public sealed record BrokerCallbackResult(Uri? RedirectUri, PublicBrokerError? Error) { public static BrokerCallbackResu
 622public sealed record BrokerTokenRequest(string GrantType, string ClientId, Uri? RedirectUri, string? Code, string? CodeV
 623public sealed record BrokerTokenResult(ExternalTokenResponse? Token, PublicBrokerError? Error) { public static BrokerTok
 624public sealed record BrokerLogoutResult(bool Completed, Uri? NavigationUri, Uri? RedirectUri, PublicBrokerError? Error)
 625{
 626    public static BrokerLogoutResult Complete(Uri redirectUri) => new(true, null, redirectUri, null);
 627    public static BrokerLogoutResult Navigate(Uri navigationUri) => new(false, navigationUri, null, null);
 628    public static BrokerLogoutResult Fail(PublicBrokerError error) => new(false, null, null, error);
 629}

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()