From 446ff4a8e9bf62e33587cd1e4bc69ed1ab6bb0e1 Mon Sep 17 00:00:00 2001 From: Olivier Freund Date: Fri, 28 Nov 2025 16:28:38 +0100 Subject: [PATCH] fix: CamundaCloudTokenProvider - Allow disabling of file system credentials cache persistence and add tolerance for token expiration - Adds a DisableCredentialsCachePersistence() method in client CamundaCloudClientBuilder to prevent persisting token cache in file system to keep only in-memory cache. - Fixes concurrent access issue to AccessToken cache. - Adds AccessToken due date tolerance. Refs: #415 #642 --- .../Builder/CamundaCloudTokenProviderTest.cs | 22 +++ .../Misc/PersistedAccessTokenCacheTest.cs | 62 ++++++++ .../Api/Builder/ICamundaCloudClientBuilder.cs | 18 +++ .../ICamundaCloudTokenProviderBuilder.cs | 18 +++ .../Impl/Builder/CamundaCloudClientBuilder.cs | 12 ++ .../Impl/Builder/CamundaCloudTokenProvider.cs | 8 +- .../CamundaCloudTokenProviderBuilder.cs | 25 +++- Client/Impl/Misc/PersistedAccessTokenCache.cs | 136 +++++++++++++----- 8 files changed, 262 insertions(+), 39 deletions(-) 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..61b90196 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 persitence 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..67964004 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 persitence 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..9bb2573d 100644 --- a/Client/Impl/Builder/CamundaCloudClientBuilder.cs +++ b/Client/Impl/Builder/CamundaCloudClientBuilder.cs @@ -67,6 +67,18 @@ 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..22360fe5 100644 --- a/Client/Impl/Builder/CamundaCloudTokenProvider.cs +++ b/Client/Impl/Builder/CamundaCloudTokenProvider.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Net.Http; +using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -31,10 +32,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; 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..5d4aec31 100644 --- a/Client/Impl/Misc/PersistedAccessTokenCache.cs +++ b/Client/Impl/Misc/PersistedAccessTokenCache.cs @@ -1,9 +1,10 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; 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; @@ -15,83 +16,146 @@ 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; - 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) { - var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - var dueDate = currentAccessToken.DueDate; + // 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.Add(this.dueDateTolerance).ToUnixTimeMilliseconds(); + var dueDate = accessToken.DueDate; + if (now < dueDate) + { + return true; + } + + var tolerance = (this.dueDateTolerance == default) ? string.Empty : $"(-{this.dueDateTolerance})"; + this.logger?.LogTrace( + "Access token is no longer valid (now: {Now} > dueTime{DueDateTolerance}: {DueTime}), request new one", + now, + tolerance, + dueDate); + + 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)); } } \ No newline at end of file