< Summary

Information
Class: Elsa.ExternalAuthentication.Services.BrokerInitiationResult
Assembly: Elsa.ExternalAuthentication
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs
Line coverage
100%
Covered lines: 1
Uncovered lines: 0
Coverable lines: 1
Total lines: 629
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_NavigationUri()100%11100%

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
 19public sealed class ExternalAuthenticationBroker(
 20    IIdentityProviderConnectionRegistry connectionRegistry,
 21    IIdentityProviderConnectionValidityAssessor validityAssessor,
 22    IEnumerable<IExternalAuthenticationAdapter> adapters,
 23    IEnumerable<ISecretBindingResolver> secretBindingResolvers,
 24    IExternalAuthenticationHandleHasher handleHasher,
 25    IDataProtectionProvider dataProtectionProvider,
 26    IExternalIdentityResolver identityResolver,
 27    IPermissionGrantResolver permissionGrantResolver,
 28    IExternalAuthenticationStateStore stateStore,
 29    IAuthorizationGrantStore grantStore,
 30    IExternalAuthenticationSessionStore sessionStore,
 31    IExternalAuthenticationTokenIssuer tokenIssuer,
 32    IUserCredentialsValidator credentialsValidator,
 33    IUserProvider userProvider,
 34    IRoleProvider roleProvider,
 35    IElsaTokenService elsaTokenService,
 36    IIdentityRefreshTokenService identityRefreshTokenService,
 37    ITenantAccessor tenantAccessor,
 38    ISystemClock clock,
 39    IOptions<ExternalAuthenticationOptions> options,
 40    ExternalAuthenticationSecurityNotifier? notifier = null) : IExternalAuthenticationBroker
 41{
 42    private ValueTask RecordOutcomeAsync(string flow, string stage, SecurityEventOutcome outcome, BrokerErrorCategory? c
 43        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    {
 47        return notifier?.PublishAsync(new ExternalAuthenticationOutcomeRecorded(
 48            ExternalAuthenticationSecurityNotifier.Context(null, tenantId, connectionId, userId, outcome, "External auth
 49    }
 50
 51    private async ValueTask<BrokerCallbackResult> CallbackOutcomeAsync(BrokerCallbackResult result, string flow, string 
 52    {
 53        await RecordOutcomeCategoryAsync(flow, stage, result.Error is null ? SecurityEventOutcome.Succeeded : SecurityEv
 54        return result;
 55    }
 56
 57    private async ValueTask<BrokerTokenResult> TokenOutcomeAsync(BrokerTokenResult result, string flow, string stage, Ca
 58    {
 59        await RecordOutcomeCategoryAsync(flow, stage, result.Error is null ? SecurityEventOutcome.Succeeded : SecurityEv
 60        return result;
 61    }
 62    private async ValueTask<BrokerLogoutResult> LogoutOutcomeAsync(BrokerLogoutResult result, string stage, Cancellation
 63    {
 64        await RecordOutcomeCategoryAsync("logout", stage, result.Error is null ? SecurityEventOutcome.Succeeded : Securi
 65        return result;
 66    }
 67
 68    public async ValueTask<IReadOnlyCollection<LoginMethod>> DiscoverAsync(string targetTenantId, string clientId, Cance
 69    {
 70        EnsureClient(clientId);
 71        var registry = await connectionRegistry.GetAsync(targetTenantId, cancellationToken);
 72        var advertisedIds = registry.LoginMethods.Select(method => method.Id).ToHashSet(StringComparer.Ordinal);
 73        var assessments = await Task.WhenAll(registry.Connections
 74            .Where(connection => advertisedIds.Contains(connection.Connection.Id))
 75            .Select(connection => validityAssessor.AssessAsync(connection, cancellationToken).AsTask()));
 76        var availableIds = assessments
 77            .Where(connection => connection.Validity == ConnectionValidity.Valid)
 78            .Select(connection => connection.Connection.Id)
 79            .ToHashSet(StringComparer.Ordinal);
 80        var externalMethods = registry.LoginMethods.Where(method => availableIds.Contains(method.Id)).ToArray();
 81        var localOptions = options.Value.LocalLogin;
 82        if (!localOptions.IsEnabled)
 83            return externalMethods;
 84
 85        var local = new LoginMethod("local", "local", LoginMethodKind.Local, localOptions.DisplayName, localOptions.Icon
 86        return [local, .. externalMethods];
 87    }
 88
 89    public async ValueTask<BrokerInitiationResult> InitiateExternalAsync(BrokerAuthorizationRequest request, string targ
 90    {
 91        AuthenticationClient client;
 92        try
 93        {
 94            client = EnsureClient(request.ClientId);
 95            ValidateAuthorizationRequest(request, client);
 96        }
 97        catch (InvalidOperationException)
 98        {
 99            var error = BrokerErrorFactory.Create(BrokerErrorCategory.InvalidRequest);
 100            await RecordOutcomeAsync("external", "initiate", SecurityEventOutcome.Rejected, BrokerErrorCategory.InvalidR
 101            return BrokerInitiationResult.Fail(error);
 102        }
 103        var connection = await connectionRegistry.FindByKeyAsync(targetTenantId, request.ConnectionKey, cancellationToke
 104        if (connection is not null)
 105            connection = await validityAssessor.AssessAsync(connection, cancellationToken);
 106        if (connection is null || connection.Validity != ConnectionValidity.Valid || connection.IsShadowed || !connectio
 107        {
 108            var error = BrokerErrorFactory.Create(BrokerErrorCategory.MethodUnavailable);
 109            await RecordOutcomeAsync("external", "initiate", SecurityEventOutcome.Rejected, BrokerErrorCategory.MethodUn
 110            return BrokerInitiationResult.Fail(error);
 111        }
 112
 113        var adapter = adapters.FirstOrDefault(x => string.Equals(x.Type, connection.Connection.AdapterType, StringCompar
 114        if (adapter is null)
 115        {
 116            var error = BrokerErrorFactory.Create(BrokerErrorCategory.MethodUnavailable);
 117            await RecordOutcomeAsync("external", "initiate", SecurityEventOutcome.Rejected, BrokerErrorCategory.MethodUn
 118            return BrokerInitiationResult.Fail(error);
 119        }
 120
 121        var state = CreateOpaqueValue();
 122        var transaction = new BrokerTransaction
 123        {
 124            HandleHash = Hash(state),
 125            Purpose = BrokerTransactionPurpose.ExternalSignIn,
 126            ClientId = request.ClientId,
 127            CallbackUri = request.RedirectUri,
 128            ReturnPath = request.ReturnPath,
 129            ClientState = request.ClientState,
 130            TenantId = targetTenantId,
 131            ConnectionId = connection.Connection.Id,
 132            ConnectionKey = ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
 133            ConnectionMaterialRevision = connection.Connection.MaterialRevision,
 134            PkceChallenge = request.CodeChallenge,
 135            ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.BrokerTransactionLifetime)
 136        };
 137        IReadOnlyDictionary<string, ResolvedSecretBinding> secrets = new Dictionary<string, ResolvedSecretBinding>();
 138        ExternalAuthorizationRequest adapterRequest;
 139        try
 140        {
 141            secrets = await ResolveSecretsAsync(connection.Connection.SecretBindings, cancellationToken);
 142            transaction.SecretGenerationFingerprint = GetSecretFingerprint(secrets);
 143            adapterRequest = await adapter.CreateAuthorizationRequestAsync(new(connection, secrets, transaction, state, 
 144            transaction.ProtectedPayload = dataProtectionProvider.CreateProtector("Elsa.ExternalAuthentication.AdapterPa
 145        }
 146        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 147        {
 148            throw;
 149        }
 150        catch
 151        {
 152            var error = BrokerErrorFactory.Create(BrokerErrorCategory.TemporarilyUnavailable);
 153            await RecordOutcomeAsync("external", "initiate", SecurityEventOutcome.Failed, BrokerErrorCategory.Temporaril
 154            return BrokerInitiationResult.Fail(error);
 155        }
 156        finally
 157        {
 158            DisposeSecrets(secrets);
 159        }
 160        await stateStore.PutAsync(BrokerTransactionPurpose.ExternalSignIn.ToString(), transaction.HandleHash, transactio
 161        await RecordOutcomeAsync("external", "initiate", SecurityEventOutcome.Succeeded, null, targetTenantId, connectio
 162        return BrokerInitiationResult.Redirect(adapterRequest.NavigationUri);
 163    }
 164
 165    public async ValueTask<BrokerCallbackResult> CompleteCallbackAsync(string connectionKey, string state, IReadOnlyDict
 166    {
 167        var taken = await stateStore.TryTakeAsync<BrokerTransaction>(BrokerTransactionPurpose.ExternalSignIn.ToString(),
 168        if (taken is not TakeResult<BrokerTransaction>.Taken { Value: var transaction })
 169            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(taken is TakeResult<Br
 170        if (!string.Equals(transaction.ConnectionKey, ConnectionRevisionCalculator.NormalizeKey(connectionKey), StringCo
 171            return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.InvalidRequest, cancellationToken);
 172
 173        var connection = await connectionRegistry.FindByIdAsync(transaction.TenantId, transaction.ConnectionId!, cancell
 174        if (connection is null || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection.Arc
 175            return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.MethodUnavailable, cancellationToken)
 176        if (!string.Equals(connection.Connection.MaterialRevision, transaction.ConnectionMaterialRevision, StringCompari
 177            return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.FlowChanged, cancellationToken);
 178
 179        var adapter = adapters.FirstOrDefault(x => string.Equals(x.Type, connection.Connection.AdapterType, StringCompar
 180        if (adapter is null)
 181            return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.MethodUnavailable, cancellationToken)
 182
 183        try
 184        {
 185            var secrets = await ResolveSecretsAsync(connection.Connection.SecretBindings, cancellationToken);
 186            try
 187            {
 188                if (!string.Equals(transaction.SecretGenerationFingerprint, GetSecretFingerprint(secrets), StringCompari
 189                    return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.FlowChanged, cancellationToke
 190
 191                var originalPayload = transaction.ProtectedPayload;
 192                transaction.ProtectedPayload = dataProtectionProvider.CreateProtector("Elsa.ExternalAuthentication.Adapt
 193                ExternalAuthenticationResult authentication;
 194                try
 195                {
 196                    authentication = await adapter.AuthenticateCallbackAsync(new(connection, secrets, transaction, state
 197                }
 198                finally
 199                {
 200                    transaction.ProtectedPayload = originalPayload;
 201                }
 202
 203                using var upstreamLogoutHint = authentication.UpstreamLogoutHint;
 204                var resolution = await identityResolver.ResolveAsync(new(transaction.TenantId, connection, authenticatio
 205                var grantResult = await permissionGrantResolver.ResolveAsync(new(
 206                    transaction.TenantId,
 207                    resolution.UserId,
 208                    connection,
 209                    authentication.Identity,
 210                    authentication.ProjectedClaims), cancellationToken);
 211                var session = new ExternalAuthenticationSession
 212                {
 213                    Id = CreateOpaqueValue(),
 214                    AuthenticationClientId = transaction.ClientId,
 215                    TenantId = transaction.TenantId,
 216                    UserId = resolution.UserId,
 217                    ConnectionKey = ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
 218                    ConnectionMaterialRevision = connection.Connection.MaterialRevision,
 219                    SecretGenerationFingerprint = transaction.SecretGenerationFingerprint,
 220                    Issuer = authentication.Identity.Issuer,
 221                    SubjectHash = Hash(authentication.Identity.Subject),
 222                    ExternalGrants = grantResult.Grants,
 223                    ProtectedUpstreamLogoutHint = connection.Connection.UpstreamLogoutMode == UpstreamLogoutMode.Disable
 224                        ? null
 225                        : dataProtectionProvider.CreateProtector("Elsa.ExternalAuthentication.UpstreamLogoutHint.v1").Pr
 226                    StartedAt = clock.UtcNow,
 227                    LastRefreshedAt = clock.UtcNow,
 228                    ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.MaximumSessionAge),
 229                    RefreshExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.MaximumSessionAge)
 230                };
 231                await sessionStore.SaveAsync(session, cancellationToken);
 232                var code = CreateOpaqueValue();
 233                await grantStore.SaveAsync(new()
 234                    { CodeHash = Hash(code), ClientId = transaction.ClientId, CallbackUri = transaction.CallbackUri, Ten
 235                if (notifier is not null)
 236                    await notifier.PublishAsync(new ExternalSignInCompleted(
 237                    ExternalAuthenticationSecurityNotifier.Context(null, transaction.TenantId, connection.Connection.Id,
 238                    session.Id,
 239                    connection.Connection.AdapterType), cancellationToken);
 240                var callbackResult = await CallbackOutcomeAsync(BrokerCallbackResult.Redirect(AppendCallbackParameters(t
 241                if (identityResolver is IExternalIdentitySignInTracker signInTracker)
 242                {
 243                    var signInRecorded = await signInTracker.RecordSuccessfulSignInAsync(
 244                        transaction.TenantId,
 245                        connection.Connection.Key,
 246                        authentication.Identity,
 247                        resolution.UserId,
 248                        clock.UtcNow,
 249                        cancellationToken);
 250                    if (!signInRecorded)
 251                        throw new InvalidOperationException("The external identity link changed while sign-in was comple
 252                }
 253                return callbackResult;
 254            }
 255            finally
 256            {
 257                DisposeSecrets(secrets);
 258            }
 259        }
 260        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 261        {
 262            throw;
 263        }
 264        catch
 265        {
 266            return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.AuthenticationFailed, cancellationTok
 267        }
 268    }
 269
 270    public async ValueTask<BrokerCallbackResult> InitiateLocalAsync(LocalBrokerAuthorizationRequest request, string targ
 271    {
 272        AuthenticationClient client;
 273        try
 274        {
 275            client = EnsureClient(request.ClientId);
 276            ValidateAuthorizationRequest(request, client);
 277        }
 278        catch (InvalidOperationException)
 279        {
 280            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.In
 281        }
 282        if (!options.Value.LocalLogin.IsEnabled)
 283            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Me
 284        Identity.Entities.User? user;
 285        try
 286        {
 287            user = await credentialsValidator.ValidateAsync(request.Username.Trim(), request.Password, cancellationToken
 288        }
 289        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 290        {
 291            throw;
 292        }
 293        catch
 294        {
 295            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Se
 296        }
 297        if (user is null || !string.Equals(user.TenantId.NormalizeTenantId(), targetTenantId.NormalizeTenantId(), String
 298            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Au
 299
 300        var code = CreateOpaqueValue();
 301        await grantStore.SaveAsync(new()
 302            { CodeHash = Hash(code), ClientId = request.ClientId, CallbackUri = request.RedirectUri, TenantId = targetTe
 303        return await CallbackOutcomeAsync(BrokerCallbackResult.Redirect(AppendCallbackParameters(request.RedirectUri, co
 304    }
 305
 306    public async ValueTask<BrokerTokenResult> ExchangeAsync(BrokerTokenRequest request, CancellationToken cancellationTo
 307    {
 308        AuthenticationClient client;
 309        try
 310        {
 311            client = EnsureClient(request.ClientId);
 312            await ValidateExchangeClientAsync(client, request, cancellationToken);
 313        }
 314        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 315        {
 316            throw;
 317        }
 318        catch
 319        {
 320            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidR
 321        }
 322
 323        if (!string.Equals(request.GrantType, "refresh_token", StringComparison.Ordinal) && !string.Equals(request.Grant
 324            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidR
 325        if (string.Equals(request.GrantType, "refresh_token", StringComparison.Ordinal))
 326        {
 327            using var refreshToken = new SensitiveString(request.RefreshToken ?? string.Empty);
 328            try
 329            {
 330                var token = refreshToken.Reveal();
 331                var response = IsJwt(token)
 332                    ? await RefreshLocalAsync(token, cancellationToken)
 333                    : await tokenIssuer.RefreshAsync(request.ClientId, refreshToken, cancellationToken);
 334                return await TokenOutcomeAsync(response is null
 335                    ? BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.AccessDenied))
 336                    : BrokerTokenResult.Success(response), "refresh", "exchange", cancellationToken);
 337            }
 338            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
 339            catch { return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.
 340        }
 341
 342        var grantResult = await grantStore.TryTakeAsync(Hash(request.Code ?? string.Empty), cancellationToken);
 343        if (grantResult is not TakeResult<AuthorizationGrant>.Taken { Value: var grant })
 344            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidR
 345        if (!string.Equals(grant.ClientId, request.ClientId, StringComparison.Ordinal) || request.RedirectUri != grant.C
 346            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidR
 347
 348        if (grant.ExternalSessionId is { } sessionId)
 349        {
 350            var session = await sessionStore.FindByIdAsync(sessionId, cancellationToken);
 351            if (session is null || session.RevokedAt != null || session.ExpiresAt <= clock.UtcNow || !string.Equals(sess
 352                return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Acce
 353            var connection = await connectionRegistry.FindByKeyAsync(session.TenantId, session.ConnectionKey, cancellati
 354            if (connection is null || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection
 355                return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Acce
 356            try
 357            {
 358                return await TokenOutcomeAsync(BrokerTokenResult.Success(await tokenIssuer.IssueAsync(session, cancellat
 359            }
 360            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 361            {
 362                throw;
 363            }
 364            catch
 365            {
 366                return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Serv
 367            }
 368        }
 369
 370        using var tenantContext = tenantAccessor.PushContext(new()
 371            { Id = grant.TenantId, Name = grant.TenantId });
 372        var user = await userProvider.FindAsync(new()
 373            { Id = grant.UserId }, cancellationToken);
 374        if (user is null)
 375            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.AccessDe
 376        try
 377        {
 378            var roles = (await roleProvider.FindByIdsAsync(user.Roles, cancellationToken)).ToArray();
 379            var context = new TokenIssuanceContext(user, roles.Select(x => x.Name).ToArray(), roles.SelectMany(x => x.Pe
 380            var access = await elsaTokenService.IssueAccessTokenAsync(context, cancellationToken);
 381            var refresh = await elsaTokenService.IssueRefreshTokenAsync(context, cancellationToken);
 382            return await TokenOutcomeAsync(BrokerTokenResult.Success(new(
 383                access.Token,
 384                "Bearer",
 385                SecondsUntil(access.ExpiresAt),
 386                refresh.Token,
 387                SecondsUntil(refresh.ExpiresAt),
 388                0)), "token_exchange", "issue", cancellationToken);
 389        }
 390        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 391        {
 392            throw;
 393        }
 394        catch
 395        {
 396            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.ServerEr
 397        }
 398    }
 399
 400    private async ValueTask<ExternalTokenResponse?> RefreshLocalAsync(string refreshToken, CancellationToken cancellatio
 401    {
 402        var tokens = await identityRefreshTokenService.RefreshAsync(refreshToken, cancellationToken);
 403
 404        if (tokens is null)
 405            return null;
 406
 407        return new(
 408            tokens.AccessToken,
 409            "Bearer",
 410            SecondsUntil(GetExpiresAt(tokens.AccessToken)),
 411            tokens.RefreshToken,
 412            SecondsUntil(GetExpiresAt(tokens.RefreshToken)),
 413            0);
 414    }
 415
 416    private long SecondsUntil(DateTimeOffset? expiresAt) =>
 417        expiresAt is null ? 0 : Math.Max(0, (long)(expiresAt.Value - clock.UtcNow).TotalSeconds);
 418
 419    private static DateTimeOffset GetExpiresAt(string token) =>
 420        new(new JsonWebTokenHandler().ReadJsonWebToken(token).ValidTo, TimeSpan.Zero);
 421
 422    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        {
 429            client = EnsureClient(request.ClientId);
 430            if (!client.LogoutCallbackUris.Contains(request.PostLogoutRedirectUri))
 431                throw new InvalidOperationException();
 432        }
 433        catch (InvalidOperationException)
 434        {
 435            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Invali
 436        }
 437        if (!string.Equals(request.Mode, "local", StringComparison.OrdinalIgnoreCase) && !string.Equals(request.Mode, "u
 438            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Invali
 439
 440        var session = await sessionStore.FindByIdAsync(externalSessionId, cancellationToken);
 441        if (session is null || !string.Equals(session.AuthenticationClientId, request.ClientId, StringComparison.Ordinal
 442            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Access
 443
 444        var revoked = await sessionStore.RevokeAsync(session.Id, "logout", clock.UtcNow, cancellationToken);
 445        if (revoked && notifier is not null)
 446            await notifier.PublishAsync(new ExternalAuthenticationSessionRevoked(
 447                ExternalAuthenticationSecurityNotifier.Context(null, session.TenantId, session.ConnectionKey, session.Us
 448                session.Id,
 449                "logout"), cancellationToken);
 450        var connection = await connectionRegistry.FindByKeyAsync(session.TenantId, session.ConnectionKey, cancellationTo
 451        var wantsUpstream = string.Equals(request.Mode, "upstream", StringComparison.OrdinalIgnoreCase)
 452            || connection?.Connection.UpstreamLogoutMode == UpstreamLogoutMode.Always;
 453        if (!wantsUpstream)
 454            return await LogoutOutcomeAsync(BrokerLogoutResult.Complete(request.PostLogoutRedirectUri), "complete", canc
 455        if (connection is null || connection.Connection.UpstreamLogoutMode == UpstreamLogoutMode.Disabled)
 456            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Invali
 457
 458        var adapter = adapters.FirstOrDefault(x => string.Equals(x.Type, connection.Connection.AdapterType, StringCompar
 459        if (adapter is null)
 460            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Method
 461
 462        var state = CreateOpaqueValue();
 463        var transaction = new BrokerTransaction
 464        {
 465            HandleHash = Hash(state),
 466            Purpose = BrokerTransactionPurpose.UpstreamLogout,
 467            ClientId = request.ClientId,
 468            CallbackUri = request.PostLogoutRedirectUri,
 469            ReturnPath = "/",
 470            TenantId = session.TenantId,
 471            ConnectionId = connection.Connection.Id,
 472            ConnectionKey = ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
 473            ConnectionMaterialRevision = session.ConnectionMaterialRevision,
 474            PkceChallenge = string.Empty,
 475            ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.BrokerTransactionLifetime)
 476        };
 477        IReadOnlyDictionary<string, ResolvedSecretBinding> logoutSecrets = new Dictionary<string, ResolvedSecretBinding>
 478        ExternalLogoutRequest? upstream;
 479        try
 480        {
 481            logoutSecrets = await ResolveSecretsAsync(connection.Connection.SecretBindings, cancellationToken);
 482            using var upstreamLogoutHint = session.ProtectedUpstreamLogoutHint is { Length: > 0 }
 483                ? new SensitiveString(Encoding.UTF8.GetString(dataProtectionProvider.CreateProtector("Elsa.ExternalAuthe
 484                : null;
 485            upstream = await adapter.CreateLogoutRequestAsync(new(connection, logoutSecrets, transaction, state, clock, 
 486            transaction.ProtectedPayload = dataProtectionProvider.CreateProtector("Elsa.ExternalAuthentication.AdapterPa
 487        }
 488        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 489        {
 490            throw;
 491        }
 492        catch
 493        {
 494            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Tempor
 495        }
 496        finally
 497        {
 498            DisposeSecrets(logoutSecrets);
 499        }
 500        if (upstream is null)
 501            return await LogoutOutcomeAsync(BrokerLogoutResult.Complete(request.PostLogoutRedirectUri), "upstream", canc
 502
 503        await stateStore.PutAsync(BrokerTransactionPurpose.UpstreamLogout.ToString(), transaction.HandleHash, transactio
 504        var continuationHandle = CreateOpaqueValue();
 505        await stateStore.PutAsync("UpstreamLogoutContinue", Hash(continuationHandle), new BrokerTransaction
 506        {
 507            HandleHash = Hash(continuationHandle), Purpose = BrokerTransactionPurpose.UpstreamLogout, ClientId = request
 508            CallbackUri = request.PostLogoutRedirectUri, ReturnPath = upstream.NavigationUri.AbsoluteUri, TenantId = ses
 509            ExpiresAt = transaction.ExpiresAt
 510        }, transaction.ExpiresAt, cancellationToken);
 511        return await LogoutOutcomeAsync(BrokerLogoutResult.Navigate(new($"/external-authentication/logout/continue/{Uri.
 512    }
 513
 514    public async ValueTask<BrokerCallbackResult> CompleteLogoutAsync(string connectionKey, string state, CancellationTok
 515    {
 516        var taken = await stateStore.TryTakeAsync<BrokerTransaction>(BrokerTransactionPurpose.UpstreamLogout.ToString(),
 517        if (taken is not TakeResult<BrokerTransaction>.Taken { Value: var transaction })
 518            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(taken is TakeResult<Br
 519        if (!string.Equals(transaction.ConnectionKey, ConnectionRevisionCalculator.NormalizeKey(connectionKey), StringCo
 520            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.In
 521        var connection = await connectionRegistry.FindByIdAsync(transaction.TenantId, transaction.ConnectionId!, cancell
 522        return connection is null || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection.
 523            ? await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.FlowCha
 524            : await CallbackOutcomeAsync(BrokerCallbackResult.Redirect(transaction.CallbackUri), "logout", "callback", t
 525    }
 526
 527    public async ValueTask<BrokerLogoutResult> ContinueLogoutAsync(string handle, CancellationToken cancellationToken = 
 528    {
 529        var taken = await stateStore.TryTakeAsync<BrokerTransaction>("UpstreamLogoutContinue", Hash(handle), cancellatio
 530        if (taken is not TakeResult<BrokerTransaction>.Taken { Value: var transaction } || !Uri.TryCreate(transaction.Re
 531            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Invali
 532        return await LogoutOutcomeAsync(BrokerLogoutResult.Navigate(navigation), "continue", cancellationToken);
 533    }
 534
 535    private AuthenticationClient EnsureClient(string clientId) => options.Value.Clients.FirstOrDefault(x => x.IsEnabled 
 536    private static void ValidateAuthorizationRequest(BrokerAuthorizationRequest request, AuthenticationClient client)
 537    {
 538        if (!string.Equals(request.ResponseType, "code", StringComparison.Ordinal) || !string.Equals(request.CodeChallen
 539            throw new InvalidOperationException("The broker authorization request is invalid.");
 540    }
 541    private static bool VerifyPkce(string challenge, string? verifier) => !string.IsNullOrWhiteSpace(verifier) && string
 542    private static string CreateOpaqueValue() => Base64Url(RandomNumberGenerator.GetBytes(32));
 543
 544    private string Hash(string value) => handleHasher.Hash(value);
 545    private static string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replac
 546    private static Uri AppendCallbackParameters(Uri uri, string code, string? clientState)
 547    {
 548        var result = AppendQuery(uri, "code", code);
 549        return string.IsNullOrWhiteSpace(clientState) ? result : AppendQuery(result, "state", clientState);
 550    }
 551    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    {
 554        var error = BrokerErrorFactory.Create(category);
 555        return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(error, AppendQuery(AppendQuery(transaction.CallbackU
 556    }
 557
 558    private async ValueTask<IReadOnlyDictionary<string, ResolvedSecretBinding>> ResolveSecretsAsync(IDictionary<string, 
 559    {
 560        var resolved = new Dictionary<string, ResolvedSecretBinding>(StringComparer.Ordinal);
 561        try
 562        {
 563            foreach (var (name, binding) in bindings)
 564            {
 565                var resolver = secretBindingResolvers.FirstOrDefault(x => string.Equals(x.Type, binding.ResolverType, St
 566                    ?? throw new InvalidOperationException("A required secret binding resolver is unavailable.");
 567                resolved[name] = await resolver.ResolveAsync(binding, cancellationToken);
 568            }
 569            return resolved;
 570        }
 571        catch
 572        {
 573            DisposeSecrets(resolved);
 574            throw;
 575        }
 576    }
 577
 578    private static string GetSecretFingerprint(IReadOnlyDictionary<string, ResolvedSecretBinding> secrets) => Convert.To
 579    private static void DisposeSecrets(IReadOnlyDictionary<string, ResolvedSecretBinding> secrets)
 580    {
 581        foreach (var secret in secrets.Values)
 582            secret.Value.Dispose();
 583    }
 584
 585    private async ValueTask ValidateExchangeClientAsync(AuthenticationClient client, BrokerTokenRequest request, Cancell
 586    {
 587        if (client.ClientType == AuthenticationClientType.Public)
 588        {
 589            if (string.IsNullOrWhiteSpace(request.Origin) || !client.AllowedOrigins.Contains(request.Origin) || !string.
 590                throw new InvalidOperationException();
 591            return;
 592        }
 593
 594        if (client.SecretBinding is null || string.IsNullOrWhiteSpace(request.ClientSecret) || !string.Equals(client.Cli
 595            throw new InvalidOperationException();
 596        var resolver = secretBindingResolvers.FirstOrDefault(x => string.Equals(x.Type, client.SecretBinding.ResolverTyp
 597            ?? throw new InvalidOperationException();
 598        using var configured = (await resolver.ResolveAsync(client.SecretBinding, cancellationToken)).Value;
 599        using var supplied = new SensitiveString(request.ClientSecret);
 600        if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(configured.Reveal()), Encoding.UTF8.GetBytes
 601            throw new InvalidOperationException();
 602    }
 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);
 53620public 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

get_NavigationUri()