diff --git a/doc/changelogs/3.6.0.md b/doc/changelogs/3.6.0.md index 53889153e..d45d8867d 100644 --- a/doc/changelogs/3.6.0.md +++ b/doc/changelogs/3.6.0.md @@ -66,6 +66,8 @@ Compare: [`3.5.3...3.6.0`](https://github.com/elsa-workflows/elsa-core/compare/3 ## 🔧 Improvements +- **Identity token-use enforcement**: Access and refresh JWTs now carry a `token_use` claim. Default API bearer authentication accepts only access tokens, and `/identity/refresh-token` accepts only refresh tokens, preventing refresh tokens from being used as normal API bearer tokens. ([#7482](https://github.com/elsa-workflows/elsa-core/issues/7482)) + - **`Elsa.Common` — distributed lock resilience**: A retry pipeline now wraps distributed lock acquisition and release to survive transient errors (network glitches, database timeouts). A new `ITransientExceptionDetector` service identifies retryable exceptions. ([ca268c16ad](https://github.com/elsa-workflows/elsa-core/commit/ca268c16ad)) ([#7161](https://github.com/elsa-workflows/elsa-core/pull/7161)) - **Tenant task manager with dependency ordering**: Tenant startup, background, and recurring task execution is now consolidated into a single `TenantTaskManager`. A new `[TaskDependency]` attribute and `TopologicalTaskSorter` ensure tasks execute in the correct dependency order. ([b577279321](https://github.com/elsa-workflows/elsa-core/commit/b577279321)) ([#7174](https://github.com/elsa-workflows/elsa-core/pull/7174)) @@ -162,4 +164,4 @@ Compare: [`3.5.3...3.6.0`](https://github.com/elsa-workflows/elsa-core/compare/3 * Removed `Elsa.ServerAndStudio.Web` and related sample projects from solution. ([ecf5b390f1](https://github.com/elsa-workflows/elsa-core/commit/ecf5b390f1)) * Updated documentation to reflect .NET 10.0 support and remove deprecated external dependency references. ([7add1030c4](https://github.com/elsa-workflows/elsa-core/commit/7add1030c4)) * Added DeepWiki badge to README. ([b3ad57191a](https://github.com/elsa-workflows/elsa-core/commit/b3ad57191a)) -* Null safety and compiler warning fixes across multiple modules. ([490c8a2c9e](https://github.com/elsa-workflows/elsa-core/commit/490c8a2c9e), [2c0b3da5de](https://github.com/elsa-workflows/elsa-core/commit/2c0b3da5de)) ([#7050](https://github.com/elsa-workflows/elsa-core/pull/7050), [#7051](https://github.com/elsa-workflows/elsa-core/pull/7051)) \ No newline at end of file +* Null safety and compiler warning fixes across multiple modules. ([490c8a2c9e](https://github.com/elsa-workflows/elsa-core/commit/490c8a2c9e), [2c0b3da5de](https://github.com/elsa-workflows/elsa-core/commit/2c0b3da5de)) ([#7050](https://github.com/elsa-workflows/elsa-core/pull/7050), [#7051](https://github.com/elsa-workflows/elsa-core/pull/7051)) diff --git a/doc/wiki/identity-tenancy-security.md b/doc/wiki/identity-tenancy-security.md index be347a2e3..9e5192115 100644 --- a/doc/wiki/identity-tenancy-security.md +++ b/doc/wiki/identity-tenancy-security.md @@ -34,6 +34,8 @@ elsa See [src/apps/Elsa.Server.Web/Program.cs](../../src/apps/Elsa.Server.Web/Program.cs). +Identity JWTs include a `token_use` claim. API bearer authentication accepts only access tokens (`token_use=access`), while `/identity/refresh-token` uses a dedicated refresh-token bearer scheme and accepts only refresh tokens (`token_use=refresh`). Clients should not send refresh tokens to normal API endpoints or access tokens to the refresh endpoint. + ## Default Admin Bootstrap The default admin bootstrap is documented in [src/modules/Elsa.Identity/README.md](../../src/modules/Elsa.Identity/README.md) and [ADR 0010](../adr/0010-default-admin-user-bootstrap-for-initial-identity-access.md). diff --git a/src/modules/Elsa.Identity/Constants/IdentityAuthenticationSchemes.cs b/src/modules/Elsa.Identity/Constants/IdentityAuthenticationSchemes.cs new file mode 100644 index 000000000..c3bb6b7c2 --- /dev/null +++ b/src/modules/Elsa.Identity/Constants/IdentityAuthenticationSchemes.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Elsa.Identity.Constants; + +/// +/// Authentication scheme names used by Elsa identity. +/// +public static class IdentityAuthenticationSchemes +{ + /// + /// The default JWT bearer scheme for API access tokens. + /// + public const string AccessToken = JwtBearerDefaults.AuthenticationScheme; + + /// + /// JWT bearer scheme used by the token refresh endpoint. + /// + public const string RefreshToken = "RefreshToken"; +} diff --git a/src/modules/Elsa.Identity/Constants/TokenUse.cs b/src/modules/Elsa.Identity/Constants/TokenUse.cs new file mode 100644 index 000000000..e5d0abb45 --- /dev/null +++ b/src/modules/Elsa.Identity/Constants/TokenUse.cs @@ -0,0 +1,22 @@ +namespace Elsa.Identity.Constants; + +/// +/// Constants for distinguishing identity token usage. +/// +public static class TokenUse +{ + /// + /// The claim type that stores the intended token usage. + /// + public const string ClaimType = "token_use"; + + /// + /// Token use value for API bearer access tokens. + /// + public const string Access = "access"; + + /// + /// Token use value for refresh tokens. + /// + public const string Refresh = "refresh"; +} diff --git a/src/modules/Elsa.Identity/Endpoints/RefreshToken/Endpoint.cs b/src/modules/Elsa.Identity/Endpoints/RefreshToken/Endpoint.cs index 4d134171c..d33092332 100644 --- a/src/modules/Elsa.Identity/Endpoints/RefreshToken/Endpoint.cs +++ b/src/modules/Elsa.Identity/Endpoints/RefreshToken/Endpoint.cs @@ -1,4 +1,5 @@ using Elsa.Extensions; +using Elsa.Identity.Constants; using Elsa.Identity.Contracts; using Elsa.Identity.Models; using FastEndpoints; @@ -26,6 +27,7 @@ internal class RefreshToken : EndpointWithoutRequest public override void Configure() { Post("/identity/refresh-token"); + AuthSchemes(IdentityAuthenticationSchemes.RefreshToken); } /// @@ -40,4 +42,4 @@ internal class RefreshToken : EndpointWithoutRequest return new LoginResponse(true, tokens.AccessToken, tokens.RefreshToken); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Identity/Features/DefaultAuthenticationFeature.cs b/src/modules/Elsa.Identity/Features/DefaultAuthenticationFeature.cs index 2bf580920..f967ecaaf 100644 --- a/src/modules/Elsa.Identity/Features/DefaultAuthenticationFeature.cs +++ b/src/modules/Elsa.Identity/Features/DefaultAuthenticationFeature.cs @@ -3,6 +3,7 @@ using Elsa.Extensions; using Elsa.Features.Abstractions; using Elsa.Features.Attributes; using Elsa.Features.Services; +using Elsa.Identity.Constants; using Elsa.Identity.Providers; using Elsa.Requirements; using Microsoft.AspNetCore.Authentication; @@ -76,7 +77,8 @@ public class DefaultAuthenticationFeature : FeatureBase : JwtBearerDefaults.AuthenticationScheme; }; }) - .AddJwtBearer(); + .AddJwtBearer() + .AddJwtBearer(IdentityAuthenticationSchemes.RefreshToken); _configureApiKeyAuthorization(authBuilder); @@ -86,4 +88,4 @@ public class DefaultAuthenticationFeature : FeatureBase Services.AddScoped(sp => (IApiKeyProvider)sp.GetRequiredService(ApiKeyProviderType)); Services.AddAuthorization(ConfigureAuthorizationOptions); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Identity/OptionConfigurators/ConfigureJwtBearerOptions.cs b/src/modules/Elsa.Identity/OptionConfigurators/ConfigureJwtBearerOptions.cs index 6120abf70..9c800000e 100644 --- a/src/modules/Elsa.Identity/OptionConfigurators/ConfigureJwtBearerOptions.cs +++ b/src/modules/Elsa.Identity/OptionConfigurators/ConfigureJwtBearerOptions.cs @@ -1,3 +1,4 @@ +using Elsa.Identity.Constants; using Elsa.Identity.Options; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Options; @@ -26,6 +27,16 @@ public class ConfigureJwtBearerOptions : IConfigureNamedOptions public void Configure(string? name, JwtBearerOptions options) { - _identityTokenOptions.Value.ConfigureJwtBearerOptions(options); + var requiredTokenUse = name switch + { + IdentityAuthenticationSchemes.AccessToken => TokenUse.Access, + IdentityAuthenticationSchemes.RefreshToken => TokenUse.Refresh, + _ => null + }; + + if (requiredTokenUse == null) + return; + + _identityTokenOptions.Value.ConfigureJwtBearerOptions(options, requiredTokenUse); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Identity/Options/IdentityTokenOptions.cs b/src/modules/Elsa.Identity/Options/IdentityTokenOptions.cs index 78069cff3..85166f4cd 100644 --- a/src/modules/Elsa.Identity/Options/IdentityTokenOptions.cs +++ b/src/modules/Elsa.Identity/Options/IdentityTokenOptions.cs @@ -52,7 +52,14 @@ public class IdentityTokenOptions /// Configures the with the values from this instance. /// /// The options to configure. - public void ConfigureJwtBearerOptions(JwtBearerOptions options) + public void ConfigureJwtBearerOptions(JwtBearerOptions options) => ConfigureJwtBearerOptions(options, TokenUse.Access); + + /// + /// Configures the with the values from this instance. + /// + /// The options to configure. + /// The required token usage claim value. + public void ConfigureJwtBearerOptions(JwtBearerOptions options, string requiredTokenUse) { options.TokenValidationParameters = new TokenValidationParameters { @@ -63,10 +70,24 @@ public class IdentityTokenOptions LifetimeValidator = ValidateLifetime, NameClaimType = JwtRegisteredClaimNames.Name }; + options.Events ??= new JwtBearerEvents(); + var previousOnTokenValidated = options.Events.OnTokenValidated; + options.Events.OnTokenValidated = async context => + { + await previousOnTokenValidated(context); + + if (context.Result?.Failure != null || context.Result?.None == true) + return; + + var tokenUse = context.Principal?.FindFirst(TokenUse.ClaimType)?.Value; + + if (!string.Equals(tokenUse, requiredTokenUse, StringComparison.Ordinal)) + context.Fail($"The token is not a valid {requiredTokenUse} token."); + }; } private static bool ValidateLifetime(DateTime? notBefore, DateTime? expires, SecurityToken securityToken, TokenValidationParameters validationParameters) { return expires != null && expires > DateTime.UtcNow; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Identity/Services/DefaultAccessTokenIssuer.cs b/src/modules/Elsa.Identity/Services/DefaultAccessTokenIssuer.cs index 0cef5df6f..7731f1325 100644 --- a/src/modules/Elsa.Identity/Services/DefaultAccessTokenIssuer.cs +++ b/src/modules/Elsa.Identity/Services/DefaultAccessTokenIssuer.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Elsa.Common; using Elsa.Extensions; +using Elsa.Identity.Constants; using Elsa.Identity.Contracts; using Elsa.Identity.Entities; using Elsa.Identity.Models; @@ -48,20 +49,21 @@ public class DefaultAccessTokenIssuer(IRoleProvider roleProvider, ISystemClock s var now = systemClock.UtcNow; var accessTokenExpiresAt = now.Add(accessTokenLifetime); var refreshTokenExpiresAt = now.Add(refreshTokenLifetime); - var accessToken = JwtBearer.CreateToken(options => ConfigureTokenOptions(options, accessTokenExpiresAt.UtcDateTime)); - var refreshToken = JwtBearer.CreateToken(options => ConfigureTokenOptions(options, refreshTokenExpiresAt.UtcDateTime)); + var accessToken = JwtBearer.CreateToken(options => ConfigureTokenOptions(options, accessTokenExpiresAt.UtcDateTime, TokenUse.Access)); + var refreshToken = JwtBearer.CreateToken(options => ConfigureTokenOptions(options, refreshTokenExpiresAt.UtcDateTime, TokenUse.Refresh)); return new IssuedTokens(accessToken, refreshToken); - void ConfigureTokenOptions(JwtCreationOptions options, DateTime expireAt) + void ConfigureTokenOptions(JwtCreationOptions options, DateTime expireAt, string tokenUse) { options.SigningKey = signingKey; options.ExpireAt = expireAt; options.Issuer = issuer; options.Audience = audience; options.User.Claims.AddRange(claims); + options.User.Claims.Add(new Claim(TokenUse.ClaimType, tokenUse)); options.User.Permissions.AddRange(permissions); options.User.Roles.AddRange(roleNames); } } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs b/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs index 97054b7b9..f7f198c10 100644 --- a/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs +++ b/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs @@ -1,6 +1,7 @@ using AspNetCore.Authentication.ApiKey; using CShells.Features; using Elsa.Extensions; +using Elsa.Identity.Constants; using Elsa.Identity.Providers; using Elsa.Requirements; using JetBrains.Annotations; @@ -43,7 +44,8 @@ public class DefaultAuthenticationFeature : IShellFeature : JwtBearerDefaults.AuthenticationScheme; }; }) - .AddJwtBearer(); + .AddJwtBearer() + .AddJwtBearer(IdentityAuthenticationSchemes.RefreshToken); // Configure API key authorization based on provider type if (ApiKeyProviderType == typeof(AdminApiKeyProvider)) diff --git a/test/unit/Elsa.Identity.UnitTests/IdentityTokenTestConstants.cs b/test/unit/Elsa.Identity.UnitTests/IdentityTokenTestConstants.cs new file mode 100644 index 000000000..5abe3bc0c --- /dev/null +++ b/test/unit/Elsa.Identity.UnitTests/IdentityTokenTestConstants.cs @@ -0,0 +1,6 @@ +namespace Elsa.Identity.UnitTests; + +internal static class IdentityTokenTestConstants +{ + public const string SigningKey = "test-signing-key-with-at-least-32-chars"; +} diff --git a/test/unit/Elsa.Identity.UnitTests/Options/ConfigureJwtBearerOptionsTests.cs b/test/unit/Elsa.Identity.UnitTests/Options/ConfigureJwtBearerOptionsTests.cs new file mode 100644 index 000000000..65e11bb13 --- /dev/null +++ b/test/unit/Elsa.Identity.UnitTests/Options/ConfigureJwtBearerOptionsTests.cs @@ -0,0 +1,56 @@ +using Elsa.Extensions; +using Elsa.Identity.Constants; +using Elsa.Identity.Options; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Elsa.Identity.UnitTests.Options; + +public class ConfigureJwtBearerOptionsTests +{ + [Fact] + public async Task Configure_UsesAccessTokenValidationForDefaultBearerScheme() + { + var options = Configure(JwtBearerDefaults.AuthenticationScheme); + var result = await IdentityTokenOptionsTokenUseTests.ValidateTokenUseAsync(options, actualTokenUse: TokenUse.Refresh); + + Assert.NotNull(result.Failure); + } + + [Fact] + public async Task Configure_UsesRefreshTokenValidationForRefreshTokenScheme() + { + var options = Configure(IdentityAuthenticationSchemes.RefreshToken); + var result = await IdentityTokenOptionsTokenUseTests.ValidateTokenUseAsync(options, actualTokenUse: TokenUse.Access); + + Assert.NotNull(result.Failure); + } + + [Fact] + public void Configure_SkipsNonElsaManagedSchemes() + { + var configureOptions = CreateConfigureOptions(); + var options = new JwtBearerOptions(); + + configureOptions.Configure("ThirdPartyBearer", options); + + Assert.Null(options.TokenValidationParameters.ValidIssuer); + } + + private static JwtBearerOptions Configure(string scheme) + { + var configureOptions = CreateConfigureOptions(); + var options = new JwtBearerOptions(); + + configureOptions.Configure(scheme, options); + + return options; + } + + private static ConfigureJwtBearerOptions CreateConfigureOptions() + { + return new ConfigureJwtBearerOptions(Microsoft.Extensions.Options.Options.Create(new IdentityTokenOptions + { + SigningKey = IdentityTokenTestConstants.SigningKey + })); + } +} diff --git a/test/unit/Elsa.Identity.UnitTests/Options/IdentityTokenOptionsTokenUseTests.cs b/test/unit/Elsa.Identity.UnitTests/Options/IdentityTokenOptionsTokenUseTests.cs new file mode 100644 index 000000000..e303a6554 --- /dev/null +++ b/test/unit/Elsa.Identity.UnitTests/Options/IdentityTokenOptionsTokenUseTests.cs @@ -0,0 +1,166 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Elsa.Identity.Constants; +using Elsa.Identity.Options; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.IdentityModel.Tokens; + +namespace Elsa.Identity.UnitTests.Options; + +public class IdentityTokenOptionsTokenUseTests +{ + [Fact] + public async Task AccessTokenSchemeRejectsRefreshToken() + { + var result = await ValidateTokenUseAsync(requiredTokenUse: TokenUse.Access, actualTokenUse: TokenUse.Refresh); + + Assert.NotNull(result.Failure); + } + + [Fact] + public async Task RefreshTokenSchemeRejectsAccessToken() + { + var result = await ValidateTokenUseAsync(requiredTokenUse: TokenUse.Refresh, actualTokenUse: TokenUse.Access); + + Assert.NotNull(result.Failure); + } + + [Fact] + public async Task AccessTokenSchemeAcceptsAccessToken() + { + var result = await ValidateTokenUseAsync(requiredTokenUse: TokenUse.Access, actualTokenUse: TokenUse.Access); + + Assert.Null(result.Failure); + } + + [Fact] + public async Task RefreshTokenSchemeAcceptsRefreshToken() + { + var result = await ValidateTokenUseAsync(requiredTokenUse: TokenUse.Refresh, actualTokenUse: TokenUse.Refresh); + + Assert.Null(result.Failure); + } + + [Fact] + public async Task AccessTokenSchemeRejectsTokenWithMissingTokenUseClaim() + { + var result = await ValidateTokenUseAsync(requiredTokenUse: TokenUse.Access, actualTokenUse: null); + + Assert.NotNull(result.Failure); + } + + [Fact] + public async Task OnTokenValidatedRunsPreviousHandlerBeforeTokenUseEnforcement() + { + var previousHandlerCalled = false; + var identityOptions = new IdentityTokenOptions + { + SigningKey = IdentityTokenTestConstants.SigningKey + }; + var jwtBearerOptions = new JwtBearerOptions + { + Events = new JwtBearerEvents + { + OnTokenValidated = context => + { + previousHandlerCalled = true; + context.Success(); + return Task.CompletedTask; + } + } + }; + identityOptions.ConfigureJwtBearerOptions(jwtBearerOptions, TokenUse.Access); + + var result = await ValidateTokenUseAsync(jwtBearerOptions, actualTokenUse: TokenUse.Refresh); + + Assert.True(previousHandlerCalled); + Assert.NotNull(result.Failure); + } + + [Fact] + public async Task OnTokenValidatedPreservesPreviousNoResult() + { + var identityOptions = new IdentityTokenOptions + { + SigningKey = IdentityTokenTestConstants.SigningKey + }; + var jwtBearerOptions = new JwtBearerOptions + { + Events = new JwtBearerEvents + { + OnTokenValidated = context => + { + context.NoResult(); + return Task.CompletedTask; + } + } + }; + identityOptions.ConfigureJwtBearerOptions(jwtBearerOptions, TokenUse.Access); + + var result = await ValidateTokenUseAsync(jwtBearerOptions, actualTokenUse: TokenUse.Access); + + Assert.True(result.None); + } + + private static async Task ValidateTokenUseAsync(string requiredTokenUse, string? actualTokenUse) + { + var identityOptions = new IdentityTokenOptions + { + SigningKey = IdentityTokenTestConstants.SigningKey + }; + var jwtBearerOptions = new JwtBearerOptions(); + identityOptions.ConfigureJwtBearerOptions(jwtBearerOptions, requiredTokenUse); + return await ValidateTokenUseAsync(jwtBearerOptions, actualTokenUse); + } + + public static async Task ValidateTokenUseAsync(JwtBearerOptions jwtBearerOptions, string? actualTokenUse) + { + var identityOptions = new IdentityTokenOptions + { + SigningKey = IdentityTokenTestConstants.SigningKey + }; + var principal = ValidateToken(CreateToken(identityOptions, actualTokenUse), jwtBearerOptions.TokenValidationParameters, out var securityToken); + var context = new TokenValidatedContext( + new DefaultHttpContext(), + new AuthenticationScheme(JwtBearerDefaults.AuthenticationScheme, null, typeof(JwtBearerHandler)), + jwtBearerOptions) + { + Principal = principal, + SecurityToken = securityToken + }; + + await jwtBearerOptions.Events.TokenValidated(context); + + return context.Result ?? AuthenticateResult.Success(new AuthenticationTicket(principal, JwtBearerDefaults.AuthenticationScheme)); + } + + private static string CreateToken(IdentityTokenOptions options, string? tokenUse) + { + var now = DateTime.UtcNow; + var credentials = new SigningCredentials(options.CreateSecurityKey(), SecurityAlgorithms.HmacSha256); + var claims = new List + { + new(JwtRegisteredClaimNames.Name, "alice") + }; + + if (tokenUse != null) + claims.Add(new Claim(TokenUse.ClaimType, tokenUse)); + + var token = new JwtSecurityToken( + issuer: options.Issuer, + audience: options.Audience, + claims: claims, + notBefore: now, + expires: now.AddMinutes(5), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private static ClaimsPrincipal ValidateToken(string token, TokenValidationParameters tokenValidationParameters, out SecurityToken securityToken) + { + return new JwtSecurityTokenHandler().ValidateToken(token, tokenValidationParameters, out securityToken); + } +}