< 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: 625
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 ExternalAuthorizationContext(connection, 
 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 ExternalCallbackContext(connection, sec
 197                }
 198                finally
 199                {
 200                    transaction.ProtectedPayload = originalPayload;
 201                }
 202
 203                using var upstreamLogoutHint = authentication.UpstreamLogoutHint;
 204                var resolution = await identityResolver.ResolveAsync(new ExternalIdentityResolutionContext(transaction.T
 205                var grantResult = await permissionGrantResolver.ResolveAsync(new PermissionGrantResolutionContext(
 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 AuthorizationGrant { CodeHash = Hash(code), ClientId = transaction.Client
 234                if (notifier is not null)
 235                    await notifier.PublishAsync(new ExternalSignInCompleted(
 236                    ExternalAuthenticationSecurityNotifier.Context(null, transaction.TenantId, connection.Connection.Id,
 237                    session.Id,
 238                    connection.Connection.AdapterType), cancellationToken);
 239                var callbackResult = await CallbackOutcomeAsync(BrokerCallbackResult.Redirect(AppendCallbackParameters(t
 240                if (identityResolver is IExternalIdentitySignInTracker signInTracker)
 241                {
 242                    var signInRecorded = await signInTracker.RecordSuccessfulSignInAsync(
 243                        transaction.TenantId,
 244                        connection.Connection.Key,
 245                        authentication.Identity,
 246                        resolution.UserId,
 247                        clock.UtcNow,
 248                        cancellationToken);
 249                    if (!signInRecorded)
 250                        throw new InvalidOperationException("The external identity link changed while sign-in was comple
 251                }
 252                return callbackResult;
 253            }
 254            finally
 255            {
 256                DisposeSecrets(secrets);
 257            }
 258        }
 259        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 260        {
 261            throw;
 262        }
 263        catch
 264        {
 265            return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.AuthenticationFailed, cancellationTok
 266        }
 267    }
 268
 269    public async ValueTask<BrokerCallbackResult> InitiateLocalAsync(LocalBrokerAuthorizationRequest request, string targ
 270    {
 271        AuthenticationClient client;
 272        try
 273        {
 274            client = EnsureClient(request.ClientId);
 275            ValidateAuthorizationRequest(request, client);
 276        }
 277        catch (InvalidOperationException)
 278        {
 279            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.In
 280        }
 281        if (!options.Value.LocalLogin.IsEnabled)
 282            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Me
 283        Elsa.Identity.Entities.User? user;
 284        try
 285        {
 286            user = await credentialsValidator.ValidateAsync(request.Username.Trim(), request.Password, cancellationToken
 287        }
 288        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 289        {
 290            throw;
 291        }
 292        catch
 293        {
 294            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Se
 295        }
 296        if (user is null || !string.Equals(user.TenantId.NormalizeTenantId(), targetTenantId.NormalizeTenantId(), String
 297            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Au
 298
 299        var code = CreateOpaqueValue();
 300        await grantStore.SaveAsync(new AuthorizationGrant { CodeHash = Hash(code), ClientId = request.ClientId, Callback
 301        return await CallbackOutcomeAsync(BrokerCallbackResult.Redirect(AppendCallbackParameters(request.RedirectUri, co
 302    }
 303
 304    public async ValueTask<BrokerTokenResult> ExchangeAsync(BrokerTokenRequest request, CancellationToken cancellationTo
 305    {
 306        AuthenticationClient client;
 307        try
 308        {
 309            client = EnsureClient(request.ClientId);
 310            await ValidateExchangeClientAsync(client, request, cancellationToken);
 311        }
 312        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 313        {
 314            throw;
 315        }
 316        catch
 317        {
 318            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidR
 319        }
 320
 321        if (!string.Equals(request.GrantType, "refresh_token", StringComparison.Ordinal) && !string.Equals(request.Grant
 322            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidR
 323        if (string.Equals(request.GrantType, "refresh_token", StringComparison.Ordinal))
 324        {
 325            using var refreshToken = new SensitiveString(request.RefreshToken ?? string.Empty);
 326            try
 327            {
 328                var token = refreshToken.Reveal();
 329                var response = IsJwt(token)
 330                    ? await RefreshLocalAsync(token, cancellationToken)
 331                    : await tokenIssuer.RefreshAsync(request.ClientId, refreshToken, cancellationToken);
 332                return await TokenOutcomeAsync(response is null
 333                    ? BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.AccessDenied))
 334                    : BrokerTokenResult.Success(response), "refresh", "exchange", cancellationToken);
 335            }
 336            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
 337            catch { return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.
 338        }
 339
 340        var grantResult = await grantStore.TryTakeAsync(Hash(request.Code ?? string.Empty), cancellationToken);
 341        if (grantResult is not TakeResult<AuthorizationGrant>.Taken { Value: var grant })
 342            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidR
 343        if (!string.Equals(grant.ClientId, request.ClientId, StringComparison.Ordinal) || request.RedirectUri != grant.C
 344            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidR
 345
 346        if (grant.ExternalSessionId is { } sessionId)
 347        {
 348            var session = await sessionStore.FindByIdAsync(sessionId, cancellationToken);
 349            if (session is null || session.RevokedAt != null || session.ExpiresAt <= clock.UtcNow || !string.Equals(sess
 350                return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Acce
 351            var connection = await connectionRegistry.FindByKeyAsync(session.TenantId, session.ConnectionKey, cancellati
 352            if (connection is null || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection
 353                return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Acce
 354            try
 355            {
 356                return await TokenOutcomeAsync(BrokerTokenResult.Success(await tokenIssuer.IssueAsync(session, cancellat
 357            }
 358            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 359            {
 360                throw;
 361            }
 362            catch
 363            {
 364                return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Serv
 365            }
 366        }
 367
 368        using var tenantContext = tenantAccessor.PushContext(new Tenant { Id = grant.TenantId, Name = grant.TenantId });
 369        var user = await userProvider.FindAsync(new UserFilter { Id = grant.UserId }, cancellationToken);
 370        if (user is null)
 371            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.AccessDe
 372        try
 373        {
 374            var roles = (await roleProvider.FindByIdsAsync(user.Roles, cancellationToken)).ToArray();
 375            var context = new TokenIssuanceContext(user, roles.Select(x => x.Name).ToArray(), roles.SelectMany(x => x.Pe
 376            var access = await elsaTokenService.IssueAccessTokenAsync(context, cancellationToken);
 377            var refresh = await elsaTokenService.IssueRefreshTokenAsync(context, cancellationToken);
 378            return await TokenOutcomeAsync(BrokerTokenResult.Success(new ExternalTokenResponse(
 379                access.Token,
 380                "Bearer",
 381                SecondsUntil(access.ExpiresAt),
 382                refresh.Token,
 383                SecondsUntil(refresh.ExpiresAt),
 384                0)), "token_exchange", "issue", cancellationToken);
 385        }
 386        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 387        {
 388            throw;
 389        }
 390        catch
 391        {
 392            return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.ServerEr
 393        }
 394    }
 395
 396    private async ValueTask<ExternalTokenResponse?> RefreshLocalAsync(string refreshToken, CancellationToken cancellatio
 397    {
 398        var tokens = await identityRefreshTokenService.RefreshAsync(refreshToken, cancellationToken);
 399
 400        if (tokens is null)
 401            return null;
 402
 403        return new ExternalTokenResponse(
 404            tokens.AccessToken,
 405            "Bearer",
 406            SecondsUntil(GetExpiresAt(tokens.AccessToken)),
 407            tokens.RefreshToken,
 408            SecondsUntil(GetExpiresAt(tokens.RefreshToken)),
 409            0);
 410    }
 411
 412    private long SecondsUntil(DateTimeOffset? expiresAt) =>
 413        expiresAt is null ? 0 : Math.Max(0, (long)(expiresAt.Value - clock.UtcNow).TotalSeconds);
 414
 415    private static DateTimeOffset GetExpiresAt(string token) =>
 416        new(new JsonWebTokenHandler().ReadJsonWebToken(token).ValidTo, TimeSpan.Zero);
 417
 418    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        {
 425            client = EnsureClient(request.ClientId);
 426            if (!client.LogoutCallbackUris.Contains(request.PostLogoutRedirectUri))
 427                throw new InvalidOperationException();
 428        }
 429        catch (InvalidOperationException)
 430        {
 431            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Invali
 432        }
 433        if (!string.Equals(request.Mode, "local", StringComparison.OrdinalIgnoreCase) && !string.Equals(request.Mode, "u
 434            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Invali
 435
 436        var session = await sessionStore.FindByIdAsync(externalSessionId, cancellationToken);
 437        if (session is null || !string.Equals(session.AuthenticationClientId, request.ClientId, StringComparison.Ordinal
 438            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Access
 439
 440        var revoked = await sessionStore.RevokeAsync(session.Id, "logout", clock.UtcNow, cancellationToken);
 441        if (revoked && notifier is not null)
 442            await notifier.PublishAsync(new ExternalAuthenticationSessionRevoked(
 443                ExternalAuthenticationSecurityNotifier.Context(null, session.TenantId, session.ConnectionKey, session.Us
 444                session.Id,
 445                "logout"), cancellationToken);
 446        var connection = await connectionRegistry.FindByKeyAsync(session.TenantId, session.ConnectionKey, cancellationTo
 447        var wantsUpstream = string.Equals(request.Mode, "upstream", StringComparison.OrdinalIgnoreCase)
 448            || connection?.Connection.UpstreamLogoutMode == UpstreamLogoutMode.Always;
 449        if (!wantsUpstream)
 450            return await LogoutOutcomeAsync(BrokerLogoutResult.Complete(request.PostLogoutRedirectUri), "complete", canc
 451        if (connection is null || connection.Connection.UpstreamLogoutMode == UpstreamLogoutMode.Disabled)
 452            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Invali
 453
 454        var adapter = adapters.FirstOrDefault(x => string.Equals(x.Type, connection.Connection.AdapterType, StringCompar
 455        if (adapter is null)
 456            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Method
 457
 458        var state = CreateOpaqueValue();
 459        var transaction = new BrokerTransaction
 460        {
 461            HandleHash = Hash(state),
 462            Purpose = BrokerTransactionPurpose.UpstreamLogout,
 463            ClientId = request.ClientId,
 464            CallbackUri = request.PostLogoutRedirectUri,
 465            ReturnPath = "/",
 466            TenantId = session.TenantId,
 467            ConnectionId = connection.Connection.Id,
 468            ConnectionKey = ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
 469            ConnectionMaterialRevision = session.ConnectionMaterialRevision,
 470            PkceChallenge = string.Empty,
 471            ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.BrokerTransactionLifetime)
 472        };
 473        IReadOnlyDictionary<string, ResolvedSecretBinding> logoutSecrets = new Dictionary<string, ResolvedSecretBinding>
 474        ExternalLogoutRequest? upstream;
 475        try
 476        {
 477            logoutSecrets = await ResolveSecretsAsync(connection.Connection.SecretBindings, cancellationToken);
 478            using var upstreamLogoutHint = session.ProtectedUpstreamLogoutHint is { Length: > 0 }
 479                ? new SensitiveString(Encoding.UTF8.GetString(dataProtectionProvider.CreateProtector("Elsa.ExternalAuthe
 480                : null;
 481            upstream = await adapter.CreateLogoutRequestAsync(new ExternalLogoutContext(connection, logoutSecrets, trans
 482            transaction.ProtectedPayload = dataProtectionProvider.CreateProtector("Elsa.ExternalAuthentication.AdapterPa
 483        }
 484        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 485        {
 486            throw;
 487        }
 488        catch
 489        {
 490            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Tempor
 491        }
 492        finally
 493        {
 494            DisposeSecrets(logoutSecrets);
 495        }
 496        if (upstream is null)
 497            return await LogoutOutcomeAsync(BrokerLogoutResult.Complete(request.PostLogoutRedirectUri), "upstream", canc
 498
 499        await stateStore.PutAsync(BrokerTransactionPurpose.UpstreamLogout.ToString(), transaction.HandleHash, transactio
 500        var continuationHandle = CreateOpaqueValue();
 501        await stateStore.PutAsync("UpstreamLogoutContinue", Hash(continuationHandle), new BrokerTransaction
 502        {
 503            HandleHash = Hash(continuationHandle), Purpose = BrokerTransactionPurpose.UpstreamLogout, ClientId = request
 504            CallbackUri = request.PostLogoutRedirectUri, ReturnPath = upstream.NavigationUri.AbsoluteUri, TenantId = ses
 505            ExpiresAt = transaction.ExpiresAt
 506        }, transaction.ExpiresAt, cancellationToken);
 507        return await LogoutOutcomeAsync(BrokerLogoutResult.Navigate(new Uri($"/external-authentication/logout/continue/{
 508    }
 509
 510    public async ValueTask<BrokerCallbackResult> CompleteLogoutAsync(string connectionKey, string state, CancellationTok
 511    {
 512        var taken = await stateStore.TryTakeAsync<BrokerTransaction>(BrokerTransactionPurpose.UpstreamLogout.ToString(),
 513        if (taken is not TakeResult<BrokerTransaction>.Taken { Value: var transaction })
 514            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(taken is TakeResult<Br
 515        if (!string.Equals(transaction.ConnectionKey, ConnectionRevisionCalculator.NormalizeKey(connectionKey), StringCo
 516            return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.In
 517        var connection = await connectionRegistry.FindByIdAsync(transaction.TenantId, transaction.ConnectionId!, cancell
 518        return connection is null || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection.
 519            ? await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.FlowCha
 520            : await CallbackOutcomeAsync(BrokerCallbackResult.Redirect(transaction.CallbackUri), "logout", "callback", t
 521    }
 522
 523    public async ValueTask<BrokerLogoutResult> ContinueLogoutAsync(string handle, CancellationToken cancellationToken = 
 524    {
 525        var taken = await stateStore.TryTakeAsync<BrokerTransaction>("UpstreamLogoutContinue", Hash(handle), cancellatio
 526        if (taken is not TakeResult<BrokerTransaction>.Taken { Value: var transaction } || !Uri.TryCreate(transaction.Re
 527            return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.Invali
 528        return await LogoutOutcomeAsync(BrokerLogoutResult.Navigate(navigation), "continue", cancellationToken);
 529    }
 530
 531    private AuthenticationClient EnsureClient(string clientId) => options.Value.Clients.FirstOrDefault(x => x.IsEnabled 
 532    private static void ValidateAuthorizationRequest(BrokerAuthorizationRequest request, AuthenticationClient client)
 533    {
 534        if (!string.Equals(request.ResponseType, "code", StringComparison.Ordinal) || !string.Equals(request.CodeChallen
 535            throw new InvalidOperationException("The broker authorization request is invalid.");
 536    }
 537    private static bool VerifyPkce(string challenge, string? verifier) => !string.IsNullOrWhiteSpace(verifier) && string
 538    private static string CreateOpaqueValue() => Base64Url(RandomNumberGenerator.GetBytes(32));
 539
 540    private string Hash(string value) => handleHasher.Hash(value);
 541    private static string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replac
 542    private static Uri AppendCallbackParameters(Uri uri, string code, string? clientState)
 543    {
 544        var result = AppendQuery(uri, "code", code);
 545        return string.IsNullOrWhiteSpace(clientState) ? result : AppendQuery(result, "state", clientState);
 546    }
 547    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    {
 550        var error = BrokerErrorFactory.Create(category);
 551        return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(error, AppendQuery(AppendQuery(transaction.CallbackU
 552    }
 553
 554    private async ValueTask<IReadOnlyDictionary<string, ResolvedSecretBinding>> ResolveSecretsAsync(IDictionary<string, 
 555    {
 556        var resolved = new Dictionary<string, ResolvedSecretBinding>(StringComparer.Ordinal);
 557        try
 558        {
 559            foreach (var (name, binding) in bindings)
 560            {
 561                var resolver = secretBindingResolvers.FirstOrDefault(x => string.Equals(x.Type, binding.ResolverType, St
 562                    ?? throw new InvalidOperationException("A required secret binding resolver is unavailable.");
 563                resolved[name] = await resolver.ResolveAsync(binding, cancellationToken);
 564            }
 565            return resolved;
 566        }
 567        catch
 568        {
 569            DisposeSecrets(resolved);
 570            throw;
 571        }
 572    }
 573
 574    private static string GetSecretFingerprint(IReadOnlyDictionary<string, ResolvedSecretBinding> secrets) => Convert.To
 575    private static void DisposeSecrets(IReadOnlyDictionary<string, ResolvedSecretBinding> secrets)
 576    {
 577        foreach (var secret in secrets.Values)
 578            secret.Value.Dispose();
 579    }
 580
 581    private async ValueTask ValidateExchangeClientAsync(AuthenticationClient client, BrokerTokenRequest request, Cancell
 582    {
 583        if (client.ClientType == AuthenticationClientType.Public)
 584        {
 585            if (string.IsNullOrWhiteSpace(request.Origin) || !client.AllowedOrigins.Contains(request.Origin) || !string.
 586                throw new InvalidOperationException();
 587            return;
 588        }
 589
 590        if (client.SecretBinding is null || string.IsNullOrWhiteSpace(request.ClientSecret) || !string.Equals(client.Cli
 591            throw new InvalidOperationException();
 592        var resolver = secretBindingResolvers.FirstOrDefault(x => string.Equals(x.Type, client.SecretBinding.ResolverTyp
 593            ?? throw new InvalidOperationException();
 594        using var configured = (await resolver.ResolveAsync(client.SecretBinding, cancellationToken)).Value;
 595        using var supplied = new SensitiveString(request.ClientSecret);
 596        if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(configured.Reveal()), Encoding.UTF8.GetBytes
 597            throw new InvalidOperationException();
 598    }
 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);
 53616public 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

get_NavigationUri()