diff --git a/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs b/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs index e3559cea4..24b8b0686 100644 --- a/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs +++ b/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs @@ -12,6 +12,7 @@ using Elsa.Identity.Contracts; using Elsa.Identity.Models; using Microsoft.Extensions.Options; using Microsoft.AspNetCore.DataProtection; +using Microsoft.IdentityModel.JsonWebTokens; namespace Elsa.ExternalAuthentication.Services; @@ -31,6 +32,7 @@ public sealed class ExternalAuthenticationBroker( IUserProvider userProvider, IRoleProvider roleProvider, IElsaTokenService elsaTokenService, + IIdentityRefreshTokenService identityRefreshTokenService, ITenantAccessor tenantAccessor, ISystemClock clock, IOptions options, @@ -296,7 +298,16 @@ public sealed class ExternalAuthenticationBroker( if (string.Equals(request.GrantType, "refresh_token", StringComparison.Ordinal)) { using var refreshToken = new SensitiveString(request.RefreshToken ?? string.Empty); - try { return await TokenOutcomeAsync(BrokerTokenResult.Success(await tokenIssuer.RefreshAsync(request.ClientId, refreshToken, cancellationToken)), "refresh", "exchange", cancellationToken); } + try + { + var token = refreshToken.Reveal(); + var response = IsJwt(token) + ? await RefreshLocalAsync(token, cancellationToken) + : await tokenIssuer.RefreshAsync(request.ClientId, refreshToken, cancellationToken); + return await TokenOutcomeAsync(response is null + ? BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.AccessDenied)) + : BrokerTokenResult.Success(response), "refresh", "exchange", cancellationToken); + } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch { return await TokenOutcomeAsync(BrokerTokenResult.Fail(BrokerErrorFactory.Create(BrokerErrorCategory.AccessDenied)), "refresh", "exchange", cancellationToken); } } @@ -339,7 +350,13 @@ public sealed class ExternalAuthenticationBroker( var context = new TokenIssuanceContext(user, roles.Select(x => x.Name).ToArray(), roles.SelectMany(x => x.Permissions).Distinct().ToArray(), []); var access = await elsaTokenService.IssueAccessTokenAsync(context, cancellationToken); var refresh = await elsaTokenService.IssueRefreshTokenAsync(context, cancellationToken); - return await TokenOutcomeAsync(BrokerTokenResult.Success(new ExternalTokenResponse(access.Token, "Bearer", (long)(access.ExpiresAt - clock.UtcNow).TotalSeconds, refresh.Token, 0, 0)), "token_exchange", "issue", cancellationToken); + return await TokenOutcomeAsync(BrokerTokenResult.Success(new ExternalTokenResponse( + access.Token, + "Bearer", + SecondsUntil(access.ExpiresAt), + refresh.Token, + SecondsUntil(refresh.ExpiresAt), + 0)), "token_exchange", "issue", cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -351,6 +368,30 @@ public sealed class ExternalAuthenticationBroker( } } + private async ValueTask RefreshLocalAsync(string refreshToken, CancellationToken cancellationToken) + { + var tokens = await identityRefreshTokenService.RefreshAsync(refreshToken, cancellationToken); + + if (tokens is null) + return null; + + return new ExternalTokenResponse( + tokens.AccessToken, + "Bearer", + SecondsUntil(GetExpiresAt(tokens.AccessToken)), + tokens.RefreshToken, + SecondsUntil(GetExpiresAt(tokens.RefreshToken)), + 0); + } + + private long SecondsUntil(DateTimeOffset? expiresAt) => + expiresAt is null ? 0 : Math.Max(0, (long)(expiresAt.Value - clock.UtcNow).TotalSeconds); + + private static DateTimeOffset GetExpiresAt(string token) => + new(new JsonWebTokenHandler().ReadJsonWebToken(token).ValidTo, TimeSpan.Zero); + + private static bool IsJwt(string token) => token.Count(x => x == '.') == 2; + public async ValueTask LogoutAsync(BrokerLogoutRequest request, string externalSessionId, CancellationToken cancellationToken = default) { AuthenticationClient client; diff --git a/src/modules/Elsa.Identity/Contracts/IIdentityRefreshTokenService.cs b/src/modules/Elsa.Identity/Contracts/IIdentityRefreshTokenService.cs new file mode 100644 index 000000000..9cdf9a5ef --- /dev/null +++ b/src/modules/Elsa.Identity/Contracts/IIdentityRefreshTokenService.cs @@ -0,0 +1,14 @@ +using Elsa.Identity.Models; + +namespace Elsa.Identity.Contracts; + +/// +/// Validates and exchanges Elsa identity refresh tokens. +/// +public interface IIdentityRefreshTokenService +{ + /// + /// Validates the specified refresh token and issues a new token pair using the user's current roles and permissions. + /// + ValueTask RefreshAsync(string refreshToken, CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Identity/Features/IdentityFeature.cs b/src/modules/Elsa.Identity/Features/IdentityFeature.cs index 4f2619a6a..577ccb805 100644 --- a/src/modules/Elsa.Identity/Features/IdentityFeature.cs +++ b/src/modules/Elsa.Identity/Features/IdentityFeature.cs @@ -215,6 +215,7 @@ public class IdentityFeature : FeatureBase .AddScoped() .AddScoped() .AddScoped(sp => ActivatorUtilities.CreateInstance(sp)) + .AddScoped() .AddScoped() .AddScoped() .AddScoped(sp => sp.GetRequiredService()) diff --git a/src/modules/Elsa.Identity/Options/IdentityTokenOptions.cs b/src/modules/Elsa.Identity/Options/IdentityTokenOptions.cs index 85166f4cd..41e060044 100644 --- a/src/modules/Elsa.Identity/Options/IdentityTokenOptions.cs +++ b/src/modules/Elsa.Identity/Options/IdentityTokenOptions.cs @@ -61,15 +61,7 @@ public class IdentityTokenOptions /// The required token usage claim value. public void ConfigureJwtBearerOptions(JwtBearerOptions options, string requiredTokenUse) { - options.TokenValidationParameters = new TokenValidationParameters - { - IssuerSigningKey = CreateSecurityKey(), - ValidAudience = Audience, - ValidIssuer = Issuer, - ValidateLifetime = true, - LifetimeValidator = ValidateLifetime, - NameClaimType = JwtRegisteredClaimNames.Name - }; + options.TokenValidationParameters = CreateTokenValidationParameters(); options.Events ??= new JwtBearerEvents(); var previousOnTokenValidated = options.Events.OnTokenValidated; options.Events.OnTokenValidated = async context => @@ -86,6 +78,20 @@ public class IdentityTokenOptions }; } + /// + /// Creates token validation parameters for Elsa identity tokens. + /// + public TokenValidationParameters CreateTokenValidationParameters() => new() + { + IssuerSigningKey = CreateSecurityKey(), + ValidAudience = Audience, + ValidIssuer = Issuer, + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + LifetimeValidator = ValidateLifetime, + NameClaimType = JwtRegisteredClaimNames.Name + }; + private static bool ValidateLifetime(DateTime? notBefore, DateTime? expires, SecurityToken securityToken, TokenValidationParameters validationParameters) { return expires != null && expires > DateTime.UtcNow; diff --git a/src/modules/Elsa.Identity/Services/DefaultIdentityRefreshTokenService.cs b/src/modules/Elsa.Identity/Services/DefaultIdentityRefreshTokenService.cs new file mode 100644 index 000000000..5411d48d1 --- /dev/null +++ b/src/modules/Elsa.Identity/Services/DefaultIdentityRefreshTokenService.cs @@ -0,0 +1,54 @@ +using Elsa.Common.Multitenancy; +using Elsa.Identity.Constants; +using Elsa.Identity.Contracts; +using Elsa.Identity.Models; +using Elsa.Identity.Options; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; + +namespace Elsa.Identity.Services; + +/// +/// Validates and exchanges Elsa identity refresh tokens. +/// +public sealed class DefaultIdentityRefreshTokenService( + IUserProvider userProvider, + IAccessTokenIssuer accessTokenIssuer, + ITenantAccessor tenantAccessor, + IOptions identityTokenOptions) : IIdentityRefreshTokenService +{ + /// + public async ValueTask RefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(refreshToken)) + return null; + + var options = identityTokenOptions.Value; + var validationResult = await new JsonWebTokenHandler().ValidateTokenAsync(refreshToken, options.CreateTokenValidationParameters()); + + if (!validationResult.IsValid) + return null; + + var identity = validationResult.ClaimsIdentity; + var tokenUse = identity.FindFirst(TokenUse.ClaimType)?.Value; + + if (!string.Equals(tokenUse, TokenUse.Refresh, StringComparison.Ordinal)) + return null; + + var userId = identity.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; + var userName = identity.FindFirst(JwtRegisteredClaimNames.Name)?.Value; + + if (string.IsNullOrWhiteSpace(userId) && string.IsNullOrWhiteSpace(userName)) + return null; + + var tenantId = identity.FindFirst(options.TenantIdClaimsType)?.Value; + var tenant = string.IsNullOrWhiteSpace(tenantId) ? null : new Tenant { Id = tenantId, Name = tenantId }; + using var tenantContext = tenantAccessor.PushContext(tenant); + var userFilter = string.IsNullOrWhiteSpace(userId) + ? new UserFilter { Name = userName } + : new UserFilter { Id = userId }; + var user = await userProvider.FindAsync(userFilter, cancellationToken); + + return user is null ? null : await accessTokenIssuer.IssueTokensAsync(user, cancellationToken); + } +} diff --git a/src/modules/Elsa.Identity/ShellFeatures/IdentityFeature.cs b/src/modules/Elsa.Identity/ShellFeatures/IdentityFeature.cs index d6613f04d..16087567d 100644 --- a/src/modules/Elsa.Identity/ShellFeatures/IdentityFeature.cs +++ b/src/modules/Elsa.Identity/ShellFeatures/IdentityFeature.cs @@ -80,6 +80,7 @@ public class IdentityFeature : IFastEndpointsShellFeature .AddScoped() .AddScoped() .AddScoped(sp => ActivatorUtilities.CreateInstance(sp)) + .AddScoped() .AddScoped() .AddScoped() .AddScoped(sp => sp.GetRequiredService()) diff --git a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerSecurityTests.cs b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerSecurityTests.cs index 54cdce871..54f3a66ff 100644 --- a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerSecurityTests.cs +++ b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerSecurityTests.cs @@ -25,7 +25,7 @@ namespace Elsa.ExternalAuthentication.IntegrationTests.Broker; public class BrokerSecurityTests { [Fact] - public async Task LocalAuthorizationCodeExchangeResolvesPermissionsInTheGrantTenant() + public async Task LocalAuthorizationCodeExchangeAndRefreshResolveCurrentPermissionsInTheTokenTenant() { const string verifier = "local-login-code-verifier"; var challenge = Convert.ToBase64String(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))) @@ -44,18 +44,25 @@ public class BrokerSecurityTests 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 + var tokenOptions = Microsoft.Extensions.Options.Options.Create(new IdentityTokenOptions { SigningKey = "local-external-authentication-test-signing-key", Issuer = "https://elsa.test", Audience = "elsa-api" - })); + }); + var tokens = new DefaultElsaTokenService(new CurrentTestClock(), tokenOptions); + var refreshTokens = new DefaultIdentityRefreshTokenService(users, new DefaultAccessTokenIssuer(roles, tokens), tenantAccessor, tokenOptions); + var externalTokenIssuer = Substitute.For(); + externalTokenIssuer.RefreshAsync("studio", Arg.Any(), Arg.Any()) + .Returns(ValueTask.FromException(new InvalidOperationException("A local refresh token must not use the external-session issuer."))); var broker = CreateBroker( new RecordingAdapter(), + tokenIssuer: externalTokenIssuer, credentialsValidator: credentials, userProvider: users, roleProvider: roles, tokenService: tokens, + identityRefreshTokenService: refreshTokens, tenantAccessor: tenantAccessor); BrokerCallbackResult authorization; using (tenantAccessor.PushContext(new Tenant { Id = "tenant-a", Name = "Tenant A" })) @@ -93,6 +100,25 @@ public class BrokerSecurityTests 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 == "*"); + + role.Permissions = ["workflows:manage"]; + var refresh = await broker.ExchangeAsync(new BrokerTokenRequest( + "refresh_token", + "studio", + null, + null, + null, + exchange.Token.RefreshToken, + "https://studio.example")); + + Assert.Null(refresh.Error); + Assert.NotNull(refresh.Token); + Assert.True(exchange.Token.RefreshExpiresIn > 0); + Assert.True(refresh.Token.RefreshExpiresIn > 0); + var refreshedAccessToken = new JsonWebTokenHandler().ReadJsonWebToken(refresh.Token.AccessToken); + Assert.Contains(refreshedAccessToken.Claims, claim => claim.Type == "permissions" && claim.Value == "workflows:manage"); + Assert.DoesNotContain(refreshedAccessToken.Claims, claim => claim.Type == "permissions" && claim.Value == "*"); + await externalTokenIssuer.DidNotReceive().RefreshAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -112,6 +138,32 @@ public class BrokerSecurityTests Assert.Contains("state=state", result.RedirectUri?.Query); } + [Fact] + public async Task OpaqueRefreshTokensContinueToUseTheExternalSessionIssuer() + { + var expected = new ExternalTokenResponse("access", "Bearer", 300, "session.rotated", 600, 600); + var externalTokenIssuer = Substitute.For(); + externalTokenIssuer.RefreshAsync("studio", Arg.Any(), Arg.Any()).Returns(expected); + var identityRefreshTokenService = Substitute.For(); + var broker = CreateBroker( + new RecordingAdapter(), + tokenIssuer: externalTokenIssuer, + identityRefreshTokenService: identityRefreshTokenService); + + var result = await broker.ExchangeAsync(new BrokerTokenRequest( + "refresh_token", + "studio", + null, + null, + null, + "session.random", + "https://studio.example")); + + Assert.Null(result.Error); + Assert.Same(expected, result.Token); + await identityRefreshTokenService.DidNotReceive().RefreshAsync(Arg.Any(), Arg.Any()); + } + [Fact] public async Task ExternalInitiationUsesExactlyOneOpaqueProviderStateAndPersistsAdapterPayload() { @@ -326,10 +378,12 @@ public class BrokerSecurityTests IExternalIdentityResolver? identityResolver = null, IPermissionGrantResolver? permissionGrantResolver = null, IExternalAuthenticationSessionStore? sessionStore = null, + IExternalAuthenticationTokenIssuer? tokenIssuer = null, IUserCredentialsValidator? credentialsValidator = null, IUserProvider? userProvider = null, IRoleProvider? roleProvider = null, IElsaTokenService? tokenService = null, + IIdentityRefreshTokenService? identityRefreshTokenService = null, ITenantAccessor? tenantAccessor = null, ExternalAuthenticationSecurityNotifier? notifier = null) { @@ -350,13 +404,14 @@ 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(), credentialsValidator ?? Substitute.For(), userProvider ?? Substitute.For(), roleProvider ?? Substitute.For(), tokenService ?? Substitute.For(), tenantAccessor ?? new DefaultTenantAccessor(), 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), tokenIssuer ?? Substitute.For(), credentialsValidator ?? Substitute.For(), userProvider ?? Substitute.For(), roleProvider ?? Substitute.For(), tokenService ?? Substitute.For(), identityRefreshTokenService ?? 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"); private static string? Query(Uri uri, string key) => System.Web.HttpUtility.ParseQueryString(uri.Query)[key]; private sealed class TestClock : ISystemClock { public DateTimeOffset UtcNow => DateTimeOffset.Parse("2026-01-01T00:00:00Z"); } + private sealed class CurrentTestClock : ISystemClock { public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; } internal sealed class RecordingAdapter : IExternalAuthenticationAdapter { diff --git a/test/performance/Elsa.Workflows.PerformanceTests/ExternalAuthentication/ExternalAuthenticationBenchmarks.cs b/test/performance/Elsa.Workflows.PerformanceTests/ExternalAuthentication/ExternalAuthenticationBenchmarks.cs index 5e8cc21f7..ea416aae4 100644 --- a/test/performance/Elsa.Workflows.PerformanceTests/ExternalAuthentication/ExternalAuthenticationBenchmarks.cs +++ b/test/performance/Elsa.Workflows.PerformanceTests/ExternalAuthentication/ExternalAuthenticationBenchmarks.cs @@ -74,6 +74,7 @@ public class ExternalAuthenticationBenchmarks unused, unused, unused, + unused, new DefaultTenantAccessor(), clock, options); @@ -145,7 +146,8 @@ public class ExternalAuthenticationBenchmarks IUserCredentialsValidator, IUserProvider, IRoleProvider, - IElsaTokenService + IElsaTokenService, + IIdentityRefreshTokenService { public ValueTask ResolveAsync(ExternalIdentityResolutionContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public ValueTask ResolveAsync(PermissionGrantResolutionContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -156,6 +158,7 @@ public class ExternalAuthenticationBenchmarks public ValueTask> FindManyAsync(RoleFilter filter, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public ValueTask IssueAccessTokenAsync(TokenIssuanceContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public ValueTask IssueRefreshTokenAsync(TokenIssuanceContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public ValueTask RefreshAsync(string refreshToken, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class FixedClock : ISystemClock diff --git a/test/unit/Elsa.Identity.UnitTests/Services/DefaultIdentityRefreshTokenServiceTests.cs b/test/unit/Elsa.Identity.UnitTests/Services/DefaultIdentityRefreshTokenServiceTests.cs new file mode 100644 index 000000000..60672c195 --- /dev/null +++ b/test/unit/Elsa.Identity.UnitTests/Services/DefaultIdentityRefreshTokenServiceTests.cs @@ -0,0 +1,45 @@ +using Elsa.Common; +using Elsa.Common.Multitenancy; +using Elsa.Identity.Contracts; +using Elsa.Identity.Entities; +using Elsa.Identity.Models; +using Elsa.Identity.Options; +using Elsa.Identity.Services; +using NSubstitute; + +namespace Elsa.Identity.UnitTests.Services; + +public class DefaultIdentityRefreshTokenServiceTests +{ + [Fact] + public async Task RefreshAsyncRejectsAccessAndTamperedTokens() + { + var options = Microsoft.Extensions.Options.Options.Create(new IdentityTokenOptions + { + SigningKey = IdentityTokenTestConstants.SigningKey, + Issuer = "https://elsa.test", + Audience = "elsa-api" + }); + var tokenService = new DefaultElsaTokenService(new CurrentClock(), options); + var user = new User { Id = "user-a", Name = "admin" }; + var context = new TokenIssuanceContext(user, [], [], []); + var accessToken = await tokenService.IssueAccessTokenAsync(context); + var refreshToken = await tokenService.IssueRefreshTokenAsync(context); + var tamperedRefreshToken = refreshToken.Token[..^1] + (refreshToken.Token[^1] == 'a' ? 'b' : 'a'); + var accessTokenIssuer = Substitute.For(); + var service = new DefaultIdentityRefreshTokenService( + Substitute.For(), + accessTokenIssuer, + new DefaultTenantAccessor(), + options); + + Assert.Null(await service.RefreshAsync(accessToken.Token)); + Assert.Null(await service.RefreshAsync(tamperedRefreshToken)); + await accessTokenIssuer.DidNotReceive().IssueTokensAsync(Arg.Any(), Arg.Any()); + } + + private sealed class CurrentClock : ISystemClock + { + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; + } +}