Preserve Elsa permissions during external sign-in
This commit is contained in:
parent
379e03fdda
commit
42e507b0d8
|
|
@ -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<ExternalTokenResponse> IssueAsync(ExternalAuthenticationSession session, CancellationToken cancellationToken = default)
|
||||
|
|
@ -53,6 +55,7 @@ public sealed class DefaultExternalAuthenticationTokenIssuer(
|
|||
|
||||
private async ValueTask<ExternalTokenResponse> 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();
|
||||
|
|
|
|||
|
|
@ -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<ExternalAuthenticationOptions> 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);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ public sealed class DefaultElsaTokenService(ISystemClock systemClock, IOptions<I
|
|||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, context.User.Id),
|
||||
new(JwtRegisteredClaimNames.Name, context.User.Name)
|
||||
};
|
||||
claims.AddRange(context.AdditionalClaims);
|
||||
|
|
|
|||
|
|
@ -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<IUserCredentialsValidator>();
|
||||
credentials.ValidateAsync("admin", "password", Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult<User?>(user));
|
||||
var users = Substitute.For<IUserProvider>();
|
||||
users.FindAsync(Arg.Any<UserFilter>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => Task.FromResult(tenantAccessor.TenantId == "tenant-a" ? user : null));
|
||||
var roles = Substitute.For<IRoleProvider>();
|
||||
roles.FindManyAsync(Arg.Any<RoleFilter>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => ValueTask.FromResult<IEnumerable<Role>>(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<RoleFilter>(), Arg.Any<CancellationToken>()).Returns(ValueTask.FromResult<IEnumerable<Role>>([]));
|
||||
var tokens = Substitute.For<IElsaTokenService>();
|
||||
tokens.IssueAccessTokenAsync(Arg.Any<TokenIssuanceContext>(), Arg.Any<CancellationToken>()).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<Uri> { new("https://studio.example/authentication/external/callback") }, new HashSet<Uri>(), new HashSet<string> { "https://studio.example" }, new HashSet<string> { "/workflows" }, null, true)]
|
||||
});
|
||||
var clock = new TestClock();
|
||||
return new ExternalAuthenticationBroker(registry, [adapter], resolvers ?? [], hasher ?? new HmacExternalAuthenticationHandleHasher(), new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider(), identityResolver ?? Substitute.For<IExternalIdentityResolver>(), permissionGrantResolver ?? Substitute.For<IPermissionGrantResolver>(), new InMemoryExternalAuthenticationStateStore(clock), grants ?? new InMemoryAuthorizationGrantStore(clock), sessionStore ?? new InMemoryExternalAuthenticationSessionStore(clock), Substitute.For<IExternalAuthenticationTokenIssuer>(), Substitute.For<IUserCredentialsValidator>(), Substitute.For<IUserProvider>(), Substitute.For<IRoleProvider>(), Substitute.For<IElsaTokenService>(), clock, options, notifier);
|
||||
return new ExternalAuthenticationBroker(registry, [adapter], resolvers ?? [], hasher ?? new HmacExternalAuthenticationHandleHasher(), new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider(), identityResolver ?? Substitute.For<IExternalIdentityResolver>(), permissionGrantResolver ?? Substitute.For<IPermissionGrantResolver>(), new InMemoryExternalAuthenticationStateStore(clock), grants ?? new InMemoryAuthorizationGrantStore(clock), sessionStore ?? new InMemoryExternalAuthenticationSessionStore(clock), Substitute.For<IExternalAuthenticationTokenIssuer>(), credentialsValidator ?? Substitute.For<IUserCredentialsValidator>(), userProvider ?? Substitute.For<IUserProvider>(), roleProvider ?? Substitute.For<IRoleProvider>(), tokenService ?? Substitute.For<IElsaTokenService>(), 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");
|
||||
|
|
|
|||
|
|
@ -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<EffectiveIdentityProviderConnection?>(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<IUserProvider>();
|
||||
users.FindAsync(Arg.Any<UserFilter>(), Arg.Any<CancellationToken>()).Returns(Task.FromResult<User?>(user));
|
||||
users.FindAsync(Arg.Any<UserFilter>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => Task.FromResult(tenantAccessor.TenantId == "tenant-a" ? user : null));
|
||||
var roles = Substitute.For<IRoleProvider>();
|
||||
roles.FindManyAsync(Arg.Any<RoleFilter>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => ValueTask.FromResult<IEnumerable<Role>>([role]));
|
||||
.Returns(_ => ValueTask.FromResult<IEnumerable<Role>>(tenantAccessor.TenantId == "tenant-a" ? [role] : []));
|
||||
var issuanceContexts = new List<TokenIssuanceContext>();
|
||||
var tokenService = Substitute.For<IElsaTokenService>();
|
||||
tokenService.IssueAccessTokenAsync(
|
||||
Arg.Do<TokenIssuanceContext>(context => issuanceContexts.Add(context)),
|
||||
Arg.Any<CancellationToken>())
|
||||
.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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue