Fix local external authentication refresh
This commit is contained in:
parent
e8aa353d57
commit
7e82a55a9f
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue