using System.Security.Cryptography; using Elsa.Common; using Elsa.Common.Multitenancy; using Elsa.ExternalAuthentication.Contracts; using Elsa.ExternalAuthentication.IntegrationTests.Broker; using Elsa.ExternalAuthentication.Models; using Elsa.ExternalAuthentication.Services; using Elsa.ExternalAuthentication.Stores.InMemory; using Elsa.Identity.Contracts; using Elsa.Identity.Entities; using Elsa.Identity.Models; using NSubstitute; namespace Elsa.ExternalAuthentication.IntegrationTests.Sessions; public class ExternalRefreshPermissionTests { [Fact] public async Task BrokerSnapshotsResolvedExternalGrantsWithProvenance() { var identity = new ExternalIdentity("https://issuer.example", "subject-a", new Dictionary>()); var adapter = new BrokerSecurityTests.RecordingAdapter { AuthenticationResult = new ExternalAuthenticationResult(identity, new Dictionary> { ["groups"] = ["operators"] }, []) }; var identityResolver = Substitute.For(); identityResolver.ResolveAsync(Arg.Any(), Arg.Any()) .Returns(ValueTask.FromResult(new ExternalIdentityResolution("user-a", false))); var permissionResolver = Substitute.For(); var expectedGrant = new PermissionGrant("workflows:manage", "group-mapping", "groups:operators"); permissionResolver.ResolveAsync(Arg.Any(), Arg.Any()) .Returns(ValueTask.FromResult(new PermissionGrantResult([expectedGrant], []))); var sessions = Substitute.For(); ExternalAuthenticationSession? savedSession = null; sessions.SaveAsync(Arg.Do(session => savedSession = session), Arg.Any()) .Returns(ValueTask.CompletedTask); var broker = BrokerSecurityTests.CreateBroker( adapter, identityResolver: identityResolver, permissionGrantResolver: permissionResolver, sessionStore: sessions); var request = new BrokerAuthorizationRequest( "studio", new Uri("https://studio.example/authentication/external/callback"), "code", "challenge", "S256", "/workflows", "contoso"); await broker.InitiateExternalAsync(request, "tenant-a"); var result = await broker.CompleteCallbackAsync( "contoso", adapter.CorrelationState!, new Dictionary> { ["state"] = [adapter.CorrelationState!] }); Assert.Null(result.Error); Assert.NotNull(savedSession); Assert.Equal([expectedGrant], savedSession.ExternalGrants); await permissionResolver.Received(1).ResolveAsync( Arg.Is(context => context.TargetTenantId == "tenant-a" && context.UserId == "user-a" && context.Identity == identity && context.ProjectedClaims.ContainsKey("groups")), Arg.Any()); } [Fact] public async Task RefreshRetainsBoundedExternalSnapshotAndReevaluatesCurrentElsaRoles() { var clock = new TestClock(); var sessionStore = new InMemoryExternalAuthenticationSessionStore(clock); var connection = new IdentityProviderConnection { Id = "connection-a", TenantId = "tenant-a", Key = "contoso", AdapterType = "openid-connect", DisplayName = "Contoso", IsEnabled = true, MaterialRevision = "revision-a" }; var effective = new EffectiveIdentityProviderConnection( connection, ConnectionSourceOwnership.Configuration, new ConnectionScope(ConnectionScopeKind.Tenant, "tenant-a"), ConnectionValidity.Valid, false, "configuration"); var registry = Substitute.For(); registry.FindByKeyAsync("tenant-a", "contoso", Arg.Any()) .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(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 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, tenantAccessor, clock); var externalGrant = new PermissionGrant("reports:view", "claim-mapping", "department:engineering"); var session = new ExternalAuthenticationSession { Id = "session-a", AuthenticationClientId = "studio", TenantId = "tenant-a", UserId = "user-a", ConnectionKey = "contoso", ConnectionMaterialRevision = "revision-a", SecretGenerationFingerprint = Convert.ToHexString(SHA256.HashData([])), Issuer = "https://issuer.example", SubjectHash = "subject-hash", ExternalGrants = [externalGrant], StartedAt = clock.UtcNow, LastRefreshedAt = clock.UtcNow, ExpiresAt = clock.UtcNow.AddHours(8), RefreshExpiresAt = clock.UtcNow.AddHours(8) }; 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); Assert.Equal(["workflows:manage", "reports:view"], issuanceContexts[1].Permissions); Assert.DoesNotContain("*", issuanceContexts[1].Permissions); Assert.Equal("session-a", issuanceContexts[1].ExternalAuthenticationSessionId); } private sealed class TestClock : ISystemClock { public DateTimeOffset UtcNow => DateTimeOffset.Parse("2026-07-24T12:00:00Z"); } }