elsa-core/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs
2026-08-02 03:35:39 +02:00

601 lines
42 KiB
C#

using System.Security.Cryptography;
using System.Text;
using Elsa.Common;
using Elsa.Common.Multitenancy;
using Elsa.Extensions;
using Elsa.ExternalAuthentication.Contracts;
using Elsa.ExternalAuthentication.Models;
using Elsa.ExternalAuthentication.Options;
using Elsa.ExternalAuthentication.Notifications;
using Elsa.ExternalAuthentication.Validation;
using Elsa.Identity.Contracts;
using Elsa.Identity.Models;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.IdentityModel.JsonWebTokens;
namespace Elsa.ExternalAuthentication.Services;
public sealed class ExternalAuthenticationBroker(
IIdentityProviderConnectionRegistry connectionRegistry,
IEnumerable<IExternalAuthenticationAdapter> adapters,
IEnumerable<ISecretBindingResolver> secretBindingResolvers,
IExternalAuthenticationHandleHasher handleHasher,
IDataProtectionProvider dataProtectionProvider,
IExternalIdentityResolver identityResolver,
IPermissionGrantResolver permissionGrantResolver,
IExternalAuthenticationStateStore stateStore,
IAuthorizationGrantStore grantStore,
IExternalAuthenticationSessionStore sessionStore,
IExternalAuthenticationTokenIssuer tokenIssuer,
IUserCredentialsValidator credentialsValidator,
IUserProvider userProvider,
IRoleProvider roleProvider,
IElsaTokenService elsaTokenService,
IIdentityRefreshTokenService identityRefreshTokenService,
ITenantAccessor tenantAccessor,
ISystemClock clock,
IOptions<ExternalAuthenticationOptions> options,
ExternalAuthenticationSecurityNotifier? notifier = null) : IExternalAuthenticationBroker
{
private ValueTask RecordOutcomeAsync(string flow, string stage, SecurityEventOutcome outcome, BrokerErrorCategory? category, string? tenantId, string? connectionId, string? userId, CancellationToken cancellationToken) =>
RecordOutcomeCategoryAsync(flow, stage, outcome, category is { } value ? BrokerErrorFactory.Create(value).Error : "success", tenantId, connectionId, userId, cancellationToken);
private ValueTask RecordOutcomeCategoryAsync(string flow, string stage, SecurityEventOutcome outcome, string category, string? tenantId, string? connectionId, string? userId, CancellationToken cancellationToken)
{
return notifier?.PublishAsync(new ExternalAuthenticationOutcomeRecorded(
ExternalAuthenticationSecurityNotifier.Context(null, tenantId, connectionId, userId, outcome, "External authentication broker operation completed."), flow, stage, category), cancellationToken) ?? ValueTask.CompletedTask;
}
private async ValueTask<BrokerCallbackResult> CallbackOutcomeAsync(BrokerCallbackResult result, string flow, string stage, string? tenantId, string? connectionId, CancellationToken cancellationToken)
{
await RecordOutcomeCategoryAsync(flow, stage, result.Error is null ? SecurityEventOutcome.Succeeded : SecurityEventOutcome.Rejected, result.Error?.Error ?? "success", tenantId, connectionId, null, cancellationToken);
return result;
}
private async ValueTask<BrokerTokenResult> TokenOutcomeAsync(BrokerTokenResult result, string flow, string stage, CancellationToken cancellationToken)
{
await RecordOutcomeCategoryAsync(flow, stage, result.Error is null ? SecurityEventOutcome.Succeeded : SecurityEventOutcome.Rejected, result.Error?.Error ?? "success", null, null, null, cancellationToken);
return result;
}
private async ValueTask<BrokerLogoutResult> LogoutOutcomeAsync(BrokerLogoutResult result, string stage, CancellationToken cancellationToken)
{
await RecordOutcomeCategoryAsync("logout", stage, result.Error is null ? SecurityEventOutcome.Succeeded : SecurityEventOutcome.Rejected, result.Error?.Error ?? "success", null, null, null, cancellationToken);
return result;
}
public async ValueTask<IReadOnlyCollection<LoginMethod>> DiscoverAsync(string targetTenantId, string clientId, CancellationToken cancellationToken = default)
{
EnsureClient(clientId);
var externalMethods = (await connectionRegistry.GetAsync(targetTenantId, cancellationToken)).LoginMethods;
var localOptions = options.Value.LocalLogin;
if (!localOptions.IsEnabled)
return externalMethods;
var local = new LoginMethod("local", "local", LoginMethodKind.Local, localOptions.DisplayName, localOptions.IconId, localOptions.DisplayOrder, localOptions.IsPreferred && !externalMethods.Any(x => x.IsPreferred), new Uri("/external-authentication/local/authorize", UriKind.Relative));
return [local, .. externalMethods];
}
public async ValueTask<BrokerInitiationResult> InitiateExternalAsync(BrokerAuthorizationRequest request, string targetTenantId, CancellationToken cancellationToken = default)
{
AuthenticationClient client;
try
{
client = EnsureClient(request.ClientId);
ValidateAuthorizationRequest(request, client);
}
catch (InvalidOperationException)
{
var error = BrokerErrorFactory.Create(BrokerErrorCategory.InvalidRequest);
await RecordOutcomeAsync("external", "initiate", SecurityEventOutcome.Rejected, BrokerErrorCategory.InvalidRequest, targetTenantId, null, null, cancellationToken);
return BrokerInitiationResult.Fail(error);
}
var connection = await connectionRegistry.FindByKeyAsync(targetTenantId, request.ConnectionKey, cancellationToken);
if (connection is null || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection.ArchivedAt != null)
{
var error = BrokerErrorFactory.Create(BrokerErrorCategory.MethodUnavailable);
await RecordOutcomeAsync("external", "initiate", SecurityEventOutcome.Rejected, BrokerErrorCategory.MethodUnavailable, targetTenantId, null, null, cancellationToken);
return BrokerInitiationResult.Fail(error);
}
var adapter = adapters.FirstOrDefault(x => string.Equals(x.Type, connection.Connection.AdapterType, StringComparison.Ordinal));
if (adapter is null)
{
var error = BrokerErrorFactory.Create(BrokerErrorCategory.MethodUnavailable);
await RecordOutcomeAsync("external", "initiate", SecurityEventOutcome.Rejected, BrokerErrorCategory.MethodUnavailable, targetTenantId, connection.Connection.Id, null, cancellationToken);
return BrokerInitiationResult.Fail(error);
}
var state = CreateOpaqueValue();
var transaction = new BrokerTransaction
{
HandleHash = Hash(state),
Purpose = BrokerTransactionPurpose.ExternalSignIn,
ClientId = request.ClientId,
CallbackUri = request.RedirectUri,
ReturnPath = request.ReturnPath,
ClientState = request.ClientState,
TenantId = targetTenantId,
ConnectionId = connection.Connection.Id,
ConnectionKey = ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
ConnectionMaterialRevision = connection.Connection.MaterialRevision,
PkceChallenge = request.CodeChallenge,
ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.BrokerTransactionLifetime)
};
IReadOnlyDictionary<string, ResolvedSecretBinding> secrets = new Dictionary<string, ResolvedSecretBinding>();
ExternalAuthorizationRequest adapterRequest;
try
{
secrets = await ResolveSecretsAsync(connection.Connection.SecretBindings, cancellationToken);
transaction.SecretGenerationFingerprint = GetSecretFingerprint(secrets);
adapterRequest = await adapter.CreateAuthorizationRequestAsync(new ExternalAuthorizationContext(connection, secrets, transaction, state, clock), cancellationToken);
transaction.ProtectedPayload = dataProtectionProvider.CreateProtector("Elsa.ExternalAuthentication.AdapterPayload.v1").Protect(adapterRequest.ProtectedAdapterState);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
var error = BrokerErrorFactory.Create(BrokerErrorCategory.TemporarilyUnavailable);
await RecordOutcomeAsync("external", "initiate", SecurityEventOutcome.Failed, BrokerErrorCategory.TemporarilyUnavailable, targetTenantId, connection.Connection.Id, null, cancellationToken);
return BrokerInitiationResult.Fail(error);
}
finally
{
DisposeSecrets(secrets);
}
await stateStore.PutAsync(BrokerTransactionPurpose.ExternalSignIn.ToString(), transaction.HandleHash, transaction, transaction.ExpiresAt, cancellationToken);
await RecordOutcomeAsync("external", "initiate", SecurityEventOutcome.Succeeded, null, targetTenantId, connection.Connection.Id, null, cancellationToken);
return BrokerInitiationResult.Redirect(adapterRequest.NavigationUri);
}
public async ValueTask<BrokerCallbackResult> CompleteCallbackAsync(string connectionKey, string state, IReadOnlyDictionary<string, IReadOnlyCollection<string>> parameters, CancellationToken cancellationToken = default)
{
var taken = await stateStore.TryTakeAsync<BrokerTransaction>(BrokerTransactionPurpose.ExternalSignIn.ToString(), Hash(state), cancellationToken);
if (taken is not TakeResult<BrokerTransaction>.Taken { Value: var transaction })
return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(taken is TakeResult<BrokerTransaction>.Expired ? BrokerErrorCategory.FlowExpired : BrokerErrorCategory.InvalidRequest)), "external", "callback", null, connectionKey, cancellationToken);
if (!string.Equals(transaction.ConnectionKey, ConnectionRevisionCalculator.NormalizeKey(connectionKey), StringComparison.Ordinal))
return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.InvalidRequest, cancellationToken);
var connection = await connectionRegistry.FindByIdAsync(transaction.TenantId, transaction.ConnectionId!, cancellationToken);
if (connection is null || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection.ArchivedAt != null)
return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.MethodUnavailable, cancellationToken);
if (!string.Equals(connection.Connection.MaterialRevision, transaction.ConnectionMaterialRevision, StringComparison.Ordinal))
return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.FlowChanged, cancellationToken);
var adapter = adapters.FirstOrDefault(x => string.Equals(x.Type, connection.Connection.AdapterType, StringComparison.Ordinal));
if (adapter is null)
return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.MethodUnavailable, cancellationToken);
try
{
var secrets = await ResolveSecretsAsync(connection.Connection.SecretBindings, cancellationToken);
try
{
if (!string.Equals(transaction.SecretGenerationFingerprint, GetSecretFingerprint(secrets), StringComparison.Ordinal))
return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.FlowChanged, cancellationToken);
var originalPayload = transaction.ProtectedPayload;
transaction.ProtectedPayload = dataProtectionProvider.CreateProtector("Elsa.ExternalAuthentication.AdapterPayload.v1").Unprotect(originalPayload);
ExternalAuthenticationResult authentication;
try
{
authentication = await adapter.AuthenticateCallbackAsync(new ExternalCallbackContext(connection, secrets, transaction, state, parameters, clock), cancellationToken);
}
finally
{
transaction.ProtectedPayload = originalPayload;
}
using var upstreamLogoutHint = authentication.UpstreamLogoutHint;
var resolution = await identityResolver.ResolveAsync(new ExternalIdentityResolutionContext(transaction.TenantId, connection, authentication.Identity, authentication.ProjectedClaims), cancellationToken);
var grantResult = await permissionGrantResolver.ResolveAsync(new PermissionGrantResolutionContext(
transaction.TenantId,
resolution.UserId,
connection,
authentication.Identity,
authentication.ProjectedClaims), cancellationToken);
var session = new ExternalAuthenticationSession
{
Id = CreateOpaqueValue(),
AuthenticationClientId = transaction.ClientId,
TenantId = transaction.TenantId,
UserId = resolution.UserId,
ConnectionKey = ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
ConnectionMaterialRevision = connection.Connection.MaterialRevision,
SecretGenerationFingerprint = transaction.SecretGenerationFingerprint,
Issuer = authentication.Identity.Issuer,
SubjectHash = Hash(authentication.Identity.Subject),
ExternalGrants = grantResult.Grants,
ProtectedUpstreamLogoutHint = connection.Connection.UpstreamLogoutMode == UpstreamLogoutMode.Disabled || upstreamLogoutHint is null
? null
: dataProtectionProvider.CreateProtector("Elsa.ExternalAuthentication.UpstreamLogoutHint.v1").Protect(Encoding.UTF8.GetBytes(upstreamLogoutHint.Reveal())),
StartedAt = clock.UtcNow,
LastRefreshedAt = clock.UtcNow,
ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.MaximumSessionAge),
RefreshExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.MaximumSessionAge)
};
await sessionStore.SaveAsync(session, cancellationToken);
var code = CreateOpaqueValue();
await grantStore.SaveAsync(new AuthorizationGrant { CodeHash = Hash(code), ClientId = transaction.ClientId, CallbackUri = transaction.CallbackUri, TenantId = transaction.TenantId, UserId = resolution.UserId, ExternalSessionId = session.Id, PkceChallenge = transaction.PkceChallenge, ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.CompletionCodeLifetime) }, cancellationToken);
if (notifier is not null)
await notifier.PublishAsync(new ExternalSignInCompleted(
ExternalAuthenticationSecurityNotifier.Context(null, transaction.TenantId, connection.Connection.Id, resolution.UserId, SecurityEventOutcome.Succeeded, "External sign-in completed."),
session.Id,
connection.Connection.AdapterType), cancellationToken);
return await CallbackOutcomeAsync(BrokerCallbackResult.Redirect(AppendCallbackParameters(transaction.CallbackUri, code, transaction.ClientState)), "external", "callback", transaction.TenantId, connection.Connection.Id, cancellationToken);
}
finally
{
DisposeSecrets(secrets);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return await FailTrustedCallbackAsync(transaction, BrokerErrorCategory.AuthenticationFailed, cancellationToken);
}
}
public async ValueTask<BrokerCallbackResult> InitiateLocalAsync(LocalBrokerAuthorizationRequest request, string targetTenantId, CancellationToken cancellationToken = default)
{
AuthenticationClient client;
try
{
client = EnsureClient(request.ClientId);
ValidateAuthorizationRequest(request, client);
}
catch (InvalidOperationException)
{
return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidRequest)), "local", "initiate", targetTenantId, null, cancellationToken);
}
if (!options.Value.LocalLogin.IsEnabled)
return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.MethodUnavailable)), "local", "initiate", targetTenantId, null, cancellationToken);
Elsa.Identity.Entities.User? user;
try
{
user = await credentialsValidator.ValidateAsync(request.Username.Trim(), request.Password, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.ServerError)), "local", "initiate", targetTenantId, null, cancellationToken);
}
if (user is null || !string.Equals(user.TenantId.NormalizeTenantId(), targetTenantId.NormalizeTenantId(), StringComparison.Ordinal))
return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.AuthenticationFailed)), "local", "initiate", targetTenantId, null, cancellationToken);
var code = CreateOpaqueValue();
await grantStore.SaveAsync(new AuthorizationGrant { CodeHash = Hash(code), ClientId = request.ClientId, CallbackUri = request.RedirectUri, TenantId = targetTenantId, UserId = user.Id, PkceChallenge = request.CodeChallenge, ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.CompletionCodeLifetime) }, cancellationToken);
return await CallbackOutcomeAsync(BrokerCallbackResult.Redirect(AppendCallbackParameters(request.RedirectUri, code, request.ClientState)), "local", "initiate", targetTenantId, null, cancellationToken);
}
public async ValueTask<BrokerTokenResult> ExchangeAsync(BrokerTokenRequest request, CancellationToken cancellationToken = default)
{
AuthenticationClient client;
try
{
client = EnsureClient(request.ClientId);
await ValidateExchangeClientAsync(client, request, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidRequest)), "token_exchange", "validate_client", cancellationToken);
}
if (!string.Equals(request.GrantType, "refresh_token", StringComparison.Ordinal) && !string.Equals(request.GrantType, "authorization_code", StringComparison.Ordinal))
return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidRequest)), "token_exchange", "validate_grant", cancellationToken);
if (string.Equals(request.GrantType, "refresh_token", StringComparison.Ordinal))
{
using var refreshToken = new SensitiveString(request.RefreshToken ?? string.Empty);
try
{
var token = refreshToken.Reveal();
var response = IsJwt(token)
? await RefreshLocalAsync(token, cancellationToken)
: await tokenIssuer.RefreshAsync(request.ClientId, refreshToken, cancellationToken);
return await TokenOutcomeAsync(response is null
? BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.AccessDenied))
: BrokerTokenResult.Success(response), "refresh", "exchange", cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch { return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.AccessDenied)), "refresh", "exchange", cancellationToken); }
}
var grantResult = await grantStore.TryTakeAsync(Hash(request.Code ?? string.Empty), cancellationToken);
if (grantResult is not TakeResult<AuthorizationGrant>.Taken { Value: var grant })
return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidRequest)), "token_exchange", "consume_code", cancellationToken);
if (!string.Equals(grant.ClientId, request.ClientId, StringComparison.Ordinal) || request.RedirectUri != grant.CallbackUri || !VerifyPkce(grant.PkceChallenge, request.CodeVerifier))
return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidRequest)), "token_exchange", "validate_code", cancellationToken);
if (grant.ExternalSessionId is { } sessionId)
{
var session = await sessionStore.FindByIdAsync(sessionId, cancellationToken);
if (session is null || session.RevokedAt != null || session.ExpiresAt <= clock.UtcNow || !string.Equals(session.AuthenticationClientId, client.ClientId, StringComparison.Ordinal))
return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.AccessDenied)), "token_exchange", "validate_session", cancellationToken);
var connection = await connectionRegistry.FindByKeyAsync(session.TenantId, session.ConnectionKey, cancellationToken);
if (connection is null || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection.ArchivedAt != null || !string.Equals(connection.Connection.MaterialRevision, session.ConnectionMaterialRevision, StringComparison.Ordinal))
return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.AccessDenied)), "token_exchange", "validate_connection", cancellationToken);
try
{
return await TokenOutcomeAsync(BrokerTokenResult.Success(await tokenIssuer.IssueAsync(session, cancellationToken)), "token_exchange", "issue", cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.ServerError)), "token_exchange", "issue", cancellationToken);
}
}
using var tenantContext = tenantAccessor.PushContext(new Tenant { Id = grant.TenantId, Name = grant.TenantId });
var user = await userProvider.FindAsync(new UserFilter { Id = grant.UserId }, cancellationToken);
if (user is null)
return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.AccessDenied)), "token_exchange", "resolve_user", cancellationToken);
try
{
var roles = (await roleProvider.FindByIdsAsync(user.Roles, cancellationToken)).ToArray();
var context = new TokenIssuanceContext(user, roles.Select(x => x.Name).ToArray(), roles.SelectMany(x => x.Permissions).Distinct().ToArray(), []);
var access = await elsaTokenService.IssueAccessTokenAsync(context, cancellationToken);
var refresh = await elsaTokenService.IssueRefreshTokenAsync(context, cancellationToken);
return await TokenOutcomeAsync(BrokerTokenResult.Success(new ExternalTokenResponse(
access.Token,
"Bearer",
SecondsUntil(access.ExpiresAt),
refresh.Token,
SecondsUntil(refresh.ExpiresAt),
0)), "token_exchange", "issue", cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.ServerError)), "token_exchange", "issue", cancellationToken);
}
}
private async ValueTask<ExternalTokenResponse?> RefreshLocalAsync(string refreshToken, CancellationToken cancellationToken)
{
var tokens = await identityRefreshTokenService.RefreshAsync(refreshToken, cancellationToken);
if (tokens is null)
return null;
return new ExternalTokenResponse(
tokens.AccessToken,
"Bearer",
SecondsUntil(GetExpiresAt(tokens.AccessToken)),
tokens.RefreshToken,
SecondsUntil(GetExpiresAt(tokens.RefreshToken)),
0);
}
private long SecondsUntil(DateTimeOffset? expiresAt) =>
expiresAt is null ? 0 : Math.Max(0, (long)(expiresAt.Value - clock.UtcNow).TotalSeconds);
private static DateTimeOffset GetExpiresAt(string token) =>
new(new JsonWebTokenHandler().ReadJsonWebToken(token).ValidTo, TimeSpan.Zero);
private static bool IsJwt(string token) => token.Count(x => x == '.') == 2;
public async ValueTask<BrokerLogoutResult> LogoutAsync(BrokerLogoutRequest request, string externalSessionId, CancellationToken cancellationToken = default)
{
AuthenticationClient client;
try
{
client = EnsureClient(request.ClientId);
if (!client.LogoutCallbackUris.Contains(request.PostLogoutRedirectUri))
throw new InvalidOperationException();
}
catch (InvalidOperationException)
{
return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidRequest)), "validate", cancellationToken);
}
if (!string.Equals(request.Mode, "local", StringComparison.OrdinalIgnoreCase) && !string.Equals(request.Mode, "upstream", StringComparison.OrdinalIgnoreCase))
return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidRequest)), "validate", cancellationToken);
var session = await sessionStore.FindByIdAsync(externalSessionId, cancellationToken);
if (session is null || !string.Equals(session.AuthenticationClientId, request.ClientId, StringComparison.Ordinal))
return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.AccessDenied)), "session", cancellationToken);
var revoked = await sessionStore.RevokeAsync(session.Id, "logout", clock.UtcNow, cancellationToken);
if (revoked && notifier is not null)
await notifier.PublishAsync(new ExternalAuthenticationSessionRevoked(
ExternalAuthenticationSecurityNotifier.Context(null, session.TenantId, session.ConnectionKey, session.UserId, SecurityEventOutcome.Succeeded, "External authentication session revoked."),
session.Id,
"logout"), cancellationToken);
var connection = await connectionRegistry.FindByKeyAsync(session.TenantId, session.ConnectionKey, cancellationToken);
var wantsUpstream = string.Equals(request.Mode, "upstream", StringComparison.OrdinalIgnoreCase)
|| connection?.Connection.UpstreamLogoutMode == UpstreamLogoutMode.Always;
if (!wantsUpstream)
return await LogoutOutcomeAsync(BrokerLogoutResult.Complete(request.PostLogoutRedirectUri), "complete", cancellationToken);
if (connection is null || connection.Connection.UpstreamLogoutMode == UpstreamLogoutMode.Disabled)
return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidRequest)), "upstream", cancellationToken);
var adapter = adapters.FirstOrDefault(x => string.Equals(x.Type, connection.Connection.AdapterType, StringComparison.Ordinal));
if (adapter is null)
return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.MethodUnavailable)), "upstream", cancellationToken);
var state = CreateOpaqueValue();
var transaction = new BrokerTransaction
{
HandleHash = Hash(state),
Purpose = BrokerTransactionPurpose.UpstreamLogout,
ClientId = request.ClientId,
CallbackUri = request.PostLogoutRedirectUri,
ReturnPath = "/",
TenantId = session.TenantId,
ConnectionId = connection.Connection.Id,
ConnectionKey = ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
ConnectionMaterialRevision = session.ConnectionMaterialRevision,
PkceChallenge = string.Empty,
ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.BrokerTransactionLifetime)
};
IReadOnlyDictionary<string, ResolvedSecretBinding> logoutSecrets = new Dictionary<string, ResolvedSecretBinding>();
ExternalLogoutRequest? upstream;
try
{
logoutSecrets = await ResolveSecretsAsync(connection.Connection.SecretBindings, cancellationToken);
using var upstreamLogoutHint = session.ProtectedUpstreamLogoutHint is { Length: > 0 }
? new SensitiveString(Encoding.UTF8.GetString(dataProtectionProvider.CreateProtector("Elsa.ExternalAuthentication.UpstreamLogoutHint.v1").Unprotect(session.ProtectedUpstreamLogoutHint)))
: null;
upstream = await adapter.CreateLogoutRequestAsync(new ExternalLogoutContext(connection, logoutSecrets, transaction, state, clock, upstreamLogoutHint), cancellationToken);
transaction.ProtectedPayload = dataProtectionProvider.CreateProtector("Elsa.ExternalAuthentication.AdapterPayload.v1").Protect(upstream?.ProtectedAdapterState ?? []);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.TemporarilyUnavailable)), "upstream", cancellationToken);
}
finally
{
DisposeSecrets(logoutSecrets);
}
if (upstream is null)
return await LogoutOutcomeAsync(BrokerLogoutResult.Complete(request.PostLogoutRedirectUri), "upstream", cancellationToken);
await stateStore.PutAsync(BrokerTransactionPurpose.UpstreamLogout.ToString(), transaction.HandleHash, transaction, transaction.ExpiresAt, cancellationToken);
var continuationHandle = CreateOpaqueValue();
await stateStore.PutAsync("UpstreamLogoutContinue", Hash(continuationHandle), new BrokerTransaction
{
HandleHash = Hash(continuationHandle), Purpose = BrokerTransactionPurpose.UpstreamLogout, ClientId = request.ClientId,
CallbackUri = request.PostLogoutRedirectUri, ReturnPath = upstream.NavigationUri.AbsoluteUri, TenantId = session.TenantId,
ExpiresAt = transaction.ExpiresAt
}, transaction.ExpiresAt, cancellationToken);
return await LogoutOutcomeAsync(BrokerLogoutResult.Navigate(new Uri($"/external-authentication/logout/continue/{Uri.EscapeDataString(continuationHandle)}", UriKind.Relative)), "initiate", cancellationToken);
}
public async ValueTask<BrokerCallbackResult> CompleteLogoutAsync(string connectionKey, string state, CancellationToken cancellationToken = default)
{
var taken = await stateStore.TryTakeAsync<BrokerTransaction>(BrokerTransactionPurpose.UpstreamLogout.ToString(), Hash(state), cancellationToken);
if (taken is not TakeResult<BrokerTransaction>.Taken { Value: var transaction })
return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(taken is TakeResult<BrokerTransaction>.Expired ? BrokerErrorCategory.FlowExpired : BrokerErrorCategory.InvalidRequest)), "logout", "callback", null, connectionKey, cancellationToken);
if (!string.Equals(transaction.ConnectionKey, ConnectionRevisionCalculator.NormalizeKey(connectionKey), StringComparison.Ordinal))
return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidRequest)), "logout", "callback", transaction.TenantId, connectionKey, cancellationToken);
var connection = await connectionRegistry.FindByIdAsync(transaction.TenantId, transaction.ConnectionId!, cancellationToken);
return connection is null || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection.ArchivedAt is not null || !string.Equals(connection.Connection.MaterialRevision, transaction.ConnectionMaterialRevision, StringComparison.Ordinal)
? await CallbackOutcomeAsync(BrokerCallbackResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.FlowChanged)), "logout", "callback", transaction.TenantId, connectionKey, cancellationToken)
: await CallbackOutcomeAsync(BrokerCallbackResult.Redirect(transaction.CallbackUri), "logout", "callback", transaction.TenantId, connectionKey, cancellationToken);
}
public async ValueTask<BrokerLogoutResult> ContinueLogoutAsync(string handle, CancellationToken cancellationToken = default)
{
var taken = await stateStore.TryTakeAsync<BrokerTransaction>("UpstreamLogoutContinue", Hash(handle), cancellationToken);
if (taken is not TakeResult<BrokerTransaction>.Taken { Value: var transaction } || !Uri.TryCreate(transaction.ReturnPath, UriKind.Absolute, out var navigation))
return await LogoutOutcomeAsync(BrokerLogoutResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.InvalidRequest)), "continue", cancellationToken);
return await LogoutOutcomeAsync(BrokerLogoutResult.Navigate(navigation), "continue", cancellationToken);
}
private AuthenticationClient EnsureClient(string clientId) => options.Value.Clients.FirstOrDefault(x => x.IsEnabled && string.Equals(x.ClientId, clientId, StringComparison.Ordinal)) ?? throw new InvalidOperationException("Authentication client is unavailable.");
private static void ValidateAuthorizationRequest(BrokerAuthorizationRequest request, AuthenticationClient client)
{
if (!string.Equals(request.ResponseType, "code", StringComparison.Ordinal) || !string.Equals(request.CodeChallengeMethod, "S256", StringComparison.Ordinal) || !client.CallbackUris.Contains(request.RedirectUri) || !ClientReturnPathValidator.TryValidateForClient(request.ReturnPath, client.AllowedReturnPathPrefixes, out _))
throw new InvalidOperationException("The broker authorization request is invalid.");
}
private static bool VerifyPkce(string challenge, string? verifier) => !string.IsNullOrWhiteSpace(verifier) && string.Equals(challenge, Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))), StringComparison.Ordinal);
private static string CreateOpaqueValue() => Base64Url(RandomNumberGenerator.GetBytes(32));
private string Hash(string value) => handleHasher.Hash(value);
private static string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static Uri AppendCallbackParameters(Uri uri, string code, string? clientState)
{
var result = AppendQuery(uri, "code", code);
return string.IsNullOrWhiteSpace(clientState) ? result : AppendQuery(result, "state", clientState);
}
private static Uri AppendQuery(Uri uri, string key, string value) { var separator = string.IsNullOrEmpty(uri.Query) ? "?" : "&"; return new Uri(uri + separator + Uri.EscapeDataString(key) + "=" + Uri.EscapeDataString(value), uri.IsAbsoluteUri ? UriKind.Absolute : UriKind.Relative); }
private async ValueTask<BrokerCallbackResult> FailTrustedCallbackAsync(BrokerTransaction transaction, BrokerErrorCategory category, CancellationToken cancellationToken)
{
var error = BrokerErrorFactory.Create(category);
return await CallbackOutcomeAsync(BrokerCallbackResult.Fail(error, AppendQuery(AppendQuery(transaction.CallbackUri, "error", error.Error), "correlation_id", error.CorrelationId)), "external", "callback", transaction.TenantId, transaction.ConnectionId, cancellationToken);
}
private async ValueTask<IReadOnlyDictionary<string, ResolvedSecretBinding>> ResolveSecretsAsync(IDictionary<string, SecretBinding> bindings, CancellationToken cancellationToken)
{
var resolved = new Dictionary<string, ResolvedSecretBinding>(StringComparer.Ordinal);
try
{
foreach (var (name, binding) in bindings)
{
var resolver = secretBindingResolvers.FirstOrDefault(x => string.Equals(x.Type, binding.ResolverType, StringComparison.Ordinal))
?? throw new InvalidOperationException("A required secret binding resolver is unavailable.");
resolved[name] = await resolver.ResolveAsync(binding, cancellationToken);
}
return resolved;
}
catch
{
DisposeSecrets(resolved);
throw;
}
}
private static string GetSecretFingerprint(IReadOnlyDictionary<string, ResolvedSecretBinding> secrets) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("\n", secrets.OrderBy(x => x.Key, StringComparer.Ordinal).Select(x => $"{x.Key}:{x.Value.GenerationFingerprint}")))));
private static void DisposeSecrets(IReadOnlyDictionary<string, ResolvedSecretBinding> secrets)
{
foreach (var secret in secrets.Values)
secret.Value.Dispose();
}
private async ValueTask ValidateExchangeClientAsync(AuthenticationClient client, BrokerTokenRequest request, CancellationToken cancellationToken)
{
if (client.ClientType == AuthenticationClientType.Public)
{
if (string.IsNullOrWhiteSpace(request.Origin) || !client.AllowedOrigins.Contains(request.Origin) || !string.IsNullOrEmpty(request.ClientSecret))
throw new InvalidOperationException();
return;
}
if (client.SecretBinding is null || string.IsNullOrWhiteSpace(request.ClientSecret) || !string.Equals(client.ClientId, request.BasicClientId, StringComparison.Ordinal))
throw new InvalidOperationException();
var resolver = secretBindingResolvers.FirstOrDefault(x => string.Equals(x.Type, client.SecretBinding.ResolverType, StringComparison.Ordinal))
?? throw new InvalidOperationException();
using var configured = (await resolver.ResolveAsync(client.SecretBinding, cancellationToken)).Value;
using var supplied = new SensitiveString(request.ClientSecret);
if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(configured.Reveal()), Encoding.UTF8.GetBytes(supplied.Reveal())))
throw new InvalidOperationException();
}
}
public interface IExternalAuthenticationBroker
{
ValueTask<IReadOnlyCollection<LoginMethod>> DiscoverAsync(string targetTenantId, string clientId, CancellationToken cancellationToken = default);
ValueTask<BrokerInitiationResult> InitiateExternalAsync(BrokerAuthorizationRequest request, string targetTenantId, CancellationToken cancellationToken = default);
ValueTask<BrokerCallbackResult> CompleteCallbackAsync(string connectionKey, string state, IReadOnlyDictionary<string, IReadOnlyCollection<string>> parameters, CancellationToken cancellationToken = default);
ValueTask<BrokerCallbackResult> InitiateLocalAsync(LocalBrokerAuthorizationRequest request, string targetTenantId, CancellationToken cancellationToken = default);
ValueTask<BrokerTokenResult> ExchangeAsync(BrokerTokenRequest request, CancellationToken cancellationToken = default);
ValueTask<BrokerLogoutResult> LogoutAsync(BrokerLogoutRequest request, string externalSessionId, CancellationToken cancellationToken = default);
ValueTask<BrokerLogoutResult> ContinueLogoutAsync(string handle, CancellationToken cancellationToken = default);
ValueTask<BrokerCallbackResult> CompleteLogoutAsync(string connectionKey, string state, CancellationToken cancellationToken = default);
}
public record BrokerAuthorizationRequest(string ClientId, Uri RedirectUri, string ResponseType, string CodeChallenge, string CodeChallengeMethod, string ReturnPath, string ConnectionKey, string? ClientState = null);
public sealed record LocalBrokerAuthorizationRequest(string ClientId, Uri RedirectUri, string ResponseType, string CodeChallenge, string CodeChallengeMethod, string ReturnPath, string Username, string Password, string? ClientState = null) : BrokerAuthorizationRequest(ClientId, RedirectUri, ResponseType, CodeChallenge, CodeChallengeMethod, ReturnPath, "local", ClientState);
public sealed record BrokerLogoutRequest(string ClientId, Uri PostLogoutRedirectUri, string Mode);
public sealed record BrokerInitiationResult(Uri? NavigationUri, PublicBrokerError? Error) { public static BrokerInitiationResult Redirect(Uri uri) => new(uri, null); public static BrokerInitiationResult Fail(PublicBrokerError error) => new(null, error); }
public sealed record BrokerCallbackResult(Uri? RedirectUri, PublicBrokerError? Error) { public static BrokerCallbackResult Redirect(Uri uri) => new(uri, null); public static BrokerCallbackResult Fail(PublicBrokerError error, Uri? redirectUri = null) => new(redirectUri, error); }
public sealed record BrokerTokenRequest(string GrantType, string ClientId, Uri? RedirectUri, string? Code, string? CodeVerifier, string? RefreshToken, string? Origin = null, string? BasicClientId = null, string? ClientSecret = null);
public sealed record BrokerTokenResult(ExternalTokenResponse? Token, PublicBrokerError? Error) { public static BrokerTokenResult Success(ExternalTokenResponse token) => new(token, null); public static BrokerTokenResult Fail(PublicBrokerError error) => new(null, error); }
public sealed record BrokerLogoutResult(bool Completed, Uri? NavigationUri, Uri? RedirectUri, PublicBrokerError? Error)
{
public static BrokerLogoutResult Complete(Uri redirectUri) => new(true, null, redirectUri, null);
public static BrokerLogoutResult Navigate(Uri navigationUri) => new(false, navigationUri, null, null);
public static BrokerLogoutResult Fail(PublicBrokerError error) => new(false, null, null, error);
}