Fix local external authentication refresh

This commit is contained in:
Sipke Schoorstra 2026-07-26 01:49:36 +02:00
parent e8aa353d57
commit 7e82a55a9f
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
9 changed files with 236 additions and 16 deletions

View file

@ -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<ExternalAuthenticationOptions> 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<ExternalTokenResponse?> 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<BrokerLogoutResult> LogoutAsync(BrokerLogoutRequest request, string externalSessionId, CancellationToken cancellationToken = default)
{
AuthenticationClient client;

View file

@ -0,0 +1,14 @@
using Elsa.Identity.Models;
namespace Elsa.Identity.Contracts;
/// <summary>
/// Validates and exchanges Elsa identity refresh tokens.
/// </summary>
public interface IIdentityRefreshTokenService
{
/// <summary>
/// Validates the specified refresh token and issues a new token pair using the user's current roles and permissions.
/// </summary>
ValueTask<IssuedTokens?> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default);
}

View file

@ -215,6 +215,7 @@ public class IdentityFeature : FeatureBase
.AddScoped<ISecretHasher, DefaultSecretHasher>()
.AddScoped<IElsaTokenService, DefaultElsaTokenService>()
.AddScoped<IAccessTokenIssuer>(sp => ActivatorUtilities.CreateInstance<DefaultAccessTokenIssuer>(sp))
.AddScoped<IIdentityRefreshTokenService, DefaultIdentityRefreshTokenService>()
.AddScoped<IUserCredentialsValidator, DefaultUserCredentialsValidator>()
.AddScoped<IApplicationCredentialsValidator, DefaultApplicationCredentialsValidator>()
.AddScoped<IApiKeyGenerator>(sp => sp.GetRequiredService<DefaultApiKeyGeneratorAndParser>())

View file

@ -61,15 +61,7 @@ public class IdentityTokenOptions
/// <param name="requiredTokenUse">The required token usage claim value.</param>
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
};
}
/// <summary>
/// Creates token validation parameters for Elsa identity tokens.
/// </summary>
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;

View file

@ -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;
/// <summary>
/// Validates and exchanges Elsa identity refresh tokens.
/// </summary>
public sealed class DefaultIdentityRefreshTokenService(
IUserProvider userProvider,
IAccessTokenIssuer accessTokenIssuer,
ITenantAccessor tenantAccessor,
IOptions<IdentityTokenOptions> identityTokenOptions) : IIdentityRefreshTokenService
{
/// <inheritdoc />
public async ValueTask<IssuedTokens?> 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);
}
}

View file

@ -80,6 +80,7 @@ public class IdentityFeature : IFastEndpointsShellFeature
.AddScoped<ISecretHasher, DefaultSecretHasher>()
.AddScoped<IElsaTokenService, DefaultElsaTokenService>()
.AddScoped<IAccessTokenIssuer>(sp => ActivatorUtilities.CreateInstance<DefaultAccessTokenIssuer>(sp))
.AddScoped<IIdentityRefreshTokenService, DefaultIdentityRefreshTokenService>()
.AddScoped<IUserCredentialsValidator, DefaultUserCredentialsValidator>()
.AddScoped<IApplicationCredentialsValidator, DefaultApplicationCredentialsValidator>()
.AddScoped<IApiKeyGenerator>(sp => sp.GetRequiredService<DefaultApiKeyGeneratorAndParser>())

View file

@ -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<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
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<IExternalAuthenticationTokenIssuer>();
externalTokenIssuer.RefreshAsync("studio", Arg.Any<SensitiveString>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromException<ExternalTokenResponse>(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<string>(), Arg.Any<SensitiveString>(), Arg.Any<CancellationToken>());
}
[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<IExternalAuthenticationTokenIssuer>();
externalTokenIssuer.RefreshAsync("studio", Arg.Any<SensitiveString>(), Arg.Any<CancellationToken>()).Returns(expected);
var identityRefreshTokenService = Substitute.For<IIdentityRefreshTokenService>();
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<string>(), Arg.Any<CancellationToken>());
}
[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<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>(), credentialsValidator ?? Substitute.For<IUserCredentialsValidator>(), userProvider ?? Substitute.For<IUserProvider>(), roleProvider ?? Substitute.For<IRoleProvider>(), tokenService ?? Substitute.For<IElsaTokenService>(), tenantAccessor ?? new DefaultTenantAccessor(), 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), tokenIssuer ?? Substitute.For<IExternalAuthenticationTokenIssuer>(), credentialsValidator ?? Substitute.For<IUserCredentialsValidator>(), userProvider ?? Substitute.For<IUserProvider>(), roleProvider ?? Substitute.For<IRoleProvider>(), tokenService ?? Substitute.For<IElsaTokenService>(), identityRefreshTokenService ?? Substitute.For<IIdentityRefreshTokenService>(), 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
{

View file

@ -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<ExternalIdentityResolution> ResolveAsync(ExternalIdentityResolutionContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ValueTask<PermissionGrantResult> ResolveAsync(PermissionGrantResolutionContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
@ -156,6 +158,7 @@ public class ExternalAuthenticationBenchmarks
public ValueTask<IEnumerable<Role>> FindManyAsync(RoleFilter filter, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ValueTask<IssuedAccessToken> IssueAccessTokenAsync(TokenIssuanceContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ValueTask<IssuedAccessToken> IssueRefreshTokenAsync(TokenIssuanceContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ValueTask<IssuedTokens?> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
private sealed class FixedClock : ISystemClock

View file

@ -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<IAccessTokenIssuer>();
var service = new DefaultIdentityRefreshTokenService(
Substitute.For<IUserProvider>(),
accessTokenIssuer,
new DefaultTenantAccessor(),
options);
Assert.Null(await service.RefreshAsync(accessToken.Token));
Assert.Null(await service.RefreshAsync(tamperedRefreshToken));
await accessTokenIssuer.DidNotReceive().IssueTokensAsync(Arg.Any<User>(), Arg.Any<CancellationToken>());
}
private sealed class CurrentClock : ISystemClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
}