diff --git a/src/modules/Elsa.ExternalAuthentication/Services/DefaultExternalAuthenticationTokenIssuer.cs b/src/modules/Elsa.ExternalAuthentication/Services/DefaultExternalAuthenticationTokenIssuer.cs index 85b385d6d..861a5ee96 100644 --- a/src/modules/Elsa.ExternalAuthentication/Services/DefaultExternalAuthenticationTokenIssuer.cs +++ b/src/modules/Elsa.ExternalAuthentication/Services/DefaultExternalAuthenticationTokenIssuer.cs @@ -1,6 +1,7 @@ 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; @@ -16,6 +17,7 @@ public sealed class DefaultExternalAuthenticationTokenIssuer( IUserProvider userProvider, IRoleProvider roleProvider, IElsaTokenService tokenService, + ITenantAccessor tenantAccessor, ISystemClock clock) : IExternalAuthenticationTokenIssuer { public async ValueTask IssueAsync(ExternalAuthenticationSession session, CancellationToken cancellationToken = default) @@ -53,6 +55,7 @@ public sealed class DefaultExternalAuthenticationTokenIssuer( private async ValueTask IssueResponseAsync(ExternalAuthenticationSession session, string refreshToken, CancellationToken cancellationToken) { + using var tenantContext = tenantAccessor.PushContext(new Tenant { Id = session.TenantId, Name = session.TenantId }); var user = await userProvider.FindAsync(new UserFilter { Id = session.UserId }, cancellationToken) ?? throw new InvalidOperationException("The external authentication session user no longer exists."); var roles = (await roleProvider.FindByIdsAsync(user.Roles, cancellationToken)).ToArray(); diff --git a/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs b/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs index d6f0a77ed..2858f4cfd 100644 --- a/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs +++ b/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs @@ -1,6 +1,7 @@ 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; @@ -30,6 +31,7 @@ public sealed class ExternalAuthenticationBroker( IUserProvider userProvider, IRoleProvider roleProvider, IElsaTokenService elsaTokenService, + ITenantAccessor tenantAccessor, ISystemClock clock, IOptions options, ExternalAuthenticationSecurityNotifier? notifier = null) : IExternalAuthenticationBroker @@ -322,6 +324,7 @@ public sealed class ExternalAuthenticationBroker( } } + 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); diff --git a/src/modules/Elsa.Identity/Services/DefaultElsaTokenService.cs b/src/modules/Elsa.Identity/Services/DefaultElsaTokenService.cs index 87144678c..dace1331a 100644 --- a/src/modules/Elsa.Identity/Services/DefaultElsaTokenService.cs +++ b/src/modules/Elsa.Identity/Services/DefaultElsaTokenService.cs @@ -45,6 +45,7 @@ public sealed class DefaultElsaTokenService(ISystemClock systemClock, IOptions { + new(JwtRegisteredClaimNames.Sub, context.User.Id), new(JwtRegisteredClaimNames.Name, context.User.Name) }; claims.AddRange(context.AdditionalClaims); diff --git a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerSecurityTests.cs b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerSecurityTests.cs index d619d959a..c50f22c10 100644 --- a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerSecurityTests.cs +++ b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerSecurityTests.cs @@ -1,5 +1,8 @@ +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using Elsa.Common; +using Elsa.Common.Multitenancy; using Elsa.ExternalAuthentication.Contracts; using Elsa.ExternalAuthentication.Models; using Elsa.ExternalAuthentication.Notifications; @@ -9,15 +12,89 @@ using Elsa.ExternalAuthentication.Stores.InMemory; using Elsa.Identity.Contracts; using Elsa.Identity.Entities; using Elsa.Identity.Models; +using Elsa.Identity.Options; +using Elsa.Identity.Services; using Elsa.Mediator.Contracts; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; using NSubstitute; namespace Elsa.ExternalAuthentication.IntegrationTests.Broker; public class BrokerSecurityTests { + [Fact] + public async Task LocalAuthorizationCodeExchangeResolvesPermissionsInTheGrantTenant() + { + const string verifier = "local-login-code-verifier"; + var challenge = Convert.ToBase64String(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + var tenantAccessor = new DefaultTenantAccessor(); + var user = new User { Id = "user-a", Name = "admin", TenantId = "tenant-a", Roles = ["admin"] }; + var role = new Role { Id = "admin", Name = "Administrator", TenantId = "tenant-a", Permissions = ["*"] }; + var credentials = Substitute.For(); + credentials.ValidateAsync("admin", "password", Arg.Any()) + .Returns(ValueTask.FromResult(user)); + var users = Substitute.For(); + users.FindAsync(Arg.Any(), Arg.Any()) + .Returns(_ => Task.FromResult(tenantAccessor.TenantId == "tenant-a" ? user : null)); + var roles = Substitute.For(); + roles.FindManyAsync(Arg.Any(), Arg.Any()) + .Returns(_ => ValueTask.FromResult>(tenantAccessor.TenantId == "tenant-a" ? [role] : [])); + var tokens = new DefaultElsaTokenService(new TestClock(), Microsoft.Extensions.Options.Options.Create(new IdentityTokenOptions + { + SigningKey = "local-external-authentication-test-signing-key", + Issuer = "https://elsa.test", + Audience = "elsa-api" + })); + var broker = CreateBroker( + new RecordingAdapter(), + credentialsValidator: credentials, + userProvider: users, + roleProvider: roles, + tokenService: tokens, + tenantAccessor: tenantAccessor); + BrokerCallbackResult authorization; + using (tenantAccessor.PushContext(new Tenant { Id = "tenant-a", Name = "Tenant A" })) + { + authorization = await broker.InitiateLocalAsync( + new LocalBrokerAuthorizationRequest( + "studio", + new Uri("https://studio.example/authentication/external/callback"), + "code", + challenge, + "S256", + "/workflows", + "admin", + "password"), + "tenant-a"); + } + var code = Query(authorization.RedirectUri!, "code"); + + BrokerTokenResult exchange; + using (tenantAccessor.PushContext(new Tenant { Id = "tenant-b", Name = "Tenant B" })) + { + exchange = await broker.ExchangeAsync(new BrokerTokenRequest( + "authorization_code", + "studio", + new Uri("https://studio.example/authentication/external/callback"), + code, + verifier, + null, + "https://studio.example")); + + Assert.Equal("tenant-b", tenantAccessor.TenantId); + } + + Assert.Null(exchange.Error); + var accessToken = new JsonWebTokenHandler().ReadJsonWebToken(exchange.Token!.AccessToken); + Assert.Contains(accessToken.Claims, claim => claim.Type == JwtRegisteredClaimNames.Sub && claim.Value == user.Id); + Assert.Contains(accessToken.Claims, claim => claim.Type == "permissions" && claim.Value == "*"); + } + [Fact] public async Task ExternalInitiationUsesExactlyOneOpaqueProviderStateAndPersistsAdapterPayload() { @@ -115,7 +192,7 @@ public class BrokerSecurityTests roles.FindManyAsync(Arg.Any(), Arg.Any()).Returns(ValueTask.FromResult>([])); var tokens = Substitute.For(); tokens.IssueAccessTokenAsync(Arg.Any(), Arg.Any()).Returns(ValueTask.FromResult(new IssuedAccessToken("access", clock.UtcNow.AddHours(1)))); - var issuer = new DefaultExternalAuthenticationTokenIssuer(store, registry, [], users, roles, tokens, clock); + var issuer = new DefaultExternalAuthenticationTokenIssuer(store, registry, [], users, roles, tokens, new DefaultTenantAccessor(), clock); var session = new ExternalAuthenticationSession { Id = "session-a", AuthenticationClientId = "studio", TenantId = "tenant-a", UserId = "user-a", ConnectionKey = "contoso", ConnectionMaterialRevision = "revision-a", SecretGenerationFingerprint = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData([])), Issuer = "issuer", SubjectHash = "subject", StartedAt = clock.UtcNow, LastRefreshedAt = clock.UtcNow, ExpiresAt = clock.UtcNow.AddHours(1), RefreshExpiresAt = clock.UtcNow.AddHours(1) }; var first = await issuer.IssueAsync(session); @@ -232,6 +309,11 @@ public class BrokerSecurityTests IExternalIdentityResolver? identityResolver = null, IPermissionGrantResolver? permissionGrantResolver = null, IExternalAuthenticationSessionStore? sessionStore = null, + IUserCredentialsValidator? credentialsValidator = null, + IUserProvider? userProvider = null, + IRoleProvider? roleProvider = null, + IElsaTokenService? tokenService = null, + ITenantAccessor? tenantAccessor = null, ExternalAuthenticationSecurityNotifier? notifier = null) { var connection = new IdentityProviderConnection @@ -251,7 +333,7 @@ public class BrokerSecurityTests new HashSet { new("https://studio.example/authentication/external/callback") }, new HashSet(), new HashSet { "https://studio.example" }, new HashSet { "/workflows" }, null, true)] }); var clock = new TestClock(); - return new ExternalAuthenticationBroker(registry, [adapter], resolvers ?? [], hasher ?? new HmacExternalAuthenticationHandleHasher(), new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider(), identityResolver ?? Substitute.For(), permissionGrantResolver ?? Substitute.For(), new InMemoryExternalAuthenticationStateStore(clock), grants ?? new InMemoryAuthorizationGrantStore(clock), sessionStore ?? new InMemoryExternalAuthenticationSessionStore(clock), Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For(), clock, options, notifier); + return new ExternalAuthenticationBroker(registry, [adapter], resolvers ?? [], hasher ?? new HmacExternalAuthenticationHandleHasher(), new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider(), identityResolver ?? Substitute.For(), permissionGrantResolver ?? Substitute.For(), new InMemoryExternalAuthenticationStateStore(clock), grants ?? new InMemoryAuthorizationGrantStore(clock), sessionStore ?? new InMemoryExternalAuthenticationSessionStore(clock), Substitute.For(), credentialsValidator ?? Substitute.For(), userProvider ?? Substitute.For(), roleProvider ?? Substitute.For(), tokenService ?? Substitute.For(), tenantAccessor ?? new DefaultTenantAccessor(), clock, options, notifier); } private static BrokerAuthorizationRequest Request(string returnPath) => new("studio", new Uri("https://studio.example/authentication/external/callback"), "code", "challenge", "S256", returnPath, "contoso"); diff --git a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Sessions/ExternalRefreshPermissionTests.cs b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Sessions/ExternalRefreshPermissionTests.cs index 7cf499c6b..909590bd7 100644 --- a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Sessions/ExternalRefreshPermissionTests.cs +++ b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Sessions/ExternalRefreshPermissionTests.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using Elsa.Common; +using Elsa.Common.Multitenancy; using Elsa.ExternalAuthentication.Contracts; using Elsa.ExternalAuthentication.IntegrationTests.Broker; using Elsa.ExternalAuthentication.Models; @@ -92,18 +93,20 @@ public class ExternalRefreshPermissionTests .Returns(ValueTask.FromResult(effective)); var user = new User { Id = "user-a", Name = "alice", TenantId = "tenant-a", Roles = ["role-a"] }; var role = new Role { Id = "role-a", Name = "Operators", TenantId = "tenant-a", Permissions = ["*"] }; + var tenantAccessor = new DefaultTenantAccessor(); var users = Substitute.For(); - users.FindAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(user)); + users.FindAsync(Arg.Any(), Arg.Any()) + .Returns(_ => Task.FromResult(tenantAccessor.TenantId == "tenant-a" ? user : null)); var roles = Substitute.For(); roles.FindManyAsync(Arg.Any(), Arg.Any()) - .Returns(_ => ValueTask.FromResult>([role])); + .Returns(_ => ValueTask.FromResult>(tenantAccessor.TenantId == "tenant-a" ? [role] : [])); var issuanceContexts = new List(); var tokenService = Substitute.For(); tokenService.IssueAccessTokenAsync( Arg.Do(context => issuanceContexts.Add(context)), Arg.Any()) .Returns(_ => ValueTask.FromResult(new IssuedAccessToken($"access-{issuanceContexts.Count}", clock.UtcNow.AddHours(1)))); - var issuer = new DefaultExternalAuthenticationTokenIssuer(sessionStore, registry, [], users, roles, tokenService, clock); + var issuer = new DefaultExternalAuthenticationTokenIssuer(sessionStore, registry, [], users, roles, tokenService, tenantAccessor, clock); var externalGrant = new PermissionGrant("reports:view", "claim-mapping", "department:engineering"); var session = new ExternalAuthenticationSession { @@ -123,10 +126,15 @@ public class ExternalRefreshPermissionTests RefreshExpiresAt = clock.UtcNow.AddHours(8) }; - var initial = await issuer.IssueAsync(session); - role.Permissions = ["workflows:manage"]; - using var refreshToken = new SensitiveString(initial.RefreshToken); - await issuer.RefreshAsync("studio", refreshToken); + using (tenantAccessor.PushContext(new Tenant { Id = "tenant-b", Name = "Tenant B" })) + { + var initial = await issuer.IssueAsync(session); + role.Permissions = ["workflows:manage"]; + using var refreshToken = new SensitiveString(initial.RefreshToken); + await issuer.RefreshAsync("studio", refreshToken); + + Assert.Equal("tenant-b", tenantAccessor.TenantId); + } Assert.Equal(2, issuanceContexts.Count); Assert.Equal(["*", "reports:view"], issuanceContexts[0].Permissions); diff --git a/test/performance/Elsa.Workflows.PerformanceTests/ExternalAuthentication/ExternalAuthenticationBenchmarks.cs b/test/performance/Elsa.Workflows.PerformanceTests/ExternalAuthentication/ExternalAuthenticationBenchmarks.cs index c50da9c28..5e8cc21f7 100644 --- a/test/performance/Elsa.Workflows.PerformanceTests/ExternalAuthentication/ExternalAuthenticationBenchmarks.cs +++ b/test/performance/Elsa.Workflows.PerformanceTests/ExternalAuthentication/ExternalAuthenticationBenchmarks.cs @@ -1,6 +1,7 @@ using System.Text.Json; using BenchmarkDotNet.Attributes; using Elsa.Common; +using Elsa.Common.Multitenancy; using Elsa.ExternalAuthentication.Contracts; using Elsa.ExternalAuthentication.Models; using Elsa.ExternalAuthentication.Options; @@ -73,6 +74,7 @@ public class ExternalAuthenticationBenchmarks unused, unused, unused, + new DefaultTenantAccessor(), clock, options); } diff --git a/test/unit/Elsa.Identity.UnitTests/Services/DefaultElsaTokenServiceTests.cs b/test/unit/Elsa.Identity.UnitTests/Services/DefaultElsaTokenServiceTests.cs index b2c6a5d16..e579c085b 100644 --- a/test/unit/Elsa.Identity.UnitTests/Services/DefaultElsaTokenServiceTests.cs +++ b/test/unit/Elsa.Identity.UnitTests/Services/DefaultElsaTokenServiceTests.cs @@ -36,6 +36,7 @@ public class DefaultElsaTokenServiceTests var token = new JsonWebTokenHandler().ReadJsonWebToken(result.Token); Assert.Equal(clock.UtcNow.AddMinutes(15), result.ExpiresAt); + Assert.Contains(token.Claims, x => x.Type == JwtRegisteredClaimNames.Sub && x.Value == user.Id); Assert.Contains(token.Claims, x => x.Type == JwtRegisteredClaimNames.Name && x.Value == user.Name); Assert.Contains(token.Claims, x => x.Type == options.Value.TenantIdClaimsType && x.Value == user.TenantId); Assert.Contains(token.Claims, x => x.Type == ClaimTypes.Role && x.Value == "operator");