| | | 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.Identity.Contracts; |
| | | 9 | | using Elsa.ExternalAuthentication.Options; |
| | | 10 | | using Elsa.Identity.Models; |
| | | 11 | | using Microsoft.Extensions.Options; |
| | | 12 | | |
| | | 13 | | namespace Elsa.ExternalAuthentication.Services; |
| | | 14 | | |
| | 4 | 15 | | public sealed class DefaultExternalAuthenticationTokenIssuer( |
| | 4 | 16 | | IExternalAuthenticationSessionStore sessionStore, |
| | 4 | 17 | | IIdentityProviderConnectionRegistry connectionRegistry, |
| | 4 | 18 | | IEnumerable<ISecretBindingResolver> secretBindingResolvers, |
| | 4 | 19 | | IUserProvider userProvider, |
| | 4 | 20 | | IRoleProvider roleProvider, |
| | 4 | 21 | | IElsaTokenService tokenService, |
| | 4 | 22 | | ITenantAccessor tenantAccessor, |
| | 4 | 23 | | ISystemClock clock, |
| | 4 | 24 | | IOptions<ExternalAuthenticationOptions> options) : IExternalAuthenticationTokenIssuer |
| | | 25 | | { |
| | | 26 | | public async ValueTask<ExternalTokenResponse> IssueAsync(ExternalAuthenticationSession session, CancellationToken ca |
| | | 27 | | { |
| | 4 | 28 | | var refreshToken = CreateRefreshToken(session.Id); |
| | 4 | 29 | | session.CurrentRefreshTokenHash = Hash(refreshToken); |
| | 4 | 30 | | await sessionStore.SaveAsync(session, cancellationToken); |
| | 4 | 31 | | return await IssueResponseAsync(session, refreshToken, cancellationToken); |
| | 4 | 32 | | } |
| | | 33 | | |
| | | 34 | | public async ValueTask<ExternalTokenResponse> RefreshAsync(string clientId, SensitiveString refreshToken, Cancellati |
| | | 35 | | { |
| | 3 | 36 | | var rawToken = refreshToken.Reveal(); |
| | 3 | 37 | | var separator = rawToken.IndexOf('.', StringComparison.Ordinal); |
| | 3 | 38 | | if (separator <= 0 || separator == rawToken.Length - 1) |
| | 0 | 39 | | throw new InvalidOperationException("The external refresh token is invalid."); |
| | 3 | 40 | | var sessionId = rawToken[..separator]; |
| | 3 | 41 | | var currentHash = Hash(rawToken); |
| | 3 | 42 | | var session = await sessionStore.FindByIdAsync(sessionId, cancellationToken); |
| | 3 | 43 | | if (session is null || !string.Equals(session.AuthenticationClientId, clientId, StringComparison.Ordinal)) |
| | 0 | 44 | | throw new InvalidOperationException("The external refresh token is invalid."); |
| | 3 | 45 | | var connection = await connectionRegistry.FindByKeyAsync(session.TenantId, session.ConnectionKey, cancellationTo |
| | 3 | 46 | | if (session.RevokedAt != null || session.ExpiresAt <= clock.UtcNow || connection is null || connection.IsShadowe |
| | 0 | 47 | | throw new InvalidOperationException("The external authentication session is no longer valid."); |
| | 3 | 48 | | if (!string.Equals(session.SecretGenerationFingerprint, await GetSecretFingerprintAsync(connection.Connection.Se |
| | 0 | 49 | | throw new InvalidOperationException("The external authentication session secrets changed."); |
| | | 50 | | |
| | 3 | 51 | | var nextToken = CreateRefreshToken(session.Id); |
| | 3 | 52 | | var rotation = await sessionStore.TryRotateRefreshTokenAsync(session.Id, currentHash, session.RefreshGeneration, |
| | 3 | 53 | | if (rotation is not ExternalAuthenticationSessionRotationResult.Rotated { Session: var rotated }) |
| | 1 | 54 | | throw new InvalidOperationException("The external refresh token cannot be used."); |
| | | 55 | | |
| | 2 | 56 | | return await IssueResponseAsync(rotated, nextToken, cancellationToken); |
| | 2 | 57 | | } |
| | | 58 | | |
| | | 59 | | private async ValueTask<ExternalTokenResponse> IssueResponseAsync(ExternalAuthenticationSession session, string refr |
| | | 60 | | { |
| | 6 | 61 | | using var tenantContext = tenantAccessor.PushContext(new() |
| | 6 | 62 | | { Id = session.TenantId, Name = session.TenantId }); |
| | 6 | 63 | | var user = await userProvider.FindAsync(new() |
| | 6 | 64 | | { Id = session.UserId }, cancellationToken) |
| | 6 | 65 | | ?? throw new InvalidOperationException("The external authentication session user no longer exists."); |
| | 6 | 66 | | var roles = (await roleProvider.FindByIdsAsync(user.Roles, cancellationToken)).ToArray(); |
| | | 67 | | // Role permissions go through the same deployment boundary as the external grants beside them. They |
| | | 68 | | // used to be concatenated raw, which let a permission the boundary had just excluded during grant |
| | | 69 | | // resolution reappear here from the same roles -- making the deny list unenforceable for anything a |
| | | 70 | | // role happened to carry, and ElsaRolePermissionGrantSource's own filtering pointless. Re-applying it |
| | | 71 | | // at issuance also picks up a boundary that changed since sign-in, because refreshing reissues. |
| | | 72 | | // With no boundary configured, which is the default, every well-formed permission passes and nothing |
| | | 73 | | // about this changes. |
| | 6 | 74 | | var boundary = new PermissionGrantBoundary(options.Value.PermissionGrants); |
| | 10 | 75 | | var permissions = roles.SelectMany(x => x.Permissions) |
| | 4 | 76 | | .Concat(session.ExternalGrants.Select(x => x.Permission)) |
| | 6 | 77 | | .Where(boundary.Allows) |
| | 6 | 78 | | .Distinct(StringComparer.Ordinal) |
| | 6 | 79 | | .ToArray(); |
| | 10 | 80 | | var accessToken = await tokenService.IssueAccessTokenAsync(new(user, roles.Select(x => x.Name).ToArray(), permis |
| | 6 | 81 | | var now = clock.UtcNow; |
| | 6 | 82 | | return new( |
| | 6 | 83 | | accessToken.Token, |
| | 6 | 84 | | "Bearer", |
| | 6 | 85 | | Math.Max(0, (long)(accessToken.ExpiresAt - now).TotalSeconds), |
| | 6 | 86 | | refreshToken, |
| | 6 | 87 | | Math.Max(0, (long)(session.RefreshExpiresAt - now).TotalSeconds), |
| | 6 | 88 | | Math.Max(0, (long)(session.ExpiresAt - now).TotalSeconds)); |
| | 6 | 89 | | } |
| | | 90 | | |
| | 7 | 91 | | private static string CreateRefreshToken(string sessionId) => $"{sessionId}.{Convert.ToBase64String(RandomNumberGene |
| | 10 | 92 | | private static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); |
| | | 93 | | |
| | | 94 | | private async ValueTask<string> GetSecretFingerprintAsync(IDictionary<string, SecretBinding> bindings, CancellationT |
| | | 95 | | { |
| | 3 | 96 | | var fingerprints = new List<string>(); |
| | 6 | 97 | | foreach (var (name, binding) in bindings) |
| | | 98 | | { |
| | 0 | 99 | | var resolver = secretBindingResolvers.FirstOrDefault(x => string.Equals(x.Type, binding.ResolverType, String |
| | 0 | 100 | | ?? throw new InvalidOperationException("A required secret binding resolver is unavailable."); |
| | 0 | 101 | | var resolved = await resolver.ResolveAsync(binding, cancellationToken); |
| | 0 | 102 | | fingerprints.Add($"{name}:{resolved.GenerationFingerprint}"); |
| | 0 | 103 | | resolved.Value.Dispose(); |
| | 0 | 104 | | } |
| | 3 | 105 | | return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("\n", fingerprints.OrderBy(x => x, |
| | 3 | 106 | | } |
| | | 107 | | } |