diff --git a/Client.UnitTests/Impl/Builder/CamundaCloudTokenProviderTest.cs b/Client.UnitTests/Impl/Builder/CamundaCloudTokenProviderTest.cs index 82b5752f..c4050591 100644 --- a/Client.UnitTests/Impl/Builder/CamundaCloudTokenProviderTest.cs +++ b/Client.UnitTests/Impl/Builder/CamundaCloudTokenProviderTest.cs @@ -123,4 +123,26 @@ public async Task ShouldNotThrowObjectDisposedExceptionWhenTokenExpires() // then Assert.AreEqual(2, MessageHandlerStub.RequestCount); } + + [Test] + public void ShouldThrowArgumentExceptionForInvalidAccessTokenDueDateTolerance() + { + // given + var builder = new CamundaCloudTokenProviderBuilder(); + + // then + Assert.Throws(() => builder + .UseAccessTokenDueDateTolerance(TimeSpan.FromSeconds(-2))); + } + + [Test] + public void ShouldNotThrowArgumentExceptionForValidAccessTokenDueDateTolerance() + { + // given + var builder = new CamundaCloudTokenProviderBuilder(); + + // then + Assert.DoesNotThrow(() => builder + .UseAccessTokenDueDateTolerance(TimeSpan.FromSeconds(2))); + } } \ No newline at end of file diff --git a/Client.UnitTests/Impl/Misc/PersistedAccessTokenCacheTest.cs b/Client.UnitTests/Impl/Misc/PersistedAccessTokenCacheTest.cs index c51fc0ff..964fe8ad 100644 --- a/Client.UnitTests/Impl/Misc/PersistedAccessTokenCacheTest.cs +++ b/Client.UnitTests/Impl/Misc/PersistedAccessTokenCacheTest.cs @@ -112,6 +112,48 @@ public async Task ShouldResolveNewTokenAfterExpiry() Assert.AreEqual(2, fetchCounter); } + [Test] + public async Task ShouldResolveNewTokenDuringDueDateTolerancePeriod() + { + // given + var expectedDueDateTolerance = TimeSpan.FromSeconds(30); + var fetchCounter = 0; + var accessTokenCache = new PersistedAccessTokenCache( + Path.Combine(tempPath, TestContext.CurrentContext.Test.Name), + () => Task.FromResult(new AccessToken("token-" + fetchCounter++, + DateTimeOffset.UtcNow.AddSeconds(15).ToUnixTimeMilliseconds())), + dueDateTolerance: expectedDueDateTolerance); + + // when + _ = await accessTokenCache.Get("test"); + var token = await accessTokenCache.Get("test"); + + // then + Assert.AreEqual("token-1", token); + Assert.AreEqual(2, fetchCounter); + } + + [Test] + public async Task ShouldNotResolveNewTokenBeforeDueDateTolerancePeriod() + { + // given + var expectedDueDateTolerance = TimeSpan.FromSeconds(30); + var fetchCounter = 0; + var accessTokenCache = new PersistedAccessTokenCache( + Path.Combine(tempPath, TestContext.CurrentContext.Test.Name), + () => Task.FromResult(new AccessToken("token-" + fetchCounter++, + DateTimeOffset.UtcNow.AddSeconds(45).ToUnixTimeMilliseconds())), + dueDateTolerance: expectedDueDateTolerance); + + // when + _ = await accessTokenCache.Get("test"); + var token = await accessTokenCache.Get("test"); + + // then + Assert.AreEqual("token-0", token); + Assert.AreEqual(1, fetchCounter); + } + [Test] public async Task ShouldReflectTokenOnDiskAfterExpiry() { @@ -163,6 +205,26 @@ public async Task ShouldPersistTokenToDisk() Assert.That(content, Does.Contain(audience)); } + [Test] + public async Task ShouldNotPersistTokenToDiskWhenDisabled() + { + // given + var audience = "test"; + var fetchCounter = 0; + var path = Path.Combine(tempPath, TestContext.CurrentContext.Test.Name); + var accessTokenCache = new PersistedAccessTokenCache(path, + () => Task.FromResult(new AccessToken("token-" + fetchCounter++, + DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeMilliseconds())), + persistedCredentialsCacheEnabled: false); + + // when + _ = await accessTokenCache.Get(audience); + + // then + var persistedCacheDirectoryExists = Directory.Exists(path); + Assert.IsFalse(persistedCacheDirectoryExists); + } + [Test] public async Task ShouldPersistMultipleTokenToDisk() { diff --git a/Client/Api/Builder/ICamundaCloudClientBuilder.cs b/Client/Api/Builder/ICamundaCloudClientBuilder.cs index ea9c1a74..32bb7b3e 100644 --- a/Client/Api/Builder/ICamundaCloudClientBuilder.cs +++ b/Client/Api/Builder/ICamundaCloudClientBuilder.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using System; namespace Zeebe.Client.Api.Builder; @@ -82,6 +83,23 @@ public interface ICamundaCloudClientBuilderFinalStep /// the fluent ICamundaCloudClientBuilderFinalStep. ICamundaCloudClientBuilderFinalStep UsePersistedStoragePath(string path); + /// + /// Disables credentials cache persistence to file system. + /// + /// the fluent ICamundaCloudClientBuilderFinalStep. + ICamundaCloudClientBuilderFinalStep DisableCredentialsCachePersistence(); + + /// + /// Use AccessToken due date tolerance to refresh token before due date. + /// To compensate network latency and prevent server side rejection when the + /// AccessToken due date is too close to UTC now, this option allows you to + /// add a tolerance period to refresh the token slightly before due date. + /// e.g. TimeSpan.FromSeconds(2) will refresh the token 2 seconds before due date. + /// + /// The tolerance to apply to token due date + /// The final step in building a CamundaCloudTokenProvider. + ICamundaCloudClientBuilderFinalStep UseAccessTokenDueDateTolerance(TimeSpan tolerance); + /// /// The IZeebeClient, which is setup entirely to talk with the defined Camunda Cloud cluster. /// diff --git a/Client/Api/Builder/ICamundaCloudTokenProviderBuilder.cs b/Client/Api/Builder/ICamundaCloudTokenProviderBuilder.cs index af79d218..7584e5ae 100644 --- a/Client/Api/Builder/ICamundaCloudTokenProviderBuilder.cs +++ b/Client/Api/Builder/ICamundaCloudTokenProviderBuilder.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using System; using Zeebe.Client.Impl.Builder; namespace Zeebe.Client.Api.Builder; @@ -66,6 +67,23 @@ public interface ICamundaCloudTokenProviderBuilderFinalStep /// The final step in building a CamundaCloudTokenProvider. ICamundaCloudTokenProviderBuilderFinalStep UsePath(string path); + /// + /// Disables credentials cache persistence to file system. + /// + /// The final step in building a CamundaCloudTokenProvider. + ICamundaCloudTokenProviderBuilderFinalStep DisableCredentialsCachePersistence(); + + /// + /// Use AccessToken due date tolerance to refresh token before due date. + /// To compensate network latency and prevent server side rejection when the + /// AccessToken due date is too close to UTC now, this option allows you to + /// add a tolerance period to refresh the token slightly before due date. + /// e.g. TimeSpan.FromSeconds(2) will refresh the token 2 seconds before due date. + /// + /// The tolerance to apply to token due date + /// The final step in building a CamundaCloudTokenProvider. + ICamundaCloudTokenProviderBuilderFinalStep UseAccessTokenDueDateTolerance(TimeSpan tolerance); + /// /// Builds the CamundaCloudTokenProvider, which can be used by the ZeebeClient to /// communicate with the Camunda Cloud. diff --git a/Client/Impl/Builder/CamundaCloudClientBuilder.cs b/Client/Impl/Builder/CamundaCloudClientBuilder.cs index a48cf1c4..077c2c31 100644 --- a/Client/Impl/Builder/CamundaCloudClientBuilder.cs +++ b/Client/Impl/Builder/CamundaCloudClientBuilder.cs @@ -67,6 +67,20 @@ public ICamundaCloudClientBuilderFinalStep UsePersistedStoragePath(string path) return this; } + /// + public ICamundaCloudClientBuilderFinalStep DisableCredentialsCachePersistence() + { + _ = camundaCloudTokenProviderBuilder.DisableCredentialsCachePersistence(); + return this; + } + + /// + public ICamundaCloudClientBuilderFinalStep UseAccessTokenDueDateTolerance(TimeSpan tolerance) + { + _ = camundaCloudTokenProviderBuilder.UseAccessTokenDueDateTolerance(tolerance); + return this; + } + public IZeebeClient Build() { return ZeebeClient.Builder() diff --git a/Client/Impl/Builder/CamundaCloudTokenProvider.cs b/Client/Impl/Builder/CamundaCloudTokenProvider.cs index 392e6b28..70bbc233 100644 --- a/Client/Impl/Builder/CamundaCloudTokenProvider.cs +++ b/Client/Impl/Builder/CamundaCloudTokenProvider.cs @@ -31,10 +31,13 @@ internal CamundaCloudTokenProvider( string clientSecret, string audience, string path = null, - ILoggerFactory loggerFactory = null) + ILoggerFactory loggerFactory = null, + bool persistedCredentialsCacheEnabled = true, + TimeSpan accessTokenDueDateTolerance = default) { persistedAccessTokenCache = new PersistedAccessTokenCache(path ?? ZeebeRootPath, FetchAccessToken, - loggerFactory?.CreateLogger()); + loggerFactory?.CreateLogger(), + persistedCredentialsCacheEnabled, accessTokenDueDateTolerance); logger = loggerFactory?.CreateLogger(); this.authServer = authServer; this.clientId = clientId; @@ -53,6 +56,7 @@ public void Dispose() { httpClient.Dispose(); httpMessageHandler.Dispose(); + this.persistedAccessTokenCache.Dispose(); } public static CamundaCloudTokenProviderBuilder Builder() diff --git a/Client/Impl/Builder/CamundaCloudTokenProviderBuilder.cs b/Client/Impl/Builder/CamundaCloudTokenProviderBuilder.cs index 54e8f9a0..8157113b 100644 --- a/Client/Impl/Builder/CamundaCloudTokenProviderBuilder.cs +++ b/Client/Impl/Builder/CamundaCloudTokenProviderBuilder.cs @@ -19,6 +19,8 @@ public class CamundaCloudTokenProviderBuilder : private string clientSecret; private ILoggerFactory loggerFactory; private string path; + private bool persistedCredentialsCacheEnabled = true; + private TimeSpan accessTokenDueDateTolerance = TimeSpan.Zero; /// public ICamundaCloudTokenProviderBuilder UseLoggerFactory(ILoggerFactory loggerFactory) @@ -50,6 +52,25 @@ public ICamundaCloudTokenProviderBuilderFinalStep UsePath(string path) return this; } + /// + public ICamundaCloudTokenProviderBuilderFinalStep DisableCredentialsCachePersistence() + { + this.persistedCredentialsCacheEnabled = false; + return this; + } + + /// + public ICamundaCloudTokenProviderBuilderFinalStep UseAccessTokenDueDateTolerance(TimeSpan tolerance) + { + if (tolerance < TimeSpan.Zero) + { + throw new ArgumentException("AccessToken due date tolerance must be a positive time span", nameof(tolerance)); + } + + this.accessTokenDueDateTolerance = tolerance; + return this; + } + /// public CamundaCloudTokenProvider Build() { @@ -59,7 +80,9 @@ public CamundaCloudTokenProvider Build() clientSecret, audience, path, - loggerFactory); + loggerFactory, + persistedCredentialsCacheEnabled, + accessTokenDueDateTolerance); } /// diff --git a/Client/Impl/Misc/PersistedAccessTokenCache.cs b/Client/Impl/Misc/PersistedAccessTokenCache.cs index d13077be..9b143efa 100644 --- a/Client/Impl/Misc/PersistedAccessTokenCache.cs +++ b/Client/Impl/Misc/PersistedAccessTokenCache.cs @@ -1,13 +1,14 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace Zeebe.Client.Impl.Misc; -public class PersistedAccessTokenCache : IAccessTokenCache +public class PersistedAccessTokenCache : IAccessTokenCache, IDisposable { private readonly IAccessTokenCache.AccessTokenResolverAsync accessTokenFetcherAsync; @@ -15,83 +16,165 @@ public class PersistedAccessTokenCache : IAccessTokenCache // Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".zeebe"); private readonly ILogger logger; - private readonly string tokenStoragePath; + private readonly bool persistedCredentialsCacheEnabled; + private readonly SemaphoreSlim mutex = new SemaphoreSlim(1, 1); + private readonly TimeSpan dueDateTolerance; + private bool disposedValue; - public PersistedAccessTokenCache(string path, IAccessTokenCache.AccessTokenResolverAsync fetcherAsync, - ILogger logger = null) + public PersistedAccessTokenCache( + string path, + IAccessTokenCache.AccessTokenResolverAsync fetcherAsync, + ILogger logger = null, + bool persistedCredentialsCacheEnabled = true, + TimeSpan dueDateTolerance = default) { - var directoryInfo = Directory.CreateDirectory(path); - if (!directoryInfo.Exists) + if (persistedCredentialsCacheEnabled) { - throw new IOException("Expected to create '~/.zeebe/' directory, but failed to do so."); + var directoryInfo = Directory.CreateDirectory(path); + if (!directoryInfo.Exists) + { + throw new IOException("Expected to create '~/.zeebe/' directory, but failed to do so."); + } } - tokenStoragePath = path; + this.tokenStoragePath = path; + this.persistedCredentialsCacheEnabled = persistedCredentialsCacheEnabled; this.logger = logger; - accessTokenFetcherAsync = fetcherAsync; - CachedCredentials = new Dictionary(); + this.accessTokenFetcherAsync = fetcherAsync; + this.CachedCredentials = new Dictionary(); + this.dueDateTolerance = dueDateTolerance; } private static string ZeebeTokenFileName => "credentials"; + private Dictionary CachedCredentials { get; set; } - private string TokenFileName => Path.Combine(tokenStoragePath, ZeebeTokenFileName); + + private string TokenFileName => Path.Combine(this.tokenStoragePath, ZeebeTokenFileName); public async Task Get(string audience) + { + // shortcut sync lock if in-memory token is valid (read-only) + if (this.CachedCredentials.TryGetValue(audience, out var currentAccessToken) + && this.IsValid(currentAccessToken)) + { + this.logger?.LogTrace("Use in memory access token"); + return currentAccessToken.Token; + } + + // Secure concurrent access to token cache + // and prevent race condition when accessing the file system. + await this.mutex.WaitAsync().ConfigureAwait(false); + try + { + return await this.GetOrRefreshAccessToken(audience).ConfigureAwait(false); + } + finally + { + this.mutex.Release(); + } + } + + private async Task GetOrRefreshAccessToken(string audience) { // check in memory - if (CachedCredentials.TryGetValue(audience, out var currentAccessToken)) + if (this.CachedCredentials.TryGetValue(audience, out var currentAccessToken)) { - logger?.LogTrace("Use in memory access token"); - return await GetValidToken(audience, currentAccessToken); + this.logger?.LogTrace("Use in memory access token"); + return await this.GetValidToken(audience, currentAccessToken); } - // check if token file exists - var useCachedFileToken = File.Exists(TokenFileName); - if (useCachedFileToken) + if (this.persistedCredentialsCacheEnabled) { - logger?.LogTrace("Read cached access token from {TokenFileName}", TokenFileName); - // read token - var content = await File.ReadAllTextAsync(TokenFileName); - CachedCredentials = JsonConvert.DeserializeObject>(content); - if (CachedCredentials.TryGetValue(audience, out currentAccessToken)) + // check if token file exists + var useCachedFileToken = File.Exists(this.TokenFileName); + if (useCachedFileToken) { - logger?.LogTrace("Found access token in credentials file"); - return await GetValidToken(audience, currentAccessToken); + this.logger?.LogTrace("Read cached access token from {TokenFileName}", this.TokenFileName); + // read token + var content = await File.ReadAllTextAsync(this.TokenFileName); + this.CachedCredentials = JsonConvert.DeserializeObject>(content); + if (this.CachedCredentials.TryGetValue(audience, out currentAccessToken)) + { + logger?.LogTrace("Found access token in credentials file"); + return await this.GetValidToken(audience, currentAccessToken); + } } } // fetch new token - var newAccessToken = await FetchNewAccessToken(audience); + var newAccessToken = await this.FetchNewAccessToken(audience); return newAccessToken.Token; } - private async Task GetValidToken(string audience, AccessToken currentAccessToken) + private bool IsValid(AccessToken accessToken) { + // add {dueDateTolerance} to UTC now to compensate network latency + // and prevent server rejection if due date is too close to now. + // Meaning that token will be refreshed {dueDateTolerance} before expiration. var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - var dueDate = currentAccessToken.DueDate; - if (now < dueDate) + var adjustedDueDate = accessToken.DueDate - (long)this.dueDateTolerance.TotalMilliseconds; + + if (now < adjustedDueDate) + { + return true; + } + + this.logger?.LogTrace( + "Access token is no longer valid (now: {Now} > adjustedDueTime: {AdjustedDueTime} after applying tolerance: {Tolerance}), request new one", + now, + adjustedDueDate, + this.dueDateTolerance); + + return false; + } + + private async Task GetValidToken(string audience, AccessToken currentAccessToken) + { + if (this.IsValid(currentAccessToken)) { // still valid return currentAccessToken.Token; } - logger?.LogTrace("Access token is no longer valid (now: {Now} > dueTime: {DueTime}), request new one", now, - dueDate); - var newAccessToken = await FetchNewAccessToken(audience); + var newAccessToken = await this.FetchNewAccessToken(audience); return newAccessToken.Token; } private async Task FetchNewAccessToken(string audience) { - var newAccessToken = await accessTokenFetcherAsync(); - CachedCredentials[audience] = newAccessToken; - WriteCredentials(); + var newAccessToken = await this.accessTokenFetcherAsync(); + this.CachedCredentials[audience] = newAccessToken; + + if (this.persistedCredentialsCacheEnabled) + { + this.WriteCredentials(); + } + return newAccessToken; } private void WriteCredentials() { - File.WriteAllText(TokenFileName, JsonConvert.SerializeObject(CachedCredentials)); + File.WriteAllText(this.TokenFileName, JsonConvert.SerializeObject(this.CachedCredentials)); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + this.mutex.Dispose(); + } + + disposedValue = true; + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); } } \ No newline at end of file