| | | 1 | | using System.Security.Cryptography; |
| | | 2 | | using System.Text; |
| | | 3 | | using Elsa.Common; |
| | | 4 | | using Elsa.Common.Multitenancy; |
| | | 5 | | using Elsa.Extensions; |
| | | 6 | | using Elsa.ExternalAuthentication.Contracts; |
| | | 7 | | using Elsa.ExternalAuthentication.Models; |
| | | 8 | | using Elsa.ExternalAuthentication.Options; |
| | | 9 | | using Elsa.ExternalAuthentication.Notifications; |
| | | 10 | | using Elsa.ExternalAuthentication.Validation; |
| | | 11 | | using Elsa.Identity.Contracts; |
| | | 12 | | using Elsa.Identity.Models; |
| | | 13 | | using Microsoft.Extensions.Options; |
| | | 14 | | using Microsoft.AspNetCore.DataProtection; |
| | | 15 | | using Microsoft.IdentityModel.JsonWebTokens; |
| | | 16 | | |
| | | 17 | | namespace Elsa.ExternalAuthentication.Services; |
| | | 18 | | |
| | | 19 | | public 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 | | |
| | | 601 | | public 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 | | |
| | | 613 | | public record BrokerAuthorizationRequest(string ClientId, Uri RedirectUri, string ResponseType, string CodeChallenge, st |
| | 7 | 614 | | public sealed record LocalBrokerAuthorizationRequest(string ClientId, Uri RedirectUri, string ResponseType, string CodeC |
| | | 615 | | public sealed record BrokerLogoutRequest(string ClientId, Uri PostLogoutRedirectUri, string Mode); |
| | | 616 | | public sealed record BrokerInitiationResult(Uri? NavigationUri, PublicBrokerError? Error) { public static BrokerInitiati |
| | | 617 | | public sealed record BrokerCallbackResult(Uri? RedirectUri, PublicBrokerError? Error) { public static BrokerCallbackResu |
| | | 618 | | public sealed record BrokerTokenRequest(string GrantType, string ClientId, Uri? RedirectUri, string? Code, string? CodeV |
| | | 619 | | public sealed record BrokerTokenResult(ExternalTokenResponse? Token, PublicBrokerError? Error) { public static BrokerTok |
| | | 620 | | public 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 | | } |