Distinguish refresh tokens from API access tokens (#7509)

* Separate access and refresh token use

* Address Greptile identity token feedback
This commit is contained in:
Sipke Schoorstra 2026-05-20 14:04:28 +02:00 committed by GitHub
parent 435c25793b
commit 1da8709e2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 326 additions and 13 deletions

View file

@ -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))
* 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))

View file

@ -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).

View file

@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
namespace Elsa.Identity.Constants;
/// <summary>
/// Authentication scheme names used by Elsa identity.
/// </summary>
public static class IdentityAuthenticationSchemes
{
/// <summary>
/// The default JWT bearer scheme for API access tokens.
/// </summary>
public const string AccessToken = JwtBearerDefaults.AuthenticationScheme;
/// <summary>
/// JWT bearer scheme used by the token refresh endpoint.
/// </summary>
public const string RefreshToken = "RefreshToken";
}

View file

@ -0,0 +1,22 @@
namespace Elsa.Identity.Constants;
/// <summary>
/// Constants for distinguishing identity token usage.
/// </summary>
public static class TokenUse
{
/// <summary>
/// The claim type that stores the intended token usage.
/// </summary>
public const string ClaimType = "token_use";
/// <summary>
/// Token use value for API bearer access tokens.
/// </summary>
public const string Access = "access";
/// <summary>
/// Token use value for refresh tokens.
/// </summary>
public const string Refresh = "refresh";
}

View file

@ -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<LoginResponse>
public override void Configure()
{
Post("/identity/refresh-token");
AuthSchemes(IdentityAuthenticationSchemes.RefreshToken);
}
/// <inheritdoc />
@ -40,4 +42,4 @@ internal class RefreshToken : EndpointWithoutRequest<LoginResponse>
return new LoginResponse(true, tokens.AccessToken, tokens.RefreshToken);
}
}
}

View file

@ -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<IApiKeyProvider>(sp => (IApiKeyProvider)sp.GetRequiredService(ApiKeyProviderType));
Services.AddAuthorization(ConfigureAuthorizationOptions);
}
}
}

View file

@ -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<JwtBearerOptions
/// <inheritdoc />
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);
}
}
}

View file

@ -52,7 +52,14 @@ public class IdentityTokenOptions
/// Configures the <see cref="JwtBearerOptions"/> with the values from this instance.
/// </summary>
/// <param name="options">The options to configure.</param>
public void ConfigureJwtBearerOptions(JwtBearerOptions options)
public void ConfigureJwtBearerOptions(JwtBearerOptions options) => ConfigureJwtBearerOptions(options, TokenUse.Access);
/// <summary>
/// Configures the <see cref="JwtBearerOptions"/> with the values from this instance.
/// </summary>
/// <param name="options">The options to configure.</param>
/// <param name="requiredTokenUse">The required token usage claim value.</param>
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;
}
}
}

View file

@ -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);
}
}
}
}

View file

@ -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))

View file

@ -0,0 +1,6 @@
namespace Elsa.Identity.UnitTests;
internal static class IdentityTokenTestConstants
{
public const string SigningKey = "test-signing-key-with-at-least-32-chars";
}

View file

@ -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
}));
}
}

View file

@ -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<AuthenticateResult> 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<AuthenticateResult> 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<Claim>
{
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);
}
}